feat: Add Households to Mealie (#3970)

This commit is contained in:
Michael Genson
2024-08-22 10:14:32 -05:00
committed by GitHub
parent 0c29cef17d
commit eb170cc7e5
315 changed files with 6975 additions and 3577 deletions

View File

@@ -26,7 +26,7 @@ SessionLocal, engine = sql_global_init(settings.DB_URL) # type: ignore
@contextmanager
def session_context() -> Session:
def session_context() -> Generator[Session, None, None]:
"""
session_context() provides a managed session to the database that is automatically
closed when the context is exited. This is the preferred method of accessing the

View File

@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
from mealie.core import root_logger
from mealie.db.models.group.group import Group
from mealie.db.models.group.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.recipe import RecipeModel

View File

@@ -17,25 +17,38 @@ from mealie.db.fixes.fix_slug_foods import fix_slug_food_names
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories
from mealie.repos.seed.init_users import default_user_init
from mealie.schema.user.user import GroupBase
from mealie.schema.household.household import HouseholdCreate, HouseholdInDB
from mealie.schema.user.user import GroupBase, GroupInDB
from mealie.services.group_services.group_service import GroupService
from mealie.services.household_services.household_service import HouseholdService
PROJECT_DIR = Path(__file__).parent.parent.parent
logger = root_logger.get_logger()
def init_db(db: AllRepositories) -> None:
default_group_init(db)
default_user_init(db)
def default_group_init(db: AllRepositories):
def init_db(session: orm.Session) -> None:
settings = get_app_settings()
logger.info("Generating Default Group")
instance_repos = get_repositories(session)
default_group = default_group_init(instance_repos, settings.DEFAULT_GROUP)
GroupService.create_group(db, GroupBase(name=settings.DEFAULT_GROUP))
group_repos = get_repositories(session, group_id=default_group.id, household_id=None)
default_household = group_repos.households.get_by_name(settings.DEFAULT_HOUSEHOLD)
if default_household is None:
default_household = default_household_init(group_repos, settings.DEFAULT_HOUSEHOLD)
household_repos = get_repositories(session, group_id=default_group.id, household_id=default_household.id)
default_user_init(household_repos)
def default_group_init(repos: AllRepositories, name: str) -> GroupInDB:
logger.info("Generating Default Group and Household")
return GroupService.create_group(repos, GroupBase(name=name))
def default_household_init(repos: AllRepositories, name: str) -> HouseholdInDB:
logger.info("Generating Default Household")
return HouseholdService.create_household(repos, HouseholdCreate(name=name))
# Adapted from https://alembic.sqlalchemy.org/en/latest/cookbook.html#test-current-database-revision-is-at-head-s
@@ -103,7 +116,7 @@ def main():
if session.get_bind().name == "postgresql": # needed for fuzzy search and fast GIN text indices
session.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm;"))
db = get_repositories(session)
db = get_repositories(session, group_id=None, household_id=None)
safe_try(lambda: fix_migration_data(session))
safe_try(lambda: fix_slug_food_names(db))
safe_try(lambda: fix_group_with_no_name(session))
@@ -112,7 +125,7 @@ def main():
logger.debug("Database exists")
else:
logger.info("Database contains no users, initializing...")
init_db(db)
init_db(session)
if __name__ == "__main__":

View File

@@ -1,7 +1,7 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym
from text_unidecode import unidecode
from ._model_utils.datetime import get_utc_now
@@ -12,6 +12,10 @@ class SqlAlchemyBase(DeclarativeBase):
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=get_utc_now, index=True)
update_at: Mapped[datetime | None] = mapped_column(DateTime, default=get_utc_now, onupdate=get_utc_now)
@declared_attr
def updated_at(cls) -> Mapped[datetime | None]:
return synonym("update_at")
@classmethod
def normalize(cls, val: str) -> str:
return unidecode(val).lower().strip()

View File

@@ -1,11 +1,4 @@
from .cookbook import *
from .events import *
from .exports import *
from .group import *
from .invite_tokens import *
from .mealplan import *
from .preferences import *
from .recipe_action import *
from .report import *
from .shopping_list import *
from .webhooks import *

View File

@@ -1,40 +1,32 @@
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
import sqlalchemy as sa
import sqlalchemy.orm as orm
from pydantic import ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_settings
from mealie.db.models.labels import MultiPurposeLabel
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
from ..group.invite_tokens import GroupInviteToken
from ..group.webhooks import GroupWebhooksModel
from ..household.cookbook import CookBook
from ..household.invite_tokens import GroupInviteToken
from ..household.mealplan import GroupMealPlan
from ..household.webhooks import GroupWebhooksModel
from ..recipe.category import Category, group_to_categories
from ..server.task import ServerTaskModel
from .cookbook import CookBook
from .mealplan import GroupMealPlan
from .preferences import GroupPreferencesModel
if TYPE_CHECKING:
from ..recipe import (
IngredientFoodModel,
IngredientUnitModel,
RecipeModel,
Tag,
Tool,
)
from ..household import Household
from ..household.events import GroupEventNotifierModel
from ..household.recipe_action import GroupRecipeAction
from ..household.shopping_list import ShoppingList
from ..recipe import IngredientFoodModel, IngredientUnitModel, RecipeModel, Tag, Tool
from ..users import User
from .events import GroupEventNotifierModel
from .exports import GroupDataExportsModel
from .recipe_action import GroupRecipeAction
from .report import ReportModel
from .shopping_list import ShoppingList
class Group(SqlAlchemyBase, BaseMixins):
@@ -42,6 +34,7 @@ class Group(SqlAlchemyBase, BaseMixins):
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
slug: Mapped[str | None] = mapped_column(sa.String, index=True, unique=True)
households: Mapped[list["Household"]] = orm.relationship("Household", back_populates="group")
users: Mapped[list["User"]] = orm.relationship("User", back_populates="group")
categories: Mapped[list[Category]] = orm.relationship(Category, secondary=group_to_categories, single_parent=True)
@@ -89,6 +82,7 @@ class Group(SqlAlchemyBase, BaseMixins):
tags: Mapped[list["Tag"]] = orm.relationship("Tag", **common_args)
model_config = ConfigDict(
exclude={
"households",
"users",
"webhooks",
"recipe_actions",
@@ -104,12 +98,3 @@ class Group(SqlAlchemyBase, BaseMixins):
@auto_init()
def __init__(self, **_) -> None:
pass
@staticmethod # TODO: Remove this
def get_by_name(session: Session, name: str) -> Optional["Group"]:
settings = get_app_settings()
item = session.execute(select(Group).filter(Group.name == name)).scalars().one_or_none()
if item is None:
item = session.execute(select(Group).filter(Group.name == settings.DEFAULT_GROUP)).scalars().one_or_none()
return item

View File

@@ -20,9 +20,9 @@ class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="preferences")
private_group: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
# Recipe Defaults
# Deprecated (see household preferences)
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
recipe_public: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_show_assets: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)

View File

@@ -0,0 +1,35 @@
from .cookbook import CookBook
from .events import GroupEventNotifierModel, GroupEventNotifierOptionsModel
from .household import Household
from .invite_tokens import GroupInviteToken
from .mealplan import GroupMealPlan, GroupMealPlanRules
from .preferences import HouseholdPreferencesModel
from .recipe_action import GroupRecipeAction
from .shopping_list import (
ShoppingList,
ShoppingListExtras,
ShoppingListItem,
ShoppingListItemRecipeReference,
ShoppingListMultiPurposeLabel,
ShoppingListRecipeReference,
)
from .webhooks import GroupWebhooksModel
__all__ = [
"CookBook",
"GroupEventNotifierModel",
"GroupEventNotifierOptionsModel",
"GroupInviteToken",
"GroupMealPlan",
"GroupMealPlanRules",
"Household",
"HouseholdPreferencesModel",
"GroupRecipeAction",
"ShoppingList",
"ShoppingListExtras",
"ShoppingListItem",
"ShoppingListItemRecipeReference",
"ShoppingListMultiPurposeLabel",
"ShoppingListRecipeReference",
"GroupWebhooksModel",
]

View File

@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String, orm
from sqlalchemy import Boolean, ForeignKey, Integer, String, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase
@@ -11,16 +11,23 @@ from ..recipe.tag import Tag, cookbooks_to_tags
from ..recipe.tool import Tool, cookbooks_to_tools
if TYPE_CHECKING:
from .group import Group
from ..group import Group
from .household import Household
class CookBook(SqlAlchemyBase, BaseMixins):
__tablename__ = "cookbooks"
__table_args__: tuple[UniqueConstraint, ...] = (
UniqueConstraint("slug", "group_id", name="cookbook_slug_group_id_key"),
)
id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
group_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("groups.id"), index=True)
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="cookbooks")
household_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("households.id"), index=True)
household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="cookbooks")
name: Mapped[str] = mapped_column(String, nullable=False)
slug: Mapped[str] = mapped_column(String, nullable=False, index=True)

View File

@@ -8,7 +8,8 @@ from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from .group import Group
from ..group import Group
from .household import Household
class GroupEventNotifierOptionsModel(SqlAlchemyBase, BaseMixins):
@@ -62,6 +63,10 @@ class GroupEventNotifierModel(SqlAlchemyBase, BaseMixins):
"Group", back_populates="group_event_notifiers", single_parent=True
)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
household: Mapped[Optional["Household"]] = orm.relationship(
"Household", back_populates="group_event_notifiers", single_parent=True
)
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
options: Mapped[GroupEventNotifierOptionsModel] = orm.relationship(
GroupEventNotifierOptionsModel, uselist=False, cascade="all, delete-orphan"

View File

@@ -0,0 +1,80 @@
from typing import TYPE_CHECKING
import sqlalchemy as sa
import sqlalchemy.orm as orm
from pydantic import ConfigDict
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from ..group import Group
from ..users import User
from . import (
CookBook,
GroupEventNotifierModel,
GroupInviteToken,
GroupRecipeAction,
GroupWebhooksModel,
HouseholdPreferencesModel,
)
class Household(SqlAlchemyBase, BaseMixins):
__tablename__ = "households"
__table_args__ = (
sa.UniqueConstraint("group_id", "name", name="household_name_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)
name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
slug: Mapped[str | None] = mapped_column(sa.String, index=True)
invite_tokens: Mapped[list["GroupInviteToken"]] = orm.relationship(
"GroupInviteToken", back_populates="household", cascade="all, delete-orphan"
)
preferences: Mapped["HouseholdPreferencesModel"] = orm.relationship(
"HouseholdPreferencesModel",
back_populates="household",
uselist=False,
single_parent=True,
cascade="all, delete-orphan",
)
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="households")
users: Mapped[list["User"]] = orm.relationship("User", back_populates="household")
COMMON_ARGS = {
"back_populates": "household",
"cascade": "all, delete-orphan",
"single_parent": True,
}
recipe_actions: Mapped[list["GroupRecipeAction"]] = orm.relationship("GroupRecipeAction", **COMMON_ARGS)
cookbooks: Mapped[list["CookBook"]] = orm.relationship("CookBook", **COMMON_ARGS)
webhooks: Mapped[list["GroupWebhooksModel"]] = orm.relationship("GroupWebhooksModel", **COMMON_ARGS)
group_event_notifiers: Mapped[list["GroupEventNotifierModel"]] = orm.relationship(
"GroupEventNotifierModel", **COMMON_ARGS
)
model_config = ConfigDict(
exclude={
"users",
"webhooks",
"recipe_actions",
"cookbooks",
"preferences",
"invite_tokens",
"group_event_notifiers",
"group",
}
)
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@@ -8,7 +8,8 @@ from .._model_utils import guid
from .._model_utils.auto_init import auto_init
if TYPE_CHECKING:
from .group import Group
from ..group import Group
from .household import Household
class GroupInviteToken(SqlAlchemyBase, BaseMixins):
@@ -18,6 +19,8 @@ class GroupInviteToken(SqlAlchemyBase, BaseMixins):
group_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("groups.id"), index=True)
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="invite_tokens")
household_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("households.id"), index=True)
household: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="invite_tokens")
@auto_init()
def __init__(self, **_):

View File

@@ -1,7 +1,8 @@
from datetime import date
import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Date, ForeignKey, String, orm
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags
@@ -12,9 +13,10 @@ from .._model_utils.guid import GUID
from ..recipe.category import Category, plan_rules_to_categories
if TYPE_CHECKING:
from ..group import Group
from ..recipe import RecipeModel
from ..users import User
from .group import Group
from .household import Household
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
@@ -22,6 +24,7 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
day: Mapped[str] = mapped_column(
String, nullable=False, default="unset"
@@ -41,13 +44,15 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
class GroupMealPlan(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_meal_plans"
date: Mapped[date] = mapped_column(Date, index=True, nullable=False)
date: Mapped[datetime.date] = mapped_column(Date, index=True, nullable=False)
entry_type: Mapped[str] = mapped_column(String, index=True, nullable=False)
title: Mapped[str] = mapped_column(String, index=True, nullable=False)
text: Mapped[str] = mapped_column(String, nullable=False)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="mealplans")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("users.id"), index=True)
user: Mapped[Optional["User"]] = orm.relationship("User", back_populates="mealplans")

View File

@@ -0,0 +1,37 @@
from typing import TYPE_CHECKING, Optional
import sqlalchemy as sa
import sqlalchemy.orm as orm
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from .household import Household
class HouseholdPreferencesModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "household_preferences"
id: Mapped[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: Mapped[Optional["Household"]] = orm.relationship("Household", back_populates="preferences")
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
private_household: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
# Recipe Defaults
recipe_public: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_show_assets: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@@ -8,7 +8,8 @@ from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from .group import Group
from ..group import Group
from .household import Household
class GroupRecipeAction(SqlAlchemyBase, BaseMixins):
@@ -16,6 +17,8 @@ class GroupRecipeAction(SqlAlchemyBase, BaseMixins):
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
group: Mapped["Group"] = relationship("Group", back_populates="recipe_actions", single_parent=True)
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
household: Mapped["Household"] = relationship("Household", back_populates="recipe_actions")
action_type: Mapped[str] = mapped_column(String, index=True)
title: Mapped[str] = mapped_column(String, index=True)

View File

@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Optional
from pydantic import ConfigDict
from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, UniqueConstraint, event, orm
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column
@@ -16,22 +17,30 @@ from .._model_utils.guid import GUID
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
if TYPE_CHECKING:
from ..group import Group
from ..recipe import RecipeModel
from ..users import User
from .group import Group
from .household import Household
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_item_recipe_reference"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_item: Mapped["ShoppingListItem"] = orm.relationship(
"ShoppingListItem", back_populates="recipe_references"
)
shopping_list_item_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False)
recipe_scale: Mapped[float] = mapped_column(Float, default=1)
recipe_note: Mapped[str | None] = mapped_column(String)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "household_id")
@auto_init()
def __init__(self, **_) -> None:
pass
@@ -42,8 +51,12 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
# Id's
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="list_items")
shopping_list_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("shopping_lists.id"), index=True)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
# Meta
is_ingredient: Mapped[bool | None] = mapped_column(Boolean, default=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
@@ -85,7 +98,10 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_recipe_reference"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="recipe_references")
shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(
@@ -107,10 +123,15 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="label_settings")
label_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), primary_key=True)
label: Mapped["MultiPurposeLabel"] = orm.relationship(
"MultiPurposeLabel", back_populates="shopping_lists_label_settings"
)
group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
model_config = ConfigDict(exclude={"label"})
@@ -125,6 +146,8 @@ class ShoppingList(SqlAlchemyBase, BaseMixins):
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="shopping_lists")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user: Mapped["User"] = orm.relationship("User", back_populates="shopping_lists")
@@ -178,7 +201,7 @@ session_buffer_context = ContextVar("session_buffer", default=SessionBuffer())
@event.listens_for(ShoppingListItem, "after_update")
@event.listens_for(ShoppingListItem, "after_delete")
def buffer_shopping_list_updates(_, connection, target: ShoppingListItem):
"""Adds the shopping list id to the session buffer so its `update_at` property can be updated later"""
"""Adds the shopping list id to the session buffer so its `updated_at` property can be updated later"""
session_buffer = session_buffer_context.get()
session_buffer.add(target.shopping_list_id)
@@ -186,7 +209,7 @@ def buffer_shopping_list_updates(_, connection, target: ShoppingListItem):
@event.listens_for(orm.Session, "after_flush")
def update_shopping_lists(session: orm.Session, _):
"""Pulls all pending shopping list updates from the buffer and updates their `update_at` property"""
"""Pulls all pending shopping list updates from the buffer and updates their `updated_at` property"""
session_buffer = session_buffer_context.get()
if not session_buffer.shopping_list_ids:
@@ -204,7 +227,7 @@ def update_shopping_lists(session: orm.Session, _):
if not shopping_list:
continue
shopping_list.update_at = datetime.now(timezone.utc)
shopping_list.updated_at = datetime.now(timezone.utc)
local_session.commit()
except Exception:
local_session.rollback()

View File

@@ -9,7 +9,8 @@ from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from .group import Group
from ..group import Group
from .household import Household
class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
@@ -18,6 +19,10 @@ class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="webhooks", single_parent=True)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
household: Mapped[Optional["Household"]] = orm.relationship(
"Household", back_populates="webhooks", single_parent=True
)
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), index=True)
enabled: Mapped[bool | None] = mapped_column(Boolean, default=False)
name: Mapped[str | None] = mapped_column(String)
@@ -27,7 +32,7 @@ class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
webhook_type: Mapped[str | None] = mapped_column(String, default="") # Future use for different types of webhooks
scheduled_time: Mapped[time | None] = mapped_column(Time, default=lambda: datetime.now(timezone.utc).time())
# Columne is no longer used but is kept for since it's super annoying to
# Column is no longer used but is kept for since it's super annoying to
# delete a column in SQLite and it's not a big deal to keep it around
time: Mapped[str | None] = mapped_column(String, default="00:00")

View File

@@ -1,6 +1,7 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, orm
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
@@ -21,6 +22,9 @@ class RecipeComment(SqlAlchemyBase, BaseMixins):
recipe_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("recipes.id"), nullable=False, index=True)
recipe: Mapped["RecipeModel"] = orm.relationship("RecipeModel", back_populates="comments")
group_id: AssociationProxy[GUID] = association_proxy("recipe", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("recipe", "household_id")
# User Link
user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user: Mapped["User"] = orm.relationship(

View File

@@ -5,6 +5,7 @@ import sqlalchemy as sa
import sqlalchemy.orm as orm
from pydantic import ConfigDict
from sqlalchemy import event
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column, validates
from sqlalchemy.orm.attributes import get_history
@@ -31,7 +32,8 @@ from .tag import recipes_to_tags
from .tool import recipes_to_tools
if TYPE_CHECKING:
from ..group import Group, GroupMealPlan, ShoppingListItemRecipeReference, ShoppingListRecipeReference
from ..group import Group, GroupMealPlan
from ..household import Household, ShoppingListItemRecipeReference, ShoppingListRecipeReference
from ..users import User
from . import Category, Tag, Tool
@@ -49,19 +51,25 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
household: AssociationProxy["Household"] = association_proxy("user", "household")
user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True)
user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id])
rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True)
rated_by: Mapped[list["User"]] = orm.relationship(
"User", secondary=UserToRecipe.__tablename__, back_populates="rated_recipes"
"User",
secondary=UserToRecipe.__tablename__,
back_populates="rated_recipes",
overlaps="recipe,favorited_by,favorited_recipes",
)
favorited_by: Mapped[list["User"]] = orm.relationship(
"User",
secondary=UserToRecipe.__tablename__,
primaryjoin="and_(RecipeModel.id==UserToRecipe.recipe_id, UserToRecipe.is_favorite==True)",
back_populates="favorite_recipes",
viewonly=True,
overlaps="recipe,rated_by,rated_recipes",
)
meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(

View File

@@ -2,6 +2,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .._model_base import BaseMixins, SqlAlchemyBase
@@ -21,6 +22,9 @@ class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins):
recipe_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("recipes.id"), nullable=False, index=True)
recipe: Mapped["RecipeModel"] = relationship("RecipeModel", back_populates="timeline_events")
group_id: AssociationProxy[GUID] = association_proxy("recipe", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("recipe", "household_id")
# Related User (Actor)
user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
user: Mapped["User"] = relationship(

View File

@@ -1,12 +1,18 @@
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Column, Float, ForeignKey, UniqueConstraint, event
from sqlalchemy.engine.base import Connection
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm.session import Session
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.auto_init import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from ..recipe import RecipeModel
class UserToRecipe(SqlAlchemyBase, BaseMixins):
__tablename__ = "users_to_recipes"
@@ -14,7 +20,11 @@ class UserToRecipe(SqlAlchemyBase, BaseMixins):
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
user_id = Column(GUID, ForeignKey("users.id"), index=True, primary_key=True)
recipe: Mapped["RecipeModel"] = relationship("RecipeModel")
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True, primary_key=True)
group_id: AssociationProxy[GUID] = association_proxy("recipe", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("recipe", "household_id")
rating = Column(Float, index=True, nullable=True)
is_favorite = Column(Boolean, index=True, nullable=False)

View File

@@ -3,9 +3,10 @@ from datetime import datetime
from typing import TYPE_CHECKING, Optional
from pydantic import ConfigDict
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, orm
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, orm, select
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, Session, mapped_column
from mealie.core.config import get_app_settings
from mealie.db.models._model_utils.auto_init import auto_init
@@ -16,8 +17,9 @@ from .user_to_recipe import UserToRecipe
if TYPE_CHECKING:
from ..group import Group
from ..group.mealplan import GroupMealPlan
from ..group.shopping_list import ShoppingList
from ..household import Household
from ..household.mealplan import GroupMealPlan
from ..household.shopping_list import ShoppingList
from ..recipe import RecipeComment, RecipeModel, RecipeTimelineEvent
from .password_reset import PasswordResetModel
@@ -30,6 +32,9 @@ class LongLiveToken(SqlAlchemyBase, BaseMixins):
user_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("users.id"), index=True)
user: Mapped[Optional["User"]] = orm.relationship("User")
group_id: AssociationProxy[GUID] = association_proxy("user", "group_id")
household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
def __init__(self, name, token, user_id, **_) -> None:
self.name = name
self.token = token
@@ -55,6 +60,8 @@ class User(SqlAlchemyBase, BaseMixins):
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group: Mapped["Group"] = orm.relationship("Group", back_populates="users")
household_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("households.id"), nullable=True, index=True)
household: Mapped["Household"] = orm.relationship("Household", back_populates="users")
cache_key: Mapped[str | None] = mapped_column(String, default="1234")
login_attemps: Mapped[int | None] = mapped_column(Integer, default=0)
@@ -85,14 +92,17 @@ class User(SqlAlchemyBase, BaseMixins):
)
shopping_lists: Mapped[Optional["ShoppingList"]] = orm.relationship("ShoppingList", **sp_args)
rated_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=UserToRecipe.__tablename__, back_populates="rated_by"
"RecipeModel",
secondary=UserToRecipe.__tablename__,
back_populates="rated_by",
overlaps="recipe,favorited_by,favorited_recipes",
)
favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel",
secondary=UserToRecipe.__tablename__,
primaryjoin="and_(User.id==UserToRecipe.user_id, UserToRecipe.is_favorite==True)",
back_populates="favorited_by",
viewonly=True,
overlaps="recipe,rated_by,rated_recipes",
)
model_config = ConfigDict(
exclude={
@@ -102,6 +112,7 @@ class User(SqlAlchemyBase, BaseMixins):
"can_invite",
"can_organize",
"group",
"household",
}
)
@@ -109,15 +120,33 @@ class User(SqlAlchemyBase, BaseMixins):
def group_slug(self) -> str:
return self.group.slug
@hybrid_property
def household_slug(self) -> str:
return self.household.slug
@auto_init()
def __init__(self, session, full_name, password, group: str | None = None, **kwargs) -> None:
if group is None:
def __init__(
self, session: Session, full_name, password, group: str | None = None, household: str | None = None, **kwargs
) -> None:
if group is None or household is None:
settings = get_app_settings()
group = settings.DEFAULT_GROUP
group = group or settings.DEFAULT_GROUP
household = household or settings.DEFAULT_HOUSEHOLD
from mealie.db.models.group import Group
from mealie.db.models.household import Household
self.group = Group.get_by_name(session, group)
self.group = session.execute(select(Group).filter(Group.name == group)).scalars().one_or_none()
if self.group:
self.household = (
session.execute(
select(Household).filter(Household.name == household, Household.group_id == self.group.id)
)
.scalars()
.one_or_none()
)
else:
self.household = None
self.rated_recipes = []
@@ -129,14 +158,25 @@ class User(SqlAlchemyBase, BaseMixins):
self._set_permissions(**kwargs)
@auto_init()
def update(self, full_name, email, group, username, session=None, **kwargs):
def update(self, session: Session, full_name, email, group, household, username, **kwargs):
self.username = username
self.full_name = full_name
self.email = email
from mealie.db.models.group import Group
from mealie.db.models.household import Household
self.group = Group.get_by_name(session, group)
self.group = session.execute(select(Group).filter(Group.name == group)).scalars().one_or_none()
if self.group:
self.household = (
session.execute(
select(Household).filter(Household.name == household, Household.group_id == self.group.id)
)
.scalars()
.one_or_none()
)
else:
self.household = None
if self.username is None:
self.username = full_name