mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-01-05 08:31:25 -05:00
feat: Add Household Filter to Meal Plan Rules (#4231)
This commit is contained in:
@@ -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,50 +89,29 @@ 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)
|
||||
|
||||
recipe_repo = self.repos.recipes
|
||||
random_recipes: list[Recipe] = []
|
||||
|
||||
if not rules: # If no rules are set, return any random recipe from the group
|
||||
random_recipes = recipe_repo.get_random()
|
||||
else: # otherwise construct a query based on the rules
|
||||
tags = []
|
||||
categories = []
|
||||
for rule in rules:
|
||||
if rule.tags:
|
||||
tags.extend(rule.tags)
|
||||
if rule.categories:
|
||||
categories.extend(rule.categories)
|
||||
|
||||
if tags or categories:
|
||||
random_recipes = self.repos.recipes.get_random_by_categories_and_tags(categories, tags)
|
||||
else:
|
||||
random_recipes = recipe_repo.get_random()
|
||||
|
||||
try:
|
||||
recipe = random_recipes[0]
|
||||
return self.mixins.create_one(
|
||||
SavePlanEntry(
|
||||
date=data.date,
|
||||
entry_type=data.entry_type,
|
||||
recipe_id=recipe.id,
|
||||
group_id=self.group_id,
|
||||
user_id=self.user.id,
|
||||
)
|
||||
)
|
||||
except IndexError as e:
|
||||
random_recipes = self._get_random_recipes_from_mealplan(data.date, data.entry_type)
|
||||
if not random_recipes:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=ErrorResponse.respond(message=self.t("mealplan.no-recipes-match-your-rules"))
|
||||
) from e
|
||||
)
|
||||
|
||||
recipe = random_recipes[0]
|
||||
return self.mixins.create_one(
|
||||
SavePlanEntry(
|
||||
date=data.date,
|
||||
entry_type=data.entry_type,
|
||||
recipe_id=recipe.id,
|
||||
group_id=self.group_id,
|
||||
user_id=self.user.id,
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("", response_model=PlanEntryPagination)
|
||||
def get_all(
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user