prs-fleshgolem-2070: feat: sqlalchemy 2.0 (#2096)

* upgrade sqlalchemy to 2.0

* rewrite all db models to sqla 2.0 mapping api

* fix some importing and typing weirdness

* fix types of a lot of nullable columns

* remove get_ref methods

* fix issues found by tests

* rewrite all queries in repository_recipe to 2.0 style

* rewrite all repository queries to 2.0 api

* rewrite all remaining queries to 2.0 api

* remove now-unneeded __allow_unmapped__ flag

* remove and fix some unneeded cases of "# type: ignore"

* fix formatting

* bump black version

* run black

* can this please be the last one. okay. just. okay.

* fix repository errors

* remove return

* drop open API validator

---------

Co-authored-by: Sören Busch <fleshgolem@gmx.net>
This commit is contained in:
Hayden 2023-02-06 18:43:12 -09:00 committed by GitHub
parent 91cd00976a
commit 9e77a9f367
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
86 changed files with 1776 additions and 1572 deletions

2
.gitignore vendored
View File

@ -159,3 +159,5 @@ dev/code-generation/generated/test_routes.py
mealie/services/parser_services/crfpp/model.crfmodel mealie/services/parser_services/crfpp/model.crfmodel
lcov.info lcov.info
dev/code-generation/openapi.json dev/code-generation/openapi.json
.run/

View File

@ -11,10 +11,6 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
exclude: ^tests/data/ exclude: ^tests/data/
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 22.3.0 rev: 23.1.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.241
hooks:
- id: ruff

View File

@ -25,7 +25,6 @@ def get_path_objects(app: FastAPI):
for key, value in app.openapi().items(): for key, value in app.openapi().items():
if key == "paths": if key == "paths":
for key, value in value.items(): for key, value in value.items():
paths.append( paths.append(
PathObject( PathObject(
route_object=RouteObject(key), route_object=RouteObject(key),
@ -50,7 +49,6 @@ def read_template(file: Path):
def generate_python_templates(static_paths: list[PathObject], function_paths: list[PathObject]): def generate_python_templates(static_paths: list[PathObject], function_paths: list[PathObject]):
template = Template(read_template(CodeTemplates.pytest_routes)) template = Template(read_template(CodeTemplates.pytest_routes))
content = template.render( content = template.render(
paths={ paths={

View File

@ -79,14 +79,12 @@ def find_modules(root: pathlib.Path) -> list[Modules]:
modules: list[Modules] = [] modules: list[Modules] = []
for file in root.iterdir(): for file in root.iterdir():
if file.is_dir() and file.name not in SKIP: if file.is_dir() and file.name not in SKIP:
modules.append(Modules(directory=file)) modules.append(Modules(directory=file))
return modules return modules
def main(): def main():
modules = find_modules(SCHEMA_PATH) modules = find_modules(SCHEMA_PATH)
for module in modules: for module in modules:

View File

@ -232,7 +232,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
def login(username="changeme@email.com", password="MyPassword"): def login(username="changeme@email.com", password="MyPassword"):
payload = {"username": username, "password": password} payload = {"username": username, "password": password}
r = requests.post("http://localhost:9000/api/auth/token", payload) r = requests.post("http://localhost:9000/api/auth/token", payload)

View File

@ -9,7 +9,6 @@ class GunicornConfig:
"""Configuration to generate the properties for Gunicorn""" """Configuration to generate the properties for Gunicorn"""
def __init__(self): def __init__(self):
# Env Variables # Env Variables
self.host = os.getenv("HOST", "127.0.0.1") self.host = os.getenv("HOST", "127.0.0.1")
self.port = os.getenv("API_PORT", "9000") self.port = os.getenv("API_PORT", "9000")

View File

@ -15,14 +15,9 @@ def sql_global_init(db_url: str):
if "sqlite" in db_url: if "sqlite" in db_url:
connect_args["check_same_thread"] = False connect_args["check_same_thread"] = False
engine = sa.create_engine( engine = sa.create_engine(db_url, echo=False, connect_args=connect_args, pool_pre_ping=True, future=True)
db_url,
echo=False,
connect_args=connect_args,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True)
return SessionLocal, engine return SessionLocal, engine

View File

@ -2,7 +2,7 @@ from collections.abc import Callable
from pathlib import Path from pathlib import Path
from time import sleep from time import sleep
from sqlalchemy import engine, orm from sqlalchemy import engine, orm, text
from alembic import command, config, script from alembic import command, config, script
from alembic.config import Config from alembic.config import Config
@ -59,7 +59,7 @@ def safe_try(func: Callable):
def connect(session: orm.Session) -> bool: def connect(session: orm.Session) -> bool:
try: try:
session.execute("SELECT 1") session.execute(text("SELECT 1"))
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error connecting to database: {e}") logger.error(f"Error connecting to database: {e}")

View File

@ -1,5 +1,5 @@
from .group import * from .group import *
from .labels import * from .labels import *
from .recipe.recipe import * # type: ignore from .recipe import *
from .server import * from .server import *
from .users import * from .users import *

View File

@ -1,37 +1,19 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Column, DateTime, Integer from sqlalchemy import DateTime, Integer
from sqlalchemy.ext.declarative import as_declarative from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm.session import Session
@as_declarative() class SqlAlchemyBase(DeclarativeBase):
class Base: id: Mapped[int] = mapped_column(Integer, primary_key=True)
id = Column(Integer, primary_key=True) created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.now)
created_at = Column(DateTime, default=datetime.now) update_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now)
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class BaseMixins: class BaseMixins:
""" """
`self.update` method which directly passing arguments to the `__init__` `self.update` method which directly passing arguments to the `__init__`
`cls.get_ref` method which will return the object from the database or none. Useful for many-to-many relationships.
""" """
def update(self, *args, **kwarg): def update(self, *args, **kwarg):
self.__init__(*args, **kwarg) self.__init__(*args, **kwarg)
@classmethod
def get_ref(cls, match_value: str, match_attr: str | None = None, session: Session | None = None):
match_attr = match_attr or cls.Config.get_attr # type: ignore
if match_value is None or session is None:
return None
eff_ref = getattr(cls, match_attr)
return session.query(cls).filter(eff_ref == match_value).one_or_none()
SqlAlchemyBase = declarative_base(cls=Base, constructor=None)

View File

@ -2,13 +2,13 @@ from functools import wraps
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field, NoneStr from pydantic import BaseModel, Field, NoneStr
from sqlalchemy import select
from sqlalchemy.orm import MANYTOMANY, MANYTOONE, ONETOMANY, Session from sqlalchemy.orm import MANYTOMANY, MANYTOONE, ONETOMANY, Session
from sqlalchemy.orm.decl_api import DeclarativeMeta
from sqlalchemy.orm.mapper import Mapper from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.orm.relationships import RelationshipProperty from sqlalchemy.orm.relationships import RelationshipProperty
from sqlalchemy.sql.base import ColumnCollection from sqlalchemy.sql.base import ColumnCollection
from sqlalchemy.util._collections import ImmutableProperties
from .._model_base import SqlAlchemyBase
from .helpers import safe_call from .helpers import safe_call
@ -26,7 +26,7 @@ class AutoInitConfig(BaseModel):
# auto_create: bool = False # auto_create: bool = False
def _get_config(relation_cls: DeclarativeMeta) -> AutoInitConfig: def _get_config(relation_cls: type[SqlAlchemyBase]) -> AutoInitConfig:
""" """
Returns the config for the given class. Returns the config for the given class.
""" """
@ -45,7 +45,7 @@ def _get_config(relation_cls: DeclarativeMeta) -> AutoInitConfig:
return cfg return cfg
def get_lookup_attr(relation_cls: DeclarativeMeta) -> str: def get_lookup_attr(relation_cls: type[SqlAlchemyBase]) -> str:
"""Returns the primary key attribute of the related class as a string. """Returns the primary key attribute of the related class as a string.
Args: Args:
@ -73,7 +73,9 @@ def handle_many_to_many(session, get_attr, relation_cls, all_elements: list[dict
return handle_one_to_many_list(session, get_attr, relation_cls, all_elements) return handle_one_to_many_list(session, get_attr, relation_cls, all_elements)
def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elements: list[dict] | list[str]): def handle_one_to_many_list(
session: Session, get_attr, relation_cls: type[SqlAlchemyBase], all_elements: list[dict] | list[str]
):
elems_to_create: list[dict] = [] elems_to_create: list[dict] = []
updated_elems: list[dict] = [] updated_elems: list[dict] = []
@ -81,16 +83,15 @@ def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elemen
for elem in all_elements: for elem in all_elements:
elem_id = elem.get(get_attr, None) if isinstance(elem, dict) else elem elem_id = elem.get(get_attr, None) if isinstance(elem, dict) else elem
existing_elem = session.query(relation_cls).filter_by(**{get_attr: elem_id}).one_or_none() stmt = select(relation_cls).filter_by(**{get_attr: elem_id})
existing_elem = session.execute(stmt).scalars().one_or_none()
is_dict = isinstance(elem, dict) if existing_elem is None and isinstance(elem, dict):
elems_to_create.append(elem)
if existing_elem is None and is_dict:
elems_to_create.append(elem) # type: ignore
continue continue
elif is_dict: elif isinstance(elem, dict):
for key, value in elem.items(): # type: ignore for key, value in elem.items():
if key not in cfg.exclude: if key not in cfg.exclude:
setattr(existing_elem, key, value) setattr(existing_elem, key, value)
@ -110,7 +111,7 @@ def auto_init(): # sourcery no-metrics
def decorator(init): def decorator(init):
@wraps(init) @wraps(init)
def wrapper(self: DeclarativeMeta, *args, **kwargs): # sourcery no-metrics def wrapper(self: SqlAlchemyBase, *args, **kwargs): # sourcery no-metrics
""" """
Custom initializer that allows nested children initialization. Custom initializer that allows nested children initialization.
Only keys that are present as instance's class attributes are allowed. Only keys that are present as instance's class attributes are allowed.
@ -120,14 +121,14 @@ def auto_init(): # sourcery no-metrics
Ref: https://github.com/tiangolo/fastapi/issues/2194 Ref: https://github.com/tiangolo/fastapi/issues/2194
""" """
cls = self.__class__ cls = self.__class__
config = _get_config(cls)
exclude = _get_config(cls).exclude exclude = config.exclude
alchemy_mapper: Mapper = self.__mapper__ alchemy_mapper: Mapper = self.__mapper__
model_columns: ColumnCollection = alchemy_mapper.columns model_columns: ColumnCollection = alchemy_mapper.columns
relationships: ImmutableProperties = alchemy_mapper.relationships relationships = alchemy_mapper.relationships
session = kwargs.get("session", None) session: Session = kwargs.get("session", None)
if session is None: if session is None:
raise ValueError("Session is required to initialize the model with `auto_init`") raise ValueError("Session is required to initialize the model with `auto_init`")
@ -151,7 +152,7 @@ def auto_init(): # sourcery no-metrics
relation_dir = prop.direction relation_dir = prop.direction
# Identifies the parent class of the related object. # Identifies the parent class of the related object.
relation_cls: DeclarativeMeta = prop.mapper.entity relation_cls: type[SqlAlchemyBase] = prop.mapper.entity
# Identifies if the relationship was declared with use_list=True # Identifies if the relationship was declared with use_list=True
use_list: bool = prop.uselist use_list: bool = prop.uselist
@ -174,7 +175,8 @@ def auto_init(): # sourcery no-metrics
raise ValueError(f"Expected 'id' to be provided for {key}") raise ValueError(f"Expected 'id' to be provided for {key}")
if isinstance(val, (str, int, UUID)): if isinstance(val, (str, int, UUID)):
instance = session.query(relation_cls).filter_by(**{get_attr: val}).one_or_none() stmt = select(relation_cls).filter_by(**{get_attr: val})
instance = session.execute(stmt).scalars().one_or_none()
setattr(self, key, instance) setattr(self, key, instance)
else: else:
# If the value is not of the type defined above we assume that it isn't a valid id # If the value is not of the type defined above we assume that it isn't a valid id

View File

@ -1,4 +1,7 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init, guid from .._model_utils import auto_init, guid
@ -6,28 +9,33 @@ from ..recipe.category import Category, cookbooks_to_categories
from ..recipe.tag import Tag, cookbooks_to_tags from ..recipe.tag import Tag, cookbooks_to_tags
from ..recipe.tool import Tool, cookbooks_to_tools from ..recipe.tool import Tool, cookbooks_to_tools
if TYPE_CHECKING:
from group import Group
class CookBook(SqlAlchemyBase, BaseMixins): class CookBook(SqlAlchemyBase, BaseMixins):
__tablename__ = "cookbooks" __tablename__ = "cookbooks"
id = Column(guid.GUID, primary_key=True, default=guid.GUID.generate) id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
position = Column(Integer, nullable=False, default=1) position: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
group_id = Column(guid.GUID, ForeignKey("groups.id")) group_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="cookbooks") group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="cookbooks")
name = Column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
slug = Column(String, nullable=False) slug: Mapped[str] = mapped_column(String, nullable=False)
description = Column(String, default="") description: Mapped[str | None] = mapped_column(String, default="")
public = Column(Boolean, default=False) public: Mapped[str | None] = mapped_column(Boolean, default=False)
categories = orm.relationship(Category, secondary=cookbooks_to_categories, single_parent=True) categories: Mapped[list[Category]] = orm.relationship(
require_all_categories = Column(Boolean, default=True) Category, secondary=cookbooks_to_categories, single_parent=True
)
require_all_categories: Mapped[bool | None] = mapped_column(Boolean, default=True)
tags = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True) tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True)
require_all_tags = Column(Boolean, default=True) require_all_tags: Mapped[bool | None] = mapped_column(Boolean, default=True)
tools = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True) tools: Mapped[list[Tool]] = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True)
require_all_tools = Column(Boolean, default=True) require_all_tools: Mapped[bool | None] = mapped_column(Boolean, default=True)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,42 +1,48 @@
from sqlalchemy import Boolean, Column, ForeignKey, String, orm from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, ForeignKey, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID, auto_init from .._model_utils import GUID, auto_init
if TYPE_CHECKING:
from group import Group
class GroupEventNotifierOptionsModel(SqlAlchemyBase, BaseMixins): class GroupEventNotifierOptionsModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_events_notifier_options" __tablename__ = "group_events_notifier_options"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
event_notifier_id = Column(GUID, ForeignKey("group_events_notifiers.id"), nullable=False) event_notifier_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("group_events_notifiers.id"), nullable=False)
recipe_created = Column(Boolean, default=False, nullable=False) recipe_created: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
recipe_updated = Column(Boolean, default=False, nullable=False) recipe_updated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
recipe_deleted = Column(Boolean, default=False, nullable=False) recipe_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user_signup = Column(Boolean, default=False, nullable=False) user_signup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
data_migrations = Column(Boolean, default=False, nullable=False) data_migrations: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
data_export = Column(Boolean, default=False, nullable=False) data_export: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
data_import = Column(Boolean, default=False, nullable=False) data_import: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
mealplan_entry_created = Column(Boolean, default=False, nullable=False) mealplan_entry_created: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
shopping_list_created = Column(Boolean, default=False, nullable=False) shopping_list_created: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
shopping_list_updated = Column(Boolean, default=False, nullable=False) shopping_list_updated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
shopping_list_deleted = Column(Boolean, default=False, nullable=False) shopping_list_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
cookbook_created = Column(Boolean, default=False, nullable=False) cookbook_created: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
cookbook_updated = Column(Boolean, default=False, nullable=False) cookbook_updated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
cookbook_deleted = Column(Boolean, default=False, nullable=False) cookbook_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
tag_created = Column(Boolean, default=False, nullable=False) tag_created: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
tag_updated = Column(Boolean, default=False, nullable=False) tag_updated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
tag_deleted = Column(Boolean, default=False, nullable=False) tag_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
category_created = Column(Boolean, default=False, nullable=False) category_created: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
category_updated = Column(Boolean, default=False, nullable=False) category_updated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
category_deleted = Column(Boolean, default=False, nullable=False) category_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
@ -46,15 +52,19 @@ class GroupEventNotifierOptionsModel(SqlAlchemyBase, BaseMixins):
class GroupEventNotifierModel(SqlAlchemyBase, BaseMixins): class GroupEventNotifierModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_events_notifiers" __tablename__ = "group_events_notifiers"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name = Column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
enabled = Column(Boolean, default=True, nullable=False) enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
apprise_url = Column(String, nullable=False) apprise_url: Mapped[str] = mapped_column(String, nullable=False)
group = orm.relationship("Group", back_populates="group_event_notifiers", single_parent=True) group: Mapped[Optional["Group"]] = orm.relationship(
group_id = Column(GUID, ForeignKey("groups.id"), index=True) "Group", back_populates="group_event_notifiers", single_parent=True
)
group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
options = orm.relationship(GroupEventNotifierOptionsModel, uselist=False, cascade="all, delete-orphan") options: Mapped[GroupEventNotifierOptionsModel] = orm.relationship(
GroupEventNotifierOptionsModel, uselist=False, cascade="all, delete-orphan"
)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,21 +1,27 @@
from sqlalchemy import Column, ForeignKey, String, orm from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID, auto_init from .._model_utils import GUID, auto_init
if TYPE_CHECKING:
from group import Group
class GroupDataExportsModel(SqlAlchemyBase, BaseMixins): class GroupDataExportsModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_data_exports" __tablename__ = "group_data_exports"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group = orm.relationship("Group", back_populates="data_exports", single_parent=True) group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="data_exports", single_parent=True)
group_id = Column(GUID, ForeignKey("groups.id"), index=True) group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
name = Column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
filename = Column(String, nullable=False) filename: Mapped[str] = mapped_column(String, nullable=False)
path = Column(String, nullable=False) path: Mapped[str] = mapped_column(String, nullable=False)
size = Column(String, nullable=False) size: Mapped[str] = mapped_column(String, nullable=False)
expires = Column(String, nullable=False) expires: Mapped[str] = mapped_column(String, nullable=False)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,5 +1,9 @@
from typing import TYPE_CHECKING, Optional
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from sqlalchemy import select
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
@ -15,18 +19,28 @@ from .cookbook import CookBook
from .mealplan import GroupMealPlan from .mealplan import GroupMealPlan
from .preferences import GroupPreferencesModel from .preferences import GroupPreferencesModel
if TYPE_CHECKING:
from ..recipe import IngredientFoodModel, IngredientUnitModel, RecipeModel, Tag, Tool
from ..users import User
from .events import GroupEventNotifierModel
from .exports import GroupDataExportsModel
from .report import ReportModel
from .shopping_list import ShoppingList
class Group(SqlAlchemyBase, BaseMixins): class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups" __tablename__ = "groups"
id = sa.Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name = sa.Column(sa.String, index=True, nullable=False, unique=True) name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False, unique=True)
users = orm.relationship("User", back_populates="group") users: Mapped[list["User"]] = orm.relationship("User", back_populates="group")
categories = orm.relationship(Category, secondary=group_to_categories, single_parent=True, uselist=True) categories: Mapped[Category] = orm.relationship(
Category, secondary=group_to_categories, single_parent=True, uselist=True
)
invite_tokens = orm.relationship( invite_tokens: Mapped[list[GroupInviteToken]] = orm.relationship(
GroupInviteToken, back_populates="group", cascade="all, delete-orphan", uselist=True GroupInviteToken, back_populates="group", cascade="all, delete-orphan", uselist=True
) )
preferences = orm.relationship( preferences: Mapped[GroupPreferencesModel] = orm.relationship(
GroupPreferencesModel, GroupPreferencesModel,
back_populates="group", back_populates="group",
uselist=False, uselist=False,
@ -35,7 +49,7 @@ class Group(SqlAlchemyBase, BaseMixins):
) )
# Recipes # Recipes
recipes = orm.relationship("RecipeModel", back_populates="group", uselist=True) recipes: Mapped[list["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="group", uselist=True)
# CRUD From Others # CRUD From Others
common_args = { common_args = {
@ -44,23 +58,26 @@ class Group(SqlAlchemyBase, BaseMixins):
"single_parent": True, "single_parent": True,
} }
labels = orm.relationship(MultiPurposeLabel, **common_args) labels: Mapped[list[MultiPurposeLabel]] = orm.relationship(MultiPurposeLabel, **common_args)
mealplans = orm.relationship(GroupMealPlan, order_by="GroupMealPlan.date", **common_args) mealplans: Mapped[list[GroupMealPlan]] = orm.relationship(
webhooks = orm.relationship(GroupWebhooksModel, **common_args) GroupMealPlan, order_by="GroupMealPlan.date", **common_args
cookbooks = orm.relationship(CookBook, **common_args) )
server_tasks = orm.relationship(ServerTaskModel, **common_args) webhooks: Mapped[list[GroupWebhooksModel]] = orm.relationship(GroupWebhooksModel, **common_args)
data_exports = orm.relationship("GroupDataExportsModel", **common_args) cookbooks: Mapped[list[CookBook]] = orm.relationship(CookBook, **common_args)
shopping_lists = orm.relationship("ShoppingList", **common_args) server_tasks: Mapped[list[ServerTaskModel]] = orm.relationship(ServerTaskModel, **common_args)
group_reports = orm.relationship("ReportModel", **common_args) data_exports: Mapped[list["GroupDataExportsModel"]] = orm.relationship("GroupDataExportsModel", **common_args)
group_event_notifiers = orm.relationship("GroupEventNotifierModel", **common_args) shopping_lists: Mapped[list["ShoppingList"]] = orm.relationship("ShoppingList", **common_args)
group_reports: Mapped[list["ReportModel"]] = orm.relationship("ReportModel", **common_args)
group_event_notifiers: Mapped[list["GroupEventNotifierModel"]] = orm.relationship(
"GroupEventNotifierModel", **common_args
)
# Owned Models # Owned Models
ingredient_units = orm.relationship("IngredientUnitModel", **common_args) ingredient_units: Mapped[list["IngredientUnitModel"]] = orm.relationship("IngredientUnitModel", **common_args)
ingredient_foods = orm.relationship("IngredientFoodModel", **common_args) ingredient_foods: Mapped[list["IngredientFoodModel"]] = orm.relationship("IngredientFoodModel", **common_args)
tools = orm.relationship("Tool", **common_args) tools: Mapped[list["Tool"]] = orm.relationship("Tool", **common_args)
tags = orm.relationship("Tag", **common_args) tags: Mapped[list["Tag"]] = orm.relationship("Tag", **common_args)
categories = orm.relationship("Category", **common_args)
class Config: class Config:
exclude = { exclude = {
@ -79,10 +96,10 @@ class Group(SqlAlchemyBase, BaseMixins):
pass pass
@staticmethod # TODO: Remove this @staticmethod # TODO: Remove this
def get_ref(session: Session, name: str): # type: ignore def get_by_name(session: Session, name: str) -> Optional["Group"]:
settings = get_app_settings() settings = get_app_settings()
item = session.query(Group).filter(Group.name == name).one_or_none() item = session.execute(select(Group).filter(Group.name == name)).scalars().one_or_none()
if item is None: if item is None:
item = session.query(Group).filter(Group.name == settings.DEFAULT_GROUP).one() item = session.execute(select(Group).filter(Group.name == settings.DEFAULT_GROUP)).scalars().one_or_none()
return item return item

View File

@ -1,16 +1,22 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, Integer, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init, guid from .._model_utils import auto_init, guid
if TYPE_CHECKING:
from group import Group
class GroupInviteToken(SqlAlchemyBase, BaseMixins): class GroupInviteToken(SqlAlchemyBase, BaseMixins):
__tablename__ = "invite_tokens" __tablename__ = "invite_tokens"
token = Column(String, index=True, nullable=False, unique=True) token: Mapped[str] = mapped_column(String, index=True, nullable=False, unique=True)
uses_left = Column(Integer, nullable=False, default=1) uses_left: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
group_id = Column(guid.GUID, ForeignKey("groups.id")) group_id: Mapped[guid.GUID | None] = mapped_column(guid.GUID, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="invite_tokens") group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="invite_tokens")
@auto_init() @auto_init()
def __init__(self, **_): def __init__(self, **_):

View File

@ -1,4 +1,8 @@
from sqlalchemy import Column, Date, ForeignKey, String, orm from datetime import date
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Date, ForeignKey, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags
@ -6,18 +10,27 @@ from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID, auto_init from .._model_utils import GUID, auto_init
from ..recipe.category import Category, plan_rules_to_categories from ..recipe.category import Category, plan_rules_to_categories
if TYPE_CHECKING:
from group import Group
from ..recipe import RecipeModel
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase): class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
__tablename__ = "group_meal_plan_rules" __tablename__ = "group_meal_plan_rules"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False) group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False)
day = Column(String, nullable=False, default="unset") # "MONDAY", "TUESDAY", "WEDNESDAY", etc... day: Mapped[str] = mapped_column(
entry_type = Column(String, nullable=False, default="") # "breakfast", "lunch", "dinner", "side" String, nullable=False, default="unset"
) # "MONDAY", "TUESDAY", "WEDNESDAY", etc...
entry_type: Mapped[str] = mapped_column(
String, nullable=False, default=""
) # "breakfast", "lunch", "dinner", "side"
categories = orm.relationship(Category, secondary=plan_rules_to_categories, uselist=True) categories: Mapped[Category] = orm.relationship(Category, secondary=plan_rules_to_categories, uselist=True)
tags = orm.relationship(Tag, secondary=plan_rules_to_tags, uselist=True) tags: Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags, uselist=True)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
@ -27,16 +40,18 @@ class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
class GroupMealPlan(SqlAlchemyBase, BaseMixins): class GroupMealPlan(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_meal_plans" __tablename__ = "group_meal_plans"
date = Column(Date, index=True, nullable=False) date: Mapped[date] = mapped_column(Date, index=True, nullable=False)
entry_type = Column(String, index=True, nullable=False) entry_type: Mapped[str] = mapped_column(String, index=True, nullable=False)
title = Column(String, index=True, nullable=False) title: Mapped[str] = mapped_column(String, index=True, nullable=False)
text = Column(String, nullable=False) text: Mapped[str] = mapped_column(String, nullable=False)
group_id = Column(GUID, ForeignKey("groups.id"), index=True) group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
group = orm.relationship("Group", back_populates="mealplans") group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="mealplans")
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True) recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe = orm.relationship("RecipeModel", back_populates="meal_entries", uselist=False) recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(
"RecipeModel", back_populates="meal_entries", uselist=False
)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,29 +1,34 @@
from typing import TYPE_CHECKING, Optional
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import auto_init
from .._model_utils.guid import GUID
if TYPE_CHECKING:
from group import Group
class GroupPreferencesModel(SqlAlchemyBase, BaseMixins): class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_preferences" __tablename__ = "group_preferences"
id = sa.Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="preferences") group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="preferences")
private_group: bool = sa.Column(sa.Boolean, default=True) private_group: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
first_day_of_week = sa.Column(sa.Integer, default=0) first_day_of_week: Mapped[int | None] = mapped_column(sa.Integer, default=0)
# Recipe Defaults # Recipe Defaults
recipe_public: bool = sa.Column(sa.Boolean, default=True) recipe_public: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
recipe_show_nutrition: bool = sa.Column(sa.Boolean, default=False) recipe_show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_show_assets: bool = sa.Column(sa.Boolean, default=False) recipe_show_assets: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_landscape_view: bool = sa.Column(sa.Boolean, default=False) recipe_landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_comments: bool = sa.Column(sa.Boolean, default=False) recipe_disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
recipe_disable_amount: bool = sa.Column(sa.Boolean, default=True) recipe_disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,6 +1,8 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Column, ForeignKey, orm from sqlalchemy import ForeignKey, orm
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql.sqltypes import Boolean, DateTime, String from sqlalchemy.sql.sqltypes import Boolean, DateTime, String
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
@ -8,18 +10,21 @@ from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
if TYPE_CHECKING:
from group import Group
class ReportEntryModel(SqlAlchemyBase, BaseMixins): class ReportEntryModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "report_entries" __tablename__ = "report_entries"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
success = Column(Boolean, default=False) success: Mapped[bool | None] = mapped_column(Boolean, default=False)
message = Column(String, nullable=True) message: Mapped[str] = mapped_column(String, nullable=True)
exception = Column(String, nullable=True) exception: Mapped[str] = mapped_column(String, nullable=True)
timestamp = Column(DateTime, nullable=False, default=datetime.utcnow) timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
report_id = Column(GUID, ForeignKey("group_reports.id"), nullable=False) report_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("group_reports.id"), nullable=False)
report = orm.relationship("ReportModel", back_populates="entries") report: Mapped["ReportModel"] = orm.relationship("ReportModel", back_populates="entries")
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
@ -28,18 +33,20 @@ class ReportEntryModel(SqlAlchemyBase, BaseMixins):
class ReportModel(SqlAlchemyBase, BaseMixins): class ReportModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_reports" __tablename__ = "group_reports"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name = Column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
status = Column(String, nullable=False) status: Mapped[str] = mapped_column(String, nullable=False)
category = Column(String, index=True, nullable=False) category: Mapped[str] = mapped_column(String, index=True, nullable=False)
timestamp = Column(DateTime, nullable=False, default=datetime.utcnow) timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
entries = orm.relationship(ReportEntryModel, back_populates="report", cascade="all, delete-orphan") entries: Mapped[list[ReportEntryModel]] = orm.relationship(
ReportEntryModel, back_populates="report", cascade="all, delete-orphan"
)
# Relationships # Relationships
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="group_reports", single_parent=True) group: Mapped["Group"] = orm.relationship("Group", back_populates="group_reports", single_parent=True)
class Config: class Config:
exclude = ["entries"] exclude = ["entries"]

View File

@ -1,5 +1,8 @@
from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, orm from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, orm
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models.labels import MultiPurposeLabel from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.api_extras import ShoppingListExtras, ShoppingListItemExtras, api_extras from mealie.db.models.recipe.api_extras import ShoppingListExtras, ShoppingListItemExtras, api_extras
@ -8,16 +11,21 @@ from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID, auto_init from .._model_utils import GUID, auto_init
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
if TYPE_CHECKING:
from group import Group
from ..recipe import RecipeModel
class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase): class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_item_recipe_reference" __tablename__ = "shopping_list_item_recipe_reference"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_item_id = Column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True) shopping_list_item_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True)
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True) recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs") recipe: Mapped[Optional["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
recipe_quantity = Column(Float, nullable=False) recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False)
recipe_scale = Column(Float, nullable=False, default=1) recipe_scale: Mapped[float | None] = mapped_column(Float, default=1)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
@ -28,32 +36,38 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_list_items" __tablename__ = "shopping_list_items"
# Id's # Id's
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id")) shopping_list_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("shopping_lists.id"))
# Meta # Meta
is_ingredient = Column(Boolean, default=True) is_ingredient: Mapped[bool | None] = mapped_column(Boolean, default=True)
position = Column(Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
checked = Column(Boolean, default=False) checked: Mapped[bool | None] = mapped_column(Boolean, default=False)
quantity = Column(Float, default=1) quantity: Mapped[float | None] = mapped_column(Float, default=1)
note = Column(String) note: Mapped[str | None] = mapped_column(String)
is_food = Column(Boolean, default=False) is_food: Mapped[bool | None] = mapped_column(Boolean, default=False)
extras: list[ShoppingListItemExtras] = orm.relationship("ShoppingListItemExtras", cascade="all, delete-orphan") extras: Mapped[list[ShoppingListItemExtras]] = orm.relationship(
"ShoppingListItemExtras", cascade="all, delete-orphan"
)
# Scaling Items # Scaling Items
unit_id = Column(GUID, ForeignKey("ingredient_units.id")) unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"))
unit = orm.relationship(IngredientUnitModel, uselist=False) unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False)
food_id = Column(GUID, ForeignKey("ingredient_foods.id")) food_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"))
food = orm.relationship(IngredientFoodModel, uselist=False) food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False)
label_id = Column(GUID, ForeignKey("multi_purpose_labels.id")) label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"))
label = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="shopping_list_items") label: Mapped[MultiPurposeLabel | None] = orm.relationship(
MultiPurposeLabel, uselist=False, back_populates="shopping_list_items"
)
# Recipe Reference # Recipe Reference
recipe_references = orm.relationship(ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan") recipe_references: Mapped[list[ShoppingListItemRecipeReference]] = orm.relationship(
ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan"
)
class Config: class Config:
exclude = {"id", "label", "food", "unit"} exclude = {"id", "label", "food", "unit"}
@ -66,14 +80,16 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase): class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
__tablename__ = "shopping_list_recipe_reference" __tablename__ = "shopping_list_recipe_reference"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
shopping_list_id = Column(GUID, ForeignKey("shopping_lists.id"), primary_key=True) shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True) recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
recipe = orm.relationship("RecipeModel", uselist=False, back_populates="shopping_list_refs") recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(
"RecipeModel", uselist=False, back_populates="shopping_list_refs"
)
recipe_quantity = Column(Float, nullable=False) recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False)
class Config: class Config:
exclude = {"id", "recipe"} exclude = {"id", "recipe"}
@ -85,21 +101,23 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
class ShoppingList(SqlAlchemyBase, BaseMixins): class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists" __tablename__ = "shopping_lists"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="shopping_lists") group: Mapped["Group"] = orm.relationship("Group", back_populates="shopping_lists")
name = Column(String) name: Mapped[str | None] = mapped_column(String)
list_items = orm.relationship( list_items: Mapped[ShoppingListItem] = orm.relationship(
ShoppingListItem, ShoppingListItem,
cascade="all, delete, delete-orphan", cascade="all, delete, delete-orphan",
order_by="ShoppingListItem.position", order_by="ShoppingListItem.position",
collection_class=ordering_list("position"), collection_class=ordering_list("position"),
) )
recipe_references = orm.relationship(ShoppingListRecipeReference, cascade="all, delete, delete-orphan") recipe_references: Mapped[ShoppingListRecipeReference] = orm.relationship(
extras: list[ShoppingListExtras] = orm.relationship("ShoppingListExtras", cascade="all, delete-orphan") ShoppingListRecipeReference, cascade="all, delete, delete-orphan"
)
extras: Mapped[list[ShoppingListExtras]] = orm.relationship("ShoppingListExtras", cascade="all, delete-orphan")
class Config: class Config:
exclude = {"id", "list_items"} exclude = {"id", "list_items"}

View File

@ -1,29 +1,34 @@
from datetime import datetime from datetime import datetime, time
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, Column, ForeignKey, String, Time, orm from sqlalchemy import Boolean, ForeignKey, String, Time, orm
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID, auto_init from .._model_utils import GUID, auto_init
if TYPE_CHECKING:
from group import Group
class GroupWebhooksModel(SqlAlchemyBase, BaseMixins): class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "webhook_urls" __tablename__ = "webhook_urls"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
group = orm.relationship("Group", back_populates="webhooks", single_parent=True) group: Mapped[Optional["Group"]] = orm.relationship("Group", back_populates="webhooks", single_parent=True)
group_id = Column(GUID, ForeignKey("groups.id"), index=True) group_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("groups.id"), index=True)
enabled = Column(Boolean, default=False) enabled: Mapped[bool | None] = mapped_column(Boolean, default=False)
name = Column(String) name: Mapped[str | None] = mapped_column(String)
url = Column(String) url: Mapped[str | None] = mapped_column(String)
# New Fields # New Fields
webhook_type = Column(String, default="") # Future use for different types of webhooks webhook_type: Mapped[str | None] = mapped_column(String, default="") # Future use for different types of webhooks
scheduled_time = Column(Time, default=lambda: datetime.now().time()) scheduled_time: Mapped[time | None] = mapped_column(Time, default=lambda: datetime.now().time())
# Columne is no longer used but is kept for since it's super annoying to # Columne 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 # delete a column in SQLite and it's not a big deal to keep it around
time = Column(String, default="00:00") time: Mapped[str | None] = mapped_column(String, default="00:00")
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,22 +1,29 @@
from sqlalchemy import Column, ForeignKey, String, orm from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from ._model_utils import auto_init from ._model_utils import auto_init
from ._model_utils.guid import GUID from ._model_utils.guid import GUID
if TYPE_CHECKING:
from group import Group, ShoppingListItem
from recipe import IngredientFoodModel
class MultiPurposeLabel(SqlAlchemyBase, BaseMixins): class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
__tablename__ = "multi_purpose_labels" __tablename__ = "multi_purpose_labels"
id = Column(GUID, default=GUID.generate, primary_key=True) id: Mapped[GUID] = mapped_column(GUID, default=GUID.generate, primary_key=True)
name = Column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
color = Column(String(10), nullable=False, default="") color: Mapped[str] = mapped_column(String(10), nullable=False, default="")
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="labels") group: Mapped["Group"] = orm.relationship("Group", back_populates="labels")
shopping_list_items = orm.relationship("ShoppingListItem", back_populates="label") shopping_list_items: Mapped["ShoppingListItem"] = orm.relationship("ShoppingListItem", back_populates="label")
foods = orm.relationship("IngredientFoodModel", back_populates="label") foods: Mapped["IngredientFoodModel"] = orm.relationship("IngredientFoodModel", back_populates="label")
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -0,0 +1,14 @@
from .api_extras import *
from .assets import *
from .category import *
from .comment import *
from .ingredient import *
from .instruction import *
from .note import *
from .nutrition import *
from .recipe import *
from .recipe_timeline import *
from .settings import *
from .shared import *
from .tag import *
from .tool import *

View File

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

View File

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

View File

@ -1,13 +1,18 @@
from typing import TYPE_CHECKING
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from slugify import slugify from slugify import slugify
from sqlalchemy.orm import validates from sqlalchemy.orm import Mapped, mapped_column, validates
from mealie.core import root_logger from mealie.core import root_logger
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
if TYPE_CHECKING:
from ..group import Group
from . import RecipeModel
logger = root_logger.get_logger() logger = root_logger.get_logger()
@ -45,13 +50,15 @@ class Category(SqlAlchemyBase, BaseMixins):
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),) __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="category_slug_group_id_key"),)
# ID Relationships # ID Relationships
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="categories", foreign_keys=[group_id])
id = sa.Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
name = sa.Column(sa.String, index=True, nullable=False) name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, nullable=False) slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
recipes = orm.relationship("RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category") recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_categories, back_populates="recipe_category"
)
@validates("name") @validates("name")
def validate_name(self, key, name): def validate_name(self, key, name):
@ -62,18 +69,3 @@ class Category(SqlAlchemyBase, BaseMixins):
self.group_id = group_id self.group_id = group_id
self.name = name.strip() self.name = name.strip()
self.slug = slugify(name) self.slug = slugify(name)
@classmethod # TODO: Remove this
def get_ref(cls, match_value: str, session=None): # type: ignore
if not session or not match_value:
return None
slug = slugify(match_value)
result = session.query(Category).filter(Category.slug == slug).one_or_none()
if result:
logger.debug("Category exists, associating recipe")
return result
else:
logger.debug("Category doesn't exists, creating Category")
return Category(name=match_value) # type: ignore

View File

@ -1,26 +1,35 @@
from sqlalchemy import Column, ForeignKey, String, orm from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import auto_init from mealie.db.models._model_utils import auto_init
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
if TYPE_CHECKING:
from ..users import User
from . import RecipeModel
class RecipeComment(SqlAlchemyBase, BaseMixins): class RecipeComment(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_comments" __tablename__ = "recipe_comments"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
text = Column(String) text: Mapped[str | None] = mapped_column(String)
# Recipe Link # Recipe Link
recipe_id = Column(GUID, ForeignKey("recipes.id"), nullable=False) recipe_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("recipes.id"), nullable=False)
recipe = orm.relationship("RecipeModel", back_populates="comments") recipe: Mapped["RecipeModel"] = orm.relationship("RecipeModel", back_populates="comments")
# User Link # User Link
user_id = Column(GUID, ForeignKey("users.id"), nullable=False) user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False)
user = orm.relationship("User", back_populates="comments", single_parent=True, foreign_keys=[user_id]) user: Mapped["User"] = orm.relationship(
"User", back_populates="comments", single_parent=True, foreign_keys=[user_id]
)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
pass pass
def update(self, text, **_) -> None: # type: ignore def update(self, text, **_) -> None:
self.text = text self.text = text

View File

@ -1,4 +1,7 @@
from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, orm from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.labels import MultiPurposeLabel from mealie.db.models.labels import MultiPurposeLabel
@ -7,21 +10,24 @@ from mealie.db.models.recipe.api_extras import IngredientFoodExtras, api_extras
from .._model_utils import auto_init from .._model_utils import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
if TYPE_CHECKING:
from ..group import Group
class IngredientUnitModel(SqlAlchemyBase, BaseMixins): class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_units" __tablename__ = "ingredient_units"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships # ID Relationships
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False) group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False)
group = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_units", foreign_keys=[group_id])
name = Column(String) name: Mapped[str | None] = mapped_column(String)
description = Column(String) description: Mapped[str | None] = mapped_column(String)
abbreviation = Column(String) abbreviation: Mapped[str | None] = mapped_column(String)
use_abbreviation = Column(Boolean, default=False) use_abbreviation: Mapped[bool | None] = mapped_column(Boolean, default=False)
fraction = Column(Boolean, default=True) fraction: Mapped[bool | None] = mapped_column(Boolean, default=True)
ingredients = orm.relationship("RecipeIngredient", back_populates="unit") ingredients: Mapped[list["RecipeIngredient"]] = orm.relationship("RecipeIngredient", back_populates="unit")
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
@ -30,19 +36,19 @@ class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
class IngredientFoodModel(SqlAlchemyBase, BaseMixins): class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "ingredient_foods" __tablename__ = "ingredient_foods"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships # ID Relationships
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False) group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False)
group = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id])
name = Column(String) name: Mapped[str | None] = mapped_column(String)
description = Column(String) description: Mapped[str | None] = mapped_column(String)
ingredients = orm.relationship("RecipeIngredient", back_populates="food") ingredients: Mapped[list["RecipeIngredient"]] = orm.relationship("RecipeIngredient", back_populates="food")
extras: list[IngredientFoodExtras] = orm.relationship("IngredientFoodExtras", cascade="all, delete-orphan") extras: Mapped[list[IngredientFoodExtras]] = orm.relationship("IngredientFoodExtras", cascade="all, delete-orphan")
label_id = Column(GUID, ForeignKey("multi_purpose_labels.id")) label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"))
label = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="foods") label: Mapped[MultiPurposeLabel | None] = orm.relationship(MultiPurposeLabel, uselist=False, back_populates="foods")
@api_extras @api_extras
@auto_init() @auto_init()
@ -52,24 +58,24 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
class RecipeIngredient(SqlAlchemyBase, BaseMixins): class RecipeIngredient(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes_ingredients" __tablename__ = "recipes_ingredients"
id = Column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
position = Column(Integer) position: Mapped[int | None] = mapped_column(Integer)
recipe_id = Column(GUID, ForeignKey("recipes.id")) recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
title = Column(String) # Section Header - Shows if Present title: Mapped[str | None] = mapped_column(String) # Section Header - Shows if Present
note = Column(String) # Force Show Text - Overrides Concat note: Mapped[str | None] = mapped_column(String) # Force Show Text - Overrides Concat
# Scaling Items # Scaling Items
unit_id = Column(GUID, ForeignKey("ingredient_units.id")) unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"))
unit = orm.relationship(IngredientUnitModel, uselist=False) unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False)
food_id = Column(GUID, ForeignKey("ingredient_foods.id")) food_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"))
food = orm.relationship(IngredientFoodModel, uselist=False) food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False)
quantity = Column(Float) quantity: Mapped[float | None] = mapped_column(Float)
original_text = Column(String) original_text: Mapped[str | None] = mapped_column(String)
reference_id = Column(GUID) # Reference Links reference_id: Mapped[GUID | None] = mapped_column(GUID) # Reference Links
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,4 +1,5 @@
from sqlalchemy import Column, ForeignKey, Integer, String, orm from sqlalchemy import ForeignKey, Integer, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import auto_init
@ -7,8 +8,8 @@ from .._model_utils.guid import GUID
class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins): class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_ingredient_ref_link" __tablename__ = "recipe_ingredient_ref_link"
instruction_id = Column(GUID, ForeignKey("recipe_instructions.id")) instruction_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipe_instructions.id"))
reference_id = Column(GUID) reference_id: Mapped[GUID | None] = mapped_column(GUID)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
@ -17,14 +18,16 @@ class RecipeIngredientRefLink(SqlAlchemyBase, BaseMixins):
class RecipeInstruction(SqlAlchemyBase): class RecipeInstruction(SqlAlchemyBase):
__tablename__ = "recipe_instructions" __tablename__ = "recipe_instructions"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
recipe_id = Column(GUID, ForeignKey("recipes.id")) recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
position = Column(Integer) position: Mapped[int | None] = mapped_column(Integer)
type = Column(String, default="") type: Mapped[str | None] = mapped_column(String, default="")
title = Column(String) title: Mapped[str | None] = mapped_column(String)
text = Column(String) text: Mapped[str | None] = mapped_column(String)
ingredient_references = orm.relationship("RecipeIngredientRefLink", cascade="all, delete-orphan") ingredient_references: Mapped[list[RecipeIngredientRefLink]] = orm.relationship(
RecipeIngredientRefLink, cascade="all, delete-orphan"
)
class Config: class Config:
exclude = { exclude = {

View File

@ -1,4 +1,5 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@ -6,10 +7,10 @@ from mealie.db.models._model_utils.guid import GUID
class Note(SqlAlchemyBase): class Note(SqlAlchemyBase):
__tablename__ = "notes" __tablename__ = "notes"
id = sa.Column(sa.Integer, primary_key=True) id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id")) recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"))
title = sa.Column(sa.String) title: Mapped[str | None] = mapped_column(sa.String)
text = sa.Column(sa.String) text: Mapped[str | None] = mapped_column(sa.String)
def __init__(self, title, text) -> None: def __init__(self, title, text) -> None:
self.title = title self.title = title

View File

@ -1,4 +1,5 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@ -6,15 +7,15 @@ from mealie.db.models._model_utils.guid import GUID
class Nutrition(SqlAlchemyBase): class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition" __tablename__ = "recipe_nutrition"
id = sa.Column(sa.Integer, primary_key=True) id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id")) recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"))
calories = sa.Column(sa.String) calories: Mapped[str | None] = mapped_column(sa.String)
fat_content = sa.Column(sa.String) fat_content: Mapped[str | None] = mapped_column(sa.String)
fiber_content = sa.Column(sa.String) fiber_content: Mapped[str | None] = mapped_column(sa.String)
protein_content = sa.Column(sa.String) protein_content: Mapped[str | None] = mapped_column(sa.String)
carbohydrate_content = sa.Column(sa.String) carbohydrate_content: Mapped[str | None] = mapped_column(sa.String)
sodium_content = sa.Column(sa.String) sodium_content: Mapped[str | None] = mapped_column(sa.String)
sugar_content = sa.Column(sa.String) sugar_content: Mapped[str | None] = mapped_column(sa.String)
def __init__( def __init__(
self, self,

View File

@ -1,9 +1,10 @@
import datetime from datetime import date, datetime
from typing import TYPE_CHECKING
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import validates from sqlalchemy.orm import Mapped, mapped_column, validates
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@ -24,90 +25,103 @@ from .shared import RecipeShareTokenModel
from .tag import recipes_to_tags from .tag import recipes_to_tags
from .tool import recipes_to_tools from .tool import recipes_to_tools
if TYPE_CHECKING:
from ..group import Group, GroupMealPlan, ShoppingListItemRecipeReference, ShoppingListRecipeReference
from ..users import User
from . import Category, Tag, Tool
class RecipeModel(SqlAlchemyBase, BaseMixins): class RecipeModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipes" __tablename__ = "recipes"
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),) __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"),)
id = sa.Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
slug = sa.Column(sa.String, index=True) slug: Mapped[str | None] = mapped_column(sa.String, index=True)
# ID Relationships # ID Relationships
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id])
user_id = sa.Column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True) user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True)
user = orm.relationship("User", uselist=False, foreign_keys=[user_id]) user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id])
meal_entries = orm.relationship("GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan") meal_entries: Mapped["GroupMealPlan"] = orm.relationship(
"GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
)
favorited_by = orm.relationship("User", secondary=users_to_favorites, back_populates="favorite_recipes") favorited_by: Mapped[list["User"]] = orm.relationship(
"User", secondary=users_to_favorites, back_populates="favorite_recipes"
)
# General Recipe Properties # General Recipe Properties
name = sa.Column(sa.String, nullable=False) name: Mapped[str] = mapped_column(sa.String, nullable=False)
description = sa.Column(sa.String) description: Mapped[str | None] = mapped_column(sa.String)
image = sa.Column(sa.String) image: Mapped[str | None] = mapped_column(sa.String)
# Time Related Properties # Time Related Properties
total_time = sa.Column(sa.String) total_time: Mapped[str | None] = mapped_column(sa.String)
prep_time = sa.Column(sa.String) prep_time: Mapped[str | None] = mapped_column(sa.String)
perform_time = sa.Column(sa.String) perform_time: Mapped[str | None] = mapped_column(sa.String)
cook_time = sa.Column(sa.String) cook_time: Mapped[str | None] = mapped_column(sa.String)
recipe_yield = sa.Column(sa.String) recipe_yield: Mapped[str | None] = mapped_column(sa.String)
recipeCuisine = sa.Column(sa.String) recipeCuisine: Mapped[str | None] = mapped_column(sa.String)
assets = orm.relationship("RecipeAsset", cascade="all, delete-orphan") assets: Mapped[RecipeAsset] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan") nutrition: Mapped[Nutrition] = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
recipe_category = orm.relationship("Category", secondary=recipes_to_categories, back_populates="recipes") recipe_category: Mapped[list["Category"]] = orm.relationship(
tools = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes") "Category", secondary=recipes_to_categories, back_populates="recipes"
)
tools: Mapped[list["Tool"]] = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes")
recipe_ingredient: list[RecipeIngredient] = orm.relationship( recipe_ingredient: Mapped[list[RecipeIngredient]] = orm.relationship(
"RecipeIngredient", "RecipeIngredient",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="RecipeIngredient.position", order_by="RecipeIngredient.position",
collection_class=ordering_list("position"), collection_class=ordering_list("position"),
) )
recipe_instructions: list[RecipeInstruction] = orm.relationship( recipe_instructions: Mapped[list[RecipeInstruction]] = orm.relationship(
"RecipeInstruction", "RecipeInstruction",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="RecipeInstruction.position", order_by="RecipeInstruction.position",
collection_class=ordering_list("position"), collection_class=ordering_list("position"),
) )
share_tokens = orm.relationship( share_tokens: Mapped[list[RecipeShareTokenModel]] = orm.relationship(
RecipeShareTokenModel, back_populates="recipe", cascade="all, delete, delete-orphan" RecipeShareTokenModel, back_populates="recipe", cascade="all, delete, delete-orphan"
) )
comments: list[RecipeComment] = orm.relationship( comments: Mapped[list[RecipeComment]] = orm.relationship(
"RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan" "RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan"
) )
timeline_events: list[RecipeTimelineEvent] = orm.relationship( timeline_events: Mapped[list[RecipeTimelineEvent]] = orm.relationship(
"RecipeTimelineEvent", back_populates="recipe", cascade="all, delete, delete-orphan" "RecipeTimelineEvent", back_populates="recipe", cascade="all, delete, delete-orphan"
) )
# Mealie Specific # Mealie Specific
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan") settings: Mapped[list["RecipeSettings"]] = orm.relationship(
tags = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes") "RecipeSettings", uselist=False, cascade="all, delete-orphan"
notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan") )
rating = sa.Column(sa.Integer) tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes")
org_url = sa.Column(sa.String) notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
extras: list[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete-orphan") rating: Mapped[int | None] = mapped_column(sa.Integer)
is_ocr_recipe = sa.Column(sa.Boolean, default=False) org_url: Mapped[str | None] = mapped_column(sa.String)
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
# Time Stamp Properties # Time Stamp Properties
date_added = sa.Column(sa.Date, default=datetime.date.today) date_added: Mapped[date | None] = mapped_column(sa.Date, default=date.today)
date_updated = sa.Column(sa.DateTime) date_updated: Mapped[datetime | None] = mapped_column(sa.DateTime)
last_made = sa.Column(sa.DateTime) last_made: Mapped[datetime | None] = mapped_column(sa.DateTime)
# Shopping List Refs # Shopping List Refs
shopping_list_refs = orm.relationship( shopping_list_refs: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship(
"ShoppingListRecipeReference", "ShoppingListRecipeReference",
back_populates="recipe", back_populates="recipe",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
shopping_list_item_refs = orm.relationship( shopping_list_item_refs: Mapped[list["ShoppingListItemRecipeReference"]] = orm.relationship(
"ShoppingListItemRecipeReference", "ShoppingListItemRecipeReference",
back_populates="recipe", back_populates="recipe",
cascade="all, delete-orphan", cascade="all, delete-orphan",
@ -160,4 +174,4 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
if notes: if notes:
self.notes = [Note(**n) for n in notes] self.notes = [Note(**n) for n in notes]
self.date_updated = datetime.datetime.now() self.date_updated = datetime.now()

View File

@ -1,33 +1,40 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Column, DateTime, ForeignKey, String from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import auto_init
from .._model_utils.guid import GUID from .._model_utils.guid import GUID
if TYPE_CHECKING:
from ..users import User
from . import RecipeModel
class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins): class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_timeline_events" __tablename__ = "recipe_timeline_events"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# Parent Recipe # Parent Recipe
recipe_id = Column(GUID, ForeignKey("recipes.id"), nullable=False) recipe_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("recipes.id"), nullable=False)
recipe = relationship("RecipeModel", back_populates="timeline_events") recipe: Mapped["RecipeModel"] = relationship("RecipeModel", back_populates="timeline_events")
# Related User (Actor) # Related User (Actor)
user_id = Column(GUID, ForeignKey("users.id"), nullable=False) user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False)
user = relationship("User", back_populates="recipe_timeline_events", single_parent=True, foreign_keys=[user_id]) user: Mapped["User"] = relationship(
"User", back_populates="recipe_timeline_events", single_parent=True, foreign_keys=[user_id]
)
# General Properties # General Properties
subject = Column(String, nullable=False) subject: Mapped[str] = mapped_column(String, nullable=False)
message = Column(String) message: Mapped[str | None] = mapped_column(String)
event_type = Column(String) event_type: Mapped[str | None] = mapped_column(String)
image = Column(String) image: Mapped[str | None] = mapped_column(String)
# Timestamps # Timestamps
timestamp = Column(DateTime) timestamp: Mapped[datetime | None] = mapped_column(DateTime)
@auto_init() @auto_init()
def __init__( def __init__(

View File

@ -1,4 +1,5 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@ -6,15 +7,15 @@ from mealie.db.models._model_utils.guid import GUID
class RecipeSettings(SqlAlchemyBase): class RecipeSettings(SqlAlchemyBase):
__tablename__ = "recipe_settings" __tablename__ = "recipe_settings"
id = sa.Column(sa.Integer, primary_key=True) id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id")) recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"))
public = sa.Column(sa.Boolean) public: Mapped[bool | None] = mapped_column(sa.Boolean)
show_nutrition = sa.Column(sa.Boolean) show_nutrition: Mapped[bool | None] = mapped_column(sa.Boolean)
show_assets = sa.Column(sa.Boolean) show_assets: Mapped[bool | None] = mapped_column(sa.Boolean)
landscape_view = sa.Column(sa.Boolean) landscape_view: Mapped[bool | None] = mapped_column(sa.Boolean)
disable_amount = sa.Column(sa.Boolean, default=True) disable_amount: Mapped[bool | None] = mapped_column(sa.Boolean, default=True)
disable_comments = sa.Column(sa.Boolean, default=False) disable_comments: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
locked = sa.Column(sa.Boolean, default=False) locked: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
def __init__( def __init__(
self, self,

View File

@ -1,11 +1,16 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from uuid import uuid4 from uuid import uuid4
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import GUID, auto_init from mealie.db.models._model_utils import GUID, auto_init
if TYPE_CHECKING:
from . import RecipeModel
def defaut_expires_at_time() -> datetime: def defaut_expires_at_time() -> datetime:
return datetime.utcnow() + timedelta(days=30) return datetime.utcnow() + timedelta(days=30)
@ -13,14 +18,14 @@ def defaut_expires_at_time() -> datetime:
class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins): class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "recipe_share_tokens" __tablename__ = "recipe_share_tokens"
id = sa.Column(GUID, primary_key=True, default=uuid4) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=uuid4)
group_id = sa.Column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"), nullable=False) recipe_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("recipes.id"), nullable=False)
recipe = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False) recipe: Mapped["RecipeModel"] = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False)
expires_at = sa.Column(sa.DateTime, nullable=False) expires_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,12 +1,19 @@
from typing import TYPE_CHECKING
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from slugify import slugify from slugify import slugify
from sqlalchemy.orm import validates from sqlalchemy.orm import Mapped, mapped_column, validates
from mealie.core import root_logger from mealie.core import root_logger
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import guid from mealie.db.models._model_utils import guid
if TYPE_CHECKING:
from ..group import Group
from . import RecipeModel
logger = root_logger.get_logger() logger = root_logger.get_logger()
recipes_to_tags = sa.Table( recipes_to_tags = sa.Table(
@ -34,15 +41,17 @@ cookbooks_to_tags = sa.Table(
class Tag(SqlAlchemyBase, BaseMixins): class Tag(SqlAlchemyBase, BaseMixins):
__tablename__ = "tags" __tablename__ = "tags"
__table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),) __table_args__ = (sa.UniqueConstraint("slug", "group_id", name="tags_slug_group_id_key"),)
id = sa.Column(guid.GUID, primary_key=True, default=guid.GUID.generate) id: Mapped[guid.GUID] = mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
# ID Relationships # ID Relationships
group_id = sa.Column(guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[guid.GUID] = mapped_column(guid.GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="tags", foreign_keys=[group_id])
name = sa.Column(sa.String, index=True, nullable=False) name: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
slug = sa.Column(sa.String, index=True, nullable=False) slug: Mapped[str] = mapped_column(sa.String, index=True, nullable=False)
recipes = orm.relationship("RecipeModel", secondary=recipes_to_tags, back_populates="tags") recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_tags, back_populates="tags"
)
@validates("name") @validates("name")
def validate_name(self, key, name): def validate_name(self, key, name):
@ -53,17 +62,3 @@ class Tag(SqlAlchemyBase, BaseMixins):
self.group_id = group_id self.group_id = group_id
self.name = name.strip() self.name = name.strip()
self.slug = slugify(self.name) self.slug = slugify(self.name)
@classmethod # TODO: Remove this
def get_ref(cls, match_value: str, session=None): # type: ignore
if not session or not match_value:
return None
slug = slugify(match_value)
if result := session.query(Tag).filter(Tag.slug == slug).one_or_none():
logger.debug("Category exists, associating recipe")
return result
else:
logger.debug("Category doesn't exists, creating Category")
return Tag(name=match_value) # type: ignore

View File

@ -1,10 +1,17 @@
from typing import TYPE_CHECKING
from slugify import slugify from slugify import slugify
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils import auto_init from mealie.db.models._model_utils import auto_init
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
if TYPE_CHECKING:
from ..group import Group
from . import RecipeModel
recipes_to_tools = Table( recipes_to_tools = Table(
"recipes_to_tools", "recipes_to_tools",
SqlAlchemyBase.metadata, SqlAlchemyBase.metadata,
@ -23,16 +30,18 @@ cookbooks_to_tools = Table(
class Tool(SqlAlchemyBase, BaseMixins): class Tool(SqlAlchemyBase, BaseMixins):
__tablename__ = "tools" __tablename__ = "tools"
__table_args__ = (UniqueConstraint("slug", "group_id", name="tools_slug_group_id_key"),) __table_args__ = (UniqueConstraint("slug", "group_id", name="tools_slug_group_id_key"),)
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
# ID Relationships # ID Relationships
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False) group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False)
group = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id]) group: Mapped["Group"] = orm.relationship("Group", back_populates="tools", foreign_keys=[group_id])
name = Column(String, index=True, unique=True, nullable=False) name: Mapped[str] = mapped_column(String, index=True, unique=True, nullable=False)
slug = Column(String, index=True, unique=True, nullable=False) slug: Mapped[str] = mapped_column(String, index=True, unique=True, nullable=False)
on_hand = Column(Boolean, default=False) on_hand: Mapped[bool | None] = mapped_column(Boolean, default=False)
recipes = orm.relationship("RecipeModel", secondary=recipes_to_tools, back_populates="tools") recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=recipes_to_tools, back_populates="tools"
)
@auto_init() @auto_init()
def __init__(self, name, **_) -> None: def __init__(self, name, **_) -> None:

View File

@ -1,20 +1,27 @@
from sqlalchemy import Column, DateTime, ForeignKey, String, orm from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from .._model_utils import auto_init from .._model_utils import auto_init
if TYPE_CHECKING:
from ..group import Group
class ServerTaskModel(SqlAlchemyBase, BaseMixins): class ServerTaskModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "server_tasks" __tablename__ = "server_tasks"
name = Column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
completed_date = Column(DateTime, nullable=True) completed_date: Mapped[datetime] = mapped_column(DateTime, nullable=True)
status = Column(String, nullable=False) status: Mapped[str] = mapped_column(String, nullable=False)
log = Column(String, nullable=True) log: Mapped[str] = mapped_column(String, nullable=True)
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="server_tasks") group: Mapped["Group"] = orm.relationship("Group", back_populates="server_tasks")
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,15 +1,21 @@
from sqlalchemy import Column, ForeignKey, String, orm from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import GUID from .._model_utils import GUID
if TYPE_CHECKING:
from .users import User
class PasswordResetModel(SqlAlchemyBase, BaseMixins): class PasswordResetModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "password_reset_tokens" __tablename__ = "password_reset_tokens"
user_id = Column(GUID, ForeignKey("users.id"), nullable=False) user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False)
user = orm.relationship("User", back_populates="password_reset_tokens", uselist=False) user: Mapped["User"] = orm.relationship("User", back_populates="password_reset_tokens", uselist=False)
token = Column(String(64), unique=True, nullable=False) token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
def __init__(self, user_id, token, **_): def __init__(self, user_id, token, **_):
self.user_id = user_id self.user_id = user_id

View File

@ -1,4 +1,8 @@
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, orm from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
@ -7,14 +11,19 @@ from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init from .._model_utils import auto_init
from .user_to_favorite import users_to_favorites from .user_to_favorite import users_to_favorites
if TYPE_CHECKING:
from ..group import Group
from ..recipe import RecipeComment, RecipeModel, RecipeTimelineEvent
from .password_reset import PasswordResetModel
class LongLiveToken(SqlAlchemyBase, BaseMixins): class LongLiveToken(SqlAlchemyBase, BaseMixins):
__tablename__ = "long_live_tokens" __tablename__ = "long_live_tokens"
name = Column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
token = Column(String, nullable=False) token: Mapped[str] = mapped_column(String, nullable=False)
user_id = Column(GUID, ForeignKey("users.id")) user_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("users.id"))
user = orm.relationship("User") user: Mapped[Optional["User"]] = orm.relationship("User")
def __init__(self, name, token, user_id, **_) -> None: def __init__(self, name, token, user_id, **_) -> None:
self.name = name self.name = name
@ -24,25 +33,25 @@ class LongLiveToken(SqlAlchemyBase, BaseMixins):
class User(SqlAlchemyBase, BaseMixins): class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users" __tablename__ = "users"
id = Column(GUID, primary_key=True, default=GUID.generate) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
full_name = Column(String, index=True) full_name: Mapped[str | None] = mapped_column(String, index=True)
username = Column(String, index=True, unique=True) username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
email = Column(String, unique=True, index=True) email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
password = Column(String) password: Mapped[str | None] = mapped_column(String)
admin = Column(Boolean, default=False) admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
advanced = Column(Boolean, default=False) advanced: Mapped[bool | None] = mapped_column(Boolean, default=False)
group_id = Column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
group = orm.relationship("Group", back_populates="users") group: Mapped["Group"] = orm.relationship("Group", back_populates="users")
cache_key = Column(String, default="1234") cache_key: Mapped[str | None] = mapped_column(String, default="1234")
login_attemps = Column(Integer, default=0) login_attemps: Mapped[int | None] = mapped_column(Integer, default=0)
locked_at = Column(DateTime, default=None) locked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
# Group Permissions # Group Permissions
can_manage = Column(Boolean, default=False) can_manage: Mapped[bool | None] = mapped_column(Boolean, default=False)
can_invite = Column(Boolean, default=False) can_invite: Mapped[bool | None] = mapped_column(Boolean, default=False)
can_organize = Column(Boolean, default=False) can_organize: Mapped[bool | None] = mapped_column(Boolean, default=False)
sp_args = { sp_args = {
"back_populates": "user", "back_populates": "user",
@ -50,15 +59,19 @@ class User(SqlAlchemyBase, BaseMixins):
"single_parent": True, "single_parent": True,
} }
tokens = orm.relationship(LongLiveToken, **sp_args) tokens: Mapped[list[LongLiveToken]] = orm.relationship(LongLiveToken, **sp_args)
comments = orm.relationship("RecipeComment", **sp_args) comments: Mapped[list["RecipeComment"]] = orm.relationship("RecipeComment", **sp_args)
recipe_timeline_events = orm.relationship("RecipeTimelineEvent", **sp_args) recipe_timeline_events: Mapped[list["RecipeTimelineEvent"]] = orm.relationship("RecipeTimelineEvent", **sp_args)
password_reset_tokens = orm.relationship("PasswordResetModel", **sp_args) password_reset_tokens: Mapped[list["PasswordResetModel"]] = orm.relationship("PasswordResetModel", **sp_args)
owned_recipes_id = Column(GUID, ForeignKey("recipes.id")) owned_recipes_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"))
owned_recipes = orm.relationship("RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id]) owned_recipes: Mapped[Optional["RecipeModel"]] = orm.relationship(
"RecipeModel", single_parent=True, foreign_keys=[owned_recipes_id]
)
favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by") favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=users_to_favorites, back_populates="favorited_by"
)
class Config: class Config:
exclude = { exclude = {
@ -78,7 +91,7 @@ class User(SqlAlchemyBase, BaseMixins):
from mealie.db.models.group import Group from mealie.db.models.group import Group
self.group = Group.get_ref(session, group) self.group = Group.get_by_name(session, group)
self.favorite_recipes = [] self.favorite_recipes = []
@ -97,7 +110,7 @@ class User(SqlAlchemyBase, BaseMixins):
from mealie.db.models.group import Group from mealie.db.models.group import Group
self.group = Group.get_ref(session, group) self.group = Group.get_by_name(session, group)
if self.username is None: if self.username is None:
self.username = full_name self.username = full_name
@ -126,7 +139,3 @@ class User(SqlAlchemyBase, BaseMixins):
self.can_manage = can_manage self.can_manage = can_manage
self.can_invite = can_invite self.can_invite = can_invite
self.can_organize = can_organize self.can_organize = can_organize
@staticmethod # TODO: Remove This
def get_ref(session, id: str): # type: ignore
return session.query(User).filter(User.id == id).one()

View File

@ -12,11 +12,11 @@ def pretty_size(size: int) -> str:
""" """
if size < 1024: if size < 1024:
return f"{size} bytes" return f"{size} bytes"
elif size < 1024 ** 2: elif size < 1024**2:
return f"{round(size / 1024, 2)} KB" return f"{round(size / 1024, 2)} KB"
elif size < 1024 ** 2 * 1024: elif size < 1024**2 * 1024:
return f"{round(size / 1024 / 1024, 2)} MB" return f"{round(size / 1024 / 1024, 2)} MB"
elif size < 1024 ** 2 * 1024 * 1024: elif size < 1024**2 * 1024 * 1024:
return f"{round(size / 1024 / 1024 / 1024, 2)} GB" return f"{round(size / 1024 / 1024 / 1024, 2)} GB"
else: else:
return f"{round(size / 1024 / 1024 / 1024 / 1024, 2)} TB" return f"{round(size / 1024 / 1024 / 1024 / 1024, 2)} TB"

View File

@ -1,5 +1,7 @@
from collections.abc import Sequence
from functools import cached_property from functools import cached_property
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from mealie.db.models.group import Group, GroupMealPlan, ReportEntryModel, ReportModel from mealie.db.models.group import Group, GroupMealPlan, ReportEntryModel, ReportModel
@ -70,13 +72,16 @@ PK_GROUP_ID = "group_id"
class RepositoryCategories(RepositoryGeneric[CategoryOut, Category]): class RepositoryCategories(RepositoryGeneric[CategoryOut, Category]):
def get_empty(self): def get_empty(self) -> Sequence[Category]:
return self.session.query(Category).filter(~Category.recipes.any()).all() stmt = select(Category).filter(~Category.recipes.any())
return self.session.execute(stmt).scalars().all()
class RepositoryTags(RepositoryGeneric[TagOut, Tag]): class RepositoryTags(RepositoryGeneric[TagOut, Tag]):
def get_empty(self): def get_empty(self) -> Sequence[Tag]:
return self.session.query(Tag).filter(~Tag.recipes.any()).all() stmt = select(Tag).filter(~Tag.recipes.any())
return self.session.execute(stmt).scalars().all()
class AllRepositories: class AllRepositories:

View File

@ -1,4 +1,5 @@
from pydantic import UUID4 from pydantic import UUID4
from sqlalchemy import select
from mealie.db.models.recipe.ingredient import IngredientFoodModel from mealie.db.models.recipe.ingredient import IngredientFoodModel
from mealie.schema.recipe.recipe_ingredient import IngredientFood from mealie.schema.recipe.recipe_ingredient import IngredientFood
@ -7,15 +8,13 @@ from .repository_generic import RepositoryGeneric
class RepositoryFood(RepositoryGeneric[IngredientFood, IngredientFoodModel]): class RepositoryFood(RepositoryGeneric[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()
def merge(self, from_food: UUID4, to_food: UUID4) -> IngredientFood | None: def merge(self, from_food: UUID4, to_food: UUID4) -> IngredientFood | None:
from_model = self._get_food(from_food)
from_model: IngredientFoodModel = ( to_model = self._get_food(to_food)
self.session.query(self.model).filter_by(**self._filter_builder(**{"id": from_food})).one()
)
to_model: IngredientFoodModel = (
self.session.query(self.model).filter_by(**self._filter_builder(**{"id": to_food})).one()
)
to_model.ingredients += from_model.ingredients to_model.ingredients += from_model.ingredients
@ -29,4 +28,4 @@ class RepositoryFood(RepositoryGeneric[IngredientFood, IngredientFoodModel]):
return self.get_one(to_food) return self.get_one(to_food)
def by_group(self, group_id: UUID4) -> "RepositoryFood": def by_group(self, group_id: UUID4) -> "RepositoryFood":
return super().by_group(group_id) # type: ignore return super().by_group(group_id)

View File

@ -6,21 +6,19 @@ from typing import Any, Generic, TypeVar
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel
from sqlalchemy import func from sqlalchemy import Select, delete, func, select
from sqlalchemy.orm import Query
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.sql import sqltypes from sqlalchemy.sql import sqltypes
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.schema.response.pagination import ( from mealie.db.models._model_base import SqlAlchemyBase
OrderDirection, from mealie.schema.response.pagination import OrderDirection, PaginationBase, PaginationQuery
PaginationBase,
PaginationQuery,
)
from mealie.schema.response.query_filter import QueryFilter from mealie.schema.response.query_filter import QueryFilter
Schema = TypeVar("Schema", bound=BaseModel) Schema = TypeVar("Schema", bound=BaseModel)
Model = TypeVar("Model") Model = TypeVar("Model", bound=SqlAlchemyBase)
T = TypeVar("T", bound="RepositoryGeneric")
class RepositoryGeneric(Generic[Schema, Model]): class RepositoryGeneric(Generic[Schema, Model]):
@ -33,6 +31,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
user_id: UUID4 | None = None user_id: UUID4 | None = None
group_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: def __init__(self, session: Session, primary_key: str, sql_model: type[Model], schema: type[Schema]) -> None:
self.session = session self.session = session
@ -42,11 +41,11 @@ class RepositoryGeneric(Generic[Schema, Model]):
self.logger = get_logger() self.logger = get_logger()
def by_user(self, user_id: UUID4) -> RepositoryGeneric[Schema, Model]: def by_user(self: T, user_id: UUID4) -> T:
self.user_id = user_id self.user_id = user_id
return self return self
def by_group(self, group_id: UUID4) -> RepositoryGeneric[Schema, Model]: def by_group(self: T, group_id: UUID4) -> T:
self.group_id = group_id self.group_id = group_id
return self return self
@ -55,7 +54,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
self.logger.error(e) self.logger.error(e)
def _query(self): def _query(self):
return self.session.query(self.model) return select(self.model)
def _filter_builder(self, **kwargs) -> dict[str, Any]: def _filter_builder(self, **kwargs) -> dict[str, Any]:
dct = {} dct = {}
@ -98,8 +97,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
except AttributeError: except AttributeError:
self.logger.info(f'Attempted to sort by unknown sort property "{order_by}"; ignoring') self.logger.info(f'Attempted to sort by unknown sort property "{order_by}"; ignoring')
result = self.session.execute(q.offset(start).limit(limit)).scalars().all()
return [eff_schema.from_orm(x) for x in q.offset(start).limit(limit).all()] return [eff_schema.from_orm(x) for x in result]
def multi_query( def multi_query(
self, self,
@ -120,7 +119,9 @@ class RepositoryGeneric(Generic[Schema, Model]):
order_attr = order_attr.desc() order_attr = order_attr.desc()
q = q.order_by(order_attr) q = q.order_by(order_attr)
return [eff_schema.from_orm(x) for x in q.offset(start).limit(limit).all()] q = q.offset(start).limit(limit)
result = self.session.execute(q).scalars().all()
return [eff_schema.from_orm(x) for x in result]
def _query_one(self, match_value: str | int | UUID4, match_key: str | None = None) -> Model: def _query_one(self, match_value: str | int | UUID4, match_key: str | None = None) -> Model:
""" """
@ -131,14 +132,14 @@ class RepositoryGeneric(Generic[Schema, Model]):
match_key = self.primary_key match_key = self.primary_key
fltr = self._filter_builder(**{match_key: match_value}) fltr = self._filter_builder(**{match_key: match_value})
return self._query().filter_by(**fltr).one() return self.session.execute(self._query().filter_by(**fltr)).scalars().one()
def get_one( def get_one(
self, value: str | int | UUID4, key: str | None = None, any_case=False, override_schema=None self, value: str | int | UUID4, key: str | None = None, any_case=False, override_schema=None
) -> Schema | None: ) -> Schema | None:
key = key or self.primary_key key = key or self.primary_key
q = self.session.query(self.model) q = self._query()
if any_case: if any_case:
search_attr = getattr(self.model, key) search_attr = getattr(self.model, key)
@ -146,7 +147,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
else: else:
q = q.filter_by(**self._filter_builder(**{key: value})) q = q.filter_by(**self._filter_builder(**{key: value}))
result = q.one_or_none() result = self.session.execute(q).scalars().one_or_none()
if not result: if not result:
return None return None
@ -156,7 +157,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
def create(self, data: Schema | BaseModel | dict) -> Schema: def create(self, data: Schema | BaseModel | dict) -> Schema:
data = data if isinstance(data, dict) else data.dict() data = data if isinstance(data, dict) else data.dict()
new_document = self.model(session=self.session, **data) # type: ignore new_document = self.model(session=self.session, **data)
self.session.add(new_document) self.session.add(new_document)
self.session.commit() self.session.commit()
self.session.refresh(new_document) self.session.refresh(new_document)
@ -167,7 +168,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
new_documents = [] new_documents = []
for document in data: for document in data:
document = document if isinstance(document, dict) else document.dict() document = document if isinstance(document, dict) else document.dict()
new_document = self.model(session=self.session, **document) # type: ignore new_document = self.model(session=self.session, **document)
new_documents.append(new_document) new_documents.append(new_document)
self.session.add_all(new_documents) self.session.add_all(new_documents)
@ -191,7 +192,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
new_data = new_data if isinstance(new_data, dict) else new_data.dict() new_data = new_data if isinstance(new_data, dict) else new_data.dict()
entry = self._query_one(match_value=match_value) entry = self._query_one(match_value=match_value)
entry.update(session=self.session, **new_data) # type: ignore entry.update(session=self.session, **new_data)
self.session.commit() self.session.commit()
return self.schema.from_orm(entry) return self.schema.from_orm(entry)
@ -202,7 +203,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
document_data = document if isinstance(document, dict) else document.dict() document_data = document if isinstance(document, dict) else document.dict()
document_data_by_id[document_data["id"]] = document_data document_data_by_id[document_data["id"]] = document_data
documents_to_update = self._query().filter(self.model.id.in_(list(document_data_by_id.keys()))) # type: ignore documents_to_update_query = self._query().filter(self.model.id.in_(list(document_data_by_id.keys())))
documents_to_update = self.session.execute(documents_to_update_query).scalars().all()
updated_documents = [] updated_documents = []
for document_to_update in documents_to_update: for document_to_update in documents_to_update:
@ -226,7 +228,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
def delete(self, value, match_key: str | None = None) -> Schema: def delete(self, value, match_key: str | None = None) -> Schema:
match_key = match_key or self.primary_key match_key = match_key or self.primary_key
result = self._query().filter_by(**{match_key: value}).one() result = self.session.execute(self._query().filter_by(**{match_key: value})).scalars().one()
results_as_model = self.schema.from_orm(result) results_as_model = self.schema.from_orm(result)
try: try:
@ -239,7 +241,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
return results_as_model return results_as_model
def delete_many(self, values: Iterable) -> Schema: def delete_many(self, values: Iterable) -> Schema:
results = self._query().filter(self.model.id.in_(values)) # type: ignore query = self._query().filter(self.model.id.in_(values)) # type: ignore
results = self.session.execute(query).scalars().all()
results_as_model = [self.schema.from_orm(result) for result in results] results_as_model = [self.schema.from_orm(result) for result in results]
try: try:
@ -256,14 +259,14 @@ class RepositoryGeneric(Generic[Schema, Model]):
return results_as_model # type: ignore return results_as_model # type: ignore
def delete_all(self) -> None: def delete_all(self) -> None:
self._query().delete() delete(self.model)
self.session.commit() self.session.commit()
def count_all(self, match_key=None, match_value=None) -> int: def count_all(self, match_key=None, match_value=None) -> int:
if None in [match_key, match_value]: q = select(func.count(self.model.id))
return self._query().count() if None not in [match_key, match_value]:
else: q = q.filter_by(**{match_key: match_value})
return self._query().filter_by(**{match_key: match_value}).count() return self.session.scalar(q)
def _count_attribute( def _count_attribute(
self, self,
@ -274,12 +277,12 @@ class RepositoryGeneric(Generic[Schema, Model]):
) -> int | list[Schema]: # sourcery skip: assign-if-exp ) -> int | list[Schema]: # sourcery skip: assign-if-exp
eff_schema = override_schema or self.schema eff_schema = override_schema or self.schema
q = self._query().filter(attribute_name == attr_match)
if count: if count:
return q.count() q = select(func.count(self.model.id)).filter(attribute_name == attr_match)
return self.session.scalar(q)
else: else:
return [eff_schema.from_orm(x) for x in q.all()] q = self._query().filter(attribute_name == attr_match)
return [eff_schema.from_orm(x) for x in self.session.execute(q).scalars().all()]
def page_all(self, pagination: PaginationQuery, override=None) -> PaginationBase[Schema]: def page_all(self, pagination: PaginationQuery, override=None) -> PaginationBase[Schema]:
""" """
@ -293,14 +296,14 @@ class RepositoryGeneric(Generic[Schema, Model]):
""" """
eff_schema = override or self.schema eff_schema = override or self.schema
q = self.session.query(self.model) q = self._query()
fltr = self._filter_builder() fltr = self._filter_builder()
q = q.filter_by(**fltr) q = q.filter_by(**fltr)
q, count, total_pages = self.add_pagination_to_query(q, pagination) q, count, total_pages = self.add_pagination_to_query(q, pagination)
try: try:
data = q.all() data = self.session.execute(q).scalars().all()
except Exception as e: except Exception as e:
self._log_exception(e) self._log_exception(e)
self.session.rollback() self.session.rollback()
@ -314,7 +317,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
items=[eff_schema.from_orm(s) for s in data], items=[eff_schema.from_orm(s) for s in data],
) )
def add_pagination_to_query(self, query: Query, pagination: PaginationQuery) -> tuple[Query, int, int]: def add_pagination_to_query(self, query: Select, pagination: PaginationQuery) -> tuple[Select, int, int]:
""" """
Adds pagination data to an existing query. Adds pagination data to an existing query.
@ -333,7 +336,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
self.logger.error(e) self.logger.error(e)
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
count = query.count() count_query = select(func.count()).select_from(query)
count = self.session.scalar(count_query)
# interpret -1 as "get_all" # interpret -1 as "get_all"
if pagination.per_page == -1: if pagination.per_page == -1:

View File

@ -1,4 +1,5 @@
from pydantic import UUID4 from pydantic import UUID4
from sqlalchemy import func, select
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
@ -9,21 +10,26 @@ from mealie.db.models.users.users import User
from mealie.schema.group.group_statistics import GroupStatistics from mealie.schema.group.group_statistics import GroupStatistics
from mealie.schema.user.user import GroupInDB from mealie.schema.user.user import GroupInDB
from ..db.models._model_base import SqlAlchemyBase
from .repository_generic import RepositoryGeneric from .repository_generic import RepositoryGeneric
class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]): class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]):
def get_by_name(self, name: str, limit=1) -> GroupInDB | Group | None: def get_by_name(self, name: str) -> GroupInDB | None:
dbgroup = self.session.query(self.model).filter_by(**{"name": name}).one_or_none() dbgroup = self.session.execute(select(self.model).filter_by(name=name)).scalars().one_or_none()
if dbgroup is None: if dbgroup is None:
return None return None
return self.schema.from_orm(dbgroup) return self.schema.from_orm(dbgroup)
def statistics(self, group_id: UUID4) -> GroupStatistics: 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( return GroupStatistics(
total_recipes=self.session.query(RecipeModel).filter_by(group_id=group_id).count(), total_recipes=model_count(RecipeModel),
total_users=self.session.query(User).filter_by(group_id=group_id).count(), total_users=model_count(User),
total_categories=self.session.query(Category).filter_by(group_id=group_id).count(), total_categories=model_count(Category),
total_tags=self.session.query(Tag).filter_by(group_id=group_id).count(), total_tags=model_count(Tag),
total_tools=self.session.query(Tool).filter_by(group_id=group_id).count(), total_tools=model_count(Tool),
) )

View File

@ -1,6 +1,6 @@
from uuid import UUID from uuid import UUID
from sqlalchemy import or_ from sqlalchemy import or_, select
from mealie.db.models.group.mealplan import GroupMealPlanRules from mealie.db.models.group.mealplan import GroupMealPlanRules
from mealie.schema.meal_plan.plan_rules import PlanRulesDay, PlanRulesOut, PlanRulesType from mealie.schema.meal_plan.plan_rules import PlanRulesDay, PlanRulesOut, PlanRulesType
@ -10,10 +10,10 @@ from .repository_generic import RepositoryGeneric
class RepositoryMealPlanRules(RepositoryGeneric[PlanRulesOut, GroupMealPlanRules]): class RepositoryMealPlanRules(RepositoryGeneric[PlanRulesOut, GroupMealPlanRules]):
def by_group(self, group_id: UUID) -> "RepositoryMealPlanRules": def by_group(self, group_id: UUID) -> "RepositoryMealPlanRules":
return super().by_group(group_id) # type: ignore return super().by_group(group_id)
def get_rules(self, day: PlanRulesDay, entry_type: PlanRulesType) -> list[PlanRulesOut]: def get_rules(self, day: PlanRulesDay, entry_type: PlanRulesType) -> list[PlanRulesOut]:
qry = self.session.query(GroupMealPlanRules).filter( stmt = select(GroupMealPlanRules).filter(
or_( or_(
GroupMealPlanRules.day == day, GroupMealPlanRules.day == day,
GroupMealPlanRules.day.is_(None), GroupMealPlanRules.day.is_(None),
@ -26,4 +26,6 @@ class RepositoryMealPlanRules(RepositoryGeneric[PlanRulesOut, GroupMealPlanRules
), ),
) )
return [self.schema.from_orm(x) for x in qry.all()] rules = self.session.execute(stmt).scalars().all()
return [self.schema.from_orm(x) for x in rules]

View File

@ -1,6 +1,8 @@
from datetime import date from datetime import date
from uuid import UUID from uuid import UUID
from sqlalchemy import select
from mealie.db.models.group import GroupMealPlan from mealie.db.models.group import GroupMealPlan
from mealie.schema.meal_plan.new_meal import ReadPlanEntry from mealie.schema.meal_plan.new_meal import ReadPlanEntry
@ -9,10 +11,10 @@ from .repository_generic import RepositoryGeneric
class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]): class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]):
def by_group(self, group_id: UUID) -> "RepositoryMeals": def by_group(self, group_id: UUID) -> "RepositoryMeals":
return super().by_group(group_id) # type: ignore return super().by_group(group_id)
def get_today(self, group_id: UUID) -> list[ReadPlanEntry]: def get_today(self, group_id: UUID) -> list[ReadPlanEntry]:
today = date.today() today = date.today()
qry = self.session.query(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id) stmt = select(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id)
plans = self.session.execute(stmt).scalars().all()
return [self.schema.from_orm(x) for x in qry.all()] return [self.schema.from_orm(x) for x in plans]

View File

@ -1,10 +1,10 @@
from collections.abc import Sequence
from random import randint from random import randint
from typing import Any
from uuid import UUID from uuid import UUID
from pydantic import UUID4 from pydantic import UUID4
from slugify import slugify from slugify import slugify
from sqlalchemy import and_, func from sqlalchemy import and_, func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@ -18,13 +18,14 @@ from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import ( from mealie.schema.recipe.recipe import (
RecipeCategory, RecipeCategory,
RecipePagination,
RecipeSummary, RecipeSummary,
RecipeSummaryWithIngredients, RecipeSummaryWithIngredients,
RecipeTag, RecipeTag,
RecipeTool, RecipeTool,
) )
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
from mealie.schema.response.pagination import PaginationBase, PaginationQuery from mealie.schema.response.pagination import PaginationQuery
from .repository_generic import RepositoryGeneric from .repository_generic import RepositoryGeneric
@ -46,34 +47,31 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
raise raise
def by_group(self, group_id: UUID) -> "RepositoryRecipes": def by_group(self, group_id: UUID) -> "RepositoryRecipes":
return super().by_group(group_id) # type: ignore 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): 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 eff_schema = override_schema or self.schema
if order_by: if order_by:
order_attr = getattr(self.model, str(order_by)) order_attr = getattr(self.model, str(order_by))
stmt = (
return [ select(self.model)
eff_schema.from_orm(x)
for x in self.session.query(self.model)
.join(RecipeSettings) .join(RecipeSettings)
.filter(RecipeSettings.public == True) # noqa: 711 .filter(RecipeSettings.public == True) # noqa: 711
.order_by(order_attr.desc()) .order_by(order_attr.desc())
.offset(start) .offset(start)
.limit(limit) .limit(limit)
.all() )
] return [eff_schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()]
return [ stmt = (
eff_schema.from_orm(x) select(self.model)
for x in self.session.query(self.model)
.join(RecipeSettings) .join(RecipeSettings)
.filter(RecipeSettings.public == True) # noqa: 711 .filter(RecipeSettings.public == True) # noqa: 711
.offset(start) .offset(start)
.limit(limit) .limit(limit)
.all() )
] return [eff_schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()]
def update_image(self, slug: str, _: str | None = None) -> int: def update_image(self, slug: str, _: str | None = None) -> int:
entry: RecipeModel = self._query_one(match_value=slug) entry: RecipeModel = self._query_one(match_value=slug)
@ -100,7 +98,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
def summary( def summary(
self, group_id, start=0, limit=99999, load_foods=False, order_by="created_at", order_descending=True self, group_id, start=0, limit=99999, load_foods=False, order_by="created_at", order_descending=True
) -> Any: ) -> Sequence[RecipeModel]:
args = [ args = [
joinedload(RecipeModel.recipe_category), joinedload(RecipeModel.recipe_category),
joinedload(RecipeModel.tags), joinedload(RecipeModel.tags),
@ -126,15 +124,15 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
else: else:
order_attr = order_attr.asc() order_attr = order_attr.asc()
return ( stmt = (
self.session.query(RecipeModel) select(RecipeModel)
.options(*args) .options(*args)
.filter(RecipeModel.group_id == group_id) .filter(RecipeModel.group_id == group_id)
.order_by(order_attr) .order_by(order_attr)
.offset(start) .offset(start)
.limit(limit) .limit(limit)
.all()
) )
return self.session.execute(stmt).scalars().all()
def page_all( def page_all(
self, self,
@ -145,8 +143,8 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
categories: list[UUID4 | str] | None = None, categories: list[UUID4 | str] | None = None,
tags: list[UUID4 | str] | None = None, tags: list[UUID4 | str] | None = None,
tools: list[UUID4 | str] | None = None, tools: list[UUID4 | str] | None = None,
) -> PaginationBase[RecipeSummary]: ) -> RecipePagination:
q = self.session.query(self.model) q = select(self.model)
args = [ args = [
joinedload(RecipeModel.recipe_category), joinedload(RecipeModel.recipe_category),
@ -154,6 +152,8 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
joinedload(RecipeModel.tools), joinedload(RecipeModel.tools),
] ]
item_class: type[RecipeSummary | RecipeSummaryWithIngredients]
if load_food: if load_food:
args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food))) args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)))
args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.unit))) args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.unit)))
@ -205,14 +205,14 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
q, count, total_pages = self.add_pagination_to_query(q, pagination) q, count, total_pages = self.add_pagination_to_query(q, pagination)
try: try:
data = q.all() data = self.session.execute(q).scalars().unique().all()
except Exception as e: except Exception as e:
self._log_exception(e) self._log_exception(e)
self.session.rollback() self.session.rollback()
raise e raise e
items = [item_class.from_orm(item) for item in data] items = [item_class.from_orm(item) for item in data]
return PaginationBase( return RecipePagination(
page=pagination.page, page=pagination.page,
per_page=pagination.per_page, per_page=pagination.per_page,
total=count, total=count,
@ -226,14 +226,12 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
""" """
ids = [x.id for x in categories] ids = [x.id for x in categories]
stmt = (
return [ select(RecipeModel)
RecipeSummary.from_orm(x)
for x in self.session.query(RecipeModel)
.join(RecipeModel.recipe_category) .join(RecipeModel.recipe_category)
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids))) .filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
.all() )
] return [RecipeSummary.from_orm(x) for x in self.session.execute(stmt).unique().scalars().all()]
def _category_tag_filters( def _category_tag_filters(
self, self,
@ -284,8 +282,8 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
fltr = self._category_tag_filters( fltr = self._category_tag_filters(
categories, tags, tools, require_all_categories, require_all_tags, require_all_tools categories, tags, tools, require_all_categories, require_all_tags, require_all_tools
) )
stmt = select(RecipeModel).filter(*fltr)
return [self.schema.from_orm(x) for x in self.session.query(RecipeModel).filter(*fltr).all()] return [self.schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()]
def get_random_by_categories_and_tags( def get_random_by_categories_and_tags(
self, categories: list[RecipeCategory], tags: list[RecipeTag] self, categories: list[RecipeCategory], tags: list[RecipeTag]
@ -300,33 +298,27 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
# - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy # - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy
filters = self._category_tag_filters(categories, tags) # type: ignore filters = self._category_tag_filters(categories, tags) # type: ignore
stmt = (
return [ select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific
self.schema.from_orm(x) )
for x in self.session.query(RecipeModel) return [self.schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()]
.filter(and_(*filters))
.order_by(func.random()) # Postgres and SQLite specific
.limit(1)
]
def get_random(self, limit=1) -> list[Recipe]: def get_random(self, limit=1) -> list[Recipe]:
return [ stmt = (
self.schema.from_orm(x) select(RecipeModel)
for x in self.session.query(RecipeModel)
.filter(RecipeModel.group_id == self.group_id) .filter(RecipeModel.group_id == self.group_id)
.order_by(func.random()) # Postgres and SQLite specific .order_by(func.random()) # Postgres and SQLite specific
.limit(limit) .limit(limit)
] )
return [self.schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()]
def get_by_slug(self, group_id: UUID4, slug: str, limit=1) -> Recipe | None: def get_by_slug(self, group_id: UUID4, slug: str, limit=1) -> Recipe | None:
dbrecipe = ( stmt = select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
self.session.query(RecipeModel) dbrecipe = self.session.execute(stmt).scalars().one_or_none()
.filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
.one_or_none()
)
if dbrecipe is None: if dbrecipe is None:
return None return None
return self.schema.from_orm(dbrecipe) return self.schema.from_orm(dbrecipe)
def all_ids(self, group_id: UUID4) -> list[UUID4]: def all_ids(self, group_id: UUID4) -> Sequence[UUID4]:
return [tpl[0] for tpl in self.session.query(RecipeModel.id).filter(RecipeModel.group_id == group_id).all()] stmt = select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
return self.session.execute(stmt).scalars().all()

View File

@ -1,4 +1,5 @@
from pydantic import UUID4 from pydantic import UUID4
from sqlalchemy import select
from mealie.db.models.recipe.ingredient import IngredientUnitModel from mealie.db.models.recipe.ingredient import IngredientUnitModel
from mealie.schema.recipe.recipe_ingredient import IngredientUnit from mealie.schema.recipe.recipe_ingredient import IngredientUnit
@ -7,15 +8,13 @@ from .repository_generic import RepositoryGeneric
class RepositoryUnit(RepositoryGeneric[IngredientUnit, IngredientUnitModel]): class RepositoryUnit(RepositoryGeneric[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()
def merge(self, from_unit: UUID4, to_unit: UUID4) -> IngredientUnit | None: def merge(self, from_unit: UUID4, to_unit: UUID4) -> IngredientUnit | None:
from_model = self._get_unit(from_unit)
from_model: IngredientUnitModel = ( to_model = self._get_unit(to_unit)
self.session.query(self.model).filter_by(**self._filter_builder(**{"id": from_unit})).one()
)
to_model: IngredientUnitModel = (
self.session.query(self.model).filter_by(**self._filter_builder(**{"id": to_unit})).one()
)
to_model.ingredients += from_model.ingredients to_model.ingredients += from_model.ingredients
@ -29,4 +28,4 @@ class RepositoryUnit(RepositoryGeneric[IngredientUnit, IngredientUnitModel]):
return self.get_one(to_unit) return self.get_one(to_unit)
def by_group(self, group_id: UUID4) -> "RepositoryUnit": def by_group(self, group_id: UUID4) -> "RepositoryUnit":
return super().by_group(group_id) # type: ignore return super().by_group(group_id)

View File

@ -2,6 +2,7 @@ import random
import shutil import shutil
from pydantic import UUID4 from pydantic import UUID4
from sqlalchemy import select
from mealie.assets import users as users_assets from mealie.assets import users as users_assets
from mealie.schema.user.user import PrivateUser, User from mealie.schema.user.user import PrivateUser, User
@ -35,12 +36,14 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
entry = super().delete(value, match_key) entry = super().delete(value, match_key)
# Delete the user's directory # Delete the user's directory
shutil.rmtree(PrivateUser.get_directory(value)) shutil.rmtree(PrivateUser.get_directory(value))
return entry # type: ignore return entry
def get_by_username(self, username: str) -> PrivateUser | None: def get_by_username(self, username: str) -> PrivateUser | None:
dbuser = self.session.query(User).filter(User.username == username).one_or_none() stmt = select(User).filter(User.username == username)
dbuser = self.session.execute(stmt).scalars().one_or_none()
return None if dbuser is None else self.schema.from_orm(dbuser) return None if dbuser is None else self.schema.from_orm(dbuser)
def get_locked_users(self) -> list[PrivateUser]: def get_locked_users(self) -> list[PrivateUser]:
results = self.session.query(User).filter(User.locked_at != None).all() # noqa E711 stmt = select(User).filter(User.locked_at != None) # noqa E711
results = self.session.execute(stmt).scalars().all()
return [self.schema.from_orm(x) for x in results] return [self.schema.from_orm(x) for x in results]

View File

@ -37,7 +37,6 @@ class HttpRepo(Generic[C, R, U]):
exception_msgs: Callable[[type[Exception]], str] | None = None, exception_msgs: Callable[[type[Exception]], str] | None = None,
default_message: str | None = None, default_message: str | None = None,
) -> None: ) -> None:
self.repo = repo self.repo = repo
self.logger = logger self.logger = logger
self.exception_msgs = exception_msgs self.exception_msgs = exception_msgs

View File

@ -39,7 +39,6 @@ class AdminAboutController(BaseAdminController):
@router.get("/statistics", response_model=AppStatistics) @router.get("/statistics", response_model=AppStatistics)
def get_app_statistics(self): def get_app_statistics(self):
return AppStatistics( return AppStatistics(
total_recipes=self.repos.recipes.count_all(), total_recipes=self.repos.recipes.count_all(),
uncategorized_recipes=self.repos.recipes.count_uncategorized(), # type: ignore uncategorized_recipes=self.repos.recipes.count_uncategorized(), # type: ignore

View File

@ -85,7 +85,6 @@ class AdminMaintenanceController(BaseAdminController):
@router.get("/logs", response_model=MaintenanceLogs) @router.get("/logs", response_model=MaintenanceLogs)
def get_logs(self, lines: int = 200): def get_logs(self, lines: int = 200):
return MaintenanceLogs(logs=tail_log(LOGGER_FILE, lines)) return MaintenanceLogs(logs=tail_log(LOGGER_FILE, lines))
@router.get("/storage", response_model=MaintenanceStorageDetails) @router.get("/storage", response_model=MaintenanceStorageDetails)

View File

@ -49,7 +49,6 @@ class MealieAuthToken(BaseModel):
@public_router.post("/token") @public_router.post("/token")
def get_token(data: CustomOAuth2Form = Depends(), session: Session = Depends(generate_session)): def get_token(data: CustomOAuth2Form = Depends(), session: Session = Depends(generate_session)):
email = data.username email = data.username
password = data.password password = data.password

View File

@ -8,8 +8,7 @@ from typing import Any, TypeVar, cast
from dateutil import parser as date_parser from dateutil import parser as date_parser
from dateutil.parser import ParserError from dateutil.parser import ParserError
from humps import decamelize from humps import decamelize
from sqlalchemy import bindparam, text from sqlalchemy import Select, bindparam, text
from sqlalchemy.orm.query import Query
from sqlalchemy.sql import sqltypes from sqlalchemy.sql import sqltypes
from sqlalchemy.sql.expression import BindParameter from sqlalchemy.sql.expression import BindParameter
@ -72,7 +71,7 @@ class QueryFilter:
return f"<<{joined}>>" return f"<<{joined}>>"
def filter_query(self, query: Query, model: type[Model]) -> Query: def filter_query(self, query: Select, model: type[Model]) -> Select:
segments: list[str] = [] segments: list[str] = []
params: list[BindParameter] = [] params: list[BindParameter] = []
for i, component in enumerate(self.filter_components): for i, component in enumerate(self.filter_components):

View File

@ -4,7 +4,7 @@ from pathlib import Path
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import MetaData, create_engine from sqlalchemy import MetaData, create_engine, insert, text
from sqlalchemy.engine import base from sqlalchemy.engine import base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@ -85,41 +85,48 @@ class AlchemyExporter(BaseService):
Returns the schema of the SQLAlchemy database as a python dictionary. This dictionary is wrapped by Returns the schema of the SQLAlchemy database as a python dictionary. This dictionary is wrapped by
jsonable_encoder to ensure that the object can be converted to a json string. jsonable_encoder to ensure that the object can be converted to a json string.
""" """
self.meta.reflect(bind=self.engine) with self.engine.connect() as connection:
self.meta.reflect(bind=self.engine)
all_tables = self.meta.tables.values() all_tables = self.meta.tables.values()
results = { results = {
**{table.name: [] for table in all_tables}, **{table.name: [] for table in all_tables},
"alembic_version": [dict(row) for row in self.engine.execute("SELECT * FROM alembic_version").fetchall()], "alembic_version": [
} dict(row) for row in connection.execute(text("SELECT * FROM alembic_version")).mappings()
],
}
return jsonable_encoder(results) return jsonable_encoder(results)
def dump(self) -> dict[str, list[dict]]: def dump(self) -> dict[str, list[dict]]:
""" """
Returns the entire SQLAlchemy database as a python dictionary. This dictionary is wrapped by Returns the entire SQLAlchemy database as a python dictionary. This dictionary is wrapped by
jsonable_encoder to ensure that the object can be converted to a json string. jsonable_encoder to ensure that the object can be converted to a json string.
""" """
self.meta.reflect(bind=self.engine) # http://docs.sqlalchemy.org/en/rel_0_9/core/reflection.html with self.engine.connect() as connection:
result = { self.meta.reflect(bind=self.engine) # http://docs.sqlalchemy.org/en/rel_0_9/core/reflection.html
table.name: [dict(row) for row in self.engine.execute(table.select())] for table in self.meta.sorted_tables
} result = {
table.name: [dict(row) for row in connection.execute(table.select()).mappings()]
for table in self.meta.sorted_tables
}
return jsonable_encoder(result) return jsonable_encoder(result)
def restore(self, db_dump: dict) -> None: def restore(self, db_dump: dict) -> None:
"""Restores all data from dictionary into the database""" """Restores all data from dictionary into the database"""
data = AlchemyExporter.convert_to_datetime(db_dump) with self.engine.begin() as connection:
data = AlchemyExporter.convert_to_datetime(db_dump)
self.meta.reflect(bind=self.engine) self.meta.reflect(bind=self.engine)
for table_name, rows in data.items(): for table_name, rows in data.items():
if not rows: if not rows:
continue continue
table = self.meta.tables[table_name] table = self.meta.tables[table_name]
self.engine.execute(table.delete()) connection.execute(table.delete())
self.engine.execute(table.insert(), rows) connection.execute(insert(table), rows)
def drop_all(self) -> None: def drop_all(self) -> None:
"""Drops all data from the database""" """Drops all data from the database"""
@ -129,11 +136,11 @@ class AlchemyExporter(BaseService):
try: try:
if is_postgres: if is_postgres:
session.execute("SET session_replication_role = 'replica'") session.execute(text("SET session_replication_role = 'replica'"))
for table in self.meta.sorted_tables: for table in self.meta.sorted_tables:
session.execute(f"DELETE FROM {table.name}") session.execute(text(f"DELETE FROM {table.name}"))
finally: finally:
if is_postgres: if is_postgres:
session.execute("SET session_replication_role = 'origin'") session.execute(text("SET session_replication_role = 'origin'"))
session.commit() session.commit()

View File

@ -69,7 +69,6 @@ class DefaultEmailSender(ABCEmailSender, BaseService):
""" """
def send(self, email_to: str, subject: str, html: str) -> bool: def send(self, email_to: str, subject: str, html: str) -> bool:
if self.settings.SMTP_FROM_EMAIL is None or self.settings.SMTP_FROM_NAME is None: if self.settings.SMTP_FROM_EMAIL is None or self.settings.SMTP_FROM_NAME is None:
raise ValueError("SMTP_FROM_EMAIL and SMTP_FROM_NAME must be set in the config file.") raise ValueError("SMTP_FROM_EMAIL and SMTP_FROM_NAME must be set in the config file.")

View File

@ -8,6 +8,7 @@ from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from pydantic import UUID4 from pydantic import UUID4
from sqlalchemy import select
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from mealie.db.db_setup import session_context from mealie.db.db_setup import session_context
@ -143,12 +144,9 @@ class WebhookEventListener(EventListenerBase):
def get_scheduled_webhooks(self, start_dt: datetime, end_dt: datetime) -> list[ReadWebhook]: def get_scheduled_webhooks(self, start_dt: datetime, end_dt: datetime) -> list[ReadWebhook]:
"""Fetches all scheduled webhooks from the database""" """Fetches all scheduled webhooks from the database"""
with self.ensure_session() as session: with self.ensure_session() as session:
return ( stmt = select(GroupWebhooksModel).where(
session.query(GroupWebhooksModel) GroupWebhooksModel.enabled == True, # noqa: E712 - required for SQLAlchemy comparison
.where( GroupWebhooksModel.scheduled_time > start_dt.astimezone(timezone.utc).time(),
GroupWebhooksModel.enabled == True, # noqa: E712 - required for SQLAlchemy comparison GroupWebhooksModel.scheduled_time <= end_dt.astimezone(timezone.utc).time(),
GroupWebhooksModel.scheduled_time > start_dt.astimezone(timezone.utc).time(),
GroupWebhooksModel.scheduled_time <= end_dt.astimezone(timezone.utc).time(),
)
.all()
) )
return session.execute(stmt).scalars().all()

View File

@ -67,7 +67,6 @@ class BaseMigrator(BaseService):
self.report_id = self.report.id self.report_id = self.report.id
def _save_all_entries(self) -> None: def _save_all_entries(self) -> None:
is_success = True is_success = True
is_failure = True is_failure = True

View File

@ -17,7 +17,6 @@ replace_abbreviations = {
def replace_common_abbreviations(string: str) -> str: def replace_common_abbreviations(string: str) -> str:
for k, v in replace_abbreviations.items(): for k, v in replace_abbreviations.items():
regex = rf"(?<=\d)\s?({k}\bs?)" regex = rf"(?<=\d)\s?({k}\bs?)"
string = re.sub(regex, v, string) string = re.sub(regex, v, string)

View File

@ -180,7 +180,6 @@ def import_data(lines):
# otherwise it's a token # otherwise it's a token
# e.g.: potato \t I2 \t L5 \t NoCAP \t B-NAME/0.978253 # e.g.: potato \t I2 \t L5 \t NoCAP \t B-NAME/0.978253
else: else:
columns = re.split("\t", line.strip()) columns = re.split("\t", line.strip())
token = columns[0].strip() token = columns[0].strip()

View File

@ -33,7 +33,6 @@ async def largest_content_len(urls: list[str]) -> tuple[str, int]:
tasks = [do(client, url) for url in urls] tasks = [do(client, url) for url in urls]
responses: list[Response] = await gather_with_concurrency(10, *tasks) responses: list[Response] = await gather_with_concurrency(10, *tasks)
for response in responses: for response in responses:
len_int = int(response.headers.get("Content-Length", 0)) len_int = int(response.headers.get("Content-Length", 0))
if len_int > largest_len: if len_int > largest_len:
largest_url = str(response.url) largest_url = str(response.url)

View File

@ -108,7 +108,6 @@ class RecipeService(BaseService):
return Recipe(**additional_attrs) return Recipe(**additional_attrs)
def create_one(self, create_data: Recipe | CreateRecipe) -> Recipe: def create_one(self, create_data: Recipe | CreateRecipe) -> Recipe:
if create_data.name is None: if create_data.name is None:
create_data.name = "New Recipe" create_data.name = "New Recipe"

View File

@ -1,6 +1,8 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from sqlalchemy import select
from mealie.core import root_logger from mealie.core import root_logger
from mealie.core.config import get_app_dirs from mealie.core.config import get_app_dirs
from mealie.db.db_setup import session_context from mealie.db.db_setup import session_context
@ -17,7 +19,8 @@ def purge_group_data_exports(max_minutes_old=ONE_DAY_AS_MINUTES):
limit = datetime.datetime.now() - datetime.timedelta(minutes=max_minutes_old) limit = datetime.datetime.now() - datetime.timedelta(minutes=max_minutes_old)
with session_context() as session: with session_context() as session:
results = session.query(GroupDataExportsModel).filter(GroupDataExportsModel.expires <= limit) stmt = select(GroupDataExportsModel).filter(GroupDataExportsModel.expires <= limit)
results = session.execute(stmt).scalars().all()
total_removed = 0 total_removed = 0
for result in results: for result in results:

View File

@ -1,5 +1,7 @@
import datetime import datetime
from sqlalchemy import delete
from mealie.core import root_logger from mealie.core import root_logger
from mealie.db.db_setup import session_context from mealie.db.db_setup import session_context
from mealie.db.models.users.password_reset import PasswordResetModel from mealie.db.models.users.password_reset import PasswordResetModel
@ -15,7 +17,8 @@ def purge_password_reset_tokens():
limit = datetime.datetime.now() - datetime.timedelta(days=MAX_DAYS_OLD) limit = datetime.datetime.now() - datetime.timedelta(days=MAX_DAYS_OLD)
with session_context() as session: with session_context() as session:
session.query(PasswordResetModel).filter(PasswordResetModel.created_at <= limit).delete() stmt = delete(PasswordResetModel).filter(PasswordResetModel.created_at <= limit)
session.execute(stmt)
session.commit() session.commit()
session.close() session.close()
logger.info("password reset tokens purged") logger.info("password reset tokens purged")

View File

@ -1,5 +1,7 @@
import datetime import datetime
from sqlalchemy import delete
from mealie.core import root_logger from mealie.core import root_logger
from mealie.db.db_setup import session_context from mealie.db.db_setup import session_context
from mealie.db.models.group import GroupInviteToken from mealie.db.models.group import GroupInviteToken
@ -15,7 +17,8 @@ def purge_group_registration():
limit = datetime.datetime.now() - datetime.timedelta(days=MAX_DAYS_OLD) limit = datetime.datetime.now() - datetime.timedelta(days=MAX_DAYS_OLD)
with session_context() as session: with session_context() as session:
session.query(GroupInviteToken).filter(GroupInviteToken.created_at <= limit).delete() stmt = delete(GroupInviteToken).filter(GroupInviteToken.created_at <= limit)
session.execute(stmt)
session.commit() session.commit()
session.close() session.close()

View File

@ -83,7 +83,6 @@ class RecipeBulkScraperService(BaseService):
tasks = [_do(b.url) for b in urls.imports] tasks = [_do(b.url) for b in urls.imports]
results = await gather(*tasks) results = await gather(*tasks)
for b, recipe in zip(urls.imports, results, strict=True): for b, recipe in zip(urls.imports, results, strict=True):
if not recipe: if not recipe:
continue continue

1825
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@ start = "mealie.app:main"
Jinja2 = "^3.1.2" Jinja2 = "^3.1.2"
Pillow = "^9.2.0" Pillow = "^9.2.0"
PyYAML = "^5.3.1" PyYAML = "^5.3.1"
SQLAlchemy = "^1.4.29" SQLAlchemy = "^2"
aiofiles = "^22.1.0" aiofiles = "^22.1.0"
alembic = "^1.7.5" alembic = "^1.7.5"
aniso8601 = "9.0.1" aniso8601 = "9.0.1"
@ -43,12 +43,11 @@ tzdata = "^2022.7"
uvicorn = {extras = ["standard"], version = "^0.20.0"} uvicorn = {extras = ["standard"], version = "^0.20.0"}
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^21.12b0" black = "^23.1.0"
coverage = "^7.0" coverage = "^7.0"
coveragepy-lcov = "^0.1.1" coveragepy-lcov = "^0.1.1"
mkdocs-material = "^9.0.0" mkdocs-material = "^9.0.0"
mypy = "^0.991" mypy = "^0.991"
openapi-spec-validator = "^0.5.0"
pre-commit = "^3.0.4" pre-commit = "^3.0.4"
pydantic-to-typescript = "^1.0.7" pydantic-to-typescript = "^1.0.7"
pylint = "^2.6.0" pylint = "^2.6.0"

View File

@ -30,7 +30,6 @@ def override_get_db():
@fixture(scope="session") @fixture(scope="session")
def api_client(): def api_client():
app.dependency_overrides[generate_session] = override_get_db app.dependency_overrides[generate_session] = override_get_db
yield TestClient(app) yield TestClient(app)

View File

@ -25,7 +25,6 @@ def create_item(list_id: UUID4) -> dict:
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def shopping_lists(database: AllRepositories, unique_user: TestUser): def shopping_lists(database: AllRepositories, unique_user: TestUser):
models: list[ShoppingListOut] = [] models: list[ShoppingListOut] = []
for _ in range(3): for _ in range(3):
@ -46,7 +45,6 @@ def shopping_lists(database: AllRepositories, unique_user: TestUser):
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def shopping_list(database: AllRepositories, unique_user: TestUser): def shopping_list(database: AllRepositories, unique_user: TestUser):
model = database.group_shopping_lists.create( model = database.group_shopping_lists.create(
ShoppingListSave(name=random_string(10), group_id=unique_user.group_id), ShoppingListSave(name=random_string(10), group_id=unique_user.group_id),
) )

View File

@ -60,7 +60,6 @@ def test_group_invitation_link(api_client: TestClient, unique_user: TestUser, in
def test_group_invitation_delete_after_uses(api_client: TestClient, invite: str) -> None: def test_group_invitation_delete_after_uses(api_client: TestClient, invite: str) -> None:
# Register First User # Register First User
_, r = register_user(api_client, invite) _, r = register_user(api_client, invite)
assert r.status_code == 201 assert r.status_code == 201

View File

@ -85,7 +85,6 @@ def test_crud_mealplan(api_client: TestClient, unique_user: TestUser):
def test_get_all_mealplans(api_client: TestClient, unique_user: TestUser): def test_get_all_mealplans(api_client: TestClient, unique_user: TestUser):
for _ in range(3): for _ in range(3):
new_plan = CreatePlanEntry( new_plan = CreatePlanEntry(
date=date.today(), date=date.today(),

View File

@ -94,7 +94,6 @@ def test_shopping_list_items_get_one(
unique_user: TestUser, unique_user: TestUser,
list_with_items: ShoppingListOut, list_with_items: ShoppingListOut,
) -> None: ) -> None:
for _ in range(3): for _ in range(3):
item = random.choice(list_with_items.list_items) item = random.choice(list_with_items.list_items)

View File

@ -19,7 +19,6 @@ from tests.utils.fixture_schemas import TestUser
def ten_slugs( def ten_slugs(
api_client: TestClient, unique_user: TestUser, database: AllRepositories api_client: TestClient, unique_user: TestUser, database: AllRepositories
) -> Generator[list[str], None, None]: ) -> Generator[list[str], None, None]:
slugs: list[str] = [] slugs: list[str] = []
for _ in range(10): for _ in range(10):
@ -98,7 +97,6 @@ def test_bulk_delete_recipes(
database: AllRepositories, database: AllRepositories,
ten_slugs: list[str], ten_slugs: list[str],
): ):
payload = {"recipes": ten_slugs} payload = {"recipes": ten_slugs}
response = api_client.post(api_routes.recipes_bulk_actions_delete, json=payload, headers=unique_user.token) response = api_client.post(api_routes.recipes_bulk_actions_delete, json=payload, headers=unique_user.token)

View File

@ -13,7 +13,6 @@ from tests.utils.fixture_schemas import TestUser
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def slug(api_client: TestClient, unique_user: TestUser, database: AllRepositories) -> Generator[str, None, None]: def slug(api_client: TestClient, unique_user: TestUser, database: AllRepositories) -> Generator[str, None, None]:
payload = {"name": random_string(length=20)} payload = {"name": random_string(length=20)}
response = api_client.post(api_routes.recipes, json=payload, headers=unique_user.token) response = api_client.post(api_routes.recipes, json=payload, headers=unique_user.token)
assert response.status_code == 201 assert response.status_code == 201

View File

@ -25,13 +25,10 @@ def test_superuser_login(api_client: TestClient, admin_token):
response = api_client.post(api_routes.auth_token, data=form_data) response = api_client.post(api_routes.auth_token, data=form_data)
assert response.status_code == 200 assert response.status_code == 200
new_token = json.loads(response.text).get("access_token")
response = api_client.get(api_routes.users_self, headers=admin_token) response = api_client.get(api_routes.users_self, headers=admin_token)
assert response.status_code == 200 assert response.status_code == 200
return {"Authorization": f"Bearer {new_token}"}
def test_user_token_refresh(api_client: TestClient, admin_user: TestUser): def test_user_token_refresh(api_client: TestClient, admin_user: TestUser):
response = api_client.post(api_routes.auth_refresh, headers=admin_user.token) response = api_client.post(api_routes.auth_refresh, headers=admin_user.token)

View File

@ -113,7 +113,6 @@ def test_recipe_repo_get_by_categories_multi(database: AllRepositories, unique_u
by_category = repo.get_by_categories(cast(list[RecipeCategory], created_categories)) by_category = repo.get_by_categories(cast(list[RecipeCategory], created_categories))
assert len(by_category) == 10 assert len(by_category) == 10
for recipe_summary in by_category: for recipe_summary in by_category:
for recipe_category in recipe_summary.recipe_category: for recipe_category in recipe_summary.recipe_category:
assert recipe_category.id in known_category_ids assert recipe_category.id in known_category_ids

View File

@ -25,7 +25,6 @@ def test_camelize_variables():
def test_cast_to(): def test_cast_to():
model = TestModel(long_name="Hello", long_int=1, long_float=1.1) model = TestModel(long_name="Hello", long_int=1, long_float=1.1)
model2 = model.cast(TestModel2, another_str="World") model2 = model.cast(TestModel2, another_str="World")
@ -37,7 +36,6 @@ def test_cast_to():
def test_map_to(): def test_map_to():
model = TestModel(long_name="Model1", long_int=100, long_float=1.5) model = TestModel(long_name="Model1", long_int=100, long_float=1.5)
model2 = TestModel2(long_name="Model2", long_int=1, long_float=1.1, another_str="World") model2 = TestModel2(long_name="Model2", long_int=1, long_float=1.1, another_str="World")

View File

@ -60,7 +60,6 @@ def test_get_scheduled_webhooks_filter_query(database: AllRepositories, unique_u
assert result.enabled assert result.enabled
for expected_item in expected: for expected_item in expected:
if result.name == expected_item.name: # Names are uniquely generated so we can use this to compare if result.name == expected_item.name: # Names are uniquely generated so we can use this to compare
assert result.enabled == expected_item.enabled assert result.enabled == expected_item.enabled
break break

View File

@ -256,7 +256,6 @@ ingredients_test_cases = (
@pytest.mark.parametrize("ingredients", ingredients_test_cases, ids=(x.test_id for x in ingredients_test_cases)) @pytest.mark.parametrize("ingredients", ingredients_test_cases, ids=(x.test_id for x in ingredients_test_cases))
def test_cleaner_clean_ingredients(ingredients: CleanerCase): def test_cleaner_clean_ingredients(ingredients: CleanerCase):
if ingredients.exception: if ingredients.exception:
with pytest.raises(ingredients.exception): with pytest.raises(ingredients.exception):
cleaner.clean_ingredients(ingredients.input) cleaner.clean_ingredients(ingredients.input)

View File

@ -11,7 +11,6 @@ SUBJECTS = {"Mealie Forgot Password", "Invitation to join Mealie", "Test Email"}
class TestEmailSender(ABCEmailSender): class TestEmailSender(ABCEmailSender):
def send(self, email_to: str, subject: str, html: str) -> bool: def send(self, email_to: str, subject: str, html: str) -> bool:
# check email_to: # check email_to:
assert email_to == FAKE_ADDRESS assert email_to == FAKE_ADDRESS

View File

@ -18,7 +18,6 @@ class TestIngredient:
def crf_exists() -> bool: def crf_exists() -> bool:
return shutil.which("crf_test") is not None return shutil.which("crf_test") is not None

View File

@ -1,8 +0,0 @@
from openapi_spec_validator import openapi_v30_spec_validator, validate_spec
from mealie.app import app
def test_validate_open_api_spec():
open_api = app.openapi()
validate_spec(open_api, validator=openapi_v30_spec_validator)

View File

@ -9,9 +9,9 @@ from mealie.pkgs.stats.fs_stats import pretty_size
(0, "0 bytes"), (0, "0 bytes"),
(1, "1 bytes"), (1, "1 bytes"),
(1024, "1.0 KB"), (1024, "1.0 KB"),
(1024 ** 2, "1.0 MB"), (1024**2, "1.0 MB"),
(1024 ** 2 * 1024, "1.0 GB"), (1024**2 * 1024, "1.0 GB"),
(1024 ** 2 * 1024 * 1024, "1.0 TB"), (1024**2 * 1024 * 1024, "1.0 TB"),
], ],
) )
def test_pretty_size(size: int, expected: str) -> None: def test_pretty_size(size: int, expected: str) -> None:

View File

@ -2,5 +2,4 @@ from fastapi.encoders import jsonable_encoder
def jsonify(data): def jsonify(data):
return jsonable_encoder(data) return jsonable_encoder(data)