mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-04 13:05: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:
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
|
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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue