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

@@ -68,7 +68,7 @@ async def get_public_group(group_slug: str = fastapi.Path(...), session=Depends(
repos = get_repositories(session)
group = repos.groups.get_by_slug_or_id(group_slug)
if not group or group.preferences.private_group or not group.preferences.recipe_public:
if not group or group.preferences.private_group:
raise HTTPException(404, "group not found")
else:
return group
@@ -111,7 +111,7 @@ async def get_current_user(
except PyJWTError as e:
raise credentials_exception from e
repos = get_repositories(session)
repos = get_repositories(session, group_id=None, household_id=None)
user = repos.users.get_one(token_data.user_id, "id", any_case=False)
@@ -139,7 +139,7 @@ async def get_admin_user(current_user: PrivateUser = Depends(get_current_user))
def validate_long_live_token(session: Session, client_token: str, user_id: str) -> PrivateUser:
repos = get_repositories(session)
repos = get_repositories(session, group_id=None, household_id=None)
token = repos.api_tokens.multi_query({"token": client_token, "user_id": user_id})

View File

@@ -56,7 +56,7 @@ class AuthProvider(Generic[T], metaclass=abc.ABCMeta):
if self.__has_tried_user:
return self.user
db = get_repositories(self.session)
db = get_repositories(self.session, group_id=None, household_id=None)
user = user = db.users.get_one(username, "username", any_case=True)
if not user:

View File

@@ -23,7 +23,7 @@ class CredentialsProvider(AuthProvider[CredentialsRequest]):
async def authenticate(self) -> tuple[str, timedelta] | None:
"""Attempt to authenticate a user given a username and password"""
settings = get_app_settings()
db = get_repositories(self.session)
db = get_repositories(self.session, group_id=None, household_id=None)
user = self.try_get_user(self.data.username)
if not user:

View File

@@ -95,7 +95,7 @@ class LDAPProvider(CredentialsProvider):
"""
settings = get_app_settings()
db = get_repositories(self.session)
db = get_repositories(self.session, group_id=None, household_id=None)
if not self.data:
return None
data = self.data

View File

@@ -33,7 +33,7 @@ class OpenIDProvider(AuthProvider[OIDCRequest]):
if not claims:
return None
repos = get_repositories(self.session)
repos = get_repositories(self.session, group_id=None, household_id=None)
user = self.try_get_user(claims.get(settings.OIDC_USER_CLAIM))
is_admin = False

View File

@@ -179,6 +179,7 @@ class AppSettings(AppLoggingSettings):
return self.DB_PROVIDER.db_url_public if self.DB_PROVIDER else None
DEFAULT_GROUP: str = "Home"
DEFAULT_HOUSEHOLD: str = "Family"
_DEFAULT_EMAIL: str = "changeme@example.com"
"""

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

View File

@@ -34,7 +34,7 @@ class AsyncSafeTransport(httpx.AsyncBaseTransport):
self._wrapper = httpx.AsyncHTTPTransport(**kwargs)
self._log = log
async def handle_async_request(self, request):
async def handle_async_request(self, request) -> httpx.Response:
# override timeout value for _all_ requests
request.extensions["timeout"] = httpx.Timeout(self.timeout, pool=self.timeout).as_dict()

6
mealie/repos/_utils.py Normal file
View File

@@ -0,0 +1,6 @@
class NotSet:
def __bool__(self):
return False
NOT_SET = NotSet()

View File

@@ -1,7 +1,11 @@
from pydantic import UUID4
from sqlalchemy.orm import Session
from ._utils import NOT_SET, NotSet
from .repository_factory import AllRepositories
def get_repositories(session: Session):
return AllRepositories(session)
def get_repositories(
session: Session, *, group_id: UUID4 | None | NotSet = NOT_SET, household_id: UUID4 | None | NotSet = NOT_SET
):
return AllRepositories(session, group_id=group_id, household_id=household_id)

View File

@@ -1,25 +1,28 @@
from collections.abc import Sequence
from functools import cached_property
from pydantic import UUID4
from sqlalchemy import select
from sqlalchemy.orm import Session
from mealie.db.models.group import Group, GroupMealPlan, ReportEntryModel, ReportModel
from mealie.db.models.group.cookbook import CookBook
from mealie.db.models.group.events import GroupEventNotifierModel
from mealie.db.models.group import Group, ReportEntryModel, ReportModel
from mealie.db.models.group.exports import GroupDataExportsModel
from mealie.db.models.group.invite_tokens import GroupInviteToken
from mealie.db.models.group.mealplan import GroupMealPlanRules
from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.group.recipe_action import GroupRecipeAction
from mealie.db.models.group.shopping_list import (
from mealie.db.models.household.cookbook import CookBook
from mealie.db.models.household.events import GroupEventNotifierModel
from mealie.db.models.household.household import Household
from mealie.db.models.household.invite_tokens import GroupInviteToken
from mealie.db.models.household.mealplan import GroupMealPlan, GroupMealPlanRules
from mealie.db.models.household.preferences import HouseholdPreferencesModel
from mealie.db.models.household.recipe_action import GroupRecipeAction
from mealie.db.models.household.shopping_list import (
ShoppingList,
ShoppingListItem,
ShoppingListItemRecipeReference,
ShoppingListMultiPurposeLabel,
ShoppingListRecipeReference,
)
from mealie.db.models.group.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.comment import RecipeComment
@@ -33,22 +36,25 @@ from mealie.db.models.users import LongLiveToken, User
from mealie.db.models.users.password_reset import PasswordResetModel
from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.repos.repository_foods import RepositoryFood
from mealie.repos.repository_household import RepositoryHousehold
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
from mealie.repos.repository_units import RepositoryUnit
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.group.group_events import GroupEventNotifierOut
from mealie.schema.group.group_exports import GroupDataExport
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.group_recipe_action import GroupRecipeActionOut
from mealie.schema.group.group_shopping_list import (
from mealie.schema.household.group_events import GroupEventNotifierOut
from mealie.schema.household.group_recipe_action import GroupRecipeActionOut
from mealie.schema.household.group_shopping_list import (
ShoppingListItemOut,
ShoppingListItemRecipeRefOut,
ShoppingListMultiPurposeLabelOut,
ShoppingListOut,
ShoppingListRecipeRefOut,
)
from mealie.schema.group.invite_token import ReadInviteToken
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.household.household import HouseholdInDB
from mealie.schema.household.household_preferences import ReadHouseholdPreferences
from mealie.schema.household.invite_token import ReadInviteToken
from mealie.schema.household.webhook import ReadWebhook
from mealie.schema.labels import MultiPurposeLabelOut
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from mealie.schema.meal_plan.plan_rules import PlanRulesOut
@@ -62,7 +68,8 @@ from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser
from mealie.schema.user.user import UserRatingOut
from mealie.schema.user.user_passwords import PrivatePasswordResetToken
from .repository_generic import RepositoryGeneric
from ._utils import NOT_SET, NotSet
from .repository_generic import GroupRepositoryGeneric, HouseholdRepositoryGeneric
from .repository_group import RepositoryGroup
from .repository_meals import RepositoryMeals
from .repository_recipes import RepositoryRecipes
@@ -73,89 +80,110 @@ PK_ID = "id"
PK_SLUG = "slug"
PK_TOKEN = "token"
PK_GROUP_ID = "group_id"
PK_HOUSEHOLD_ID = "household_id"
class RepositoryCategories(RepositoryGeneric[CategoryOut, Category]):
class RepositoryCategories(GroupRepositoryGeneric[CategoryOut, Category]):
def get_empty(self) -> Sequence[Category]:
stmt = select(Category).filter(~Category.recipes.any())
return self.session.execute(stmt).scalars().all()
class RepositoryTags(RepositoryGeneric[TagOut, Tag]):
class RepositoryTags(GroupRepositoryGeneric[TagOut, Tag]):
def get_empty(self) -> Sequence[Tag]:
stmt = select(Tag).filter(~Tag.recipes.any())
return self.session.execute(stmt).scalars().all()
class AllRepositories:
def __init__(self, session: Session) -> None:
"""
`AllRepositories` class is the data access layer for all database actions within
Mealie. Database uses composition from classes derived from AccessModel. These
can be substantiated from the AccessModel class or through inheritance when
additional methods are required.
"""
"""
`AllRepositories` class is the data access layer for all database actions within
Mealie. Database uses composition from classes derived from AccessModel. These
can be substantiated from the AccessModel class or through inheritance when
additional methods are required.
"""
def __init__(
self,
session: Session,
*,
group_id: UUID4 | None | NotSet = NOT_SET,
household_id: UUID4 | None | NotSet = NOT_SET,
) -> None:
self.session = session
self.group_id = group_id
self.household_id = household_id
# ================================================================
# Recipe
@cached_property
def recipes(self) -> RepositoryRecipes:
return RepositoryRecipes(self.session, PK_SLUG, RecipeModel, Recipe)
return RepositoryRecipes(
self.session, PK_SLUG, RecipeModel, Recipe, group_id=self.group_id, household_id=self.household_id
)
@cached_property
def ingredient_foods(self) -> RepositoryFood:
return RepositoryFood(self.session, PK_ID, IngredientFoodModel, IngredientFood)
return RepositoryFood(self.session, PK_ID, IngredientFoodModel, IngredientFood, group_id=self.group_id)
@cached_property
def ingredient_units(self) -> RepositoryUnit:
return RepositoryUnit(self.session, PK_ID, IngredientUnitModel, IngredientUnit)
return RepositoryUnit(self.session, PK_ID, IngredientUnitModel, IngredientUnit, group_id=self.group_id)
@cached_property
def tools(self) -> RepositoryGeneric[RecipeToolOut, Tool]:
return RepositoryGeneric(self.session, PK_ID, Tool, RecipeToolOut)
def tools(self) -> GroupRepositoryGeneric[RecipeToolOut, Tool]:
return GroupRepositoryGeneric(self.session, PK_ID, Tool, RecipeToolOut, group_id=self.group_id)
@cached_property
def comments(self) -> RepositoryGeneric[RecipeCommentOut, RecipeComment]:
return RepositoryGeneric(self.session, PK_ID, RecipeComment, RecipeCommentOut)
def comments(self) -> GroupRepositoryGeneric[RecipeCommentOut, RecipeComment]:
# Since users can comment on recipes that belong to other households,
# this is a group repository, rather than a household repository.
return GroupRepositoryGeneric(self.session, PK_ID, RecipeComment, RecipeCommentOut, group_id=self.group_id)
@cached_property
def categories(self) -> RepositoryCategories:
return RepositoryCategories(self.session, PK_ID, Category, CategoryOut)
return RepositoryCategories(self.session, PK_ID, Category, CategoryOut, group_id=self.group_id)
@cached_property
def tags(self) -> RepositoryTags:
return RepositoryTags(self.session, PK_ID, Tag, TagOut)
return RepositoryTags(self.session, PK_ID, Tag, TagOut, group_id=self.group_id)
@cached_property
def recipe_share_tokens(self) -> RepositoryGeneric[RecipeShareToken, RecipeShareTokenModel]:
return RepositoryGeneric(self.session, PK_ID, RecipeShareTokenModel, RecipeShareToken)
def recipe_share_tokens(self) -> GroupRepositoryGeneric[RecipeShareToken, RecipeShareTokenModel]:
return GroupRepositoryGeneric(
self.session, PK_ID, RecipeShareTokenModel, RecipeShareToken, group_id=self.group_id
)
@cached_property
def recipe_timeline_events(self) -> RepositoryGeneric[RecipeTimelineEventOut, RecipeTimelineEvent]:
return RepositoryGeneric(self.session, PK_ID, RecipeTimelineEvent, RecipeTimelineEventOut)
def recipe_timeline_events(self) -> GroupRepositoryGeneric[RecipeTimelineEventOut, RecipeTimelineEvent]:
# Since users can post events on recipes that belong to other households,
# this is a group repository, rather than a household repository.
return GroupRepositoryGeneric(
self.session, PK_ID, RecipeTimelineEvent, RecipeTimelineEventOut, group_id=self.group_id
)
# ================================================================
# User
@cached_property
def users(self) -> RepositoryUsers:
return RepositoryUsers(self.session, PK_ID, User, PrivateUser)
return RepositoryUsers(self.session, PK_ID, User, PrivateUser, group_id=self.group_id)
@cached_property
def user_ratings(self) -> RepositoryUserRatings:
return RepositoryUserRatings(self.session, PK_ID, UserToRecipe, UserRatingOut)
return RepositoryUserRatings(self.session, PK_ID, UserToRecipe, UserRatingOut, group_id=self.group_id)
@cached_property
def api_tokens(self) -> RepositoryGeneric[LongLiveTokenInDB, LongLiveToken]:
return RepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB)
def api_tokens(self) -> GroupRepositoryGeneric[LongLiveTokenInDB, LongLiveToken]:
return GroupRepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB, group_id=self.group_id)
@cached_property
def tokens_pw_reset(self) -> RepositoryGeneric[PrivatePasswordResetToken, PasswordResetModel]:
return RepositoryGeneric(self.session, PK_TOKEN, PasswordResetModel, PrivatePasswordResetToken)
def tokens_pw_reset(self) -> GroupRepositoryGeneric[PrivatePasswordResetToken, PasswordResetModel]:
return GroupRepositoryGeneric(
self.session, PK_TOKEN, PasswordResetModel, PrivatePasswordResetToken, group_id=self.group_id
)
# ================================================================
# Group
@@ -165,84 +193,172 @@ class AllRepositories:
return RepositoryGroup(self.session, PK_ID, Group, GroupInDB)
@cached_property
def group_invite_tokens(self) -> RepositoryGeneric[ReadInviteToken, GroupInviteToken]:
return RepositoryGeneric(self.session, PK_TOKEN, GroupInviteToken, ReadInviteToken)
def group_preferences(self) -> GroupRepositoryGeneric[ReadGroupPreferences, GroupPreferencesModel]:
return GroupRepositoryGeneric(
self.session, PK_GROUP_ID, GroupPreferencesModel, ReadGroupPreferences, group_id=self.group_id
)
@cached_property
def group_preferences(self) -> RepositoryGeneric[ReadGroupPreferences, GroupPreferencesModel]:
return RepositoryGeneric(self.session, PK_GROUP_ID, GroupPreferencesModel, ReadGroupPreferences)
def group_exports(self) -> GroupRepositoryGeneric[GroupDataExport, GroupDataExportsModel]:
return GroupRepositoryGeneric(
self.session, PK_ID, GroupDataExportsModel, GroupDataExport, group_id=self.group_id
)
@cached_property
def group_exports(self) -> RepositoryGeneric[GroupDataExport, GroupDataExportsModel]:
return RepositoryGeneric(self.session, PK_ID, GroupDataExportsModel, GroupDataExport)
def group_reports(self) -> GroupRepositoryGeneric[ReportOut, ReportModel]:
return GroupRepositoryGeneric(self.session, PK_ID, ReportModel, ReportOut, group_id=self.group_id)
@cached_property
def group_reports(self) -> RepositoryGeneric[ReportOut, ReportModel]:
return RepositoryGeneric(self.session, PK_ID, ReportModel, ReportOut)
def group_report_entries(self) -> GroupRepositoryGeneric[ReportEntryOut, ReportEntryModel]:
return GroupRepositoryGeneric(self.session, PK_ID, ReportEntryModel, ReportEntryOut, group_id=self.group_id)
# ================================================================
# Household
@cached_property
def group_report_entries(self) -> RepositoryGeneric[ReportEntryOut, ReportEntryModel]:
return RepositoryGeneric(self.session, PK_ID, ReportEntryModel, ReportEntryOut)
def households(self) -> RepositoryHousehold:
return RepositoryHousehold(self.session, PK_ID, Household, HouseholdInDB, group_id=self.group_id)
@cached_property
def cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return RepositoryGeneric(self.session, PK_ID, CookBook, ReadCookBook)
def household_preferences(self) -> HouseholdRepositoryGeneric[ReadHouseholdPreferences, HouseholdPreferencesModel]:
return HouseholdRepositoryGeneric(
self.session,
PK_HOUSEHOLD_ID,
HouseholdPreferencesModel,
ReadHouseholdPreferences,
group_id=self.group_id,
household_id=self.household_id,
)
@cached_property
def group_recipe_actions(self) -> RepositoryGeneric[GroupRecipeActionOut, GroupRecipeAction]:
return RepositoryGeneric(self.session, PK_ID, GroupRecipeAction, GroupRecipeActionOut)
def cookbooks(self) -> HouseholdRepositoryGeneric[ReadCookBook, CookBook]:
return HouseholdRepositoryGeneric(
self.session, PK_ID, CookBook, ReadCookBook, group_id=self.group_id, household_id=self.household_id
)
@cached_property
def group_invite_tokens(self) -> HouseholdRepositoryGeneric[ReadInviteToken, GroupInviteToken]:
return HouseholdRepositoryGeneric(
self.session,
PK_TOKEN,
GroupInviteToken,
ReadInviteToken,
group_id=self.group_id,
household_id=self.household_id,
)
@cached_property
def group_recipe_actions(self) -> HouseholdRepositoryGeneric[GroupRecipeActionOut, GroupRecipeAction]:
return HouseholdRepositoryGeneric(
self.session,
PK_ID,
GroupRecipeAction,
GroupRecipeActionOut,
group_id=self.group_id,
household_id=self.household_id,
)
# ================================================================
# Meal Plan
@cached_property
def meals(self) -> RepositoryMeals:
return RepositoryMeals(self.session, PK_ID, GroupMealPlan, ReadPlanEntry)
return RepositoryMeals(
self.session, PK_ID, GroupMealPlan, ReadPlanEntry, group_id=self.group_id, household_id=self.household_id
)
@cached_property
def group_meal_plan_rules(self) -> RepositoryMealPlanRules:
return RepositoryMealPlanRules(self.session, PK_ID, GroupMealPlanRules, PlanRulesOut)
@cached_property
def webhooks(self) -> RepositoryGeneric[ReadWebhook, GroupWebhooksModel]:
return RepositoryGeneric(self.session, PK_ID, GroupWebhooksModel, ReadWebhook)
return RepositoryMealPlanRules(
self.session,
PK_ID,
GroupMealPlanRules,
PlanRulesOut,
group_id=self.group_id,
household_id=self.household_id,
)
# ================================================================
# Shopping List
@cached_property
def group_shopping_lists(self) -> RepositoryShoppingList:
return RepositoryShoppingList(self.session, PK_ID, ShoppingList, ShoppingListOut)
return RepositoryShoppingList(
self.session, PK_ID, ShoppingList, ShoppingListOut, group_id=self.group_id, household_id=self.household_id
)
@cached_property
def group_shopping_list_item(self) -> RepositoryGeneric[ShoppingListItemOut, ShoppingListItem]:
return RepositoryGeneric(self.session, PK_ID, ShoppingListItem, ShoppingListItemOut)
def group_shopping_list_item(self) -> HouseholdRepositoryGeneric[ShoppingListItemOut, ShoppingListItem]:
return HouseholdRepositoryGeneric(
self.session,
PK_ID,
ShoppingListItem,
ShoppingListItemOut,
group_id=self.group_id,
household_id=self.household_id,
)
@cached_property
def group_shopping_list_item_references(
self,
) -> RepositoryGeneric[ShoppingListItemRecipeRefOut, ShoppingListItemRecipeReference]:
return RepositoryGeneric(self.session, PK_ID, ShoppingListItemRecipeReference, ShoppingListItemRecipeRefOut)
) -> HouseholdRepositoryGeneric[ShoppingListItemRecipeRefOut, ShoppingListItemRecipeReference]:
return HouseholdRepositoryGeneric(
self.session,
PK_ID,
ShoppingListItemRecipeReference,
ShoppingListItemRecipeRefOut,
group_id=self.group_id,
household_id=self.household_id,
)
@cached_property
def group_shopping_list_recipe_refs(
self,
) -> RepositoryGeneric[ShoppingListRecipeRefOut, ShoppingListRecipeReference]:
return RepositoryGeneric(self.session, PK_ID, ShoppingListRecipeReference, ShoppingListRecipeRefOut)
) -> HouseholdRepositoryGeneric[ShoppingListRecipeRefOut, ShoppingListRecipeReference]:
return HouseholdRepositoryGeneric(
self.session,
PK_ID,
ShoppingListRecipeReference,
ShoppingListRecipeRefOut,
group_id=self.group_id,
household_id=self.household_id,
)
@cached_property
def shopping_list_multi_purpose_labels(
self,
) -> RepositoryGeneric[ShoppingListMultiPurposeLabelOut, ShoppingListMultiPurposeLabel]:
return RepositoryGeneric(self.session, PK_ID, ShoppingListMultiPurposeLabel, ShoppingListMultiPurposeLabelOut)
) -> HouseholdRepositoryGeneric[ShoppingListMultiPurposeLabelOut, ShoppingListMultiPurposeLabel]:
return HouseholdRepositoryGeneric(
self.session,
PK_ID,
ShoppingListMultiPurposeLabel,
ShoppingListMultiPurposeLabelOut,
group_id=self.group_id,
household_id=self.household_id,
)
@cached_property
def group_multi_purpose_labels(self) -> RepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
return RepositoryGeneric(self.session, PK_ID, MultiPurposeLabel, MultiPurposeLabelOut)
def group_multi_purpose_labels(self) -> GroupRepositoryGeneric[MultiPurposeLabelOut, MultiPurposeLabel]:
return GroupRepositoryGeneric(
self.session, PK_ID, MultiPurposeLabel, MultiPurposeLabelOut, group_id=self.group_id
)
# ================================================================
# Group Events
# Events
@cached_property
def group_event_notifier(self) -> RepositoryGeneric[GroupEventNotifierOut, GroupEventNotifierModel]:
return RepositoryGeneric(self.session, PK_ID, GroupEventNotifierModel, GroupEventNotifierOut)
def group_event_notifier(self) -> HouseholdRepositoryGeneric[GroupEventNotifierOut, GroupEventNotifierModel]:
return HouseholdRepositoryGeneric(
self.session,
PK_ID,
GroupEventNotifierModel,
GroupEventNotifierOut,
group_id=self.group_id,
household_id=self.household_id,
)
@cached_property
def webhooks(self) -> HouseholdRepositoryGeneric[ReadWebhook, GroupWebhooksModel]:
return HouseholdRepositoryGeneric(
self.session, PK_ID, GroupWebhooksModel, ReadWebhook, group_id=self.group_id, household_id=self.household_id
)

View File

@@ -4,10 +4,10 @@ from sqlalchemy import select
from mealie.db.models.recipe.ingredient import IngredientFoodModel
from mealie.schema.recipe.recipe_ingredient import IngredientFood
from .repository_generic import RepositoryGeneric
from .repository_generic import GroupRepositoryGeneric
class RepositoryFood(RepositoryGeneric[IngredientFood, IngredientFoodModel]):
class RepositoryFood(GroupRepositoryGeneric[IngredientFood, IngredientFoodModel]):
def _get_food(self, id: UUID4) -> IngredientFoodModel:
stmt = select(self.model).filter_by(**self._filter_builder(**{"id": id}))
return self.session.execute(stmt).scalars().one()
@@ -26,6 +26,3 @@ class RepositoryFood(RepositoryGeneric[IngredientFood, IngredientFoodModel]):
raise e
return self.get_one(to_food)
def by_group(self, group_id: UUID4) -> "RepositoryFood":
return super().by_group(group_id)

View File

@@ -19,6 +19,8 @@ from mealie.schema.response.pagination import OrderByNullPosition, OrderDirectio
from mealie.schema.response.query_filter import QueryFilter
from mealie.schema.response.query_search import SearchFilter
from ._utils import NOT_SET, NotSet
Schema = TypeVar("Schema", bound=MealieModel)
Model = TypeVar("Model", bound=SqlAlchemyBase)
@@ -33,11 +35,18 @@ class RepositoryGeneric(Generic[Schema, Model]):
Generic ([Model]): Represents the SqlAlchemyModel Model
"""
user_id: UUID4 | None = None
group_id: UUID4 | None = None
session: Session
def __init__(self, session: Session, primary_key: str, sql_model: type[Model], schema: type[Schema]) -> None:
_group_id: UUID4 | None = None
_household_id: UUID4 | None = None
def __init__(
self,
session: Session,
primary_key: str,
sql_model: type[Model],
schema: type[Schema],
) -> None:
self.session = session
self.primary_key = primary_key
self.model = sql_model
@@ -45,13 +54,13 @@ class RepositoryGeneric(Generic[Schema, Model]):
self.logger = get_logger()
def by_user(self: T, user_id: UUID4) -> T:
self.user_id = user_id
return self
@property
def group_id(self) -> UUID4 | None:
return self._group_id
def by_group(self: T, group_id: UUID4) -> T:
self.group_id = group_id
return self
@property
def household_id(self) -> UUID4 | None:
return self._household_id
def _log_exception(self, e: Exception) -> None:
self.logger.error(f"Error processing query for Repo model={self.model.__name__} schema={self.schema.__name__}")
@@ -70,6 +79,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
if self.group_id:
dct["group_id"] = self.group_id
if self.household_id:
dct["household_id"] = self.household_id
return {**dct, **kwargs}
@@ -341,7 +352,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
self.logger.error(e)
raise HTTPException(status_code=400, detail=str(e)) from e
count_query = select(func.count()).select_from(query)
count_query = select(func.count()).select_from(query.subquery())
count = self.session.scalar(count_query)
if not count:
count = 0
@@ -373,15 +384,15 @@ class RepositoryGeneric(Generic[Schema, Model]):
order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None,
) -> Select:
# queries handle uppercase and lowercase differently, which is undesirable
if isinstance(order_attr.type, sqltypes.String):
order_attr = func.lower(order_attr)
if order_dir is OrderDirection.asc:
order_attr = order_attr.asc()
elif order_dir is OrderDirection.desc:
order_attr = order_attr.desc()
# queries handle uppercase and lowercase differently, which is undesirable
if isinstance(order_attr.type, sqltypes.String):
order_attr = func.lower(order_attr)
if order_by_null is OrderByNullPosition.first:
order_attr = nulls_first(order_attr)
elif order_by_null is OrderByNullPosition.last:
@@ -435,3 +446,40 @@ class RepositoryGeneric(Generic[Schema, Model]):
def add_search_to_query(self, query: Select, schema: type[Schema], search: str) -> Select:
search_filter = SearchFilter(self.session, search, schema._normalize_search)
return search_filter.filter_query_by_search(query, schema, self.model)
class GroupRepositoryGeneric(RepositoryGeneric[Schema, Model]):
def __init__(
self,
session: Session,
primary_key: str,
sql_model: type[Model],
schema: type[Schema],
*,
group_id: UUID4 | None | NotSet,
) -> None:
super().__init__(session, primary_key, sql_model, schema)
if group_id is NOT_SET:
raise ValueError("group_id must be set")
self._group_id = group_id if group_id else None
class HouseholdRepositoryGeneric(RepositoryGeneric[Schema, Model]):
def __init__(
self,
session: Session,
primary_key: str,
sql_model: type[Model],
schema: type[Schema],
*,
group_id: UUID4 | None | NotSet,
household_id: UUID4 | None | NotSet,
) -> None:
super().__init__(session, primary_key, sql_model, schema)
if group_id is NOT_SET:
raise ValueError("group_id must be set")
self._group_id = group_id if group_id else None
if household_id is NOT_SET:
raise ValueError("household_id must be set")
self._household_id = household_id if household_id else None

View File

@@ -4,19 +4,12 @@ from uuid import UUID
from pydantic import UUID4
from slugify import slugify
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from mealie.db.models.group import Group
from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.users import User
from mealie.schema.group.group_statistics import GroupStatistics
from mealie.schema.user.user import GroupBase, GroupInDB, UpdateGroup
from ..db.models._model_base import SqlAlchemyBase
from .repository_generic import RepositoryGeneric
@@ -74,16 +67,3 @@ class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]):
return self.get_one(slug_or_id)
else:
return self.get_one(slug_or_id, key="slug")
def statistics(self, group_id: UUID4) -> GroupStatistics:
def model_count(model: type[SqlAlchemyBase]) -> int:
stmt = select(func.count(model.id)).filter_by(group_id=group_id)
return self.session.scalar(stmt)
return GroupStatistics(
total_recipes=model_count(RecipeModel),
total_users=model_count(User),
total_categories=model_count(Category),
total_tags=model_count(Tag),
total_tools=model_count(Tool),
)

View File

@@ -0,0 +1,103 @@
from collections.abc import Iterable
from typing import cast
from uuid import UUID
from pydantic import UUID4
from slugify import slugify
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models.household.household import Household
from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.users import User
from mealie.repos.repository_generic import GroupRepositoryGeneric
from mealie.schema.household.household import HouseholdCreate, HouseholdInDB, UpdateHousehold
from mealie.schema.household.household_statistics import HouseholdStatistics
class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]):
def create(self, data: HouseholdCreate | dict) -> HouseholdInDB:
if isinstance(data, HouseholdCreate):
data = data.model_dump()
if not data.get("group_id"):
data["group_id"] = self.group_id
max_attempts = 10
original_name = cast(str, data["name"])
attempts = 0
while True:
try:
data["slug"] = slugify(data["name"])
return super().create(data)
except IntegrityError:
self.session.rollback()
attempts += 1
if attempts >= max_attempts:
raise
data["name"] = f"{original_name} ({attempts})"
def create_many(self, data: Iterable[HouseholdInDB | dict]) -> list[HouseholdInDB]:
# since create uses special logic for resolving slugs, we don't want to use the standard create_many method
return [self.create(new_household) for new_household in data]
def update(self, match_value: str | int | UUID4, new_data: UpdateHousehold | dict) -> HouseholdInDB:
if isinstance(new_data, HouseholdCreate):
new_data.slug = slugify(new_data.name)
else:
new_data["slug"] = slugify(new_data["name"])
return super().update(match_value, new_data)
def update_many(self, data: Iterable[UpdateHousehold | dict]) -> list[HouseholdInDB]:
# since update uses special logic for resolving slugs, we don't want to use the standard update_many method
return [
self.update(household["id"] if isinstance(household, dict) else household.id, household)
for household in data
]
def get_by_name(self, name: str) -> HouseholdInDB | None:
if not self.group_id:
raise Exception("group_id not set")
dbhousehold = (
self.session.execute(select(self.model).filter_by(name=name, group_id=self.group_id))
.scalars()
.one_or_none()
)
if dbhousehold is None:
return None
return self.schema.model_validate(dbhousehold)
def get_by_slug_or_id(self, slug_or_id: str | UUID) -> HouseholdInDB | None:
if isinstance(slug_or_id, str):
try:
slug_or_id = UUID(slug_or_id)
except ValueError:
pass
if isinstance(slug_or_id, UUID):
return self.get_one(slug_or_id)
else:
return self.get_one(slug_or_id, key="slug")
def statistics(self, group_id: UUID4, household_id: UUID4) -> HouseholdStatistics:
def model_count(model: type[SqlAlchemyBase], *, filter_household: bool = True) -> int:
stmt = select(func.count(model.id)).filter_by(group_id=group_id)
if filter_household:
stmt = stmt.filter_by(household_id=household_id)
return self.session.scalar(stmt)
return HouseholdStatistics(
# household-level statistics
total_recipes=model_count(RecipeModel),
total_users=model_count(User),
# group-level statistics
total_categories=model_count(Category, filter_household=False),
total_tags=model_count(Tag, filter_household=False),
total_tools=model_count(Tool, filter_household=False),
)

View File

@@ -1,17 +1,12 @@
from uuid import UUID
from sqlalchemy import or_, select
from mealie.db.models.group.mealplan import GroupMealPlanRules
from mealie.db.models.household.mealplan import GroupMealPlanRules
from mealie.schema.meal_plan.plan_rules import PlanRulesDay, PlanRulesOut, PlanRulesType
from .repository_generic import RepositoryGeneric
from .repository_generic import HouseholdRepositoryGeneric
class RepositoryMealPlanRules(RepositoryGeneric[PlanRulesOut, GroupMealPlanRules]):
def by_group(self, group_id: UUID) -> "RepositoryMealPlanRules":
return super().by_group(group_id)
class RepositoryMealPlanRules(HouseholdRepositoryGeneric[PlanRulesOut, GroupMealPlanRules]):
def get_rules(self, day: PlanRulesDay, entry_type: PlanRulesType) -> list[PlanRulesOut]:
stmt = select(GroupMealPlanRules).filter(
or_(
@@ -26,6 +21,11 @@ class RepositoryMealPlanRules(RepositoryGeneric[PlanRulesOut, GroupMealPlanRules
),
)
if self.group_id:
stmt = stmt.filter(GroupMealPlanRules.group_id == self.group_id)
if self.household_id:
stmt = stmt.filter(GroupMealPlanRules.household_id == self.household_id)
rules = self.session.execute(stmt).scalars().all()
return [self.schema.model_validate(x) for x in rules]

View File

@@ -1,20 +1,21 @@
from datetime import datetime, timezone
from uuid import UUID
from sqlalchemy import select
from mealie.db.models.group import GroupMealPlan
from mealie.db.models.household import GroupMealPlan
from mealie.schema.meal_plan.new_meal import ReadPlanEntry
from .repository_generic import RepositoryGeneric
from .repository_generic import HouseholdRepositoryGeneric
class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]):
def by_group(self, group_id: UUID) -> "RepositoryMeals":
return super().by_group(group_id)
class RepositoryMeals(HouseholdRepositoryGeneric[ReadPlanEntry, GroupMealPlan]):
def get_today(self) -> list[ReadPlanEntry]:
if not self.household_id:
raise Exception("household_id not set")
def get_today(self, group_id: UUID) -> list[ReadPlanEntry]:
today = datetime.now(tz=timezone.utc).date()
stmt = select(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id)
stmt = select(GroupMealPlan).filter(
GroupMealPlan.date == today, GroupMealPlan.household_id == self.household_id
)
plans = self.session.execute(stmt).scalars().all()
return [self.schema.model_validate(x) for x in plans]

View File

@@ -8,11 +8,11 @@ from pydantic import UUID4
from slugify import slugify
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import InstrumentedAttribute, joinedload
from typing_extensions import Self
from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.user_to_recipe import UserToRecipe
@@ -34,10 +34,17 @@ from mealie.schema.response.pagination import (
from ..db.models._model_base import SqlAlchemyBase
from ..schema._mealie.mealie_model import extract_uuids
from .repository_generic import RepositoryGeneric
from .repository_generic import HouseholdRepositoryGeneric
class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
user_id: UUID4 | None = None
def by_user(self: Self, user_id: UUID4) -> Self:
"""Add a user_id to the repo, which will be used to handle recipe ratings"""
self.user_id = user_id
return self
def create(self, document: Recipe) -> Recipe: # type: ignore
max_retries = 10
original_name: str = document.name # type: ignore
@@ -53,33 +60,6 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
if i >= max_retries:
raise
def by_group(self, group_id: UUID) -> "RepositoryRecipes":
return super().by_group(group_id)
def get_all_public(self, limit: int | None = None, order_by: str | None = None, start=0, override_schema=None):
eff_schema = override_schema or self.schema
if order_by:
order_attr = getattr(self.model, str(order_by))
stmt = (
sa.select(self.model)
.join(RecipeSettings)
.filter(RecipeSettings.public == True) # noqa: E712
.order_by(order_attr.desc())
.offset(start)
.limit(limit)
)
return [eff_schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
stmt = (
sa.select(self.model)
.join(RecipeSettings)
.filter(RecipeSettings.public == True) # noqa: E712
.offset(start)
.limit(limit)
)
return [eff_schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
def update_image(self, slug: str, _: str | None = None) -> int:
entry: RecipeModel = self._query_one(match_value=slug)
entry.image = randint(0, 255)
@@ -103,44 +83,6 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
override_schema=override_schema,
)
def summary(
self, group_id, start=0, limit=99999, load_foods=False, order_by="created_at", order_descending=True
) -> Sequence[RecipeModel]:
args = [
joinedload(RecipeModel.recipe_category),
joinedload(RecipeModel.tags),
joinedload(RecipeModel.tools),
]
if load_foods:
args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredientModel.food)))
try:
if order_by:
order_attr = getattr(RecipeModel, order_by)
else:
order_attr = RecipeModel.created_at
except AttributeError:
self.logger.info(f'Attempted to sort by unknown sort property "{order_by}"; ignoring')
order_attr = RecipeModel.created_at
if order_descending:
order_attr = order_attr.desc()
else:
order_attr = order_attr.asc()
stmt = (
sa.select(RecipeModel)
.options(*args)
.filter(RecipeModel.group_id == group_id)
.order_by(order_attr)
.offset(start)
.limit(limit)
)
return self.session.execute(stmt).scalars().all()
def _uuids_for_items(self, items: list[UUID | str] | None, model: type[SqlAlchemyBase]) -> list[UUID] | None:
if not items:
return None
@@ -227,6 +169,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
joinedload(RecipeModel.recipe_category),
joinedload(RecipeModel.tags),
joinedload(RecipeModel.tools),
joinedload(RecipeModel.user),
]
q = q.options(*args)
@@ -296,6 +239,11 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.join(RecipeModel.recipe_category)
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
)
if self.group_id:
stmt = stmt.filter(RecipeModel.group_id == self.group_id)
if self.household_id:
stmt = stmt.filter(RecipeModel.household_id == self.household_id)
return [RecipeSummary.model_validate(x) for x in self.session.execute(stmt).unique().scalars().all()]
def _build_recipe_filter(
@@ -309,12 +257,11 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
require_all_tools: bool = True,
require_all_foods: bool = True,
) -> list:
fltr: list[sa.ColumnElement] = []
if self.group_id:
fltr = [
RecipeModel.group_id == self.group_id,
]
else:
fltr = []
fltr.append(RecipeModel.group_id == self.group_id)
if self.household_id:
fltr.append(RecipeModel.household_id == self.household_id)
if categories:
if require_all_categories:
@@ -382,12 +329,12 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
def get_random(self, limit=1) -> list[Recipe]:
stmt = (
sa.select(RecipeModel)
.filter(RecipeModel.group_id == self.group_id)
.order_by(sa.func.random()) # Postgres and SQLite specific
.limit(limit)
)
stmt = sa.select(RecipeModel).order_by(sa.func.random()).limit(limit) # Postgres and SQLite specific
if self.group_id:
stmt = stmt.filter(RecipeModel.group_id == self.group_id)
if self.household_id:
stmt = stmt.filter(RecipeModel.household_id == self.household_id)
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
def get_by_slug(self, group_id: UUID4, slug: str) -> Recipe | None:

View File

@@ -1,11 +1,11 @@
from pydantic import UUID4
from mealie.db.models.group.shopping_list import ShoppingList
from mealie.schema.group.group_shopping_list import ShoppingListOut, ShoppingListUpdate
from mealie.db.models.household.shopping_list import ShoppingList
from mealie.schema.household.group_shopping_list import ShoppingListOut, ShoppingListUpdate
from .repository_generic import RepositoryGeneric
from .repository_generic import HouseholdRepositoryGeneric
class RepositoryShoppingList(RepositoryGeneric[ShoppingListOut, ShoppingList]):
class RepositoryShoppingList(HouseholdRepositoryGeneric[ShoppingListOut, ShoppingList]):
def update(self, item_id: UUID4, data: ShoppingListUpdate) -> ShoppingListOut: # type: ignore
return super().update(item_id, data)

View File

@@ -4,10 +4,10 @@ from sqlalchemy import select
from mealie.db.models.recipe.ingredient import IngredientUnitModel
from mealie.schema.recipe.recipe_ingredient import IngredientUnit
from .repository_generic import RepositoryGeneric
from .repository_generic import GroupRepositoryGeneric
class RepositoryUnit(RepositoryGeneric[IngredientUnit, IngredientUnitModel]):
class RepositoryUnit(GroupRepositoryGeneric[IngredientUnit, IngredientUnitModel]):
def _get_unit(self, id: UUID4) -> IngredientUnitModel:
stmt = select(self.model).filter_by(**self._filter_builder(**{"id": id}))
return self.session.execute(stmt).scalars().one()
@@ -26,6 +26,3 @@ class RepositoryUnit(RepositoryGeneric[IngredientUnit, IngredientUnitModel]):
raise e
return self.get_one(to_unit)
def by_group(self, group_id: UUID4) -> "RepositoryUnit":
return super().by_group(group_id)

View File

@@ -10,12 +10,12 @@ from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.schema.user.user import PrivateUser, UserRatingOut
from ..db.models.users import User
from .repository_generic import RepositoryGeneric
from .repository_generic import GroupRepositoryGeneric
settings = get_app_settings()
class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
class RepositoryUsers(GroupRepositoryGeneric[PrivateUser, User]):
def update_password(self, id, password: str):
entry = self._query_one(match_value=id)
if settings.IS_DEMO:
@@ -75,7 +75,10 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
return [self.schema.model_validate(x) for x in results]
class RepositoryUserRatings(RepositoryGeneric[UserRatingOut, UserToRecipe]):
class RepositoryUserRatings(GroupRepositoryGeneric[UserRatingOut, UserToRecipe]):
# Since users can post events on recipes that belong to other households,
# this is a group repository, rather than a household repository.
def get_by_user(self, user_id: UUID4, favorites_only=False) -> list[UserRatingOut]:
stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id)
if favorites_only:

View File

@@ -2,8 +2,6 @@ from abc import ABC, abstractmethod
from logging import Logger
from pathlib import Path
from pydantic import UUID4
from mealie.core.root_logger import get_logger
from mealie.repos.repository_factory import AllRepositories
@@ -13,14 +11,13 @@ class AbstractSeeder(ABC):
Abstract class for seeding data.
"""
def __init__(self, db: AllRepositories, logger: Logger | None = None, group_id: UUID4 | None = None):
def __init__(self, db: AllRepositories, logger: Logger | None = None):
"""
Initialize the abstract seeder.
:param db_conn: Database connection.
:param logger: Logger.
"""
self.repos = db
self.group_id = group_id
self.logger = logger or get_logger("Data Seeder")
self.resources = Path(__file__).parent / "resources"

View File

@@ -15,6 +15,7 @@ def dev_users() -> list[dict]:
"email": "jason@example.com",
"password": hash_password(settings._DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"household": settings.DEFAULT_HOUSEHOLD,
"admin": False,
},
{
@@ -23,6 +24,7 @@ def dev_users() -> list[dict]:
"email": "bob@example.com",
"password": hash_password(settings._DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"household": settings.DEFAULT_HOUSEHOLD,
"admin": False,
},
{
@@ -31,6 +33,7 @@ def dev_users() -> list[dict]:
"email": "sarah@example.com",
"password": hash_password(settings._DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"household": settings.DEFAULT_HOUSEHOLD,
"admin": False,
},
{
@@ -39,6 +42,7 @@ def dev_users() -> list[dict]:
"email": "sammy@example.com",
"password": hash_password(settings._DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"household": settings.DEFAULT_HOUSEHOLD,
"admin": False,
},
]
@@ -51,6 +55,7 @@ def default_user_init(db: AllRepositories):
"email": settings._DEFAULT_EMAIL,
"password": hash_password(settings._DEFAULT_PASSWORD),
"group": settings.DEFAULT_GROUP,
"household": settings.DEFAULT_HOUSEHOLD,
"admin": True,
}

View File

@@ -14,7 +14,7 @@ from .resources import foods, labels, units
class MultiPurposeLabelSeeder(AbstractSeeder):
@cached_property
def service(self):
return MultiPurposeLabelService(self.repos, self.group_id)
return MultiPurposeLabelService(self.repos)
def get_file(self, locale: str | None = None) -> pathlib.Path:
locale_path = self.resources / "labels" / "locales" / f"{locale}.json"
@@ -31,7 +31,7 @@ class MultiPurposeLabelSeeder(AbstractSeeder):
seen_label_names.add(label["name"])
yield MultiPurposeLabelSave(
name=label["name"],
group_id=self.group_id,
group_id=self.repos.group_id,
)
def seed(self, locale: str | None = None) -> None:
@@ -58,7 +58,7 @@ class IngredientUnitsSeeder(AbstractSeeder):
seen_unit_names.add(unit["name"])
yield SaveIngredientUnit(
group_id=self.group_id,
group_id=self.repos.group_id,
name=unit["name"],
plural_name=unit.get("plural_name"),
description=unit["description"],
@@ -86,7 +86,7 @@ class IngredientFoodsSeeder(AbstractSeeder):
seed_foods: dict[str, str] = json.loads(file.read_text(encoding="utf-8"))
for food in set(seed_foods.values()):
yield SaveIngredientFood(
group_id=self.group_id,
group_id=self.repos.group_id,
name=food,
description="",
)

View File

@@ -7,6 +7,7 @@ from . import (
comments,
explore,
groups,
households,
organizers,
parser,
recipe,
@@ -21,6 +22,7 @@ router = APIRouter(prefix="/api")
router.include_router(app.router)
router.include_router(auth.router)
router.include_router(users.router)
router.include_router(households.router)
router.include_router(groups.router)
router.include_router(recipe.router)
router.include_router(organizers.router)

View File

@@ -6,7 +6,12 @@ from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import Session
from mealie.core.config import get_app_dirs, get_app_settings
from mealie.core.dependencies.dependencies import get_admin_user, get_current_user, get_integration_id, get_public_group
from mealie.core.dependencies.dependencies import (
get_admin_user,
get_current_user,
get_integration_id,
get_public_group,
)
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.core.root_logger import get_logger
from mealie.core.settings.directories import AppDirectories
@@ -14,8 +19,10 @@ from mealie.core.settings.settings import AppSettings
from mealie.db.db_setup import generate_session
from mealie.lang import local_provider
from mealie.lang.providers import Translator
from mealie.repos.all_repositories import AllRepositories
from mealie.repos._utils import NOT_SET, NotSet
from mealie.repos.all_repositories import AllRepositories, get_repositories
from mealie.routes._base.checks import OperationChecks
from mealie.schema.household.household import HouseholdInDB
from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.services.event_bus_service.event_bus_service import EventBusService
from mealie.services.event_bus_service.event_types import EventDocumentDataBase, EventTypes
@@ -37,7 +44,7 @@ class _BaseController(ABC): # noqa: B024
@property
def repos(self):
if not self._repos:
self._repos = AllRepositories(self.session)
self._repos = AllRepositories(self.session, group_id=self.group_id, household_id=self.household_id)
return self._repos
@property
@@ -58,6 +65,14 @@ class _BaseController(ABC): # noqa: B024
self._folders = get_app_dirs()
return self._folders
@property
def group_id(self) -> UUID4 | None | NotSet:
return NOT_SET
@property
def household_id(self) -> UUID4 | None | NotSet:
return NOT_SET
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -71,15 +86,40 @@ class BasePublicController(_BaseController):
...
class BasePublicExploreController(BasePublicController):
class BasePublicGroupExploreController(BasePublicController):
"""
This is a public class for all User restricted controllers in the API.
It includes the common SharedDependencies and some common methods used
by all Admin controllers.
Base class for all controllers that are public and explore group data.
"""
group: GroupInDB = Depends(get_public_group)
@property
def group_id(self) -> UUID4 | None | NotSet:
return self.group.id
def get_explore_url_path(self, endpoint: str) -> str:
if endpoint.startswith("/"):
endpoint = endpoint[1:]
return f"/explore/groups/{self.group.slug}/{endpoint}"
class BasePublicHouseholdExploreController(BasePublicGroupExploreController):
"""
Base class for all controllers that are public and explore household data.
"""
@property
def cross_household_repos(self):
"""
Household-level repos with no household filter. Public controllers don't have access to a household identifier;
instead, they return all public data, filtered by the household preferences.
When using this repo, the caller should filter by household preferences, e.g.:
`household.preferences.privateHousehold = FALSE`
"""
return get_repositories(self.session, group_id=self.group_id, household_id=None)
class BaseUserController(_BaseController):
"""
@@ -105,10 +145,18 @@ class BaseUserController(_BaseController):
def group_id(self) -> UUID4:
return self.user.group_id
@property
def household_id(self) -> UUID4:
return self.user.household_id
@property
def group(self) -> GroupInDB:
return self.repos.groups.get_one(self.group_id)
@property
def household(self) -> HouseholdInDB:
return self.repos.households.get_one(self.household_id)
@property
def checks(self) -> OperationChecks:
if not self._checks:
@@ -125,6 +173,13 @@ class BaseAdminController(BaseUserController):
user: PrivateUser = Depends(get_admin_user)
@property
def repos(self):
if not self._repos:
# Admins have access to all groups and households, so we don't want to filter by group_id or household_id
self._repos = AllRepositories(self.session, group_id=None, household_id=None)
return self._repos
class BaseCrudController(BaseUserController):
"""
@@ -133,10 +188,18 @@ class BaseCrudController(BaseUserController):
event_bus: EventBusService = Depends(EventBusService.as_dependency)
def publish_event(self, event_type: EventTypes, document_data: EventDocumentDataBase, message: str = "") -> None:
def publish_event(
self,
event_type: EventTypes,
document_data: EventDocumentDataBase,
group_id: UUID4,
household_id: UUID4 | None,
message: str = "",
) -> None:
self.event_bus.dispatch(
integration_id=self.integration_id,
group_id=self.group_id,
group_id=group_id,
household_id=household_id,
event_type=event_type,
document_data=document_data,
message=message,

View File

@@ -35,7 +35,7 @@ class MealieCrudRoute(APIRoute):
response = await original_route_handler(request)
response_body = json.loads(response.body)
if isinstance(response_body, dict):
if last_modified := response_body.get("updateAt"):
if last_modified := response_body.get("updatedAt"):
response.headers["last-modified"] = last_modified
# Force no-cache for all responses to prevent browser from caching API calls

View File

@@ -2,11 +2,11 @@ from mealie.routes._base.routers import AdminAPIRouter
from . import (
admin_about,
admin_analytics,
admin_backups,
admin_email,
admin_maintenance,
admin_management_groups,
admin_management_households,
admin_management_users,
)
@@ -14,8 +14,8 @@ router = AdminAPIRouter(prefix="/admin")
router.include_router(admin_about.router, tags=["Admin: About"])
router.include_router(admin_management_users.router, tags=["Admin: Manage Users"])
router.include_router(admin_management_households.router, tags=["Admin: Manage Households"])
router.include_router(admin_management_groups.router, tags=["Admin: Manage Groups"])
router.include_router(admin_email.router, tags=["Admin: Email"])
router.include_router(admin_backups.router, tags=["Admin: Backups"])
router.include_router(admin_maintenance.router, tags=["Admin: Maintenance"])
router.include_router(admin_analytics.router, tags=["Admin: Analytics"])

View File

@@ -27,6 +27,7 @@ class AdminAboutController(BaseAdminController):
db_type=settings.DB_ENGINE,
db_url=settings.DB_URL_PUBLIC,
default_group=settings.DEFAULT_GROUP,
default_household=settings.DEFAULT_HOUSEHOLD,
allow_signup=settings.ALLOW_SIGNUP,
build_id=settings.GIT_COMMIT_HASH,
recipe_scraper_version=recipe_scraper_version.__version__,
@@ -44,6 +45,7 @@ class AdminAboutController(BaseAdminController):
uncategorized_recipes=self.repos.recipes.count_uncategorized(), # type: ignore
untagged_recipes=self.repos.recipes.count_untagged(), # type: ignore
total_users=self.repos.users.count_all(),
total_households=self.repos.households.count_all(),
total_groups=self.repos.groups.count_all(),
)

View File

@@ -1,20 +0,0 @@
from functools import cached_property
from fastapi import APIRouter
from mealie.routes._base import BaseAdminController, controller
from mealie.schema.analytics.analytics import MealieAnalytics
from mealie.services.analytics.service_analytics import AnalyticsService
router = APIRouter(prefix="/analytics", include_in_schema=False) # deprecated - use statistics route instead
@controller(router)
class AdminAboutController(BaseAdminController):
@cached_property
def service(self) -> AnalyticsService:
return AnalyticsService(self.repos)
@router.get("", response_model=MealieAnalytics)
def get_analytics(self):
return self.service.calculate_analytics()

View File

@@ -13,11 +13,11 @@ from mealie.services.group_services.group_service import GroupService
from .._base import BaseAdminController, controller
from .._base.mixins import HttpRepo
router = APIRouter(prefix="/groups", tags=["Admin: Groups"])
router = APIRouter(prefix="/groups")
@controller(router)
class AdminUserManagementRoutes(BaseAdminController):
class AdminGroupManagementRoutes(BaseAdminController):
@cached_property
def repo(self):
if not self.user:
@@ -64,6 +64,7 @@ class AdminUserManagementRoutes(BaseAdminController):
group.preferences = self.repos.group_preferences.update(item_id, preferences)
if data.name not in ["", group.name]:
# only update the group if the name changed, since the name is the only field that can be updated
group.name = data.name
group = self.repo.update(item_id, group)

View File

@@ -0,0 +1,91 @@
from functools import cached_property
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import UUID4
from sqlalchemy import func, select
from mealie.db.models.users.users import User
from mealie.schema.household.household import (
HouseholdCreate,
HouseholdInDB,
HouseholdPagination,
UpdateHouseholdAdmin,
)
from mealie.schema.mapper import mapper
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.responses import ErrorResponse
from mealie.services.household_services.household_service import HouseholdService
from .._base import BaseAdminController, controller
from .._base.mixins import HttpRepo
router = APIRouter(prefix="/households")
@controller(router)
class AdminHouseholdManagementRoutes(BaseAdminController):
@cached_property
def repo(self):
if not self.user:
raise Exception("No user is logged in.")
return self.repos.households
# =======================================================================
# CRUD Operations
@property
def mixins(self):
return HttpRepo[HouseholdCreate, HouseholdInDB, UpdateHouseholdAdmin](
self.repo,
self.logger,
self.registered_exceptions,
)
@router.get("", response_model=HouseholdPagination)
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
response = self.repo.page_all(
pagination=q,
override=HouseholdInDB,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=HouseholdInDB, status_code=status.HTTP_201_CREATED)
def create_one(self, data: HouseholdCreate):
return HouseholdService.create_household(self.repos, data)
@router.get("/{item_id}", response_model=HouseholdInDB)
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@router.put("/{item_id}", response_model=HouseholdInDB)
def update_one(self, item_id: UUID4, data: UpdateHouseholdAdmin):
household = self.repo.get_one(item_id)
if data.preferences:
preferences = self.repos.household_preferences.get_one(value=item_id, key="household_id")
preferences = mapper(data.preferences, preferences)
household.preferences = self.repos.household_preferences.update(item_id, preferences)
if data.name not in ["", household.name]:
# only update the household if the name changed, since the name is the only field that can be updated
household.name = data.name
household = self.repo.update(item_id, household)
return household
@router.delete("/{item_id}", response_model=HouseholdInDB)
def delete_one(self, item_id: UUID4):
item = self.repo.get_one(item_id)
if item:
stmt = select(func.count(User.id)).filter_by(group_id=item.group_id, household_id=item_id)
user_count = self.session.scalar(stmt)
if user_count:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse.respond(message="Cannot delete household with users"),
)
return self.mixins.delete_one(item_id)

View File

@@ -14,7 +14,7 @@ from mealie.schema.user.user_passwords import ForgotPassword, PasswordResetToken
from mealie.services.user_services.password_reset_service import PasswordResetService
from mealie.services.user_services.user_service import UserService
router = APIRouter(prefix="/users", tags=["Admin: Users"])
router = APIRouter(prefix="/users")
@controller(router)

View File

@@ -16,12 +16,20 @@ def get_app_info(session: Session = Depends(generate_session)):
"""Get general application information"""
settings = get_app_settings()
repos = get_repositories(session)
default_group = repos.groups.get_by_name(settings.DEFAULT_GROUP)
public_repos = get_repositories(session, group_id=None, household_id=None)
default_group_slug: str | None = None
default_household_slug: str | None = None
default_group = public_repos.groups.get_by_name(settings.DEFAULT_GROUP)
if default_group and default_group.preferences and not default_group.preferences.private_group:
default_group_slug = default_group.slug
else:
default_group_slug = None
if default_group and default_group_slug:
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 and default_household.preferences and not default_household.preferences.private_household:
default_household_slug = default_household.slug
return AppInfo(
version=APP_VERSION,
@@ -29,6 +37,7 @@ def get_app_info(session: Session = Depends(generate_session)):
production=settings.PRODUCTION,
allow_signup=settings.ALLOW_SIGNUP,
default_group_slug=default_group_slug,
default_household_slug=default_household_slug,
enable_oidc=settings.OIDC_READY,
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
oidc_provider_name=settings.OIDC_PROVIDER_NAME,

View File

@@ -7,13 +7,14 @@ from . import (
controller_public_recipes,
)
prefix = "/explore"
router = APIRouter(prefix="/explore/groups/{group_slug}")
router = APIRouter()
# group
router.include_router(controller_public_foods.router, tags=["Explore: Foods"])
router.include_router(controller_public_organizers.categories_router, tags=["Explore: Categories"])
router.include_router(controller_public_organizers.tags_router, tags=["Explore: Tags"])
router.include_router(controller_public_organizers.tools_router, tags=["Explore: Tools"])
router.include_router(controller_public_cookbooks.router, prefix=prefix, tags=["Explore: Cookbooks"])
router.include_router(controller_public_foods.router, prefix=prefix, tags=["Explore: Foods"])
router.include_router(controller_public_organizers.categories_router, prefix=prefix, tags=["Explore: Categories"])
router.include_router(controller_public_organizers.tags_router, prefix=prefix, tags=["Explore: Tags"])
router.include_router(controller_public_organizers.tools_router, prefix=prefix, tags=["Explore: Tools"])
router.include_router(controller_public_recipes.router, prefix=prefix, tags=["Explore: Recipes"])
# household
router.include_router(controller_public_cookbooks.router, tags=["Explore: Cookbooks"])
router.include_router(controller_public_recipes.router, tags=["Explore: Recipes"])

View File

@@ -3,46 +3,46 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController
from mealie.schema.cookbook.cookbook import ReadCookBook, RecipeCookBook
from mealie.schema.make_dependable import make_dependable
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
router = APIRouter(prefix="/cookbooks/{group_slug}")
router = APIRouter(prefix="/cookbooks")
@controller(router)
class PublicCookbooksController(BasePublicExploreController):
class PublicCookbooksController(BasePublicHouseholdExploreController):
@property
def cookbooks(self):
return self.repos.cookbooks.by_group(self.group.id)
@property
def recipes(self):
return self.repos.recipes.by_group(self.group.id)
def cross_household_cookbooks(self):
return self.cross_household_repos.cookbooks
@router.get("", response_model=PaginationBase[ReadCookBook])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
self,
q: PaginationQuery = Depends(make_dependable(PaginationQuery)),
search: str | None = None,
) -> PaginationBase[ReadCookBook]:
public_filter = "public = TRUE"
public_filter = "(household.preferences.privateHousehold = FALSE AND public = TRUE)"
if q.query_filter:
q.query_filter = f"({q.query_filter}) AND {public_filter}"
else:
q.query_filter = public_filter
response = self.cookbooks.page_all(
response = self.cross_household_cookbooks.page_all(
pagination=q,
override=ReadCookBook,
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump())
response.set_pagination_guides(self.get_explore_url_path(router.url_path_for("get_all")), q.model_dump())
return response
@router.get("/{item_id}", response_model=RecipeCookBook)
def get_one(self, item_id: UUID4 | str) -> RecipeCookBook:
NOT_FOUND_EXCEPTION = HTTPException(404, "cookbook not found")
if isinstance(item_id, UUID):
match_attr = "id"
else:
@@ -51,12 +51,24 @@ class PublicCookbooksController(BasePublicExploreController):
match_attr = "id"
except ValueError:
match_attr = "slug"
cookbook = self.cookbooks.get_one(item_id, match_attr)
cookbook = self.cross_household_cookbooks.get_one(item_id, match_attr)
if not cookbook or not cookbook.public:
raise HTTPException(404, "cookbook not found")
raise NOT_FOUND_EXCEPTION
household = self.repos.households.get_one(cookbook.household_id)
if not household or household.preferences.private_household:
raise NOT_FOUND_EXCEPTION
recipes = self.recipes.page_all(
PaginationQuery(page=1, per_page=-1, query_filter="settings.public = TRUE"), cookbook=cookbook
# limit recipes to only the household the cookbook belongs to
recipes_repo = get_repositories(
self.session, group_id=self.group_id, household_id=cookbook.household_id
).recipes
recipes = recipes_repo.page_all(
PaginationQuery(
page=1,
per_page=-1,
query_filter="settings.public = TRUE",
),
cookbook=cookbook,
)
return cookbook.cast(RecipeCookBook, recipes=recipes.items)

View File

@@ -2,23 +2,25 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.routes._base.base_controllers import BasePublicGroupExploreController
from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
router = APIRouter(prefix="/foods/{group_slug}")
router = APIRouter(prefix="/foods")
@controller(router)
class PublicFoodsController(BasePublicExploreController):
class PublicFoodsController(BasePublicGroupExploreController):
@property
def ingredient_foods(self):
return self.repos.ingredient_foods.by_group(self.group.id)
return self.repos.ingredient_foods
@router.get("", response_model=PaginationBase[IngredientFood])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
self,
q: PaginationQuery = Depends(make_dependable(PaginationQuery)),
search: str | None = None,
) -> PaginationBase[IngredientFood]:
response = self.ingredient_foods.page_all(
pagination=q,
@@ -26,7 +28,7 @@ class PublicFoodsController(BasePublicExploreController):
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump())
response.set_pagination_guides(self.get_explore_url_path(router.url_path_for("get_all")), q.model_dump())
return response
@router.get("/{item_id}", response_model=IngredientFood)

View File

@@ -2,28 +2,30 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.routes._base.base_controllers import BasePublicGroupExploreController
from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe.recipe import RecipeCategory, RecipeTag, RecipeTool
from mealie.schema.recipe.recipe_category import CategoryOut, TagOut
from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
base_prefix = "/organizers/{group_slug}"
base_prefix = "/organizers"
categories_router = APIRouter(prefix=f"{base_prefix}/categories")
tags_router = APIRouter(prefix=f"{base_prefix}/tags")
tools_router = APIRouter(prefix=f"{base_prefix}/tools")
@controller(categories_router)
class PublicCategoriesController(BasePublicExploreController):
class PublicCategoriesController(BasePublicGroupExploreController):
@property
def categories(self):
return self.repos.categories.by_group(self.group.id)
return self.repos.categories
@categories_router.get("", response_model=PaginationBase[RecipeCategory])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
self,
q: PaginationQuery = Depends(make_dependable(PaginationQuery)),
search: str | None = None,
) -> PaginationBase[RecipeCategory]:
response = self.categories.page_all(
pagination=q,
@@ -31,9 +33,7 @@ class PublicCategoriesController(BasePublicExploreController):
search=search,
)
response.set_pagination_guides(
categories_router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump()
)
response.set_pagination_guides(self.get_explore_url_path(tags_router.url_path_for("get_all")), q.model_dump())
return response
@categories_router.get("/{item_id}", response_model=CategoryOut)
@@ -46,14 +46,16 @@ class PublicCategoriesController(BasePublicExploreController):
@controller(tags_router)
class PublicTagsController(BasePublicExploreController):
class PublicTagsController(BasePublicGroupExploreController):
@property
def tags(self):
return self.repos.tags.by_group(self.group.id)
return self.repos.tags
@tags_router.get("", response_model=PaginationBase[RecipeTag])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
self,
q: PaginationQuery = Depends(make_dependable(PaginationQuery)),
search: str | None = None,
) -> PaginationBase[RecipeTag]:
response = self.tags.page_all(
pagination=q,
@@ -61,7 +63,7 @@ class PublicTagsController(BasePublicExploreController):
search=search,
)
response.set_pagination_guides(tags_router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump())
response.set_pagination_guides(self.get_explore_url_path(tags_router.url_path_for("get_all")), q.model_dump())
return response
@tags_router.get("/{item_id}", response_model=TagOut)
@@ -74,14 +76,16 @@ class PublicTagsController(BasePublicExploreController):
@controller(tools_router)
class PublicToolsController(BasePublicExploreController):
class PublicToolsController(BasePublicGroupExploreController):
@property
def tools(self):
return self.repos.tools.by_group(self.group.id)
return self.repos.tools
@tools_router.get("", response_model=PaginationBase[RecipeTool])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
self,
q: PaginationQuery = Depends(make_dependable(PaginationQuery)),
search: str | None = None,
) -> PaginationBase[RecipeTool]:
response = self.tools.page_all(
pagination=q,
@@ -89,7 +93,7 @@ class PublicToolsController(BasePublicExploreController):
search=search,
)
response.set_pagination_guides(tools_router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump())
response.set_pagination_guides(self.get_explore_url_path(tools_router.url_path_for("get_all")), q.model_dump())
return response
@tools_router.get("/{item_id}", response_model=RecipeToolOut)

View File

@@ -4,8 +4,9 @@ import orjson
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import UUID4
from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.routes._base.base_controllers import BasePublicHouseholdExploreController
from mealie.routes.recipe.recipe_crud_routes import JSONBytes
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.make_dependable import make_dependable
@@ -13,18 +14,18 @@ from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.response.pagination import PaginationBase, PaginationQuery, RecipeSearchQuery
router = APIRouter(prefix="/recipes/{group_slug}")
router = APIRouter(prefix="/recipes")
@controller(router)
class PublicRecipesController(BasePublicExploreController):
class PublicRecipesController(BasePublicHouseholdExploreController):
@property
def cookbooks(self):
return self.repos.cookbooks.by_group(self.group.id)
def cross_household_cookbooks(self):
return self.cross_household_repos.cookbooks
@property
def recipes(self):
return self.repos.recipes.by_group(self.group.id)
def cross_household_recipes(self):
return self.cross_household_repos.recipes
@router.get("", response_model=PaginationBase[RecipeSummary])
def get_all(
@@ -38,7 +39,9 @@ class PublicRecipesController(BasePublicExploreController):
foods: list[UUID4 | str] | None = Query(None),
) -> PaginationBase[RecipeSummary]:
cookbook_data: ReadCookBook | None = None
recipes_repo = self.cross_household_recipes
if search_query.cookbook:
COOKBOOK_NOT_FOUND_EXCEPTION = HTTPException(404, "cookbook not found")
if isinstance(search_query.cookbook, UUID):
cb_match_attr = "id"
else:
@@ -47,18 +50,26 @@ class PublicRecipesController(BasePublicExploreController):
cb_match_attr = "id"
except ValueError:
cb_match_attr = "slug"
cookbook_data = self.cookbooks.get_one(search_query.cookbook, cb_match_attr)
cookbook_data = self.cross_household_cookbooks.get_one(search_query.cookbook, cb_match_attr)
if cookbook_data is None or not cookbook_data.public:
raise HTTPException(status_code=404, detail="cookbook not found")
raise COOKBOOK_NOT_FOUND_EXCEPTION
household = self.repos.households.get_one(cookbook_data.household_id)
if not household or household.preferences.private_household:
raise COOKBOOK_NOT_FOUND_EXCEPTION
public_filter = "settings.public = TRUE"
# filter recipes by the cookbook's household
recipes_repo = get_repositories(
self.session, group_id=self.group_id, household_id=cookbook_data.household_id
).recipes
public_filter = "(household.preferences.privateHousehold = FALSE AND settings.public = TRUE)"
if q.query_filter:
q.query_filter = f"({q.query_filter}) AND {public_filter}"
else:
q.query_filter = public_filter
pagination_response = self.recipes.page_all(
pagination_response = recipes_repo.page_all(
pagination=q,
cookbook=cookbook_data,
categories=categories,
@@ -75,7 +86,7 @@ class PublicRecipesController(BasePublicExploreController):
# merge default pagination with the request's query params
query_params = q.model_dump() | {**request.query_params}
pagination_response.set_pagination_guides(
router.url_path_for("get_all", group_slug=self.group.slug),
self.get_explore_url_path(router.url_path_for("get_all")),
{k: v for k, v in query_params.items() if v is not None},
)
@@ -86,9 +97,13 @@ class PublicRecipesController(BasePublicExploreController):
@router.get("/{recipe_slug}", response_model=Recipe)
def get_recipe(self, recipe_slug: str) -> Recipe:
recipe = self.repos.recipes.by_group(self.group.id).get_one(recipe_slug)
RECIPE_NOT_FOUND_EXCEPTION = HTTPException(404, "recipe not found")
recipe = self.cross_household_recipes.get_one(recipe_slug)
if not recipe or not recipe.settings.public:
raise HTTPException(404, "recipe not found")
raise RECIPE_NOT_FOUND_EXCEPTION
household = self.repos.households.get_one(recipe.household_id)
if not household or household.preferences.private_household:
raise RECIPE_NOT_FOUND_EXCEPTION
return recipe

View File

@@ -1,36 +1,17 @@
from fastapi import APIRouter
from . import (
controller_cookbooks,
controller_group_notifications,
controller_group_recipe_actions,
controller_group_reports,
controller_group_self_service,
controller_invitations,
controller_labels,
controller_mealplan,
controller_mealplan_config,
controller_mealplan_rules,
controller_migrations,
controller_seeder,
controller_shopping_lists,
controller_webhooks,
)
router = APIRouter()
router.include_router(controller_group_self_service.router)
router.include_router(controller_mealplan_rules.router)
router.include_router(controller_mealplan_config.router)
router.include_router(controller_mealplan.router)
router.include_router(controller_cookbooks.router)
router.include_router(controller_webhooks.router)
router.include_router(controller_invitations.router)
router.include_router(controller_migrations.router)
router.include_router(controller_group_reports.router)
router.include_router(controller_shopping_lists.router)
router.include_router(controller_shopping_lists.item_router)
router.include_router(controller_labels.router)
router.include_router(controller_group_notifications.router)
router.include_router(controller_group_recipe_actions.router)
router.include_router(controller_seeder.router)

View File

@@ -17,7 +17,7 @@ router = APIRouter(prefix="/groups/reports", tags=["Groups: Reports"])
class GroupReportsController(BaseUserController):
@cached_property
def repo(self):
return self.repos.group_reports.by_group(self.user.group_id)
return self.repos.group_reports
def registered_exceptions(self, ex: type[Exception]) -> str:
return {

View File

@@ -1,14 +1,16 @@
from functools import cached_property
from fastapi import HTTPException, status
from fastapi import Query
from pydantic import UUID4
from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.group.group_permissions import SetPermissions
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.schema.group.group_statistics import GroupStatistics, GroupStorage
from mealie.schema.user.user import GroupSummary, UserOut
from mealie.schema.group.group_statistics import GroupStorage
from mealie.schema.household.household import HouseholdSummary
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.user.user import GroupSummary, UserSummary
from mealie.services.group_services.group_service import GroupService
router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"])
@@ -25,10 +27,20 @@ class GroupSelfServiceController(BaseUserController):
"""Returns the Group Data for the Current User"""
return self.group.cast(GroupSummary)
@router.get("/members", response_model=list[UserOut])
def get_group_members(self):
"""Returns the Group of user lists"""
return self.repos.users.multi_query(query_by={"group_id": self.group.id}, override_schema=UserOut)
@router.get("/members", response_model=list[UserSummary])
def get_group_members(self, household_id: UUID4 | None = Query(None, alias="householdId")):
"""Returns all users belonging to the current group, optionally filtered by household_id"""
query_filter = f"household_id={household_id}" if household_id else None
private_users = self.repos.users.page_all(PaginationQuery(page=1, per_page=-1, query_filter=query_filter)).items
return [user.cast(UserSummary) for user in private_users]
@router.get("/households", response_model=list[HouseholdSummary])
def get_group_households(self):
"""Returns all households belonging to the current group"""
households = self.repos.households.page_all(PaginationQuery(page=1, per_page=-1)).items
return [household.cast(HouseholdSummary) for household in households]
@router.get("/preferences", response_model=ReadGroupPreferences)
def get_group_preferences(self):
@@ -38,28 +50,6 @@ class GroupSelfServiceController(BaseUserController):
def update_group_preferences(self, new_pref: UpdateGroupPreferences):
return self.repos.group_preferences.update(self.group_id, new_pref)
@router.put("/permissions", response_model=UserOut)
def set_member_permissions(self, permissions: SetPermissions):
self.checks.can_manage()
target_user = self.repos.users.get_one(permissions.user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
if target_user.group_id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this group")
target_user.can_invite = permissions.can_invite
target_user.can_manage = permissions.can_manage
target_user.can_organize = permissions.can_organize
return self.repos.users.update(permissions.user_id, target_user)
@router.get("/statistics", response_model=GroupStatistics)
def get_statistics(self):
return self.service.calculate_statistics()
@router.get("/storage", response_model=GroupStorage)
def get_storage(self):
return self.service.calculate_group_storage()

View File

@@ -17,14 +17,14 @@ from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelPagination
from mealie.schema.response.pagination import PaginationQuery
from mealie.services.group_services.labels_service import MultiPurposeLabelService
router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"], route_class=MealieCrudRoute)
router = APIRouter(prefix="/groups/labels", tags=["Groups: Multi Purpose Labels"], route_class=MealieCrudRoute)
@controller(router)
class MultiPurposeLabelsController(BaseUserController):
@cached_property
def service(self):
return MultiPurposeLabelService(self.repos, self.group.id)
return MultiPurposeLabelService(self.repos)
@cached_property
def repo(self):

View File

@@ -1,26 +0,0 @@
from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB
router = UserAPIRouter(prefix="/groups/categories", tags=["Groups: Mealplan Categories"])
@controller(router)
class GroupMealplanConfigController(BaseUserController):
@property
def mixins(self):
return HttpRepo[GroupInDB, GroupInDB, GroupInDB](self.repos.groups, self.logger)
@router.get("", response_model=list[CategoryBase])
def get_mealplan_categories(self):
data = self.mixins.get_one(self.user.group_id)
return data.categories
@router.put("", response_model=list[CategoryBase])
def update_mealplan_categories(self, new_categories: list[CategoryBase]):
data = self.mixins.get_one(self.user.group_id)
data.categories = new_categories
return self.mixins.update_one(data, data.id).categories

View File

@@ -21,7 +21,7 @@ from mealie.services.migrations import (
TandoorMigrator,
)
router = UserAPIRouter(prefix="/groups/migrations", tags=["Group: Migrations"])
router = UserAPIRouter(prefix="/groups/migrations", tags=["Groups: Migrations"])
@controller(router)
@@ -43,6 +43,7 @@ class GroupMigrationController(BaseUserController):
"db": self.repos,
"session": self.session,
"user_id": self.user.id,
"household_id": self.household_id,
"group_id": self.group_id,
"add_migration_tag": add_migration_tag,
"translator": self.translator,

View File

@@ -15,7 +15,7 @@ router = APIRouter(prefix="/groups/seeders", tags=["Groups: Seeders"])
class DataSeederController(BaseUserController):
@cached_property
def service(self) -> SeederService:
return SeederService(self.repos, self.user, self.group)
return SeederService(self.repos)
def _wrap(self, func):
try:

View File

@@ -0,0 +1,28 @@
from fastapi import APIRouter
from . import (
controller_cookbooks,
controller_group_notifications,
controller_group_recipe_actions,
controller_household_self_service,
controller_invitations,
controller_mealplan,
controller_mealplan_rules,
controller_shopping_lists,
controller_webhooks,
)
router = APIRouter()
router.include_router(controller_cookbooks.router)
router.include_router(controller_group_notifications.router)
router.include_router(controller_group_recipe_actions.router)
router.include_router(controller_household_self_service.router)
router.include_router(controller_invitations.router)
router.include_router(controller_shopping_lists.router)
router.include_router(controller_shopping_lists.item_router)
router.include_router(controller_webhooks.router)
# mealplan_rules must be added before mealplan due to the way the routes are defined
router.include_router(controller_mealplan_rules.router)
router.include_router(controller_mealplan.router)

View File

@@ -1,3 +1,4 @@
from collections import defaultdict
from functools import cached_property
from uuid import UUID
@@ -19,14 +20,14 @@ from mealie.services.event_bus_service.event_types import (
EventTypes,
)
router = APIRouter(prefix="/groups/cookbooks", tags=["Groups: Cookbooks"], route_class=MealieCrudRoute)
router = APIRouter(prefix="/households/cookbooks", tags=["Households: Cookbooks"], route_class=MealieCrudRoute)
@controller(router)
class GroupCookbookController(BaseCrudController):
@cached_property
def repo(self):
return self.repos.cookbooks.by_group(self.group_id)
return self.repos.cookbooks
def registered_exceptions(self, ex: type[Exception]) -> str:
registered = {
@@ -54,13 +55,15 @@ class GroupCookbookController(BaseCrudController):
@router.post("", response_model=ReadCookBook, status_code=201)
def create_one(self, data: CreateCookBook):
data = mapper.cast(data, SaveCookBook, group_id=self.group_id)
data = mapper.cast(data, SaveCookBook, group_id=self.group_id, household_id=self.household_id)
cookbook = self.mixins.create_one(data)
if cookbook:
self.publish_event(
event_type=EventTypes.cookbook_created,
document_data=EventCookbookData(operation=EventOperation.create, cookbook_id=cookbook.id),
group_id=cookbook.group_id,
household_id=cookbook.household_id,
message=self.t("notifications.generic-created", name=cookbook.name),
)
@@ -68,19 +71,25 @@ class GroupCookbookController(BaseCrudController):
@router.put("", response_model=list[ReadCookBook])
def update_many(self, data: list[UpdateCookBook]):
updated = []
updated_by_group_and_household: defaultdict[UUID4, defaultdict[UUID4, list[ReadCookBook]]] = defaultdict(
lambda: defaultdict(list)
)
for cookbook in data:
cb = self.mixins.update_one(cookbook, cookbook.id)
updated.append(cb)
updated_by_group_and_household[cb.group_id][cb.household_id].append(cb)
if updated:
self.publish_event(
event_type=EventTypes.cookbook_updated,
document_data=EventCookbookBulkData(
operation=EventOperation.update, cookbook_ids=[cb.id for cb in updated]
),
)
if updated_by_group_and_household:
for group_id, household_dict in updated_by_group_and_household.items():
for household_id, updated in household_dict.items():
self.publish_event(
event_type=EventTypes.cookbook_updated,
document_data=EventCookbookBulkData(
operation=EventOperation.update, cookbook_ids=[cb.id for cb in updated]
),
group_id=group_id,
household_id=household_id,
)
return updated
@@ -102,7 +111,7 @@ class GroupCookbookController(BaseCrudController):
return cookbook.cast(
RecipeCookBook,
recipes=self.repos.recipes.by_group(self.group_id).by_category_and_tags(
recipes=self.repos.recipes.by_category_and_tags(
cookbook.categories,
cookbook.tags,
cookbook.tools,
@@ -119,6 +128,8 @@ class GroupCookbookController(BaseCrudController):
self.publish_event(
event_type=EventTypes.cookbook_updated,
document_data=EventCookbookData(operation=EventOperation.update, cookbook_id=cookbook.id),
group_id=cookbook.group_id,
household_id=cookbook.household_id,
message=self.t("notifications.generic-updated", name=cookbook.name),
)
@@ -131,6 +142,8 @@ class GroupCookbookController(BaseCrudController):
self.publish_event(
event_type=EventTypes.cookbook_deleted,
document_data=EventCookbookData(operation=EventOperation.delete, cookbook_id=cookbook.id),
group_id=cookbook.group_id,
household_id=cookbook.household_id,
message=self.t("notifications.generic-deleted", name=cookbook.name),
)

View File

@@ -7,7 +7,7 @@ from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute
from mealie.schema.group.group_events import (
from mealie.schema.household.group_events import (
GroupEventNotifierCreate,
GroupEventNotifierOut,
GroupEventNotifierPrivate,
@@ -29,7 +29,7 @@ from mealie.services.event_bus_service.event_types import (
)
router = APIRouter(
prefix="/groups/events/notifications", tags=["Group: Event Notifications"], route_class=MealieCrudRoute
prefix="/households/events/notifications", tags=["Households: Event Notifications"], route_class=MealieCrudRoute
)
@@ -42,7 +42,7 @@ class GroupEventsNotifierController(BaseUserController):
if not self.user:
raise Exception("No user is logged in.")
return self.repos.group_event_notifier.by_group(self.user.group_id)
return self.repos.group_event_notifier
# =======================================================================
# CRUD Operations
@@ -63,7 +63,7 @@ class GroupEventsNotifierController(BaseUserController):
@router.post("", response_model=GroupEventNotifierOut, status_code=201)
def create_one(self, data: GroupEventNotifierCreate):
save_data = cast(data, GroupEventNotifierSave, group_id=self.user.group_id)
save_data = cast(data, GroupEventNotifierSave, group_id=self.group_id, household_id=self.household_id)
return self.mixins.create_one(save_data)
@router.get("/{item_id}", response_model=GroupEventNotifierOut)
@@ -100,5 +100,5 @@ class GroupEventsNotifierController(BaseUserController):
document_data=EventDocumentDataBase(document_type=EventDocumentType.generic, operation=EventOperation.info),
)
test_listener = AppriseEventListener(self.group_id)
test_listener = AppriseEventListener(self.group_id, self.household_id)
test_listener.publish_to_subscribers(test_event, [item.apprise_url])

View File

@@ -6,7 +6,7 @@ from pydantic import UUID4
from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import HttpRepo
from mealie.schema.group.group_recipe_action import (
from mealie.schema.household.group_recipe_action import (
CreateGroupRecipeAction,
GroupRecipeActionOut,
GroupRecipeActionPagination,
@@ -14,14 +14,14 @@ from mealie.schema.group.group_recipe_action import (
)
from mealie.schema.response.pagination import PaginationQuery
router = APIRouter(prefix="/groups/recipe-actions", tags=["Groups: Recipe Actions"])
router = APIRouter(prefix="/households/recipe-actions", tags=["Households: Recipe Actions"])
@controller(router)
class GroupRecipeActionController(BaseUserController):
@cached_property
def repo(self):
return self.repos.group_recipe_actions.by_group(self.group_id)
return self.repos.group_recipe_actions
@property
def mixins(self):
@@ -39,7 +39,7 @@ class GroupRecipeActionController(BaseUserController):
@router.post("", response_model=GroupRecipeActionOut, status_code=201)
def create_one(self, data: CreateGroupRecipeAction):
save = data.cast(SaveGroupRecipeAction, group_id=self.group.id)
save = data.cast(SaveGroupRecipeAction, group_id=self.group_id, household_id=self.household_id)
return self.mixins.create_one(save)
@router.get("/{item_id}", response_model=GroupRecipeActionOut)

View File

@@ -0,0 +1,69 @@
from functools import cached_property
from fastapi import HTTPException, status
from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.household.household import HouseholdInDB
from mealie.schema.household.household_permissions import SetPermissions
from mealie.schema.household.household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences
from mealie.schema.household.household_statistics import HouseholdStatistics
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.user.user import UserOut
from mealie.services.household_services.household_service import HouseholdService
router = UserAPIRouter(prefix="/households", tags=["Households: Self Service"])
@controller(router)
class HouseholdSelfServiceController(BaseUserController):
@cached_property
def service(self) -> HouseholdService:
return HouseholdService(self.group_id, self.household_id, self.repos)
@router.get("/self", response_model=HouseholdInDB)
def get_logged_in_user_household(self):
"""Returns the Household Data for the Current User"""
return self.household
@router.get("/members", response_model=list[UserOut])
def get_household_members(self):
"""Returns all users belonging to the current household"""
private_users = self.repos.users.page_all(
PaginationQuery(page=1, per_page=-1, query_filter=f"household_id={self.household_id}")
).items
return [user.cast(UserOut) for user in private_users]
@router.get("/preferences", response_model=ReadHouseholdPreferences)
def get_household_preferences(self):
return self.household.preferences
@router.put("/preferences", response_model=ReadHouseholdPreferences)
def update_household_preferences(self, new_pref: UpdateHouseholdPreferences):
return self.repos.household_preferences.update(self.household_id, new_pref)
@router.put("/permissions", response_model=UserOut)
def set_member_permissions(self, permissions: SetPermissions):
self.checks.can_manage()
target_user = self.repos.users.get_one(permissions.user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
if target_user.group_id != self.group_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this group")
if target_user.household_id != self.household_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not a member of this household")
target_user.can_invite = permissions.can_invite
target_user.can_manage = permissions.can_manage
target_user.can_organize = permissions.can_organize
return self.repos.users.update(permissions.user_id, target_user)
@router.get("/statistics", response_model=HouseholdStatistics)
def get_statistics(self):
return self.service.calculate_statistics()

View File

@@ -4,23 +4,24 @@ from fastapi import APIRouter, Header, HTTPException, status
from mealie.core.security import url_safe_token
from mealie.routes._base import BaseUserController, controller
from mealie.schema.group.invite_token import (
from mealie.schema.household.invite_token import (
CreateInviteToken,
EmailInitationResponse,
EmailInvitation,
ReadInviteToken,
SaveInviteToken,
)
from mealie.schema.response.pagination import PaginationQuery
from mealie.services.email.email_service import EmailService
router = APIRouter(prefix="/groups/invitations", tags=["Groups: Invitations"])
router = APIRouter(prefix="/households/invitations", tags=["Households: Invitations"])
@controller(router)
class GroupInvitationsController(BaseUserController):
@router.get("", response_model=list[ReadInviteToken])
def get_invite_tokens(self):
return self.repos.group_invite_tokens.multi_query({"group_id": self.group_id})
return self.repos.group_invite_tokens.page_all(PaginationQuery(page=1, per_page=-1)).items
@router.post("", response_model=ReadInviteToken, status_code=status.HTTP_201_CREATED)
def create_invite_token(self, uses: CreateInviteToken):
@@ -30,7 +31,9 @@ class GroupInvitationsController(BaseUserController):
detail="User is not allowed to create invite tokens",
)
token = SaveInviteToken(uses_left=uses.uses, group_id=self.group_id, token=url_safe_token())
token = SaveInviteToken(
uses_left=uses.uses, group_id=self.group_id, household_id=self.household_id, token=url_safe_token()
)
return self.repos.group_invite_tokens.create(token)
@router.post("/email", response_model=EmailInitationResponse)

View File

@@ -17,14 +17,14 @@ from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.responses import ErrorResponse
from mealie.services.event_bus_service.event_types import EventMealplanCreatedData, EventTypes
router = APIRouter(prefix="/groups/mealplans", tags=["Groups: Mealplans"])
router = APIRouter(prefix="/households/mealplans", tags=["Households: Mealplans"])
@controller(router)
class GroupMealplanController(BaseCrudController):
@cached_property
def repo(self) -> RepositoryMeals:
return self.repos.meals.by_group(self.group_id)
return self.repos.meals
def registered_exceptions(self, ex: type[Exception]) -> str:
registered = {
@@ -42,7 +42,7 @@ class GroupMealplanController(BaseCrudController):
@router.get("/today")
def get_todays_meals(self):
return self.repo.get_today(group_id=self.group_id)
return self.repo.get_today()
@router.post("/random", response_model=ReadPlanEntry)
def create_random_meal(self, data: CreateRandomEntry):
@@ -55,11 +55,9 @@ class GroupMealplanController(BaseCrudController):
to the random meal selector.
"""
# Get relevant group rules
rules = self.repos.group_meal_plan_rules.by_group(self.group_id).get_rules(
PlanRulesDay.from_date(data.date), data.entry_type.value
)
rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(data.date), data.entry_type.value)
recipe_repo = self.repos.recipes.by_group(self.group_id)
recipe_repo = self.repos.recipes
random_recipes: list[Recipe] = []
if not rules: # If no rules are set, return any random recipe from the group
@@ -74,9 +72,7 @@ class GroupMealplanController(BaseCrudController):
categories.extend(rule.categories)
if tags or categories:
random_recipes = self.repos.recipes.by_group(self.group_id).get_random_by_categories_and_tags(
categories, tags
)
random_recipes = self.repos.recipes.get_random_by_categories_and_tags(categories, tags)
else:
random_recipes = recipe_repo.get_random()
@@ -124,7 +120,7 @@ class GroupMealplanController(BaseCrudController):
@router.post("", response_model=ReadPlanEntry, status_code=201)
def create_one(self, data: CreatePlanEntry):
data = mapper.cast(data, SavePlanEntry, group_id=self.group.id, user_id=self.user.id)
data = mapper.cast(data, SavePlanEntry, group_id=self.group_id, user_id=self.user.id)
result = self.mixins.create_one(data)
self.publish_event(
@@ -136,6 +132,8 @@ class GroupMealplanController(BaseCrudController):
recipe_slug=result.recipe.slug if result.recipe else None,
date=data.date,
),
group_id=result.group_id,
household_id=result.household_id,
message=f"Mealplan entry created for {data.date} for {data.entry_type}",
)

View File

@@ -11,14 +11,14 @@ from mealie.schema import mapper
from mealie.schema.meal_plan.plan_rules import PlanRulesCreate, PlanRulesOut, PlanRulesPagination, PlanRulesSave
from mealie.schema.response.pagination import PaginationQuery
router = UserAPIRouter(prefix="/groups/mealplans/rules", tags=["Groups: Mealplan Rules"])
router = UserAPIRouter(prefix="/households/mealplans/rules", tags=["Households: Mealplan Rules"])
@controller(router)
class GroupMealplanConfigController(BaseUserController):
@cached_property
def repo(self):
return self.repos.group_meal_plan_rules.by_group(self.group_id)
return self.repos.group_meal_plan_rules
@cached_property
def mixins(self):
@@ -36,7 +36,7 @@ class GroupMealplanConfigController(BaseUserController):
@router.post("", response_model=PlanRulesOut, status_code=201)
def create_one(self, data: PlanRulesCreate):
save = mapper.cast(data, PlanRulesSave, group_id=self.group.id)
save = mapper.cast(data, PlanRulesSave, group_id=self.group.id, household_id=self.household.id)
return self.mixins.create_one(save)
@router.get("/{item_id}", response_model=PlanRulesOut)

View File

@@ -7,7 +7,7 @@ from pydantic import UUID4
from mealie.routes._base.base_controllers import BaseCrudController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import HttpRepo
from mealie.schema.group.group_shopping_list import (
from mealie.schema.household.group_shopping_list import (
ShoppingListAddRecipeParams,
ShoppingListCreate,
ShoppingListItemCreate,
@@ -32,9 +32,9 @@ from mealie.services.event_bus_service.event_types import (
EventShoppingListItemBulkData,
EventTypes,
)
from mealie.services.group_services.shopping_lists import ShoppingListService
from mealie.services.household_services.shopping_lists import ShoppingListService
item_router = APIRouter(prefix="/groups/shopping/items", tags=["Group: Shopping List Items"])
item_router = APIRouter(prefix="/households/shopping/items", tags=["Households: Shopping List Items"])
def publish_list_item_events(publisher: Callable, items_collection: ShoppingListItemsCollectionOut) -> None:
@@ -52,6 +52,9 @@ def publish_list_item_events(publisher: Callable, items_collection: ShoppingList
shopping_list_id=shopping_list_id,
shopping_list_item_ids=[item.id for item in items],
),
# since these are all the same shopping list, they share a group_id and household_id
group_id=items[0].group_id,
household_id=items[0].household_id,
)
if items_collection.updated_items:
@@ -67,6 +70,9 @@ def publish_list_item_events(publisher: Callable, items_collection: ShoppingList
shopping_list_id=shopping_list_id,
shopping_list_item_ids=[item.id for item in items],
),
# since these are all the same shopping list, they share a group_id and household_id
group_id=items[0].group_id,
household_id=items[0].household_id,
)
if items_collection.deleted_items:
@@ -82,6 +88,9 @@ def publish_list_item_events(publisher: Callable, items_collection: ShoppingList
shopping_list_id=shopping_list_id,
shopping_list_item_ids=[item.id for item in items],
),
# since these are all the same shopping list, they share a group_id and household_id
group_id=items[0].group_id,
household_id=items[0].household_id,
)
@@ -89,7 +98,7 @@ def publish_list_item_events(publisher: Callable, items_collection: ShoppingList
class ShoppingListItemController(BaseCrudController):
@cached_property
def service(self):
return ShoppingListService(self.repos, self.group, self.user)
return ShoppingListService(self.repos)
@cached_property
def repo(self):
@@ -143,18 +152,18 @@ class ShoppingListItemController(BaseCrudController):
return self.delete_many([item_id])
router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"])
router = APIRouter(prefix="/households/shopping/lists", tags=["Households: Shopping Lists"])
@controller(router)
class ShoppingListController(BaseCrudController):
@cached_property
def service(self):
return ShoppingListService(self.repos, self.group, self.user)
return ShoppingListService(self.repos)
@cached_property
def repo(self):
return self.repos.group_shopping_lists.by_group(self.user.group_id)
return self.repos.group_shopping_lists
# =======================================================================
# CRUD Operations
@@ -175,11 +184,13 @@ class ShoppingListController(BaseCrudController):
@router.post("", response_model=ShoppingListOut, status_code=201)
def create_one(self, data: ShoppingListCreate):
shopping_list = self.service.create_one_list(data)
shopping_list = self.service.create_one_list(data, self.user.id)
if shopping_list:
self.publish_event(
event_type=EventTypes.shopping_list_created,
document_data=EventShoppingListData(operation=EventOperation.create, shopping_list_id=shopping_list.id),
group_id=shopping_list.group_id,
household_id=shopping_list.household_id,
message=self.t("notifications.generic-created", name=shopping_list.name),
)
@@ -195,6 +206,8 @@ class ShoppingListController(BaseCrudController):
self.publish_event(
event_type=EventTypes.shopping_list_updated,
document_data=EventShoppingListData(operation=EventOperation.update, shopping_list_id=shopping_list.id),
group_id=shopping_list.group_id,
household_id=shopping_list.household_id,
message=self.t("notifications.generic-updated", name=shopping_list.name),
)
@@ -207,6 +220,8 @@ class ShoppingListController(BaseCrudController):
self.publish_event(
event_type=EventTypes.shopping_list_deleted,
document_data=EventShoppingListData(operation=EventOperation.delete, shopping_list_id=shopping_list.id),
group_id=shopping_list.group_id,
household_id=shopping_list.household_id,
message=self.t("notifications.generic-deleted", name=shopping_list.name),
)
@@ -252,6 +267,8 @@ class ShoppingListController(BaseCrudController):
self.publish_event(
event_type=EventTypes.shopping_list_updated,
document_data=EventShoppingListData(operation=EventOperation.update, shopping_list_id=updated_list.id),
group_id=updated_list.group_id,
household_id=updated_list.household_id,
message=self.t("notifications.generic-updated", name=updated_list.name),
)

View File

@@ -8,18 +8,18 @@ from mealie.routes._base.base_controllers import BaseUserController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import HttpRepo
from mealie.schema import mapper
from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination
from mealie.schema.household.webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination
from mealie.schema.response.pagination import PaginationQuery
from mealie.services.scheduler.tasks.post_webhooks import post_group_webhooks, post_single_webhook
router = APIRouter(prefix="/groups/webhooks", tags=["Groups: Webhooks"])
router = APIRouter(prefix="/households/webhooks", tags=["Households: Webhooks"])
@controller(router)
class ReadWebhookController(BaseUserController):
@cached_property
def repo(self):
return self.repos.webhooks.by_group(self.group_id)
return self.repos.webhooks
@property
def mixins(self) -> HttpRepo:
@@ -37,7 +37,7 @@ class ReadWebhookController(BaseUserController):
@router.post("", response_model=ReadWebhook, status_code=201)
def create_one(self, data: CreateWebhook):
save = mapper.cast(data, SaveWebhook, group_id=self.group.id)
save = mapper.cast(data, SaveWebhook, group_id=self.group_id, household_id=self.household_id)
return self.mixins.create_one(save)
@router.post("/rerun")
@@ -46,7 +46,7 @@ class ReadWebhookController(BaseUserController):
start_time = datetime.min.time()
start_dt = datetime.combine(datetime.now(timezone.utc).date(), start_time)
post_group_webhooks(start_dt=start_dt, group_id=self.group.id)
post_group_webhooks(start_dt=start_dt, group_id=self.group.id, household_id=self.household.id)
@router.get("/{item_id}", response_model=ReadWebhook)
def get_one(self, item_id: UUID4):

View File

@@ -12,7 +12,7 @@ These routes are for development only! These assets are served by Caddy when not
in development mode. If you make changes, be sure to test the production container.
"""
router = APIRouter(prefix="/recipes")
router = APIRouter(prefix="/recipes", include_in_schema=False)
class ImageType(str, Enum):

View File

@@ -9,7 +9,7 @@ These routes are for development only! These assets are served by Caddy when not
in development mode. If you make changes, be sure to test the production container.
"""
router = APIRouter(prefix="/users")
router = APIRouter(prefix="/users", include_in_schema=False)
@router.get("/{user_id}/{file_name}", response_class=FileResponse)

View File

@@ -8,7 +8,7 @@ from mealie.routes._base.mixins import HttpRepo
from mealie.schema import mapper
from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse
from mealie.schema.recipe.recipe import RecipeCategory, RecipeCategoryPagination
from mealie.schema.recipe.recipe_category import CategoryBase, CategorySave
from mealie.schema.recipe.recipe_category import CategoryBase, CategoryOut, CategorySave
from mealie.schema.response.pagination import PaginationQuery
from mealie.services import urls
from mealie.services.event_bus_service.event_types import EventCategoryData, EventOperation, EventTypes
@@ -29,11 +29,11 @@ class RecipeCategoryController(BaseCrudController):
# CRUD Operations
@cached_property
def repo(self):
return self.repos.categories.by_group(self.group_id)
return self.repos.categories
@cached_property
def mixins(self):
return HttpRepo(self.repo, self.logger)
return HttpRepo[CategorySave, CategoryOut, CategorySave](self.repo, self.logger)
@router.get("", response_model=RecipeCategoryPagination)
def get_all(self, q: PaginationQuery = Depends(PaginationQuery), search: str | None = None):
@@ -56,6 +56,8 @@ class RecipeCategoryController(BaseCrudController):
self.publish_event(
event_type=EventTypes.category_created,
document_data=EventCategoryData(operation=EventOperation.create, category_id=new_category.id),
group_id=new_category.group_id,
household_id=None,
message=self.t(
"notifications.generic-created-with-url",
name=new_category.name,
@@ -82,6 +84,8 @@ class RecipeCategoryController(BaseCrudController):
self.publish_event(
event_type=EventTypes.category_updated,
document_data=EventCategoryData(operation=EventOperation.update, category_id=category.id),
group_id=category.group_id,
household_id=None,
message=self.t(
"notifications.generic-updated-with-url",
name=category.name,
@@ -102,6 +106,8 @@ class RecipeCategoryController(BaseCrudController):
self.publish_event(
event_type=EventTypes.category_deleted,
document_data=EventCategoryData(operation=EventOperation.delete, category_id=category.id),
group_id=category.group_id,
household_id=None,
message=self.t("notifications.generic-deleted", name=category.name),
)
@@ -121,5 +127,5 @@ class RecipeCategoryController(BaseCrudController):
id=category.id,
slug=category.slug,
name=category.name,
recipes=self.repos.recipes.by_group(self.group_id).get_by_categories([category]),
recipes=self.repos.recipes.get_by_categories([category]),
)

View File

@@ -20,7 +20,7 @@ router = APIRouter(prefix="/tags", tags=["Organizer: Tags"])
class TagController(BaseCrudController):
@cached_property
def repo(self):
return self.repos.tags.by_group(self.group_id)
return self.repos.tags
@cached_property
def mixins(self):
@@ -58,6 +58,8 @@ class TagController(BaseCrudController):
self.publish_event(
event_type=EventTypes.tag_created,
document_data=EventTagData(operation=EventOperation.create, tag_id=new_tag.id),
group_id=new_tag.group_id,
household_id=None,
message=self.t(
"notifications.generic-created-with-url",
name=new_tag.name,
@@ -77,6 +79,8 @@ class TagController(BaseCrudController):
self.publish_event(
event_type=EventTypes.tag_updated,
document_data=EventTagData(operation=EventOperation.update, tag_id=tag.id),
group_id=tag.group_id,
household_id=None,
message=self.t(
"notifications.generic-updated-with-url",
name=tag.name,
@@ -103,6 +107,8 @@ class TagController(BaseCrudController):
self.publish_event(
event_type=EventTypes.tag_deleted,
document_data=EventTagData(operation=EventOperation.delete, tag_id=tag.id),
group_id=tag.group_id,
household_id=None,
message=self.t("notifications.generic-deleted", name=tag.name),
)

View File

@@ -18,7 +18,7 @@ router = APIRouter(prefix="/tools", tags=["Organizer: Tools"])
class RecipeToolController(BaseUserController):
@cached_property
def repo(self):
return self.repos.tools.by_group(self.group_id)
return self.repos.tools
@property
def mixins(self) -> HttpRepo:

View File

@@ -1,12 +1,11 @@
from fastapi import APIRouter
from . import all_recipe_routes, bulk_actions, comments, recipe_crud_routes, shared_routes, timeline_events
from . import bulk_actions, comments, recipe_crud_routes, shared_routes, timeline_events
prefix = "/recipes"
router = APIRouter()
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
router.include_router(recipe_crud_routes.router_exports)
router.include_router(recipe_crud_routes.router)
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])

View File

@@ -1,20 +0,0 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm.session import Session
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.schema.recipe import RecipeSummary
router = APIRouter()
@router.get("/summary/untagged", response_model=list[RecipeSummary])
async def get_untagged_recipes(count: bool = False, session: Session = Depends(generate_session)):
db = get_repositories(session)
return db.recipes.count_untagged(count=count, override_schema=RecipeSummary)
@router.get("/summary/uncategorized", response_model=list[RecipeSummary])
async def get_uncategorized_recipes(count: bool = False, session: Session = Depends(generate_session)):
db = get_repositories(session)
return db.recipes.count_uncategorized(count=count, override_schema=RecipeSummary)

View File

@@ -10,5 +10,5 @@ class RecipeCommentsController(BaseUserController):
@router.get("/{slug}/comments", response_model=list[RecipeCommentOut])
async def get_recipe_comments(self, slug: str):
"""Get all comments for a recipe"""
recipe = self.repos.recipes.by_group(self.group_id).get_one(slug)
recipe = self.repos.recipes.get_one(slug)
return self.repos.comments.multi_query({"recipe_id": recipe.id})

View File

@@ -30,7 +30,7 @@ from mealie.core.dependencies import (
validate_recipe_token,
)
from mealie.core.security import create_recipe_slug_token
from mealie.db.models.group.cookbook import CookBook
from mealie.db.models.household.cookbook import CookBook
from mealie.pkgs import cache
from mealie.repos.repository_generic import RepositoryGeneric
from mealie.repos.repository_recipes import RepositoryRecipes
@@ -95,15 +95,15 @@ class JSONBytes(JSONResponse):
class BaseRecipeController(BaseCrudController):
@cached_property
def repo(self) -> RepositoryRecipes:
return self.repos.recipes.by_group(self.group_id)
return self.repos.recipes
@cached_property
def cookbooks_repo(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return self.repos.cookbooks.by_group(self.group_id)
return self.repos.cookbooks
@cached_property
def service(self) -> RecipeService:
return RecipeService(self.repos, self.user, self.group, translator=self.translator)
return RecipeService(self.repos, self.user, self.household, translator=self.translator)
@cached_property
def mixins(self):
@@ -207,7 +207,7 @@ class RecipeController(BaseRecipeController):
) from e
if req.include_tags:
ctx = ScraperContext(self.user.id, self.group_id, self.repos)
ctx = ScraperContext(self.repos)
recipe.tags = extras.use_tags(ctx) # type: ignore
@@ -217,6 +217,8 @@ class RecipeController(BaseRecipeController):
self.publish_event(
event_type=EventTypes.recipe_created,
document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=new_recipe.slug),
group_id=new_recipe.group_id,
household_id=new_recipe.household_id,
message=self.t(
"notifications.generic-created-with-url",
name=new_recipe.name,
@@ -236,6 +238,8 @@ class RecipeController(BaseRecipeController):
self.publish_event(
event_type=EventTypes.recipe_created,
document_data=EventRecipeBulkReportData(operation=EventOperation.create, report_id=report_id),
group_id=self.group_id,
household_id=self.household_id,
)
return {"reportId": report_id}
@@ -265,6 +269,8 @@ class RecipeController(BaseRecipeController):
self.publish_event(
event_type=EventTypes.recipe_created,
document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=recipe.slug),
group_id=recipe.group_id,
household_id=recipe.household_id,
)
return recipe.slug
@@ -290,6 +296,8 @@ class RecipeController(BaseRecipeController):
self.publish_event(
event_type=EventTypes.recipe_created,
document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=recipe.slug),
group_id=recipe.group_id,
household_id=recipe.household_id,
)
return recipe.slug
@@ -324,7 +332,7 @@ class RecipeController(BaseRecipeController):
raise HTTPException(status_code=404, detail="cookbook not found")
# we use the repo by user so we can sort favorites correctly
pagination_response = self.repo.by_user(self.user.id).page_all(
pagination_response = self.repos.recipes.by_user(self.user.id).page_all(
pagination=q,
cookbook=cookbook_data,
categories=categories,
@@ -374,6 +382,8 @@ class RecipeController(BaseRecipeController):
self.publish_event(
event_type=EventTypes.recipe_created,
document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=new_recipe.slug),
group_id=new_recipe.group_id,
household_id=new_recipe.household_id,
message=self.t(
"notifications.generic-created-with-url",
name=new_recipe.name,
@@ -395,6 +405,8 @@ class RecipeController(BaseRecipeController):
self.publish_event(
event_type=EventTypes.recipe_created,
document_data=EventRecipeData(operation=EventOperation.create, recipe_slug=new_recipe.slug),
group_id=new_recipe.group_id,
household_id=new_recipe.household_id,
message=self.t(
"notifications.generic-duplicated",
name=new_recipe.name,
@@ -415,6 +427,8 @@ class RecipeController(BaseRecipeController):
self.publish_event(
event_type=EventTypes.recipe_updated,
document_data=EventRecipeData(operation=EventOperation.update, recipe_slug=recipe.slug),
group_id=recipe.group_id,
household_id=recipe.household_id,
message=self.t(
"notifications.generic-updated-with-url",
name=recipe.name,
@@ -436,6 +450,8 @@ class RecipeController(BaseRecipeController):
self.publish_event(
event_type=EventTypes.recipe_updated,
document_data=EventRecipeData(operation=EventOperation.update, recipe_slug=recipe.slug),
group_id=recipe.group_id,
household_id=recipe.household_id,
message=self.t(
"notifications.generic-updated-with-url",
name=recipe.name,
@@ -458,6 +474,8 @@ class RecipeController(BaseRecipeController):
self.publish_event(
event_type=EventTypes.recipe_updated,
document_data=EventRecipeData(operation=EventOperation.update, recipe_slug=recipe.slug),
group_id=recipe.group_id,
household_id=recipe.household_id,
message=self.t(
"notifications.generic-updated-with-url",
name=recipe.name,
@@ -479,6 +497,8 @@ class RecipeController(BaseRecipeController):
self.publish_event(
event_type=EventTypes.recipe_deleted,
document_data=EventRecipeData(operation=EventOperation.delete, recipe_slug=recipe.slug),
group_id=recipe.group_id,
household_id=recipe.household_id,
message=self.t("notifications.generic-deleted", name=recipe.name),
)

View File

@@ -12,7 +12,7 @@ router = APIRouter()
@router.get("/shared/{token_id}", response_model=Recipe)
def get_shared_recipe(token_id: UUID4, session: Session = Depends(generate_session)):
db = get_repositories(session)
db = get_repositories(session, group_id=None, household_id=None)
token_summary = db.recipe_share_tokens.get_one(token_id)

View File

@@ -32,7 +32,7 @@ class RecipeTimelineEventsController(BaseCrudController):
@cached_property
def recipes_repo(self):
return self.repos.recipes.by_group(self.group_id)
return self.repos.recipes
@cached_property
def mixins(self):
@@ -69,6 +69,8 @@ class RecipeTimelineEventsController(BaseCrudController):
document_data=EventRecipeTimelineEventData(
operation=EventOperation.create, recipe_slug=recipe.slug, recipe_timeline_event_id=event.id
),
group_id=recipe.group_id,
household_id=recipe.household_id,
message=self.t(
"notifications.generic-updated-with-url",
name=recipe.name,
@@ -92,6 +94,8 @@ class RecipeTimelineEventsController(BaseCrudController):
document_data=EventRecipeTimelineEventData(
operation=EventOperation.update, recipe_slug=recipe.slug, recipe_timeline_event_id=event.id
),
group_id=recipe.group_id,
household_id=recipe.household_id,
message=self.t(
"notifications.generic-updated-with-url",
name=recipe.name,
@@ -117,6 +121,8 @@ class RecipeTimelineEventsController(BaseCrudController):
document_data=EventRecipeTimelineEventData(
operation=EventOperation.delete, recipe_slug=recipe.slug, recipe_timeline_event_id=event.id
),
group_id=recipe.group_id,
household_id=recipe.household_id,
message=self.t(
"notifications.generic-updated-with-url",
name=recipe.name,
@@ -145,6 +151,8 @@ class RecipeTimelineEventsController(BaseCrudController):
document_data=EventRecipeTimelineEventData(
operation=EventOperation.update, recipe_slug=recipe.slug, recipe_timeline_event_id=event.id
),
group_id=recipe.group_id,
household_id=recipe.household_id,
message=self.t(
"notifications.generic-updated-with-url",
name=recipe.name,

View File

@@ -15,7 +15,7 @@ router = UserAPIRouter(prefix="/shared/recipes", tags=["Shared: Recipes"])
class RecipeSharedController(BaseUserController):
@cached_property
def repo(self):
return self.repos.recipe_share_tokens.by_group(self.group_id)
return self.repos.recipe_share_tokens
@cached_property
def mixins(self):

View File

@@ -162,13 +162,14 @@ def serve_recipe_with_meta_public(
session: Session = Depends(generate_session),
):
try:
repos = AllRepositories(session)
group = repos.groups.get_by_slug_or_id(group_slug)
public_repos = AllRepositories(session)
group = public_repos.groups.get_by_slug_or_id(group_slug)
if not group or group.preferences.private_group: # type: ignore
return response_404()
recipe = repos.recipes.by_group(group.id).get_one(recipe_slug)
group_repos = AllRepositories(session, group_id=group.id)
recipe = group_repos.recipes.get_one(recipe_slug)
if not recipe or not recipe.settings.public: # type: ignore
return response_404()
@@ -189,9 +190,9 @@ async def serve_recipe_with_meta(
return serve_recipe_with_meta_public(group_slug, recipe_slug, session)
try:
repos = AllRepositories(session)
repos = AllRepositories(session, group_id=user.group_id)
recipe = repos.recipes.by_group(user.group_id).get_one(recipe_slug, "slug")
recipe = repos.recipes.get_one(recipe_slug, "slug")
if recipe is None:
return response_404()

View File

@@ -25,7 +25,7 @@ router = APIRouter(prefix="/foods", tags=["Recipes: Foods"], route_class=MealieC
class IngredientFoodsController(BaseUserController):
@cached_property
def repo(self):
return self.repos.ingredient_foods.by_group(self.group_id)
return self.repos.ingredient_foods
@cached_property
def mixins(self):

View File

@@ -25,7 +25,7 @@ router = APIRouter(prefix="/units", tags=["Recipes: Units"], route_class=MealieC
class IngredientUnitsController(BaseUserController):
@cached_property
def repo(self):
return self.repos.ingredient_units.by_group(self.group_id)
return self.repos.ingredient_units
@cached_property
def mixins(self):

View File

@@ -11,7 +11,7 @@ from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.response import ErrorResponse, SuccessResponse
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut
from mealie.schema.user.user import UserPagination, UserRatings, UserRatingSummary, UserSummary, UserSummaryPagination
from mealie.schema.user.user import UserPagination, UserRatings, UserRatingSummary
user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
@@ -58,18 +58,6 @@ class AdminUserController(BaseAdminController):
@controller(user_router)
class UserController(BaseUserController):
@user_router.get("/group-users", response_model=UserSummaryPagination)
def get_all_group_users(self, q: PaginationQuery = Depends(PaginationQuery)):
"""Returns all users from the current group"""
response = self.repos.users.by_group(self.group_id).page_all(
pagination=q,
override=UserSummary,
)
response.set_pagination_guides(user_router.url_path_for("get_all_group_users"), q.model_dump())
return response
@user_router.get("/self", response_model=UserOut)
def get_logged_in_user(self):
return self.user

View File

@@ -22,7 +22,7 @@ class UserRatingsController(BaseUserController):
except ValueError:
pass
recipes_repo = self.repos.recipes.by_group(self.group_id)
recipes_repo = self.repos.recipes
if isinstance(slug_or_id, UUID):
recipe = recipes_repo.get_one(slug_or_id, key="id")
else:

View File

@@ -29,7 +29,7 @@ class RegistrationController(BasePublicController):
registration_service = RegistrationService(
self.logger,
get_repositories(self.session),
get_repositories(self.session, group_id=None, household_id=None),
self.translator,
)
@@ -38,6 +38,7 @@ class RegistrationController(BasePublicController):
self.event_bus.dispatch(
integration_id="registration",
group_id=result.group_id,
household_id=result.household_id,
event_type=EventTypes.user_signup,
document_data=EventUserSignupData(username=result.username, email=result.email),
)

View File

@@ -14,7 +14,7 @@ router = APIRouter()
@router.get("/user/name", response_model=ValidationResponse)
def validate_user(name: str, session: Session = Depends(generate_session)):
"""Checks if a user with the given name exists"""
db = get_repositories(session)
db = get_repositories(session, group_id=None, household_id=None)
existing_element = db.users.get_one(name, "username", any_case=True)
return ValidationResponse(valid=existing_element is None)
@@ -22,7 +22,7 @@ def validate_user(name: str, session: Session = Depends(generate_session)):
@router.get("/user/email", response_model=ValidationResponse)
def validate_user_email(email: str, session: Session = Depends(generate_session)):
"""Checks if a user with the given name exists"""
db = get_repositories(session)
db = get_repositories(session, group_id=None, household_id=None)
existing_element = db.users.get_one(email, "email", any_case=True)
return ValidationResponse(valid=existing_element is None)
@@ -30,15 +30,23 @@ def validate_user_email(email: str, session: Session = Depends(generate_session)
@router.get("/group", response_model=ValidationResponse)
def validate_group(name: str, session: Session = Depends(generate_session)):
"""Checks if a group with the given name exists"""
db = get_repositories(session)
db = get_repositories(session, group_id=None, household_id=None)
existing_element = db.groups.get_by_name(name)
return ValidationResponse(valid=existing_element is None)
@router.get("/household", response_model=ValidationResponse)
def validate_household(name: str, session: Session = Depends(generate_session)):
"""Checks if a household with the given name exists"""
db = get_repositories(session, group_id=None, household_id=None)
existing_element = db.households.get_by_name(name)
return ValidationResponse(valid=existing_element is None)
@router.get("/recipe", response_model=ValidationResponse)
def validate_recipe(group_id: UUID, name: str, session: Session = Depends(generate_session)):
"""Checks if a group with the given slug exists"""
db = get_repositories(session)
"""Checks if a recipe with the given slug exists"""
db = get_repositories(session, group_id=None, household_id=None)
slug = slugify(name)
existing_element = db.recipes.get_by_slug(group_id, slug)
return ValidationResponse(valid=existing_element is None)

View File

@@ -3,11 +3,11 @@ from .datetime_parse import DateError, DateTimeError, DurationError, TimeError
from .mealie_model import HasUUID, MealieModel, SearchType
__all__ = [
"HasUUID",
"MealieModel",
"SearchType",
"DateError",
"DateTimeError",
"DurationError",
"TimeError",
"HasUUID",
"MealieModel",
"SearchType",
]

View File

@@ -7,7 +7,7 @@ from enum import Enum
from typing import ClassVar, Protocol, TypeVar
from humps.main import camelize
from pydantic import UUID4, BaseModel, ConfigDict, model_validator
from pydantic import UUID4, AliasChoices, BaseModel, ConfigDict, Field, model_validator
from sqlalchemy import Select, desc, func, or_, text
from sqlalchemy.orm import InstrumentedAttribute, Session
from sqlalchemy.orm.interfaces import LoaderOption
@@ -20,6 +20,26 @@ T = TypeVar("T", bound=BaseModel)
HOUR_ONLY_TZ_PATTERN = re.compile(r"[+-]\d{2}$")
def UpdatedAtField(*args, **kwargs):
"""
Wrapper for Pydantic's Field, which sets default values for our update_at aliases
Since the database stores this value as update_at, we want to accept this as a possible value
"""
kwargs.pop("alias", None)
kwargs.pop("alias_generator", None)
# ensure the alias is not overwritten by the generator, if there is one
# https://docs.pydantic.dev/latest/concepts/alias/#alias-priority
kwargs["alias_priority"] = 2
kwargs["validation_alias"] = AliasChoices("update_at", "updateAt", "updated_at", "updatedAt")
kwargs["serialization_alias"] = "updatedAt"
return Field(*args, **kwargs)
class SearchType(Enum):
fuzzy = "fuzzy"
tokenized = "tokenized"
@@ -77,7 +97,7 @@ class MealieModel(BaseModel):
Cast the current model to another with additional arguments. Useful for
transforming DTOs into models that are saved to a database
"""
create_data = {field: getattr(self, field) for field in self.__fields__ if field in cls.__fields__}
create_data = {field: getattr(self, field) for field in self.model_fields if field in cls.model_fields}
create_data.update(kwargs or {})
return cls(**create_data)

View File

@@ -20,6 +20,12 @@ __all__ = [
"MaintenanceLogs",
"MaintenanceStorageDetails",
"MaintenanceSummary",
"ChowdownURL",
"MigrationFile",
"MigrationImport",
"Migrations",
"CustomPageBase",
"CustomPageOut",
"CommentImport",
"CustomPageImport",
"GroupImport",
@@ -28,11 +34,11 @@ __all__ = [
"RecipeImport",
"SettingsImport",
"UserImport",
"EmailReady",
"EmailSuccess",
"EmailTest",
"CustomPageBase",
"CustomPageOut",
"AllBackups",
"BackupFile",
"BackupOptions",
"CreateBackup",
"ImportJob",
"AdminAboutInfo",
"AppInfo",
"AppStartupInfo",
@@ -40,13 +46,7 @@ __all__ = [
"AppTheme",
"CheckAppConfig",
"OIDCInfo",
"ChowdownURL",
"MigrationFile",
"MigrationImport",
"Migrations",
"AllBackups",
"BackupFile",
"BackupOptions",
"CreateBackup",
"ImportJob",
"EmailReady",
"EmailSuccess",
"EmailTest",
]

View File

@@ -4,6 +4,7 @@ from mealie.schema._mealie import MealieModel
class AppStatistics(MealieModel):
total_recipes: int
total_users: int
total_households: int
total_groups: int
uncategorized_recipes: int
untagged_recipes: int
@@ -15,6 +16,7 @@ class AppInfo(MealieModel):
demo_status: bool
allow_signup: bool
default_group_slug: str | None = None
default_household_slug: str | None = None
enable_oidc: bool
oidc_redirect: bool
oidc_provider_name: str
@@ -58,6 +60,7 @@ class AdminAboutInfo(AppInfo):
db_type: str
db_url: str | None = None
default_group: str
default_household: str
build_id: str
recipe_scraper_version: str

Some files were not shown because too many files have changed in this diff Show More