diff --git a/mealie/db/migration_types.py b/mealie/db/migration_types.py index 7ce86b584d6..a0c78e5b7b2 100644 --- a/mealie/db/migration_types.py +++ b/mealie/db/migration_types.py @@ -1 +1,2 @@ +from mealie.db.models._model_utils.datetime import NaiveDateTime # noqa: F401 from mealie.db.models._model_utils.guid import GUID # noqa: F401 diff --git a/mealie/db/models/_model_base.py b/mealie/db/models/_model_base.py index e49bc61b9f6..fd5e68c8079 100644 --- a/mealie/db/models/_model_base.py +++ b/mealie/db/models/_model_base.py @@ -1,16 +1,16 @@ from datetime import datetime -from sqlalchemy import DateTime, Integer +from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym from text_unidecode import unidecode -from ._model_utils.datetime import get_utc_now +from ._model_utils.datetime import NaiveDateTime, get_utc_now class SqlAlchemyBase(DeclarativeBase): id: Mapped[int] = mapped_column(Integer, primary_key=True) - created_at: Mapped[datetime | None] = mapped_column(DateTime, default=get_utc_now, index=True) - update_at: Mapped[datetime | None] = mapped_column(DateTime, default=get_utc_now, onupdate=get_utc_now) + created_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True) + update_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, onupdate=get_utc_now) @declared_attr def updated_at(cls) -> Mapped[datetime | None]: diff --git a/mealie/db/models/_model_utils/datetime.py b/mealie/db/models/_model_utils/datetime.py index fbfb62dfeb2..5c24d1d526e 100644 --- a/mealie/db/models/_model_utils/datetime.py +++ b/mealie/db/models/_model_utils/datetime.py @@ -1,5 +1,7 @@ from datetime import datetime, timezone +from sqlalchemy.types import DateTime, TypeDecorator + def get_utc_now(): """ @@ -13,3 +15,36 @@ def get_utc_today(): Returns the current date in UTC. """ return datetime.now(timezone.utc).date() + + +class NaiveDateTime(TypeDecorator): + """ + Mealie uses naive date times since the app handles timezones explicitly. + All timezones are generated, stored, and retrieved as UTC. + + This class strips the timezone from a datetime object when storing it so the database (i.e. postgres) + doesn't do any timezone conversion when storing the datetime, then re-inserts UTC when retrieving it. + """ + + impl = DateTime + cache_ok = True + + def process_bind_param(self, value: datetime | None, dialect): + if value is None: + return value + + try: + if value.tzinfo is not None: + value = value.astimezone(timezone.utc) + return value.replace(tzinfo=None) + except Exception: + return value + + def process_result_value(self, value: datetime | None, dialect): + try: + if value is not None: + value = value.replace(tzinfo=timezone.utc) + except Exception: + pass + + return value diff --git a/mealie/db/models/group/report.py b/mealie/db/models/group/report.py index afde1c37a93..0847b7797ac 100644 --- a/mealie/db/models/group/report.py +++ b/mealie/db/models/group/report.py @@ -4,12 +4,12 @@ from pydantic import ConfigDict 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, String from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from .._model_utils.auto_init import auto_init -from .._model_utils.datetime import get_utc_now +from .._model_utils.datetime import NaiveDateTime, get_utc_now from .._model_utils.guid import GUID if TYPE_CHECKING: @@ -23,7 +23,7 @@ class ReportEntryModel(SqlAlchemyBase, BaseMixins): success: Mapped[bool | None] = mapped_column(Boolean, default=False) message: Mapped[str] = mapped_column(String, nullable=True) exception: Mapped[str] = mapped_column(String, nullable=True) - timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=get_utc_now) + timestamp: Mapped[datetime] = mapped_column(NaiveDateTime, nullable=False, default=get_utc_now) report_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("group_reports.id"), nullable=False, index=True) report: Mapped["ReportModel"] = orm.relationship("ReportModel", back_populates="entries") @@ -40,7 +40,7 @@ class ReportModel(SqlAlchemyBase, BaseMixins): name: Mapped[str] = mapped_column(String, nullable=False) status: Mapped[str] = mapped_column(String, nullable=False) category: Mapped[str] = mapped_column(String, index=True, nullable=False) - timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=get_utc_now) + timestamp: Mapped[datetime] = mapped_column(NaiveDateTime, nullable=False, default=get_utc_now) entries: Mapped[list[ReportEntryModel]] = orm.relationship( ReportEntryModel, back_populates="report", cascade="all, delete-orphan" diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 79feee0cec0..9266ec899f0 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -12,7 +12,7 @@ from sqlalchemy.orm.session import object_session from mealie.db.models._model_utils.auto_init import auto_init -from mealie.db.models._model_utils.datetime import get_utc_today +from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today from mealie.db.models._model_utils.guid import GUID from .._model_base import BaseMixins, SqlAlchemyBase @@ -135,8 +135,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): # Time Stamp Properties date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today) - date_updated: Mapped[datetime | None] = mapped_column(sa.DateTime) - last_made: Mapped[datetime | None] = mapped_column(sa.DateTime) + date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime) + last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime) # Shopping List Refs shopping_list_refs: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship( diff --git a/mealie/db/models/recipe/recipe_timeline.py b/mealie/db/models/recipe/recipe_timeline.py index 3972db606be..402606e50b4 100644 --- a/mealie/db/models/recipe/recipe_timeline.py +++ b/mealie/db/models/recipe/recipe_timeline.py @@ -1,10 +1,12 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING -from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy import ForeignKey, String from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy from sqlalchemy.orm import Mapped, mapped_column, relationship +from mealie.db.models._model_utils.datetime import NaiveDateTime + from .._model_base import BaseMixins, SqlAlchemyBase from .._model_utils.auto_init import auto_init from .._model_utils.guid import GUID @@ -38,7 +40,7 @@ class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins): image: Mapped[str | None] = mapped_column(String) # Timestamps - timestamp: Mapped[datetime | None] = mapped_column(DateTime, index=True) + timestamp: Mapped[datetime | None] = mapped_column(NaiveDateTime, index=True) @auto_init() def __init__( diff --git a/mealie/db/models/recipe/shared.py b/mealie/db/models/recipe/shared.py index 82edc29ee67..9d90bcc1772 100644 --- a/mealie/db/models/recipe/shared.py +++ b/mealie/db/models/recipe/shared.py @@ -7,6 +7,7 @@ from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase from mealie.db.models._model_utils.auto_init import auto_init +from mealie.db.models._model_utils.datetime import NaiveDateTime from mealie.db.models._model_utils.guid import GUID if TYPE_CHECKING: @@ -26,7 +27,7 @@ class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins): recipe_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("recipes.id"), nullable=False, index=True) recipe: Mapped["RecipeModel"] = sa.orm.relationship("RecipeModel", back_populates="share_tokens", uselist=False) - expires_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False) + expires_at: Mapped[datetime] = mapped_column(NaiveDateTime, nullable=False) @auto_init() def __init__(self, **_) -> None: diff --git a/mealie/db/models/server/task.py b/mealie/db/models/server/task.py index 3b6cb6aba3e..bd78018fd4e 100644 --- a/mealie/db/models/server/task.py +++ b/mealie/db/models/server/task.py @@ -1,10 +1,11 @@ from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import DateTime, ForeignKey, String, orm +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_utils.datetime import NaiveDateTime from mealie.db.models._model_utils.guid import GUID from .._model_utils.auto_init import auto_init @@ -18,7 +19,7 @@ class ServerTaskModel(SqlAlchemyBase, BaseMixins): __tablename__ = "server_tasks" name: Mapped[str] = mapped_column(String, nullable=False) - completed_date: Mapped[datetime] = mapped_column(DateTime, nullable=True) + completed_date: Mapped[datetime] = mapped_column(NaiveDateTime, nullable=True) status: Mapped[str] = mapped_column(String, nullable=False) log: Mapped[str] = mapped_column(String, nullable=True) diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 2659f9288ec..51dd1cbd1fd 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -3,13 +3,14 @@ from typing import TYPE_CHECKING, Optional from pydantic import ConfigDict -from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, orm, select +from sqlalchemy import Boolean, Enum, ForeignKey, Integer, String, orm, select from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, Session, mapped_column from mealie.core.config import get_app_settings from mealie.db.models._model_utils.auto_init import auto_init +from mealie.db.models._model_utils.datetime import NaiveDateTime from mealie.db.models._model_utils.guid import GUID from .._model_base import BaseMixins, SqlAlchemyBase @@ -65,7 +66,7 @@ class User(SqlAlchemyBase, BaseMixins): cache_key: Mapped[str | None] = mapped_column(String, default="1234") login_attemps: Mapped[int | None] = mapped_column(Integer, default=0) - locked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None) + locked_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=None) # Group Permissions can_manage_household: Mapped[bool | None] = mapped_column(Boolean, default=False) diff --git a/mealie/schema/response/query_filter.py b/mealie/schema/response/query_filter.py index 35dabea8764..16eb2bddc86 100644 --- a/mealie/schema/response/query_filter.py +++ b/mealie/schema/response/query_filter.py @@ -15,6 +15,7 @@ from sqlalchemy.sql import sqltypes from mealie.db.models._model_base import SqlAlchemyBase +from mealie.db.models._model_utils.datetime import NaiveDateTime from mealie.db.models._model_utils.guid import GUID Model = TypeVar("Model", bound=SqlAlchemyBase) @@ -177,7 +178,7 @@ def validate(self, model_attr_type: Any) -> Any: except ValueError as e: raise ValueError(f"invalid query string: invalid UUID '{v}'") from e - if isinstance(model_attr_type, sqltypes.Date | sqltypes.DateTime): + if isinstance(model_attr_type, sqltypes.Date | sqltypes.DateTime | NaiveDateTime): try: dt = date_parser.parse(v) sanitized_values[i] = dt.date() if isinstance(model_attr_type, sqltypes.Date) else dt diff --git a/mealie/services/scheduler/tasks/purge_group_exports.py b/mealie/services/scheduler/tasks/purge_group_exports.py index ba2835c0f03..ea69821f151 100644 --- a/mealie/services/scheduler/tasks/purge_group_exports.py +++ b/mealie/services/scheduler/tasks/purge_group_exports.py @@ -1,11 +1,12 @@ import datetime from pathlib import Path -from sqlalchemy import DateTime, cast, select +from sqlalchemy import cast, select from mealie.core import root_logger from mealie.core.config import get_app_dirs from mealie.db.db_setup import session_context +from mealie.db.models._model_utils.datetime import NaiveDateTime from mealie.db.models.group.exports import GroupDataExportsModel ONE_DAY_AS_MINUTES = 1440 @@ -19,7 +20,7 @@ def purge_group_data_exports(max_minutes_old=ONE_DAY_AS_MINUTES): limit = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=max_minutes_old) with session_context() as session: - stmt = select(GroupDataExportsModel).filter(cast(GroupDataExportsModel.expires, DateTime) <= limit) + stmt = select(GroupDataExportsModel).filter(cast(GroupDataExportsModel.expires, NaiveDateTime) <= limit) results = session.execute(stmt).scalars().all() total_removed = 0