mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	feat: Add Household Filter to Meal Plan Rules (#4231)
This commit is contained in:
		| @@ -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 ### | ||||
| @@ -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> | ||||
| @@ -5,8 +5,15 @@ | ||||
|       <v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" :label="$t('meal-plan.meal-type')"></v-select> | ||||
|     </div> | ||||
|  | ||||
|     <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, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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: [], | ||||
|         }; | ||||
|       } | ||||
|     } | ||||
|   | ||||
| @@ -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 @@ if TYPE_CHECKING: | ||||
|     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: | ||||
|   | ||||
| @@ -2,6 +2,7 @@ from __future__ import annotations | ||||
|  | ||||
| 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 @@ class RepositoryGeneric(Generic[Schema, Model]): | ||||
|     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 @@ class RepositoryGeneric(Generic[Schema, Model]): | ||||
|             # 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) | ||||
|   | ||||
| @@ -23,7 +23,6 @@ from mealie.schema.recipe.recipe import ( | ||||
|     RecipeCategory, | ||||
|     RecipePagination, | ||||
|     RecipeSummary, | ||||
|     RecipeTag, | ||||
|     RecipeTool, | ||||
| ) | ||||
| from mealie.schema.recipe.recipe_category import CategoryBase, TagBase | ||||
| @@ -99,6 +98,9 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]): | ||||
|                     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 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]): | ||||
|         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: | ||||
|   | ||||
| @@ -4,14 +4,15 @@ from functools import cached_property | ||||
| 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 @@ class GroupMealplanController(BaseCrudController): | ||||
|             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 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,36 +89,19 @@ class GroupMealplanController(BaseCrudController): | ||||
|     @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) | ||||
|         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")) | ||||
|             ) | ||||
|  | ||||
|         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( | ||||
| @@ -87,10 +112,6 @@ class GroupMealplanController(BaseCrudController): | ||||
|                 user_id=self.user.id, | ||||
|             ) | ||||
|         ) | ||||
|         except IndexError as e: | ||||
|             raise HTTPException( | ||||
|                 status_code=404, detail=ErrorResponse.respond(message=self.t("mealplan.no-recipes-match-your-rules")) | ||||
|             ) from e | ||||
|  | ||||
|     @router.get("", response_model=PlanEntryPagination) | ||||
|     def get_all( | ||||
|   | ||||
| @@ -9,14 +9,16 @@ from .new_meal import ( | ||||
|     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 @@ __all__ = [ | ||||
|     "ReadPlanEntry", | ||||
|     "SavePlanEntry", | ||||
|     "UpdatePlanEntry", | ||||
|     "Category", | ||||
|     "BasePlanRuleFilter", | ||||
|     "PlanCategory", | ||||
|     "PlanHousehold", | ||||
|     "PlanRulesCreate", | ||||
|     "PlanRulesDay", | ||||
|     "PlanRulesOut", | ||||
|     "PlanRulesPagination", | ||||
|     "PlanRulesSave", | ||||
|     "PlanRulesType", | ||||
|     "Tag", | ||||
|     "PlanTag", | ||||
| ] | ||||
|   | ||||
| @@ -5,19 +5,27 @@ from pydantic import UUID4, ConfigDict | ||||
| 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 Tag(Category): | ||||
| class PlanTag(BasePlanRuleFilter): | ||||
|     model_config = ConfigDict(from_attributes=True) | ||||
|  | ||||
|  | ||||
| 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): | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user