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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
69 changed files with 250 additions and 176 deletions

View File

@ -27,6 +27,7 @@
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
},
"extensions": [
"charliermarsh.ruff",
"dbaeumer.vscode-eslint",
"matangover.mypy",
"ms-python.black-formatter",

View File

@ -13,7 +13,7 @@ from text_unidecode import unidecode
import mealie.db.migration_types
from alembic import op
from mealie.db.models._model_utils import GUID
from mealie.db.models._model_utils.guid import GUID
# revision identifiers, used by Alembic.
revision = "5ab195a474eb"

View File

@ -6,7 +6,7 @@ Create Date: 2024-03-18 02:28:15.896959
"""
from datetime import datetime
from datetime import datetime, timezone
from textwrap import dedent
from typing import Any
from uuid import uuid4
@ -34,7 +34,7 @@ def new_user_rating(user_id: Any, recipe_id: Any, rating: float | None = None, i
else:
id = "%.32x" % uuid4().int
now = datetime.now().isoformat()
now = datetime.now(timezone.utc).isoformat()
return {
"id": id,
"user_id": user_id,

View File

@ -102,7 +102,7 @@
<v-icon left>
{{ $globals.icons.calendar }}
</v-icon>
{{ $t('recipe.last-made-date', { date: value ? new Date(value+"Z").toLocaleDateString($i18n.locale) : $t("general.never") } ) }}
{{ $t('recipe.last-made-date', { date: value ? new Date(value).toLocaleDateString($i18n.locale) : $t("general.never") } ) }}
</v-chip>
</div>
</div>
@ -199,11 +199,7 @@ export default defineComponent({
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
// update recipe in parent so the user can see it
// we remove the trailing "Z" since this is how the API returns it
context.emit(
"input", newTimelineEvent.value.timestamp
.substring(0, newTimelineEvent.value.timestamp.length - 1)
);
context.emit("input", newTimelineEvent.value.timestamp);
}
// update the image, if provided

View File

@ -8,7 +8,7 @@
<template v-if="!useMobileFormat" #opposite>
<v-chip v-if="event.timestamp" label large>
<v-icon class="mr-1"> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
{{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }}
</v-chip>
</template>
<v-card
@ -25,7 +25,7 @@
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
<v-chip label>
<v-icon> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
</v-chip>
</v-col>
<v-col v-else cols="9" style="margin: auto; text-align: center;">

View File

@ -75,7 +75,7 @@
<v-row v-if="listItem.checked" no-gutters class="mb-2">
<v-col cols="auto">
<div class="text-caption font-weight-light font-italic">
{{ $t("shopping-list.completed-on", {date: new Date(listItem.updateAt+"Z").toLocaleDateString($i18n.locale)}) }}
{{ $t("shopping-list.completed-on", {date: new Date(listItem.updateAt || "").toLocaleDateString($i18n.locale)}) }}
</div>
</v-col>
</v-row>

View File

@ -4,7 +4,7 @@ import { useUserApi } from "~/composables/api";
import { ShoppingListItemOut } from "~/lib/api/types/group";
const localStorageKey = "shopping-list-queue";
const queueTimeout = 48 * 60 * 60 * 1000; // 48 hours
const queueTimeout = 5 * 60 * 1000; // 5 minutes
type ItemQueueType = "create" | "update" | "delete";

View File

@ -868,7 +868,6 @@ export default defineComponent({
// set a temporary updatedAt timestamp prior to refresh so it appears at the top of the checked items
item.updateAt = new Date().toISOString();
item.updateAt = item.updateAt.substring(0, item.updateAt.length-1);
}
// make updates reflect immediately

View File

@ -51,7 +51,7 @@
<v-list-item-title>
{{ token.name }}
</v-list-item-title>
<v-list-item-subtitle> {{ $t('general.created-on-date', [$d(new Date(token.createdAt+"Z"))]) }} </v-list-item-subtitle>
<v-list-item-subtitle> {{ $t('general.created-on-date', [$d(new Date(token.createdAt))]) }} </v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<BaseButton delete small @click="deleteToken(token.id)"></BaseButton>

View File

@ -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

View File

@ -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:

View File

@ -1,7 +0,0 @@
from .auto_init import auto_init
from .guid import GUID
__all__ = [
"auto_init",
"GUID",
]

View 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()

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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"

View File

@ -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()

View File

@ -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

View File

@ -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):

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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):

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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:

View File

@ -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]

View File

@ -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)

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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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"),

View File

@ -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)

View File

@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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:

4
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
[[package]]
name = "aiofiles"
@ -3475,4 +3475,4 @@ pgsql = ["psycopg2-binary"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "a3013c99f7e125bab3566192fe93d7b808eb6b837e4ae3d0e42a344673963950"
content-hash = "d2b389e15570fa45314e20d80bce9e47a52a087c17864fb079d90f2028f69efe"

View File

@ -48,6 +48,7 @@ pydantic-settings = "^2.1.0"
pillow-heif = "^0.17.0"
pyjwt = "^2.8.0"
openai = "^1.27.0"
typing-extensions = "^4.12.2"
[tool.poetry.group.postgres.dependencies]
psycopg2-binary = { version = "^2.9.1" }
@ -144,6 +145,7 @@ select = [
"T", # flake8-print
"UP", # pyupgrade
"B", # flake8-bugbear
"DTZ", # flake8-datetimez
# "ANN", # flake8-annotations
# "C", # McCabe complexity
# "RUF", # Ruff specific

View File

@ -1,4 +1,4 @@
from datetime import date, timedelta
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
@ -15,8 +15,10 @@ def route_all_slice(page: int, perPage: int, start_date: str, end_date: str):
def test_create_mealplan_no_recipe(api_client: TestClient, unique_user: TestUser):
title = random_string(length=25)
text = random_string(length=25)
new_plan = CreatePlanEntry(date=date.today(), entry_type="breakfast", title=title, text=text).model_dump()
new_plan["date"] = date.today().strftime("%Y-%m-%d")
new_plan = CreatePlanEntry(
date=datetime.now(timezone.utc).date(), entry_type="breakfast", title=title, text=text
).model_dump()
new_plan["date"] = datetime.now(timezone.utc).date().strftime("%Y-%m-%d")
response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token)
@ -36,8 +38,10 @@ def test_create_mealplan_with_recipe(api_client: TestClient, unique_user: TestUs
recipe = response.json()
recipe_id = recipe["id"]
new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).model_dump(by_alias=True)
new_plan["date"] = date.today().strftime("%Y-%m-%d")
new_plan = CreatePlanEntry(
date=datetime.now(timezone.utc).date(), entry_type="dinner", recipe_id=recipe_id
).model_dump(by_alias=True)
new_plan["date"] = datetime.now(timezone.utc).date().strftime("%Y-%m-%d")
new_plan["recipeId"] = str(recipe_id)
response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token)
@ -49,14 +53,14 @@ def test_create_mealplan_with_recipe(api_client: TestClient, unique_user: TestUs
def test_crud_mealplan(api_client: TestClient, unique_user: TestUser):
new_plan = CreatePlanEntry(
date=date.today(),
date=datetime.now(timezone.utc).date(),
entry_type="breakfast",
title=random_string(),
text=random_string(),
).model_dump()
# Create
new_plan["date"] = date.today().strftime("%Y-%m-%d")
new_plan["date"] = datetime.now(timezone.utc).date().strftime("%Y-%m-%d")
response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token)
response_json = response.json()
assert response.status_code == 201
@ -87,13 +91,13 @@ def test_crud_mealplan(api_client: TestClient, unique_user: TestUser):
def test_get_all_mealplans(api_client: TestClient, unique_user: TestUser):
for _ in range(3):
new_plan = CreatePlanEntry(
date=date.today(),
date=datetime.now(timezone.utc).date(),
entry_type="breakfast",
title=random_string(),
text=random_string(),
).model_dump()
new_plan["date"] = date.today().strftime("%Y-%m-%d")
new_plan["date"] = datetime.now(timezone.utc).date().strftime("%Y-%m-%d")
response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token)
assert response.status_code == 201
@ -105,7 +109,7 @@ def test_get_all_mealplans(api_client: TestClient, unique_user: TestUser):
def test_get_slice_mealplans(api_client: TestClient, unique_user: TestUser):
# Make List of 10 dates from now to +10 days
dates = [date.today() + timedelta(days=x) for x in range(10)]
dates = [datetime.now(timezone.utc).date() + timedelta(days=x) for x in range(10)]
# Make a list of 10 meal plans
meal_plans = [
@ -139,7 +143,7 @@ def test_get_mealplan_today(api_client: TestClient, unique_user: TestUser):
# Create Meal Plans for today
test_meal_plans = [
CreatePlanEntry(
date=date.today(), entry_type="breakfast", title=random_string(), text=random_string()
date=datetime.now(timezone.utc).date(), entry_type="breakfast", title=random_string(), text=random_string()
).model_dump()
for _ in range(3)
]
@ -158,4 +162,4 @@ def test_get_mealplan_today(api_client: TestClient, unique_user: TestUser):
response_json = response.json()
for meal_plan in response_json:
assert meal_plan["date"] == date.today().strftime("%Y-%m-%d")
assert meal_plan["date"] == datetime.now(timezone.utc).date().strftime("%Y-%m-%d")

View File

@ -14,7 +14,7 @@ def webhook_data():
"name": "Test-Name",
"url": "https://my-fake-url.com",
"time": "00:00",
"scheduledTime": datetime.now(),
"scheduledTime": datetime.now(timezone.utc),
}

View File

@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from fastapi.testclient import TestClient
@ -106,7 +106,7 @@ def test_user_update_last_made(api_client: TestClient, user_tuple: list[TestUser
response = api_client.put(api_routes.recipes + f"/{recipe_name}", json=recipe, headers=usr_1.token)
# User 2 should be able to update the last made timestamp
last_made_json = {"timestamp": datetime.now().isoformat()}
last_made_json = {"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")}
response = api_client.patch(
api_routes.recipes_slug_last_made(recipe_name), json=last_made_json, headers=usr_2.token
)

View File

@ -1,7 +1,7 @@
import random
import time
from collections import defaultdict
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta, timezone
from random import randint
from urllib.parse import parse_qsl, urlsplit
@ -233,7 +233,7 @@ def test_pagination_filter_null(database: AllRepositories, unique_user: TestUser
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
last_made=datetime.now(),
last_made=datetime.now(timezone.utc),
)
)
@ -619,7 +619,7 @@ def test_pagination_filter_datetimes(
def test_pagination_order_by_multiple(
database: AllRepositories, unique_user: TestUser, order_direction: OrderDirection
):
current_time = datetime.now()
current_time = datetime.now(timezone.utc)
alphabet = ["a", "b", "c", "d", "e"]
abbreviations = alphabet.copy()
@ -681,7 +681,7 @@ def test_pagination_order_by_multiple_directions(
order_by_str: str,
order_direction: OrderDirection,
):
current_time = datetime.now()
current_time = datetime.now(timezone.utc)
alphabet = ["a", "b", "c", "d", "e"]
abbreviations = alphabet.copy()
@ -729,7 +729,7 @@ def test_pagination_order_by_multiple_directions(
def test_pagination_order_by_nested_model(
database: AllRepositories, unique_user: TestUser, order_direction: OrderDirection
):
current_time = datetime.now()
current_time = datetime.now(timezone.utc)
alphabet = ["a", "b", "c", "d", "e"]
labels = database.group_multi_purpose_labels.create_many(
@ -759,7 +759,7 @@ def test_pagination_order_by_nested_model(
def test_pagination_order_by_doesnt_filter(database: AllRepositories, unique_user: TestUser):
current_time = datetime.now()
current_time = datetime.now(timezone.utc)
label = database.group_multi_purpose_labels.create(
MultiPurposeLabelSave(name=random_string(), group_id=unique_user.group_id)
@ -805,7 +805,7 @@ def test_pagination_order_by_nulls(
null_position: OrderByNullPosition,
order_direction: OrderDirection,
):
current_time = datetime.now()
current_time = datetime.now(timezone.utc)
label = database.group_multi_purpose_labels.create(
MultiPurposeLabelSave(name=random_string(), group_id=unique_user.group_id)
@ -909,10 +909,11 @@ def test_pagination_shopping_list_items_with_labels(database: AllRepositories, u
def test_pagination_filter_dates(api_client: TestClient, unique_user: TestUser):
yesterday = date.today() - timedelta(days=1)
today = date.today()
tomorrow = date.today() + timedelta(days=1)
day_after_tomorrow = date.today() + timedelta(days=2)
today = datetime.now(timezone.utc).date()
yesterday = today - timedelta(days=1)
tomorrow = today + timedelta(days=1)
day_after_tomorrow = today + timedelta(days=2)
mealplan_today = CreatePlanEntry(date=today, entry_type="breakfast", title=random_string(), text=random_string())
mealplan_tomorrow = CreatePlanEntry(

View File

@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from typing import cast
from uuid import UUID
@ -298,12 +298,12 @@ def test_recipe_repo_pagination_by_categories(database: AllRepositories, unique_
page=1,
per_page=-1,
order_by="random",
pagination_seed=str(datetime.now()),
pagination_seed=str(datetime.now(timezone.utc)),
order_direction=OrderDirection.asc,
)
random_ordered = []
for i in range(5):
pagination_query.pagination_seed = str(datetime.now())
pagination_query.pagination_seed = str(datetime.now(timezone.utc))
random_ordered.append(database.recipes.page_all(pagination_query, categories=[category_slug]).items)
assert not all(i == random_ordered[0] for i in random_ordered)
@ -391,12 +391,12 @@ def test_recipe_repo_pagination_by_tags(database: AllRepositories, unique_user:
page=1,
per_page=-1,
order_by="random",
pagination_seed=str(datetime.now()),
pagination_seed=str(datetime.now(timezone.utc)),
order_direction=OrderDirection.asc,
)
random_ordered = []
for i in range(5):
pagination_query.pagination_seed = str(datetime.now())
pagination_query.pagination_seed = str(datetime.now(timezone.utc))
random_ordered.append(database.recipes.page_all(pagination_query, tags=[tag_slug]).items)
assert len(random_ordered[0]) == 15
assert not all(i == random_ordered[0] for i in random_ordered)
@ -487,12 +487,12 @@ def test_recipe_repo_pagination_by_tools(database: AllRepositories, unique_user:
page=1,
per_page=-1,
order_by="random",
pagination_seed=str(datetime.now()),
pagination_seed=str(datetime.now(timezone.utc)),
order_direction=OrderDirection.asc,
)
random_ordered = []
for i in range(5):
pagination_query.pagination_seed = str(datetime.now())
pagination_query.pagination_seed = str(datetime.now(timezone.utc))
random_ordered.append(database.recipes.page_all(pagination_query, tools=[tool_id]).items)
assert len(random_ordered[0]) == 15
assert not all(i == random_ordered[0] for i in random_ordered)
@ -571,12 +571,12 @@ def test_recipe_repo_pagination_by_foods(database: AllRepositories, unique_user:
page=1,
per_page=-1,
order_by="random",
pagination_seed=str(datetime.now()),
pagination_seed=str(datetime.now(timezone.utc)),
order_direction=OrderDirection.asc,
)
random_ordered = []
for i in range(5):
pagination_query.pagination_seed = str(datetime.now())
pagination_query.pagination_seed = str(datetime.now(timezone.utc))
random_ordered.append(database.recipes.page_all(pagination_query, foods=[food_id]).items)
assert len(random_ordered[0]) == 15
assert not all(i == random_ordered[0] for i in random_ordered)
@ -651,12 +651,12 @@ def test_random_order_recipe_search(
page=1,
per_page=-1,
order_by="random",
pagination_seed=str(datetime.now()),
pagination_seed=str(datetime.now(timezone.utc)),
order_direction=OrderDirection.asc,
)
random_ordered = []
for _ in range(5):
pagination.pagination_seed = str(datetime.now())
pagination.pagination_seed = str(datetime.now(timezone.utc))
random_ordered.append(repo.page_all(pagination, search="soup").items)
assert not all(i == random_ordered[0] for i in random_ordered)

View File

@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
import pytest
@ -125,11 +125,11 @@ def test_random_order_search(
page=1,
per_page=-1,
order_by="random",
pagination_seed=str(datetime.now()),
pagination_seed=str(datetime.now(timezone.utc)),
order_direction=OrderDirection.asc,
)
random_ordered = []
for _ in range(5):
pagination.pagination_seed = str(datetime.now())
pagination.pagination_seed = str(datetime.now(timezone.utc))
random_ordered.append(repo.page_all(pagination, search="unit").items)
assert not all(i == random_ordered[0] for i in random_ordered)

View File

@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
import tests.data as test_data
from mealie.core.config import get_app_settings
from mealie.db.db_setup import session_context
from mealie.db.models._model_utils import GUID
from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.group import Group
from mealie.db.models.group.shopping_list import ShoppingList
from mealie.db.models.labels import MultiPurposeLabel

View File

@ -1,4 +1,4 @@
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
from pydantic import UUID4
@ -34,8 +34,10 @@ def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser):
response_json = response.json()
initial_event_count = len(response_json["items"])
new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).model_dump(by_alias=True)
new_plan["date"] = date.today().isoformat()
new_plan = CreatePlanEntry(
date=datetime.now(timezone.utc).date(), entry_type="dinner", recipe_id=recipe_id
).model_dump(by_alias=True)
new_plan["date"] = datetime.now(timezone.utc).date().isoformat()
new_plan["recipeId"] = str(recipe_id)
response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token)
@ -63,7 +65,7 @@ def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser):
response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token)
new_recipe_data: dict = response.json()
recipe = RecipeSummary.model_validate(new_recipe_data)
assert recipe.last_made.date() == date.today() # type: ignore
assert recipe.last_made.date() == datetime.now(timezone.utc).date() # type: ignore
# make sure nothing else was updated
for data in [original_recipe_data, new_recipe_data]:
@ -99,8 +101,10 @@ def test_new_mealplan_event_duplicates(api_client: TestClient, unique_user: Test
response_json = response.json()
initial_event_count = len(response_json["items"])
new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).model_dump(by_alias=True)
new_plan["date"] = date.today().isoformat()
new_plan = CreatePlanEntry(
date=datetime.now(timezone.utc).date(), entry_type="dinner", recipe_id=recipe_id
).model_dump(by_alias=True)
new_plan["date"] = datetime.now(timezone.utc).date().isoformat()
new_plan["recipeId"] = str(recipe_id)
response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token)
@ -143,10 +147,10 @@ def test_new_mealplan_events_with_multiple_recipes(api_client: TestClient, uniqu
for recipe in recipes:
mealplan_count_by_recipe_id[recipe.id] = 0 # type: ignore
for _ in range(random_int(1, 5)):
new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=str(recipe.id)).model_dump(
by_alias=True
)
new_plan["date"] = date.today().isoformat()
new_plan = CreatePlanEntry(
date=datetime.now(timezone.utc).date(), entry_type="dinner", recipe_id=str(recipe.id)
).model_dump(by_alias=True)
new_plan["date"] = datetime.now(timezone.utc).date().isoformat()
new_plan["recipeId"] = str(recipe.id)
response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token)
@ -196,15 +200,17 @@ def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser
recipe = RecipeSummary.model_validate(response.json())
recipe_id = str(recipe.id)
future_dt = datetime.now() + timedelta(days=random_int(1, 10))
future_dt = datetime.now(timezone.utc) + timedelta(days=random_int(1, 10))
recipe.last_made = future_dt
response = api_client.put(
api_routes.recipes_slug(recipe.slug), json=utils.jsonify(recipe), headers=unique_user.token
)
assert response.status_code == 200
new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).model_dump(by_alias=True)
new_plan["date"] = date.today().isoformat()
new_plan = CreatePlanEntry(
date=datetime.now(timezone.utc).date(), entry_type="dinner", recipe_id=recipe_id
).model_dump(by_alias=True)
new_plan["date"] = datetime.now(timezone.utc).date().isoformat()
new_plan["recipeId"] = str(recipe_id)
response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token)

View File

@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group.group_shopping_list import ShoppingListItemCreate, ShoppingListItemOut, ShoppingListSave
@ -40,7 +40,7 @@ def test_cleanup(database: AllRepositories, unique_user: TestUser):
for item in unchecked_items + checked_items:
assert item in shopping_list.list_items
checked_items.sort(key=lambda x: x.update_at or datetime.now(), reverse=True)
checked_items.sort(key=lambda x: x.update_at or datetime.now(timezone.utc), reverse=True)
expected_kept_items = unchecked_items + checked_items[:MAX_CHECKED_ITEMS]
expected_deleted_items = checked_items[MAX_CHECKED_ITEMS:]

View File

@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pydantic import UUID4
@ -23,7 +23,7 @@ def webhook_factory(
name=name or random_string(),
url=url or random_string(),
webhook_type=webhook_type,
scheduled_time=scheduled_time.time() if scheduled_time else datetime.now().time(),
scheduled_time=scheduled_time.time() if scheduled_time else datetime.now(timezone.utc).time(),
group_id=group_id,
)
@ -35,7 +35,7 @@ def test_get_scheduled_webhooks_filter_query(database: AllRepositories, unique_u
expected: list[SaveWebhook] = []
start = datetime.now()
start = datetime.now(timezone.utc)
for _ in range(5):
new_item = webhook_factory(group_id=unique_user.group_id, enabled=random_bool())
@ -52,7 +52,7 @@ def test_get_scheduled_webhooks_filter_query(database: AllRepositories, unique_u
expected.append(new_item)
event_bus_listener = WebhookEventListener(unique_user.group_id) # type: ignore
results = event_bus_listener.get_scheduled_webhooks(start, datetime.now() + timedelta(minutes=5))
results = event_bus_listener.get_scheduled_webhooks(start, datetime.now(timezone.utc) + timedelta(minutes=5))
assert len(results) == len(expected)

View File

@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from mealie.repos.repository_factory import AllRepositories
from mealie.services.user_services.user_service import UserService
@ -59,7 +59,7 @@ def test_lock_unlocker_user(database: AllRepositories, unique_user: TestUser) ->
assert not unlocked_user.is_locked
# Sanity check that the is_locked property is working
user.locked_at = datetime.now() - timedelta(days=2)
user.locked_at = datetime.now(timezone.utc) - timedelta(days=2)
assert not user.is_locked
@ -85,7 +85,7 @@ def test_reset_locked_users(database: AllRepositories, unique_user: TestUser) ->
assert user.login_attemps == 5
# Test that the locked user is unlocked by reset
user.locked_at = datetime.now() - timedelta(days=2)
user.locked_at = datetime.now(timezone.utc) - timedelta(days=2)
database.users.update(user.id, user)
unlocked = user_service.reset_locked_users()
user = database.users.get_one(unique_user.user_id)

View File

@ -1,4 +1,4 @@
from datetime import date
from datetime import datetime, timezone
from uuid import uuid4
import pytest
@ -7,7 +7,7 @@ from mealie.schema.meal_plan.new_meal import CreatePlanEntry
def test_create_plan_with_title():
entry = CreatePlanEntry(date=date.today(), title="Test Title")
entry = CreatePlanEntry(date=datetime.now(timezone.utc).date(), title="Test Title")
assert entry.title == "Test Title"
assert entry.recipe_id is None
@ -15,7 +15,7 @@ def test_create_plan_with_title():
def test_create_plan_with_slug():
uuid = uuid4()
entry = CreatePlanEntry(date=date.today(), recipe_id=uuid)
entry = CreatePlanEntry(date=datetime.now(timezone.utc).date(), recipe_id=uuid)
assert entry.recipe_id == uuid
assert entry.title == ""
@ -23,4 +23,4 @@ def test_create_plan_with_slug():
def test_slug_or_title_validation():
with pytest.raises(ValueError):
CreatePlanEntry(date=date.today(), slug="", title="")
CreatePlanEntry(date=datetime.now(timezone.utc).date(), slug="", title="")