mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-25 19:20:26 -04:00
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:
@@ -17,7 +17,7 @@ from sqlalchemy.orm import Session, load_only
|
||||
|
||||
from alembic import op
|
||||
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
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
||||
@@ -8,8 +8,8 @@ from mealie.core import root_logger
|
||||
from mealie.db.models._model_base import SqlAlchemyBase
|
||||
from mealie.db.models.group.group import Group
|
||||
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.labels import MultiPurposeLabel
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
from mealie.db.models.users.users import User
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from .group import *
|
||||
from .labels import *
|
||||
from .recipe import *
|
||||
from .server import *
|
||||
from .users import *
|
||||
|
||||
23
mealie/db/models/_filterable_column.py
Normal file
23
mealie/db/models/_filterable_column.py
Normal 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
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import Integer
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym
|
||||
from text_unidecode import unidecode
|
||||
|
||||
from ._filterable_column import FilterableColumn
|
||||
from ._model_utils.datetime import NaiveDateTime, get_utc_now
|
||||
|
||||
# Punctuation characters replaced with spaces during text normalization.
|
||||
@@ -16,8 +17,10 @@ _NORMALIZE_PUNCTUATION_TABLE = str.maketrans(NORMALIZE_PUNCTUATION, " " * len(NO
|
||||
|
||||
class SqlAlchemyBase(DeclarativeBase):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
created_at: Mapped[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)
|
||||
created_at: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True)
|
||||
update_at: FilterableColumn[datetime | None] = mapped_column(
|
||||
NaiveDateTime, default=get_utc_now, onupdate=get_utc_now
|
||||
)
|
||||
|
||||
@declared_attr
|
||||
def updated_at(cls) -> Mapped[datetime | None]:
|
||||
|
||||
@@ -5,9 +5,9 @@ import sqlalchemy.orm as orm
|
||||
from pydantic import ConfigDict
|
||||
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.guid import GUID
|
||||
from ..household.cookbook import CookBook
|
||||
@@ -31,9 +31,9 @@ if TYPE_CHECKING:
|
||||
|
||||
class Group(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "groups"
|
||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
|
||||
slug: Mapped[str | None] = mapped_column(sa.String, index=True, unique=True)
|
||||
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
|
||||
slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True, unique=True)
|
||||
households: Mapped[list["Household"]] = orm.relationship("Household", 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)
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Optional
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, UniqueConstraint, orm
|
||||
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.auto_init import auto_init
|
||||
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"),
|
||||
)
|
||||
|
||||
id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
id: FilterableColumn[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
|
||||
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")
|
||||
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")
|
||||
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(String, default="")
|
||||
public: Mapped[str | None] = mapped_column(Boolean, default=False)
|
||||
name: FilterableColumn[str] = mapped_column(String, nullable=False)
|
||||
slug: FilterableColumn[str] = mapped_column(String, nullable=False, index=True)
|
||||
description: FilterableColumn[str | None] = mapped_column(String, default="")
|
||||
public: FilterableColumn[str | None] = mapped_column(Boolean, default=False)
|
||||
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
|
||||
|
||||
# Old filters - deprecated in favor of query filter strings
|
||||
categories: Mapped[list[Category]] = orm.relationship(
|
||||
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)
|
||||
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)
|
||||
require_all_tools: Mapped[bool | None] = mapped_column(Boolean, default=True)
|
||||
require_all_tools: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
|
||||
@@ -5,7 +5,7 @@ import sqlalchemy.orm as orm
|
||||
from pydantic import ConfigDict
|
||||
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.guid import GUID
|
||||
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"),
|
||||
)
|
||||
|
||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
|
||||
slug: Mapped[str | None] = mapped_column(sa.String, index=True)
|
||||
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
name: FilterableColumn[str] = mapped_column(sa.String, index=True, nullable=False)
|
||||
slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
|
||||
|
||||
invite_tokens: Mapped[list["GroupInviteToken"]] = orm.relationship(
|
||||
"GroupInviteToken", back_populates="household", cascade="all, delete-orphan"
|
||||
@@ -48,7 +48,7 @@ class Household(SqlAlchemyBase, BaseMixins):
|
||||
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")
|
||||
users: Mapped[list["User"]] = orm.relationship("User", back_populates="household")
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
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.guid import GUID
|
||||
from ..recipe.category import Category, plan_rules_to_categories
|
||||
@@ -30,14 +30,14 @@ plan_rules_to_households = Table(
|
||||
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
|
||||
__tablename__ = "group_meal_plan_rules"
|
||||
|
||||
id: Mapped[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)
|
||||
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
|
||||
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
group_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, 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"
|
||||
) # "MONDAY", "TUESDAY", "WEDNESDAY", etc...
|
||||
entry_type: Mapped[str] = mapped_column(
|
||||
entry_type: FilterableColumn[str] = mapped_column(
|
||||
String, nullable=False, default=""
|
||||
) # "breakfast", "lunch", "dinner", etc ...
|
||||
query_filter_string: Mapped[str] = mapped_column(String, nullable=False, default="")
|
||||
@@ -55,19 +55,19 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
|
||||
class GroupMealPlan(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "group_meal_plans"
|
||||
|
||||
date: Mapped[datetime.date] = mapped_column(Date, index=True, nullable=False)
|
||||
entry_type: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
text: Mapped[str] = mapped_column(String, nullable=False)
|
||||
date: FilterableColumn[datetime.date] = mapped_column(Date, index=True, nullable=False)
|
||||
entry_type: FilterableColumn[str] = mapped_column(String, index=True, nullable=False)
|
||||
title: FilterableColumn[str] = mapped_column(String, index=True, 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")
|
||||
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
|
||||
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")
|
||||
|
||||
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="meal_entries", uselist=False
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import sqlalchemy.orm as orm
|
||||
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
|
||||
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.guid import GUID
|
||||
|
||||
@@ -15,27 +15,29 @@ if TYPE_CHECKING:
|
||||
|
||||
class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
|
||||
__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")
|
||||
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
|
||||
|
||||
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||
show_announcements: Mapped[bool] = mapped_column(sa.Boolean, default=True)
|
||||
private_household: FilterableColumn[bool | None] = 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)
|
||||
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
|
||||
lock_recipe_edits_from_other_households: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||
first_day_of_week: FilterableColumn[int | None] = mapped_column(sa.Integer, default=0)
|
||||
|
||||
# Recipe Defaults
|
||||
recipe_public: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||
recipe_show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
recipe_show_assets: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
recipe_landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
recipe_disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
recipe_public: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||
recipe_show_nutrition: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
recipe_show_assets: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
recipe_landscape_view: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
recipe_disable_comments: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
|
||||
# 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()
|
||||
def __init__(self, **_) -> None:
|
||||
|
||||
@@ -8,10 +8,10 @@ from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
|
||||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
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.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.guid import GUID
|
||||
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
||||
@@ -25,18 +25,20 @@ if TYPE_CHECKING:
|
||||
|
||||
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
|
||||
__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(
|
||||
"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_quantity: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
recipe_scale: Mapped[float] = mapped_column(Float, default=1)
|
||||
recipe_note: Mapped[str | None] = mapped_column(String)
|
||||
recipe_quantity: FilterableColumn[float] = mapped_column(Float, nullable=False)
|
||||
recipe_scale: FilterableColumn[float] = mapped_column(Float, default=1)
|
||||
recipe_note: FilterableColumn[str | None] = mapped_column(String)
|
||||
|
||||
group_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "group_id")
|
||||
household_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "household_id")
|
||||
@@ -50,33 +52,33 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "shopping_list_items"
|
||||
|
||||
# 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_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")
|
||||
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
|
||||
|
||||
# Meta
|
||||
is_ingredient: Mapped[bool | None] = mapped_column(Boolean, default=True)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||
checked: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
is_ingredient: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
|
||||
position: FilterableColumn[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||
checked: FilterableColumn[bool | None] = mapped_column(Boolean, default=False)
|
||||
|
||||
quantity: Mapped[float | None] = mapped_column(Float, default=1)
|
||||
note: Mapped[str | None] = mapped_column(String)
|
||||
quantity: FilterableColumn[float | None] = mapped_column(Float, default=1)
|
||||
note: FilterableColumn[str | None] = mapped_column(String)
|
||||
|
||||
extras: Mapped[list[ShoppingListItemExtras]] = orm.relationship(
|
||||
"ShoppingListItemExtras", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
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)
|
||||
|
||||
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(
|
||||
MultiPurposeLabel, uselist=False, back_populates="shopping_list_items"
|
||||
)
|
||||
@@ -98,19 +100,19 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
|
||||
__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_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")
|
||||
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(
|
||||
"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"})
|
||||
|
||||
@auto_init()
|
||||
@@ -121,12 +123,12 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
|
||||
class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "shopping_lists_multi_purpose_labels"
|
||||
__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")
|
||||
|
||||
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(
|
||||
"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")
|
||||
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"})
|
||||
|
||||
@auto_init()
|
||||
@@ -144,16 +146,16 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
class ShoppingList(SqlAlchemyBase, BaseMixins):
|
||||
__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")
|
||||
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
|
||||
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")
|
||||
|
||||
name: Mapped[str | None] = mapped_column(String)
|
||||
name: FilterableColumn[str | None] = mapped_column(String)
|
||||
list_items: Mapped[list[ShoppingListItem]] = orm.relationship(
|
||||
ShoppingListItem,
|
||||
cascade="all, delete, delete-orphan",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ class ExtrasGeneric:
|
||||
This class is not an actual table, so it does not inherit from SqlAlchemyBase
|
||||
"""
|
||||
|
||||
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
|
||||
key_name: Mapped[str | None] = mapped_column(sa.String)
|
||||
value: Mapped[str | None] = mapped_column(sa.String)
|
||||
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
|
||||
key_name: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
value: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
|
||||
def __init__(self, key, value) -> None:
|
||||
self.key_name = key
|
||||
@@ -40,21 +40,25 @@ class ExtrasGeneric:
|
||||
# used specifically for recipe extras
|
||||
class ApiExtras(ExtrasGeneric, SqlAlchemyBase):
|
||||
__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):
|
||||
__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):
|
||||
__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):
|
||||
__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
|
||||
)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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
|
||||
|
||||
|
||||
class RecipeAsset(SqlAlchemyBase):
|
||||
__tablename__ = "recipe_assets"
|
||||
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
|
||||
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
|
||||
name: Mapped[str | None] = mapped_column(sa.String)
|
||||
icon: Mapped[str | None] = mapped_column(sa.String)
|
||||
file_name: Mapped[str | None] = mapped_column(sa.String)
|
||||
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
|
||||
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
|
||||
name: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
icon: FilterableColumn[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:
|
||||
self.name = name
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, validates
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -54,12 +54,12 @@ class Category(SqlAlchemyBase, BaseMixins):
|
||||
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),)
|
||||
|
||||
# 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])
|
||||
|
||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
|
||||
slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
|
||||
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
name: FilterableColumn[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(
|
||||
"RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category"
|
||||
)
|
||||
|
||||
@@ -6,9 +6,9 @@ from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models.labels import MultiPurposeLabel
|
||||
from mealie.db.models._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
|
||||
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.guid import GUID
|
||||
@@ -29,19 +29,19 @@ households_to_ingredient_foods = sa.Table(
|
||||
|
||||
class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
|
||||
__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
|
||||
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])
|
||||
|
||||
name: Mapped[str | None] = mapped_column(String)
|
||||
plural_name: Mapped[str | None] = mapped_column(String)
|
||||
description: Mapped[str | None] = mapped_column(String)
|
||||
abbreviation: Mapped[str | None] = mapped_column(String)
|
||||
plural_abbreviation: Mapped[str | None] = mapped_column(String)
|
||||
use_abbreviation: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
fraction: Mapped[bool | None] = mapped_column(Boolean, default=True)
|
||||
name: FilterableColumn[str | None] = mapped_column(String)
|
||||
plural_name: FilterableColumn[str | None] = mapped_column(String)
|
||||
description: FilterableColumn[str | None] = mapped_column(String)
|
||||
abbreviation: FilterableColumn[str | None] = mapped_column(String)
|
||||
plural_abbreviation: FilterableColumn[str | None] = mapped_column(String)
|
||||
use_abbreviation: FilterableColumn[bool | None] = mapped_column(Boolean, default=False)
|
||||
fraction: FilterableColumn[bool | None] = mapped_column(Boolean, default=True)
|
||||
|
||||
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
|
||||
"RecipeIngredientModel", back_populates="unit"
|
||||
@@ -53,14 +53,14 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
|
||||
)
|
||||
|
||||
# Standardization
|
||||
standard_quantity: Mapped[float | None] = mapped_column(Float)
|
||||
standard_unit: Mapped[str | None] = mapped_column(String)
|
||||
standard_quantity: FilterableColumn[float | None] = mapped_column(Float)
|
||||
standard_unit: FilterableColumn[str | None] = mapped_column(String)
|
||||
|
||||
# Automatically updated by sqlalchemy event, do not write to this manually
|
||||
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
|
||||
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
|
||||
abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True)
|
||||
plural_abbreviation_normalized: Mapped[str | None] = mapped_column(String, index=True)
|
||||
name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
|
||||
plural_name_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
|
||||
abbreviation_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
|
||||
plural_abbreviation_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
|
||||
|
||||
@auto_init()
|
||||
def __init__(
|
||||
@@ -152,18 +152,18 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
|
||||
__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
|
||||
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])
|
||||
households_with_ingredient_food: Mapped[list["Household"]] = orm.relationship(
|
||||
"Household", secondary=households_to_ingredient_foods, back_populates="ingredient_foods_on_hand"
|
||||
)
|
||||
|
||||
name: Mapped[str | None] = mapped_column(String)
|
||||
plural_name: Mapped[str | None] = mapped_column(String)
|
||||
description: Mapped[str | None] = mapped_column(String)
|
||||
name: FilterableColumn[str | None] = mapped_column(String)
|
||||
plural_name: FilterableColumn[str | None] = mapped_column(String)
|
||||
description: FilterableColumn[str | None] = mapped_column(String)
|
||||
|
||||
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
|
||||
"RecipeIngredientModel", back_populates="food"
|
||||
@@ -175,12 +175,12 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
|
||||
)
|
||||
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")
|
||||
|
||||
# Automatically updated by sqlalchemy event, do not write to this manually
|
||||
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
|
||||
plural_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: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
|
||||
|
||||
model_config = ConfigDict(
|
||||
exclude={
|
||||
@@ -261,15 +261,15 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
|
||||
__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")
|
||||
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
name: FilterableColumn[str] = mapped_column(String)
|
||||
|
||||
# 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()
|
||||
def __init__(self, session: Session, name: str, **_) -> None:
|
||||
@@ -302,15 +302,15 @@ class IngredientUnitAliasModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
|
||||
__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")
|
||||
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
name: FilterableColumn[str] = mapped_column(String)
|
||||
|
||||
# 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()
|
||||
def __init__(self, session: Session, name: str, **_) -> None:
|
||||
@@ -343,34 +343,34 @@ class IngredientFoodAliasModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
class RecipeIngredientModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipes_ingredients"
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
position: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
|
||||
id: FilterableColumn[int] = mapped_column(Integer, primary_key=True)
|
||||
position: FilterableColumn[int | None] = mapped_column(Integer, index=True)
|
||||
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
|
||||
|
||||
title: Mapped[str | None] = mapped_column(String) # Section Header - Shows if Present
|
||||
note: Mapped[str | None] = mapped_column(String) # Force Show Text - Overrides Concat
|
||||
title: FilterableColumn[str | None] = mapped_column(String) # Section Header - Shows if Present
|
||||
note: FilterableColumn[str | None] = mapped_column(String) # Force Show Text - Overrides Concat
|
||||
|
||||
# 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)
|
||||
|
||||
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)
|
||||
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
|
||||
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(
|
||||
"RecipeModel", back_populates="referenced_ingredients", foreign_keys=[referenced_recipe_id]
|
||||
)
|
||||
|
||||
# Automatically updated by sqlalchemy event, do not write to this manually
|
||||
note_normalized: Mapped[str | None] = mapped_column(String, index=True)
|
||||
original_text_normalized: Mapped[str | None] = mapped_column(String, index=True)
|
||||
note_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
|
||||
original_text_normalized: FilterableColumn[str | None] = mapped_column(String, index=True)
|
||||
|
||||
@auto_init()
|
||||
def __init__(
|
||||
|
||||
@@ -3,26 +3,26 @@ from typing import TYPE_CHECKING
|
||||
from sqlalchemy import ForeignKey, String, UniqueConstraint, orm
|
||||
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.guid import GUID
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .group.group import Group
|
||||
from .household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
||||
from .recipe import IngredientFoodModel
|
||||
from ..group.group import Group
|
||||
from ..household.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
||||
from . import IngredientFoodModel
|
||||
|
||||
|
||||
class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "multi_purpose_labels"
|
||||
__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)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(10), nullable=False, default="")
|
||||
id: FilterableColumn[GUID] = mapped_column(GUID, default=GUID.generate, primary_key=True)
|
||||
name: FilterableColumn[str] = mapped_column(String(255), nullable=False)
|
||||
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")
|
||||
|
||||
shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label")
|
||||
@@ -1,22 +1,22 @@
|
||||
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
|
||||
|
||||
|
||||
class Nutrition(SqlAlchemyBase):
|
||||
__tablename__ = "recipe_nutrition"
|
||||
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
|
||||
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
|
||||
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
|
||||
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
|
||||
|
||||
calories: Mapped[str | None] = mapped_column(sa.String)
|
||||
carbohydrate_content: Mapped[str | None] = mapped_column(sa.String)
|
||||
cholesterol_content: Mapped[str | None] = mapped_column(sa.String)
|
||||
fat_content: Mapped[str | None] = mapped_column(sa.String)
|
||||
fiber_content: Mapped[str | None] = mapped_column(sa.String)
|
||||
protein_content: Mapped[str | None] = mapped_column(sa.String)
|
||||
saturated_fat_content: Mapped[str | None] = mapped_column(sa.String)
|
||||
calories: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
carbohydrate_content: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
cholesterol_content: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
fiber_content: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
protein_content: FilterableColumn[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
|
||||
# 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)
|
||||
|
||||
sodium_content: Mapped[str | None] = mapped_column(sa.String)
|
||||
sugar_content: Mapped[str | None] = mapped_column(sa.String)
|
||||
trans_fat_content: Mapped[str | None] = mapped_column(sa.String)
|
||||
unsaturated_fat_content: Mapped[str | None] = mapped_column(sa.String)
|
||||
sodium_content: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
sugar_content: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
trans_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
unsaturated_fat_content: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -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.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 ..users.user_to_recipe import UserToRecipe
|
||||
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"),
|
||||
)
|
||||
|
||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
slug: Mapped[str | None] = mapped_column(sa.String, index=True)
|
||||
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
slug: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
|
||||
|
||||
# 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])
|
||||
|
||||
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
|
||||
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])
|
||||
|
||||
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(
|
||||
"User",
|
||||
secondary=UserToRecipe.__tablename__,
|
||||
@@ -78,20 +78,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
)
|
||||
|
||||
# General Recipe Properties
|
||||
name: Mapped[str] = mapped_column(sa.String, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(sa.String)
|
||||
name: FilterableColumn[str] = mapped_column(sa.String, nullable=False)
|
||||
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
|
||||
total_time: Mapped[str | None] = mapped_column(sa.String)
|
||||
prep_time: Mapped[str | None] = mapped_column(sa.String)
|
||||
perform_time: Mapped[str | None] = mapped_column(sa.String)
|
||||
cook_time: Mapped[str | None] = mapped_column(sa.String)
|
||||
total_time: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
prep_time: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
perform_time: FilterableColumn[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_quantity: Mapped[float] = mapped_column(sa.Float, index=True, default=0)
|
||||
recipe_servings: Mapped[float] = mapped_column(sa.Float, index=True, default=0)
|
||||
recipe_yield: FilterableColumn[str | None] = mapped_column(sa.String)
|
||||
recipe_yield_quantity: FilterableColumn[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")
|
||||
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")
|
||||
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")
|
||||
|
||||
# Time Stamp Properties
|
||||
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today)
|
||||
date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime)
|
||||
date_added: FilterableColumn[date | None] = mapped_column(sa.Date, default=get_utc_today)
|
||||
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(
|
||||
"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
|
||||
name_normalized: Mapped[str] = mapped_column(sa.String, nullable=False, index=True)
|
||||
description_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
|
||||
name_normalized: FilterableColumn[str] = mapped_column(sa.String, nullable=False, index=True)
|
||||
description_normalized: FilterableColumn[str | None] = mapped_column(sa.String, index=True)
|
||||
model_config = ConfigDict(
|
||||
get_attr="slug",
|
||||
exclude={
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
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.guid import GUID
|
||||
|
||||
@@ -18,29 +18,29 @@ if TYPE_CHECKING:
|
||||
|
||||
class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins):
|
||||
__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
|
||||
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")
|
||||
|
||||
group_id: AssociationProxy[GUID] = association_proxy("recipe", "group_id")
|
||||
household_id: AssociationProxy[GUID] = association_proxy("recipe", "household_id")
|
||||
|
||||
# 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", back_populates="recipe_timeline_events", single_parent=True, foreign_keys=[user_id]
|
||||
)
|
||||
|
||||
# General Properties
|
||||
subject: Mapped[str] = mapped_column(String, nullable=False)
|
||||
message: Mapped[str | None] = mapped_column(String)
|
||||
event_type: Mapped[str | None] = mapped_column(String)
|
||||
image: Mapped[str | None] = mapped_column(String)
|
||||
subject: FilterableColumn[str] = mapped_column(String, nullable=False)
|
||||
message: FilterableColumn[str | None] = mapped_column(String)
|
||||
event_type: FilterableColumn[str | None] = mapped_column(String)
|
||||
image: FilterableColumn[str | None] = mapped_column(String)
|
||||
|
||||
# Timestamps
|
||||
timestamp: Mapped[datetime | None] = mapped_column(NaiveDateTime, index=True)
|
||||
timestamp: FilterableColumn[datetime | None] = mapped_column(NaiveDateTime, index=True)
|
||||
|
||||
@auto_init()
|
||||
def __init__(
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import sqlalchemy as sa
|
||||
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
|
||||
|
||||
|
||||
class RecipeSettings(SqlAlchemyBase):
|
||||
__tablename__ = "recipe_settings"
|
||||
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
|
||||
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
|
||||
public: Mapped[bool | None] = mapped_column(sa.Boolean)
|
||||
show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean)
|
||||
show_assets: Mapped[bool | None] = mapped_column(sa.Boolean)
|
||||
landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean)
|
||||
disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
locked: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
id: FilterableColumn[int] = mapped_column(sa.Integer, primary_key=True)
|
||||
recipe_id: FilterableColumn[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
|
||||
public: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
|
||||
show_nutrition: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
|
||||
show_assets: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
|
||||
landscape_view: FilterableColumn[bool | None] = mapped_column(sa.Boolean)
|
||||
disable_comments: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
locked: FilterableColumn[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
|
||||
# Deprecated
|
||||
disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
|
||||
|
||||
@@ -3,9 +3,9 @@ from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
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.datetime import NaiveDateTime
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
@@ -22,12 +22,12 @@ class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "recipe_share_tokens"
|
||||
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: Mapped["RecipeModel"] = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False)
|
||||
recipe_id: FilterableColumn[GUID] = mapped_column(GUID, sa.ForeignKey("recipes.id"), nullable=False, index=True)
|
||||
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()
|
||||
def __init__(self, **_) -> None:
|
||||
|
||||
@@ -6,7 +6,7 @@ from slugify import slugify
|
||||
from sqlalchemy.orm import Mapped, mapped_column, validates
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -44,14 +44,16 @@ cookbooks_to_tags = sa.Table(
|
||||
class Tag(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "tags"
|
||||
__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
|
||||
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])
|
||||
|
||||
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
|
||||
slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
|
||||
name: FilterableColumn[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(
|
||||
"RecipeModel", secondary=recipes_to_tags, back_populates="tags"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from slugify import slugify
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
|
||||
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.guid import GUID
|
||||
|
||||
@@ -42,14 +42,14 @@ cookbooks_to_tools = Table(
|
||||
class Tool(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "tools"
|
||||
__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
|
||||
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])
|
||||
|
||||
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
name: FilterableColumn[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(
|
||||
"Household", secondary=households_to_tools, back_populates="tools_on_hand"
|
||||
|
||||
@@ -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.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_base import BaseMixins, FilterableColumn, SqlAlchemyBase
|
||||
from .user_to_recipe import UserToRecipe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -50,18 +50,21 @@ class AuthMethod(enum.Enum):
|
||||
|
||||
class User(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "users"
|
||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
full_name: Mapped[str | None] = mapped_column(String, index=True)
|
||||
username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
|
||||
|
||||
id: FilterableColumn[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
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)
|
||||
password: Mapped[str | None] = mapped_column(String)
|
||||
auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
|
||||
admin: 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")
|
||||
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")
|
||||
|
||||
cache_key: Mapped[str | None] = mapped_column(String, default="1234")
|
||||
|
||||
@@ -25,10 +25,10 @@ from mealie.db.models.household.shopping_list import (
|
||||
ShoppingListRecipeReference,
|
||||
)
|
||||
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.comment import RecipeComment
|
||||
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_timeline import RecipeTimelineEvent
|
||||
from mealie.db.models.recipe.shared import RecipeShareTokenModel
|
||||
|
||||
@@ -25,7 +25,7 @@ from mealie.schema.response.pagination import (
|
||||
RequestQuery,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -467,6 +467,12 @@ class RepositoryGeneric[Schema: MealieModel, Model: SqlAlchemyBase]:
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
||||
@@ -22,6 +22,17 @@ from .keywords import PlaceholderKeyword, RelationalKeyword
|
||||
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):
|
||||
left_parenthesis: str | None = None
|
||||
right_parenthesis: str | None = None
|
||||
@@ -202,7 +213,7 @@ class QueryFilterBuilder:
|
||||
@classmethod
|
||||
def get_model_and_model_attr_from_attr_string[Model: SqlAlchemyBase](
|
||||
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
|
||||
model and model attribute. Optionally provide a query to apply the necessary table joins.
|
||||
@@ -222,7 +233,7 @@ class QueryFilterBuilder:
|
||||
if not attribute_chain:
|
||||
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):
|
||||
try:
|
||||
model_attr = getattr(current_model, attribute_link)
|
||||
@@ -259,6 +270,9 @@ class QueryFilterBuilder:
|
||||
if model_attr is None:
|
||||
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
|
||||
|
||||
@classmethod
|
||||
@@ -334,7 +348,7 @@ class QueryFilterBuilder:
|
||||
column_aliases = column_aliases or {}
|
||||
|
||||
# join tables and build model chain
|
||||
attr_model_map: dict[int, Any] = {}
|
||||
attr_map: dict[int, tuple[type[SqlAlchemyBase], InstrumentedAttribute]] = {}
|
||||
model_attr: InstrumentedAttribute
|
||||
for i, component in enumerate(self.filter_components):
|
||||
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(
|
||||
component.attribute_name, model, query=query
|
||||
)
|
||||
attr_model_map[i] = nested_model
|
||||
attr_map[i] = (nested_model, model_attr)
|
||||
|
||||
# build query filter
|
||||
partial_group: list[sa.ColumnElement] = []
|
||||
@@ -367,9 +381,9 @@ class QueryFilterBuilder:
|
||||
|
||||
else:
|
||||
component = cast(QueryFilterBuilderComponent, component)
|
||||
base_attribute_name = component.attribute_name.split(".")[-1]
|
||||
model_attr = getattr(attr_model_map[i], base_attribute_name)
|
||||
nested_model, model_attr = attr_map[i]
|
||||
|
||||
base_attribute_name = component.attribute_name.split(".")[-1]
|
||||
if (column_alias := column_aliases.get(base_attribute_name)) is not None:
|
||||
model_attr = column_alias
|
||||
|
||||
|
||||
@@ -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 (
|
||||
LogicalOperator,
|
||||
NonFilterableValueError,
|
||||
QueryFilterBuilder,
|
||||
QueryFilterJSON,
|
||||
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
|
||||
|
||||
@@ -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.mealplan import GroupMealPlanRules
|
||||
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.labels import MultiPurposeLabel
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
from mealie.db.models.recipe.tool import Tool
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
|
||||
Reference in New Issue
Block a user