mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-30 17:53:31 -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 | ||||
|   | ||||
| @@ -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]: | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -4,12 +4,12 @@ from typing import TYPE_CHECKING | ||||
| 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" | ||||
|   | ||||
| @@ -12,7 +12,7 @@ from sqlalchemy.orm.attributes import get_history | ||||
| 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( | ||||
|   | ||||
| @@ -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__( | ||||
|   | ||||
| @@ -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_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: | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -3,13 +3,14 @@ from datetime import datetime | ||||
| 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) | ||||
|   | ||||
| @@ -15,6 +15,7 @@ from sqlalchemy.orm import InstrumentedAttribute, Mapper | ||||
| 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 @@ class QueryFilterComponent: | ||||
|                 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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user