mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-23 17:02:55 -04:00
feat: Add Household Filter to Meal Plan Rules (#4231)
This commit is contained in:
parent
38502e82d4
commit
4712994242
@ -0,0 +1,53 @@
|
||||
"""add households filter to meal plans
|
||||
|
||||
Revision ID: 1fe4bd37ccc8
|
||||
Revises: be568e39ffdf
|
||||
Create Date: 2024-09-18 14:52:55.831540
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "1fe4bd37ccc8"
|
||||
down_revision: str | None = "be568e39ffdf"
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"plan_rules_to_households",
|
||||
sa.Column("group_plan_rule_id", mealie.db.migration_types.GUID(), nullable=True),
|
||||
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["group_plan_rule_id"],
|
||||
["group_meal_plan_rules.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["household_id"],
|
||||
["households.id"],
|
||||
),
|
||||
sa.UniqueConstraint("group_plan_rule_id", "household_id", name="group_plan_rule_id_household_id_key"),
|
||||
)
|
||||
with op.batch_alter_table("plan_rules_to_households", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_plan_rules_to_households_group_plan_rule_id"), ["group_plan_rule_id"], unique=False
|
||||
)
|
||||
batch_op.create_index(batch_op.f("ix_plan_rules_to_households_household_id"), ["household_id"], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("plan_rules_to_households", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_plan_rules_to_households_household_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_plan_rules_to_households_group_plan_rule_id"))
|
||||
|
||||
op.drop_table("plan_rules_to_households")
|
||||
# ### end Alembic commands ###
|
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<v-select
|
||||
v-model="selected"
|
||||
:items="households"
|
||||
:label="label"
|
||||
:hint="description"
|
||||
:persistent-hint="!!description"
|
||||
item-text="name"
|
||||
:multiple="multiselect"
|
||||
:prepend-inner-icon="$globals.icons.household"
|
||||
return-object
|
||||
>
|
||||
<template #selection="data">
|
||||
<v-chip
|
||||
:key="data.index"
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
small
|
||||
close
|
||||
label
|
||||
color="accent"
|
||||
dark
|
||||
@click:close="removeByIndex(data.index)"
|
||||
>
|
||||
{{ data.item.name || data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, useContext } from "@nuxtjs/composition-api";
|
||||
import { useHouseholdStore } from "~/composables/store/use-household-store";
|
||||
|
||||
interface HouseholdLike {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => HouseholdLike[],
|
||||
required: true,
|
||||
},
|
||||
multiselect: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const selected = computed({
|
||||
get: () => props.value,
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (selected.value === undefined) {
|
||||
selected.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
const { i18n } = useContext();
|
||||
const label = computed(
|
||||
() => props.multiselect ? i18n.tc("household.households") : i18n.tc("household.household")
|
||||
);
|
||||
|
||||
const { store: households } = useHouseholdStore();
|
||||
function removeByIndex(index: number) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
||||
selected.value = [...newSelected];
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
label,
|
||||
households,
|
||||
removeByIndex,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
@ -5,8 +5,15 @@
|
||||
<v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" :label="$t('meal-plan.meal-type')"></v-select>
|
||||
</div>
|
||||
|
||||
<RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" />
|
||||
<RecipeOrganizerSelector v-model="inputTags" selector-type="tags" />
|
||||
<div class="mb-5">
|
||||
<RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" />
|
||||
<RecipeOrganizerSelector v-model="inputTags" selector-type="tags" />
|
||||
<GroupHouseholdSelector
|
||||
v-model="inputHouseholds"
|
||||
multiselect
|
||||
:description="$tc('meal-plan.mealplan-households-description')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TODO: proper pluralization of inputDay -->
|
||||
{{ $t('meal-plan.this-rule-will-apply', {
|
||||
@ -18,11 +25,13 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||
import GroupHouseholdSelector from "~/components/Domain/Household/GroupHouseholdSelector.vue";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { RecipeTag, RecipeCategory } from "~/lib/api/types/recipe";
|
||||
import { PlanCategory, PlanHousehold, PlanTag } from "~/lib/api/types/meal-plan";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
GroupHouseholdSelector,
|
||||
RecipeOrganizerSelector,
|
||||
},
|
||||
props: {
|
||||
@ -35,11 +44,15 @@ export default defineComponent({
|
||||
default: "unset",
|
||||
},
|
||||
categories: {
|
||||
type: Array as () => RecipeCategory[],
|
||||
type: Array as () => PlanCategory[],
|
||||
default: () => [],
|
||||
},
|
||||
tags: {
|
||||
type: Array as () => RecipeTag[],
|
||||
type: Array as () => PlanTag[],
|
||||
default: () => [],
|
||||
},
|
||||
households: {
|
||||
type: Array as () => PlanHousehold[],
|
||||
default: () => [],
|
||||
},
|
||||
showHelp: {
|
||||
@ -105,6 +118,15 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const inputHouseholds = computed({
|
||||
get: () => {
|
||||
return props.households;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:households", val);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
MEAL_TYPE_OPTIONS,
|
||||
MEAL_DAY_OPTIONS,
|
||||
@ -112,6 +134,7 @@ export default defineComponent({
|
||||
inputEntryType,
|
||||
inputCategories,
|
||||
inputTags,
|
||||
inputHouseholds,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -315,6 +315,10 @@
|
||||
"mealplan-settings": "Mealplan Settings",
|
||||
"mealplan-update-failed": "Mealplan update failed",
|
||||
"mealplan-updated": "Mealplan Updated",
|
||||
"mealplan-households-description": "If no household is selected, recipes can be added from any household",
|
||||
"any-category": "Any Category",
|
||||
"any-tag": "Any Tag",
|
||||
"any-household": "Any Household",
|
||||
"no-meal-plan-defined-yet": "No meal plan defined yet",
|
||||
"no-meal-planned-for-today": "No meal planned for today",
|
||||
"numberOfDays-hint": "Number of days on page load",
|
||||
|
@ -31,13 +31,24 @@ export interface ListItem {
|
||||
quantity?: number;
|
||||
checked?: boolean;
|
||||
}
|
||||
export interface PlanCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface PlanHousehold {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface PlanRulesCreate {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
categories?: Category[];
|
||||
tags?: Tag[];
|
||||
categories?: PlanCategory[];
|
||||
tags?: PlanTag[];
|
||||
households?: PlanHousehold[];
|
||||
}
|
||||
export interface Tag {
|
||||
export interface PlanTag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
@ -45,8 +56,9 @@ export interface Tag {
|
||||
export interface PlanRulesOut {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
categories?: Category[];
|
||||
tags?: Tag[];
|
||||
categories?: PlanCategory[];
|
||||
tags?: PlanTag[];
|
||||
households?: PlanHousehold[];
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
id: string;
|
||||
@ -54,8 +66,9 @@ export interface PlanRulesOut {
|
||||
export interface PlanRulesSave {
|
||||
day?: PlanRulesDay & string;
|
||||
entryType?: PlanRulesType & string;
|
||||
categories?: Category[];
|
||||
tags?: Tag[];
|
||||
categories?: PlanCategory[];
|
||||
tags?: PlanTag[];
|
||||
households?: PlanHousehold[];
|
||||
groupId: string;
|
||||
householdId: string;
|
||||
}
|
||||
|
@ -20,6 +20,7 @@
|
||||
:entry-type.sync="createData.entryType"
|
||||
:categories.sync="createData.categories"
|
||||
:tags.sync="createData.tags"
|
||||
:households.sync="createData.households"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
@ -58,12 +59,58 @@
|
||||
<template v-if="!editState[rule.id]">
|
||||
<div v-if="rule.categories">
|
||||
<h4 class="py-1">{{ $t("category.categories") }}:</h4>
|
||||
<RecipeChips :items="rule.categories" small />
|
||||
<RecipeChips v-if="rule.categories.length" :items="rule.categories" small class="pb-3" />
|
||||
<v-card-text
|
||||
v-else
|
||||
label
|
||||
class="ma-0 px-0 pt-0 pb-3"
|
||||
text-color="accent"
|
||||
small
|
||||
dark
|
||||
>
|
||||
{{ $tc("meal-plan.any-category") }}
|
||||
</v-card-text>
|
||||
</div>
|
||||
|
||||
<div v-if="rule.tags">
|
||||
<h4 class="py-1">{{ $t("tag.tags") }}:</h4>
|
||||
<RecipeChips :items="rule.tags" url-prefix="tags" small />
|
||||
<RecipeChips v-if="rule.tags.length" :items="rule.tags" url-prefix="tags" small class="pb-3" />
|
||||
<v-card-text
|
||||
v-else
|
||||
label
|
||||
class="ma-0 px-0 pt-0 pb-3"
|
||||
text-color="accent"
|
||||
small
|
||||
dark
|
||||
>
|
||||
{{ $tc("meal-plan.any-tag") }}
|
||||
</v-card-text>
|
||||
</div>
|
||||
<div v-if="rule.households">
|
||||
<h4 class="py-1">{{ $t("household.households") }}:</h4>
|
||||
<div v-if="rule.households.length">
|
||||
<v-chip
|
||||
v-for="household in rule.households"
|
||||
:key="household.id"
|
||||
label
|
||||
class="ma-1"
|
||||
color="accent"
|
||||
small
|
||||
dark
|
||||
>
|
||||
{{ household.name }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-card-text
|
||||
v-else
|
||||
label
|
||||
class="ma-0 px-0 pt-0 pb-3"
|
||||
text-color="accent"
|
||||
small
|
||||
dark
|
||||
>
|
||||
{{ $tc("meal-plan.any-household") }}
|
||||
</v-card-text>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@ -72,6 +119,7 @@
|
||||
:entry-type.sync="allRules[idx].entryType"
|
||||
:categories.sync="allRules[idx].categories"
|
||||
:tags.sync="allRules[idx].tags"
|
||||
:households.sync="allRules[idx].households"
|
||||
/>
|
||||
<div class="d-flex justify-end">
|
||||
<BaseButton update @click="updateRule(rule)" />
|
||||
@ -138,6 +186,7 @@ export default defineComponent({
|
||||
day: "unset",
|
||||
categories: [],
|
||||
tags: [],
|
||||
households: [],
|
||||
});
|
||||
|
||||
async function createRule() {
|
||||
@ -149,6 +198,7 @@ export default defineComponent({
|
||||
day: "unset",
|
||||
categories: [],
|
||||
tags: [],
|
||||
households: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, String, orm
|
||||
from sqlalchemy import Column, Date, ForeignKey, String, Table, UniqueConstraint, orm
|
||||
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@ -18,6 +18,14 @@ if TYPE_CHECKING:
|
||||
from ..users import User
|
||||
from .household import Household
|
||||
|
||||
plan_rules_to_households = Table(
|
||||
"plan_rules_to_households",
|
||||
SqlAlchemyBase.metadata,
|
||||
Column("group_plan_rule_id", GUID, ForeignKey("group_meal_plan_rules.id"), index=True),
|
||||
Column("household_id", GUID, ForeignKey("households.id"), index=True),
|
||||
UniqueConstraint("group_plan_rule_id", "household_id", name="group_plan_rule_id_household_id_key"),
|
||||
)
|
||||
|
||||
|
||||
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
|
||||
__tablename__ = "group_meal_plan_rules"
|
||||
@ -33,8 +41,10 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
|
||||
String, nullable=False, default=""
|
||||
) # "breakfast", "lunch", "dinner", "side"
|
||||
|
||||
# Filters
|
||||
categories: Mapped[list[Category]] = orm.relationship(Category, secondary=plan_rules_to_categories)
|
||||
tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags)
|
||||
households: Mapped[list["Household"]] = orm.relationship("Household", secondary=plan_rules_to_households)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import random
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
from math import ceil
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
@ -62,6 +63,9 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
def household_id(self) -> UUID4 | None:
|
||||
return self._household_id
|
||||
|
||||
def _random_seed(self) -> str:
|
||||
return str(datetime.now(tz=timezone.utc))
|
||||
|
||||
def _log_exception(self, e: Exception) -> None:
|
||||
self.logger.error(f"Error processing query for Repo model={self.model.__name__} schema={self.schema.__name__}")
|
||||
self.logger.error(e)
|
||||
@ -409,6 +413,9 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
# this solution is db-independent & stable to paging
|
||||
temp_query = query.with_only_columns(self.model.id)
|
||||
allids = self.session.execute(temp_query).scalars().all() # fast because id is indexed
|
||||
if not allids:
|
||||
return query
|
||||
|
||||
order = list(range(len(allids)))
|
||||
random.seed(pagination.pagination_seed)
|
||||
random.shuffle(order)
|
||||
|
@ -23,7 +23,6 @@ from mealie.schema.recipe.recipe import (
|
||||
RecipeCategory,
|
||||
RecipePagination,
|
||||
RecipeSummary,
|
||||
RecipeTag,
|
||||
RecipeTool,
|
||||
)
|
||||
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
|
||||
@ -99,6 +98,9 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
||||
ids.append(i_as_uuid)
|
||||
except ValueError:
|
||||
slugs.append(i)
|
||||
|
||||
if not slugs:
|
||||
return ids
|
||||
additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all()
|
||||
return ids + additional_ids
|
||||
|
||||
@ -308,27 +310,6 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
||||
stmt = sa.select(RecipeModel).filter(*fltr)
|
||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
def get_random_by_categories_and_tags(
|
||||
self, categories: list[RecipeCategory], tags: list[RecipeTag]
|
||||
) -> list[Recipe]:
|
||||
"""
|
||||
get_random_by_categories returns a single random Recipe that contains every category provided
|
||||
in the list. This uses a function built in to Postgres and SQLite to get a random row limited
|
||||
to 1 entry.
|
||||
"""
|
||||
|
||||
# See Also:
|
||||
# - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy
|
||||
|
||||
filters = self._build_recipe_filter(extract_uuids(categories), extract_uuids(tags)) # type: ignore
|
||||
stmt = (
|
||||
sa.select(RecipeModel)
|
||||
.filter(sa.and_(*filters))
|
||||
.order_by(sa.func.random())
|
||||
.limit(1) # Postgres and SQLite specific
|
||||
)
|
||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
def get_random(self, limit=1) -> list[Recipe]:
|
||||
stmt = sa.select(RecipeModel).order_by(sa.func.random()).limit(limit) # Postgres and SQLite specific
|
||||
if self.group_id:
|
||||
|
@ -4,14 +4,15 @@ from functools import cached_property
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from mealie.core.exceptions import mealie_registered_exceptions
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.repos.repository_meals import RepositoryMeals
|
||||
from mealie.routes._base import controller
|
||||
from mealie.routes._base.base_controllers import BaseCrudController
|
||||
from mealie.routes._base.mixins import HttpRepo
|
||||
from mealie.schema import mapper
|
||||
from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry
|
||||
from mealie.schema.meal_plan.new_meal import CreateRandomEntry, PlanEntryPagination
|
||||
from mealie.schema.meal_plan.plan_rules import PlanRulesDay
|
||||
from mealie.schema.meal_plan.new_meal import CreateRandomEntry, PlanEntryPagination, PlanEntryType
|
||||
from mealie.schema.meal_plan.plan_rules import PlanCategory, PlanHousehold, PlanRulesDay, PlanTag
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.response.responses import ErrorResponse
|
||||
@ -40,6 +41,47 @@ class GroupMealplanController(BaseCrudController):
|
||||
self.registered_exceptions,
|
||||
)
|
||||
|
||||
def _get_random_recipes_from_mealplan(
|
||||
self, plan_date: date, entry_type: PlanEntryType, limit: int = 1
|
||||
) -> list[Recipe]:
|
||||
"""
|
||||
Gets rules for a mealplan and returns a list of random recipes based on the rules.
|
||||
May return zero recipes if no recipes match the filter criteria.
|
||||
|
||||
Recipes from all households are included unless the rules specify a household filter.
|
||||
"""
|
||||
|
||||
rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value)
|
||||
cross_household_recipes = get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
|
||||
|
||||
tags: list[PlanTag] = []
|
||||
categories: list[PlanCategory] = []
|
||||
households: list[PlanHousehold] = []
|
||||
for rule in rules:
|
||||
if rule.tags:
|
||||
tags.extend(rule.tags)
|
||||
if rule.categories:
|
||||
categories.extend(rule.categories)
|
||||
if rule.households:
|
||||
households.extend(rule.households)
|
||||
|
||||
if not (tags or categories or households):
|
||||
return cross_household_recipes.get_random(limit=limit)
|
||||
|
||||
category_ids = [category.id for category in categories] or None
|
||||
tag_ids = [tag.id for tag in tags] or None
|
||||
household_ids = [household.id for household in households] or None
|
||||
|
||||
recipes_data = cross_household_recipes.page_all(
|
||||
pagination=PaginationQuery(
|
||||
page=1, per_page=limit, order_by="random", pagination_seed=self.repo._random_seed()
|
||||
),
|
||||
categories=category_ids,
|
||||
tags=tag_ids,
|
||||
households=household_ids,
|
||||
)
|
||||
return recipes_data.items
|
||||
|
||||
@router.get("/today")
|
||||
def get_todays_meals(self):
|
||||
return self.repo.get_today()
|
||||
@ -47,50 +89,29 @@ class GroupMealplanController(BaseCrudController):
|
||||
@router.post("/random", response_model=ReadPlanEntry)
|
||||
def create_random_meal(self, data: CreateRandomEntry):
|
||||
"""
|
||||
create_random_meal is a route that provides the randomized functionality for mealplaners.
|
||||
It operates by following the rules setout in the Groups mealplan settings. If not settings
|
||||
are set, it will default return any random meal.
|
||||
`create_random_meal` is a route that provides the randomized functionality for mealplaners.
|
||||
It operates by following the rules set out in the household's mealplan settings. If no settings
|
||||
are set, it will return any random meal.
|
||||
|
||||
Refer to the mealplan settings routes for more information on how rules can be applied
|
||||
to the random meal selector.
|
||||
"""
|
||||
# Get relevant group rules
|
||||
rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(data.date), data.entry_type.value)
|
||||
|
||||
recipe_repo = self.repos.recipes
|
||||
random_recipes: list[Recipe] = []
|
||||
|
||||
if not rules: # If no rules are set, return any random recipe from the group
|
||||
random_recipes = recipe_repo.get_random()
|
||||
else: # otherwise construct a query based on the rules
|
||||
tags = []
|
||||
categories = []
|
||||
for rule in rules:
|
||||
if rule.tags:
|
||||
tags.extend(rule.tags)
|
||||
if rule.categories:
|
||||
categories.extend(rule.categories)
|
||||
|
||||
if tags or categories:
|
||||
random_recipes = self.repos.recipes.get_random_by_categories_and_tags(categories, tags)
|
||||
else:
|
||||
random_recipes = recipe_repo.get_random()
|
||||
|
||||
try:
|
||||
recipe = random_recipes[0]
|
||||
return self.mixins.create_one(
|
||||
SavePlanEntry(
|
||||
date=data.date,
|
||||
entry_type=data.entry_type,
|
||||
recipe_id=recipe.id,
|
||||
group_id=self.group_id,
|
||||
user_id=self.user.id,
|
||||
)
|
||||
)
|
||||
except IndexError as e:
|
||||
random_recipes = self._get_random_recipes_from_mealplan(data.date, data.entry_type)
|
||||
if not random_recipes:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=ErrorResponse.respond(message=self.t("mealplan.no-recipes-match-your-rules"))
|
||||
) from e
|
||||
)
|
||||
|
||||
recipe = random_recipes[0]
|
||||
return self.mixins.create_one(
|
||||
SavePlanEntry(
|
||||
date=data.date,
|
||||
entry_type=data.entry_type,
|
||||
recipe_id=recipe.id,
|
||||
group_id=self.group_id,
|
||||
user_id=self.user.id,
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("", response_model=PlanEntryPagination)
|
||||
def get_all(
|
||||
|
@ -9,14 +9,16 @@ from .new_meal import (
|
||||
UpdatePlanEntry,
|
||||
)
|
||||
from .plan_rules import (
|
||||
Category,
|
||||
BasePlanRuleFilter,
|
||||
PlanCategory,
|
||||
PlanHousehold,
|
||||
PlanRulesCreate,
|
||||
PlanRulesDay,
|
||||
PlanRulesOut,
|
||||
PlanRulesPagination,
|
||||
PlanRulesSave,
|
||||
PlanRulesType,
|
||||
Tag,
|
||||
PlanTag,
|
||||
)
|
||||
from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut
|
||||
|
||||
@ -31,12 +33,14 @@ __all__ = [
|
||||
"ReadPlanEntry",
|
||||
"SavePlanEntry",
|
||||
"UpdatePlanEntry",
|
||||
"Category",
|
||||
"BasePlanRuleFilter",
|
||||
"PlanCategory",
|
||||
"PlanHousehold",
|
||||
"PlanRulesCreate",
|
||||
"PlanRulesDay",
|
||||
"PlanRulesOut",
|
||||
"PlanRulesPagination",
|
||||
"PlanRulesSave",
|
||||
"PlanRulesType",
|
||||
"Tag",
|
||||
"PlanTag",
|
||||
]
|
||||
|
@ -5,19 +5,27 @@ from pydantic import UUID4, ConfigDict
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
|
||||
from mealie.db.models.household import GroupMealPlanRules
|
||||
from mealie.db.models.household import GroupMealPlanRules, Household
|
||||
from mealie.db.models.recipe import Category, Tag
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
|
||||
|
||||
class Category(MealieModel):
|
||||
class BasePlanRuleFilter(MealieModel):
|
||||
id: UUID4
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
class PlanCategory(BasePlanRuleFilter):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class Tag(Category):
|
||||
class PlanTag(BasePlanRuleFilter):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PlanHousehold(BasePlanRuleFilter):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@ -51,8 +59,9 @@ class PlanRulesType(str, Enum):
|
||||
class PlanRulesCreate(MealieModel):
|
||||
day: PlanRulesDay = PlanRulesDay.unset
|
||||
entry_type: PlanRulesType = PlanRulesType.unset
|
||||
categories: list[Category] = []
|
||||
tags: list[Tag] = []
|
||||
categories: list[PlanCategory] = []
|
||||
tags: list[PlanTag] = []
|
||||
households: list[PlanHousehold] = []
|
||||
|
||||
|
||||
class PlanRulesSave(PlanRulesCreate):
|
||||
@ -66,7 +75,23 @@ class PlanRulesOut(PlanRulesSave):
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [joinedload(GroupMealPlanRules.categories), joinedload(GroupMealPlanRules.tags)]
|
||||
return [
|
||||
joinedload(GroupMealPlanRules.categories).load_only(
|
||||
Category.id,
|
||||
Category.name,
|
||||
Category.slug,
|
||||
),
|
||||
joinedload(GroupMealPlanRules.tags).load_only(
|
||||
Tag.id,
|
||||
Tag.name,
|
||||
Tag.slug,
|
||||
),
|
||||
joinedload(GroupMealPlanRules.households).load_only(
|
||||
Household.id,
|
||||
Household.name,
|
||||
Household.slug,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class PlanRulesPagination(PaginationBase):
|
||||
|
@ -1,8 +1,14 @@
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from mealie.schema.household.household import HouseholdSummary
|
||||
from mealie.schema.meal_plan.new_meal import CreatePlanEntry
|
||||
from mealie.schema.meal_plan.plan_rules import PlanRulesDay, PlanRulesOut, PlanRulesSave, PlanRulesType
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagOut, TagSave
|
||||
from tests.utils import api_routes
|
||||
from tests.utils.factories import random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
@ -14,6 +20,39 @@ def route_all_slice(page: int, perPage: int, start_date: str, end_date: str):
|
||||
)
|
||||
|
||||
|
||||
def create_recipe(unique_user: TestUser, tags: list[TagOut] | None = None, categories: list[CategoryOut] | None = None):
|
||||
return unique_user.repos.recipes.create(
|
||||
Recipe(
|
||||
user_id=unique_user.user_id,
|
||||
group_id=UUID(unique_user.group_id),
|
||||
name=random_string(),
|
||||
tags=tags or [],
|
||||
recipe_category=categories or [],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_rule(
|
||||
unique_user: TestUser,
|
||||
day: PlanRulesDay,
|
||||
entry_type: PlanRulesType,
|
||||
tags: list[TagOut] | None = None,
|
||||
categories: list[CategoryOut] | None = None,
|
||||
households: list[HouseholdSummary] | None = None,
|
||||
):
|
||||
return unique_user.repos.group_meal_plan_rules.create(
|
||||
PlanRulesSave(
|
||||
group_id=UUID(unique_user.group_id),
|
||||
household_id=UUID(unique_user.household_id),
|
||||
day=day,
|
||||
entry_type=entry_type,
|
||||
tags=tags or [],
|
||||
categories=categories or [],
|
||||
households=households or [],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_create_mealplan_no_recipe(api_client: TestClient, unique_user: TestUser):
|
||||
title = random_string(length=25)
|
||||
text = random_string(length=25)
|
||||
@ -167,3 +206,128 @@ def test_get_mealplan_today(api_client: TestClient, unique_user: TestUser):
|
||||
|
||||
for meal_plan in response_json:
|
||||
assert meal_plan["date"] == datetime.now(timezone.utc).date().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def test_get_mealplan_with_rules_categories_and_tags_filter(api_client: TestClient, unique_user: TestUser):
|
||||
tags = [
|
||||
unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id)) for _ in range(4)
|
||||
]
|
||||
categories = [
|
||||
unique_user.repos.categories.create(CategorySave(name=random_string(), group_id=unique_user.group_id))
|
||||
for _ in range(4)
|
||||
]
|
||||
[
|
||||
create_recipe(unique_user, tags=[tag], categories=[category])
|
||||
for tag, category in zip(tags, categories, strict=True)
|
||||
]
|
||||
[create_recipe(unique_user) for _ in range(5)]
|
||||
|
||||
i = random.randint(0, 3)
|
||||
tag = tags[i]
|
||||
category = categories[i]
|
||||
rule = create_rule(
|
||||
unique_user,
|
||||
day=PlanRulesDay.saturday,
|
||||
entry_type=PlanRulesType.breakfast,
|
||||
tags=[tag],
|
||||
categories=[category],
|
||||
)
|
||||
|
||||
try:
|
||||
payload = {"date": "2023-02-25", "entryType": "breakfast"}
|
||||
response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
recipe_data = response.json()["recipe"]
|
||||
assert recipe_data["tags"][0]["name"] == tag.name
|
||||
assert recipe_data["recipeCategory"][0]["name"] == category.name
|
||||
finally:
|
||||
unique_user.repos.group_meal_plan_rules.delete(rule.id)
|
||||
|
||||
|
||||
def test_get_mealplan_with_rules_date_and_type_filter(api_client: TestClient, unique_user: TestUser):
|
||||
tags = [
|
||||
unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id)) for _ in range(4)
|
||||
]
|
||||
recipes = [create_recipe(unique_user, tags=[tag]) for tag in tags]
|
||||
[create_recipe(unique_user) for _ in range(5)]
|
||||
|
||||
rules: list[PlanRulesOut] = []
|
||||
rules.append(
|
||||
create_rule(unique_user, day=PlanRulesDay.saturday, entry_type=PlanRulesType.breakfast, tags=[tags[0]])
|
||||
)
|
||||
rules.append(create_rule(unique_user, day=PlanRulesDay.saturday, entry_type=PlanRulesType.dinner, tags=[tags[1]]))
|
||||
rules.append(create_rule(unique_user, day=PlanRulesDay.sunday, entry_type=PlanRulesType.breakfast, tags=[tags[2]]))
|
||||
rules.append(create_rule(unique_user, day=PlanRulesDay.sunday, entry_type=PlanRulesType.dinner, tags=[tags[3]]))
|
||||
|
||||
try:
|
||||
payload = {"date": "2023-02-25", "entryType": "breakfast"}
|
||||
response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["recipe"]["slug"] == recipes[0].slug
|
||||
finally:
|
||||
for rule in rules:
|
||||
unique_user.repos.group_meal_plan_rules.delete(rule.id)
|
||||
|
||||
|
||||
def test_get_mealplan_with_rules_includes_other_households(
|
||||
api_client: TestClient, unique_user: TestUser, h2_user: TestUser
|
||||
):
|
||||
tag = h2_user.repos.tags.create(TagSave(name=random_string(), group_id=h2_user.group_id))
|
||||
recipe = create_recipe(h2_user, tags=[tag])
|
||||
rule = create_rule(unique_user, day=PlanRulesDay.saturday, entry_type=PlanRulesType.breakfast, tags=[tag])
|
||||
|
||||
try:
|
||||
payload = {"date": "2023-02-25", "entryType": "breakfast"}
|
||||
response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["recipe"]["slug"] == recipe.slug
|
||||
finally:
|
||||
unique_user.repos.group_meal_plan_rules.delete(rule.id)
|
||||
|
||||
|
||||
def test_get_mealplan_with_rules_households_filter(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
|
||||
tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
|
||||
recipe = create_recipe(unique_user, tags=[tag])
|
||||
[create_recipe(h2_user, tags=[tag]) for _ in range(10)]
|
||||
|
||||
household = unique_user.repos.households.get_by_slug_or_id(unique_user.household_id)
|
||||
assert household
|
||||
|
||||
rule = create_rule(
|
||||
unique_user, day=PlanRulesDay.saturday, entry_type=PlanRulesType.breakfast, tags=[tag], households=[household]
|
||||
)
|
||||
|
||||
try:
|
||||
payload = {"date": "2023-02-25", "entryType": "breakfast"}
|
||||
response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["recipe"]["slug"] == recipe.slug
|
||||
finally:
|
||||
unique_user.repos.group_meal_plan_rules.delete(rule.id)
|
||||
|
||||
|
||||
def test_get_mealplan_with_rules_households_filter_includes_any_households(
|
||||
api_client: TestClient, unique_user: TestUser, h2_user: TestUser
|
||||
):
|
||||
tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
|
||||
recipe = create_recipe(h2_user, tags=[tag])
|
||||
|
||||
household = unique_user.repos.households.get_by_slug_or_id(unique_user.household_id)
|
||||
assert household
|
||||
h2_household = unique_user.repos.households.get_by_slug_or_id(h2_user.household_id)
|
||||
assert h2_household
|
||||
rule = create_rule(
|
||||
unique_user,
|
||||
day=PlanRulesDay.saturday,
|
||||
entry_type=PlanRulesType.breakfast,
|
||||
tags=[tag],
|
||||
households=[household, h2_household],
|
||||
)
|
||||
|
||||
try:
|
||||
payload = {"date": "2023-02-25", "entryType": "breakfast"}
|
||||
response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["recipe"]["slug"] == recipe.slug
|
||||
finally:
|
||||
unique_user.repos.group_meal_plan_rules.delete(rule.id)
|
||||
|
Loading…
x
Reference in New Issue
Block a user