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:
Hayden
2022-02-07 19:03:11 -09:00
committed by GitHub
parent 40d1f586cd
commit d1024e272d
43 changed files with 1153 additions and 175 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()]

View File

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

View File

@@ -12,6 +12,7 @@ router = APIRouter(prefix="/categories", tags=["Categories: CRUD"])
class CategorySummary(BaseModel):
id: int
slug: str
name: str

View File

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

View File

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

View 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

View File

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

View File

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

View 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

View File

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

View File

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

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

View File

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