Repository: mealie-recipes/mealie
Pull request: feat: Add Household Filter to Meal Plan Rules
## What type of PR is this?

_(REQUIRED)_

- feature

## What this PR does / why we need it:

_(REQUIRED)_

Adds a household filter to meal plan rules. Similar to tags and categories, if no household is selected, then all households are considered. I had to re-write the part of the recipes repo that does the filtering by tag/category (since we need to support household, too) and ended out just removing it and using the normal `page_all` route, since that already supports tags, categories, households, and random ordering.

New rule form:
![image](https://github.com/user-attachments/assets/69879582-3fdd-4e2d-b0a0-f676640163f6)
![image](https://github.com/user-attachments/assets/79e42f34-494e-4288-a833-9708db0b9bae)
![image](https://github.com/user-attachments/assets/6a149d39-c4b1-479c-8786-1fefd1649113)

I also tweaked how meal plan rules render on the frontend to call out when there are no filters (e.g. "All Tags" when no tags are selected):
![image](https://github.com/user-attachments/assets/9203ac55-3851-47c1-9b2a-a66f42ff6623)
![image](https://github.com/user-attachments/assets/79fe32a8-11f7-4ba9-8d77-f4dcd71a3b08)

## Which issue(s) this PR fixes:

_(REQUIRED)_

Closes https://github.com/mealie-recipes/mealie/issues/4081

## Testing

Pytest coverage. I was surprised to find we didn't have any existing coverage for meal plan rules other than basic CRUD, so I added tests for other filters too (type, tag, etc.).


Problem statement:
[Task] - Enable Cross-Household Recipes
### What is the problem this task addresses?

Currently logged-in users can only see their household's recipes (similar to how it was with groups). One of the main reasons for adding households was specifically to have a common pool of recipes.

### Proposed/Possible Solution(s)?

Features to address:
- [x] View all recipes should include all households' recipes
- [x] Household should be filterable when viewing all recipes (probably a multi-select)
- [x] Shopping lists should support recipes from other households
- [x] Meal Plans should support recipes from other households
- [x] Meal Plan Rules should include a household filter (probably a multi-select)
- [x] User actions should support other households' recipes, including:
  - [x] Favorites
  - [x] Comments
  - [x] Timeline Events

Hints from issue discussion:

Commit message: Merge branch 'mealie-next' into feat/add-household-filter-to-mealplan-rules

Patch to review:
diff --git a/alembic/versions/2024-09-18-14.52.55_1fe4bd37ccc8_add_households_filter_to_meal_plans.py b/alembic/versions/2024-09-18-14.52.55_1fe4bd37ccc8_add_households_filter_to_meal_plans.py
new file mode 100644
index 00000000000..a127e72f44a
--- /dev/null
+++ b/alembic/versions/2024-09-18-14.52.55_1fe4bd37ccc8_add_households_filter_to_meal_plans.py
@@ -0,0 +1,53 @@
+"""add households filter to meal plans
+
+Revision ID: 1fe4bd37ccc8
+Revises: be568e39ffdf
+Create Date: 2024-09-18 14:52:55.831540
+
+"""
+
+import sqlalchemy as sa
+
+import mealie.db.migration_types
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "1fe4bd37ccc8"
+down_revision: str | None = "be568e39ffdf"
+branch_labels: str | tuple[str, ...] | None = None
+depends_on: str | tuple[str, ...] | None = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table(
+        "plan_rules_to_households",
+        sa.Column("group_plan_rule_id", mealie.db.migration_types.GUID(), nullable=True),
+        sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True),
+        sa.ForeignKeyConstraint(
+            ["group_plan_rule_id"],
+            ["group_meal_plan_rules.id"],
+        ),
+        sa.ForeignKeyConstraint(
+            ["household_id"],
+            ["households.id"],
+        ),
+        sa.UniqueConstraint("group_plan_rule_id", "household_id", name="group_plan_rule_id_household_id_key"),
+    )
+    with op.batch_alter_table("plan_rules_to_households", schema=None) as batch_op:
+        batch_op.create_index(
+            batch_op.f("ix_plan_rules_to_households_group_plan_rule_id"), ["group_plan_rule_id"], unique=False
+        )
+        batch_op.create_index(batch_op.f("ix_plan_rules_to_households_household_id"), ["household_id"], unique=False)
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table("plan_rules_to_households", schema=None) as batch_op:
+        batch_op.drop_index(batch_op.f("ix_plan_rules_to_households_household_id"))
+        batch_op.drop_index(batch_op.f("ix_plan_rules_to_households_group_plan_rule_id"))
+
+    op.drop_table("plan_rules_to_households")
+    # ### end Alembic commands ###
diff --git a/frontend/components/Domain/Household/GroupHouseholdSelector.vue b/frontend/components/Domain/Household/GroupHouseholdSelector.vue
new file mode 100644
index 00000000000..fcdf63e4004
--- /dev/null
+++ b/frontend/components/Domain/Household/GroupHouseholdSelector.vue
@@ -0,0 +1,91 @@
+<template>
+  <v-select
+    v-model="selected"
+    :items="households"
+    :label="label"
+    :hint="description"
+    :persistent-hint="!!description"
+    item-text="name"
+    :multiple="multiselect"
+    :prepend-inner-icon="$globals.icons.household"
+    return-object
+  >
+    <template #selection="data">
+      <v-chip
+        :key="data.index"
+        class="ma-1"
+        :input-value="data.selected"
+        small
+        close
+        label
+        color="accent"
+        dark
+        @click:close="removeByIndex(data.index)"
+      >
+        {{ data.item.name || data.item }}
+      </v-chip>
+    </template>
+  </v-select>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, useContext } from "@nuxtjs/composition-api";
+import { useHouseholdStore } from "~/composables/store/use-household-store";
+
+interface HouseholdLike {
+  id: string;
+  name: string;
+}
+
+export default defineComponent({
+  props: {
+    value: {
+      type: Array as () => HouseholdLike[],
+      required: true,
+    },
+    multiselect: {
+      type: Boolean,
+      default: false,
+    },
+    description: {
+      type: String,
+      default: "",
+    },
+  },
+  setup(props, context) {
+    const selected = computed({
+      get: () => props.value,
+      set: (val) => {
+        context.emit("input", val);
+      },
+    });
+
+    onMounted(() => {
+      if (selected.value === undefined) {
+        selected.value = [];
+      }
+    });
+
+    const { i18n } = useContext();
+    const label = computed(
+      () => props.multiselect ? i18n.tc("household.households") : i18n.tc("household.household")
+    );
+
+    const { store: households } = useHouseholdStore();
+    function removeByIndex(index: number) {
+      if (selected.value === undefined) {
+        return;
+      }
+      const newSelected = selected.value.filter((_, i) => i !== index);
+      selected.value = [...newSelected];
+    }
+
+    return {
+      selected,
+      label,
+      households,
+      removeByIndex,
+    };
+  },
+});
+</script>
diff --git a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue
index cf71112a3ee..1f263eb3621 100644
--- a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue
+++ b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue
@@ -5,8 +5,15 @@
       <v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" :label="$t('meal-plan.meal-type')"></v-select>
     </div>
 
-    <RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" />
-    <RecipeOrganizerSelector v-model="inputTags" selector-type="tags" />
+    <div class="mb-5">
+      <RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" />
+      <RecipeOrganizerSelector v-model="inputTags" selector-type="tags" />
+      <GroupHouseholdSelector
+        v-model="inputHouseholds"
+        multiselect
+        :description="$tc('meal-plan.mealplan-households-description')"
+      />
+    </div>
 
     <!-- TODO: proper pluralization of inputDay -->
     {{ $t('meal-plan.this-rule-will-apply', {
@@ -18,11 +25,13 @@
 
 <script lang="ts">
 import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
+import GroupHouseholdSelector from "~/components/Domain/Household/GroupHouseholdSelector.vue";
 import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
-import { RecipeTag, RecipeCategory } from "~/lib/api/types/recipe";
+import { PlanCategory, PlanHousehold, PlanTag } from "~/lib/api/types/meal-plan";
 
 export default defineComponent({
   components: {
+    GroupHouseholdSelector,
     RecipeOrganizerSelector,
   },
   props: {
@@ -35,11 +44,15 @@ export default defineComponent({
       default: "unset",
     },
     categories: {
-      type: Array as () => RecipeCategory[],
+      type: Array as () => PlanCategory[],
       default: () => [],
     },
     tags: {
-      type: Array as () => RecipeTag[],
+      type: Array as () => PlanTag[],
+      default: () => [],
+    },
+    households: {
+      type: Array as () => PlanHousehold[],
       default: () => [],
     },
     showHelp: {
@@ -105,6 +118,15 @@ export default defineComponent({
       },
     });
 
+    const inputHouseholds = computed({
+      get: () => {
+        return props.households;
+      },
+      set: (val) => {
+        context.emit("update:households", val);
+      },
+    });
+
     return {
       MEAL_TYPE_OPTIONS,
       MEAL_DAY_OPTIONS,
@@ -112,6 +134,7 @@ export default defineComponent({
       inputEntryType,
       inputCategories,
       inputTags,
+      inputHouseholds,
     };
   },
 });
diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json
index 72ec75defa9..0bc6c3d0aff 100644
--- a/frontend/lang/messages/en-US.json
+++ b/frontend/lang/messages/en-US.json
@@ -315,6 +315,10 @@
     "mealplan-settings": "Mealplan Settings",
     "mealplan-update-failed": "Mealplan update failed",
     "mealplan-updated": "Mealplan Updated",
+    "mealplan-households-description": "If no household is selected, recipes can be added from any household",
+    "any-category": "Any Category",
+    "any-tag": "Any Tag",
+    "any-household": "Any Household",
     "no-meal-plan-defined-yet": "No meal plan defined yet",
     "no-meal-planned-for-today": "No meal planned for today",
     "numberOfDays-hint": "Number of days on page load",
diff --git a/frontend/lib/api/types/meal-plan.ts b/frontend/lib/api/types/meal-plan.ts
index 6cfa62e2998..5d376eab36d 100644
--- a/frontend/lib/api/types/meal-plan.ts
+++ b/frontend/lib/api/types/meal-plan.ts
@@ -31,13 +31,24 @@ export interface ListItem {
   quantity?: number;
   checked?: boolean;
 }
+export interface PlanCategory {
+  id: string;
+  name: string;
+  slug: string;
+}
+export interface PlanHousehold {
+  id: string;
+  name: string;
+  slug: string;
+}
 export interface PlanRulesCreate {
   day?: PlanRulesDay & string;
   entryType?: PlanRulesType & string;
-  categories?: Category[];
-  tags?: Tag[];
+  categories?: PlanCategory[];
+  tags?: PlanTag[];
+  households?: PlanHousehold[];
 }
-export interface Tag {
+export interface PlanTag {
   id: string;
   name: string;
   slug: string;
@@ -45,8 +56,9 @@ export interface Tag {
 export interface PlanRulesOut {
   day?: PlanRulesDay & string;
   entryType?: PlanRulesType & string;
-  categories?: Category[];
-  tags?: Tag[];
+  categories?: PlanCategory[];
+  tags?: PlanTag[];
+  households?: PlanHousehold[];
   groupId: string;
   householdId: string;
   id: string;
@@ -54,8 +66,9 @@ export interface PlanRulesOut {
 export interface PlanRulesSave {
   day?: PlanRulesDay & string;
   entryType?: PlanRulesType & string;
-  categories?: Category[];
-  tags?: Tag[];
+  categories?: PlanCategory[];
+  tags?: PlanTag[];
+  households?: PlanHousehold[];
   groupId: string;
   householdId: string;
 }
diff --git a/frontend/pages/household/mealplan/settings.vue b/frontend/pages/household/mealplan/settings.vue
index 7fea7c01dc0..598f08c670d 100644
--- a/frontend/pages/household/mealplan/settings.vue
+++ b/frontend/pages/household/mealplan/settings.vue
@@ -20,6 +20,7 @@
           :entry-type.sync="createData.entryType"
           :categories.sync="createData.categories"
           :tags.sync="createData.tags"
+          :households.sync="createData.households"
         />
       </v-card-text>
       <v-card-actions class="justify-end">
@@ -58,12 +59,58 @@
               <template v-if="!editState[rule.id]">
                 <div v-if="rule.categories">
                   <h4 class="py-1">{{ $t("category.categories") }}:</h4>
-                  <RecipeChips :items="rule.categories" small />
+                  <RecipeChips v-if="rule.categories.length" :items="rule.categories" small class="pb-3" />
+                  <v-card-text
+                    v-else
+                    label
+                    class="ma-0 px-0 pt-0 pb-3"
+                    text-color="accent"
+                    small
+                    dark
+                  >
+                    {{ $tc("meal-plan.any-category") }}
+                  </v-card-text>
                 </div>
 
                 <div v-if="rule.tags">
                   <h4 class="py-1">{{ $t("tag.tags") }}:</h4>
-                  <RecipeChips :items="rule.tags" url-prefix="tags" small />
+                  <RecipeChips v-if="rule.tags.length" :items="rule.tags" url-prefix="tags" small class="pb-3" />
+                  <v-card-text
+                    v-else
+                    label
+                    class="ma-0 px-0 pt-0 pb-3"
+                    text-color="accent"
+                    small
+                    dark
+                  >
+                    {{ $tc("meal-plan.any-tag") }}
+                  </v-card-text>
+                </div>
+                <div v-if="rule.households">
+                  <h4 class="py-1">{{ $t("household.households") }}:</h4>
+                  <div v-if="rule.households.length">
+                    <v-chip
+                      v-for="household in rule.households"
+                      :key="household.id"
+                      label
+                      class="ma-1"
+                      color="accent"
+                      small
+                      dark
+                    >
+                      {{ household.name }}
+                    </v-chip>
+                  </div>
+                  <v-card-text
+                    v-else
+                    label
+                    class="ma-0 px-0 pt-0 pb-3"
+                    text-color="accent"
+                    small
+                    dark
+                  >
+                    {{ $tc("meal-plan.any-household") }}
+                  </v-card-text>
                 </div>
               </template>
               <template v-else>
@@ -72,6 +119,7 @@
                   :entry-type.sync="allRules[idx].entryType"
                   :categories.sync="allRules[idx].categories"
                   :tags.sync="allRules[idx].tags"
+                  :households.sync="allRules[idx].households"
                 />
                 <div class="d-flex justify-end">
                   <BaseButton update @click="updateRule(rule)" />
@@ -138,6 +186,7 @@ export default defineComponent({
       day: "unset",
       categories: [],
       tags: [],
+      households: [],
     });
 
     async function createRule() {
@@ -149,6 +198,7 @@ export default defineComponent({
           day: "unset",
           categories: [],
           tags: [],
+          households: [],
         };
       }
     }
diff --git a/mealie/db/models/household/mealplan.py b/mealie/db/models/household/mealplan.py
index 8d6d56c9af8..64702136ca8 100644
--- a/mealie/db/models/household/mealplan.py
+++ b/mealie/db/models/household/mealplan.py
@@ -1,7 +1,7 @@
 import datetime
 from typing import TYPE_CHECKING, Optional
 
-from sqlalchemy import Date, ForeignKey, String, orm
+from sqlalchemy import Column, Date, ForeignKey, String, Table, UniqueConstraint, orm
 from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
 from sqlalchemy.orm import Mapped, mapped_column
 
@@ -18,6 +18,14 @@
     from ..users import User
     from .household import Household
 
+plan_rules_to_households = Table(
+    "plan_rules_to_households",
+    SqlAlchemyBase.metadata,
+    Column("group_plan_rule_id", GUID, ForeignKey("group_meal_plan_rules.id"), index=True),
+    Column("household_id", GUID, ForeignKey("households.id"), index=True),
+    UniqueConstraint("group_plan_rule_id", "household_id", name="group_plan_rule_id_household_id_key"),
+)
+
 
 class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
     __tablename__ = "group_meal_plan_rules"
@@ -33,8 +41,10 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
         String, nullable=False, default=""
     )  # "breakfast", "lunch", "dinner", "side"
 
+    # Filters
     categories: Mapped[list[Category]] = orm.relationship(Category, secondary=plan_rules_to_categories)
     tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags)
+    households: Mapped[list["Household"]] = orm.relationship("Household", secondary=plan_rules_to_households)
 
     @auto_init()
     def __init__(self, **_) -> None:
diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py
index e86db00baa3..584bf1a27f0 100644
--- a/mealie/repos/repository_generic.py
+++ b/mealie/repos/repository_generic.py
@@ -2,6 +2,7 @@
 
 import random
 from collections.abc import Iterable
+from datetime import datetime, timezone
 from math import ceil
 from typing import Any, Generic, TypeVar
 
@@ -62,6 +63,9 @@ def group_id(self) -> UUID4 | None:
     def household_id(self) -> UUID4 | None:
         return self._household_id
 
+    def _random_seed(self) -> str:
+        return str(datetime.now(tz=timezone.utc))
+
     def _log_exception(self, e: Exception) -> None:
         self.logger.error(f"Error processing query for Repo model={self.model.__name__} schema={self.schema.__name__}")
         self.logger.error(e)
@@ -409,6 +413,9 @@ def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> S
             # this solution is db-independent & stable to paging
             temp_query = query.with_only_columns(self.model.id)
             allids = self.session.execute(temp_query).scalars().all()  # fast because id is indexed
+            if not allids:
+                return query
+
             order = list(range(len(allids)))
             random.seed(pagination.pagination_seed)
             random.shuffle(order)
diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py
index 41c0cf10317..263ee200a56 100644
--- a/mealie/repos/repository_recipes.py
+++ b/mealie/repos/repository_recipes.py
@@ -23,7 +23,6 @@
     RecipeCategory,
     RecipePagination,
     RecipeSummary,
-    RecipeTag,
     RecipeTool,
 )
 from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
@@ -99,6 +98,9 @@ def _uuids_for_items(self, items: list[UUID | str] | None, model: type[SqlAlchem
                     ids.append(i_as_uuid)
                 except ValueError:
                     slugs.append(i)
+
+        if not slugs:
+            return ids
         additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all()
         return ids + additional_ids
 
@@ -308,27 +310,6 @@ def by_category_and_tags(
         stmt = sa.select(RecipeModel).filter(*fltr)
         return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
 
-    def get_random_by_categories_and_tags(
-        self, categories: list[RecipeCategory], tags: list[RecipeTag]
-    ) -> list[Recipe]:
-        """
-        get_random_by_categories returns a single random Recipe that contains every category provided
-        in the list. This uses a function built in to Postgres and SQLite to get a random row limited
-        to 1 entry.
-        """
-
-        # See Also:
-        # - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy
-
-        filters = self._build_recipe_filter(extract_uuids(categories), extract_uuids(tags))  # type: ignore
-        stmt = (
-            sa.select(RecipeModel)
-            .filter(sa.and_(*filters))
-            .order_by(sa.func.random())
-            .limit(1)  # Postgres and SQLite specific
-        )
-        return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
-
     def get_random(self, limit=1) -> list[Recipe]:
         stmt = sa.select(RecipeModel).order_by(sa.func.random()).limit(limit)  # Postgres and SQLite specific
         if self.group_id:
diff --git a/mealie/routes/households/controller_mealplan.py b/mealie/routes/households/controller_mealplan.py
index d15e34a7165..fefeaba9b47 100644
--- a/mealie/routes/households/controller_mealplan.py
+++ b/mealie/routes/households/controller_mealplan.py
@@ -4,14 +4,15 @@
 from fastapi import APIRouter, Depends, HTTPException
 
 from mealie.core.exceptions import mealie_registered_exceptions
+from mealie.repos.all_repositories import get_repositories
 from mealie.repos.repository_meals import RepositoryMeals
 from mealie.routes._base import controller
 from mealie.routes._base.base_controllers import BaseCrudController
 from mealie.routes._base.mixins import HttpRepo
 from mealie.schema import mapper
 from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
-from mealie.schema.meal_plan.new_meal import CreateRandomEntry, PlanEntryPagination
-from mealie.schema.meal_plan.plan_rules import PlanRulesDay
+from mealie.schema.meal_plan.new_meal import CreateRandomEntry, PlanEntryPagination, PlanEntryType
+from mealie.schema.meal_plan.plan_rules import PlanCategory, PlanHousehold, PlanRulesDay, PlanTag
 from mealie.schema.recipe.recipe import Recipe
 from mealie.schema.response.pagination import PaginationQuery
 from mealie.schema.response.responses import ErrorResponse
@@ -40,6 +41,47 @@ def mixins(self):
             self.registered_exceptions,
         )
 
+    def _get_random_recipes_from_mealplan(
+        self, plan_date: date, entry_type: PlanEntryType, limit: int = 1
+    ) -> list[Recipe]:
+        """
+        Gets rules for a mealplan and returns a list of random recipes based on the rules.
+        May return zero recipes if no recipes match the filter criteria.
+
+        Recipes from all households are included unless the rules specify a a household filter.
+        """
+
+        rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value)
+        cross_household_recipes = get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
+
+        tags: list[PlanTag] = []
+        categories: list[PlanCategory] = []
+        households: list[PlanHousehold] = []
+        for rule in rules:
+            if rule.tags:
+                tags.extend(rule.tags)
+            if rule.categories:
+                categories.extend(rule.categories)
+            if rule.households:
+                households.extend(rule.households)
+
+        if not (tags or categories or households):
+            return cross_household_recipes.get_random(limit=limit)
+
+        category_ids = [category.id for category in categories] or None
+        tag_ids = [tag.id for tag in tags] or None
+        household_ids = [household.id for household in households] or None
+
+        recipes_data = cross_household_recipes.page_all(
+            pagination=PaginationQuery(
+                page=1, per_page=limit, order_by="random", pagination_seed=self.repo._random_seed()
+            ),
+            categories=category_ids,
+            tags=tag_ids,
+            households=household_ids,
+        )
+        return recipes_data.items
+
     @router.get("/today")
     def get_todays_meals(self):
         return self.repo.get_today()
@@ -47,50 +89,29 @@ def get_todays_meals(self):
     @router.post("/random", response_model=ReadPlanEntry)
     def create_random_meal(self, data: CreateRandomEntry):
         """
-        create_random_meal is a route that provides the randomized functionality for mealplaners.
-        It operates by following the rules setout in the Groups mealplan settings. If not settings
-        are set, it will default return any random meal.
+        `create_random_meal` is a route that provides the randomized functionality for mealplaners.
+        It operates by following the rules set out in the household's mealplan settings. If no settings
+        are set, it will return any random meal.
 
         Refer to the mealplan settings routes for more information on how rules can be applied
         to the random meal selector.
         """
-        # Get relevant group rules
-        rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(data.date), data.entry_type.value)
-
-        recipe_repo = self.repos.recipes
-        random_recipes: list[Recipe] = []
-
-        if not rules:  # If no rules are set, return any random recipe from the group
-            random_recipes = recipe_repo.get_random()
-        else:  # otherwise construct a query based on the rules
-            tags = []
-            categories = []
-            for rule in rules:
-                if rule.tags:
-                    tags.extend(rule.tags)
-                if rule.categories:
-                    categories.extend(rule.categories)
-
-            if tags or categories:
-                random_recipes = self.repos.recipes.get_random_by_categories_and_tags(categories, tags)
-            else:
-                random_recipes = recipe_repo.get_random()
-
-        try:
-            recipe = random_recipes[0]
-            return self.mixins.create_one(
-                SavePlanEntry(
-                    date=data.date,
-                    entry_type=data.entry_type,
-                    recipe_id=recipe.id,
-                    group_id=self.group_id,
-                    user_id=self.user.id,
-                )
-            )
-        except IndexError as e:
+        random_recipes = self._get_random_recipes_from_mealplan(data.date, data.entry_type)
+        if not random_recipes:
             raise HTTPException(
                 status_code=404, detail=ErrorResponse.respond(message=self.t("mealplan.no-recipes-match-your-rules"))
-            ) from e
+            )
+
+        recipe = random_recipes[0]
+        return self.mixins.create_one(
+            SavePlanEntry(
+                date=data.date,
+                entry_type=data.entry_type,
+                recipe_id=recipe.id,
+                group_id=self.group_id,
+                user_id=self.user.id,
+            )
+        )
 
     @router.get("", response_model=PlanEntryPagination)
     def get_all(
diff --git a/mealie/schema/meal_plan/__init__.py b/mealie/schema/meal_plan/__init__.py
index 04b1d972ba2..1af4c72f88d 100644
--- a/mealie/schema/meal_plan/__init__.py
+++ b/mealie/schema/meal_plan/__init__.py
@@ -9,14 +9,16 @@
     UpdatePlanEntry,
 )
 from .plan_rules import (
-    Category,
+    BasePlanRuleFilter,
+    PlanCategory,
+    PlanHousehold,
     PlanRulesCreate,
     PlanRulesDay,
     PlanRulesOut,
     PlanRulesPagination,
     PlanRulesSave,
     PlanRulesType,
-    Tag,
+    PlanTag,
 )
 from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut
 
@@ -31,12 +33,14 @@
     "ReadPlanEntry",
     "SavePlanEntry",
     "UpdatePlanEntry",
-    "Category",
+    "BasePlanRuleFilter",
+    "PlanCategory",
+    "PlanHousehold",
     "PlanRulesCreate",
     "PlanRulesDay",
     "PlanRulesOut",
     "PlanRulesPagination",
     "PlanRulesSave",
     "PlanRulesType",
-    "Tag",
+    "PlanTag",
 ]
diff --git a/mealie/schema/meal_plan/plan_rules.py b/mealie/schema/meal_plan/plan_rules.py
index d3faf08049d..69e95b7cd59 100644
--- a/mealie/schema/meal_plan/plan_rules.py
+++ b/mealie/schema/meal_plan/plan_rules.py
@@ -5,19 +5,27 @@
 from sqlalchemy.orm import joinedload
 from sqlalchemy.orm.interfaces import LoaderOption
 
-from mealie.db.models.household import GroupMealPlanRules
+from mealie.db.models.household import GroupMealPlanRules, Household
+from mealie.db.models.recipe import Category, Tag
 from mealie.schema._mealie import MealieModel
 from mealie.schema.response.pagination import PaginationBase
 
 
-class Category(MealieModel):
+class BasePlanRuleFilter(MealieModel):
     id: UUID4
     name: str
     slug: str
+
+
+class PlanCategory(BasePlanRuleFilter):
+    model_config = ConfigDict(from_attributes=True)
+
+
+class PlanTag(BasePlanRuleFilter):
     model_config = ConfigDict(from_attributes=True)
 
 
-class Tag(Category):
+class PlanHousehold(BasePlanRuleFilter):
     model_config = ConfigDict(from_attributes=True)
 
 
@@ -51,8 +59,9 @@ class PlanRulesType(str, Enum):
 class PlanRulesCreate(MealieModel):
     day: PlanRulesDay = PlanRulesDay.unset
     entry_type: PlanRulesType = PlanRulesType.unset
-    categories: list[Category] = []
-    tags: list[Tag] = []
+    categories: list[PlanCategory] = []
+    tags: list[PlanTag] = []
+    households: list[PlanHousehold] = []
 
 
 class PlanRulesSave(PlanRulesCreate):
@@ -66,7 +75,23 @@ class PlanRulesOut(PlanRulesSave):
 
     @classmethod
     def loader_options(cls) -> list[LoaderOption]:
-        return [joinedload(GroupMealPlanRules.categories), joinedload(GroupMealPlanRules.tags)]
+        return [
+            joinedload(GroupMealPlanRules.categories).load_only(
+                Category.id,
+                Category.name,
+                Category.slug,
+            ),
+            joinedload(GroupMealPlanRules.tags).load_only(
+                Tag.id,
+                Tag.name,
+                Tag.slug,
+            ),
+            joinedload(GroupMealPlanRules.households).load_only(
+                Household.id,
+                Household.name,
+                Household.slug,
+            ),
+        ]
 
 
 class PlanRulesPagination(PaginationBase):
diff --git a/tests/integration_tests/user_household_tests/test_group_mealplan.py b/tests/integration_tests/user_household_tests/test_group_mealplan.py
index 47a8a82d3fb..691934dfe55 100644
--- a/tests/integration_tests/user_household_tests/test_group_mealplan.py
+++ b/tests/integration_tests/user_household_tests/test_group_mealplan.py
@@ -1,8 +1,14 @@
+import random
 from datetime import datetime, timedelta, timezone
+from uuid import UUID
 
 from fastapi.testclient import TestClient
 
+from mealie.schema.household.household import HouseholdSummary
 from mealie.schema.meal_plan.new_meal import CreatePlanEntry
+from mealie.schema.meal_plan.plan_rules import PlanRulesDay, PlanRulesOut, PlanRulesSave, PlanRulesType
+from mealie.schema.recipe.recipe import Recipe
+from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagOut, TagSave
 from tests.utils import api_routes
 from tests.utils.factories import random_string
 from tests.utils.fixture_schemas import TestUser
@@ -14,6 +20,39 @@ def route_all_slice(page: int, perPage: int, start_date: str, end_date: str):
     )
 
 
+def create_recipe(unique_user: TestUser, tags: list[TagOut] | None = None, categories: list[CategoryOut] | None = None):
+    return unique_user.repos.recipes.create(
+        Recipe(
+            user_id=unique_user.user_id,
+            group_id=UUID(unique_user.group_id),
+            name=random_string(),
+            tags=tags or [],
+            recipe_category=categories or [],
+        )
+    )
+
+
+def create_rule(
+    unique_user: TestUser,
+    day: PlanRulesDay,
+    entry_type: PlanRulesType,
+    tags: list[TagOut] | None = None,
+    categories: list[CategoryOut] | None = None,
+    households: list[HouseholdSummary] | None = None,
+):
+    return unique_user.repos.group_meal_plan_rules.create(
+        PlanRulesSave(
+            group_id=UUID(unique_user.group_id),
+            household_id=UUID(unique_user.household_id),
+            day=day,
+            entry_type=entry_type,
+            tags=tags or [],
+            categories=categories or [],
+            households=households or [],
+        )
+    )
+
+
 def test_create_mealplan_no_recipe(api_client: TestClient, unique_user: TestUser):
     title = random_string(length=25)
     text = random_string(length=25)
@@ -167,3 +206,128 @@ def test_get_mealplan_today(api_client: TestClient, unique_user: TestUser):
 
     for meal_plan in response_json:
         assert meal_plan["date"] == datetime.now(timezone.utc).date().strftime("%Y-%m-%d")
+
+
+def test_get_mealplan_with_rules_categories_and_tags_filter(api_client: TestClient, unique_user: TestUser):
+    tags = [
+        unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id)) for _ in range(4)
+    ]
+    categories = [
+        unique_user.repos.categories.create(CategorySave(name=random_string(), group_id=unique_user.group_id))
+        for _ in range(4)
+    ]
+    [
+        create_recipe(unique_user, tags=[tag], categories=[category])
+        for tag, category in zip(tags, categories, strict=True)
+    ]
+    [create_recipe(unique_user) for _ in range(5)]
+
+    i = random.randint(0, 3)
+    tag = tags[i]
+    category = categories[i]
+    rule = create_rule(
+        unique_user,
+        day=PlanRulesDay.saturday,
+        entry_type=PlanRulesType.breakfast,
+        tags=[tag],
+        categories=[category],
+    )
+
+    try:
+        payload = {"date": "2023-02-25", "entryType": "breakfast"}
+        response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token)
+        assert response.status_code == 200
+        recipe_data = response.json()["recipe"]
+        assert recipe_data["tags"][0]["name"] == tag.name
+        assert recipe_data["recipeCategory"][0]["name"] == category.name
+    finally:
+        unique_user.repos.group_meal_plan_rules.delete(rule.id)
+
+
+def test_get_mealplan_with_rules_date_and_type_filter(api_client: TestClient, unique_user: TestUser):
+    tags = [
+        unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id)) for _ in range(4)
+    ]
+    recipes = [create_recipe(unique_user, tags=[tag]) for tag in tags]
+    [create_recipe(unique_user) for _ in range(5)]
+
+    rules: list[PlanRulesOut] = []
+    rules.append(
+        create_rule(unique_user, day=PlanRulesDay.saturday, entry_type=PlanRulesType.breakfast, tags=[tags[0]])
+    )
+    rules.append(create_rule(unique_user, day=PlanRulesDay.saturday, entry_type=PlanRulesType.dinner, tags=[tags[1]]))
+    rules.append(create_rule(unique_user, day=PlanRulesDay.sunday, entry_type=PlanRulesType.breakfast, tags=[tags[2]]))
+    rules.append(create_rule(unique_user, day=PlanRulesDay.sunday, entry_type=PlanRulesType.dinner, tags=[tags[3]]))
+
+    try:
+        payload = {"date": "2023-02-25", "entryType": "breakfast"}
+        response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token)
+        assert response.status_code == 200
+        assert response.json()["recipe"]["slug"] == recipes[0].slug
+    finally:
+        for rule in rules:
+            unique_user.repos.group_meal_plan_rules.delete(rule.id)
+
+
+def test_get_mealplan_with_rules_includes_other_households(
+    api_client: TestClient, unique_user: TestUser, h2_user: TestUser
+):
+    tag = h2_user.repos.tags.create(TagSave(name=random_string(), group_id=h2_user.group_id))
+    recipe = create_recipe(h2_user, tags=[tag])
+    rule = create_rule(unique_user, day=PlanRulesDay.saturday, entry_type=PlanRulesType.breakfast, tags=[tag])
+
+    try:
+        payload = {"date": "2023-02-25", "entryType": "breakfast"}
+        response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token)
+        assert response.status_code == 200
+        assert response.json()["recipe"]["slug"] == recipe.slug
+    finally:
+        unique_user.repos.group_meal_plan_rules.delete(rule.id)
+
+
+def test_get_mealplan_with_rules_households_filter(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
+    tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
+    recipe = create_recipe(unique_user, tags=[tag])
+    [create_recipe(h2_user, tags=[tag]) for _ in range(10)]
+
+    household = unique_user.repos.households.get_by_slug_or_id(unique_user.household_id)
+    assert household
+
+    rule = create_rule(
+        unique_user, day=PlanRulesDay.saturday, entry_type=PlanRulesType.breakfast, tags=[tag], households=[household]
+    )
+
+    try:
+        payload = {"date": "2023-02-25", "entryType": "breakfast"}
+        response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token)
+        assert response.status_code == 200
+        assert response.json()["recipe"]["slug"] == recipe.slug
+    finally:
+        unique_user.repos.group_meal_plan_rules.delete(rule.id)
+
+
+def test_get_mealplan_with_rules_households_filter_includes_any_households(
+    api_client: TestClient, unique_user: TestUser, h2_user: TestUser
+):
+    tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
+    recipe = create_recipe(h2_user, tags=[tag])
+
+    household = unique_user.repos.households.get_by_slug_or_id(unique_user.household_id)
+    assert household
+    h2_household = unique_user.repos.households.get_by_slug_or_id(h2_user.household_id)
+    assert h2_household
+    rule = create_rule(
+        unique_user,
+        day=PlanRulesDay.saturday,
+        entry_type=PlanRulesType.breakfast,
+        tags=[tag],
+        households=[household, h2_household],
+    )
+
+    try:
+        payload = {"date": "2023-02-25", "entryType": "breakfast"}
+        response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token)
+        assert response.status_code == 200
+        assert response.json()["recipe"]["slug"] == recipe.slug
+    finally:
+        unique_user.repos.group_meal_plan_rules.delete(rule.id)


Code review comments:
diff_hunk: @@ -40,57 +41,77 @@ def mixins(self):
             self.registered_exceptions,
         )
 
+    def _get_random_recipes_from_mealplan(
+        self, plan_date: date, entry_type: PlanEntryType, limit: int = 1
+    ) -> list[Recipe]:
+        """
+        Gets rules for a mealplan and returns a list of random recipes based on the rules.
+        May return zero recipes if no recipes match the filter criteria.
+
+        Recipes from all households are included unless the rules specify a a household filter.
path: mealie/routes/households/controller_mealplan.py
text: @author:
```suggestion
        Recipes from all households are included unless the rules specify a household filter.
```