From d1024e272df54df3095f44bcfc682a8de508bd75 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Mon, 7 Feb 2022 19:03:11 -0900 Subject: [PATCH] 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 --- docs/docs/changelog/v1.0.0.md | 1 + .../class-interfaces/group-mealplan-rules.ts | 14 ++ .../api/class-interfaces/group-mealplan.ts | 9 +- frontend/api/index.ts | 3 + .../Domain/Group/GroupMealPlanRuleForm.vue | 112 +++++++++++ frontend/composables/use-group-mealplan.ts | 4 +- frontend/composables/use-groups.ts | 26 --- frontend/layouts/default.vue | 3 +- .../{meal-plan => group/mealplan}/planner.vue | 89 ++++++--- frontend/pages/group/mealplan/settings.vue | 179 ++++++++++++++++++ .../mealplan}/this-week.vue | 0 frontend/pages/user/group/index.vue | 19 +- frontend/types/api-types/admin.ts | 13 +- frontend/types/api-types/cookbook.ts | 13 +- frontend/types/api-types/group.ts | 13 +- frontend/types/api-types/meal-plan.ts | 52 ++++- frontend/types/api-types/recipe.ts | 23 +-- frontend/types/api-types/user.ts | 13 +- frontend/utils/icons/icon-type.ts | 1 + frontend/utils/icons/icons.ts | 2 + mealie/db/models/group/group.py | 4 +- mealie/db/models/group/mealplan.py | 20 ++ mealie/db/models/recipe/category.py | 7 + mealie/db/models/recipe/recipe.py | 2 +- mealie/db/models/recipe/tag.py | 11 +- mealie/repos/repository_factory.py | 108 ++++++----- mealie/repos/repository_meal_plan_rules.py | 29 +++ mealie/repos/repository_recipes.py | 64 +++++++ mealie/routes/categories/categories.py | 1 + mealie/routes/groups/__init__.py | 6 +- mealie/routes/groups/controller_mealplan.py | 52 ++++- ...onfig.py => controller_mealplan_config.py} | 0 .../groups/controller_mealplan_rules.py | 44 +++++ mealie/schema/meal_plan/__init__.py | 1 + mealie/schema/meal_plan/new_meal.py | 7 +- mealie/schema/meal_plan/plan_rules.py | 63 ++++++ mealie/schema/recipe/recipe.py | 3 +- mealie/schema/recipe/recipe_ingredient.py | 3 +- .../group_services/service_group_meals.py | 29 +++ .../services/group_services/shopping_lists.py | 23 ++- .../test_group_mealplan_rules.py | 127 +++++++++++++ .../test_recipe_repository.py | 115 +++++++++++ .../unit_tests/schema_tests/test_meal_plan.py | 20 ++ 43 files changed, 1153 insertions(+), 175 deletions(-) create mode 100644 frontend/api/class-interfaces/group-mealplan-rules.ts create mode 100644 frontend/components/Domain/Group/GroupMealPlanRuleForm.vue rename frontend/pages/{meal-plan => group/mealplan}/planner.vue (84%) create mode 100644 frontend/pages/group/mealplan/settings.vue rename frontend/pages/{meal-plan => group/mealplan}/this-week.vue (100%) create mode 100644 mealie/repos/repository_meal_plan_rules.py rename mealie/routes/groups/{controller_meaplan_config.py => controller_mealplan_config.py} (100%) create mode 100644 mealie/routes/groups/controller_mealplan_rules.py create mode 100644 mealie/schema/meal_plan/plan_rules.py create mode 100644 mealie/services/group_services/service_group_meals.py create mode 100644 tests/integration_tests/user_group_tests/test_group_mealplan_rules.py create mode 100644 tests/unit_tests/repository_tests/test_recipe_repository.py create mode 100644 tests/unit_tests/schema_tests/test_meal_plan.py diff --git a/docs/docs/changelog/v1.0.0.md b/docs/docs/changelog/v1.0.0.md index ed6f9e1e..8e6a8f95 100644 --- a/docs/docs/changelog/v1.0.0.md +++ b/docs/docs/changelog/v1.0.0.md @@ -64,6 +64,7 @@ - Drag and Drop meals between days - Add Recipes or Notes to a specific day - New context menu action for recipes to add a recipe to a specific day on the meal-plan +- New rule based meal plan generator/selector. You can now create rules to restrict the addition of recipes for specific days or meal types (breakfast, lunch, dinner, side). You can also create rules that match against "all" days or "all" meal types to create global rules based around tags and categories. This gives you the most flexibility in creating meal plans. ### 🥙 Recipes diff --git a/frontend/api/class-interfaces/group-mealplan-rules.ts b/frontend/api/class-interfaces/group-mealplan-rules.ts new file mode 100644 index 00000000..f4ac4e94 --- /dev/null +++ b/frontend/api/class-interfaces/group-mealplan-rules.ts @@ -0,0 +1,14 @@ +import { BaseCRUDAPI } from "../_base"; +import { PlanRulesCreate, PlanRulesOut } from "~/types/api-types/meal-plan"; + +const prefix = "/api"; + +const routes = { + rule: `${prefix}/groups/mealplans/rules`, + ruleId: (id: string | number) => `${prefix}/groups/mealplans/rules/${id}`, +}; + +export class MealPlanRulesApi extends BaseCRUDAPI { + baseRoute = routes.rule; + itemRoute = routes.ruleId; +} diff --git a/frontend/api/class-interfaces/group-mealplan.ts b/frontend/api/class-interfaces/group-mealplan.ts index 4496eddc..85257760 100644 --- a/frontend/api/class-interfaces/group-mealplan.ts +++ b/frontend/api/class-interfaces/group-mealplan.ts @@ -1,13 +1,15 @@ import { BaseCRUDAPI } from "../_base"; +import { CreatRandomEntry } from "~/types/api-types/meal-plan"; const prefix = "/api"; const routes = { mealplan: `${prefix}/groups/mealplans`, + random: `${prefix}/groups/mealplans/random`, mealplanId: (id: string | number) => `${prefix}/groups/mealplans/${id}`, }; -type PlanEntryType = "breakfast" | "lunch" | "dinner" | "snack"; +type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side"; export interface CreateMealPlan { date: string; @@ -29,4 +31,9 @@ export interface MealPlan extends UpdateMealPlan { export class MealPlanAPI extends BaseCRUDAPI { baseRoute = routes.mealplan; itemRoute = routes.mealplanId; + + async setRandom(payload: CreatRandomEntry) { + console.log(payload); + return await this.requests.post(routes.random, payload); + } } diff --git a/frontend/api/index.ts b/frontend/api/index.ts index 32ccea77..8dfc7ad8 100644 --- a/frontend/api/index.ts +++ b/frontend/api/index.ts @@ -23,6 +23,7 @@ import { GroupReportsApi } from "./class-interfaces/group-reports"; import { ShoppingApi } from "./class-interfaces/group-shopping-lists"; import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose-labels"; import { GroupEventNotifierApi } from "./class-interfaces/group-event-notifier"; +import { MealPlanRulesApi } from "./class-interfaces/group-mealplan-rules"; import { ApiRequestInstance } from "~/types/api"; class Api { @@ -40,6 +41,7 @@ class Api { public groupWebhooks: WebhooksAPI; public register: RegisterAPI; public mealplans: MealPlanAPI; + public mealplanRules: MealPlanRulesApi; public email: EmailAPI; public bulk: BulkActionsAPI; public groupMigration: GroupMigrationApi; @@ -67,6 +69,7 @@ class Api { this.groupWebhooks = new WebhooksAPI(requests); this.register = new RegisterAPI(requests); this.mealplans = new MealPlanAPI(requests); + this.mealplanRules = new MealPlanRulesApi(requests); this.grouperServerTasks = new GroupServerTaskAPI(requests); // Group diff --git a/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue new file mode 100644 index 00000000..04f3efba --- /dev/null +++ b/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue @@ -0,0 +1,112 @@ + + + diff --git a/frontend/composables/use-group-mealplan.ts b/frontend/composables/use-group-mealplan.ts index b7e69c44..9162ef2f 100644 --- a/frontend/composables/use-group-mealplan.ts +++ b/frontend/composables/use-group-mealplan.ts @@ -4,13 +4,13 @@ import { useAsyncKey } from "./use-utils"; import { useUserApi } from "~/composables/api"; import { CreateMealPlan, UpdateMealPlan } from "~/api/class-interfaces/group-mealplan"; -export type MealType = "breakfast" | "lunch" | "dinner" | "snack"; +export type MealType = "breakfast" | "lunch" | "dinner" | "side"; export const planTypeOptions = [ { text: "Breakfast", value: "breakfast" }, { text: "Lunch", value: "lunch" }, { text: "Dinner", value: "dinner" }, - { text: "Snack", value: "snack" }, + { text: "Side", value: "side" }, ]; export interface DateRange { diff --git a/frontend/composables/use-groups.ts b/frontend/composables/use-groups.ts index a7891ca1..c90f036d 100644 --- a/frontend/composables/use-groups.ts +++ b/frontend/composables/use-groups.ts @@ -34,32 +34,6 @@ export const useGroupSelf = function () { return { actions, group }; }; -export const useGroupCategories = function () { - const api = useUserApi(); - - const actions = { - getAll() { - const units = useAsync(async () => { - const { data } = await api.groups.getCategories(); - return data; - }, useAsyncKey()); - - return units; - }, - async updateAll() { - if (!categories.value) { - return; - } - const { data } = await api.groups.setCategories(categories.value); - categories.value = data; - }, - }; - - const categories = actions.getAll(); - - return { actions, categories }; -}; - export const useGroups = function () { const api = useUserApi(); const loading = ref(false); diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index f7a33c58..4ccc85d9 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -60,7 +60,6 @@ - diff --git a/frontend/pages/meal-plan/this-week.vue b/frontend/pages/group/mealplan/this-week.vue similarity index 100% rename from frontend/pages/meal-plan/this-week.vue rename to frontend/pages/group/mealplan/this-week.vue diff --git a/frontend/pages/user/group/index.vue b/frontend/pages/user/group/index.vue index 9efd8528..4217a74d 100644 --- a/frontend/pages/user/group/index.vue +++ b/frontend/pages/user/group/index.vue @@ -7,16 +7,6 @@ These items are shared within your group. Editing one of them will change it for the whole group! -
- - Set the categories below for the ones that you want to be included in your mealplan random generation. - - - - - - -
@@ -82,14 +72,13 @@
- + - - diff --git a/frontend/types/api-types/admin.ts b/frontend/types/api-types/admin.ts index ae226ee0..e00d11d2 100644 --- a/frontend/types/api-types/admin.ts +++ b/frontend/types/api-types/admin.ts @@ -89,7 +89,7 @@ export interface Recipe { cookTime?: string; performTime?: string; description?: string; - recipeCategory?: RecipeTag[]; + recipeCategory?: RecipeCategory[]; tags?: RecipeTag[]; tools?: RecipeTool[]; rating?: number; @@ -107,14 +107,20 @@ export interface Recipe { }; comments?: RecipeCommentOut[]; } +export interface RecipeCategory { + id?: number; + name: string; + slug: string; +} export interface RecipeTag { + id?: number; name: string; slug: string; } export interface RecipeTool { + id?: number; name: string; slug: string; - id?: number; onHand?: boolean; } export interface RecipeIngredient { @@ -143,8 +149,8 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; id: number; + label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { name: string; @@ -156,7 +162,6 @@ export interface CreateIngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; } export interface RecipeStep { id?: string; diff --git a/frontend/types/api-types/cookbook.ts b/frontend/types/api-types/cookbook.ts index 54e35b64..bb05c2b4 100644 --- a/frontend/types/api-types/cookbook.ts +++ b/frontend/types/api-types/cookbook.ts @@ -45,7 +45,7 @@ export interface Recipe { cookTime?: string; performTime?: string; description?: string; - recipeCategory?: RecipeTag[]; + recipeCategory?: RecipeCategory[]; tags?: RecipeTag[]; tools?: RecipeTool[]; rating?: number; @@ -63,14 +63,20 @@ export interface Recipe { }; comments?: RecipeCommentOut[]; } +export interface RecipeCategory { + id?: number; + name: string; + slug: string; +} export interface RecipeTag { + id?: number; name: string; slug: string; } export interface RecipeTool { + id?: number; name: string; slug: string; - id?: number; onHand?: boolean; } export interface RecipeIngredient { @@ -99,8 +105,8 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; id: number; + label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { name: string; @@ -112,7 +118,6 @@ export interface CreateIngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; } export interface RecipeStep { id?: string; diff --git a/frontend/types/api-types/group.ts b/frontend/types/api-types/group.ts index 7be8348e..a2cf12e6 100644 --- a/frontend/types/api-types/group.ts +++ b/frontend/types/api-types/group.ts @@ -180,8 +180,8 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; id: number; + label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { name: string; @@ -234,7 +234,7 @@ export interface RecipeSummary { cookTime?: string; performTime?: string; description?: string; - recipeCategory?: RecipeTag[]; + recipeCategory?: RecipeCategory[]; tags?: RecipeTag[]; tools?: RecipeTool[]; rating?: number; @@ -243,14 +243,20 @@ export interface RecipeSummary { dateAdded?: string; dateUpdated?: string; } +export interface RecipeCategory { + id?: number; + name: string; + slug: string; +} export interface RecipeTag { + id?: number; name: string; slug: string; } export interface RecipeTool { + id?: number; name: string; slug: string; - id?: number; onHand?: boolean; } export interface RecipeIngredient { @@ -272,7 +278,6 @@ export interface CreateIngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; } export interface SaveInviteToken { usesLeft: number; diff --git a/frontend/types/api-types/meal-plan.ts b/frontend/types/api-types/meal-plan.ts index 4a117e18..6537707c 100644 --- a/frontend/types/api-types/meal-plan.ts +++ b/frontend/types/api-types/meal-plan.ts @@ -5,8 +5,19 @@ /* Do not modify it by hand - just update the pydantic models and then re-run the script */ -export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "snack"; +export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side"; +export type PlanRulesDay = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday" | "unset"; +export type PlanRulesType = "breakfast" | "lunch" | "dinner" | "unset"; +export interface Category { + id: number; + name: string; + slug: string; +} +export interface CreatRandomEntry { + date: string; + entryType?: PlanEntryType & string; +} export interface CreatePlanEntry { date: string; entryType?: PlanEntryType & string; @@ -48,6 +59,32 @@ export interface MealPlanOut { id: number; shoppingList?: number; } +export interface PlanRulesCreate { + day?: PlanRulesDay & string; + entryType?: PlanRulesType & string; + categories?: Category[]; + tags?: Tag[]; +} +export interface Tag { + id: number; + name: string; + slug: string; +} +export interface PlanRulesOut { + day?: PlanRulesDay & string; + entryType?: PlanRulesType & string; + categories?: Category[]; + tags?: Tag[]; + groupId: string; + id: string; +} +export interface PlanRulesSave { + day?: PlanRulesDay & string; + entryType?: PlanRulesType & string; + categories?: Category[]; + tags?: Tag[]; + groupId: string; +} export interface ReadPlanEntry { date: string; entryType?: PlanEntryType & string; @@ -71,7 +108,7 @@ export interface RecipeSummary { cookTime?: string; performTime?: string; description?: string; - recipeCategory?: RecipeTag[]; + recipeCategory?: RecipeCategory[]; tags?: RecipeTag[]; tools?: RecipeTool[]; rating?: number; @@ -80,14 +117,20 @@ export interface RecipeSummary { dateAdded?: string; dateUpdated?: string; } +export interface RecipeCategory { + id?: number; + name: string; + slug: string; +} export interface RecipeTag { + id?: number; name: string; slug: string; } export interface RecipeTool { + id?: number; name: string; slug: string; - id?: number; onHand?: boolean; } export interface RecipeIngredient { @@ -116,8 +159,8 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; id: number; + label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { name: string; @@ -129,7 +172,6 @@ export interface CreateIngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; } export interface SavePlanEntry { date: string; diff --git a/frontend/types/api-types/recipe.ts b/frontend/types/api-types/recipe.ts index 6d77c26e..f384a78e 100644 --- a/frontend/types/api-types/recipe.ts +++ b/frontend/types/api-types/recipe.ts @@ -42,13 +42,6 @@ export interface CreateIngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; -} -export interface MultiPurposeLabelSummary { - name: string; - color?: string; - groupId: string; - id: string; } export interface CreateIngredientUnit { name: string; @@ -65,10 +58,12 @@ export interface CreateRecipeBulk { tags?: RecipeTag[]; } export interface RecipeCategory { + id?: number; name: string; slug: string; } export interface RecipeTag { + id?: number; name: string; slug: string; } @@ -100,8 +95,14 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; id: number; + label?: MultiPurposeLabelSummary; +} +export interface MultiPurposeLabelSummary { + name: string; + color?: string; + groupId: string; + id: string; } /** * A list of ingredient references. @@ -160,7 +161,7 @@ export interface Recipe { cookTime?: string; performTime?: string; description?: string; - recipeCategory?: RecipeTag[]; + recipeCategory?: RecipeCategory[]; tags?: RecipeTag[]; tools?: RecipeTool[]; rating?: number; @@ -179,9 +180,9 @@ export interface Recipe { comments?: RecipeCommentOut[]; } export interface RecipeTool { + id?: number; name: string; slug: string; - id?: number; onHand?: boolean; } export interface RecipeStep { @@ -281,7 +282,7 @@ export interface RecipeSummary { cookTime?: string; performTime?: string; description?: string; - recipeCategory?: RecipeTag[]; + recipeCategory?: RecipeCategory[]; tags?: RecipeTag[]; tools?: RecipeTool[]; rating?: number; diff --git a/frontend/types/api-types/user.ts b/frontend/types/api-types/user.ts index 4e53eef8..d02d7f19 100644 --- a/frontend/types/api-types/user.ts +++ b/frontend/types/api-types/user.ts @@ -121,7 +121,7 @@ export interface RecipeSummary { cookTime?: string; performTime?: string; description?: string; - recipeCategory?: RecipeTag[]; + recipeCategory?: RecipeCategory[]; tags?: RecipeTag[]; tools?: RecipeTool[]; rating?: number; @@ -130,14 +130,20 @@ export interface RecipeSummary { dateAdded?: string; dateUpdated?: string; } +export interface RecipeCategory { + id?: number; + name: string; + slug: string; +} export interface RecipeTag { + id?: number; name: string; slug: string; } export interface RecipeTool { + id?: number; name: string; slug: string; - id?: number; onHand?: boolean; } export interface RecipeIngredient { @@ -166,8 +172,8 @@ export interface IngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; id: number; + label?: MultiPurposeLabelSummary; } export interface MultiPurposeLabelSummary { name: string; @@ -179,7 +185,6 @@ export interface CreateIngredientFood { name: string; description?: string; labelId?: string; - label?: MultiPurposeLabelSummary; } export interface ResetPassword { token: string; diff --git a/frontend/utils/icons/icon-type.ts b/frontend/utils/icons/icon-type.ts index 2c90633f..b2066b26 100644 --- a/frontend/utils/icons/icon-type.ts +++ b/frontend/utils/icons/icon-type.ts @@ -3,6 +3,7 @@ export interface Icon { primary: string; // General + bolwMixOutline: string; foods: string; units: string; alert: string; diff --git a/frontend/utils/icons/icons.ts b/frontend/utils/icons/icons.ts index a4fefa5f..b950c62c 100644 --- a/frontend/utils/icons/icons.ts +++ b/frontend/utils/icons/icons.ts @@ -104,6 +104,7 @@ import { mdiRefresh, mdiArrowRightBold, mdiChevronRight, + mdiBowlMixOutline, } from "@mdi/js"; export const icons = { @@ -111,6 +112,7 @@ export const icons = { primary: mdiSilverwareVariant, // General + bolwMixOutline: mdiBowlMixOutline, foods: mdiFoodApple, units: mdiBeakerOutline, alert: mdiAlert, diff --git a/mealie/db/models/group/group.py b/mealie/db/models/group/group.py index 95f301c9..1aab61c2 100644 --- a/mealie/db/models/group/group.py +++ b/mealie/db/models/group/group.py @@ -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() diff --git a/mealie/db/models/group/mealplan.py b/mealie/db/models/group/mealplan.py index 3d739674..4e49b5ba 100644 --- a/mealie/db/models/group/mealplan.py +++ b/mealie/db/models/group/mealplan.py @@ -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): diff --git a/mealie/db/models/recipe/category.py b/mealie/db/models/recipe/category.py index b47210de..84dec5b9 100644 --- a/mealie/db/models/recipe/category.py +++ b/mealie/db/models/recipe/category.py @@ -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, diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index b1b82201..a2270752 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -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( diff --git a/mealie/db/models/recipe/tag.py b/mealie/db/models/recipe/tag.py index 90ee7232..b94f8491 100644 --- a/mealie/db/models/recipe/tag.py +++ b/mealie/db/models/recipe/tag.py @@ -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: diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py index 479d807f..8124a98e 100644 --- a/mealie/repos/repository_factory.py +++ b/mealie/repos/repository_factory.py @@ -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) diff --git a/mealie/repos/repository_meal_plan_rules.py b/mealie/repos/repository_meal_plan_rules.py new file mode 100644 index 00000000..a6f1951f --- /dev/null +++ b/mealie/repos/repository_meal_plan_rules.py @@ -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()] diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index 3fa54cd1..75f905ca 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -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) + ] diff --git a/mealie/routes/categories/categories.py b/mealie/routes/categories/categories.py index 72c4087b..3d33d2a9 100644 --- a/mealie/routes/categories/categories.py +++ b/mealie/routes/categories/categories.py @@ -12,6 +12,7 @@ router = APIRouter(prefix="/categories", tags=["Categories: CRUD"]) class CategorySummary(BaseModel): + id: int slug: str name: str diff --git a/mealie/routes/groups/__init__.py b/mealie/routes/groups/__init__.py index a61c7c78..41ddade5 100644 --- a/mealie/routes/groups/__init__.py +++ b/mealie/routes/groups/__init__.py @@ -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) diff --git a/mealie/routes/groups/controller_mealplan.py b/mealie/routes/groups/controller_mealplan.py index 1963194c..76e79a58 100644 --- a/mealie/routes/groups/controller_mealplan.py +++ b/mealie/routes/groups/controller_mealplan.py @@ -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) diff --git a/mealie/routes/groups/controller_meaplan_config.py b/mealie/routes/groups/controller_mealplan_config.py similarity index 100% rename from mealie/routes/groups/controller_meaplan_config.py rename to mealie/routes/groups/controller_mealplan_config.py diff --git a/mealie/routes/groups/controller_mealplan_rules.py b/mealie/routes/groups/controller_mealplan_rules.py new file mode 100644 index 00000000..863e04f6 --- /dev/null +++ b/mealie/routes/groups/controller_mealplan_rules.py @@ -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 diff --git a/mealie/schema/meal_plan/__init__.py b/mealie/schema/meal_plan/__init__.py index 8532fae6..56fec1c9 100644 --- a/mealie/schema/meal_plan/__init__.py +++ b/mealie/schema/meal_plan/__init__.py @@ -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 * diff --git a/mealie/schema/meal_plan/new_meal.py b/mealie/schema/meal_plan/new_meal.py index 0b1fb79d..396ac349 100644 --- a/mealie/schema/meal_plan/new_meal.py +++ b/mealie/schema/meal_plan/new_meal.py @@ -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): diff --git a/mealie/schema/meal_plan/plan_rules.py b/mealie/schema/meal_plan/plan_rules.py new file mode 100644 index 00000000..5d8528d0 --- /dev/null +++ b/mealie/schema/meal_plan/plan_rules.py @@ -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 diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 49af7acf..cd9957eb 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -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] diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index d27f449d..ef6d6500 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -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() diff --git a/mealie/services/group_services/service_group_meals.py b/mealie/services/group_services/service_group_meals.py new file mode 100644 index 00000000..6ca559e7 --- /dev/null +++ b/mealie/services/group_services/service_group_meals.py @@ -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) diff --git a/mealie/services/group_services/shopping_lists.py b/mealie/services/group_services/shopping_lists.py index cb28a68e..ceffce01 100644 --- a/mealie/services/group_services/shopping_lists.py +++ b/mealie/services/group_services/shopping_lists.py @@ -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]: diff --git a/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py b/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py new file mode 100644 index 00000000..74cda06f --- /dev/null +++ b/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py @@ -0,0 +1,127 @@ +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient +from pydantic import UUID4 + +from mealie.repos.all_repositories import AllRepositories +from mealie.schema.meal_plan.plan_rules import PlanRulesOut, PlanRulesSave +from mealie.schema.recipe.recipe import RecipeCategory +from tests import utils +from tests.utils.fixture_schemas import TestUser + + +class Routes: + base = "/api/groups/mealplans/rules" + + @staticmethod + def item(item_id: UUID4) -> str: + return f"{Routes.base}/{item_id}" + + +@pytest.fixture(scope="function") +def category(database: AllRepositories): + slug = utils.random_string(length=10) + model = database.categories.create(RecipeCategory(slug=slug, name=slug)) + + yield model + + try: + database.categories.delete(model.slug) + except Exception: + pass + + +@pytest.fixture(scope="function") +def plan_rule(database: AllRepositories, unique_user: TestUser): + schema = PlanRulesSave( + group_id=unique_user.group_id, + day="monday", + entry_type="breakfast", + categories=[], + ) + + model = database.group_meal_plan_rules.create(schema) + + yield model + + try: + database.group_meal_plan_rules.delete(model.id) + except Exception: + pass + + +def test_group_mealplan_rules_create( + api_client: TestClient, unique_user: TestUser, category: RecipeCategory, database: AllRepositories +): + payload = { + "groupId": unique_user.group_id, + "day": "monday", + "entryType": "breakfast", + "categories": [category.dict()], + } + + response = api_client.post(Routes.base, json=payload, headers=unique_user.token) + assert response.status_code == 201 + + # Validate the response data + response_data = response.json() + assert response_data["groupId"] == str(unique_user.group_id) + assert response_data["day"] == "monday" + assert response_data["entryType"] == "breakfast" + assert len(response_data["categories"]) == 1 + assert response_data["categories"][0]["slug"] == category.slug + + # Validate database entry + rule = database.group_meal_plan_rules.get_one(UUID(response_data["id"])) + + assert str(rule.group_id) == unique_user.group_id + assert rule.day == "monday" + assert rule.entry_type == "breakfast" + assert len(rule.categories) == 1 + assert rule.categories[0].slug == category.slug + + # Cleanup + database.group_meal_plan_rules.delete(rule.id) + + +def test_group_mealplan_rules_read(api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut): + response = api_client.get(Routes.item(plan_rule.id), headers=unique_user.token) + assert response.status_code == 200 + + # Validate the response data + response_data = response.json() + assert response_data["id"] == str(plan_rule.id) + assert response_data["groupId"] == str(unique_user.group_id) + assert response_data["day"] == "monday" + assert response_data["entryType"] == "breakfast" + assert len(response_data["categories"]) == 0 + + +def test_group_mealplan_rules_update(api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut): + payload = { + "groupId": unique_user.group_id, + "day": "tuesday", + "entryType": "lunch", + } + + response = api_client.put(Routes.item(plan_rule.id), json=payload, headers=unique_user.token) + assert response.status_code == 200 + + # Validate the response data + response_data = response.json() + assert response_data["id"] == str(plan_rule.id) + assert response_data["groupId"] == str(unique_user.group_id) + assert response_data["day"] == "tuesday" + assert response_data["entryType"] == "lunch" + assert len(response_data["categories"]) == 0 + + +def test_group_mealplan_rules_delete( + api_client: TestClient, unique_user: TestUser, plan_rule: PlanRulesOut, database: AllRepositories +): + response = api_client.delete(Routes.item(plan_rule.id), headers=unique_user.token) + assert response.status_code == 200 + + # Validate no entry in database + assert database.group_meal_plan_rules.get_one(plan_rule.id) is None diff --git a/tests/unit_tests/repository_tests/test_recipe_repository.py b/tests/unit_tests/repository_tests/test_recipe_repository.py new file mode 100644 index 00000000..daae1958 --- /dev/null +++ b/tests/unit_tests/repository_tests/test_recipe_repository.py @@ -0,0 +1,115 @@ +from mealie.repos.repository_factory import AllRepositories +from mealie.repos.repository_recipes import RepositoryRecipes +from mealie.schema.recipe.recipe import Recipe, RecipeCategory +from tests.utils.factories import random_string +from tests.utils.fixture_schemas import TestUser + + +def test_recipe_repo_get_by_categories_basic(database: AllRepositories, unique_user: TestUser): + # Bootstrap the database with categories + slug1, slug2, slug3 = [random_string(10) for _ in range(3)] + + categories = [ + RecipeCategory(name=slug1, slug=slug1), + RecipeCategory(name=slug2, slug=slug2), + RecipeCategory(name=slug3, slug=slug3), + ] + + created_categories = [] + + for category in categories: + model = database.categories.create(category) + created_categories.append(model) + + # Bootstrap the database with recipes + recipes = [] + + for idx in range(15): + if idx % 3 == 0: + category = created_categories[0] + elif idx % 3 == 1: + category = created_categories[1] + else: + category = created_categories[2] + + recipes.append( + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(), + recipe_category=[category], + ), + ) + + created_recipes = [] + + for recipe in recipes: + models = database.recipes.create(recipe) + created_recipes.append(models) + + # Get all recipes by category + + for category in created_categories: + repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id) + recipes = repo.get_by_categories([category]) + + assert len(recipes) == 5 + + for recipe in recipes: + found_cat = recipe.recipe_category[0] + + assert found_cat.name == category.name + assert found_cat.slug == category.slug + assert found_cat.id == category.id + + +def test_recipe_repo_get_by_categories_multi(database: AllRepositories, unique_user: TestUser): + slug1, slug2 = [random_string(10) for _ in range(2)] + + categories = [ + RecipeCategory(name=slug1, slug=slug1), + RecipeCategory(name=slug2, slug=slug2), + ] + + created_categories = [] + known_category_ids = [] + + for category in categories: + model = database.categories.create(category) + created_categories.append(model) + known_category_ids.append(model.id) + + # Bootstrap the database with recipes + recipes = [] + + for _ in range(10): + recipes.append( + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(), + recipe_category=created_categories, + ), + ) + + # Insert Non-Category Recipes + recipes.append( + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(), + ) + ) + + for recipe in recipes: + database.recipes.create(recipe) + + # Get all recipes by both categories + repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id) + by_category = repo.get_by_categories(created_categories) + + assert len(by_category) == 10 + + for recipe in by_category: + for category in recipe.recipe_category: + assert category.id in known_category_ids diff --git a/tests/unit_tests/schema_tests/test_meal_plan.py b/tests/unit_tests/schema_tests/test_meal_plan.py new file mode 100644 index 00000000..5e7d6d7c --- /dev/null +++ b/tests/unit_tests/schema_tests/test_meal_plan.py @@ -0,0 +1,20 @@ +from datetime import datetime + +import pytest + +from mealie.schema.meal_plan.plan_rules import PlanRulesDay + +test_cases = [ + (datetime(2022, 2, 7), PlanRulesDay.monday), + (datetime(2022, 2, 8), PlanRulesDay.tuesday), + (datetime(2022, 2, 9), PlanRulesDay.wednesday), + (datetime(2022, 2, 10), PlanRulesDay.thursday), + (datetime(2022, 2, 11), PlanRulesDay.friday), + (datetime(2022, 2, 12), PlanRulesDay.saturday), + (datetime(2022, 2, 13), PlanRulesDay.sunday), +] + + +@pytest.mark.parametrize("date, expected", test_cases) +def test_date_obj_to_enum(date: datetime, expected: PlanRulesDay): + assert PlanRulesDay.from_date(date) == expected