feat: Add Household Filter to Meal Plan Rules (#4231)

This commit is contained in:
Michael Genson 2024-09-27 09:06:45 -05:00 committed by GitHub
parent 38502e82d4
commit 4712994242
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 533 additions and 87 deletions

View File

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

View File

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

View File

@ -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,
}; };
}, },
}); });

View File

@ -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",

View File

@ -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;
} }

View File

@ -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: [],
}; };
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
] ]

View File

@ -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):

View File

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