Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Strip Timezone from Timestamps in DB #4310

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mealie/db/migration_types.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions mealie/db/models/_model_base.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand Down
35 changes: 35 additions & 0 deletions mealie/db/models/_model_utils/datetime.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from datetime import datetime, timezone

from sqlalchemy.types import DateTime, TypeDecorator


def get_utc_now():
"""
Expand All @@ -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
8 changes: 4 additions & 4 deletions mealie/db/models/group/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
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:
Expand All @@ -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")
Expand All @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions mealie/db/models/recipe/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
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
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions mealie/db/models/recipe/recipe_timeline.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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__(
Expand Down
3 changes: 2 additions & 1 deletion mealie/db/models/recipe/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

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:
Expand All @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions mealie/db/models/server/task.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

Expand Down
5 changes: 3 additions & 2 deletions mealie/db/models/users/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion mealie/schema/response/query_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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)
Expand Down Expand Up @@ -177,7 +178,7 @@ def validate(self, model_attr_type: Any) -> Any:
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
Expand Down
5 changes: 3 additions & 2 deletions mealie/services/scheduler/tasks/purge_group_exports.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Loading