fix: Protect sensitive data in query filter API (GHSA-8m57-7cv5-rjp8) (#7629)

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
Michael Genson
2026-05-21 16:08:41 -05:00
committed by GitHub
parent 493154caa8
commit 642c826f2b
29 changed files with 387 additions and 246 deletions

View File

@@ -17,7 +17,7 @@ from sqlalchemy.orm import Session, load_only
from alembic import op from alembic import op
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.labels import MultiPurposeLabel from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel, RecipeIngredientModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel, RecipeIngredientModel
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.

View File

@@ -8,8 +8,8 @@ from mealie.core import root_logger
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models.group.group import Group from mealie.db.models.group.group import Group
from mealie.db.models.household.shopping_list import ShoppingList, ShoppingListMultiPurposeLabel from mealie.db.models.household.shopping_list import ShoppingList, ShoppingListMultiPurposeLabel
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.users.users import User from mealie.db.models.users.users import User

View File

@@ -1,5 +1,4 @@
from .group import * from .group import *
from .labels import *
from .recipe import * from .recipe import *
from .server import * from .server import *
from .users import * from .users import *

View File

@@ -0,0 +1,23 @@
from typing import TYPE_CHECKING, Annotated
from sqlalchemy.orm import Mapped, mapped_column
class _FilterableColumn[T]:
"""
Drop-in replacement for `Mapped[]` that marks a column as filterable.
Filterable columns can be used in query filter expressions.
Only valid on scalar column fields. Using it on a relationship type (e.g. `list[Model]`).
"""
def __class_getitem__(cls, item: type) -> type:
return Mapped[Annotated[item, mapped_column(info={"filterable": True})]]
# SQLAlchemy doesn't play nice with mypy when overriding Mapped, so
# we use this awkward workaround to make mypy happy
if TYPE_CHECKING:
FilterableColumn = Mapped
else:
FilterableColumn = _FilterableColumn

View File

@@ -5,6 +5,7 @@ from sqlalchemy import Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym
from text_unidecode import unidecode from text_unidecode import unidecode
from ._filterable_column import FilterableColumn
from ._model_utils.datetime import NaiveDateTime, get_utc_now from ._model_utils.datetime import NaiveDateTime, get_utc_now
# Punctuation characters replaced with spaces during text normalization. # Punctuation characters replaced with spaces during text normalization.
@@ -16,8 +17,10 @@ _NORMALIZE_PUNCTUATION_TABLE = str.maketrans(NORMALIZE_PUNCTUATION, " " * len(NO
class SqlAlchemyBase(DeclarativeBase): class SqlAlchemyBase(DeclarativeBase):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
created_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True) created_at: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True)
update_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, onupdate=get_utc_now) update_at: FilterableColumn[datetime | None] = mapped_column(
NaiveDateTime, default=get_utc_now, onupdate=get_utc_now
)
@declared_attr @declared_attr
def updated_at(cls) -> Mapped[datetime | None]: def updated_at(cls) -> Mapped[datetime | None]:

View File

@@ -5,9 +5,9 @@ import sqlalchemy.orm as orm
from pydantic import ConfigDict from pydantic import ConfigDict
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.labels import MultiPurposeLabel from mealie.db.models.recipe.labels import MultiPurposeLabel
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
from ..household.cookbook import CookBook from ..household.cookbook import CookBook
@@ -31,9 +31,9 @@ if TYPE_CHECKING:
class Group(SqlAlchemyBase, BaseMixins): class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups" __tablename__ = "groups"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False, unique=True) name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
slug: Mapped[str | None] = mapped_column(sa.String, index=True, unique=True) slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True, unique=True)
households: Mapped[list["Household"]] = orm.relationship("Household", back_populates="group") households: Mapped[list["Household"]] = orm.relationship("Household", back_populates="group")
users: Mapped[list["User"]] = orm.relationship("User", back_populates="group") users: Mapped[list["User"]] = orm.relationship("User", back_populates="group")
categories: Mapped[list[Category]] = orm.relationship(Category, secondary=group_to_categories, single_parent=True) categories: Mapped[list[Category]] = orm.relationship(Category, secondary=group_to_categories, single_parent=True)

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String, UniqueConstraint, orm from sqlalchemy import Boolean, ForeignKey, Integer, String, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils import guid from .._model_utils import guid
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from ..recipe.category import Category, cookbooks_to_categories from ..recipe.category import Category, cookbooks_to_categories
@@ -21,31 +21,31 @@ class CookBook(SqlAlchemyBase, BaseMixins):
UniqueConstraint("slug", "group_id", name="cookbook_slug_group_id_key"), UniqueConstraint("slug", "group_id", name="cookbook_slug_group_id_key"),
) )
id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) id: FilterableColumn[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=1) position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=1)
group_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("groups.id"), index=True) group_id: FilterableColumn[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("groups.id"), index=True)
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="cookbooks") group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="cookbooks")
household_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("households.id"), index=True) household_id: FilterableColumn[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("households.id"), index=True)
household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="cookbooks") household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="cookbooks")
name: Mapped[str] = mapped_column(String, nullable=False) name: FilterableColumn[str] = mapped_column(String, nullable=False)
slug: Mapped[str] = mapped_column(String, nullable=False, index=True) slug: FilterableColumn[str] = mapped_column(String, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(String, default="") description: FilterableColumn[str | None] = mapped_column(String, default="")
public: Mapped[str | None] = mapped_column(Boolean, default=False) public: FilterableColumn[str | None] = mapped_column(Boolean, default=False)
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="") query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
# Old filters - deprecated in favor of query filter strings # Old filters - deprecated in favor of query filter strings
categories: Mapped[list[Category]] = orm.relationship( categories: Mapped[list[Category]] = orm.relationship(
Category, secondary=cookbooks_to_categories, single_parent=True Category, secondary=cookbooks_to_categories, single_parent=True
) )
require_all_categories: Mapped[bool | None] = mapped_column(Boolean, default=True) require_all_categories: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True) tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True)
require_all_tags: Mapped[bool | None] = mapped_column(Boolean, default=True) require_all_tags: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
tools: Mapped[list[Tool]] = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True) tools: Mapped[list[Tool]] = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True)
require_all_tools: Mapped[bool | None] = mapped_column(Boolean, default=True) require_all_tools: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@@ -5,7 +5,7 @@ import sqlalchemy.orm as orm
from pydantic import ConfigDict from pydantic import ConfigDict
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
from ..recipe.ingredient import households_to_ingredient_foods from ..recipe.ingredient import households_to_ingredient_foods
@@ -33,9 +33,9 @@ class Household(SqlAlchemyBase, BaseMixins):
sa.UniqueConstraint("group_id", "slug", name="household_slug_group_id_key"), sa.UniqueConstraint("group_id", "slug", name="household_slug_group_id_key"),
) )
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False) name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str | None] = mapped_column(sa.String, index=True) slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
invite_tokens: Mapped[list["GroupInviteToken"]] = orm.relationship( invite_tokens: Mapped[list["GroupInviteToken"]] = orm.relationship(
"GroupInviteToken", back_populates="household", cascade="all, delete-orphan" "GroupInviteToken", back_populates="household", cascade="all, delete-orphan"
@@ -48,7 +48,7 @@ class Household(SqlAlchemyBase, BaseMixins):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="households") group: Mapped["Group"] = orm.relationship("Group", back_populates="households")
users: Mapped[list["User"]] = orm.relationship("User", back_populates="household") users: Mapped[list["User"]] = orm.relationship("User", back_populates="household")

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
from ..recipe.category import Category, plan_rules_to_categories from ..recipe.category import Category, plan_rules_to_categories
@@ -30,14 +30,14 @@ plan_rules_to_households = Table(
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase): class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
__tablename__ = "group_meal_plan_rules" __tablename__ = "group_meal_plan_rules"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True) household_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
day: Mapped[str] = mapped_column( day: FilterableColumn[str] = mapped_column(
String, nullable=False, default="unset" String, nullable=False, default="unset"
) # "MONDAY", "TUESDAY", "WEDNESDAY", etc... ) # "MONDAY", "TUESDAY", "WEDNESDAY", etc...
entry_type: Mapped[str] = mapped_column( entry_type: FilterableColumn[str] = mapped_column(
String, nullable=False, default="" String, nullable=False, default=""
) # "breakfast", "lunch", "dinner", etc ... ) # "breakfast", "lunch", "dinner", etc ...
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="") query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
@@ -55,19 +55,19 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
class GroupMealPlan(SqlAlchemyBase, BaseMixins): class GroupMealPlan(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_meal_plans" __tablename__ = "group_meal_plans"
date: Mapped[datetime.date] = mapped_column(Date, index=True, nullable=False) date: FilterableColumn[datetime.date] = mapped_column(Date, index=True, nullable=False)
entry_type: Mapped[str] = mapped_column(String, index=True, nullable=False) entry_type: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
title: Mapped[str] = mapped_column(String, index=True, nullable=False) title: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
text: Mapped[str] = mapped_column(String, nullable=False) text: FilterableColumn[str] = mapped_column(String, nullable=False)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True) group_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="mealplans") group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="mealplans")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id") household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household") household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("users.id"), index=True) user_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("users.id"), index=True)
user: Mapped[Optional["User"]] = orm.relationship("User", back_populates="mealplans") user: Mapped[Optional["User"]] = orm.relationship("User", back_populates="mealplans")
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship( recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(
"RecipeModel", back_populates="meal_entries", uselist=False "RecipeModel", back_populates="meal_entries", uselist=False
) )

View File

@@ -5,7 +5,7 @@ import sqlalchemy.orm as orm
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
@@ -15,27 +15,29 @@ if TYPE_CHECKING:
class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins): class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "household_preferences" __tablename__ = "household_preferences"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
household_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("households.id"), nullable=False, index=True) household_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("households.id"), nullable=False, index=True
)
household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="preferences") household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="preferences")
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id") group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True) private_household: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
show_announcements: Mapped[bool] = mapped_column(sa.Boolean, default=True) show_announcements: FilterableColumn[bool] = mapped_column(sa.Boolean, default=True)
lock_recipe_edits_from_other_households: Mapped[bool | None] = mapped_column(sa.Boolean, default=True) lock_recipe_edits_from_other_households: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0) first_day_of_week: FilterableColumn[int | None] = mapped_column(sa.Integer, default=0)
# Recipe Defaults # Recipe Defaults
recipe_public: Mapped[bool | None] = mapped_column(sa.Boolean, default=True) recipe_public: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) recipe_show_nutrition: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_show_assets: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) recipe_show_assets: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) recipe_landscape_view: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) recipe_disable_comments: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
# Deprecated # Deprecated
recipe_disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True) recipe_disable_amount: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@@ -8,10 +8,10 @@ from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.api_extras import ShoppingListExtras, ShoppingListItemExtras, api_extras from mealie.db.models.recipe.api_extras import ShoppingListExtras, ShoppingListItemExtras, api_extras
from mealie.db.models.recipe.labels import MultiPurposeLabel
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
@@ -25,18 +25,20 @@ if TYPE_CHECKING:
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase): class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_item_recipe_reference" __tablename__ = "shopping_list_item_recipe_reference"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_item: Mapped["ShoppingListItem"] = orm.relationship( shopping_list_item: Mapped["ShoppingListItem"] = orm.relationship(
"ShoppingListItem", back_populates="recipe_references" "ShoppingListItem", back_populates="recipe_references"
) )
shopping_list_item_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True) shopping_list_item_id: FilterableColumn[GUID] = mapped_column(
GUID, ForeignKey("shopping_list_items.id"), primary_key=True
)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs") recipe: Mapped[Optional["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False) recipe_quantity: FilterableColumn[float] = mapped_column(Float, nullable=False)
recipe_scale: Mapped[float] = mapped_column(Float, default=1) recipe_scale: FilterableColumn[float] = mapped_column(Float, default=1)
recipe_note: Mapped[str | None] = mapped_column(String) recipe_note: FilterableColumn[str | None] = mapped_column(String)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "group_id") group_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "household_id") household_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "household_id")
@@ -50,33 +52,33 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_list_items" __tablename__ = "shopping_list_items"
# Id's # Id's
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="list_items") shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="list_items")
shopping_list_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("shopping_lists.id"), index=True) shopping_list_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("shopping_lists.id"), index=True)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id") group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id") household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
# Meta # Meta
is_ingredient: Mapped[bool | None] = mapped_column(Boolean, default=True) is_ingredient: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True) position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=0, index=True)
checked: Mapped[bool | None] = mapped_column(Boolean, default=False) checked: FilterableColumn[bool | None] = mapped_column(Boolean, default=False)
quantity: Mapped[float | None] = mapped_column(Float, default=1) quantity: FilterableColumn[float | None] = mapped_column(Float, default=1)
note: Mapped[str | None] = mapped_column(String) note: FilterableColumn[str | None] = mapped_column(String)
extras: Mapped[list[ShoppingListItemExtras]] = orm.relationship( extras: Mapped[list[ShoppingListItemExtras]] = orm.relationship(
"ShoppingListItemExtras", cascade="all, delete-orphan" "ShoppingListItemExtras", cascade="all, delete-orphan"
) )
# Scaling Items # Scaling Items
unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id")) unit_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"))
unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False) unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False)
food_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id")) food_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"))
food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False) food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False)
label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id")) label_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"))
label: Mapped[MultiPurposeLabel | None] = orm.relationship( label: Mapped[MultiPurposeLabel | None] = orm.relationship(
MultiPurposeLabel, uselist=False, back_populates="shopping_list_items" MultiPurposeLabel, uselist=False, back_populates="shopping_list_items"
) )
@@ -98,19 +100,19 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase): class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_recipe_reference" __tablename__ = "shopping_list_recipe_reference"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="recipe_references") shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="recipe_references")
shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True) shopping_list_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id") group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id") household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship( recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(
"RecipeModel", uselist=False, back_populates="shopping_list_refs" "RecipeModel", uselist=False, back_populates="shopping_list_refs"
) )
recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False) recipe_quantity: FilterableColumn[float] = mapped_column(Float, nullable=False)
model_config = ConfigDict(exclude={"id", "recipe"}) model_config = ConfigDict(exclude={"id", "recipe"})
@auto_init() @auto_init()
@@ -121,12 +123,12 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins): class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists_multi_purpose_labels" __tablename__ = "shopping_lists_multi_purpose_labels"
__table_args__ = (UniqueConstraint("shopping_list_id", "label_id", name="shopping_list_id_label_id_key"),) __table_args__ = (UniqueConstraint("shopping_list_id", "label_id", name="shopping_list_id_label_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True) shopping_list_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="label_settings") shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="label_settings")
label_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), primary_key=True) label_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), primary_key=True)
label: Mapped["MultiPurposeLabel"] = orm.relationship( label: Mapped["MultiPurposeLabel"] = orm.relationship(
"MultiPurposeLabel", back_populates="shopping_lists_label_settings" "MultiPurposeLabel", back_populates="shopping_lists_label_settings"
) )
@@ -134,7 +136,7 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id") group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id") household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=0)
model_config = ConfigDict(exclude={"label"}) model_config = ConfigDict(exclude={"label"})
@auto_init() @auto_init()
@@ -144,16 +146,16 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
class ShoppingList(SqlAlchemyBase, BaseMixins): class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists" __tablename__ = "shopping_lists"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="shopping_lists") group: Mapped["Group"] = orm.relationship("Group", back_populates="shopping_lists")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id") household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household") household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True) user_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user: Mapped["User"] = orm.relationship("User", back_populates="shopping_lists") user: Mapped["User"] = orm.relationship("User", back_populates="shopping_lists")
name: Mapped[str | None] = mapped_column(String) name: FilterableColumn[str | None] = mapped_column(String)
list_items: Mapped[list[ShoppingListItem]] = orm.relationship( list_items: Mapped[list[ShoppingListItem]] = orm.relationship(
ShoppingListItem, ShoppingListItem,
cascade="all, delete, delete-orphan", cascade="all, delete, delete-orphan",

View File

@@ -1,7 +1,7 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@@ -28,9 +28,9 @@ class ExtrasGeneric:
This class is not an actual table, so it does not inherit from SqlAlchemyBase This class is not an actual table, so it does not inherit from SqlAlchemyBase
""" """
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
key_name: Mapped[str | None] = mapped_column(sa.String) key_name: FilterableColumn[str | None] = mapped_column(sa.String)
value: Mapped[str | None] = mapped_column(sa.String) value: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__(self, key, value) -> None: def __init__(self, key, value) -> None:
self.key_name = key self.key_name = key
@@ -40,21 +40,25 @@ class ExtrasGeneric:
# used specifically for recipe extras # used specifically for recipe extras
class ApiExtras(ExtrasGeneric, SqlAlchemyBase): class ApiExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "api_extras" __tablename__ = "api_extras"
recipee_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) recipee_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
class IngredientFoodExtras(ExtrasGeneric, SqlAlchemyBase): class IngredientFoodExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "ingredient_food_extras" __tablename__ = "ingredient_food_extras"
ingredient_food_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("ingredient_foods.id"), index=True) ingredient_food_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("ingredient_foods.id"), index=True
)
class ShoppingListExtras(ExtrasGeneric, SqlAlchemyBase): class ShoppingListExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "shopping_list_extras" __tablename__ = "shopping_list_extras"
shopping_list_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("shopping_lists.id"), index=True) shopping_list_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("shopping_lists.id"), index=True
)
class ShoppingListItemExtras(ExtrasGeneric, SqlAlchemyBase): class ShoppingListItemExtras(ExtrasGeneric, SqlAlchemyBase):
__tablename__ = "shopping_list_item_extras" __tablename__ = "shopping_list_item_extras"
shopping_list_item_id: Mapped[GUID | None] = mapped_column( shopping_list_item_id: FilterableColumn[GUID | None] = mapped_column(
GUID, sa.ForeignKey("shopping_list_items.id"), index=True GUID, sa.ForeignKey("shopping_list_items.id"), index=True
) )

View File

@@ -1,17 +1,17 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
class RecipeAsset(SqlAlchemyBase): class RecipeAsset(SqlAlchemyBase):
__tablename__ = "recipe_assets" __tablename__ = "recipe_assets"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
name: Mapped[str | None] = mapped_column(sa.String) name: FilterableColumn[str | None] = mapped_column(sa.String)
icon: Mapped[str | None] = mapped_column(sa.String) icon: FilterableColumn[str | None] = mapped_column(sa.String)
file_name: Mapped[str | None] = mapped_column(sa.String) file_name: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__(self, name=None, icon=None, file_name=None) -> None: def __init__(self, name=None, icon=None, file_name=None) -> None:
self.name = name self.name = name

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, validates
from mealie.core import root_logger from mealie.core import root_logger
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -54,12 +54,12 @@ class Category(SqlAlchemyBase, BaseMixins):
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),) __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),)
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id])
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False) name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False) slug: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
recipes: Mapped[list["RecipeModel"]] = orm.relationship( recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category" "RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category"
) )

View File

@@ -6,9 +6,9 @@ from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.api_extras import IngredientFoodExtras, api_extras from mealie.db.models.recipe.api_extras import IngredientFoodExtras, api_extras
from mealie.db.models.recipe.labels import MultiPurposeLabel
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
@@ -29,19 +29,19 @@ households_to_ingredient_foods = sa.Table(
class IngredientUnitModel(SqlAlchemyBase, BaseMixins): class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units" __tablename__ = "ingredient_units"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id])
name: Mapped[str | None] = mapped_column(String) name: FilterableColumn[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String) plural_name: FilterableColumn[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String) description: FilterableColumn[str | None] = mapped_column(String)
abbreviation: Mapped[str | None] = mapped_column(String) abbreviation: FilterableColumn[str | None] = mapped_column(String)
plural_abbreviation: Mapped[str | None] = mapped_column(String) plural_abbreviation: FilterableColumn[str | None] = mapped_column(String)
use_abbreviation: Mapped[bool | None] = mapped_column(Boolean, default=False) use_abbreviation: FilterableColumn[bool | None] = mapped_column(Boolean, default=False)
fraction: Mapped[bool | None] = mapped_column(Boolean, default=True) fraction: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship( ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="unit" "RecipeIngredientModel", back_populates="unit"
@@ -53,14 +53,14 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
) )
# Standardization # Standardization
standard_quantity: Mapped[float | None] = mapped_column(Float) standard_quantity: FilterableColumn[float | None] = mapped_column(Float)
standard_unit: Mapped[str | None] = mapped_column(String) standard_unit: FilterableColumn[str | None] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) plural_name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True) abbreviation_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
plural_abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True) plural_abbreviation_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
@auto_init() @auto_init()
def __init__( def __init__(
@@ -152,18 +152,18 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodModel(SqlAlchemyBase, BaseMixins): class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods" __tablename__ = "ingredient_foods"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id])
households_with_ingredient_food: Mapped[list["Household"]] = orm.relationship( households_with_ingredient_food: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_ingredient_foods, back_populates="ingredient_foods_on_hand" "Household", secondary=households_to_ingredient_foods, back_populates="ingredient_foods_on_hand"
) )
name: Mapped[str | None] = mapped_column(String) name: FilterableColumn[str | None] = mapped_column(String)
plural_name: Mapped[str | None] = mapped_column(String) plural_name: FilterableColumn[str | None] = mapped_column(String)
description: Mapped[str | None] = mapped_column(String) description: FilterableColumn[str | None] = mapped_column(String)
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship( ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel", back_populates="food" "RecipeIngredientModel", back_populates="food"
@@ -175,12 +175,12 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
) )
extras: Mapped[list[IngredientFoodExtras]] = orm.relationship("IngredientFoodExtras", cascade="all, delete-orphan") extras: Mapped[list[IngredientFoodExtras]] = orm.relationship("IngredientFoodExtras", cascade="all, delete-orphan")
label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), index=True) label_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), index=True)
label: Mapped[MultiPurposeLabel | None] = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="foods") label: Mapped[MultiPurposeLabel | None] = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="foods")
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) plural_name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict( model_config = ConfigDict(
exclude={ exclude={
@@ -261,15 +261,15 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins): class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units_aliases" __tablename__ = "ingredient_units_aliases"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
unit_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("ingredient_units.id"), primary_key=True) unit_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("ingredient_units.id"), primary_key=True)
unit: Mapped["IngredientUnitModel"] = orm.relationship("IngredientUnitModel", back_populates="aliases") unit: Mapped["IngredientUnitModel"] = orm.relationship("IngredientUnitModel", back_populates="aliases")
name: Mapped[str] = mapped_column(String) name: FilterableColumn[str] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
@auto_init() @auto_init()
def __init__(self, session: Session, name: str, **_) -> None: def __init__(self, session: Session, name: str, **_) -> None:
@@ -302,15 +302,15 @@ class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins): class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods_aliases" __tablename__ = "ingredient_foods_aliases"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
food_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), primary_key=True) food_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), primary_key=True)
food: Mapped["IngredientFoodModel"] = orm.relationship("IngredientFoodModel", back_populates="aliases") food: Mapped["IngredientFoodModel"] = orm.relationship("IngredientFoodModel", back_populates="aliases")
name: Mapped[str] = mapped_column(String) name: FilterableColumn[str] = mapped_column(String)
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
@auto_init() @auto_init()
def __init__(self, session: Session, name: str, **_) -> None: def __init__(self, session: Session, name: str, **_) -> None:
@@ -343,34 +343,34 @@ class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
class RecipeIngredientModel(SqlAlchemyBase, BaseMixins): class RecipeIngredientModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes_ingredients" __tablename__ = "recipes_ingredients"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: FilterableColumn[int] = mapped_column(Integer, primary_key=True)
position: Mapped[int | None] = mapped_column(Integer, index=True) position: FilterableColumn[int | None] = mapped_column(Integer, index=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id")) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
title: Mapped[str | None] = mapped_column(String) # Section Header - Shows if Present title: FilterableColumn[str | None] = mapped_column(String) # Section Header - Shows if Present
note: Mapped[str | None] = mapped_column(String) # Force Show Text - Overrides Concat note: FilterableColumn[str | None] = mapped_column(String) # Force Show Text - Overrides Concat
# Scaling Items # Scaling Items
unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"), index=True) unit_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"), index=True)
unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False) unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False)
food_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), index=True) food_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"), index=True)
food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False) food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False)
quantity: Mapped[float | None] = mapped_column(Float) quantity: FilterableColumn[float | None] = mapped_column(Float)
original_text: Mapped[str | None] = mapped_column(String) original_text: FilterableColumn[str | None] = mapped_column(String)
reference_id: Mapped[GUID | None] = mapped_column(GUID) # Reference Links reference_id: FilterableColumn[GUID | None] = mapped_column(GUID) # Reference Links
# Recipe Reference # Recipe Reference
referenced_recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True) referenced_recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
referenced_recipe: Mapped["RecipeModel"] = orm.relationship( referenced_recipe: Mapped["RecipeModel"] = orm.relationship(
"RecipeModel", back_populates="referenced_ingredients", foreign_keys=[referenced_recipe_id] "RecipeModel", back_populates="referenced_ingredients", foreign_keys=[referenced_recipe_id]
) )
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
note_normalized: Mapped[str | None] = mapped_column(String, index=True) note_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
original_text_normalized: Mapped[str | None] = mapped_column(String, index=True) original_text_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
@auto_init() @auto_init()
def __init__( def __init__(

View File

@@ -3,26 +3,26 @@ from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, UniqueConstraint, orm from sqlalchemy import ForeignKey, String, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from ._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from ._model_utils.guid import GUID from .._model_utils.guid import GUID
if TYPE_CHECKING: if TYPE_CHECKING:
from .group.group import Group from ..group.group import Group
from .household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel from ..household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
from .recipe import IngredientFoodModel from . import IngredientFoodModel
class MultiPurposeLabel(SqlAlchemyBase, BaseMixins): class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "multi_purpose_labels" __tablename__ = "multi_purpose_labels"
__table_args__ = (UniqueConstraint("name", "group_id", name="multi_purpose_labels_name_group_id_key"),) __table_args__ = (UniqueConstraint("name", "group_id", name="multi_purpose_labels_name_group_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, default=GUID.generate, primary_key=True) id: FilterableColumn[GUID] = mapped_column(GUID, default=GUID.generate, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: FilterableColumn[str] = mapped_column(String(255), nullable=False)
color: Mapped[str] = mapped_column(String(10), nullable=False, default="") color: FilterableColumn[str] = mapped_column(String(10), nullable=False, default="")
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="labels") group: Mapped["Group"] = orm.relationship("Group", back_populates="labels")
shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label") shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label")

View File

@@ -1,22 +1,22 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
class Nutrition(SqlAlchemyBase): class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition" __tablename__ = "recipe_nutrition"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
calories: Mapped[str | None] = mapped_column(sa.String) calories: FilterableColumn[str | None] = mapped_column(sa.String)
carbohydrate_content: Mapped[str | None] = mapped_column(sa.String) carbohydrate_content: FilterableColumn[str | None] = mapped_column(sa.String)
cholesterol_content: Mapped[str | None] = mapped_column(sa.String) cholesterol_content: FilterableColumn[str | None] = mapped_column(sa.String)
fat_content: Mapped[str | None] = mapped_column(sa.String) fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
fiber_content: Mapped[str | None] = mapped_column(sa.String) fiber_content: FilterableColumn[str | None] = mapped_column(sa.String)
protein_content: Mapped[str | None] = mapped_column(sa.String) protein_content: FilterableColumn[str | None] = mapped_column(sa.String)
saturated_fat_content: Mapped[str | None] = mapped_column(sa.String) saturated_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
# `serving_size` is not a scaling factor, but a per-serving volume or mass # `serving_size` is not a scaling factor, but a per-serving volume or mass
# according to schema.org. E.g., "2 L", "500 g", "5 cups", etc. # according to schema.org. E.g., "2 L", "500 g", "5 cups", etc.
@@ -28,10 +28,10 @@ class Nutrition(SqlAlchemyBase):
# #
# serving_size: Mapped[str | None] = mapped_column(sa.String) # serving_size: Mapped[str | None] = mapped_column(sa.String)
sodium_content: Mapped[str | None] = mapped_column(sa.String) sodium_content: FilterableColumn[str | None] = mapped_column(sa.String)
sugar_content: Mapped[str | None] = mapped_column(sa.String) sugar_content: FilterableColumn[str | None] = mapped_column(sa.String)
trans_fat_content: Mapped[str | None] = mapped_column(sa.String) trans_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
unsaturated_fat_content: Mapped[str | None] = mapped_column(sa.String) unsaturated_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
def __init__( def __init__(
self, self,

View File

@@ -16,7 +16,7 @@ from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.recipe.ingredient import RecipeIngredientModel from mealie.db.models.recipe.ingredient import RecipeIngredientModel
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from ..household.household_to_recipe import HouseholdToRecipe from ..household.household_to_recipe import HouseholdToRecipe
from ..users.user_to_recipe import UserToRecipe from ..users.user_to_recipe import UserToRecipe
from .api_extras import ApiExtras, api_extras from .api_extras import ApiExtras, api_extras
@@ -45,20 +45,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"), sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),
) )
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
slug: Mapped[str | None] = mapped_column(sa.String, index=True) slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id") household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household") household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True) user_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True)
user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id]) user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id])
rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True) rating: FilterableColumn[float | None] = mapped_column(sa.Float, index=True, nullable=True)
rated_by: Mapped[list["User"]] = orm.relationship( rated_by: Mapped[list["User"]] = orm.relationship(
"User", "User",
secondary=UserToRecipe.__tablename__, secondary=UserToRecipe.__tablename__,
@@ -78,20 +78,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
) )
# General Recipe Properties # General Recipe Properties
name: Mapped[str] = mapped_column(sa.String, nullable=False) name: FilterableColumn[str] = mapped_column(sa.String, nullable=False)
description: Mapped[str | None] = mapped_column(sa.String) description: FilterableColumn[str | None] = mapped_column(sa.String)
image: Mapped[str | None] = mapped_column(sa.String) image: FilterableColumn[str | None] = mapped_column(sa.String)
# Time Related Properties # Time Related Properties
total_time: Mapped[str | None] = mapped_column(sa.String) total_time: FilterableColumn[str | None] = mapped_column(sa.String)
prep_time: Mapped[str | None] = mapped_column(sa.String) prep_time: FilterableColumn[str | None] = mapped_column(sa.String)
perform_time: Mapped[str | None] = mapped_column(sa.String) perform_time: FilterableColumn[str | None] = mapped_column(sa.String)
cook_time: Mapped[str | None] = mapped_column(sa.String) cook_time: FilterableColumn[str | None] = mapped_column(sa.String)
recipe_yield: Mapped[str | None] = mapped_column(sa.String) recipe_yield: FilterableColumn[str | None] = mapped_column(sa.String)
recipe_yield_quantity: Mapped[float] = mapped_column(sa.Float, index=True, default=0) recipe_yield_quantity: FilterableColumn[float] = mapped_column(sa.Float, index=True, default=0)
recipe_servings: Mapped[float] = mapped_column(sa.Float, index=True, default=0) recipe_servings: FilterableColumn[float] = mapped_column(sa.Float, index=True, default=0)
assets: Mapped[list[RecipeAsset]] = orm.relationship("RecipeAsset", cascade="all, delete-orphan") assets: Mapped[list[RecipeAsset]] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Mapped[Nutrition] = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan") nutrition: Mapped[Nutrition] = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
@@ -137,14 +137,14 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
) )
tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes") tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes")
notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan") notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
org_url: Mapped[str | None] = mapped_column(sa.String) org_url: FilterableColumn[str | None] = mapped_column(sa.String)
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan") extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
# Time Stamp Properties # Time Stamp Properties
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today) date_added: FilterableColumn[date | None] = mapped_column(sa.Date, default=get_utc_today)
date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime) date_updated: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime)
last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime) last_made: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime)
made_by: Mapped[list["Household"]] = orm.relationship( made_by: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=HouseholdToRecipe.__tablename__, back_populates="made_recipes" "Household", secondary=HouseholdToRecipe.__tablename__, back_populates="made_recipes"
) )
@@ -162,8 +162,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
) )
# Automatically updated by sqlalchemy event, do not write to this manually # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str] = mapped_column(sa.String, nullable=False, index=True) name_normalized: FilterableColumn[str] = mapped_column(sa.String, nullable=False, index=True)
description_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) description_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict( model_config = ConfigDict(
get_attr="slug", get_attr="slug",
exclude={ exclude={

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from mealie.db.models._model_utils.datetime import NaiveDateTime from mealie.db.models._model_utils.datetime import NaiveDateTime
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .._model_utils.auto_init import auto_init from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
@@ -18,29 +18,29 @@ if TYPE_CHECKING:
class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins): class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_timeline_events" __tablename__ = "recipe_timeline_events"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# Parent Recipe # Parent Recipe
recipe_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("recipes.id"), nullable=False, index=True) recipe_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("recipes.id"), nullable=False, index=True)
recipe: Mapped["RecipeModel"] = relationship("RecipeModel", back_populates="timeline_events") recipe: Mapped["RecipeModel"] = relationship("RecipeModel", back_populates="timeline_events")
group_id: AssociationProxy[GUID] = association_proxy("recipe", "group_id") group_id: AssociationProxy[GUID] = association_proxy("recipe", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("recipe", "household_id") household_id: AssociationProxy[GUID] = association_proxy("recipe", "household_id")
# Related User (Actor) # Related User (Actor)
user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True) user_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user: Mapped["User"] = relationship( user: Mapped["User"] = relationship(
"User", back_populates="recipe_timeline_events", single_parent=True, foreign_keys=[user_id] "User", back_populates="recipe_timeline_events", single_parent=True, foreign_keys=[user_id]
) )
# General Properties # General Properties
subject: Mapped[str] = mapped_column(String, nullable=False) subject: FilterableColumn[str] = mapped_column(String, nullable=False)
message: Mapped[str | None] = mapped_column(String) message: FilterableColumn[str | None] = mapped_column(String)
event_type: Mapped[str | None] = mapped_column(String) event_type: FilterableColumn[str | None] = mapped_column(String)
image: Mapped[str | None] = mapped_column(String) image: FilterableColumn[str | None] = mapped_column(String)
# Timestamps # Timestamps
timestamp: Mapped[datetime | None] = mapped_column(NaiveDateTime, index=True) timestamp: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime, index=True)
@auto_init() @auto_init()
def __init__( def __init__(

View File

@@ -1,20 +1,20 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
class RecipeSettings(SqlAlchemyBase): class RecipeSettings(SqlAlchemyBase):
__tablename__ = "recipe_settings" __tablename__ = "recipe_settings"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
public: Mapped[bool | None] = mapped_column(sa.Boolean) public: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean) show_nutrition: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
show_assets: Mapped[bool | None] = mapped_column(sa.Boolean) show_assets: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean) landscape_view: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) disable_comments: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
locked: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) locked: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
# Deprecated # Deprecated
disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True) disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)

View File

@@ -3,9 +3,9 @@ from typing import TYPE_CHECKING
from uuid import uuid4 from uuid import uuid4
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.auto_init import auto_init from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.datetime import NaiveDateTime from mealie.db.models._model_utils.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@@ -22,12 +22,12 @@ class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_share_tokens" __tablename__ = "recipe_share_tokens"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=uuid4) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=uuid4)
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
recipe_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("recipes.id"), nullable=False, index=True) recipe_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("recipes.id"), nullable=False, index=True)
recipe: Mapped["RecipeModel"] = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False) recipe: Mapped["RecipeModel"] = relationship("RecipeModel", back_populates="share_tokens", uselist=False)
expires_at: Mapped[datetime] = mapped_column(NaiveDateTime, nullable=False) expires_at: FilterableColumn[datetime] = mapped_column(NaiveDateTime, nullable=False)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@@ -6,7 +6,7 @@ from slugify import slugify
from sqlalchemy.orm import Mapped, mapped_column, validates from sqlalchemy.orm import Mapped, mapped_column, validates
from mealie.core import root_logger from mealie.core import root_logger
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils import guid from mealie.db.models._model_utils import guid
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -44,14 +44,16 @@ cookbooks_to_tags = sa.Table(
class Tag(SqlAlchemyBase, BaseMixins): class Tag(SqlAlchemyBase, BaseMixins):
__tablename__ = "tags" __tablename__ = "tags"
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),) __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),)
id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate) id: FilterableColumn[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
# ID Relationships # ID Relationships
group_id: Mapped[guid.GUID] = mapped_column(guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[guid.GUID] = mapped_column(
guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True
)
group: Mapped["Group"] = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id])
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False) name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False) slug: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
recipes: Mapped[list["RecipeModel"]] = orm.relationship( recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_tags, back_populates="tags" "RecipeModel", secondary=recipes_to_tags, back_populates="tags"
) )

View File

@@ -5,7 +5,7 @@ from slugify import slugify
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from mealie.db.models._model_utils.auto_init import auto_init from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@@ -42,14 +42,14 @@ cookbooks_to_tools = Table(
class Tool(SqlAlchemyBase, BaseMixins): class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools" __tablename__ = "tools"
__table_args__ = (UniqueConstraint("slug", "group_id", name="tools_slug_group_id_key"),) __table_args__ = (UniqueConstraint("slug", "group_id", name="tools_slug_group_id_key"),)
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships # ID Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id])
name: Mapped[str] = mapped_column(String, index=True, nullable=False) name: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
slug: Mapped[str] = mapped_column(String, index=True, nullable=False) slug: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
households_with_tool: Mapped[list["Household"]] = orm.relationship( households_with_tool: Mapped[list["Household"]] = orm.relationship(
"Household", secondary=households_to_tools, back_populates="tools_on_hand" "Household", secondary=households_to_tools, back_populates="tools_on_hand"

View File

@@ -13,7 +13,7 @@ from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.datetime import NaiveDateTime from mealie.db.models._model_utils.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
from .user_to_recipe import UserToRecipe from .user_to_recipe import UserToRecipe
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -50,18 +50,21 @@ class AuthMethod(enum.Enum):
class User(SqlAlchemyBase, BaseMixins): class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
full_name: Mapped[str | None] = mapped_column(String, index=True) id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
username: Mapped[str | None] = mapped_column(String, index=True, unique=True) full_name: FilterableColumn[str | None] = mapped_column(String, index=True)
username: FilterableColumn[str | None] = mapped_column(String, index=True, unique=True)
email: Mapped[str | None] = mapped_column(String, unique=True, index=True) email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
password: Mapped[str | None] = mapped_column(String) password: Mapped[str | None] = mapped_column(String)
auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE) auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
admin: Mapped[bool | None] = mapped_column(Boolean, default=False) admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
advanced: Mapped[bool | None] = mapped_column(Boolean, default=False) advanced: Mapped[bool | None] = mapped_column(Boolean, default=False)
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: FilterableColumn[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="users") group: Mapped["Group"] = orm.relationship("Group", back_populates="users")
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), nullable=True, index=True) household_id: FilterableColumn[GUID | None] = mapped_column(
GUID, ForeignKey("households.id"), nullable=True, index=True
)
household: Mapped["Household"] = orm.relationship("Household", back_populates="users") household: Mapped["Household"] = orm.relationship("Household", back_populates="users")
cache_key: Mapped[str | None] = mapped_column(String, default="1234") cache_key: Mapped[str | None] = mapped_column(String, default="1234")

View File

@@ -25,10 +25,10 @@ from mealie.db.models.household.shopping_list import (
ShoppingListRecipeReference, ShoppingListRecipeReference,
) )
from mealie.db.models.household.webhooks import GroupWebhooksModel from mealie.db.models.household.webhooks import GroupWebhooksModel
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.comment import RecipeComment from mealie.db.models.recipe.comment import RecipeComment
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.recipe_timeline import RecipeTimelineEvent from mealie.db.models.recipe.recipe_timeline import RecipeTimelineEvent
from mealie.db.models.recipe.shared import RecipeShareTokenModel from mealie.db.models.recipe.shared import RecipeShareTokenModel

View File

@@ -25,7 +25,7 @@ from mealie.schema.response.pagination import (
RequestQuery, RequestQuery,
) )
from mealie.schema.response.query_search import SearchFilter from mealie.schema.response.query_search import SearchFilter
from mealie.services.query_filter.builder import QueryFilterBuilder from mealie.services.query_filter.builder import NonFilterableValueError, QueryFilterBuilder
from ._utils import NOT_SET, NotSet from ._utils import NOT_SET, NotSet
@@ -467,6 +467,12 @@ class RepositoryGeneric[Schema: MealieModel, Model: SqlAlchemyBase]:
query, order_attr, order_dir, request_query.order_by_null_position query, order_attr, order_dir, request_query.order_by_null_position
) )
except NonFilterableValueError as e:
raise HTTPException(
status_code=400,
detail=f'Invalid order_by statement "{request_query.order_by}": {e.message}',
) from e
except ValueError as e: except ValueError as e:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,

View File

@@ -22,6 +22,17 @@ from .keywords import PlaceholderKeyword, RelationalKeyword
from .operators import LogicalOperator, RelationalOperator from .operators import LogicalOperator, RelationalOperator
class NonFilterableValueError(ValueError):
"""Raised when trying to filter by an unfilterable field"""
def __init__(self, field: str):
self.message = f"Cannot filter on {field}"
super().__init__(self.message)
def __str__(self):
return f"{self.message}"
class QueryFilterJSONPart(MealieModel): class QueryFilterJSONPart(MealieModel):
left_parenthesis: str | None = None left_parenthesis: str | None = None
right_parenthesis: str | None = None right_parenthesis: str | None = None
@@ -202,7 +213,7 @@ class QueryFilterBuilder:
@classmethod @classmethod
def get_model_and_model_attr_from_attr_string[Model: SqlAlchemyBase]( def get_model_and_model_attr_from_attr_string[Model: SqlAlchemyBase](
cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, sa.Select | None]: ) -> tuple[type[SqlAlchemyBase], InstrumentedAttribute, sa.Select | None]:
""" """
Take an attribute string and traverse a database model and its relationships to get the desired 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. model and model attribute. Optionally provide a query to apply the necessary table joins.
@@ -222,7 +233,7 @@ class QueryFilterBuilder:
if not attribute_chain: if not attribute_chain:
raise ValueError("invalid query string: attribute name cannot be empty") raise ValueError("invalid query string: attribute name cannot be empty")
current_model: SqlAlchemyBase = model # type: ignore current_model: type[SqlAlchemyBase] = model
for i, attribute_link in enumerate(attribute_chain): for i, attribute_link in enumerate(attribute_chain):
try: try:
model_attr = getattr(current_model, attribute_link) model_attr = getattr(current_model, attribute_link)
@@ -259,6 +270,9 @@ class QueryFilterBuilder:
if model_attr is None: if model_attr is None:
raise ValueError(f"invalid attribute string: '{attr_string}'") raise ValueError(f"invalid attribute string: '{attr_string}'")
if not getattr(model_attr, "info", {}).get("filterable"):
raise NonFilterableValueError(model_attr)
return current_model, model_attr, query return current_model, model_attr, query
@classmethod @classmethod
@@ -334,7 +348,7 @@ class QueryFilterBuilder:
column_aliases = column_aliases or {} column_aliases = column_aliases or {}
# join tables and build model chain # join tables and build model chain
attr_model_map: dict[int, Any] = {} attr_map: dict[int, tuple[type[SqlAlchemyBase], InstrumentedAttribute]] = {}
model_attr: InstrumentedAttribute model_attr: InstrumentedAttribute
for i, component in enumerate(self.filter_components): for i, component in enumerate(self.filter_components):
if not isinstance(component, QueryFilterBuilderComponent): if not isinstance(component, QueryFilterBuilderComponent):
@@ -343,7 +357,7 @@ class QueryFilterBuilder:
nested_model, model_attr, query = self.get_model_and_model_attr_from_attr_string( nested_model, model_attr, query = self.get_model_and_model_attr_from_attr_string(
component.attribute_name, model, query=query component.attribute_name, model, query=query
) )
attr_model_map[i] = nested_model attr_map[i] = (nested_model, model_attr)
# build query filter # build query filter
partial_group: list[sa.ColumnElement] = [] partial_group: list[sa.ColumnElement] = []
@@ -367,9 +381,9 @@ class QueryFilterBuilder:
else: else:
component = cast(QueryFilterBuilderComponent, component) component = cast(QueryFilterBuilderComponent, component)
base_attribute_name = component.attribute_name.split(".")[-1] nested_model, model_attr = attr_map[i]
model_attr = getattr(attr_model_map[i], base_attribute_name)
base_attribute_name = component.attribute_name.split(".")[-1]
if (column_alias := column_aliases.get(base_attribute_name)) is not None: if (column_alias := column_aliases.get(base_attribute_name)) is not None:
model_attr = column_alias model_attr = column_alias

View File

@@ -1,5 +1,11 @@
import pytest
import sqlalchemy as sa
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.users.users import LongLiveToken, User
from mealie.services.query_filter.builder import ( from mealie.services.query_filter.builder import (
LogicalOperator, LogicalOperator,
NonFilterableValueError,
QueryFilterBuilder, QueryFilterBuilder,
QueryFilterJSON, QueryFilterJSON,
QueryFilterJSONPart, QueryFilterJSONPart,
@@ -74,3 +80,80 @@ def test_query_filter_builder_json_uses_raw_value():
), ),
] ]
) )
# ---------------------------------------------------------------------------
# FilterableColumn tests
# ---------------------------------------------------------------------------
def test_non_filterable_field_user_password_raises():
"""Filtering on User.password (plain Mapped, not FilterableColumn) should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("password", User)
def test_non_filterable_field_user_email_raises():
"""Filtering on User.email (plain Mapped, not FilterableColumn) should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("email", User)
def test_non_filterable_field_long_live_token_raises():
"""Filtering on LongLiveToken.token (plain Mapped, not FilterableColumn) should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("token", LongLiveToken)
def test_filterable_field_does_not_raise():
"""Filtering on a FilterableColumn field should not raise."""
model, attr, _ = QueryFilterBuilder.get_model_and_model_attr_from_attr_string("full_name", User)
assert model is User
assert attr is User.full_name
# ---------------------------------------------------------------------------
# Relationship traversal tests
# ---------------------------------------------------------------------------
def test_deep_traversal_to_filterable_field_works():
"""Traversing a relationship to a FilterableColumn field should succeed."""
model, attr, _ = QueryFilterBuilder.get_model_and_model_attr_from_attr_string("user.full_name", RecipeModel)
assert model is User
assert attr is User.full_name
def test_deep_traversal_to_non_filterable_field_raises():
"""Traversing a relationship to a plain Mapped field should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("user.email", RecipeModel)
def test_deep_traversal_user_password_raises():
"""Traversing RecipeModel.user.password should raise ValueError."""
with pytest.raises(NonFilterableValueError):
QueryFilterBuilder.get_model_and_model_attr_from_attr_string("user.password", RecipeModel)
def test_filter_query_user_email_raises():
"""filter_query on user.email should raise ValueError."""
query = sa.select(RecipeModel)
builder = QueryFilterBuilder('user.email = "test@example.com"')
with pytest.raises(NonFilterableValueError):
builder.filter_query(query, RecipeModel)
def test_filter_query_user_password_raises():
"""filter_query on user.password should raise ValueError."""
query = sa.select(RecipeModel)
builder = QueryFilterBuilder('user.password = "secret"')
with pytest.raises(NonFilterableValueError):
builder.filter_query(query, RecipeModel)
def test_association_proxy_resolving_to_filterable_field_works():
"""Single-hop association proxy (e.g. household_id) resolving to a FilterableColumn should succeed."""
model, attr, _ = QueryFilterBuilder.get_model_and_model_attr_from_attr_string("household_id", RecipeModel)
assert model is User
assert attr is User.household_id

View File

@@ -13,8 +13,8 @@ from mealie.db.models.household.household import Household
from mealie.db.models.household.household_to_recipe import HouseholdToRecipe from mealie.db.models.household.household_to_recipe import HouseholdToRecipe
from mealie.db.models.household.mealplan import GroupMealPlanRules from mealie.db.models.household.mealplan import GroupMealPlanRules
from mealie.db.models.household.shopping_list import ShoppingList from mealie.db.models.household.shopping_list import ShoppingList
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.labels import MultiPurposeLabel
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tool import Tool from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.user_to_recipe import UserToRecipe from mealie.db.models.users.user_to_recipe import UserToRecipe