diff --git a/docs/docs/changelog/v1.0.0.md b/docs/docs/changelog/v1.0.0.md index ed6f9e1e5fdf..8e6a8f952e89 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 000000000000..f4ac4e940ef8 --- /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 4496eddc3331..852577600c9f 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 32ccea777432..8dfc7ad8e511 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 000000000000..04f3efba034d --- /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 b7e69c445357..9162ef2fdbac 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 a7891ca1951d..c90f036d0fe3 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 f7a33c583ca6..4ccc85d9fa37 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 9efd8528e98c..4217a74d24a5 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 ae226ee089c6..e00d11d26a8d 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 54e35b64a190..bb05c2b482b2 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 7be8348eaabf..a2cf12e6b621 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 4a117e1851a6..6537707cf883 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 6d77c26edc47..f384a78e7ec8 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 4e53eef864c1..d02d7f191067 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 2c90633fcbc7..b2066b26b2e0 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 a4fefa5f14c1..b950c62cb2dc 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 95f301c9ac7a..1aab61c2e76f 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 3d7396744d0e..4e49b5ba9b52 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 b47210de35c2..84dec5b90e30 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 b1b82201850f..a22707527ff8 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 90ee72327363..b94f84918894 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 479d807f6b7d..8124a98eabe3 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 000000000000..a6f1951fac17 --- /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 3fa54cd19895..75f905ca3304 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 72c4087bbf67..3d33d2a9dbb8 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 a61c7c78a81d..41ddade55b08 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 1963194c3d0b..76e79a58d5d9 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 000000000000..863e04f6b8ed --- /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 8532fae6cf2f..56fec1c9885c 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 0b1fb79dbd20..396ac3498851 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 000000000000..5d8528d07e60 --- /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 49af7acf7eb8..cd9957ebd5ad 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 d27f449db104..ef6d6500edee 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 000000000000..6ca559e7ad46 --- /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 cb28a68ebb44..ceffce0151fb 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 000000000000..74cda06ff8eb --- /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 000000000000..daae1958819a --- /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 000000000000..5e7d6d7cc4e2 --- /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