mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-01-30 04:23:12 -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:
@@ -32,7 +32,7 @@ def get_latest_version() -> str:
|
||||
|
||||
global _LAST_RESET
|
||||
|
||||
now = datetime.datetime.now()
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
if not _LAST_RESET or now - _LAST_RESET > datetime.timedelta(days=MAX_DAYS_OLD):
|
||||
_LAST_RESET = now
|
||||
|
||||
@@ -4,11 +4,13 @@ from sqlalchemy import DateTime, Integer
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from text_unidecode import unidecode
|
||||
|
||||
from ._model_utils.datetime import get_utc_now
|
||||
|
||||
|
||||
class SqlAlchemyBase(DeclarativeBase):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.now, index=True)
|
||||
update_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def normalize(cls, val: str) -> str:
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from .auto_init import auto_init
|
||||
from .guid import GUID
|
||||
|
||||
__all__ = [
|
||||
"auto_init",
|
||||
"GUID",
|
||||
]
|
||||
|
||||
15
mealie/db/models/_model_utils/datetime.py
Normal file
15
mealie/db/models/_model_utils/datetime.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def get_utc_now():
|
||||
"""
|
||||
Returns the current time in UTC.
|
||||
"""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def get_utc_today():
|
||||
"""
|
||||
Returns the current date in UTC.
|
||||
"""
|
||||
return datetime.now(timezone.utc).date()
|
||||
@@ -4,13 +4,14 @@ from sqlalchemy import Boolean, ForeignKey, Integer, String, orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init, guid
|
||||
from .._model_utils import guid
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from ..recipe.category import Category, cookbooks_to_categories
|
||||
from ..recipe.tag import Tag, cookbooks_to_tags
|
||||
from ..recipe.tool import Tool, cookbooks_to_tools
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
from .group import Group
|
||||
|
||||
|
||||
class CookBook(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@@ -4,10 +4,11 @@ from sqlalchemy import Boolean, ForeignKey, String, orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
from .group import Group
|
||||
|
||||
|
||||
class GroupEventNotifierOptionsModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@@ -4,10 +4,11 @@ from sqlalchemy import ForeignKey, String, orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
from .group import Group
|
||||
|
||||
|
||||
class GroupDataExportsModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@@ -11,7 +11,8 @@ from mealie.core.config import get_app_settings
|
||||
from mealie.db.models.labels import MultiPurposeLabel
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
from ..group.invite_tokens import GroupInviteToken
|
||||
from ..group.webhooks import GroupWebhooksModel
|
||||
from ..recipe.category import Category, group_to_categories
|
||||
|
||||
@@ -4,10 +4,11 @@ from sqlalchemy import ForeignKey, Integer, String, orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init, guid
|
||||
from .._model_utils import guid
|
||||
from .._model_utils.auto_init import auto_init
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
from .group import Group
|
||||
|
||||
|
||||
class GroupInviteToken(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@@ -7,14 +7,14 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
from mealie.db.models.recipe.tag import Tag, plan_rules_to_tags
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
from ..recipe.category import Category, plan_rules_to_categories
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
|
||||
from ..recipe import RecipeModel
|
||||
from ..users import User
|
||||
from .group import Group
|
||||
|
||||
|
||||
class GroupMealPlanRules(BaseMixins, SqlAlchemyBase):
|
||||
|
||||
@@ -5,11 +5,11 @@ import sqlalchemy.orm as orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
from .group import Group
|
||||
|
||||
|
||||
class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@@ -4,10 +4,11 @@ from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
from .group import Group
|
||||
|
||||
|
||||
class GroupRecipeAction(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@@ -8,11 +8,12 @@ from sqlalchemy.sql.sqltypes import Boolean, DateTime, String
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.datetime import get_utc_now
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
from .group import Group
|
||||
|
||||
|
||||
class ReportEntryModel(SqlAlchemyBase, BaseMixins):
|
||||
@@ -22,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=datetime.utcnow)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime, 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")
|
||||
@@ -39,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=datetime.utcnow)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=get_utc_now)
|
||||
|
||||
entries: Mapped[list[ReportEntryModel]] = orm.relationship(
|
||||
ReportEntryModel, back_populates="report", cascade="all, delete-orphan"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from pydantic import ConfigDict
|
||||
@@ -11,7 +11,8 @@ from mealie.db.models.labels import MultiPurposeLabel
|
||||
from mealie.db.models.recipe.api_extras import ShoppingListExtras, ShoppingListItemExtras, api_extras
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -203,7 +204,7 @@ def update_shopping_lists(session: orm.Session, _):
|
||||
if not shopping_list:
|
||||
continue
|
||||
|
||||
shopping_list.update_at = datetime.now()
|
||||
shopping_list.update_at = datetime.now(timezone.utc)
|
||||
local_session.commit()
|
||||
except Exception:
|
||||
local_session.rollback()
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from datetime import datetime, time
|
||||
from datetime import datetime, time, timezone
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Time, orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
from .group import Group
|
||||
|
||||
|
||||
class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
|
||||
@@ -24,7 +25,7 @@ class GroupWebhooksModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
# New Fields
|
||||
webhook_type: Mapped[str | None] = mapped_column(String, default="") # Future use for different types of webhooks
|
||||
scheduled_time: Mapped[time | None] = mapped_column(Time, default=lambda: datetime.now().time())
|
||||
scheduled_time: Mapped[time | None] = mapped_column(Time, default=lambda: datetime.now(timezone.utc).time())
|
||||
|
||||
# Columne is no longer used but is kept for since it's super annoying to
|
||||
# delete a column in SQLite and it's not a big deal to keep it around
|
||||
|
||||
@@ -5,13 +5,13 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
|
||||
from ._model_utils import auto_init
|
||||
from ._model_utils.auto_init import auto_init
|
||||
from ._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from group import Group
|
||||
from group.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
||||
from recipe import IngredientFoodModel
|
||||
from .group.group import Group
|
||||
from .group.shopping_list import ShoppingListItem, ShoppingListMultiPurposeLabel
|
||||
from .recipe import IngredientFoodModel
|
||||
|
||||
|
||||
class MultiPurposeLabel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 import auto_init
|
||||
from mealie.db.models._model_utils.auto_init import auto_init
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -9,7 +9,7 @@ from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models.labels import MultiPurposeLabel
|
||||
from mealie.db.models.recipe.api_extras import IngredientFoodExtras, api_extras
|
||||
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy import ForeignKey, Integer, String, orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -10,10 +10,11 @@ from sqlalchemy.orm import Mapped, mapped_column, validates
|
||||
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.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from ..users.user_to_recipe import UserToRecipe
|
||||
from .api_extras import ApiExtras, api_extras
|
||||
from .assets import RecipeAsset
|
||||
@@ -125,7 +126,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
|
||||
# Time Stamp Properties
|
||||
date_added: Mapped[date | None] = mapped_column(sa.Date, default=date.today)
|
||||
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)
|
||||
|
||||
@@ -194,7 +195,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
if notes:
|
||||
self.notes = [Note(**n) for n in notes]
|
||||
|
||||
self.date_updated = datetime.now()
|
||||
self.date_updated = datetime.now(timezone.utc)
|
||||
|
||||
# SQLAlchemy events do not seem to register things that are set during auto_init
|
||||
if name is not None:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -42,4 +42,4 @@ class RecipeTimelineEvent(SqlAlchemyBase, BaseMixins):
|
||||
timestamp=None,
|
||||
**_,
|
||||
) -> None:
|
||||
self.timestamp = timestamp or datetime.now()
|
||||
self.timestamp = timestamp or datetime.now(timezone.utc)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -6,14 +6,15 @@ import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models._model_utils import GUID, auto_init
|
||||
from mealie.db.models._model_utils.auto_init import auto_init
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import RecipeModel
|
||||
|
||||
|
||||
def defaut_expires_at_time() -> datetime:
|
||||
return datetime.utcnow() + timedelta(days=30)
|
||||
return datetime.now(timezone.utc) + timedelta(days=30)
|
||||
|
||||
|
||||
class RecipeShareTokenModel(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstra
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models._model_utils import auto_init
|
||||
from mealie.db.models._model_utils.auto_init import auto_init
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_utils import auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..group import Group
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import ForeignKey, String, orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .users import User
|
||||
|
||||
@@ -4,7 +4,8 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
|
||||
class UserToRecipe(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
@@ -8,10 +8,10 @@ from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import Mapped, 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.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .user_to_recipe import UserToRecipe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import date
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -14,7 +14,7 @@ class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]):
|
||||
return super().by_group(group_id)
|
||||
|
||||
def get_today(self, group_id: UUID) -> list[ReadPlanEntry]:
|
||||
today = date.today()
|
||||
today = datetime.now(tz=timezone.utc).date()
|
||||
stmt = select(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id)
|
||||
plans = self.session.execute(stmt).scalars().all()
|
||||
return [self.schema.model_validate(x) for x in plans]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from functools import cached_property
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
@@ -45,7 +45,7 @@ class ReadWebhookController(BaseUserController):
|
||||
"""Manually re-fires all previously scheduled webhooks for today"""
|
||||
|
||||
start_time = datetime.min.time()
|
||||
start_dt = datetime.combine(datetime.utcnow().date(), start_time)
|
||||
start_dt = datetime.combine(datetime.now(timezone.utc).date(), start_time)
|
||||
post_group_webhooks(start_dt=start_dt, group_id=self.group.id)
|
||||
|
||||
@router.get("/{item_id}", response_model=ReadWebhook)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from mealie.db import init_db
|
||||
from mealie.db.models._model_utils import GUID
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
from mealie.services._base_service import BaseService
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent.parent.parent.parent
|
||||
|
||||
@@ -25,7 +25,7 @@ class BackupV2(BaseService):
|
||||
db_file = self.settings.DB_URL.removeprefix("sqlite:///") # type: ignore
|
||||
|
||||
# Create a backup of the SQLite database
|
||||
timestamp = datetime.datetime.now().strftime("%Y.%m.%d")
|
||||
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y.%m.%d")
|
||||
shutil.copy(db_file, self.directories.DATA_DIR.joinpath(f"mealie_{timestamp}.bak.db"))
|
||||
|
||||
def _postgres(self) -> None:
|
||||
@@ -37,7 +37,7 @@ class BackupV2(BaseService):
|
||||
exclude_ext = {".zip"}
|
||||
exclude_dirs = {"backups", ".temp"}
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y.%m.%d.%H.%M.%S")
|
||||
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y.%m.%d.%H.%M.%S")
|
||||
|
||||
backup_name = f"mealie_{timestamp}.zip"
|
||||
backup_file = self.directories.BACKUP_DIR / backup_name
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from enum import Enum, auto
|
||||
from typing import Any
|
||||
|
||||
@@ -188,4 +188,4 @@ class Event(MealieModel):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.event_id = uuid.uuid4()
|
||||
self.timestamp = datetime.now()
|
||||
self.timestamp = datetime.now(timezone.utc)
|
||||
|
||||
@@ -43,7 +43,7 @@ class Exporter(BaseService):
|
||||
name="Data Export",
|
||||
size=pretty_size(export_path.stat().st_size),
|
||||
filename=export_path.name,
|
||||
expires=datetime.datetime.now() + datetime.timedelta(days=1),
|
||||
expires=datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1),
|
||||
)
|
||||
|
||||
db.group_exports.create(group_data_export)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -35,7 +35,7 @@ class CopyMeThatMigrator(BaseMigrator):
|
||||
self.name = "copymethat"
|
||||
|
||||
self.key_aliases = [
|
||||
MigrationAlias(key="last_made", alias="made_this", func=lambda x: datetime.now()),
|
||||
MigrationAlias(key="last_made", alias="made_this", func=lambda x: datetime.now(timezone.utc)),
|
||||
MigrationAlias(key="notes", alias="recipeNotes"),
|
||||
MigrationAlias(key="orgURL", alias="original_link"),
|
||||
MigrationAlias(key="rating", alias="ratingValue"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from shutil import copytree, rmtree
|
||||
from typing import Any
|
||||
@@ -157,7 +157,7 @@ class RecipeService(BaseService):
|
||||
recipe_id=new_recipe.id,
|
||||
subject="Recipe Created",
|
||||
event_type=TimelineEventType.system,
|
||||
timestamp=new_recipe.created_at or datetime.now(),
|
||||
timestamp=new_recipe.created_at or datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
self.repos.recipe_timeline_events.create(timeline_event_data)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from mealie.core import root_logger
|
||||
@@ -28,7 +28,7 @@ class SchedulerService:
|
||||
|
||||
|
||||
async def schedule_daily():
|
||||
now = datetime.now()
|
||||
now = datetime.now(timezone.utc)
|
||||
daily_schedule_time = get_app_settings().DAILY_SCHEDULE_TIME
|
||||
logger.debug(
|
||||
"Current time is %s and DAILY_SCHEDULE_TIME is %s",
|
||||
|
||||
@@ -16,7 +16,7 @@ def purge_group_data_exports(max_minutes_old=ONE_DAY_AS_MINUTES):
|
||||
logger = root_logger.get_logger()
|
||||
|
||||
logger.debug("purging group data exports")
|
||||
limit = datetime.datetime.now() - datetime.timedelta(minutes=max_minutes_old)
|
||||
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)
|
||||
@@ -38,7 +38,7 @@ def purge_excess_files() -> None:
|
||||
directories = get_app_dirs()
|
||||
logger = root_logger.get_logger()
|
||||
|
||||
limit = datetime.datetime.now() - datetime.timedelta(minutes=ONE_DAY_AS_MINUTES * 2)
|
||||
limit = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=ONE_DAY_AS_MINUTES * 2)
|
||||
|
||||
for file in directories.GROUPS_DIR.glob("**/export/*.zip"):
|
||||
# TODO: fix comparison types
|
||||
|
||||
@@ -14,7 +14,7 @@ MAX_DAYS_OLD = 2
|
||||
def purge_password_reset_tokens():
|
||||
"""Purges all events after x days"""
|
||||
logger.debug("purging password reset tokens")
|
||||
limit = datetime.datetime.now() - datetime.timedelta(days=MAX_DAYS_OLD)
|
||||
limit = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=MAX_DAYS_OLD)
|
||||
|
||||
with session_context() as session:
|
||||
stmt = delete(PasswordResetModel).filter(PasswordResetModel.created_at <= limit)
|
||||
|
||||
@@ -14,7 +14,7 @@ MAX_DAYS_OLD = 4
|
||||
def purge_group_registration():
|
||||
"""Purges all events after x days"""
|
||||
logger.debug("purging expired registration tokens")
|
||||
limit = datetime.datetime.now() - datetime.timedelta(days=MAX_DAYS_OLD)
|
||||
limit = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=MAX_DAYS_OLD)
|
||||
|
||||
with session_context() as session:
|
||||
stmt = delete(GroupInviteToken).filter(GroupInviteToken.created_at <= limit)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
@@ -30,7 +30,7 @@ class UserService(BaseService):
|
||||
return unlocked
|
||||
|
||||
def lock_user(self, user: PrivateUser) -> PrivateUser:
|
||||
user.locked_at = datetime.now()
|
||||
user.locked_at = datetime.now(timezone.utc)
|
||||
return self.repos.users.update(user.id, user)
|
||||
|
||||
def unlock_user(self, user: PrivateUser) -> PrivateUser:
|
||||
|
||||
Reference in New Issue
Block a user