feat: Move "on hand" and "last made" to household (#4616)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson
2025-01-13 10:19:49 -06:00
committed by GitHub
parent e565b919df
commit e9892aba89
53 changed files with 1618 additions and 400 deletions

View File

@@ -46,6 +46,11 @@ from .household import (
HouseholdCreate,
HouseholdInDB,
HouseholdPagination,
HouseholdRecipeBase,
HouseholdRecipeCreate,
HouseholdRecipeOut,
HouseholdRecipeSummary,
HouseholdRecipeUpdate,
HouseholdSave,
HouseholdSummary,
HouseholdUserSummary,
@@ -91,6 +96,11 @@ __all__ = [
"HouseholdCreate",
"HouseholdInDB",
"HouseholdPagination",
"HouseholdRecipeBase",
"HouseholdRecipeCreate",
"HouseholdRecipeOut",
"HouseholdRecipeSummary",
"HouseholdRecipeUpdate",
"HouseholdSave",
"HouseholdSummary",
"HouseholdUserSummary",

View File

@@ -1,10 +1,11 @@
from datetime import datetime
from typing import Annotated
from pydantic import UUID4, ConfigDict, StringConstraints, field_validator
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.household.household import Household
from mealie.db.models.household import Household, HouseholdToRecipe
from mealie.db.models.users.users import User
from mealie.schema._mealie.mealie_model import MealieModel
from mealie.schema.household.webhook import ReadWebhook
@@ -13,6 +14,34 @@ from mealie.schema.response.pagination import PaginationBase
from .household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences
class HouseholdRecipeBase(MealieModel):
last_made: datetime | None = None
class HouseholdRecipeSummary(HouseholdRecipeBase):
recipe_id: UUID4
model_config = ConfigDict(from_attributes=True)
class HouseholdRecipeCreate(HouseholdRecipeBase):
household_id: UUID4
recipe_id: UUID4
class HouseholdRecipeUpdate(HouseholdRecipeBase): ...
class HouseholdRecipeOut(HouseholdRecipeCreate):
id: UUID4
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
joinedload(HouseholdToRecipe.household),
]
class HouseholdCreate(MealieModel):
group_id: UUID4 | None = None
name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]

View File

@@ -59,7 +59,17 @@ class RecipeCategoryPagination(PaginationBase):
class RecipeTool(RecipeTag):
id: UUID4
on_hand: bool = False
households_with_tool: list[str] = []
@field_validator("households_with_tool", mode="before")
def convert_households_to_slugs(cls, v):
if not v:
return []
try:
return [household.slug for household in v]
except AttributeError:
return v
class RecipeToolPagination(PaginationBase):

View File

@@ -7,7 +7,7 @@ from typing import ClassVar
from uuid import UUID, uuid4
from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.recipe import IngredientFoodModel
@@ -37,7 +37,6 @@ class UnitFoodBase(MealieModel):
plural_name: str | None = None
description: str = ""
extras: dict | None = {}
on_hand: bool = False
@field_validator("id", mode="before")
def convert_empty_id_to_none(cls, v):
@@ -67,6 +66,7 @@ class IngredientFoodAlias(CreateIngredientFoodAlias):
class CreateIngredientFood(UnitFoodBase):
label_id: UUID4 | None = None
aliases: list[CreateIngredientFoodAlias] = []
households_with_ingredient_food: list[str] = []
class SaveIngredientFood(CreateIngredientFood):
@@ -91,10 +91,24 @@ class IngredientFood(CreateIngredientFood):
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
selectinload(IngredientFoodModel.households_with_ingredient_food),
joinedload(IngredientFoodModel.extras),
joinedload(IngredientFoodModel.label),
]
@field_validator("households_with_ingredient_food", mode="before")
def convert_households_to_slugs(cls, v):
if not v:
return []
try:
return [household.slug for household in v]
except AttributeError:
return v
def is_on_hand(self, household_slug: str) -> bool:
return household_slug in self.households_with_tool
class IngredientFoodPagination(PaginationBase):
items: list[IngredientFood]

View File

@@ -1,15 +1,14 @@
from pydantic import UUID4, ConfigDict
from pydantic import UUID4, ConfigDict, field_validator
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.recipe import RecipeModel, Tool
from mealie.schema._mealie import MealieModel
from ...db.models.recipe import RecipeModel, Tool
class RecipeToolCreate(MealieModel):
name: str
on_hand: bool = False
households_with_tool: list[str] = []
class RecipeToolSave(RecipeToolCreate):
@@ -19,8 +18,28 @@ class RecipeToolSave(RecipeToolCreate):
class RecipeToolOut(RecipeToolCreate):
id: UUID4
slug: str
model_config = ConfigDict(from_attributes=True)
@field_validator("households_with_tool", mode="before")
def convert_households_to_slugs(cls, v):
if not v:
return []
try:
return [household.slug for household in v]
except AttributeError:
return v
def is_on_hand(self, household_slug: str) -> bool:
return household_slug in self.households_with_tool
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
selectinload(Tool.households_with_tool),
]
class RecipeToolResponse(RecipeToolOut):
recipes: list["RecipeSummary"] = []
@@ -29,6 +48,7 @@ class RecipeToolResponse(RecipeToolOut):
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
selectinload(Tool.households_with_tool),
selectinload(Tool.recipes).joinedload(RecipeModel.recipe_category),
selectinload(Tool.recipes).joinedload(RecipeModel.tags),
selectinload(Tool.recipes).joinedload(RecipeModel.tools),

View File

@@ -6,10 +6,10 @@ from enum import Enum
from typing import Any, TypeVar, cast
from uuid import UUID
import sqlalchemy as sa
from dateutil import parser as date_parser
from dateutil.parser import ParserError
from humps import decamelize
from sqlalchemy import ColumnElement, Select, and_, inspect, or_
from sqlalchemy.ext.associationproxy import AssociationProxyInstance
from sqlalchemy.orm import InstrumentedAttribute, Mapper
from sqlalchemy.sql import sqltypes
@@ -251,17 +251,19 @@ class QueryFilterBuilder:
return f"<<{joined}>>"
@classmethod
def _consolidate_group(cls, group: list[ColumnElement], logical_operators: deque[LogicalOperator]) -> ColumnElement:
consolidated_group_builder: ColumnElement | None = None
def _consolidate_group(
cls, group: list[sa.ColumnElement], logical_operators: deque[LogicalOperator]
) -> sa.ColumnElement:
consolidated_group_builder: sa.ColumnElement | None = None
for i, element in enumerate(reversed(group)):
if not i:
consolidated_group_builder = element
else:
operator = logical_operators.pop()
if operator is LogicalOperator.AND:
consolidated_group_builder = and_(consolidated_group_builder, element)
consolidated_group_builder = sa.and_(consolidated_group_builder, element)
elif operator is LogicalOperator.OR:
consolidated_group_builder = or_(consolidated_group_builder, element)
consolidated_group_builder = sa.or_(consolidated_group_builder, element)
else:
raise ValueError(f"invalid logical operator {operator}")
@@ -270,8 +272,8 @@ class QueryFilterBuilder:
@classmethod
def get_model_and_model_attr_from_attr_string(
cls, attr_string: str, model: type[Model], *, query: Select | None = None
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, Select | None]:
cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, sa.Select | None]:
"""
Take an attribute string and traverse a database model and its relationships to get the desired
model and model attribute. Optionally provide a query to apply the necessary table joins.
@@ -287,7 +289,7 @@ class QueryFilterBuilder:
mapper: Mapper
model_attr: InstrumentedAttribute | None = None
attribute_chain = attr_string.split(".")
attribute_chain = decamelize(attr_string).split(".")
if not attribute_chain:
raise ValueError("invalid query string: attribute name cannot be empty")
@@ -306,7 +308,7 @@ class QueryFilterBuilder:
if query is not None:
query = query.join(model_attr, isouter=True)
mapper = inspect(current_model)
mapper = sa.inspect(current_model)
relationship = mapper.relationships[proxied_attribute_link]
current_model = relationship.mapper.class_
model_attr = getattr(current_model, next_attribute_link)
@@ -318,7 +320,7 @@ class QueryFilterBuilder:
if query is not None:
query = query.join(model_attr, isouter=True)
mapper = inspect(current_model)
mapper = sa.inspect(current_model)
relationship = mapper.relationships[attribute_link]
current_model = relationship.mapper.class_
@@ -330,7 +332,56 @@ class QueryFilterBuilder:
return current_model, model_attr, query
def filter_query(self, query: Select, model: type[Model]) -> Select:
@staticmethod
def _get_filter_element(
component: QueryFilterBuilderComponent, model, model_attr, model_attr_type
) -> sa.ColumnElement:
# Keywords
if component.relationship is RelationalKeyword.IS:
element = model_attr.is_(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.IS_NOT:
element = model_attr.is_not(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.IN:
element = model_attr.in_(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.NOT_IN:
element = model_attr.not_in(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.CONTAINS_ALL:
primary_model_attr: InstrumentedAttribute = getattr(model, component.attribute_name.split(".")[0])
element = sa.and_()
for v in component.validate(model_attr_type):
element = sa.and_(element, primary_model_attr.any(model_attr == v))
elif component.relationship is RelationalKeyword.LIKE:
element = model_attr.like(component.validate(model_attr_type))
elif component.relationship is RelationalKeyword.NOT_LIKE:
element = model_attr.not_like(component.validate(model_attr_type))
# Operators
elif component.relationship is RelationalOperator.EQ:
element = model_attr == component.validate(model_attr_type)
elif component.relationship is RelationalOperator.NOTEQ:
element = model_attr != component.validate(model_attr_type)
elif component.relationship is RelationalOperator.GT:
element = model_attr > component.validate(model_attr_type)
elif component.relationship is RelationalOperator.LT:
element = model_attr < component.validate(model_attr_type)
elif component.relationship is RelationalOperator.GTE:
element = model_attr >= component.validate(model_attr_type)
elif component.relationship is RelationalOperator.LTE:
element = model_attr <= component.validate(model_attr_type)
else:
raise ValueError(f"invalid relationship {component.relationship}")
return element
def filter_query(
self, query: sa.Select, model: type[Model], column_aliases: dict[str, sa.ColumnElement] | None = None
) -> sa.Select:
"""
Filters a query based on the parsed filter string.
If you need to filter on a custom column expression (e.g. a computed property), you can supply column aliases
"""
column_aliases = column_aliases or {}
# join tables and build model chain
attr_model_map: dict[int, Any] = {}
model_attr: InstrumentedAttribute
@@ -344,8 +395,8 @@ class QueryFilterBuilder:
attr_model_map[i] = nested_model
# build query filter
partial_group: list[ColumnElement] = []
partial_group_stack: deque[list[ColumnElement]] = deque()
partial_group: list[sa.ColumnElement] = []
partial_group_stack: deque[list[sa.ColumnElement]] = deque()
logical_operator_stack: deque[LogicalOperator] = deque()
for i, component in enumerate(self.filter_components):
if component == self.l_group_sep:
@@ -365,43 +416,13 @@ class QueryFilterBuilder:
else:
component = cast(QueryFilterBuilderComponent, component)
model_attr = getattr(attr_model_map[i], component.attribute_name.split(".")[-1])
base_attribute_name = component.attribute_name.split(".")[-1]
model_attr = getattr(attr_model_map[i], base_attribute_name)
# Keywords
if component.relationship is RelationalKeyword.IS:
element = model_attr.is_(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.IS_NOT:
element = model_attr.is_not(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.IN:
element = model_attr.in_(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.NOT_IN:
element = model_attr.not_in(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.CONTAINS_ALL:
primary_model_attr: InstrumentedAttribute = getattr(model, component.attribute_name.split(".")[0])
element = and_()
for v in component.validate(model_attr.type):
element = and_(element, primary_model_attr.any(model_attr == v))
elif component.relationship is RelationalKeyword.LIKE:
element = model_attr.like(component.validate(model_attr.type))
elif component.relationship is RelationalKeyword.NOT_LIKE:
element = model_attr.not_like(component.validate(model_attr.type))
# Operators
elif component.relationship is RelationalOperator.EQ:
element = model_attr == component.validate(model_attr.type)
elif component.relationship is RelationalOperator.NOTEQ:
element = model_attr != component.validate(model_attr.type)
elif component.relationship is RelationalOperator.GT:
element = model_attr > component.validate(model_attr.type)
elif component.relationship is RelationalOperator.LT:
element = model_attr < component.validate(model_attr.type)
elif component.relationship is RelationalOperator.GTE:
element = model_attr >= component.validate(model_attr.type)
elif component.relationship is RelationalOperator.LTE:
element = model_attr <= component.validate(model_attr.type)
else:
raise ValueError(f"invalid relationship {component.relationship}")
if (column_alias := column_aliases.get(base_attribute_name)) is not None:
model_attr = column_alias
element = self._get_filter_element(component, model, model_attr, model_attr.type)
partial_group.append(element)
# combine the completed groups into one filter