mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-06-02 07:00: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 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 *
|
||||||
|
|||||||
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 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]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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__(
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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__(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user