mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-08 17:03:11 -05:00
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:
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user