diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4174bf70c48..3bcb22c5fef 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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", diff --git a/alembic/versions/2023-02-14-20.45.41_5ab195a474eb_add_normalized_search_properties.py b/alembic/versions/2023-02-14-20.45.41_5ab195a474eb_add_normalized_search_properties.py index ea638febd56..bf66ac0d80e 100644 --- a/alembic/versions/2023-02-14-20.45.41_5ab195a474eb_add_normalized_search_properties.py +++ b/alembic/versions/2023-02-14-20.45.41_5ab195a474eb_add_normalized_search_properties.py @@ -13,7 +13,7 @@ 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" diff --git a/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py b/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py index f0e7972f6cd..9c3b6f53ee8 100644 --- a/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py +++ b/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py @@ -6,7 +6,7 @@ """ -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, diff --git a/frontend/components/Domain/Recipe/RecipeLastMade.vue b/frontend/components/Domain/Recipe/RecipeLastMade.vue index 0811e48b2ff..608cdbb037a 100644 --- a/frontend/components/Domain/Recipe/RecipeLastMade.vue +++ b/frontend/components/Domain/Recipe/RecipeLastMade.vue @@ -102,7 +102,7 @@ {{ $globals.icons.calendar }} - {{ $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") } ) }} @@ -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 diff --git a/frontend/components/Domain/Recipe/RecipeTimelineItem.vue b/frontend/components/Domain/Recipe/RecipeTimelineItem.vue index 26e6d7d1906..180859d476d 100644 --- a/frontend/components/Domain/Recipe/RecipeTimelineItem.vue +++ b/frontend/components/Domain/Recipe/RecipeTimelineItem.vue @@ -8,7 +8,7 @@ {{ $globals.icons.calendar }} - {{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }} + {{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }} {{ $globals.icons.calendar }} - {{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }} + {{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }} diff --git a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue index 31b23e622d1..30cd3c292da 100644 --- a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue +++ b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue @@ -75,7 +75,7 @@ - {{ $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)}) }} diff --git a/frontend/composables/use-shopping-list-item-actions.ts b/frontend/composables/use-shopping-list-item-actions.ts index 342caa36b6f..f1ff15339c3 100644 --- a/frontend/composables/use-shopping-list-item-actions.ts +++ b/frontend/composables/use-shopping-list-item-actions.ts @@ -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"; diff --git a/frontend/pages/shopping-lists/_id.vue b/frontend/pages/shopping-lists/_id.vue index 8cea41faf0c..c7be3ace534 100644 --- a/frontend/pages/shopping-lists/_id.vue +++ b/frontend/pages/shopping-lists/_id.vue @@ -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 diff --git a/frontend/pages/user/profile/api-tokens.vue b/frontend/pages/user/profile/api-tokens.vue index 8b6d21b361b..baeda6f6f2c 100644 --- a/frontend/pages/user/profile/api-tokens.vue +++ b/frontend/pages/user/profile/api-tokens.vue @@ -51,7 +51,7 @@ {{ token.name }} - {{ $t('general.created-on-date', [$d(new Date(token.createdAt+"Z"))]) }} + {{ $t('general.created-on-date', [$d(new Date(token.createdAt))]) }} diff --git a/mealie/core/release_checker.py b/mealie/core/release_checker.py index 8316faa1f35..53afa3fba61 100644 --- a/mealie/core/release_checker.py +++ b/mealie/core/release_checker.py @@ -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 diff --git a/mealie/db/models/_model_base.py b/mealie/db/models/_model_base.py index 99dc2b53269..4b0da85b1e4 100644 --- a/mealie/db/models/_model_base.py +++ b/mealie/db/models/_model_base.py @@ -4,11 +4,13 @@ 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: diff --git a/mealie/db/models/_model_utils/__init__.py b/mealie/db/models/_model_utils/__init__.py index 9d921876455..e69de29bb2d 100644 --- a/mealie/db/models/_model_utils/__init__.py +++ b/mealie/db/models/_model_utils/__init__.py @@ -1,7 +0,0 @@ -from .auto_init import auto_init -from .guid import GUID - -__all__ = [ - "auto_init", - "GUID", -] diff --git a/mealie/db/models/_model_utils/datetime.py b/mealie/db/models/_model_utils/datetime.py new file mode 100644 index 00000000000..fbfb62dfeb2 --- /dev/null +++ b/mealie/db/models/_model_utils/datetime.py @@ -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() diff --git a/mealie/db/models/group/cookbook.py b/mealie/db/models/group/cookbook.py index 75a71d486c2..6cca5ff932c 100644 --- a/mealie/db/models/group/cookbook.py +++ b/mealie/db/models/group/cookbook.py @@ -4,13 +4,14 @@ 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): diff --git a/mealie/db/models/group/events.py b/mealie/db/models/group/events.py index 91f126bfd71..ea6d5483e3d 100644 --- a/mealie/db/models/group/events.py +++ b/mealie/db/models/group/events.py @@ -4,10 +4,11 @@ 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): diff --git a/mealie/db/models/group/exports.py b/mealie/db/models/group/exports.py index 26c97c621a1..811493308b4 100644 --- a/mealie/db/models/group/exports.py +++ b/mealie/db/models/group/exports.py @@ -4,10 +4,11 @@ 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): diff --git a/mealie/db/models/group/group.py b/mealie/db/models/group/group.py index 25600216e33..14eb90b7f01 100644 --- a/mealie/db/models/group/group.py +++ b/mealie/db/models/group/group.py @@ -11,7 +11,8 @@ 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 diff --git a/mealie/db/models/group/invite_tokens.py b/mealie/db/models/group/invite_tokens.py index 3f11aff0b51..3e3e3bc7ebf 100644 --- a/mealie/db/models/group/invite_tokens.py +++ b/mealie/db/models/group/invite_tokens.py @@ -4,10 +4,11 @@ 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): diff --git a/mealie/db/models/group/mealplan.py b/mealie/db/models/group/mealplan.py index d3a42549f79..f2c4678bb81 100644 --- a/mealie/db/models/group/mealplan.py +++ b/mealie/db/models/group/mealplan.py @@ -7,14 +7,14 @@ 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): diff --git a/mealie/db/models/group/preferences.py b/mealie/db/models/group/preferences.py index 21259d2be2b..51dba9d6dc6 100644 --- a/mealie/db/models/group/preferences.py +++ b/mealie/db/models/group/preferences.py @@ -5,11 +5,11 @@ 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): diff --git a/mealie/db/models/group/recipe_action.py b/mealie/db/models/group/recipe_action.py index 75704461e10..f2ac8a322e7 100644 --- a/mealie/db/models/group/recipe_action.py +++ b/mealie/db/models/group/recipe_action.py @@ -4,10 +4,11 @@ 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): diff --git a/mealie/db/models/group/report.py b/mealie/db/models/group/report.py index 011589c3c18..afde1c37a93 100644 --- a/mealie/db/models/group/report.py +++ b/mealie/db/models/group/report.py @@ -8,11 +8,12 @@ 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" diff --git a/mealie/db/models/group/shopping_list.py b/mealie/db/models/group/shopping_list.py index 9fe9bdf3f8b..38b16550e12 100644 --- a/mealie/db/models/group/shopping_list.py +++ b/mealie/db/models/group/shopping_list.py @@ -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.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() diff --git a/mealie/db/models/group/webhooks.py b/mealie/db/models/group/webhooks.py index e05e8bf837d..6f640892aa1 100644 --- a/mealie/db/models/group/webhooks.py +++ b/mealie/db/models/group/webhooks.py @@ -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 diff --git a/mealie/db/models/labels.py b/mealie/db/models/labels.py index ae05dd289a1..a1f95490886 100644 --- a/mealie/db/models/labels.py +++ b/mealie/db/models/labels.py @@ -5,13 +5,13 @@ 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): diff --git a/mealie/db/models/recipe/comment.py b/mealie/db/models/recipe/comment.py index 8e6202fe1b1..f62bbd7a1b4 100644 --- a/mealie/db/models/recipe/comment.py +++ b/mealie/db/models/recipe/comment.py @@ -4,7 +4,7 @@ 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: diff --git a/mealie/db/models/recipe/ingredient.py b/mealie/db/models/recipe/ingredient.py index bca2342c71b..9a1daee7866 100644 --- a/mealie/db/models/recipe/ingredient.py +++ b/mealie/db/models/recipe/ingredient.py @@ -9,7 +9,7 @@ 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: diff --git a/mealie/db/models/recipe/instruction.py b/mealie/db/models/recipe/instruction.py index f3d76f3905c..35fa1111317 100644 --- a/mealie/db/models/recipe/instruction.py +++ b/mealie/db/models/recipe/instruction.py @@ -3,7 +3,7 @@ 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 diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 793ce12ea42..24943cd93dd 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -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.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 @@ def __init__( 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: diff --git a/mealie/db/models/recipe/recipe_timeline.py b/mealie/db/models/recipe/recipe_timeline.py index f2753943349..d6441a70d73 100644 --- a/mealie/db/models/recipe/recipe_timeline.py +++ b/mealie/db/models/recipe/recipe_timeline.py @@ -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 @@ def __init__( timestamp=None, **_, ) -> None: - self.timestamp = timestamp or datetime.now() + self.timestamp = timestamp or datetime.now(timezone.utc) diff --git a/mealie/db/models/recipe/shared.py b/mealie/db/models/recipe/shared.py index c3301f04fe1..82edc29ee67 100644 --- a/mealie/db/models/recipe/shared.py +++ b/mealie/db/models/recipe/shared.py @@ -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 @@ 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): diff --git a/mealie/db/models/recipe/tool.py b/mealie/db/models/recipe/tool.py index ab7a9322bde..8c0f342e4ef 100644 --- a/mealie/db/models/recipe/tool.py +++ b/mealie/db/models/recipe/tool.py @@ -5,7 +5,7 @@ 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: diff --git a/mealie/db/models/server/task.py b/mealie/db/models/server/task.py index b599e1ac3d0..3b6cb6aba3e 100644 --- a/mealie/db/models/server/task.py +++ b/mealie/db/models/server/task.py @@ -7,7 +7,7 @@ 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 diff --git a/mealie/db/models/users/password_reset.py b/mealie/db/models/users/password_reset.py index 79765cedb20..0c226ecddfa 100644 --- a/mealie/db/models/users/password_reset.py +++ b/mealie/db/models/users/password_reset.py @@ -4,7 +4,7 @@ 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 diff --git a/mealie/db/models/users/user_to_recipe.py b/mealie/db/models/users/user_to_recipe.py index 8fcda14ba1d..3af33a06ed6 100644 --- a/mealie/db/models/users/user_to_recipe.py +++ b/mealie/db/models/users/user_to_recipe.py @@ -4,7 +4,8 @@ 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): diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 64f46862ac4..3eaddce197a 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -8,10 +8,10 @@ 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: diff --git a/mealie/repos/repository_meals.py b/mealie/repos/repository_meals.py index e08b79ece21..c2346141342 100644 --- a/mealie/repos/repository_meals.py +++ b/mealie/repos/repository_meals.py @@ -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 @@ def by_group(self, group_id: UUID) -> "RepositoryMeals": 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] diff --git a/mealie/routes/groups/controller_webhooks.py b/mealie/routes/groups/controller_webhooks.py index f91039a1900..2ad5a0bd45f 100644 --- a/mealie/routes/groups/controller_webhooks.py +++ b/mealie/routes/groups/controller_webhooks.py @@ -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 @@ def rerun_webhooks(self): """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) diff --git a/mealie/schema/_mealie/datetime_parse.py b/mealie/schema/_mealie/datetime_parse.py index 2109dc1a889..0e482d9a875 100644 --- a/mealie/schema/_mealie/datetime_parse.py +++ b/mealie/schema/_mealie/datetime_parse.py @@ -39,7 +39,7 @@ 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 diff --git a/mealie/schema/_mealie/mealie_model.py b/mealie/schema/_mealie/mealie_model.py index 6a12f370d72..62ff5d3d2ad 100644 --- a/mealie/schema/_mealie/mealie_model.py +++ b/mealie/schema/_mealie/mealie_model.py @@ -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 diff --git a/mealie/schema/recipe/recipe_share_token.py b/mealie/schema/recipe/recipe_share_token.py index 6976548661f..fd4e376cf30 100644 --- a/mealie/schema/recipe/recipe_share_token.py +++ b/mealie/schema/recipe/recipe_share_token.py @@ -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 @@ def defaut_expires_at_time() -> datetime: - return datetime.utcnow() + timedelta(days=30) + return datetime.now(timezone.utc) + timedelta(days=30) class RecipeShareTokenCreate(MealieModel): diff --git a/mealie/schema/recipe/recipe_timeline_events.py b/mealie/schema/recipe/recipe_timeline_events.py index 5339491235b..6c315e70ecf 100644 --- a/mealie/schema/recipe/recipe_timeline_events.py +++ b/mealie/schema/recipe/recipe_timeline_events.py @@ -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) diff --git a/mealie/schema/reports/reports.py b/mealie/schema/reports/reports.py index b4a5aaf17dc..70be15f5254 100644 --- a/mealie/schema/reports/reports.py +++ b/mealie/schema/reports/reports.py @@ -6,6 +6,7 @@ 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 diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py index 3ca7df1e4b1..5e4767454b3 100644 --- a/mealie/schema/user/user.py +++ b/mealie/schema/user/user.py @@ -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 @@ def is_locked(self) -> bool: 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) diff --git a/mealie/services/backups_v2/alchemy_exporter.py b/mealie/services/backups_v2/alchemy_exporter.py index d21f9fdbfa0..a3eb622c85d 100644 --- a/mealie/services/backups_v2/alchemy_exporter.py +++ b/mealie/services/backups_v2/alchemy_exporter.py @@ -13,7 +13,7 @@ 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 diff --git a/mealie/services/backups_v2/backup_v2.py b/mealie/services/backups_v2/backup_v2.py index 20beea9f946..fe8289ed778 100644 --- a/mealie/services/backups_v2/backup_v2.py +++ b/mealie/services/backups_v2/backup_v2.py @@ -25,7 +25,7 @@ def _sqlite(self) -> None: 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 @@ def backup(self) -> Path: 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 diff --git a/mealie/services/event_bus_service/event_types.py b/mealie/services/event_bus_service/event_types.py index 83496372ae2..a34b7032f21 100644 --- a/mealie/services/event_bus_service/event_types.py +++ b/mealie/services/event_bus_service/event_types.py @@ -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) diff --git a/mealie/services/exporter/exporter.py b/mealie/services/exporter/exporter.py index 0899493186e..1b66cc54954 100644 --- a/mealie/services/exporter/exporter.py +++ b/mealie/services/exporter/exporter.py @@ -43,7 +43,7 @@ def run(self, db: AllRepositories) -> GroupDataExport: 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) diff --git a/mealie/services/migrations/copymethat.py b/mealie/services/migrations/copymethat.py index 88515c1f5de..6b9c925c530 100644 --- a/mealie/services/migrations/copymethat.py +++ b/mealie/services/migrations/copymethat.py @@ -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 @@ def __init__(self, **kwargs): 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"), diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 18f2ff40729..8df41e2785f 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -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 @@ def create_one(self, create_data: Recipe | CreateRecipe) -> Recipe: 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) diff --git a/mealie/services/scheduler/scheduler_service.py b/mealie/services/scheduler/scheduler_service.py index 0e4681df326..ce4fc9510ac 100644 --- a/mealie/services/scheduler/scheduler_service.py +++ b/mealie/services/scheduler/scheduler_service.py @@ -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 @@ async def start(): 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", diff --git a/mealie/services/scheduler/tasks/purge_group_exports.py b/mealie/services/scheduler/tasks/purge_group_exports.py index ca8815d8efa..ba2835c0f03 100644 --- a/mealie/services/scheduler/tasks/purge_group_exports.py +++ b/mealie/services/scheduler/tasks/purge_group_exports.py @@ -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 diff --git a/mealie/services/scheduler/tasks/purge_password_reset.py b/mealie/services/scheduler/tasks/purge_password_reset.py index fef5ec065ca..f783e95913f 100644 --- a/mealie/services/scheduler/tasks/purge_password_reset.py +++ b/mealie/services/scheduler/tasks/purge_password_reset.py @@ -14,7 +14,7 @@ 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) diff --git a/mealie/services/scheduler/tasks/purge_registration.py b/mealie/services/scheduler/tasks/purge_registration.py index 7aef19e0f87..81510de2266 100644 --- a/mealie/services/scheduler/tasks/purge_registration.py +++ b/mealie/services/scheduler/tasks/purge_registration.py @@ -14,7 +14,7 @@ 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) diff --git a/mealie/services/user_services/user_service.py b/mealie/services/user_services/user_service.py index 3ad640c8e65..51db3f6e055 100644 --- a/mealie/services/user_services/user_service.py +++ b/mealie/services/user_services/user_service.py @@ -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 @@ def reset_locked_users(self, force: bool = False) -> int: 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: diff --git a/poetry.lock b/poetry.lock index 5e37d8ac87f..793f1b65271 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 303e1039476..46c45081f4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/tests/integration_tests/user_group_tests/test_group_mealplan.py b/tests/integration_tests/user_group_tests/test_group_mealplan.py index 6964aa0b178..7bc284f62e9 100644 --- a/tests/integration_tests/user_group_tests/test_group_mealplan.py +++ b/tests/integration_tests/user_group_tests/test_group_mealplan.py @@ -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") diff --git a/tests/integration_tests/user_group_tests/test_group_webhooks.py b/tests/integration_tests/user_group_tests/test_group_webhooks.py index 054929ef9a0..bcf8a5d5af9 100644 --- a/tests/integration_tests/user_group_tests/test_group_webhooks.py +++ b/tests/integration_tests/user_group_tests/test_group_webhooks.py @@ -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), } diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py index 3a1e0daff15..86efde53946 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py @@ -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 ) diff --git a/tests/unit_tests/repository_tests/test_pagination.py b/tests/unit_tests/repository_tests/test_pagination.py index d8b5899bb80..f91a2e8a921 100644 --- a/tests/unit_tests/repository_tests/test_pagination.py +++ b/tests/unit_tests/repository_tests/test_pagination.py @@ -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( diff --git a/tests/unit_tests/repository_tests/test_recipe_repository.py b/tests/unit_tests/repository_tests/test_recipe_repository.py index 552c560d043..6655ed85dfb 100644 --- a/tests/unit_tests/repository_tests/test_recipe_repository.py +++ b/tests/unit_tests/repository_tests/test_recipe_repository.py @@ -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) diff --git a/tests/unit_tests/repository_tests/test_search.py b/tests/unit_tests/repository_tests/test_search.py index a71ea50ff42..4abc79757ba 100644 --- a/tests/unit_tests/repository_tests/test_search.py +++ b/tests/unit_tests/repository_tests/test_search.py @@ -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) diff --git a/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py b/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py index 6c95dc4628a..6b99e28b30b 100644 --- a/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py +++ b/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py @@ -9,7 +9,7 @@ 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 diff --git a/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py b/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py index 0a458cc6270..036b47c892f 100644 --- a/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py +++ b/tests/unit_tests/services_tests/scheduler/tasks/test_create_timeline_events.py @@ -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) diff --git a/tests/unit_tests/services_tests/scheduler/tasks/test_delete_old_checked_shopping_list_items.py b/tests/unit_tests/services_tests/scheduler/tasks/test_delete_old_checked_shopping_list_items.py index 34705dee053..1c76312aed5 100644 --- a/tests/unit_tests/services_tests/scheduler/tasks/test_delete_old_checked_shopping_list_items.py +++ b/tests/unit_tests/services_tests/scheduler/tasks/test_delete_old_checked_shopping_list_items.py @@ -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:] diff --git a/tests/unit_tests/services_tests/scheduler/tasks/test_post_webhook.py b/tests/unit_tests/services_tests/scheduler/tasks/test_post_webhook.py index f09bbf8ae5f..8f8fc736756 100644 --- a/tests/unit_tests/services_tests/scheduler/tasks/test_post_webhook.py +++ b/tests/unit_tests/services_tests/scheduler/tasks/test_post_webhook.py @@ -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) diff --git a/tests/unit_tests/services_tests/user_services/test_user_service.py b/tests/unit_tests/services_tests/user_services/test_user_service.py index a543eb87b31..d57e256eb5e 100644 --- a/tests/unit_tests/services_tests/user_services/test_user_service.py +++ b/tests/unit_tests/services_tests/user_services/test_user_service.py @@ -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) diff --git a/tests/unit_tests/validator_tests/test_create_plan_entry.py b/tests/unit_tests/validator_tests/test_create_plan_entry.py index 12f4e1289f8..37e4c89597f 100644 --- a/tests/unit_tests/validator_tests/test_create_plan_entry.py +++ b/tests/unit_tests/validator_tests/test_create_plan_entry.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import datetime, timezone from uuid import uuid4 import pytest @@ -7,7 +7,7 @@ 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="")