mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -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> |       <v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" :label="$t('meal-plan.meal-type')"></v-select> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" /> |     <div class="mb-5"> | ||||||
|     <RecipeOrganizerSelector v-model="inputTags" selector-type="tags" /> |       <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 --> |     <!-- TODO: proper pluralization of inputDay --> | ||||||
|     {{ $t('meal-plan.this-rule-will-apply', { |     {{ $t('meal-plan.this-rule-will-apply', { | ||||||
| @@ -18,11 +25,13 @@ | |||||||
|  |  | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| import { defineComponent, computed, useContext } from "@nuxtjs/composition-api"; | 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 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({ | export default defineComponent({ | ||||||
|   components: { |   components: { | ||||||
|  |     GroupHouseholdSelector, | ||||||
|     RecipeOrganizerSelector, |     RecipeOrganizerSelector, | ||||||
|   }, |   }, | ||||||
|   props: { |   props: { | ||||||
| @@ -35,11 +44,15 @@ export default defineComponent({ | |||||||
|       default: "unset", |       default: "unset", | ||||||
|     }, |     }, | ||||||
|     categories: { |     categories: { | ||||||
|       type: Array as () => RecipeCategory[], |       type: Array as () => PlanCategory[], | ||||||
|       default: () => [], |       default: () => [], | ||||||
|     }, |     }, | ||||||
|     tags: { |     tags: { | ||||||
|       type: Array as () => RecipeTag[], |       type: Array as () => PlanTag[], | ||||||
|  |       default: () => [], | ||||||
|  |     }, | ||||||
|  |     households: { | ||||||
|  |       type: Array as () => PlanHousehold[], | ||||||
|       default: () => [], |       default: () => [], | ||||||
|     }, |     }, | ||||||
|     showHelp: { |     showHelp: { | ||||||
| @@ -105,6 +118,15 @@ export default defineComponent({ | |||||||
|       }, |       }, | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     const inputHouseholds = computed({ | ||||||
|  |       get: () => { | ||||||
|  |         return props.households; | ||||||
|  |       }, | ||||||
|  |       set: (val) => { | ||||||
|  |         context.emit("update:households", val); | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|     return { |     return { | ||||||
|       MEAL_TYPE_OPTIONS, |       MEAL_TYPE_OPTIONS, | ||||||
|       MEAL_DAY_OPTIONS, |       MEAL_DAY_OPTIONS, | ||||||
| @@ -112,6 +134,7 @@ export default defineComponent({ | |||||||
|       inputEntryType, |       inputEntryType, | ||||||
|       inputCategories, |       inputCategories, | ||||||
|       inputTags, |       inputTags, | ||||||
|  |       inputHouseholds, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -315,6 +315,10 @@ | |||||||
|     "mealplan-settings": "Mealplan Settings", |     "mealplan-settings": "Mealplan Settings", | ||||||
|     "mealplan-update-failed": "Mealplan update failed", |     "mealplan-update-failed": "Mealplan update failed", | ||||||
|     "mealplan-updated": "Mealplan Updated", |     "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-plan-defined-yet": "No meal plan defined yet", | ||||||
|     "no-meal-planned-for-today": "No meal planned for today", |     "no-meal-planned-for-today": "No meal planned for today", | ||||||
|     "numberOfDays-hint": "Number of days on page load", |     "numberOfDays-hint": "Number of days on page load", | ||||||
|   | |||||||
| @@ -31,13 +31,24 @@ export interface ListItem { | |||||||
|   quantity?: number; |   quantity?: number; | ||||||
|   checked?: boolean; |   checked?: boolean; | ||||||
| } | } | ||||||
|  | export interface PlanCategory { | ||||||
|  |   id: string; | ||||||
|  |   name: string; | ||||||
|  |   slug: string; | ||||||
|  | } | ||||||
|  | export interface PlanHousehold { | ||||||
|  |   id: string; | ||||||
|  |   name: string; | ||||||
|  |   slug: string; | ||||||
|  | } | ||||||
| export interface PlanRulesCreate { | export interface PlanRulesCreate { | ||||||
|   day?: PlanRulesDay & string; |   day?: PlanRulesDay & string; | ||||||
|   entryType?: PlanRulesType & string; |   entryType?: PlanRulesType & string; | ||||||
|   categories?: Category[]; |   categories?: PlanCategory[]; | ||||||
|   tags?: Tag[]; |   tags?: PlanTag[]; | ||||||
|  |   households?: PlanHousehold[]; | ||||||
| } | } | ||||||
| export interface Tag { | export interface PlanTag { | ||||||
|   id: string; |   id: string; | ||||||
|   name: string; |   name: string; | ||||||
|   slug: string; |   slug: string; | ||||||
| @@ -45,8 +56,9 @@ export interface Tag { | |||||||
| export interface PlanRulesOut { | export interface PlanRulesOut { | ||||||
|   day?: PlanRulesDay & string; |   day?: PlanRulesDay & string; | ||||||
|   entryType?: PlanRulesType & string; |   entryType?: PlanRulesType & string; | ||||||
|   categories?: Category[]; |   categories?: PlanCategory[]; | ||||||
|   tags?: Tag[]; |   tags?: PlanTag[]; | ||||||
|  |   households?: PlanHousehold[]; | ||||||
|   groupId: string; |   groupId: string; | ||||||
|   householdId: string; |   householdId: string; | ||||||
|   id: string; |   id: string; | ||||||
| @@ -54,8 +66,9 @@ export interface PlanRulesOut { | |||||||
| export interface PlanRulesSave { | export interface PlanRulesSave { | ||||||
|   day?: PlanRulesDay & string; |   day?: PlanRulesDay & string; | ||||||
|   entryType?: PlanRulesType & string; |   entryType?: PlanRulesType & string; | ||||||
|   categories?: Category[]; |   categories?: PlanCategory[]; | ||||||
|   tags?: Tag[]; |   tags?: PlanTag[]; | ||||||
|  |   households?: PlanHousehold[]; | ||||||
|   groupId: string; |   groupId: string; | ||||||
|   householdId: string; |   householdId: string; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -20,6 +20,7 @@ | |||||||
|           :entry-type.sync="createData.entryType" |           :entry-type.sync="createData.entryType" | ||||||
|           :categories.sync="createData.categories" |           :categories.sync="createData.categories" | ||||||
|           :tags.sync="createData.tags" |           :tags.sync="createData.tags" | ||||||
|  |           :households.sync="createData.households" | ||||||
|         /> |         /> | ||||||
|       </v-card-text> |       </v-card-text> | ||||||
|       <v-card-actions class="justify-end"> |       <v-card-actions class="justify-end"> | ||||||
| @@ -58,12 +59,58 @@ | |||||||
|               <template v-if="!editState[rule.id]"> |               <template v-if="!editState[rule.id]"> | ||||||
|                 <div v-if="rule.categories"> |                 <div v-if="rule.categories"> | ||||||
|                   <h4 class="py-1">{{ $t("category.categories") }}:</h4> |                   <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> | ||||||
|  |  | ||||||
|                 <div v-if="rule.tags"> |                 <div v-if="rule.tags"> | ||||||
|                   <h4 class="py-1">{{ $t("tag.tags") }}:</h4> |                   <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> |                 </div> | ||||||
|               </template> |               </template> | ||||||
|               <template v-else> |               <template v-else> | ||||||
| @@ -72,6 +119,7 @@ | |||||||
|                   :entry-type.sync="allRules[idx].entryType" |                   :entry-type.sync="allRules[idx].entryType" | ||||||
|                   :categories.sync="allRules[idx].categories" |                   :categories.sync="allRules[idx].categories" | ||||||
|                   :tags.sync="allRules[idx].tags" |                   :tags.sync="allRules[idx].tags" | ||||||
|  |                   :households.sync="allRules[idx].households" | ||||||
|                 /> |                 /> | ||||||
|                 <div class="d-flex justify-end"> |                 <div class="d-flex justify-end"> | ||||||
|                   <BaseButton update @click="updateRule(rule)" /> |                   <BaseButton update @click="updateRule(rule)" /> | ||||||
| @@ -138,6 +186,7 @@ export default defineComponent({ | |||||||
|       day: "unset", |       day: "unset", | ||||||
|       categories: [], |       categories: [], | ||||||
|       tags: [], |       tags: [], | ||||||
|  |       households: [], | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     async function createRule() { |     async function createRule() { | ||||||
| @@ -149,6 +198,7 @@ export default defineComponent({ | |||||||
|           day: "unset", |           day: "unset", | ||||||
|           categories: [], |           categories: [], | ||||||
|           tags: [], |           tags: [], | ||||||
|  |           households: [], | ||||||
|         }; |         }; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import datetime | import datetime | ||||||
| from typing import TYPE_CHECKING, Optional | 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.ext.associationproxy import AssociationProxy, association_proxy | ||||||
| from sqlalchemy.orm import Mapped, mapped_column | from sqlalchemy.orm import Mapped, mapped_column | ||||||
|  |  | ||||||
| @@ -18,6 +18,14 @@ if TYPE_CHECKING: | |||||||
|     from ..users import User |     from ..users import User | ||||||
|     from .household import Household |     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): | class GroupMealPlanRules(BaseMixins, SqlAlchemyBase): | ||||||
|     __tablename__ = "group_meal_plan_rules" |     __tablename__ = "group_meal_plan_rules" | ||||||
| @@ -33,8 +41,10 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase): | |||||||
|         String, nullable=False, default="" |         String, nullable=False, default="" | ||||||
|     )  # "breakfast", "lunch", "dinner", "side" |     )  # "breakfast", "lunch", "dinner", "side" | ||||||
|  |  | ||||||
|  |     # Filters | ||||||
|     categories: Mapped[list[Category]] = orm.relationship(Category, secondary=plan_rules_to_categories) |     categories: Mapped[list[Category]] = orm.relationship(Category, secondary=plan_rules_to_categories) | ||||||
|     tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags) |     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() |     @auto_init() | ||||||
|     def __init__(self, **_) -> None: |     def __init__(self, **_) -> None: | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| import random | import random | ||||||
| from collections.abc import Iterable | from collections.abc import Iterable | ||||||
|  | from datetime import datetime, timezone | ||||||
| from math import ceil | from math import ceil | ||||||
| from typing import Any, Generic, TypeVar | from typing import Any, Generic, TypeVar | ||||||
|  |  | ||||||
| @@ -62,6 +63,9 @@ class RepositoryGeneric(Generic[Schema, Model]): | |||||||
|     def household_id(self) -> UUID4 | None: |     def household_id(self) -> UUID4 | None: | ||||||
|         return self._household_id |         return self._household_id | ||||||
|  |  | ||||||
|  |     def _random_seed(self) -> str: | ||||||
|  |         return str(datetime.now(tz=timezone.utc)) | ||||||
|  |  | ||||||
|     def _log_exception(self, e: Exception) -> None: |     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(f"Error processing query for Repo model={self.model.__name__} schema={self.schema.__name__}") | ||||||
|         self.logger.error(e) |         self.logger.error(e) | ||||||
| @@ -409,6 +413,9 @@ class RepositoryGeneric(Generic[Schema, Model]): | |||||||
|             # this solution is db-independent & stable to paging |             # this solution is db-independent & stable to paging | ||||||
|             temp_query = query.with_only_columns(self.model.id) |             temp_query = query.with_only_columns(self.model.id) | ||||||
|             allids = self.session.execute(temp_query).scalars().all()  # fast because id is indexed |             allids = self.session.execute(temp_query).scalars().all()  # fast because id is indexed | ||||||
|  |             if not allids: | ||||||
|  |                 return query | ||||||
|  |  | ||||||
|             order = list(range(len(allids))) |             order = list(range(len(allids))) | ||||||
|             random.seed(pagination.pagination_seed) |             random.seed(pagination.pagination_seed) | ||||||
|             random.shuffle(order) |             random.shuffle(order) | ||||||
|   | |||||||
| @@ -23,7 +23,6 @@ from mealie.schema.recipe.recipe import ( | |||||||
|     RecipeCategory, |     RecipeCategory, | ||||||
|     RecipePagination, |     RecipePagination, | ||||||
|     RecipeSummary, |     RecipeSummary, | ||||||
|     RecipeTag, |  | ||||||
|     RecipeTool, |     RecipeTool, | ||||||
| ) | ) | ||||||
| from mealie.schema.recipe.recipe_category import CategoryBase, TagBase | from mealie.schema.recipe.recipe_category import CategoryBase, TagBase | ||||||
| @@ -99,6 +98,9 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]): | |||||||
|                     ids.append(i_as_uuid) |                     ids.append(i_as_uuid) | ||||||
|                 except ValueError: |                 except ValueError: | ||||||
|                     slugs.append(i) |                     slugs.append(i) | ||||||
|  |  | ||||||
|  |         if not slugs: | ||||||
|  |             return ids | ||||||
|         additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all() |         additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all() | ||||||
|         return ids + additional_ids |         return ids + additional_ids | ||||||
|  |  | ||||||
| @@ -308,27 +310,6 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]): | |||||||
|         stmt = sa.select(RecipeModel).filter(*fltr) |         stmt = sa.select(RecipeModel).filter(*fltr) | ||||||
|         return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()] |         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]: |     def get_random(self, limit=1) -> list[Recipe]: | ||||||
|         stmt = sa.select(RecipeModel).order_by(sa.func.random()).limit(limit)  # Postgres and SQLite specific |         stmt = sa.select(RecipeModel).order_by(sa.func.random()).limit(limit)  # Postgres and SQLite specific | ||||||
|         if self.group_id: |         if self.group_id: | ||||||
|   | |||||||
| @@ -4,14 +4,15 @@ from functools import cached_property | |||||||
| from fastapi import APIRouter, Depends, HTTPException | from fastapi import APIRouter, Depends, HTTPException | ||||||
|  |  | ||||||
| from mealie.core.exceptions import mealie_registered_exceptions | 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.repos.repository_meals import RepositoryMeals | ||||||
| from mealie.routes._base import controller | from mealie.routes._base import controller | ||||||
| from mealie.routes._base.base_controllers import BaseCrudController | from mealie.routes._base.base_controllers import BaseCrudController | ||||||
| from mealie.routes._base.mixins import HttpRepo | from mealie.routes._base.mixins import HttpRepo | ||||||
| from mealie.schema import mapper | from mealie.schema import mapper | ||||||
| from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry | 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.new_meal import CreateRandomEntry, PlanEntryPagination, PlanEntryType | ||||||
| from mealie.schema.meal_plan.plan_rules import PlanRulesDay | from mealie.schema.meal_plan.plan_rules import PlanCategory, PlanHousehold, PlanRulesDay, PlanTag | ||||||
| from mealie.schema.recipe.recipe import Recipe | from mealie.schema.recipe.recipe import Recipe | ||||||
| from mealie.schema.response.pagination import PaginationQuery | from mealie.schema.response.pagination import PaginationQuery | ||||||
| from mealie.schema.response.responses import ErrorResponse | from mealie.schema.response.responses import ErrorResponse | ||||||
| @@ -40,6 +41,47 @@ class GroupMealplanController(BaseCrudController): | |||||||
|             self.registered_exceptions, |             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") |     @router.get("/today") | ||||||
|     def get_todays_meals(self): |     def get_todays_meals(self): | ||||||
|         return self.repo.get_today() |         return self.repo.get_today() | ||||||
| @@ -47,50 +89,29 @@ class GroupMealplanController(BaseCrudController): | |||||||
|     @router.post("/random", response_model=ReadPlanEntry) |     @router.post("/random", response_model=ReadPlanEntry) | ||||||
|     def create_random_meal(self, data: CreateRandomEntry): |     def create_random_meal(self, data: CreateRandomEntry): | ||||||
|         """ |         """ | ||||||
|         create_random_meal is a route that provides the randomized functionality for mealplaners. |         `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 |         It operates by following the rules set out in the household's mealplan settings. If no settings | ||||||
|         are set, it will default return any random meal. |         are set, it will return any random meal. | ||||||
|  |  | ||||||
|         Refer to the mealplan settings routes for more information on how rules can be applied |         Refer to the mealplan settings routes for more information on how rules can be applied | ||||||
|         to the random meal selector. |         to the random meal selector. | ||||||
|         """ |         """ | ||||||
|         # Get relevant group rules |         random_recipes = self._get_random_recipes_from_mealplan(data.date, data.entry_type) | ||||||
|         rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(data.date), data.entry_type.value) |         if not random_recipes: | ||||||
|  |  | ||||||
|         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: |  | ||||||
|             raise HTTPException( |             raise HTTPException( | ||||||
|                 status_code=404, detail=ErrorResponse.respond(message=self.t("mealplan.no-recipes-match-your-rules")) |                 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) |     @router.get("", response_model=PlanEntryPagination) | ||||||
|     def get_all( |     def get_all( | ||||||
|   | |||||||
| @@ -9,14 +9,16 @@ from .new_meal import ( | |||||||
|     UpdatePlanEntry, |     UpdatePlanEntry, | ||||||
| ) | ) | ||||||
| from .plan_rules import ( | from .plan_rules import ( | ||||||
|     Category, |     BasePlanRuleFilter, | ||||||
|  |     PlanCategory, | ||||||
|  |     PlanHousehold, | ||||||
|     PlanRulesCreate, |     PlanRulesCreate, | ||||||
|     PlanRulesDay, |     PlanRulesDay, | ||||||
|     PlanRulesOut, |     PlanRulesOut, | ||||||
|     PlanRulesPagination, |     PlanRulesPagination, | ||||||
|     PlanRulesSave, |     PlanRulesSave, | ||||||
|     PlanRulesType, |     PlanRulesType, | ||||||
|     Tag, |     PlanTag, | ||||||
| ) | ) | ||||||
| from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut | from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut | ||||||
|  |  | ||||||
| @@ -31,12 +33,14 @@ __all__ = [ | |||||||
|     "ReadPlanEntry", |     "ReadPlanEntry", | ||||||
|     "SavePlanEntry", |     "SavePlanEntry", | ||||||
|     "UpdatePlanEntry", |     "UpdatePlanEntry", | ||||||
|     "Category", |     "BasePlanRuleFilter", | ||||||
|  |     "PlanCategory", | ||||||
|  |     "PlanHousehold", | ||||||
|     "PlanRulesCreate", |     "PlanRulesCreate", | ||||||
|     "PlanRulesDay", |     "PlanRulesDay", | ||||||
|     "PlanRulesOut", |     "PlanRulesOut", | ||||||
|     "PlanRulesPagination", |     "PlanRulesPagination", | ||||||
|     "PlanRulesSave", |     "PlanRulesSave", | ||||||
|     "PlanRulesType", |     "PlanRulesType", | ||||||
|     "Tag", |     "PlanTag", | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -5,19 +5,27 @@ from pydantic import UUID4, ConfigDict | |||||||
| from sqlalchemy.orm import joinedload | from sqlalchemy.orm import joinedload | ||||||
| from sqlalchemy.orm.interfaces import LoaderOption | 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._mealie import MealieModel | ||||||
| from mealie.schema.response.pagination import PaginationBase | from mealie.schema.response.pagination import PaginationBase | ||||||
|  |  | ||||||
|  |  | ||||||
| class Category(MealieModel): | class BasePlanRuleFilter(MealieModel): | ||||||
|     id: UUID4 |     id: UUID4 | ||||||
|     name: str |     name: str | ||||||
|     slug: str |     slug: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PlanCategory(BasePlanRuleFilter): | ||||||
|     model_config = ConfigDict(from_attributes=True) |     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) |     model_config = ConfigDict(from_attributes=True) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -51,8 +59,9 @@ class PlanRulesType(str, Enum): | |||||||
| class PlanRulesCreate(MealieModel): | class PlanRulesCreate(MealieModel): | ||||||
|     day: PlanRulesDay = PlanRulesDay.unset |     day: PlanRulesDay = PlanRulesDay.unset | ||||||
|     entry_type: PlanRulesType = PlanRulesType.unset |     entry_type: PlanRulesType = PlanRulesType.unset | ||||||
|     categories: list[Category] = [] |     categories: list[PlanCategory] = [] | ||||||
|     tags: list[Tag] = [] |     tags: list[PlanTag] = [] | ||||||
|  |     households: list[PlanHousehold] = [] | ||||||
|  |  | ||||||
|  |  | ||||||
| class PlanRulesSave(PlanRulesCreate): | class PlanRulesSave(PlanRulesCreate): | ||||||
| @@ -66,7 +75,23 @@ class PlanRulesOut(PlanRulesSave): | |||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def loader_options(cls) -> list[LoaderOption]: |     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): | class PlanRulesPagination(PaginationBase): | ||||||
|   | |||||||
| @@ -1,8 +1,14 @@ | |||||||
|  | import random | ||||||
| from datetime import datetime, timedelta, timezone | from datetime import datetime, timedelta, timezone | ||||||
|  | from uuid import UUID | ||||||
|  |  | ||||||
| from fastapi.testclient import TestClient | 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.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 import api_routes | ||||||
| from tests.utils.factories import random_string | from tests.utils.factories import random_string | ||||||
| from tests.utils.fixture_schemas import TestUser | 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): | def test_create_mealplan_no_recipe(api_client: TestClient, unique_user: TestUser): | ||||||
|     title = random_string(length=25) |     title = random_string(length=25) | ||||||
|     text = 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: |     for meal_plan in response_json: | ||||||
|         assert meal_plan["date"] == datetime.now(timezone.utc).date().strftime("%Y-%m-%d") |         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