mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-01-07 09:31:22 -05:00
Feature/automated meal planner (#939)
* cleanup oversized buttons * add get all by category function to reciep repos * fix shopping-list can_merge logic * use randomized data for testing * add random getter to repository for meal-planner * add stub route for random meals * cleanup global namespace * add rules database type * fix type * add plan rules schema * test plan rules methods * add mealplan rules controller * add new repository * update frontend types * formatting * fix regression * update autogenerated types * add api class for mealplan rules * add tests and fix bugs * fix data returns * proof of concept rules editor * add tag support * remove old group categories * add tag support * implement random by rules api * change snack to sides * remove incorrect typing * split repo for custom methods * fix query and use and_ clause * use repo function * remove old test * update changelog
This commit is contained in:
@@ -15,8 +15,6 @@ from .cookbook import CookBook
|
||||
from .mealplan import GroupMealPlan
|
||||
from .preferences import GroupPreferencesModel
|
||||
|
||||
settings = get_app_settings()
|
||||
|
||||
|
||||
class Group(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "groups"
|
||||
@@ -75,6 +73,8 @@ class Group(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@staticmethod
|
||||
def get_ref(session: Session, name: str):
|
||||
settings = get_app_settings()
|
||||
|
||||
item = session.query(Group).filter(Group.name == name).one_or_none()
|
||||
if item is None:
|
||||
item = session.query(Group).filter(Group.name == settings.DEFAULT_GROUP).one()
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
from sqlalchemy import Column, Date, ForeignKey, String, orm
|
||||
from sqlalchemy.sql.sqltypes import Integer
|
||||
|
||||
from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
from ..recipe.category import Category, plan_rules_to_categories
|
||||
|
||||
|
||||
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
|
||||
__tablename__ = "group_meal_plan_rules"
|
||||
|
||||
id = Column(GUID, primary_key=True, default=GUID.generate)
|
||||
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False)
|
||||
|
||||
day = Column(String, nullable=False, default="unset") # "MONDAY", "TUESDAY", "WEDNESDAY", etc...
|
||||
entry_type = Column(String, nullable=False, default="") # "breakfast", "lunch", "dinner", "side"
|
||||
|
||||
categories = orm.relationship(Category, secondary=plan_rules_to_categories, uselist=True)
|
||||
tags = orm.relationship(Tag, secondary=plan_rules_to_tags, uselist=True)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class GroupMealPlan(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@@ -18,6 +18,13 @@ group2categories = sa.Table(
|
||||
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
|
||||
)
|
||||
|
||||
plan_rules_to_categories = sa.Table(
|
||||
"plan_rules_to_categories",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("group_plan_rule_id", GUID, sa.ForeignKey("group_meal_plan_rules.id")),
|
||||
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
|
||||
)
|
||||
|
||||
recipes2categories = sa.Table(
|
||||
"recipes2categories",
|
||||
SqlAlchemyBase.metadata,
|
||||
|
||||
@@ -72,7 +72,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
assets = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
|
||||
nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
|
||||
recipe_category: list = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes")
|
||||
recipe_category = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes")
|
||||
tools = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes")
|
||||
|
||||
recipe_ingredient: list[RecipeIngredient] = orm.relationship(
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import validates
|
||||
|
||||
from mealie.core import root_logger
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models._model_utils import guid
|
||||
|
||||
logger = root_logger.get_logger()
|
||||
|
||||
@@ -15,6 +16,13 @@ recipes2tags = sa.Table(
|
||||
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
|
||||
)
|
||||
|
||||
plan_rules_to_tags = sa.Table(
|
||||
"plan_rules_to_tags",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id")),
|
||||
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
|
||||
)
|
||||
|
||||
|
||||
class Tag(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "tags"
|
||||
@@ -42,8 +50,7 @@ class Tag(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
slug = slugify(match_value)
|
||||
|
||||
result = session.query(Tag).filter(Tag.slug == slug).one_or_none()
|
||||
if result:
|
||||
if result := session.query(Tag).filter(Tag.slug == slug).one_or_none():
|
||||
logger.debug("Category exists, associating recipe")
|
||||
return result
|
||||
else:
|
||||
|
||||
@@ -8,6 +8,7 @@ from mealie.db.models.group.cookbook import CookBook
|
||||
from mealie.db.models.group.events import GroupEventNotifierModel
|
||||
from mealie.db.models.group.exports import GroupDataExportsModel
|
||||
from mealie.db.models.group.invite_tokens import GroupInviteToken
|
||||
from mealie.db.models.group.mealplan import GroupMealPlanRules
|
||||
from mealie.db.models.group.preferences import GroupPreferencesModel
|
||||
from mealie.db.models.group.shopping_list import (
|
||||
ShoppingList,
|
||||
@@ -28,6 +29,7 @@ from mealie.db.models.server.task import ServerTaskModel
|
||||
from mealie.db.models.sign_up import SignUp
|
||||
from mealie.db.models.users import LongLiveToken, User
|
||||
from mealie.db.models.users.password_reset import PasswordResetModel
|
||||
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.events import Event as EventSchema
|
||||
from mealie.schema.group.group_events import GroupEventNotifierOut
|
||||
@@ -43,6 +45,7 @@ from mealie.schema.group.invite_token import ReadInviteToken
|
||||
from mealie.schema.group.webhook import ReadWebhook
|
||||
from mealie.schema.labels import MultiPurposeLabelOut
|
||||
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
|
||||
from mealie.schema.meal_plan.plan_rules import PlanRulesOut
|
||||
from mealie.schema.recipe import Recipe, RecipeCategoryResponse, RecipeCommentOut, RecipeTagResponse, RecipeTool
|
||||
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
|
||||
from mealie.schema.recipe.recipe_share_token import RecipeShareToken
|
||||
@@ -58,10 +61,10 @@ from .repository_recipes import RepositoryRecipes
|
||||
from .repository_shopping_list import RepositoryShoppingList
|
||||
from .repository_users import RepositoryUsers
|
||||
|
||||
pk_id = "id"
|
||||
pk_slug = "slug"
|
||||
pk_token = "token"
|
||||
pk_group_id = "group_id"
|
||||
PK_ID = "id"
|
||||
PK_SLUG = "slug"
|
||||
PK_TOKEN = "token"
|
||||
PK_GROUP_ID = "group_id"
|
||||
|
||||
|
||||
class RepositoryCategories(RepositoryGeneric):
|
||||
@@ -86,134 +89,147 @@ class AllRepositories:
|
||||
self.session = session
|
||||
|
||||
# ================================================================
|
||||
# Recipe Items
|
||||
# Recipe
|
||||
|
||||
@cached_property
|
||||
def recipes(self) -> RepositoryRecipes:
|
||||
return RepositoryRecipes(self.session, pk_slug, RecipeModel, Recipe)
|
||||
return RepositoryRecipes(self.session, PK_SLUG, RecipeModel, Recipe)
|
||||
|
||||
@cached_property
|
||||
def ingredient_foods(self) -> RepositoryGeneric[IngredientFood, IngredientFoodModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, IngredientFoodModel, IngredientFood)
|
||||
return RepositoryGeneric(self.session, PK_ID, IngredientFoodModel, IngredientFood)
|
||||
|
||||
@cached_property
|
||||
def ingredient_units(self) -> RepositoryGeneric[IngredientUnit, IngredientUnitModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, IngredientUnitModel, IngredientUnit)
|
||||
return RepositoryGeneric(self.session, PK_ID, IngredientUnitModel, IngredientUnit)
|
||||
|
||||
@cached_property
|
||||
def tools(self) -> RepositoryGeneric[RecipeTool, Tool]:
|
||||
return RepositoryGeneric(self.session, pk_id, Tool, RecipeTool)
|
||||
return RepositoryGeneric(self.session, PK_ID, Tool, RecipeTool)
|
||||
|
||||
@cached_property
|
||||
def comments(self) -> RepositoryGeneric[RecipeCommentOut, RecipeComment]:
|
||||
return RepositoryGeneric(self.session, pk_id, RecipeComment, RecipeCommentOut)
|
||||
return RepositoryGeneric(self.session, PK_ID, RecipeComment, RecipeCommentOut)
|
||||
|
||||
@cached_property
|
||||
def categories(self) -> RepositoryCategories:
|
||||
# TODO: Fix Typing for Category Repository
|
||||
return RepositoryCategories(self.session, pk_slug, Category, RecipeCategoryResponse)
|
||||
return RepositoryCategories(self.session, PK_SLUG, Category, RecipeCategoryResponse)
|
||||
|
||||
@cached_property
|
||||
def tags(self) -> RepositoryTags:
|
||||
return RepositoryTags(self.session, pk_slug, Tag, RecipeTagResponse)
|
||||
return RepositoryTags(self.session, PK_SLUG, Tag, RecipeTagResponse)
|
||||
|
||||
@cached_property
|
||||
def recipe_share_tokens(self) -> RepositoryGeneric[RecipeShareToken, RecipeShareTokenModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, RecipeShareTokenModel, RecipeShareToken)
|
||||
return RepositoryGeneric(self.session, PK_ID, RecipeShareTokenModel, RecipeShareToken)
|
||||
|
||||
# ================================================================
|
||||
# Site Items
|
||||
# Site
|
||||
|
||||
@cached_property
|
||||
def sign_up(self) -> RepositoryGeneric[SignUpOut, SignUp]:
|
||||
return RepositoryGeneric(self.session, pk_id, SignUp, SignUpOut)
|
||||
return RepositoryGeneric(self.session, PK_ID, SignUp, SignUpOut)
|
||||
|
||||
@cached_property
|
||||
def events(self) -> RepositoryGeneric[EventSchema, Event]:
|
||||
return RepositoryGeneric(self.session, pk_id, Event, EventSchema)
|
||||
return RepositoryGeneric(self.session, PK_ID, Event, EventSchema)
|
||||
|
||||
# ================================================================
|
||||
# User Items
|
||||
# User
|
||||
|
||||
@cached_property
|
||||
def users(self) -> RepositoryUsers:
|
||||
return RepositoryUsers(self.session, pk_id, User, PrivateUser)
|
||||
return RepositoryUsers(self.session, PK_ID, User, PrivateUser)
|
||||
|
||||
@cached_property
|
||||
def api_tokens(self) -> RepositoryGeneric[LongLiveTokenInDB, LongLiveToken]:
|
||||
return RepositoryGeneric(self.session, pk_id, LongLiveToken, LongLiveTokenInDB)
|
||||
return RepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB)
|
||||
|
||||
@cached_property
|
||||
def tokens_pw_reset(self) -> RepositoryGeneric[PrivatePasswordResetToken, PasswordResetModel]:
|
||||
return RepositoryGeneric(self.session, pk_token, PasswordResetModel, PrivatePasswordResetToken)
|
||||
return RepositoryGeneric(self.session, PK_TOKEN, PasswordResetModel, PrivatePasswordResetToken)
|
||||
|
||||
# ================================================================
|
||||
# Group Items
|
||||
# Group
|
||||
|
||||
@cached_property
|
||||
def server_tasks(self) -> RepositoryGeneric[ServerTask, ServerTaskModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, ServerTaskModel, ServerTask)
|
||||
return RepositoryGeneric(self.session, PK_ID, ServerTaskModel, ServerTask)
|
||||
|
||||
@cached_property
|
||||
def groups(self) -> RepositoryGroup:
|
||||
return RepositoryGroup(self.session, pk_id, Group, GroupInDB)
|
||||
return RepositoryGroup(self.session, PK_ID, Group, GroupInDB)
|
||||
|
||||
@cached_property
|
||||
def group_invite_tokens(self) -> RepositoryGeneric[ReadInviteToken, GroupInviteToken]:
|
||||
return RepositoryGeneric(self.session, pk_token, GroupInviteToken, ReadInviteToken)
|
||||
return RepositoryGeneric(self.session, PK_TOKEN, GroupInviteToken, ReadInviteToken)
|
||||
|
||||
@cached_property
|
||||
def group_preferences(self) -> RepositoryGeneric[ReadGroupPreferences, GroupPreferencesModel]:
|
||||
return RepositoryGeneric(self.session, pk_group_id, GroupPreferencesModel, ReadGroupPreferences)
|
||||
return RepositoryGeneric(self.session, PK_GROUP_ID, GroupPreferencesModel, ReadGroupPreferences)
|
||||
|
||||
@cached_property
|
||||
def group_exports(self) -> RepositoryGeneric[GroupDataExport, GroupDataExportsModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, GroupDataExportsModel, GroupDataExport)
|
||||
|
||||
@cached_property
|
||||
def meals(self) -> RepositoryMeals:
|
||||
return RepositoryMeals(self.session, pk_id, GroupMealPlan, ReadPlanEntry)
|
||||
|
||||
@cached_property
|
||||
def cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
|
||||
return RepositoryGeneric(self.session, pk_id, CookBook, ReadCookBook)
|
||||
|
||||
@cached_property
|
||||
def webhooks(self) -> RepositoryGeneric[ReadWebhook, GroupWebhooksModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, GroupWebhooksModel, ReadWebhook)
|
||||
return RepositoryGeneric(self.session, PK_ID, GroupDataExportsModel, GroupDataExport)
|
||||
|
||||
@cached_property
|
||||
def group_reports(self) -> RepositoryGeneric[ReportOut, ReportModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, ReportModel, ReportOut)
|
||||
return RepositoryGeneric(self.session, PK_ID, ReportModel, ReportOut)
|
||||
|
||||
@cached_property
|
||||
def group_report_entries(self) -> RepositoryGeneric[ReportEntryOut, ReportEntryModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, ReportEntryModel, ReportEntryOut)
|
||||
return RepositoryGeneric(self.session, PK_ID, ReportEntryModel, ReportEntryOut)
|
||||
|
||||
@cached_property
|
||||
def cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
|
||||
return RepositoryGeneric(self.session, PK_ID, CookBook, ReadCookBook)
|
||||
|
||||
# ================================================================
|
||||
# Meal Plan
|
||||
|
||||
@cached_property
|
||||
def meals(self) -> RepositoryMeals:
|
||||
return RepositoryMeals(self.session, PK_ID, GroupMealPlan, ReadPlanEntry)
|
||||
|
||||
@cached_property
|
||||
def group_meal_plan_rules(self) -> RepositoryMealPlanRules:
|
||||
return RepositoryMealPlanRules(self.session, PK_ID, GroupMealPlanRules, PlanRulesOut)
|
||||
|
||||
@cached_property
|
||||
def webhooks(self) -> RepositoryGeneric[ReadWebhook, GroupWebhooksModel]:
|
||||
return RepositoryGeneric(self.session, PK_ID, GroupWebhooksModel, ReadWebhook)
|
||||
|
||||
# ================================================================
|
||||
# Shopping List
|
||||
|
||||
@cached_property
|
||||
def group_shopping_lists(self) -> RepositoryShoppingList:
|
||||
return RepositoryShoppingList(self.session, pk_id, ShoppingList, ShoppingListOut)
|
||||
return RepositoryShoppingList(self.session, PK_ID, ShoppingList, ShoppingListOut)
|
||||
|
||||
@cached_property
|
||||
def group_shopping_list_item(self) -> RepositoryGeneric[ShoppingListItemOut, ShoppingListItem]:
|
||||
return RepositoryGeneric(self.session, pk_id, ShoppingListItem, ShoppingListItemOut)
|
||||
return RepositoryGeneric(self.session, PK_ID, ShoppingListItem, ShoppingListItemOut)
|
||||
|
||||
@cached_property
|
||||
def group_shopping_list_item_references(
|
||||
self,
|
||||
) -> RepositoryGeneric[ShoppingListItemRecipeRefOut, ShoppingListItemRecipeReference]:
|
||||
return RepositoryGeneric(self.session, pk_id, ShoppingListItemRecipeReference, ShoppingListItemRecipeRefOut)
|
||||
return RepositoryGeneric(self.session, PK_ID, ShoppingListItemRecipeReference, ShoppingListItemRecipeRefOut)
|
||||
|
||||
@cached_property
|
||||
def group_shopping_list_recipe_refs(
|
||||
self,
|
||||
) -> RepositoryGeneric[ShoppingListRecipeRefOut, ShoppingListRecipeReference]:
|
||||
return RepositoryGeneric(self.session, pk_id, ShoppingListRecipeReference, ShoppingListRecipeRefOut)
|
||||
return RepositoryGeneric(self.session, PK_ID, ShoppingListRecipeReference, ShoppingListRecipeRefOut)
|
||||
|
||||
@cached_property
|
||||
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
|
||||
return RepositoryGeneric(self.session, pk_id, MultiPurposeLabel, MultiPurposeLabelOut)
|
||||
return RepositoryGeneric(self.session, PK_ID, MultiPurposeLabel, MultiPurposeLabelOut)
|
||||
|
||||
# ================================================================
|
||||
# Group Events
|
||||
|
||||
@cached_property
|
||||
def group_event_notifier(self) -> RepositoryGeneric[GroupEventNotifierOut, GroupEventNotifierModel]:
|
||||
return RepositoryGeneric(self.session, pk_id, GroupEventNotifierModel, GroupEventNotifierOut)
|
||||
return RepositoryGeneric(self.session, PK_ID, GroupEventNotifierModel, GroupEventNotifierOut)
|
||||
|
||||
29
mealie/repos/repository_meal_plan_rules.py
Normal file
29
mealie/repos/repository_meal_plan_rules.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
from mealie.db.models.group.mealplan import GroupMealPlanRules
|
||||
from mealie.schema.meal_plan.plan_rules import PlanRulesDay, PlanRulesOut, PlanRulesType
|
||||
|
||||
from .repository_generic import RepositoryGeneric
|
||||
|
||||
|
||||
class RepositoryMealPlanRules(RepositoryGeneric[PlanRulesOut, GroupMealPlanRules]):
|
||||
def by_group(self, group_id: UUID) -> "RepositoryMealPlanRules":
|
||||
return super().by_group(group_id)
|
||||
|
||||
def get_rules(self, day: PlanRulesDay, entry_type: PlanRulesType) -> list[PlanRulesOut]:
|
||||
qry = self.session.query(GroupMealPlanRules).filter(
|
||||
or_(
|
||||
GroupMealPlanRules.day.is_(day),
|
||||
GroupMealPlanRules.day.is_(None),
|
||||
GroupMealPlanRules.day.is_(PlanRulesDay.unset.value),
|
||||
),
|
||||
or_(
|
||||
GroupMealPlanRules.entry_type.is_(entry_type),
|
||||
GroupMealPlanRules.entry_type.is_(None),
|
||||
GroupMealPlanRules.entry_type.is_(PlanRulesType.unset.value),
|
||||
),
|
||||
)
|
||||
|
||||
return [self.schema.from_orm(x) for x in qry.all()]
|
||||
@@ -1,17 +1,25 @@
|
||||
from random import randint
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from mealie.db.models.recipe.category import Category
|
||||
from mealie.db.models.recipe.ingredient import RecipeIngredient
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
from mealie.db.models.recipe.settings import RecipeSettings
|
||||
from mealie.db.models.recipe.tag import Tag
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe import RecipeCategory, RecipeTag
|
||||
|
||||
from .repository_generic import RepositoryGeneric
|
||||
|
||||
|
||||
class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
def by_group(self, group_id: UUID) -> "RepositoryRecipes":
|
||||
return super().by_group(group_id)
|
||||
|
||||
def get_all_public(self, limit: int = None, order_by: str = None, start=0, override_schema=None):
|
||||
eff_schema = override_schema or self.schema
|
||||
|
||||
@@ -80,3 +88,59 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_by_categories(self, categories: list[RecipeCategory]) -> list[Recipe]:
|
||||
"""
|
||||
get_by_categories returns all the Recipes that contain every category provided in the list
|
||||
"""
|
||||
|
||||
ids = [x.id for x in categories]
|
||||
|
||||
return [
|
||||
self.schema.from_orm(x)
|
||||
for x in self.session.query(RecipeModel)
|
||||
.join(RecipeModel.recipe_category)
|
||||
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
|
||||
.all()
|
||||
]
|
||||
|
||||
def get_random_by_categories_and_tags(self, categories: list[RecipeCategory], tags: list[RecipeTag]) -> 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 = [
|
||||
RecipeModel.group_id == self.group_id,
|
||||
]
|
||||
|
||||
if categories:
|
||||
cat_ids = [x.id for x in categories]
|
||||
for cat_id in cat_ids:
|
||||
filters.append(RecipeModel.recipe_category.any(Category.id.is_(cat_id)))
|
||||
|
||||
if tags:
|
||||
tag_ids = [x.id for x in tags]
|
||||
for tag_id in tag_ids:
|
||||
filters.append(RecipeModel.tags.any(Tag.id.is_(tag_id)))
|
||||
|
||||
return [
|
||||
self.schema.from_orm(x)
|
||||
for x in self.session.query(RecipeModel)
|
||||
.filter(and_(*filters))
|
||||
.order_by(func.random()) # Postgres and SQLite specific
|
||||
.limit(1)
|
||||
]
|
||||
|
||||
def get_random(self, limit=1) -> list[Recipe]:
|
||||
return [
|
||||
self.schema.from_orm(x)
|
||||
for x in self.session.query(RecipeModel)
|
||||
.filter(RecipeModel.group_id == self.group_id)
|
||||
.order_by(func.random()) # Postgres and SQLite specific
|
||||
.limit(limit)
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ router = APIRouter(prefix="/categories", tags=["Categories: CRUD"])
|
||||
|
||||
|
||||
class CategorySummary(BaseModel):
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ from . import (
|
||||
controller_invitations,
|
||||
controller_labels,
|
||||
controller_mealplan,
|
||||
controller_meaplan_config,
|
||||
controller_mealplan_config,
|
||||
controller_mealplan_rules,
|
||||
controller_migrations,
|
||||
controller_shopping_lists,
|
||||
controller_webhooks,
|
||||
@@ -17,9 +18,10 @@ from . import (
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(controller_group_self_service.router)
|
||||
router.include_router(controller_mealplan_rules.router)
|
||||
router.include_router(controller_mealplan_config.router)
|
||||
router.include_router(controller_mealplan.router)
|
||||
router.include_router(controller_cookbooks.router)
|
||||
router.include_router(controller_meaplan_config.router)
|
||||
router.include_router(controller_webhooks.router)
|
||||
router.include_router(controller_invitations.router)
|
||||
router.include_router(controller_migrations.router)
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import date, timedelta
|
||||
from functools import cached_property
|
||||
from typing import Type
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from mealie.core.exceptions import mealie_registered_exceptions
|
||||
from mealie.repos.repository_meals import RepositoryMeals
|
||||
@@ -10,6 +10,10 @@ from mealie.routes._base import BaseUserController, controller
|
||||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.schema import mapper
|
||||
from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
|
||||
from mealie.schema.meal_plan.new_meal import CreatRandomEntry
|
||||
from mealie.schema.meal_plan.plan_rules import PlanRulesDay
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.response.responses import ErrorResponse
|
||||
|
||||
router = APIRouter(prefix="/groups/mealplans", tags=["Groups: Mealplans"])
|
||||
|
||||
@@ -34,10 +38,54 @@ class GroupMealplanController(BaseUserController):
|
||||
self.registered_exceptions,
|
||||
)
|
||||
|
||||
@router.get("/today", tags=["Groups: Mealplans"])
|
||||
@router.get("/today")
|
||||
def get_todays_meals(self):
|
||||
return self.repo.get_today(group_id=self.group_id)
|
||||
|
||||
@router.post("/random", response_model=ReadPlanEntry)
|
||||
def create_random_meal(self, data: CreatRandomEntry):
|
||||
"""
|
||||
create_random_meal is a route that provides the randomized funcitonality 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.
|
||||
|
||||
Refer to the mealplan settings routes for more information on how rules can be applied
|
||||
to the random meal selector.
|
||||
"""
|
||||
# Get relavent group rules
|
||||
rules = self.repos.group_meal_plan_rules.by_group(self.group_id).get_rules(
|
||||
PlanRulesDay.from_date(data.date), data.entry_type.value
|
||||
)
|
||||
|
||||
recipe_repo = self.repos.recipes.by_group(self.group_id)
|
||||
random_recipes: 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.by_group(self.group_id).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)
|
||||
)
|
||||
except IndexError:
|
||||
raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No recipes match your rules"))
|
||||
|
||||
@router.get("", response_model=list[ReadPlanEntry])
|
||||
def get_all(self, start: date = None, limit: date = None):
|
||||
start = start or date.today() - timedelta(days=999)
|
||||
|
||||
44
mealie/routes/groups/controller_mealplan_rules.py
Normal file
44
mealie/routes/groups/controller_mealplan_rules.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from functools import cached_property
|
||||
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base.abc_controller import BaseUserController
|
||||
from mealie.routes._base.controller import controller
|
||||
from mealie.routes._base.mixins import CrudMixins
|
||||
from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.schema import mapper
|
||||
from mealie.schema.meal_plan.plan_rules import PlanRulesCreate, PlanRulesOut, PlanRulesSave
|
||||
|
||||
router = UserAPIRouter(prefix="/groups/mealplans/rules", tags=["Groups: Mealplan Rules"])
|
||||
|
||||
|
||||
@controller(router)
|
||||
class GroupMealplanConfigController(BaseUserController):
|
||||
@cached_property
|
||||
def repo(self):
|
||||
return self.repos.group_meal_plan_rules.by_group(self.group_id)
|
||||
|
||||
@cached_property
|
||||
def mixins(self):
|
||||
return CrudMixins[PlanRulesCreate, PlanRulesOut, PlanRulesOut](self.repo, self.deps.logger)
|
||||
|
||||
@router.get("", response_model=list[PlanRulesOut])
|
||||
def get_all(self):
|
||||
return self.repo.get_all(override_schema=PlanRulesOut)
|
||||
|
||||
@router.post("", response_model=PlanRulesOut, status_code=201)
|
||||
def create_one(self, data: PlanRulesCreate):
|
||||
save = mapper.cast(data, PlanRulesSave, group_id=self.group.id)
|
||||
return self.mixins.create_one(save)
|
||||
|
||||
@router.get("/{item_id}", response_model=PlanRulesOut)
|
||||
def get_one(self, item_id: UUID4):
|
||||
return self.mixins.get_one(item_id)
|
||||
|
||||
@router.put("/{item_id}", response_model=PlanRulesOut)
|
||||
def update_one(self, item_id: UUID4, data: PlanRulesCreate):
|
||||
return self.mixins.update_one(data, item_id)
|
||||
|
||||
@router.delete("/{item_id}", response_model=PlanRulesOut)
|
||||
def delete_one(self, item_id: UUID4):
|
||||
return self.mixins.delete_one(item_id) # type: ignore
|
||||
@@ -1,4 +1,5 @@
|
||||
# GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
from .meal import *
|
||||
from .new_meal import *
|
||||
from .plan_rules import *
|
||||
from .shopping_list import *
|
||||
|
||||
@@ -13,7 +13,12 @@ class PlanEntryType(str, Enum):
|
||||
breakfast = "breakfast"
|
||||
lunch = "lunch"
|
||||
dinner = "dinner"
|
||||
snack = "snack"
|
||||
side = "side"
|
||||
|
||||
|
||||
class CreatRandomEntry(CamelModel):
|
||||
date: date
|
||||
entry_type: PlanEntryType = PlanEntryType.dinner
|
||||
|
||||
|
||||
class CreatePlanEntry(CamelModel):
|
||||
|
||||
63
mealie/schema/meal_plan/plan_rules.py
Normal file
63
mealie/schema/meal_plan/plan_rules.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import datetime
|
||||
from enum import Enum
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import UUID4
|
||||
|
||||
|
||||
class Category(CamelModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class Tag(Category):
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class PlanRulesDay(str, Enum):
|
||||
monday = "monday"
|
||||
tuesday = "tuesday"
|
||||
wednesday = "wednesday"
|
||||
thursday = "thursday"
|
||||
friday = "friday"
|
||||
saturday = "saturday"
|
||||
sunday = "sunday"
|
||||
unset = "unset"
|
||||
|
||||
@staticmethod
|
||||
def from_date(date: datetime.date):
|
||||
"""Returns the enum value for the date passed in"""
|
||||
try:
|
||||
return PlanRulesDay[(date.strftime("%A").lower())]
|
||||
except KeyError:
|
||||
return PlanRulesDay.unset
|
||||
|
||||
|
||||
class PlanRulesType(str, Enum):
|
||||
breakfast = "breakfast"
|
||||
lunch = "lunch"
|
||||
dinner = "dinner"
|
||||
unset = "unset"
|
||||
|
||||
|
||||
class PlanRulesCreate(CamelModel):
|
||||
day: PlanRulesDay = PlanRulesDay.unset
|
||||
entry_type: PlanRulesType = PlanRulesType.unset
|
||||
categories: list[Category] = []
|
||||
tags: list[Tag] = []
|
||||
|
||||
|
||||
class PlanRulesSave(PlanRulesCreate):
|
||||
group_id: UUID4
|
||||
|
||||
|
||||
class PlanRulesOut(PlanRulesSave):
|
||||
id: UUID4
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -24,6 +24,7 @@ app_dirs = get_app_dirs()
|
||||
|
||||
|
||||
class RecipeTag(CamelModel):
|
||||
id: int = 0
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
@@ -78,7 +79,7 @@ class RecipeSummary(CamelModel):
|
||||
perform_time: Optional[str] = None
|
||||
|
||||
description: Optional[str] = ""
|
||||
recipe_category: Optional[list[RecipeTag]] = []
|
||||
recipe_category: Optional[list[RecipeCategory]] = []
|
||||
tags: Optional[list[RecipeTag]] = []
|
||||
tools: list[RecipeTool] = []
|
||||
rating: Optional[int]
|
||||
|
||||
@@ -15,11 +15,11 @@ class UnitFoodBase(CamelModel):
|
||||
|
||||
class CreateIngredientFood(UnitFoodBase):
|
||||
label_id: UUID4 = None
|
||||
label: MultiPurposeLabelSummary = None
|
||||
|
||||
|
||||
class IngredientFood(CreateIngredientFood):
|
||||
id: int
|
||||
label: MultiPurposeLabelSummary = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
@@ -86,5 +86,4 @@ class IngredientRequest(CamelModel):
|
||||
|
||||
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
|
||||
|
||||
CreateIngredientFood.update_forward_refs()
|
||||
IngredientFood.update_forward_refs()
|
||||
|
||||
29
mealie/services/group_services/service_group_meals.py
Normal file
29
mealie/services/group_services/service_group_meals.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import random
|
||||
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.recipe.recipe import Recipe, RecipeCategory
|
||||
from mealie.services._base_service import BaseService
|
||||
|
||||
|
||||
class MealPlanService(BaseService):
|
||||
def __init__(self, group_id: UUID4, repos: AllRepositories):
|
||||
self.group_id = group_id
|
||||
self.repos = repos
|
||||
|
||||
def get_random_recipe(self, categories: list[RecipeCategory] = None) -> Recipe:
|
||||
"""get_random_recipe returns a single recipe matching a specific criteria of
|
||||
categories. if no categories are provided, a single recipe is returned from the
|
||||
entire recipe databas.
|
||||
|
||||
Note that the recipe must contain ALL categories in the list provided.
|
||||
|
||||
Args:
|
||||
categories (list[RecipeCategory], optional): [description]. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Recipe: [description]
|
||||
"""
|
||||
recipes = self.repos.recipes.by_group(self.group_id).get_by_categories(categories)
|
||||
return random.choice(recipes)
|
||||
@@ -23,15 +23,26 @@ class ShoppingListService:
|
||||
can_merge checks if the two items can be merged together.
|
||||
"""
|
||||
|
||||
# If no food or units are present check against the notes field.
|
||||
if not all([item1.food, item1.unit, item2.food, item2.unit]):
|
||||
# Check if foods are equal
|
||||
foods_is_none = item1.food_id is None and item2.food_id is None
|
||||
foods_not_none = not foods_is_none
|
||||
foods_equal = item1.food_id == item2.food_id
|
||||
|
||||
# Check if units are equal
|
||||
units_is_none = item1.unit_id is None and item2.unit_id is None
|
||||
units_not_none = not units_is_none
|
||||
units_equal = item1.unit_id == item2.unit_id
|
||||
|
||||
# Check if Notes are equal
|
||||
if foods_is_none and units_is_none:
|
||||
return item1.note == item2.note
|
||||
|
||||
# If the items have the same food and unit they can be merged.
|
||||
if item1.unit == item2.unit and item1.food == item2.food:
|
||||
return True
|
||||
if foods_not_none and units_not_none:
|
||||
return foods_equal and units_equal
|
||||
|
||||
if foods_not_none:
|
||||
return foods_equal
|
||||
|
||||
# Otherwise Assume They Can't Be Merged
|
||||
return False
|
||||
|
||||
def consolidate_list_items(self, item_list: list[ShoppingListItemOut]) -> list[ShoppingListItemOut]:
|
||||
|
||||
Reference in New Issue
Block a user