fix: Make Mealie Timezone-Aware (#3847)

Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
This commit is contained in:
Michael Genson
2024-07-08 16:12:20 -05:00
committed by GitHub
parent 17f9eef551
commit d5f7a883df
69 changed files with 250 additions and 176 deletions

View File

@@ -39,7 +39,7 @@ iso8601_duration_re = re.compile(
r"$"
)
EPOCH = datetime(1970, 1, 1)
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
# if greater than this, the number is in ms, if less than or equal it's in seconds
# (in seconds this is 11th October 2603, in ms it's 20th August 1970)
MS_WATERSHED = int(2e10)
@@ -209,7 +209,7 @@ def parse_datetime(value: datetime | str | bytes | int | float) -> datetime:
kw_["tzinfo"] = tzinfo
try:
return datetime(**kw_) # type: ignore
return datetime(**kw_) # type: ignore # noqa DTZ001
except ValueError as e:
raise DateTimeError() from e

View File

@@ -1,19 +1,24 @@
from __future__ import annotations
import re
from collections.abc import Sequence
from datetime import datetime, timezone
from enum import Enum
from typing import ClassVar, Protocol, TypeVar
from humps.main import camelize
from pydantic import UUID4, BaseModel, ConfigDict
from pydantic import UUID4, BaseModel, ConfigDict, model_validator
from sqlalchemy import Select, desc, func, or_, text
from sqlalchemy.orm import InstrumentedAttribute, Session
from sqlalchemy.orm.interfaces import LoaderOption
from typing_extensions import Self
from mealie.db.models._model_base import SqlAlchemyBase
T = TypeVar("T", bound=BaseModel)
HOUR_ONLY_TZ_PATTERN = re.compile(r"[+-]\d{2}$")
class SearchType(Enum):
fuzzy = "fuzzy"
@@ -30,6 +35,43 @@ class MealieModel(BaseModel):
"""
model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
@model_validator(mode="before")
@classmethod
def fix_hour_only_tz(cls, data: T) -> T:
"""
Fixes datetimes with timezones that only have the hour portion.
Pydantic assumes timezones are in the format +HH:MM, but postgres returns +HH.
https://github.com/pydantic/pydantic/issues/8609
"""
for field, field_info in cls.model_fields.items():
if field_info.annotation != datetime:
continue
try:
if not isinstance(val := getattr(data, field), str):
continue
except AttributeError:
continue
if re.search(HOUR_ONLY_TZ_PATTERN, val):
setattr(data, field, val + ":00")
return data
@model_validator(mode="after")
def set_tz_info(self) -> Self:
"""
Adds UTC timezone information to all datetimes in the model.
The server stores everything in UTC without timezone info.
"""
for field in self.model_fields:
val = getattr(self, field)
if not isinstance(val, datetime):
continue
if not val.tzinfo:
setattr(self, field, val.replace(tzinfo=timezone.utc))
return self
def cast(self, cls: type[T], **kwargs) -> T:
"""
Cast the current model to another with additional arguments. Useful for

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pydantic import UUID4, ConfigDict, Field
from sqlalchemy.orm import selectinload
@@ -11,7 +11,7 @@ from .recipe import Recipe
def defaut_expires_at_time() -> datetime:
return datetime.utcnow() + timedelta(days=30)
return datetime.now(timezone.utc) + timedelta(days=30)
class RecipeShareTokenCreate(MealieModel):

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Annotated
@@ -35,7 +35,7 @@ class RecipeTimelineEventIn(MealieModel):
message: str | None = Field(None, alias="eventMessage")
image: Annotated[TimelineEventImage | None, Field(validate_default=True)] = TimelineEventImage.does_not_have_image
timestamp: datetime = datetime.now()
timestamp: datetime = datetime.now(timezone.utc)
model_config = ConfigDict(use_enum_values=True)

View File

@@ -6,6 +6,7 @@ from pydantic.types import UUID4
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models._model_utils.datetime import get_utc_now
from mealie.db.models.group import ReportModel
from mealie.schema._mealie import MealieModel
@@ -26,7 +27,7 @@ class ReportSummaryStatus(str, enum.Enum):
class ReportEntryCreate(MealieModel):
report_id: UUID4
timestamp: datetime.datetime = Field(default_factory=datetime.datetime.utcnow)
timestamp: datetime.datetime = Field(default_factory=get_utc_now)
success: bool = True
message: str
exception: str = ""
@@ -38,7 +39,7 @@ class ReportEntryOut(ReportEntryCreate):
class ReportCreate(MealieModel):
timestamp: datetime.datetime = Field(default_factory=datetime.datetime.utcnow)
timestamp: datetime.datetime = Field(default_factory=get_utc_now)
category: ReportCategory
group_id: UUID4
name: str

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Annotated, Any, Generic, TypeVar
from uuid import UUID
@@ -186,7 +186,7 @@ class PrivateUser(UserOut):
return False
lockout_expires_at = self.locked_at + timedelta(hours=get_app_settings().SECURITY_USER_LOCKOUT_TIME)
return lockout_expires_at > datetime.now()
return lockout_expires_at > datetime.now(timezone.utc)
def directory(self) -> Path:
return PrivateUser.get_directory(self.id)