mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-30 01:34:39 -04:00 
			
		
		
		
	fix: Strip Timezone from Timestamps in DB (#4310)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
		| @@ -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 | from mealie.db.models._model_utils.guid import GUID  # noqa: F401 | ||||||
|   | |||||||
| @@ -1,16 +1,16 @@ | |||||||
| from datetime import datetime | 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 sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column, synonym | ||||||
| from text_unidecode import unidecode | 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): | class SqlAlchemyBase(DeclarativeBase): | ||||||
|     id: Mapped[int] = mapped_column(Integer, primary_key=True) |     id: Mapped[int] = mapped_column(Integer, primary_key=True) | ||||||
|     created_at: Mapped[datetime | None] = mapped_column(DateTime, default=get_utc_now, index=True) |     created_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, index=True) | ||||||
|     update_at: Mapped[datetime | None] = mapped_column(DateTime, default=get_utc_now, onupdate=get_utc_now) |     update_at: Mapped[datetime | None] = mapped_column(NaiveDateTime, default=get_utc_now, onupdate=get_utc_now) | ||||||
|  |  | ||||||
|     @declared_attr |     @declared_attr | ||||||
|     def updated_at(cls) -> Mapped[datetime | None]: |     def updated_at(cls) -> Mapped[datetime | None]: | ||||||
|   | |||||||
| @@ -1,5 +1,7 @@ | |||||||
| from datetime import datetime, timezone | from datetime import datetime, timezone | ||||||
|  |  | ||||||
|  | from sqlalchemy.types import DateTime, TypeDecorator | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_utc_now(): | def get_utc_now(): | ||||||
|     """ |     """ | ||||||
| @@ -13,3 +15,36 @@ def get_utc_today(): | |||||||
|     Returns the current date in UTC. |     Returns the current date in UTC. | ||||||
|     """ |     """ | ||||||
|     return datetime.now(timezone.utc).date() |     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 | ||||||
|   | |||||||
| @@ -4,12 +4,12 @@ from typing import TYPE_CHECKING | |||||||
| from pydantic import ConfigDict | from pydantic import ConfigDict | ||||||
| from sqlalchemy import ForeignKey, orm | from sqlalchemy import ForeignKey, orm | ||||||
| from sqlalchemy.orm import Mapped, mapped_column | 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 mealie.db.models._model_base import BaseMixins, SqlAlchemyBase | ||||||
|  |  | ||||||
| from .._model_utils.auto_init import auto_init | 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 | from .._model_utils.guid import GUID | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | if TYPE_CHECKING: | ||||||
| @@ -23,7 +23,7 @@ class ReportEntryModel(SqlAlchemyBase, BaseMixins): | |||||||
|     success: Mapped[bool | None] = mapped_column(Boolean, default=False) |     success: Mapped[bool | None] = mapped_column(Boolean, default=False) | ||||||
|     message: Mapped[str] = mapped_column(String, nullable=True) |     message: Mapped[str] = mapped_column(String, nullable=True) | ||||||
|     exception: 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_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("group_reports.id"), nullable=False, index=True) | ||||||
|     report: Mapped["ReportModel"] = orm.relationship("ReportModel", back_populates="entries") |     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) |     name: Mapped[str] = mapped_column(String, nullable=False) | ||||||
|     status: 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) |     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( |     entries: Mapped[list[ReportEntryModel]] = orm.relationship( | ||||||
|         ReportEntryModel, back_populates="report", cascade="all, delete-orphan" |         ReportEntryModel, back_populates="report", cascade="all, delete-orphan" | ||||||
|   | |||||||
| @@ -12,7 +12,7 @@ from sqlalchemy.orm.attributes import get_history | |||||||
| from sqlalchemy.orm.session import object_session | from sqlalchemy.orm.session import object_session | ||||||
|  |  | ||||||
| from mealie.db.models._model_utils.auto_init import auto_init | from mealie.db.models._model_utils.auto_init import auto_init | ||||||
| from mealie.db.models._model_utils.datetime import 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 mealie.db.models._model_utils.guid import GUID | ||||||
|  |  | ||||||
| from .._model_base import BaseMixins, SqlAlchemyBase | from .._model_base import BaseMixins, SqlAlchemyBase | ||||||
| @@ -135,8 +135,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): | |||||||
|  |  | ||||||
|     # Time Stamp Properties |     # Time Stamp Properties | ||||||
|     date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today) |     date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today) | ||||||
|     date_updated: Mapped[datetime | None] = mapped_column(sa.DateTime) |     date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime) | ||||||
|     last_made: Mapped[datetime | None] = mapped_column(sa.DateTime) |     last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime) | ||||||
|  |  | ||||||
|     # Shopping List Refs |     # Shopping List Refs | ||||||
|     shopping_list_refs: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship( |     shopping_list_refs: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship( | ||||||
|   | |||||||
| @@ -1,10 +1,12 @@ | |||||||
| from datetime import datetime, timezone | from datetime import datetime, timezone | ||||||
| from typing import TYPE_CHECKING | 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.ext.associationproxy import AssociationProxy, association_proxy | ||||||
| from sqlalchemy.orm import Mapped, mapped_column, relationship | 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_base import BaseMixins, SqlAlchemyBase | ||||||
| from .._model_utils.auto_init import auto_init | from .._model_utils.auto_init import auto_init | ||||||
| from .._model_utils.guid import GUID | from .._model_utils.guid import GUID | ||||||
| @@ -38,7 +40,7 @@ class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins): | |||||||
|     image: Mapped[str | None] = mapped_column(String) |     image: Mapped[str | None] = mapped_column(String) | ||||||
|  |  | ||||||
|     # Timestamps |     # Timestamps | ||||||
|     timestamp: Mapped[datetime | None] = mapped_column(DateTime, index=True) |     timestamp: Mapped[datetime | None] = mapped_column(NaiveDateTime, index=True) | ||||||
|  |  | ||||||
|     @auto_init() |     @auto_init() | ||||||
|     def __init__( |     def __init__( | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ 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.auto_init import auto_init | from mealie.db.models._model_utils.auto_init import auto_init | ||||||
|  | from mealie.db.models._model_utils.datetime import NaiveDateTime | ||||||
| from mealie.db.models._model_utils.guid import GUID | from mealie.db.models._model_utils.guid import GUID | ||||||
|  |  | ||||||
| if TYPE_CHECKING: | 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_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) |     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() |     @auto_init() | ||||||
|     def __init__(self, **_) -> None: |     def __init__(self, **_) -> None: | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from typing import TYPE_CHECKING | 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 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.datetime import NaiveDateTime | ||||||
| from mealie.db.models._model_utils.guid import GUID | from mealie.db.models._model_utils.guid import GUID | ||||||
|  |  | ||||||
| from .._model_utils.auto_init import auto_init | from .._model_utils.auto_init import auto_init | ||||||
| @@ -18,7 +19,7 @@ class ServerTaskModel(SqlAlchemyBase, BaseMixins): | |||||||
|  |  | ||||||
|     __tablename__ = "server_tasks" |     __tablename__ = "server_tasks" | ||||||
|     name: Mapped[str] = mapped_column(String, nullable=False) |     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) |     status: Mapped[str] = mapped_column(String, nullable=False) | ||||||
|     log: Mapped[str] = mapped_column(String, nullable=True) |     log: Mapped[str] = mapped_column(String, nullable=True) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,13 +3,14 @@ from datetime import datetime | |||||||
| from typing import TYPE_CHECKING, Optional | from typing import TYPE_CHECKING, Optional | ||||||
|  |  | ||||||
| from pydantic import ConfigDict | 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.associationproxy import AssociationProxy, association_proxy | ||||||
| from sqlalchemy.ext.hybrid import hybrid_property | from sqlalchemy.ext.hybrid import hybrid_property | ||||||
| from sqlalchemy.orm import Mapped, Session, mapped_column | from sqlalchemy.orm import Mapped, Session, mapped_column | ||||||
|  |  | ||||||
| from mealie.core.config import get_app_settings | 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.auto_init import auto_init | ||||||
|  | from mealie.db.models._model_utils.datetime import NaiveDateTime | ||||||
| from mealie.db.models._model_utils.guid import GUID | from mealie.db.models._model_utils.guid import GUID | ||||||
|  |  | ||||||
| from .._model_base import BaseMixins, SqlAlchemyBase | from .._model_base import BaseMixins, SqlAlchemyBase | ||||||
| @@ -65,7 +66,7 @@ class User(SqlAlchemyBase, BaseMixins): | |||||||
|  |  | ||||||
|     cache_key: Mapped[str | None] = mapped_column(String, default="1234") |     cache_key: Mapped[str | None] = mapped_column(String, default="1234") | ||||||
|     login_attemps: Mapped[int | None] = mapped_column(Integer, default=0) |     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 |     # Group Permissions | ||||||
|     can_manage_household: Mapped[bool | None] = mapped_column(Boolean, default=False) |     can_manage_household: Mapped[bool | None] = mapped_column(Boolean, default=False) | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ from sqlalchemy.orm import InstrumentedAttribute, Mapper | |||||||
| from sqlalchemy.sql import sqltypes | from sqlalchemy.sql import sqltypes | ||||||
|  |  | ||||||
| from mealie.db.models._model_base import SqlAlchemyBase | 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 | from mealie.db.models._model_utils.guid import GUID | ||||||
|  |  | ||||||
| Model = TypeVar("Model", bound=SqlAlchemyBase) | Model = TypeVar("Model", bound=SqlAlchemyBase) | ||||||
| @@ -177,7 +178,7 @@ class QueryFilterComponent: | |||||||
|                 except ValueError as e: |                 except ValueError as e: | ||||||
|                     raise ValueError(f"invalid query string: invalid UUID '{v}'") from 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: |                 try: | ||||||
|                     dt = date_parser.parse(v) |                     dt = date_parser.parse(v) | ||||||
|                     sanitized_values[i] = dt.date() if isinstance(model_attr_type, sqltypes.Date) else dt |                     sanitized_values[i] = dt.date() if isinstance(model_attr_type, sqltypes.Date) else dt | ||||||
|   | |||||||
| @@ -1,11 +1,12 @@ | |||||||
| import datetime | import datetime | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
| from sqlalchemy import DateTime, cast, select | from sqlalchemy import cast, 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 | ||||||
|  | from mealie.db.models._model_utils.datetime import NaiveDateTime | ||||||
| from mealie.db.models.group.exports import GroupDataExportsModel | from mealie.db.models.group.exports import GroupDataExportsModel | ||||||
|  |  | ||||||
| ONE_DAY_AS_MINUTES = 1440 | 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) |     limit = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=max_minutes_old) | ||||||
|  |  | ||||||
|     with session_context() as session: |     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() |         results = session.execute(stmt).scalars().all() | ||||||
|  |  | ||||||
|         total_removed = 0 |         total_removed = 0 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user