mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-02 20:15:24 +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:
parent
b5c0104aba
commit
b0ed242ff2
11 changed files with 64 additions and 21 deletions
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue