1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-04 04:55:21 +02:00

fix: Strip Timezone from Timestamps in DB (#4310)

Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
Michael Genson 2024-10-06 01:30:30 -05:00 committed by GitHub
parent b5c0104aba
commit b0ed242ff2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 64 additions and 21 deletions

View file

@ -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

View file

@ -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]:

View file

@ -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

View file

@ -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"

View file

@ -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(

View file

@ -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__(

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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