mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-23 18:55:15 -05:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user