From 0836c303d9a72690a8357f9480a7f62264e2f1ea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 10 Feb 2024 22:20:37 +0000 Subject: [PATCH 01/13] fix(deps): update dependency uvicorn to v0.27.1 --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index d0bb734a6646..a3fbd55ae264 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2680,13 +2680,13 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "uvicorn" -version = "0.27.0" +version = "0.27.1" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.27.0-py3-none-any.whl", hash = "sha256:890b00f6c537d58695d3bb1f28e23db9d9e7a17cbcc76d7457c499935f933e24"}, - {file = "uvicorn-0.27.0.tar.gz", hash = "sha256:c855578045d45625fd027367f7653d249f7c49f9361ba15cf9624186b26b8eb6"}, + {file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"}, + {file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"}, ] [package.dependencies] From 12b1d29413340030976db2372cfd1cc7acda8569 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 11 Feb 2024 11:02:18 +0000 Subject: [PATCH 02/13] chore(deps): update dependency pre-commit to v3.6.1 --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index a3fbd55ae264..d8f636560876 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1567,13 +1567,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.6.0" +version = "3.6.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.6.0-py2.py3-none-any.whl", hash = "sha256:c255039ef399049a5544b6ce13d135caba8f2c28c3b4033277a788f434308376"}, - {file = "pre_commit-3.6.0.tar.gz", hash = "sha256:d30bad9abf165f7785c15a21a1f46da7d0677cb00ee7ff4c579fd38922efe15d"}, + {file = "pre_commit-3.6.1-py2.py3-none-any.whl", hash = "sha256:9fe989afcf095d2c4796ce7c553cf28d4d4a9b9346de3cda079bcf40748454a4"}, + {file = "pre_commit-3.6.1.tar.gz", hash = "sha256:c90961d8aa706f75d60935aba09469a6b0bcb8345f127c3fbee4bdc5f114cf4b"}, ] [package.dependencies] From 67e48c2fd106f860ded6c4689b8377d737b324b9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 11 Feb 2024 11:10:02 +0000 Subject: [PATCH 03/13] fix(deps): update dependency python-multipart to ^0.0.9 --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index d8f636560876..8d304f0ff30b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1965,17 +1965,17 @@ pyasn1_modules = ">=0.1.5" [[package]] name = "python-multipart" -version = "0.0.8" +version = "0.0.9" description = "A streaming multipart parser for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.8-py3-none-any.whl", hash = "sha256:999725bf08cf7a071073d157a27cc34f8669af98da0d2435bde1cc1493a50ec3"}, - {file = "python_multipart-0.0.8.tar.gz", hash = "sha256:613015c642c2f6dc6d22e2d3a4d993683bb4752509ccd87f831dced121ed2f1d"}, + {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, + {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, ] [package.extras] -dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatch", "invoke (==2.2.0)", "more-itertools (==4.3.0)", "pbr (==4.3.0)", "pluggy (==1.0.0)", "py (==1.11.0)", "pytest (==7.2.0)", "pytest-cov (==4.0.0)", "pytest-timeout (==2.1.0)", "pyyaml (==5.1)"] +dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] [[package]] name = "python-slugify" @@ -2944,4 +2944,4 @@ pgsql = ["psycopg2-binary"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "1736aa5636b88092eb4e998a1a783ea13fdf1e4987b524d3570dfbc7f6e6f35a" +content-hash = "b04b135869e2955f04e9a7929abb3608228ef7d558d7bd949d26c4cf8264628d" diff --git a/pyproject.toml b/pyproject.toml index b6a8f944021e..a41e189ae50d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ python-dateutil = "^2.8.2" python-dotenv = "^1.0.0" python-jose = "^3.3.0" python-ldap = "^3.3.1" -python-multipart = "^0.0.8" +python-multipart = "^0.0.9" python-slugify = "^8.0.0" recipe-scrapers = "^14.53.0" requests = "^2.31.0" From 7a107584c7dcf27ef4c0a0d78551e679d5e846b4 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Sun, 11 Feb 2024 10:47:37 -0600 Subject: [PATCH 04/13] feat: Upgrade to Pydantic V2 (#3134) * bumped pydantic --- mealie/app.py | 2 +- mealie/core/settings/db_providers.py | 19 +- mealie/core/settings/settings.py | 42 +-- mealie/core/settings/themes.py | 6 +- mealie/db/models/_model_utils/auto_init.py | 16 +- mealie/db/models/_model_utils/helpers.py | 5 +- mealie/db/models/group/group.py | 7 +- mealie/db/models/group/report.py | 5 +- mealie/db/models/group/shopping_list.py | 17 +- mealie/db/models/recipe/instruction.py | 7 +- mealie/db/models/recipe/recipe.py | 11 +- mealie/db/models/users/users.py | 7 +- mealie/repos/repository_generic.py | 36 +-- mealie/repos/repository_group.py | 4 +- mealie/repos/repository_meal_plan_rules.py | 2 +- mealie/repos/repository_meals.py | 2 +- mealie/repos/repository_recipes.py | 18 +- mealie/repos/repository_users.py | 8 +- mealie/routes/_base/base_controllers.py | 13 +- mealie/routes/_base/controller.py | 24 +- mealie/routes/_base/mixins.py | 2 +- .../routes/admin/admin_management_groups.py | 2 +- mealie/routes/admin/admin_management_users.py | 2 +- mealie/routes/admin/admin_server_tasks.py | 2 +- mealie/routes/app/app_about.py | 2 +- mealie/routes/auth/auth.py | 2 +- mealie/routes/comments/__init__.py | 2 +- .../explore/controller_public_cookbooks.py | 13 +- .../routes/explore/controller_public_foods.py | 2 +- .../explore/controller_public_organizers.py | 8 +- .../explore/controller_public_recipes.py | 15 +- mealie/routes/groups/controller_cookbooks.py | 13 +- .../groups/controller_group_notifications.py | 2 +- mealie/routes/groups/controller_labels.py | 2 +- .../groups/controller_mealplan_rules.py | 2 +- .../groups/controller_shopping_lists.py | 4 +- mealie/routes/groups/controller_webhooks.py | 2 +- .../organizers/controller_categories.py | 12 +- mealie/routes/organizers/controller_tags.py | 2 +- mealie/routes/organizers/controller_tools.py | 2 +- mealie/routes/recipe/recipe_crud_routes.py | 16 +- mealie/routes/recipe/timeline_events.py | 2 +- mealie/routes/shared/__init__.py | 2 +- mealie/routes/unit_and_foods/foods.py | 2 +- mealie/routes/unit_and_foods/units.py | 2 +- mealie/routes/users/crud.py | 4 +- mealie/schema/_mealie/datetime_parse.py | 252 ++++++++++++++++++ mealie/schema/_mealie/mealie_model.py | 19 +- mealie/schema/admin/about.py | 2 +- mealie/schema/admin/backup.py | 4 +- mealie/schema/admin/restore.py | 4 +- mealie/schema/admin/settings.py | 16 +- mealie/schema/cookbook/cookbook.py | 27 +- mealie/schema/getter_dict.py | 33 --- mealie/schema/group/group_events.py | 18 +- mealie/schema/group/group_exports.py | 6 +- mealie/schema/group/group_preferences.py | 6 +- mealie/schema/group/group_seeder.py | 6 +- mealie/schema/group/group_shopping_list.py | 59 ++-- mealie/schema/group/invite_token.py | 8 +- mealie/schema/group/webhook.py | 10 +- mealie/schema/labels/multi_purpose_label.py | 9 +- mealie/schema/make_dependable.py | 6 +- mealie/schema/mapper.py | 6 +- mealie/schema/meal_plan/meal.py | 44 ++- mealie/schema/meal_plan/new_meal.py | 28 +- mealie/schema/meal_plan/plan_rules.py | 13 +- mealie/schema/meal_plan/shopping_list.py | 16 +- mealie/schema/recipe/recipe.py | 99 ++++--- mealie/schema/recipe/recipe_asset.py | 8 +- mealie/schema/recipe/recipe_category.py | 22 +- mealie/schema/recipe/recipe_comments.py | 12 +- mealie/schema/recipe/recipe_ingredient.py | 60 ++--- mealie/schema/recipe/recipe_notes.py | 6 +- mealie/schema/recipe/recipe_nutrition.py | 20 +- mealie/schema/recipe/recipe_scraper.py | 8 +- mealie/schema/recipe/recipe_settings.py | 6 +- mealie/schema/recipe/recipe_share_token.py | 10 +- mealie/schema/recipe/recipe_step.py | 12 +- .../schema/recipe/recipe_timeline_events.py | 19 +- mealie/schema/recipe/recipe_tool.py | 12 +- mealie/schema/recipe/request_helpers.py | 7 +- mealie/schema/reports/reports.py | 10 +- mealie/schema/response/pagination.py | 24 +- mealie/schema/response/responses.py | 6 +- mealie/schema/server/tasks.py | 6 +- mealie/schema/user/auth.py | 9 +- mealie/schema/user/registration.py | 30 ++- mealie/schema/user/user.py | 91 +++---- mealie/schema/user/user_passwords.py | 6 +- mealie/services/backups_v2/backup_file.py | 2 +- mealie/services/email/email_service.py | 2 +- .../event_bus_service/event_bus_listeners.py | 4 +- .../event_bus_service/event_bus_service.py | 6 +- .../services/event_bus_service/event_types.py | 47 ++-- mealie/services/exporter/_abc_exporter.py | 4 +- mealie/services/migrations/nextcloud.py | 2 +- .../migrations/utils/database_helpers.py | 2 +- .../services/parser_services/brute/process.py | 6 +- .../parser_services/crfpp/processor.py | 14 +- .../parser_services/ingredient_parser.py | 2 +- mealie/services/recipe/recipe_service.py | 25 +- mealie/services/recipe/template_service.py | 6 +- .../user_services/registration_service.py | 2 +- poetry.lock | 194 ++++++++++---- pyproject.toml | 5 +- tests/fixtures/fixture_users.py | 4 +- .../test_public_recipes.py | 2 +- .../user_group_tests/test_group_cookbooks.py | 2 +- .../user_group_tests/test_group_invitation.py | 2 +- .../user_group_tests/test_group_mealplan.py | 14 +- .../test_group_mealplan_rules.py | 2 +- .../test_group_notifications.py | 4 +- .../test_group_preferences.py | 4 +- .../test_group_registration.py | 4 +- .../test_group_shopping_list_items.py | 14 +- .../test_group_shopping_lists.py | 22 +- .../test_shopping_list_labels.py | 30 +-- .../test_recipe_bulk_action.py | 4 +- .../user_recipe_tests/test_recipe_crud.py | 4 +- .../user_recipe_tests/test_recipe_foods.py | 4 +- .../user_recipe_tests/test_recipe_steps.py | 2 +- .../test_recipe_timeline_events.py | 34 +-- .../user_recipe_tests/test_recipe_units.py | 4 +- .../user_tests/test_user_registration.py | 9 +- .../repository_tests/test_pagination.py | 10 +- .../schema_tests/test_mealie_model.py | 2 +- .../tasks/test_create_timeline_events.py | 20 +- tests/unit_tests/test_alembic.py | 4 +- 129 files changed, 1138 insertions(+), 833 deletions(-) create mode 100644 mealie/schema/_mealie/datetime_parse.py delete mode 100644 mealie/schema/getter_dict.py diff --git a/mealie/app.py b/mealie/app.py index a8ba8a0feb03..d9705e563321 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -112,7 +112,7 @@ async def system_startup(): logger.info("-----SYSTEM STARTUP----- \n") logger.info("------APP SETTINGS------") logger.info( - settings.json( + settings.model_dump_json( indent=4, exclude={ "SECRET", diff --git a/mealie/core/settings/db_providers.py b/mealie/core/settings/db_providers.py index f3bbc8714388..fff25bdcc14a 100644 --- a/mealie/core/settings/db_providers.py +++ b/mealie/core/settings/db_providers.py @@ -1,7 +1,8 @@ from abc import ABC, abstractmethod from pathlib import Path -from pydantic import BaseModel, BaseSettings, PostgresDsn +from pydantic import BaseModel, PostgresDsn +from pydantic_settings import BaseSettings, SettingsConfigDict class AbstractDBProvider(ABC): @@ -38,15 +39,19 @@ class PostgresProvider(AbstractDBProvider, BaseSettings): POSTGRES_PORT: str = "5432" POSTGRES_DB: str = "mealie" + model_config = SettingsConfigDict(arbitrary_types_allowed=True, extra="allow") + @property def db_url(self) -> str: host = f"{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}" - return PostgresDsn.build( - scheme="postgresql", - user=self.POSTGRES_USER, - password=self.POSTGRES_PASSWORD, - host=host, - path=f"/{self.POSTGRES_DB or ''}", + return str( + PostgresDsn.build( + scheme="postgresql", + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD, + host=host, + path=f"{self.POSTGRES_DB or ''}", + ) ) @property diff --git a/mealie/core/settings/settings.py b/mealie/core/settings/settings.py index 8ed71ab9b1c3..1d35a3614375 100644 --- a/mealie/core/settings/settings.py +++ b/mealie/core/settings/settings.py @@ -1,7 +1,8 @@ import secrets from pathlib import Path -from pydantic import BaseSettings, NoneStr, validator +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict from mealie.core.settings.themes import Theme @@ -55,7 +56,8 @@ class AppSettings(BaseSettings): SECURITY_USER_LOCKOUT_TIME: int = 24 "time in hours" - @validator("BASE_URL") + @field_validator("BASE_URL") + @classmethod def remove_trailing_slash(cls, v: str) -> str: if v and v[-1] == "/": return v[:-1] @@ -100,12 +102,12 @@ class AppSettings(BaseSettings): # =============================================== # Email Configuration - SMTP_HOST: str | None + SMTP_HOST: str | None = None SMTP_PORT: str | None = "587" SMTP_FROM_NAME: str | None = "Mealie" - SMTP_FROM_EMAIL: str | None - SMTP_USER: str | None - SMTP_PASSWORD: str | None + SMTP_FROM_EMAIL: str | None = None + SMTP_USER: str | None = None + SMTP_PASSWORD: str | None = None SMTP_AUTH_STRATEGY: str | None = "TLS" # Options: 'TLS', 'SSL', 'NONE' @property @@ -122,11 +124,11 @@ class AppSettings(BaseSettings): @staticmethod def validate_smtp( - host: str | None, - port: str | None, - from_name: str | None, - from_email: str | None, - strategy: str | None, + host: str | None = None, + port: str | None = None, + from_name: str | None = None, + from_email: str | None = None, + strategy: str | None = None, user: str | None = None, password: str | None = None, ) -> bool: @@ -143,15 +145,15 @@ class AppSettings(BaseSettings): # LDAP Configuration LDAP_AUTH_ENABLED: bool = False - LDAP_SERVER_URL: NoneStr = None + LDAP_SERVER_URL: str | None = None LDAP_TLS_INSECURE: bool = False - LDAP_TLS_CACERTFILE: NoneStr = None + LDAP_TLS_CACERTFILE: str | None = None LDAP_ENABLE_STARTTLS: bool = False - LDAP_BASE_DN: NoneStr = None - LDAP_QUERY_BIND: NoneStr = None - LDAP_QUERY_PASSWORD: NoneStr = None - LDAP_USER_FILTER: NoneStr = None - LDAP_ADMIN_FILTER: NoneStr = None + LDAP_BASE_DN: str | None = None + LDAP_QUERY_BIND: str | None = None + LDAP_QUERY_PASSWORD: str | None = None + LDAP_USER_FILTER: str | None = None + LDAP_ADMIN_FILTER: str | None = None LDAP_ID_ATTRIBUTE: str = "uid" LDAP_MAIL_ATTRIBUTE: str = "mail" LDAP_NAME_ATTRIBUTE: str = "name" @@ -173,9 +175,7 @@ class AppSettings(BaseSettings): # Testing Config TESTING: bool = False - - class Config: - arbitrary_types_allowed = True + model_config = SettingsConfigDict(arbitrary_types_allowed=True, extra="allow") def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings: diff --git a/mealie/core/settings/themes.py b/mealie/core/settings/themes.py index e2fc8113dc4b..af67d0de04ca 100644 --- a/mealie/core/settings/themes.py +++ b/mealie/core/settings/themes.py @@ -1,4 +1,4 @@ -from pydantic import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class Theme(BaseSettings): @@ -17,6 +17,4 @@ class Theme(BaseSettings): dark_info: str = "#1976D2" dark_warning: str = "#FF6D00" dark_error: str = "#EF5350" - - class Config: - env_prefix = "theme_" + model_config = SettingsConfigDict(env_prefix="theme_", extra="allow") diff --git a/mealie/db/models/_model_utils/auto_init.py b/mealie/db/models/_model_utils/auto_init.py index d61308b3f6b4..6b36fadedb3c 100644 --- a/mealie/db/models/_model_utils/auto_init.py +++ b/mealie/db/models/_model_utils/auto_init.py @@ -1,7 +1,7 @@ from functools import wraps from uuid import UUID -from pydantic import BaseModel, Field, NoneStr +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import select from sqlalchemy.orm import MANYTOMANY, MANYTOONE, ONETOMANY, Session from sqlalchemy.orm.mapper import Mapper @@ -21,7 +21,7 @@ class AutoInitConfig(BaseModel): Config class for `auto_init` decorator. """ - get_attr: NoneStr = None + get_attr: str | None = None exclude: set = Field(default_factory=_default_exclusion) # auto_create: bool = False @@ -31,16 +31,16 @@ def _get_config(relation_cls: type[SqlAlchemyBase]) -> AutoInitConfig: Returns the config for the given class. """ cfg = AutoInitConfig() - cfgKeys = cfg.dict().keys() + cfgKeys = cfg.model_dump().keys() # Get the config for the class try: - class_config: AutoInitConfig = relation_cls.Config + class_config: ConfigDict = relation_cls.model_config except AttributeError: return cfg # Map all matching attributes in Config to all AutoInitConfig attributes - for attr in dir(class_config): + for attr in class_config: if attr in cfgKeys: - setattr(cfg, attr, getattr(class_config, attr)) + setattr(cfg, attr, class_config[attr]) return cfg @@ -97,7 +97,7 @@ def handle_one_to_many_list( updated_elems.append(existing_elem) - new_elems = [safe_call(relation_cls, elem, session=session) for elem in elems_to_create] + new_elems = [safe_call(relation_cls, elem.copy(), session=session) for elem in elems_to_create] return new_elems + updated_elems @@ -164,7 +164,7 @@ def auto_init(): # sourcery no-metrics setattr(self, key, instances) elif relation_dir == ONETOMANY: - instance = safe_call(relation_cls, val, session=session) + instance = safe_call(relation_cls, val.copy() if val else None, session=session) setattr(self, key, instance) elif relation_dir == MANYTOONE and not use_list: diff --git a/mealie/db/models/_model_utils/helpers.py b/mealie/db/models/_model_utils/helpers.py index 5e24f2dd3f90..3f4ba441b5e7 100644 --- a/mealie/db/models/_model_utils/helpers.py +++ b/mealie/db/models/_model_utils/helpers.py @@ -29,12 +29,15 @@ def get_valid_call(func: Callable, args_dict) -> dict: return {k: v for k, v in args_dict.items() if k in valid_args} -def safe_call(func, dict_args: dict, **kwargs) -> Any: +def safe_call(func, dict_args: dict | None, **kwargs) -> Any: """ Safely calls the supplied function with the supplied dictionary of arguments. by removing any invalid arguments. """ + if dict_args is None: + dict_args = {} + if kwargs: dict_args.update(kwargs) diff --git a/mealie/db/models/group/group.py b/mealie/db/models/group/group.py index a8bf0e5b750c..069d840e86b6 100644 --- a/mealie/db/models/group/group.py +++ b/mealie/db/models/group/group.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Optional import sqlalchemy as sa import sqlalchemy.orm as orm +from pydantic import ConfigDict from sqlalchemy import select from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm.session import Session @@ -79,9 +80,8 @@ class Group(SqlAlchemyBase, BaseMixins): ingredient_foods: Mapped[list["IngredientFoodModel"]] = orm.relationship("IngredientFoodModel", **common_args) tools: Mapped[list["Tool"]] = orm.relationship("Tool", **common_args) tags: Mapped[list["Tag"]] = orm.relationship("Tag", **common_args) - - class Config: - exclude = { + model_config = ConfigDict( + exclude={ "users", "webhooks", "shopping_lists", @@ -91,6 +91,7 @@ class Group(SqlAlchemyBase, BaseMixins): "mealplans", "data_exports", } + ) @auto_init() def __init__(self, **_) -> None: diff --git a/mealie/db/models/group/report.py b/mealie/db/models/group/report.py index c7771efcde74..011589c3c18d 100644 --- a/mealie/db/models/group/report.py +++ b/mealie/db/models/group/report.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING +from pydantic import ConfigDict from sqlalchemy import ForeignKey, orm from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql.sqltypes import Boolean, DateTime, String @@ -47,9 +48,7 @@ class ReportModel(SqlAlchemyBase, BaseMixins): # Relationships group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) group: Mapped["Group"] = orm.relationship("Group", back_populates="group_reports", single_parent=True) - - class Config: - exclude = ["entries"] + model_config = ConfigDict(exclude=["entries"]) @auto_init() def __init__(self, **_) -> None: diff --git a/mealie/db/models/group/shopping_list.py b/mealie/db/models/group/shopping_list.py index 769b65032272..68f69d5b9687 100644 --- a/mealie/db/models/group/shopping_list.py +++ b/mealie/db/models/group/shopping_list.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Optional +from pydantic import ConfigDict from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, UniqueConstraint, orm from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.orm import Mapped, mapped_column @@ -69,9 +70,7 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins): recipe_references: Mapped[list[ShoppingListItemRecipeReference]] = orm.relationship( ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan" ) - - class Config: - exclude = {"id", "label", "food", "unit"} + model_config = ConfigDict(exclude={"id", "label", "food", "unit"}) @api_extras @auto_init() @@ -91,9 +90,7 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase): ) recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False) - - class Config: - exclude = {"id", "recipe"} + model_config = ConfigDict(exclude={"id", "recipe"}) @auto_init() def __init__(self, **_) -> None: @@ -112,9 +109,7 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins): "MultiPurposeLabel", back_populates="shopping_lists_label_settings" ) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - - class Config: - exclude = {"label"} + model_config = ConfigDict(exclude={"label"}) @auto_init() def __init__(self, **_) -> None: @@ -146,9 +141,7 @@ class ShoppingList(SqlAlchemyBase, BaseMixins): collection_class=ordering_list("position"), ) extras: Mapped[list[ShoppingListExtras]] = orm.relationship("ShoppingListExtras", cascade="all, delete-orphan") - - class Config: - exclude = {"id", "list_items"} + model_config = ConfigDict(exclude={"id", "list_items"}) @api_extras @auto_init() diff --git a/mealie/db/models/recipe/instruction.py b/mealie/db/models/recipe/instruction.py index 01592ae30d67..f3d76f3905cb 100644 --- a/mealie/db/models/recipe/instruction.py +++ b/mealie/db/models/recipe/instruction.py @@ -1,3 +1,4 @@ +from pydantic import ConfigDict from sqlalchemy import ForeignKey, Integer, String, orm from sqlalchemy.orm import Mapped, mapped_column @@ -28,12 +29,12 @@ class RecipeInstruction(SqlAlchemyBase): ingredient_references: Mapped[list[RecipeIngredientRefLink]] = orm.relationship( RecipeIngredientRefLink, cascade="all, delete-orphan" ) - - class Config: - exclude = { + model_config = ConfigDict( + exclude={ "id", "ingredient_references", } + ) @auto_init() def __init__(self, ingredient_references, session, **_) -> None: diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index ed559d3a6023..598ff220066c 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING import sqlalchemy as sa import sqlalchemy.orm as orm +from pydantic import ConfigDict from sqlalchemy import event from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.orm import Mapped, mapped_column, validates @@ -134,10 +135,9 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): # Automatically updated by sqlalchemy event, do not write to this manually name_normalized: Mapped[str] = mapped_column(sa.String, nullable=False, index=True) description_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) - - class Config: - get_attr = "slug" - exclude = { + model_config = ConfigDict( + get_attr="slug", + exclude={ "assets", "notes", "nutrition", @@ -146,7 +146,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): "settings", "comments", "timeline_events", - } + }, + ) @validates("name") def validate_name(self, _, name): diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 3e913b7895f1..ca6bea91fadb 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -2,6 +2,7 @@ import enum from datetime import datetime from typing import TYPE_CHECKING, Optional +from pydantic import ConfigDict from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, orm from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column @@ -84,9 +85,8 @@ class User(SqlAlchemyBase, BaseMixins): favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship( "RecipeModel", secondary=users_to_favorites, back_populates="favorited_by" ) - - class Config: - exclude = { + model_config = ConfigDict( + exclude={ "password", "admin", "can_manage", @@ -94,6 +94,7 @@ class User(SqlAlchemyBase, BaseMixins): "can_organize", "group", } + ) @hybrid_property def group_slug(self) -> str: diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index 6e96af42a16f..75a447c4298b 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -106,7 +106,7 @@ class RepositoryGeneric(Generic[Schema, Model]): except AttributeError: self.logger.info(f'Attempted to sort by unknown sort property "{order_by}"; ignoring') result = self.session.execute(q.offset(start).limit(limit)).unique().scalars().all() - return [eff_schema.from_orm(x) for x in result] + return [eff_schema.model_validate(x) for x in result] def multi_query( self, @@ -129,7 +129,7 @@ class RepositoryGeneric(Generic[Schema, Model]): q = q.offset(start).limit(limit) result = self.session.execute(q).unique().scalars().all() - return [eff_schema.from_orm(x) for x in result] + return [eff_schema.model_validate(x) for x in result] def _query_one(self, match_value: str | int | UUID4, match_key: str | None = None) -> Model: """ @@ -161,11 +161,11 @@ class RepositoryGeneric(Generic[Schema, Model]): if not result: return None - return eff_schema.from_orm(result) + return eff_schema.model_validate(result) def create(self, data: Schema | BaseModel | dict) -> Schema: try: - data = data if isinstance(data, dict) else data.dict() + data = data if isinstance(data, dict) else data.model_dump() new_document = self.model(session=self.session, **data) self.session.add(new_document) self.session.commit() @@ -175,12 +175,12 @@ class RepositoryGeneric(Generic[Schema, Model]): self.session.refresh(new_document) - return self.schema.from_orm(new_document) + return self.schema.model_validate(new_document) def create_many(self, data: Iterable[Schema | dict]) -> list[Schema]: new_documents = [] for document in data: - document = document if isinstance(document, dict) else document.dict() + document = document if isinstance(document, dict) else document.model_dump() new_document = self.model(session=self.session, **document) new_documents.append(new_document) @@ -190,7 +190,7 @@ class RepositoryGeneric(Generic[Schema, Model]): for created_document in new_documents: self.session.refresh(created_document) - return [self.schema.from_orm(x) for x in new_documents] + return [self.schema.model_validate(x) for x in new_documents] def update(self, match_value: str | int | UUID4, new_data: dict | BaseModel) -> Schema: """Update a database entry. @@ -202,18 +202,18 @@ class RepositoryGeneric(Generic[Schema, Model]): Returns: dict: Returns a dictionary representation of the database entry """ - new_data = new_data if isinstance(new_data, dict) else new_data.dict() + new_data = new_data if isinstance(new_data, dict) else new_data.model_dump() entry = self._query_one(match_value=match_value) entry.update(session=self.session, **new_data) self.session.commit() - return self.schema.from_orm(entry) + return self.schema.model_validate(entry) def update_many(self, data: Iterable[Schema | dict]) -> list[Schema]: document_data_by_id: dict[str, dict] = {} for document in data: - document_data = document if isinstance(document, dict) else document.dict() + document_data = document if isinstance(document, dict) else document.model_dump() document_data_by_id[document_data["id"]] = document_data documents_to_update_query = self._query().filter(self.model.id.in_(list(document_data_by_id.keys()))) @@ -226,14 +226,14 @@ class RepositoryGeneric(Generic[Schema, Model]): updated_documents.append(document_to_update) self.session.commit() - return [self.schema.from_orm(x) for x in updated_documents] + return [self.schema.model_validate(x) for x in updated_documents] def patch(self, match_value: str | int | UUID4, new_data: dict | BaseModel) -> Schema: - new_data = new_data if isinstance(new_data, dict) else new_data.dict() + new_data = new_data if isinstance(new_data, dict) else new_data.model_dump() entry = self._query_one(match_value=match_value) - entry_as_dict = self.schema.from_orm(entry).dict() + entry_as_dict = self.schema.model_validate(entry).model_dump() entry_as_dict.update(new_data) return self.update(match_value, entry_as_dict) @@ -242,7 +242,7 @@ class RepositoryGeneric(Generic[Schema, Model]): match_key = match_key or self.primary_key result = self._query_one(value, match_key) - results_as_model = self.schema.from_orm(result) + results_as_model = self.schema.model_validate(result) try: self.session.delete(result) @@ -256,7 +256,7 @@ class RepositoryGeneric(Generic[Schema, Model]): def delete_many(self, values: Iterable) -> Schema: query = self._query().filter(self.model.id.in_(values)) # type: ignore results = self.session.execute(query).unique().scalars().all() - results_as_model = [self.schema.from_orm(result) for result in results] + results_as_model = [self.schema.model_validate(result) for result in results] try: # we create a delete statement for each row @@ -295,7 +295,7 @@ class RepositoryGeneric(Generic[Schema, Model]): return self.session.scalar(q) else: q = self._query(override_schema=eff_schema).filter(attribute_name == attr_match) - return [eff_schema.from_orm(x) for x in self.session.execute(q).scalars().all()] + return [eff_schema.model_validate(x) for x in self.session.execute(q).scalars().all()] def page_all(self, pagination: PaginationQuery, override=None, search: str | None = None) -> PaginationBase[Schema]: """ @@ -309,7 +309,7 @@ class RepositoryGeneric(Generic[Schema, Model]): """ eff_schema = override or self.schema # Copy this, because calling methods (e.g. tests) might rely on it not getting mutated - pagination_result = pagination.copy() + pagination_result = pagination.model_copy() q = self._query(override_schema=eff_schema, with_options=False) fltr = self._filter_builder() @@ -336,7 +336,7 @@ class RepositoryGeneric(Generic[Schema, Model]): per_page=pagination_result.per_page, total=count, total_pages=total_pages, - items=[eff_schema.from_orm(s) for s in data], + items=[eff_schema.model_validate(s) for s in data], ) def add_pagination_to_query(self, query: Select, pagination: PaginationQuery) -> tuple[Select, int, int]: diff --git a/mealie/repos/repository_group.py b/mealie/repos/repository_group.py index 0d8a2dd069a5..99b0f0b65be0 100644 --- a/mealie/repos/repository_group.py +++ b/mealie/repos/repository_group.py @@ -23,7 +23,7 @@ from .repository_generic import RepositoryGeneric class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]): def create(self, data: GroupBase | dict) -> GroupInDB: if isinstance(data, GroupBase): - data = data.dict() + data = data.model_dump() max_attempts = 10 original_name = cast(str, data["name"]) @@ -61,7 +61,7 @@ class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]): dbgroup = self.session.execute(select(self.model).filter_by(name=name)).scalars().one_or_none() if dbgroup is None: return None - return self.schema.from_orm(dbgroup) + return self.schema.model_validate(dbgroup) def get_by_slug_or_id(self, slug_or_id: str | UUID) -> GroupInDB | None: if isinstance(slug_or_id, str): diff --git a/mealie/repos/repository_meal_plan_rules.py b/mealie/repos/repository_meal_plan_rules.py index 02cd29899628..9e1f17090755 100644 --- a/mealie/repos/repository_meal_plan_rules.py +++ b/mealie/repos/repository_meal_plan_rules.py @@ -28,4 +28,4 @@ class RepositoryMealPlanRules(RepositoryGeneric[PlanRulesOut, GroupMealPlanRules rules = self.session.execute(stmt).scalars().all() - return [self.schema.from_orm(x) for x in rules] + return [self.schema.model_validate(x) for x in rules] diff --git a/mealie/repos/repository_meals.py b/mealie/repos/repository_meals.py index 55c9272a75dc..e08b79ece21e 100644 --- a/mealie/repos/repository_meals.py +++ b/mealie/repos/repository_meals.py @@ -17,4 +17,4 @@ class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]): today = date.today() stmt = select(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id) plans = self.session.execute(stmt).scalars().all() - return [self.schema.from_orm(x) for x in plans] + return [self.schema.model_validate(x) for x in plans] diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index c5a0fe1e3ace..d2a9993628c2 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -58,7 +58,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): .offset(start) .limit(limit) ) - return [eff_schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()] + return [eff_schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()] stmt = ( select(self.model) @@ -67,7 +67,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): .offset(start) .limit(limit) ) - return [eff_schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()] + return [eff_schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()] def update_image(self, slug: str, _: str | None = None) -> int: entry: RecipeModel = self._query_one(match_value=slug) @@ -160,7 +160,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): search: str | None = None, ) -> RecipePagination: # Copy this, because calling methods (e.g. tests) might rely on it not getting mutated - pagination_result = pagination.copy() + pagination_result = pagination.model_copy() q = select(self.model) args = [ @@ -216,7 +216,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): self.session.rollback() raise e - items = [RecipeSummary.from_orm(item) for item in data] + items = [RecipeSummary.model_validate(item) for item in data] return RecipePagination( page=pagination_result.page, per_page=pagination_result.per_page, @@ -236,7 +236,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): .join(RecipeModel.recipe_category) .filter(RecipeModel.recipe_category.any(Category.id.in_(ids))) ) - return [RecipeSummary.from_orm(x) for x in self.session.execute(stmt).unique().scalars().all()] + return [RecipeSummary.model_validate(x) for x in self.session.execute(stmt).unique().scalars().all()] def _build_recipe_filter( self, @@ -298,7 +298,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): require_all_tools=require_all_tools, ) stmt = select(RecipeModel).filter(*fltr) - return [self.schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()] + return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()] def get_random_by_categories_and_tags( self, categories: list[RecipeCategory], tags: list[RecipeTag] @@ -316,7 +316,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): stmt = ( select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific ) - return [self.schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()] + return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()] def get_random(self, limit=1) -> list[Recipe]: stmt = ( @@ -325,14 +325,14 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): .order_by(func.random()) # Postgres and SQLite specific .limit(limit) ) - return [self.schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()] + return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()] def get_by_slug(self, group_id: UUID4, slug: str, limit=1) -> Recipe | None: stmt = select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug) dbrecipe = self.session.execute(stmt).scalars().one_or_none() if dbrecipe is None: return None - return self.schema.from_orm(dbrecipe) + return self.schema.model_validate(dbrecipe) def all_ids(self, group_id: UUID4) -> Sequence[UUID4]: stmt = select(RecipeModel.id).filter(RecipeModel.group_id == group_id) diff --git a/mealie/repos/repository_users.py b/mealie/repos/repository_users.py index 1370e2499d18..fafd2060b2df 100644 --- a/mealie/repos/repository_users.py +++ b/mealie/repos/repository_users.py @@ -18,7 +18,7 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]): def update_password(self, id, password: str): entry = self._query_one(match_value=id) if settings.IS_DEMO: - user_to_update = self.schema.from_orm(entry) + user_to_update = self.schema.model_validate(entry) if user_to_update.is_default_user: # do not update the default user in demo mode return user_to_update @@ -26,7 +26,7 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]): entry.update_password(password) self.session.commit() - return self.schema.from_orm(entry) + return self.schema.model_validate(entry) def create(self, user: PrivateUser | dict): # type: ignore new_user = super().create(user) @@ -66,9 +66,9 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]): def get_by_username(self, username: str) -> PrivateUser | None: stmt = select(User).filter(User.username == username) dbuser = self.session.execute(stmt).scalars().one_or_none() - return None if dbuser is None else self.schema.from_orm(dbuser) + return None if dbuser is None else self.schema.model_validate(dbuser) def get_locked_users(self) -> list[PrivateUser]: stmt = select(User).filter(User.locked_at != None) # noqa E711 results = self.session.execute(stmt).scalars().all() - return [self.schema.from_orm(x) for x in results] + return [self.schema.model_validate(x) for x in results] diff --git a/mealie/routes/_base/base_controllers.py b/mealie/routes/_base/base_controllers.py index 1f5e676dcd99..0fae6b863a98 100644 --- a/mealie/routes/_base/base_controllers.py +++ b/mealie/routes/_base/base_controllers.py @@ -2,7 +2,7 @@ from abc import ABC from logging import Logger from fastapi import Depends -from pydantic import UUID4 +from pydantic import UUID4, ConfigDict from sqlalchemy.orm import Session from mealie.core.config import get_app_dirs, get_app_settings @@ -25,10 +25,10 @@ class _BaseController(ABC): session: Session = Depends(generate_session) translator: Translator = Depends(local_provider) - _repos: AllRepositories | None - _logger: Logger | None - _settings: AppSettings | None - _folders: AppDirectories | None + _repos: AllRepositories | None = None + _logger: Logger | None = None + _settings: AppSettings | None = None + _folders: AppDirectories | None = None @property def t(self): @@ -58,8 +58,7 @@ class _BaseController(ABC): self._folders = get_app_dirs() return self._folders - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) class BasePublicController(_BaseController): diff --git a/mealie/routes/_base/controller.py b/mealie/routes/_base/controller.py index f832c13b7992..f7e13f34b381 100644 --- a/mealie/routes/_base/controller.py +++ b/mealie/routes/_base/controller.py @@ -6,11 +6,10 @@ See their repository for details -> https://github.com/dmontagu/fastapi-utils import inspect from collections.abc import Callable -from typing import Any, TypeVar, cast, get_type_hints +from typing import Any, ClassVar, ForwardRef, TypeVar, cast, get_origin, get_type_hints from fastapi import APIRouter, Depends from fastapi.routing import APIRoute -from pydantic.typing import is_classvar from starlette.routing import Route, WebSocketRoute T = TypeVar("T") @@ -47,6 +46,25 @@ def _cbv(router: APIRouter, cls: type[T], *urls: str, instance: Any | None = Non return cls +# copied from Pydantic V1 Source: https://github.com/pydantic/pydantic/blob/1c91c8627b541b22354b9ed56b9ef1bb21ac6fbd/pydantic/v1/typing.py +def _check_classvar(v: type[Any] | None) -> bool: + if v is None: + return False + + return v.__class__ == ClassVar.__class__ and getattr(v, "_name", None) == "ClassVar" + + +# copied from Pydantic V1 Source: https://github.com/pydantic/pydantic/blob/1c91c8627b541b22354b9ed56b9ef1bb21ac6fbd/pydantic/v1/typing.py +def _is_classvar(ann_type: type[Any]) -> bool: + if _check_classvar(ann_type) or _check_classvar(get_origin(ann_type)): + return True + + if ann_type.__class__ == ForwardRef and ann_type.__forward_arg__.startswith("ClassVar["): # type: ignore + return True + + return False + + def _init_cbv(cls: type[Any], instance: Any | None = None) -> None: """ Idempotently modifies the provided `cls`, performing the following modifications: @@ -67,7 +85,7 @@ def _init_cbv(cls: type[Any], instance: Any | None = None) -> None: dependency_names: list[str] = [] for name, hint in get_type_hints(cls).items(): - if is_classvar(hint): + if _is_classvar(hint): continue if name.startswith("_"): diff --git a/mealie/routes/_base/mixins.py b/mealie/routes/_base/mixins.py index 3de57a171ed9..34fdeec71a2f 100644 --- a/mealie/routes/_base/mixins.py +++ b/mealie/routes/_base/mixins.py @@ -108,7 +108,7 @@ class HttpRepo(Generic[C, R, U]): ) try: - item = self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True)) + item = self.repo.patch(item_id, data.model_dump(exclude_unset=True, exclude_defaults=True)) except Exception as ex: self.handle_exception(ex) diff --git a/mealie/routes/admin/admin_management_groups.py b/mealie/routes/admin/admin_management_groups.py index 7ca9d9ef7e00..299e1d7c9d74 100644 --- a/mealie/routes/admin/admin_management_groups.py +++ b/mealie/routes/admin/admin_management_groups.py @@ -43,7 +43,7 @@ class AdminUserManagementRoutes(BaseAdminController): override=GroupInDB, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED) diff --git a/mealie/routes/admin/admin_management_users.py b/mealie/routes/admin/admin_management_users.py index 00bc84b59423..a8c3e5c5e315 100644 --- a/mealie/routes/admin/admin_management_users.py +++ b/mealie/routes/admin/admin_management_users.py @@ -37,7 +37,7 @@ class AdminUserManagementRoutes(BaseAdminController): override=UserOut, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=UserOut, status_code=201) diff --git a/mealie/routes/admin/admin_server_tasks.py b/mealie/routes/admin/admin_server_tasks.py index 0750dd4e3375..a179e9b1e78b 100644 --- a/mealie/routes/admin/admin_server_tasks.py +++ b/mealie/routes/admin/admin_server_tasks.py @@ -18,7 +18,7 @@ class AdminServerTasksController(BaseAdminController): override=ServerTask, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("/server-tasks", response_model=ServerTask, status_code=201) diff --git a/mealie/routes/app/app_about.py b/mealie/routes/app/app_about.py index f1cfad76f483..54e11ab8f3ac 100644 --- a/mealie/routes/app/app_about.py +++ b/mealie/routes/app/app_about.py @@ -53,4 +53,4 @@ def get_app_theme(resp: Response): settings = get_app_settings() resp.headers["Cache-Control"] = "public, max-age=604800" - return AppTheme(**settings.theme.dict()) + return AppTheme(**settings.theme.model_dump()) diff --git a/mealie/routes/auth/auth.py b/mealie/routes/auth/auth.py index 8aa6cd6664c8..d17ff4145790 100644 --- a/mealie/routes/auth/auth.py +++ b/mealie/routes/auth/auth.py @@ -48,7 +48,7 @@ class MealieAuthToken(BaseModel): @classmethod def respond(cls, token: str, token_type: str = "bearer") -> dict: - return cls(access_token=token, token_type=token_type).dict() + return cls(access_token=token, token_type=token_type).model_dump() @public_router.post("/token") diff --git a/mealie/routes/comments/__init__.py b/mealie/routes/comments/__init__.py index a5bf7309b23d..fa158cb10b77 100644 --- a/mealie/routes/comments/__init__.py +++ b/mealie/routes/comments/__init__.py @@ -47,7 +47,7 @@ class RecipeCommentRoutes(BaseUserController): override=RecipeCommentOut, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=RecipeCommentOut, status_code=201) diff --git a/mealie/routes/explore/controller_public_cookbooks.py b/mealie/routes/explore/controller_public_cookbooks.py index 67afedd33674..5e1b07b1c594 100644 --- a/mealie/routes/explore/controller_public_cookbooks.py +++ b/mealie/routes/explore/controller_public_cookbooks.py @@ -1,3 +1,5 @@ +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException from pydantic import UUID4 @@ -36,12 +38,19 @@ class PublicCookbooksController(BasePublicExploreController): search=search, ) - response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump()) return response @router.get("/{item_id}", response_model=RecipeCookBook) def get_one(self, item_id: UUID4 | str) -> RecipeCookBook: - match_attr = "slug" if isinstance(item_id, str) else "id" + if isinstance(item_id, UUID): + match_attr = "id" + else: + try: + UUID(item_id) + match_attr = "id" + except ValueError: + match_attr = "slug" cookbook = self.cookbooks.get_one(item_id, match_attr) if not cookbook or not cookbook.public: diff --git a/mealie/routes/explore/controller_public_foods.py b/mealie/routes/explore/controller_public_foods.py index 2aff2c55fba8..bc9cf832edc4 100644 --- a/mealie/routes/explore/controller_public_foods.py +++ b/mealie/routes/explore/controller_public_foods.py @@ -26,7 +26,7 @@ class PublicFoodsController(BasePublicExploreController): search=search, ) - response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump()) return response @router.get("/{item_id}", response_model=IngredientFood) diff --git a/mealie/routes/explore/controller_public_organizers.py b/mealie/routes/explore/controller_public_organizers.py index 5ac4dc540726..9ea0731c398e 100644 --- a/mealie/routes/explore/controller_public_organizers.py +++ b/mealie/routes/explore/controller_public_organizers.py @@ -31,7 +31,9 @@ class PublicCategoriesController(BasePublicExploreController): search=search, ) - response.set_pagination_guides(categories_router.url_path_for("get_all", group_slug=self.group.slug), q.dict()) + response.set_pagination_guides( + categories_router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump() + ) return response @categories_router.get("/{item_id}", response_model=CategoryOut) @@ -59,7 +61,7 @@ class PublicTagsController(BasePublicExploreController): search=search, ) - response.set_pagination_guides(tags_router.url_path_for("get_all", group_slug=self.group.slug), q.dict()) + response.set_pagination_guides(tags_router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump()) return response @tags_router.get("/{item_id}", response_model=TagOut) @@ -87,7 +89,7 @@ class PublicToolsController(BasePublicExploreController): search=search, ) - response.set_pagination_guides(tools_router.url_path_for("get_all", group_slug=self.group.slug), q.dict()) + response.set_pagination_guides(tools_router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump()) return response @tools_router.get("/{item_id}", response_model=RecipeToolOut) diff --git a/mealie/routes/explore/controller_public_recipes.py b/mealie/routes/explore/controller_public_recipes.py index 77290f933af7..04ebcb256e3d 100644 --- a/mealie/routes/explore/controller_public_recipes.py +++ b/mealie/routes/explore/controller_public_recipes.py @@ -1,3 +1,5 @@ +from uuid import UUID + import orjson from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import UUID4 @@ -37,7 +39,14 @@ class PublicRecipesController(BasePublicExploreController): ) -> PaginationBase[RecipeSummary]: cookbook_data: ReadCookBook | None = None if search_query.cookbook: - cb_match_attr = "slug" if isinstance(search_query.cookbook, str) else "id" + if isinstance(search_query.cookbook, UUID): + cb_match_attr = "id" + else: + try: + UUID(search_query.cookbook) + cb_match_attr = "id" + except ValueError: + cb_match_attr = "slug" cookbook_data = self.cookbooks.get_one(search_query.cookbook, cb_match_attr) if cookbook_data is None or not cookbook_data.public: @@ -64,13 +73,13 @@ class PublicRecipesController(BasePublicExploreController): ) # merge default pagination with the request's query params - query_params = q.dict() | {**request.query_params} + query_params = q.model_dump() | {**request.query_params} pagination_response.set_pagination_guides( router.url_path_for("get_all", group_slug=self.group.slug), {k: v for k, v in query_params.items() if v is not None}, ) - json_compatible_response = orjson.dumps(pagination_response.dict(by_alias=True)) + json_compatible_response = orjson.dumps(pagination_response.model_dump(by_alias=True)) # Response is returned directly, to avoid validation and improve performance return JSONBytes(content=json_compatible_response) diff --git a/mealie/routes/groups/controller_cookbooks.py b/mealie/routes/groups/controller_cookbooks.py index f9ce17cb471e..eb9305b83331 100644 --- a/mealie/routes/groups/controller_cookbooks.py +++ b/mealie/routes/groups/controller_cookbooks.py @@ -1,4 +1,5 @@ from functools import cached_property +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from pydantic import UUID4 @@ -48,7 +49,7 @@ class GroupCookbookController(BaseCrudController): override=ReadCookBook, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=ReadCookBook, status_code=201) @@ -85,7 +86,15 @@ class GroupCookbookController(BaseCrudController): @router.get("/{item_id}", response_model=RecipeCookBook) def get_one(self, item_id: UUID4 | str): - match_attr = "slug" if isinstance(item_id, str) else "id" + if isinstance(item_id, UUID): + match_attr = "id" + else: + try: + UUID(item_id) + match_attr = "id" + except ValueError: + match_attr = "slug" + cookbook = self.repo.get_one(item_id, match_attr) if cookbook is None: diff --git a/mealie/routes/groups/controller_group_notifications.py b/mealie/routes/groups/controller_group_notifications.py index ba7a82054ef2..fe3576ed6b43 100644 --- a/mealie/routes/groups/controller_group_notifications.py +++ b/mealie/routes/groups/controller_group_notifications.py @@ -58,7 +58,7 @@ class GroupEventsNotifierController(BaseUserController): override=GroupEventNotifierOut, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=GroupEventNotifierOut, status_code=201) diff --git a/mealie/routes/groups/controller_labels.py b/mealie/routes/groups/controller_labels.py index 45f3c20d8767..4c1abadf47d2 100644 --- a/mealie/routes/groups/controller_labels.py +++ b/mealie/routes/groups/controller_labels.py @@ -48,7 +48,7 @@ class MultiPurposeLabelsController(BaseUserController): search=search, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=MultiPurposeLabelOut) diff --git a/mealie/routes/groups/controller_mealplan_rules.py b/mealie/routes/groups/controller_mealplan_rules.py index 2cc25706e833..ba6ed75bbe92 100644 --- a/mealie/routes/groups/controller_mealplan_rules.py +++ b/mealie/routes/groups/controller_mealplan_rules.py @@ -31,7 +31,7 @@ class GroupMealplanConfigController(BaseUserController): override=PlanRulesOut, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=PlanRulesOut, status_code=201) diff --git a/mealie/routes/groups/controller_shopping_lists.py b/mealie/routes/groups/controller_shopping_lists.py index 290c71d9c9a4..a61ad3f397f1 100644 --- a/mealie/routes/groups/controller_shopping_lists.py +++ b/mealie/routes/groups/controller_shopping_lists.py @@ -105,7 +105,7 @@ class ShoppingListItemController(BaseCrudController): @item_router.get("", response_model=ShoppingListItemPagination) def get_all(self, q: PaginationQuery = Depends()): response = self.repo.page_all(pagination=q, override=ShoppingListItemOut) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @item_router.post("/create-bulk", response_model=ShoppingListItemsCollectionOut, status_code=201) @@ -174,7 +174,7 @@ class ShoppingListController(BaseCrudController): override=ShoppingListSummary, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=ShoppingListOut, status_code=201) diff --git a/mealie/routes/groups/controller_webhooks.py b/mealie/routes/groups/controller_webhooks.py index 3206223527b3..4259e999457c 100644 --- a/mealie/routes/groups/controller_webhooks.py +++ b/mealie/routes/groups/controller_webhooks.py @@ -32,7 +32,7 @@ class ReadWebhookController(BaseUserController): override=ReadWebhook, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=ReadWebhook, status_code=201) diff --git a/mealie/routes/organizers/controller_categories.py b/mealie/routes/organizers/controller_categories.py index a06b42b2579c..1924f8bacae1 100644 --- a/mealie/routes/organizers/controller_categories.py +++ b/mealie/routes/organizers/controller_categories.py @@ -1,7 +1,7 @@ from functools import cached_property from fastapi import APIRouter, Depends -from pydantic import UUID4, BaseModel +from pydantic import UUID4, BaseModel, ConfigDict from mealie.routes._base import BaseCrudController, controller from mealie.routes._base.mixins import HttpRepo @@ -20,9 +20,7 @@ class CategorySummary(BaseModel): id: UUID4 slug: str name: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @controller(router) @@ -46,7 +44,7 @@ class RecipeCategoryController(BaseCrudController): search=search, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", status_code=201) @@ -71,7 +69,7 @@ class RecipeCategoryController(BaseCrudController): def get_one(self, item_id: UUID4): """Returns a list of recipes associated with the provided category.""" category_obj = self.mixins.get_one(item_id) - category_obj = CategorySummary.from_orm(category_obj) + category_obj = CategorySummary.model_validate(category_obj) return category_obj @router.put("/{item_id}", response_model=CategorySummary) @@ -119,7 +117,7 @@ class RecipeCategoryController(BaseCrudController): def get_one_by_slug(self, category_slug: str): """Returns a category object with the associated recieps relating to the category""" category: RecipeCategory = self.mixins.get_one(category_slug, "slug") - return RecipeCategoryResponse.construct( + return RecipeCategoryResponse.model_construct( id=category.id, slug=category.slug, name=category.name, diff --git a/mealie/routes/organizers/controller_tags.py b/mealie/routes/organizers/controller_tags.py index c258d3cf7ca9..22d97b3ea9cf 100644 --- a/mealie/routes/organizers/controller_tags.py +++ b/mealie/routes/organizers/controller_tags.py @@ -35,7 +35,7 @@ class TagController(BaseCrudController): search=search, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.get("/empty") diff --git a/mealie/routes/organizers/controller_tools.py b/mealie/routes/organizers/controller_tools.py index c1fc424bf162..f058d3883d80 100644 --- a/mealie/routes/organizers/controller_tools.py +++ b/mealie/routes/organizers/controller_tools.py @@ -32,7 +32,7 @@ class RecipeToolController(BaseUserController): search=search, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=RecipeTool, status_code=201) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index caa279ea8a8a..831a167a3f5a 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -1,5 +1,6 @@ from functools import cached_property from shutil import copyfileobj +from uuid import UUID from zipfile import ZipFile import orjson @@ -125,7 +126,7 @@ class RecipeExportController(BaseRecipeController): recipe: Recipe = self.mixins.get_one(slug) image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value) with ZipFile(temp_path, "w") as myzip: - myzip.writestr(f"{slug}.json", recipe.json()) + myzip.writestr(f"{slug}.json", recipe.model_dump_json()) if image_asset.is_file(): myzip.write(image_asset, arcname=image_asset.name) @@ -244,7 +245,14 @@ class RecipeController(BaseRecipeController): ): cookbook_data: ReadCookBook | None = None if search_query.cookbook: - cb_match_attr = "slug" if isinstance(search_query.cookbook, str) else "id" + if isinstance(search_query.cookbook, UUID): + cb_match_attr = "id" + else: + try: + UUID(search_query.cookbook) + cb_match_attr = "id" + except ValueError: + cb_match_attr = "slug" cookbook_data = self.cookbooks_repo.get_one(search_query.cookbook, cb_match_attr) if cookbook_data is None: @@ -265,13 +273,13 @@ class RecipeController(BaseRecipeController): ) # merge default pagination with the request's query params - query_params = q.dict() | {**request.query_params} + query_params = q.model_dump() | {**request.query_params} pagination_response.set_pagination_guides( router.url_path_for("get_all"), {k: v for k, v in query_params.items() if v is not None}, ) - json_compatible_response = orjson.dumps(pagination_response.dict(by_alias=True)) + json_compatible_response = orjson.dumps(pagination_response.model_dump(by_alias=True)) # Response is returned directly, to avoid validation and improve performance return JSONBytes(content=json_compatible_response) diff --git a/mealie/routes/recipe/timeline_events.py b/mealie/routes/recipe/timeline_events.py index e9a1a2ed6b6c..ba1af165a204 100644 --- a/mealie/routes/recipe/timeline_events.py +++ b/mealie/routes/recipe/timeline_events.py @@ -49,7 +49,7 @@ class RecipeTimelineEventsController(BaseCrudController): override=RecipeTimelineEventOut, ) - response.set_pagination_guides(events_router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(events_router.url_path_for("get_all"), q.model_dump()) return response @events_router.post("", response_model=RecipeTimelineEventOut, status_code=201) diff --git a/mealie/routes/shared/__init__.py b/mealie/routes/shared/__init__.py index 4baabe883efb..7b0ab99f372b 100644 --- a/mealie/routes/shared/__init__.py +++ b/mealie/routes/shared/__init__.py @@ -30,7 +30,7 @@ class RecipeSharedController(BaseUserController): @router.post("", response_model=RecipeShareToken, status_code=201) def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken: - save_data = RecipeShareTokenSave(**data.dict(), group_id=self.group_id) + save_data = RecipeShareTokenSave(**data.model_dump(), group_id=self.group_id) return self.mixins.create_one(save_data) @router.get("/{item_id}", response_model=RecipeShareToken) diff --git a/mealie/routes/unit_and_foods/foods.py b/mealie/routes/unit_and_foods/foods.py index f3fce5391f77..b7e49ec86300 100644 --- a/mealie/routes/unit_and_foods/foods.py +++ b/mealie/routes/unit_and_foods/foods.py @@ -52,7 +52,7 @@ class IngredientFoodsController(BaseUserController): search=search, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=IngredientFood, status_code=201) diff --git a/mealie/routes/unit_and_foods/units.py b/mealie/routes/unit_and_foods/units.py index 0c6c3087f005..04c77387daed 100644 --- a/mealie/routes/unit_and_foods/units.py +++ b/mealie/routes/unit_and_foods/units.py @@ -52,7 +52,7 @@ class IngredientUnitsController(BaseUserController): search=search, ) - response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump()) return response @router.post("", response_model=IngredientUnit, status_code=201) diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py index 2de53e1a8e0c..07f41582b978 100644 --- a/mealie/routes/users/crud.py +++ b/mealie/routes/users/crud.py @@ -29,7 +29,7 @@ class AdminUserController(BaseAdminController): override=UserOut, ) - response.set_pagination_guides(admin_router.url_path_for("get_all"), q.dict()) + response.set_pagination_guides(admin_router.url_path_for("get_all"), q.model_dump()) return response @admin_router.post("", response_model=UserOut, status_code=201) @@ -103,7 +103,7 @@ class UserController(BaseUserController): ) try: - self.repos.users.update(item_id, new_data.dict()) + self.repos.users.update(item_id, new_data.model_dump()) except Exception as e: raise HTTPException( status.HTTP_400_BAD_REQUEST, diff --git a/mealie/schema/_mealie/datetime_parse.py b/mealie/schema/_mealie/datetime_parse.py new file mode 100644 index 000000000000..2109dc1a8897 --- /dev/null +++ b/mealie/schema/_mealie/datetime_parse.py @@ -0,0 +1,252 @@ +""" +From Pydantic V1: https://github.com/pydantic/pydantic/blob/abcf81ec104d2da70894ac0402ae11a7186c5e47/pydantic/datetime_parse.py +""" + +import re +from datetime import date, datetime, time, timedelta, timezone + +date_expr = r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" +time_expr = ( + r"(?P\d{1,2}):(?P\d{1,2})" + r"(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?" + r"(?PZ|[+-]\d{2}(?::?\d{2})?)?$" +) + +date_re = re.compile(f"{date_expr}$") +time_re = re.compile(time_expr) +datetime_re = re.compile(f"{date_expr}[T ]{time_expr}") + +standard_duration_re = re.compile( + r"^" + r"(?:(?P-?\d+) (days?, )?)?" + r"((?:(?P-?\d+):)(?=\d+:\d+))?" + r"(?:(?P-?\d+):)?" + r"(?P-?\d+)" + r"(?:\.(?P\d{1,6})\d{0,6})?" + r"$" +) + +# Support the sections of ISO 8601 date representation that are accepted by timedelta +iso8601_duration_re = re.compile( + r"^(?P[-+]?)" + r"P" + r"(?:(?P\d+(.\d+)?)D)?" + r"(?:T" + r"(?:(?P\d+(.\d+)?)H)?" + r"(?:(?P\d+(.\d+)?)M)?" + r"(?:(?P\d+(.\d+)?)S)?" + r")?" + r"$" +) + +EPOCH = datetime(1970, 1, 1) +# 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) +# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9 +MAX_NUMBER = int(3e20) + + +class DateError(ValueError): + def __init__(self, *args: object) -> None: + super().__init__("invalid date format") + + +class TimeError(ValueError): + def __init__(self, *args: object) -> None: + super().__init__("invalid time format") + + +class DateTimeError(ValueError): + def __init__(self, *args: object) -> None: + super().__init__("invalid datetime format") + + +class DurationError(ValueError): + def __init__(self, *args: object) -> None: + super().__init__("invalid duration format") + + +def get_numeric(value: str | bytes | int | float, native_expected_type: str) -> None | int | float: + if isinstance(value, int | float): + return value + try: + return float(value) + except ValueError: + return None + except TypeError as e: + raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from e + + +def from_unix_seconds(seconds: int | float) -> datetime: + if seconds > MAX_NUMBER: + return datetime.max + elif seconds < -MAX_NUMBER: + return datetime.min + + while abs(seconds) > MS_WATERSHED: + seconds /= 1000 + dt = EPOCH + timedelta(seconds=seconds) + return dt.replace(tzinfo=timezone.utc) + + +def _parse_timezone(value: str | None, error: type[Exception]) -> None | int | timezone: + if value == "Z": + return timezone.utc + elif value is not None: + offset_mins = int(value[-2:]) if len(value) > 3 else 0 + offset = 60 * int(value[1:3]) + offset_mins + if value[0] == "-": + offset = -offset + try: + return timezone(timedelta(minutes=offset)) + except ValueError as e: + raise error() from e + else: + return None + + +def parse_date(value: date | str | bytes | int | float) -> date: + """ + Parse a date/int/float/string and return a datetime.date. + + Raise ValueError if the input is well formatted but not a valid date. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, date): + if isinstance(value, datetime): + return value.date() + else: + return value + + number = get_numeric(value, "date") + if number is not None: + return from_unix_seconds(number).date() + + if isinstance(value, bytes): + value = value.decode() + + match = date_re.match(value) # type: ignore + if match is None: + raise DateError() + + kw = {k: int(v) for k, v in match.groupdict().items()} + + try: + return date(**kw) + except ValueError as e: + raise DateError() from e + + +def parse_time(value: time | str | bytes | int | float) -> time: + """ + Parse a time/string and return a datetime.time. + + Raise ValueError if the input is well formatted but not a valid time. + Raise ValueError if the input isn't well formatted, in particular if it contains an offset. + """ + if isinstance(value, time): + return value + + number = get_numeric(value, "time") + if number is not None: + if number >= 86400: + # doesn't make sense since the time time loop back around to 0 + raise TimeError() + return (datetime.min + timedelta(seconds=number)).time() + + if isinstance(value, bytes): + value = value.decode() + + match = time_re.match(value) # type: ignore + if match is None: + raise TimeError() + + kw = match.groupdict() + if kw["microsecond"]: + kw["microsecond"] = kw["microsecond"].ljust(6, "0") + + tzinfo = _parse_timezone(kw.pop("tzinfo"), TimeError) + kw_: dict[str, None | int | timezone] = {k: int(v) for k, v in kw.items() if v is not None} + kw_["tzinfo"] = tzinfo + + try: + return time(**kw_) # type: ignore + except ValueError as e: + raise TimeError() from e + + +def parse_datetime(value: datetime | str | bytes | int | float) -> datetime: + """ + Parse a datetime/int/float/string and return a datetime.datetime. + + This function supports time zone offsets. When the input contains one, + the output uses a timezone with a fixed offset from UTC. + + Raise ValueError if the input is well formatted but not a valid datetime. + Raise ValueError if the input isn't well formatted. + """ + if isinstance(value, datetime): + return value + + number = get_numeric(value, "datetime") + if number is not None: + return from_unix_seconds(number) + + if isinstance(value, bytes): + value = value.decode() + + match = datetime_re.match(value) # type: ignore + if match is None: + raise DateTimeError() + + kw = match.groupdict() + if kw["microsecond"]: + kw["microsecond"] = kw["microsecond"].ljust(6, "0") + + tzinfo = _parse_timezone(kw.pop("tzinfo"), DateTimeError) + kw_: dict[str, None | int | timezone] = {k: int(v) for k, v in kw.items() if v is not None} + kw_["tzinfo"] = tzinfo + + try: + return datetime(**kw_) # type: ignore + except ValueError as e: + raise DateTimeError() from e + + +def parse_duration(value: str | bytes | int | float) -> timedelta: + """ + Parse a duration int/float/string and return a datetime.timedelta. + + The preferred format for durations in Django is '%d %H:%M:%S.%f'. + + Also supports ISO 8601 representation. + """ + if isinstance(value, timedelta): + return value + + if isinstance(value, int | float): + # below code requires a string + value = f"{value:f}" + elif isinstance(value, bytes): + value = value.decode() + + try: + match = standard_duration_re.match(value) or iso8601_duration_re.match(value) + except TypeError as e: + raise TypeError("invalid type; expected timedelta, string, bytes, int or float") from e + + if not match: + raise DurationError() + + kw = match.groupdict() + sign = -1 if kw.pop("sign", "+") == "-" else 1 + if kw.get("microseconds"): + kw["microseconds"] = kw["microseconds"].ljust(6, "0") + + if kw.get("seconds") and kw.get("microseconds") and kw["seconds"].startswith("-"): + kw["microseconds"] = "-" + kw["microseconds"] + + kw_ = {k: float(v) for k, v in kw.items() if v is not None} + + return sign * timedelta(**kw_) diff --git a/mealie/schema/_mealie/mealie_model.py b/mealie/schema/_mealie/mealie_model.py index 979d31537d2a..6a12f370d721 100644 --- a/mealie/schema/_mealie/mealie_model.py +++ b/mealie/schema/_mealie/mealie_model.py @@ -5,7 +5,7 @@ from enum import Enum from typing import ClassVar, Protocol, TypeVar from humps.main import camelize -from pydantic import UUID4, BaseModel +from pydantic import UUID4, BaseModel, ConfigDict from sqlalchemy import Select, desc, func, or_, text from sqlalchemy.orm import InstrumentedAttribute, Session from sqlalchemy.orm.interfaces import LoaderOption @@ -28,10 +28,7 @@ class MealieModel(BaseModel): Searchable properties for the search API. The first property will be used for sorting (order_by) """ - - class Config: - alias_generator = camelize - allow_population_by_field_name = True + model_config = ConfigDict(alias_generator=camelize, populate_by_name=True) def cast(self, cls: type[T], **kwargs) -> T: """ @@ -48,8 +45,8 @@ class MealieModel(BaseModel): for method chaining. """ - for field in self.__fields__: - if field in dest.__fields__: + for field in self.model_fields: + if field in dest.model_fields: setattr(dest, field, getattr(self, field)) return dest @@ -59,8 +56,8 @@ class MealieModel(BaseModel): Map matching values from another model to the current model. """ - for field in src.__fields__: - if field in self.__fields__: + for field in src.model_fields: + if field in self.model_fields: setattr(self, field, getattr(src, field)) def merge(self, src: T, replace_null=False): @@ -68,9 +65,9 @@ class MealieModel(BaseModel): Replace matching values from another instance to the current instance. """ - for field in src.__fields__: + for field in src.model_fields: val = getattr(src, field) - if field in self.__fields__ and (val is not None or replace_null): + if field in self.model_fields and (val is not None or replace_null): setattr(self, field, val) @classmethod diff --git a/mealie/schema/admin/about.py b/mealie/schema/admin/about.py index 94acf1c8d971..0c624772eed3 100644 --- a/mealie/schema/admin/about.py +++ b/mealie/schema/admin/about.py @@ -49,7 +49,7 @@ class AdminAboutInfo(AppInfo): api_port: int api_docs: bool db_type: str - db_url: str | None + db_url: str | None = None default_group: str build_id: str recipe_scraper_version: str diff --git a/mealie/schema/admin/backup.py b/mealie/schema/admin/backup.py index c59cd40fc027..730dc9063453 100644 --- a/mealie/schema/admin/backup.py +++ b/mealie/schema/admin/backup.py @@ -19,9 +19,9 @@ class ImportJob(BackupOptions): class CreateBackup(BaseModel): - tag: str | None + tag: str | None = None options: BackupOptions - templates: list[str] | None + templates: list[str] | None = None class BackupFile(BaseModel): diff --git a/mealie/schema/admin/restore.py b/mealie/schema/admin/restore.py index 4983f8b3134e..68b973cf8746 100644 --- a/mealie/schema/admin/restore.py +++ b/mealie/schema/admin/restore.py @@ -4,11 +4,11 @@ from pydantic.main import BaseModel class ImportBase(BaseModel): name: str status: bool - exception: str | None + exception: str | None = None class RecipeImport(ImportBase): - slug: str | None + slug: str | None = None class CommentImport(ImportBase): diff --git a/mealie/schema/admin/settings.py b/mealie/schema/admin/settings.py index baef0b7ef919..ba884bf4f476 100644 --- a/mealie/schema/admin/settings.py +++ b/mealie/schema/admin/settings.py @@ -1,4 +1,6 @@ -from pydantic import validator +from typing import Annotated + +from pydantic import ConfigDict, Field, field_validator from slugify import slugify from mealie.schema._mealie import MealieModel @@ -8,14 +10,12 @@ from ..recipe.recipe_category import RecipeCategoryResponse class CustomPageBase(MealieModel): name: str - slug: str | None + slug: Annotated[str | None, Field(validate_default=True)] position: int categories: list[RecipeCategoryResponse] = [] + model_config = ConfigDict(from_attributes=True) - class Config: - orm_mode = True - - @validator("slug", always=True, pre=True) + @field_validator("slug", mode="before") def validate_slug(slug: str, values): name: str = values["name"] calc_slug: str = slugify(name) @@ -28,6 +28,4 @@ class CustomPageBase(MealieModel): class CustomPageOut(CustomPageBase): id: int - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/cookbook/cookbook.py b/mealie/schema/cookbook/cookbook.py index 3769cfae07a7..79bb91152f4d 100644 --- a/mealie/schema/cookbook/cookbook.py +++ b/mealie/schema/cookbook/cookbook.py @@ -1,4 +1,7 @@ -from pydantic import UUID4, validator +from typing import Annotated + +from pydantic import UUID4, ConfigDict, Field, field_validator +from pydantic_core.core_schema import ValidationInfo from slugify import slugify from sqlalchemy.orm import joinedload from sqlalchemy.orm.interfaces import LoaderOption @@ -14,9 +17,9 @@ from ..recipe.recipe_category import CategoryBase, TagBase class CreateCookBook(MealieModel): name: str description: str = "" - slug: str | None = None + slug: Annotated[str | None, Field(validate_default=True)] = None position: int = 1 - public: bool = False + public: Annotated[bool, Field(validate_default=True)] = False categories: list[CategoryBase] = [] tags: list[TagBase] = [] tools: list[RecipeTool] = [] @@ -24,13 +27,13 @@ class CreateCookBook(MealieModel): require_all_tags: bool = True require_all_tools: bool = True - @validator("public", always=True, pre=True) - def validate_public(public: bool | None, values: dict) -> bool: # type: ignore + @field_validator("public", mode="before") + def validate_public(public: bool | None) -> bool: return False if public is None else public - @validator("slug", always=True, pre=True) - def validate_slug(slug: str, values): # type: ignore - name: str = values["name"] + @field_validator("slug", mode="before") + def validate_slug(slug: str, info: ValidationInfo): + name: str = info.data["name"] calc_slug: str = slugify(name) if slug != calc_slug: @@ -50,9 +53,7 @@ class UpdateCookBook(SaveCookBook): class ReadCookBook(UpdateCookBook): group_id: UUID4 categories: list[CategoryBase] = [] - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: @@ -66,6 +67,4 @@ class CookBookPagination(PaginationBase): class RecipeCookBook(ReadCookBook): group_id: UUID4 recipes: list[RecipeSummary] - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/getter_dict.py b/mealie/schema/getter_dict.py deleted file mode 100644 index 2fbb483ba650..000000000000 --- a/mealie/schema/getter_dict.py +++ /dev/null @@ -1,33 +0,0 @@ -from collections.abc import Callable, Mapping -from typing import Any - -from pydantic.utils import GetterDict - - -class CustomGetterDict(GetterDict): - transformations: Mapping[str, Callable[[Any], Any]] - - def get(self, key: Any, default: Any = None) -> Any: - # Transform extras into key-value dict - if key in self.transformations: - value = super().get(key, default) - return self.transformations[key](value) - - # Keep all other fields as they are - else: - return super().get(key, default) - - -class ExtrasGetterDict(CustomGetterDict): - transformations = {"extras": lambda value: {x.key_name: x.value for x in value}} - - -class GroupGetterDict(CustomGetterDict): - transformations = {"group": lambda value: value.name} - - -class UserGetterDict(CustomGetterDict): - transformations = { - "group": lambda value: value.name, - "favorite_recipes": lambda value: [x.slug for x in value], - } diff --git a/mealie/schema/group/group_events.py b/mealie/schema/group/group_events.py index 9f15f98c54ff..3253dcda1e26 100644 --- a/mealie/schema/group/group_events.py +++ b/mealie/schema/group/group_events.py @@ -1,4 +1,4 @@ -from pydantic import UUID4, NoneStr +from pydantic import UUID4, ConfigDict from sqlalchemy.orm import joinedload from sqlalchemy.orm.interfaces import LoaderOption @@ -54,9 +54,7 @@ class GroupEventNotifierOptionsSave(GroupEventNotifierOptions): class GroupEventNotifierOptionsOut(GroupEventNotifierOptions): id: UUID4 - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) # ======================================================================= @@ -65,7 +63,7 @@ class GroupEventNotifierOptionsOut(GroupEventNotifierOptions): class GroupEventNotifierCreate(MealieModel): name: str - apprise_url: str + apprise_url: str | None = None class GroupEventNotifierSave(GroupEventNotifierCreate): @@ -76,7 +74,7 @@ class GroupEventNotifierSave(GroupEventNotifierCreate): class GroupEventNotifierUpdate(GroupEventNotifierSave): id: UUID4 - apprise_url: NoneStr = None + apprise_url: str | None = None class GroupEventNotifierOut(MealieModel): @@ -85,9 +83,7 @@ class GroupEventNotifierOut(MealieModel): enabled: bool group_id: UUID4 options: GroupEventNotifierOptionsOut - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: @@ -100,6 +96,4 @@ class GroupEventPagination(PaginationBase): class GroupEventNotifierPrivate(GroupEventNotifierOut): apprise_url: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/group/group_exports.py b/mealie/schema/group/group_exports.py index e271261de455..ce2dcc2e17d7 100644 --- a/mealie/schema/group/group_exports.py +++ b/mealie/schema/group/group_exports.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import UUID4 +from pydantic import UUID4, ConfigDict from mealie.schema._mealie import MealieModel @@ -13,6 +13,4 @@ class GroupDataExport(MealieModel): path: str size: str expires: datetime - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/group/group_preferences.py b/mealie/schema/group/group_preferences.py index 492f9fa96b8d..4b47787de9a0 100644 --- a/mealie/schema/group/group_preferences.py +++ b/mealie/schema/group/group_preferences.py @@ -1,6 +1,6 @@ from uuid import UUID -from pydantic import UUID4 +from pydantic import UUID4, ConfigDict from mealie.schema._mealie import MealieModel @@ -24,6 +24,4 @@ class CreateGroupPreferences(UpdateGroupPreferences): class ReadGroupPreferences(CreateGroupPreferences): id: UUID4 - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/group/group_seeder.py b/mealie/schema/group/group_seeder.py index 1514b4dfe234..c624ff8c035c 100644 --- a/mealie/schema/group/group_seeder.py +++ b/mealie/schema/group/group_seeder.py @@ -1,4 +1,4 @@ -from pydantic import validator +from pydantic import field_validator from mealie.schema._mealie.mealie_model import MealieModel from mealie.schema._mealie.validators import validate_locale @@ -7,8 +7,8 @@ from mealie.schema._mealie.validators import validate_locale class SeederConfig(MealieModel): locale: str - @validator("locale") - def valid_locale(cls, v, values, **kwargs): + @field_validator("locale") + def valid_locale(cls, v): if not validate_locale(v): raise ValueError("invalid locale") return v diff --git a/mealie/schema/group/group_shopping_list.py b/mealie/schema/group/group_shopping_list.py index 546b47a5c1d6..d91f79e90b29 100644 --- a/mealie/schema/group/group_shopping_list.py +++ b/mealie/schema/group/group_shopping_list.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime -from pydantic import UUID4, validator +from pydantic import UUID4, ConfigDict, field_validator from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm.interfaces import LoaderOption @@ -15,7 +15,6 @@ from mealie.db.models.group import ( from mealie.db.models.recipe import IngredientFoodModel, RecipeModel from mealie.schema._mealie import MealieModel from mealie.schema._mealie.types import NoneFloat -from mealie.schema.getter_dict import ExtrasGetterDict from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary from mealie.schema.recipe.recipe import RecipeSummary from mealie.schema.recipe.recipe_ingredient import ( @@ -38,7 +37,8 @@ class ShoppingListItemRecipeRefCreate(MealieModel): recipe_note: str | None = None """the original note from the recipe""" - @validator("recipe_quantity", pre=True) + @field_validator("recipe_quantity", mode="before") + @classmethod def default_none_to_zero(cls, v): return 0 if v is None else v @@ -49,8 +49,7 @@ class ShoppingListItemRecipeRefUpdate(ShoppingListItemRecipeRefCreate): class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate): - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class ShoppingListItemBase(RecipeIngredientBase): @@ -67,6 +66,13 @@ class ShoppingListItemBase(RecipeIngredientBase): is_food: bool = False extras: dict | None = {} + @field_validator("extras", mode="before") + def convert_extras_to_dict(cls, v): + if isinstance(v, dict): + return v + + return {x.key_name: x.value for x in v} if v else {} + class ShoppingListItemCreate(ShoppingListItemBase): recipe_references: list[ShoppingListItemRecipeRefCreate] = [] @@ -85,14 +91,14 @@ class ShoppingListItemUpdateBulk(ShoppingListItemUpdate): class ShoppingListItemOut(ShoppingListItemBase): id: UUID4 - food: IngredientFood | None - label: MultiPurposeLabelSummary | None - unit: IngredientUnit | None + food: IngredientFood | None = None + label: MultiPurposeLabelSummary | None = None + unit: IngredientUnit | None = None recipe_references: list[ShoppingListItemRecipeRefOut] = [] - created_at: datetime | None - update_at: datetime | None + created_at: datetime | None = None + update_at: datetime | None = None def __init__(self, **kwargs): super().__init__(**kwargs) @@ -102,9 +108,7 @@ class ShoppingListItemOut(ShoppingListItemBase): self.label = self.food.label self.label_id = self.label.id - class Config: - orm_mode = True - getter_dict = ExtrasGetterDict + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: @@ -138,9 +142,7 @@ class ShoppingListMultiPurposeLabelUpdate(ShoppingListMultiPurposeLabelCreate): class ShoppingListMultiPurposeLabelOut(ShoppingListMultiPurposeLabelUpdate): label: MultiPurposeLabelSummary - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: @@ -155,8 +157,15 @@ class ShoppingListCreate(MealieModel): name: str | None = None extras: dict | None = {} - created_at: datetime | None - update_at: datetime | None + created_at: datetime | None = None + update_at: datetime | None = None + + @field_validator("extras", mode="before") + def convert_extras_to_dict(cls, v): + if isinstance(v, dict): + return v + + return {x.key_name: x.value for x in v} if v else {} class ShoppingListRecipeRefOut(MealieModel): @@ -167,9 +176,7 @@ class ShoppingListRecipeRefOut(MealieModel): """the number of times this recipe has been added""" recipe: RecipeSummary - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: @@ -188,10 +195,7 @@ class ShoppingListSummary(ShoppingListSave): id: UUID4 recipe_references: list[ShoppingListRecipeRefOut] label_settings: list[ShoppingListMultiPurposeLabelOut] - - class Config: - orm_mode = True - getter_dict = ExtrasGetterDict + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: @@ -222,10 +226,7 @@ class ShoppingListUpdate(ShoppingListSave): class ShoppingListOut(ShoppingListUpdate): recipe_references: list[ShoppingListRecipeRefOut] label_settings: list[ShoppingListMultiPurposeLabelOut] - - class Config: - orm_mode = True - getter_dict = ExtrasGetterDict + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: diff --git a/mealie/schema/group/invite_token.py b/mealie/schema/group/invite_token.py index dac57448d08e..61e2787f1c02 100644 --- a/mealie/schema/group/invite_token.py +++ b/mealie/schema/group/invite_token.py @@ -1,6 +1,6 @@ from uuid import UUID -from pydantic import NoneStr +from pydantic import ConfigDict from mealie.schema._mealie import MealieModel @@ -19,9 +19,7 @@ class ReadInviteToken(MealieModel): token: str uses_left: int group_id: UUID - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class EmailInvitation(MealieModel): @@ -31,4 +29,4 @@ class EmailInvitation(MealieModel): class EmailInitationResponse(MealieModel): success: bool - error: NoneStr = None + error: str | None = None diff --git a/mealie/schema/group/webhook.py b/mealie/schema/group/webhook.py index b72769862635..b7d040df49b6 100644 --- a/mealie/schema/group/webhook.py +++ b/mealie/schema/group/webhook.py @@ -3,10 +3,10 @@ import enum from uuid import UUID from isodate import parse_time -from pydantic import UUID4, validator -from pydantic.datetime_parse import parse_datetime +from pydantic import UUID4, ConfigDict, field_validator from mealie.schema._mealie import MealieModel +from mealie.schema._mealie.datetime_parse import parse_datetime from mealie.schema.response.pagination import PaginationBase @@ -22,7 +22,7 @@ class CreateWebhook(MealieModel): webhook_type: WebhookType = WebhookType.mealplan scheduled_time: datetime.time - @validator("scheduled_time", pre=True) + @field_validator("scheduled_time", mode="before") @classmethod def validate_scheduled_time(cls, v): """ @@ -55,9 +55,7 @@ class SaveWebhook(CreateWebhook): class ReadWebhook(SaveWebhook): id: UUID4 - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class WebhookPagination(PaginationBase): diff --git a/mealie/schema/labels/multi_purpose_label.py b/mealie/schema/labels/multi_purpose_label.py index eb41b23c9cce..04430b373499 100644 --- a/mealie/schema/labels/multi_purpose_label.py +++ b/mealie/schema/labels/multi_purpose_label.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import ClassVar -from pydantic import UUID4 +from pydantic import UUID4, ConfigDict from mealie.schema._mealie import MealieModel from mealie.schema.response.pagination import PaginationBase @@ -23,9 +23,7 @@ class MultiPurposeLabelUpdate(MultiPurposeLabelSave): class MultiPurposeLabelSummary(MultiPurposeLabelUpdate): _searchable_properties: ClassVar[list[str]] = ["name"] - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class MultiPurposeLabelPagination(PaginationBase): @@ -33,5 +31,4 @@ class MultiPurposeLabelPagination(PaginationBase): class MultiPurposeLabelOut(MultiPurposeLabelUpdate): - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/make_dependable.py b/mealie/schema/make_dependable.py index 9c8b5ba0e912..71cf79caa765 100644 --- a/mealie/schema/make_dependable.py +++ b/mealie/schema/make_dependable.py @@ -4,6 +4,10 @@ from fastapi.exceptions import HTTPException, RequestValidationError from pydantic import ValidationError +def format_exception(ex: Exception) -> str: + return f"{ex.__class__.__name__}: {ex}" + + def make_dependable(cls): """ Pydantic BaseModels are very powerful because we get lots of validations and type checking right out of the box. @@ -29,7 +33,7 @@ def make_dependable(cls): except (ValidationError, RequestValidationError) as e: for error in e.errors(): error["loc"] = ["query"] + list(error["loc"]) - raise HTTPException(422, detail=e.errors()) from None + raise HTTPException(422, detail=[format_exception(ex) for ex in e.errors()]) from None init_cls_and_handle_errors.__signature__ = signature(cls) return init_cls_and_handle_errors diff --git a/mealie/schema/mapper.py b/mealie/schema/mapper.py index 2ab47dda3aac..6aee59ef6a62 100644 --- a/mealie/schema/mapper.py +++ b/mealie/schema/mapper.py @@ -11,14 +11,14 @@ def mapper(source: U, dest: T, **_) -> T: Map a source model to a destination model. Only top-level fields are mapped. """ - for field in source.__fields__: - if field in dest.__fields__: + for field in source.model_fields: + if field in dest.model_fields: setattr(dest, field, getattr(source, field)) return dest def cast(source: U, dest: type[T], **kwargs) -> T: - create_data = {field: getattr(source, field) for field in source.__fields__ if field in dest.__fields__} + create_data = {field: getattr(source, field) for field in source.model_fields if field in dest.model_fields} create_data.update(kwargs or {}) return dest(**create_data) diff --git a/mealie/schema/meal_plan/meal.py b/mealie/schema/meal_plan/meal.py index 718bc76673a3..4fb0fd48efc3 100644 --- a/mealie/schema/meal_plan/meal.py +++ b/mealie/schema/meal_plan/meal.py @@ -1,53 +1,45 @@ -from datetime import date +import datetime -from pydantic import validator +from pydantic import ConfigDict, field_validator +from pydantic_core.core_schema import ValidationInfo from mealie.schema._mealie import MealieModel class MealIn(MealieModel): - slug: str | None - name: str | None - description: str | None - - class Config: - orm_mode = True + slug: str | None = None + name: str | None = None + description: str | None = None + model_config = ConfigDict(from_attributes=True) class MealDayIn(MealieModel): - date: date | None + date: datetime.date | None = None meals: list[MealIn] - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class MealDayOut(MealDayIn): id: int - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class MealPlanIn(MealieModel): group: str - start_date: date - end_date: date + start_date: datetime.date + end_date: datetime.date plan_days: list[MealDayIn] - @validator("end_date") - def end_date_after_start_date(v, values, config, field): - if "start_date" in values and v < values["start_date"]: + @field_validator("end_date") + def end_date_after_start_date(v, info: ValidationInfo): + if "start_date" in info.data and v < info.data["start_date"]: raise ValueError("EndDate should be greater than StartDate") return v - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class MealPlanOut(MealPlanIn): id: int - shopping_list: int | None - - class Config: - orm_mode = True + shopping_list: int | None = None + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/meal_plan/new_meal.py b/mealie/schema/meal_plan/new_meal.py index 90513f241be1..a0a27c7e0a6c 100644 --- a/mealie/schema/meal_plan/new_meal.py +++ b/mealie/schema/meal_plan/new_meal.py @@ -1,8 +1,10 @@ from datetime import date from enum import Enum +from typing import Annotated from uuid import UUID -from pydantic import validator +from pydantic import ConfigDict, Field, field_validator +from pydantic_core.core_schema import ValidationInfo from sqlalchemy.orm import selectinload from sqlalchemy.orm.interfaces import LoaderOption @@ -30,13 +32,13 @@ class CreatePlanEntry(MealieModel): entry_type: PlanEntryType = PlanEntryType.breakfast title: str = "" text: str = "" - recipe_id: UUID | None + recipe_id: Annotated[UUID | None, Field(validate_default=True)] = None - @validator("recipe_id", always=True) + @field_validator("recipe_id") @classmethod - def id_or_title(cls, value, values): - if bool(value) is False and bool(values["title"]) is False: - raise ValueError(f"`recipe_id={value}` or `title={values['title']}` must be provided") + def id_or_title(cls, value, info: ValidationInfo): + if bool(value) is False and bool(info.data["title"]) is False: + raise ValueError(f"`recipe_id={value}` or `title={info.data['title']}` must be provided") return value @@ -44,22 +46,18 @@ class CreatePlanEntry(MealieModel): class UpdatePlanEntry(CreatePlanEntry): id: int group_id: UUID - user_id: UUID | None + user_id: UUID | None = None class SavePlanEntry(CreatePlanEntry): group_id: UUID - user_id: UUID | None - - class Config: - orm_mode = True + user_id: UUID | None = None + model_config = ConfigDict(from_attributes=True) class ReadPlanEntry(UpdatePlanEntry): - recipe: RecipeSummary | None - - class Config: - orm_mode = True + recipe: RecipeSummary | None = None + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: diff --git a/mealie/schema/meal_plan/plan_rules.py b/mealie/schema/meal_plan/plan_rules.py index 36f69af71f30..08dc8e63ac51 100644 --- a/mealie/schema/meal_plan/plan_rules.py +++ b/mealie/schema/meal_plan/plan_rules.py @@ -1,7 +1,7 @@ import datetime from enum import Enum -from pydantic import UUID4 +from pydantic import UUID4, ConfigDict from sqlalchemy.orm import joinedload from sqlalchemy.orm.interfaces import LoaderOption @@ -14,14 +14,11 @@ class Category(MealieModel): id: UUID4 name: str slug: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class Tag(Category): - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class PlanRulesDay(str, Enum): @@ -64,9 +61,7 @@ class PlanRulesSave(PlanRulesCreate): class PlanRulesOut(PlanRulesSave): id: UUID4 - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: diff --git a/mealie/schema/meal_plan/shopping_list.py b/mealie/schema/meal_plan/shopping_list.py index a7acadb0eeaf..e25ebd1a53c4 100644 --- a/mealie/schema/meal_plan/shopping_list.py +++ b/mealie/schema/meal_plan/shopping_list.py @@ -1,26 +1,22 @@ +from pydantic import ConfigDict + from mealie.schema._mealie import MealieModel -from mealie.schema.getter_dict import GroupGetterDict class ListItem(MealieModel): - title: str | None + title: str | None = None text: str = "" quantity: int = 1 checked: bool = False - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class ShoppingListIn(MealieModel): name: str - group: str | None + group: str | None = None items: list[ListItem] class ShoppingListOut(ShoppingListIn): id: int - - class Config: - orm_mode = True - getter_dict = GroupGetterDict + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 5b5cca65aed1..7bf253e8de22 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -1,11 +1,13 @@ from __future__ import annotations import datetime +from numbers import Number from pathlib import Path -from typing import Any, ClassVar +from typing import Annotated, Any, ClassVar from uuid import uuid4 -from pydantic import UUID4, BaseModel, Field, validator +from pydantic import UUID4, BaseModel, ConfigDict, Field, field_validator +from pydantic_core.core_schema import ValidationInfo from slugify import slugify from sqlalchemy import Select, desc, func, or_, select, text from sqlalchemy.orm import Session, joinedload, selectinload @@ -22,7 +24,6 @@ from ...db.models.recipe import ( RecipeInstruction, RecipeModel, ) -from ..getter_dict import ExtrasGetterDict from .recipe_asset import RecipeAsset from .recipe_comments import RecipeCommentOut from .recipe_notes import RecipeNote @@ -39,9 +40,7 @@ class RecipeTag(MealieModel): slug: str _searchable_properties: ClassVar[list[str]] = ["name"] - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class RecipeTagPagination(PaginationBase): @@ -80,16 +79,16 @@ class CreateRecipe(MealieModel): class RecipeSummary(MealieModel): - id: UUID4 | None + id: UUID4 | None = None _normalize_search: ClassVar[bool] = True - user_id: UUID4 = Field(default_factory=uuid4) - group_id: UUID4 = Field(default_factory=uuid4) + user_id: UUID4 = Field(default_factory=uuid4, validate_default=True) + group_id: UUID4 = Field(default_factory=uuid4, validate_default=True) - name: str | None - slug: str = "" - image: Any | None - recipe_yield: str | None + name: str | None = None + slug: Annotated[str, Field(validate_default=True)] = "" + image: Any | None = None + recipe_yield: str | None = None total_time: str | None = None prep_time: str | None = None @@ -97,21 +96,28 @@ class RecipeSummary(MealieModel): perform_time: str | None = None description: str | None = "" - recipe_category: list[RecipeCategory] | None = [] - tags: list[RecipeTag] | None = [] + recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = [] + tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = [] tools: list[RecipeTool] = [] - rating: int | None + rating: int | None = None org_url: str | None = Field(None, alias="orgURL") - date_added: datetime.date | None - date_updated: datetime.datetime | None + date_added: datetime.date | None = None + date_updated: datetime.datetime | None = None - created_at: datetime.datetime | None - update_at: datetime.datetime | None - last_made: datetime.datetime | None + created_at: datetime.datetime | None = None + update_at: datetime.datetime | None = None + last_made: datetime.datetime | None = None + model_config = ConfigDict(from_attributes=True) - class Config: - orm_mode = True + @field_validator("recipe_yield", "total_time", "prep_time", "cook_time", "perform_time", mode="before") + def clean_strings(val: Any): + if val is None: + return val + if isinstance(val, Number): + return str(val) + + return val class RecipePagination(PaginationBase): @@ -119,9 +125,9 @@ class RecipePagination(PaginationBase): class Recipe(RecipeSummary): - recipe_ingredient: list[RecipeIngredient] = [] + recipe_ingredient: Annotated[list[RecipeIngredient], Field(validate_default=True)] = [] recipe_instructions: list[RecipeStep] | None = [] - nutrition: Nutrition | None + nutrition: Nutrition | None = None # Mealie Specific settings: RecipeSettings | None = None @@ -175,13 +181,11 @@ class Recipe(RecipeSummary): return self.image_dir_from_id(self.id) - class Config: - orm_mode = True - getter_dict = ExtrasGetterDict + model_config = ConfigDict(from_attributes=True) @classmethod - def from_orm(cls, obj): - recipe = super().from_orm(obj) + def model_validate(cls, obj): + recipe = super().model_validate(obj) recipe.__post_init__() return recipe @@ -198,15 +202,15 @@ class Recipe(RecipeSummary): ingredient.is_food = not ingredient.disable_amount ingredient.display = ingredient._format_display() - @validator("slug", always=True, pre=True, allow_reuse=True) - def validate_slug(slug: str, values): # type: ignore - if not values.get("name"): + @field_validator("slug", mode="before") + def validate_slug(slug: str, info: ValidationInfo): + if not info.data.get("name"): return slug - return slugify(values["name"]) + return slugify(info.data["name"]) - @validator("recipe_ingredient", always=True, pre=True, allow_reuse=True) - def validate_ingredients(recipe_ingredient, values): + @field_validator("recipe_ingredient", mode="before") + def validate_ingredients(recipe_ingredient): if not recipe_ingredient or not isinstance(recipe_ingredient, list): return recipe_ingredient @@ -215,30 +219,37 @@ class Recipe(RecipeSummary): return recipe_ingredient - @validator("tags", always=True, pre=True, allow_reuse=True) - def validate_tags(cats: list[Any]): # type: ignore + @field_validator("tags", mode="before") + def validate_tags(cats: list[Any]): if isinstance(cats, list) and cats and isinstance(cats[0], str): return [RecipeTag(id=uuid4(), name=c, slug=slugify(c)) for c in cats] return cats - @validator("recipe_category", always=True, pre=True, allow_reuse=True) - def validate_categories(cats: list[Any]): # type: ignore + @field_validator("recipe_category", mode="before") + def validate_categories(cats: list[Any]): if isinstance(cats, list) and cats and isinstance(cats[0], str): return [RecipeCategory(id=uuid4(), name=c, slug=slugify(c)) for c in cats] return cats - @validator("group_id", always=True, pre=True, allow_reuse=True) + @field_validator("group_id", mode="before") def validate_group_id(group_id: Any): if isinstance(group_id, int): return uuid4() return group_id - @validator("user_id", always=True, pre=True, allow_reuse=True) + @field_validator("user_id", mode="before") def validate_user_id(user_id: Any): if isinstance(user_id, int): return uuid4() return user_id + @field_validator("extras", mode="before") + def convert_extras_to_dict(cls, v): + if isinstance(v, dict): + return v + + return {x.key_name: x.value for x in v} if v else {} + @classmethod def loader_options(cls) -> list[LoaderOption]: return [ @@ -332,5 +343,5 @@ class RecipeLastMade(BaseModel): from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402 -RecipeSummary.update_forward_refs() -Recipe.update_forward_refs() +RecipeSummary.model_rebuild() +Recipe.model_rebuild() diff --git a/mealie/schema/recipe/recipe_asset.py b/mealie/schema/recipe/recipe_asset.py index 8abdcf4552fe..a0161e4262d1 100644 --- a/mealie/schema/recipe/recipe_asset.py +++ b/mealie/schema/recipe/recipe_asset.py @@ -1,10 +1,10 @@ +from pydantic import ConfigDict + from mealie.schema._mealie import MealieModel class RecipeAsset(MealieModel): name: str icon: str - file_name: str | None - - class Config: - orm_mode = True + file_name: str | None = None + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/recipe/recipe_category.py b/mealie/schema/recipe/recipe_category.py index c1be3dc42e13..634b7f4dbc54 100644 --- a/mealie/schema/recipe/recipe_category.py +++ b/mealie/schema/recipe/recipe_category.py @@ -1,4 +1,4 @@ -from pydantic import UUID4 +from pydantic import UUID4, ConfigDict from sqlalchemy.orm import selectinload from sqlalchemy.orm.interfaces import LoaderOption @@ -17,24 +17,18 @@ class CategorySave(CategoryIn): class CategoryBase(CategoryIn): id: UUID4 slug: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class CategoryOut(CategoryBase): slug: str group_id: UUID4 - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class RecipeCategoryResponse(CategoryBase): recipes: "list[RecipeSummary]" = [] - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class TagIn(CategoryIn): @@ -52,9 +46,7 @@ class TagBase(CategoryBase): class TagOut(TagSave): id: UUID4 slug: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class RecipeTagResponse(RecipeCategoryResponse): @@ -69,5 +61,5 @@ class RecipeTagResponse(RecipeCategoryResponse): from mealie.schema.recipe.recipe import RecipeSummary # noqa: E402 -RecipeCategoryResponse.update_forward_refs() -RecipeTagResponse.update_forward_refs() +RecipeCategoryResponse.model_rebuild() +RecipeTagResponse.model_rebuild() diff --git a/mealie/schema/recipe/recipe_comments.py b/mealie/schema/recipe/recipe_comments.py index 68adc17bc532..a768c0fec7af 100644 --- a/mealie/schema/recipe/recipe_comments.py +++ b/mealie/schema/recipe/recipe_comments.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import UUID4 +from pydantic import UUID4, ConfigDict from sqlalchemy.orm import joinedload from sqlalchemy.orm.interfaces import LoaderOption @@ -11,11 +11,9 @@ from mealie.schema.response.pagination import PaginationBase class UserBase(MealieModel): id: UUID4 - username: str | None + username: str | None = None admin: bool - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class RecipeCommentCreate(MealieModel): @@ -39,9 +37,7 @@ class RecipeCommentOut(RecipeCommentCreate): update_at: datetime user_id: UUID4 user: UserBase - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index 250f28199912..8a260a6e43a1 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -6,14 +6,13 @@ from fractions import Fraction from typing import ClassVar from uuid import UUID, uuid4 -from pydantic import UUID4, Field, validator +from pydantic import UUID4, ConfigDict, Field, field_validator from sqlalchemy.orm import joinedload from sqlalchemy.orm.interfaces import LoaderOption from mealie.db.models.recipe import IngredientFoodModel from mealie.schema._mealie import MealieModel from mealie.schema._mealie.types import NoneFloat -from mealie.schema.getter_dict import ExtrasGetterDict from mealie.schema.response.pagination import PaginationBase INGREDIENT_QTY_PRECISION = 3 @@ -37,14 +36,20 @@ class UnitFoodBase(MealieModel): description: str = "" extras: dict | None = {} + @field_validator("extras", mode="before") + def convert_extras_to_dict(cls, v): + if isinstance(v, dict): + return v + + return {x.key_name: x.value for x in v} if v else {} + class CreateIngredientFoodAlias(MealieModel): name: str class IngredientFoodAlias(CreateIngredientFoodAlias): - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class CreateIngredientFood(UnitFoodBase): @@ -61,15 +66,12 @@ class IngredientFood(CreateIngredientFood): label: MultiPurposeLabelSummary | None = None aliases: list[IngredientFoodAlias] = [] - created_at: datetime.datetime | None - update_at: datetime.datetime | None + created_at: datetime.datetime | None = None + update_at: datetime.datetime | None = None _searchable_properties: ClassVar[list[str]] = ["name_normalized", "plural_name_normalized"] _normalize_search: ClassVar[bool] = True - - class Config: - orm_mode = True - getter_dict = ExtrasGetterDict + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: @@ -85,8 +87,7 @@ class CreateIngredientUnitAlias(MealieModel): class IngredientUnitAlias(CreateIngredientUnitAlias): - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class CreateIngredientUnit(UnitFoodBase): @@ -105,8 +106,8 @@ class IngredientUnit(CreateIngredientUnit): id: UUID4 aliases: list[IngredientUnitAlias] = [] - created_at: datetime.datetime | None - update_at: datetime.datetime | None + created_at: datetime.datetime | None = None + update_at: datetime.datetime | None = None _searchable_properties: ClassVar[list[str]] = [ "name_normalized", @@ -115,15 +116,13 @@ class IngredientUnit(CreateIngredientUnit): "plural_abbreviation_normalized", ] _normalize_search: ClassVar[bool] = True - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class RecipeIngredientBase(MealieModel): quantity: NoneFloat = 1 - unit: IngredientUnit | CreateIngredientUnit | None - food: IngredientFood | CreateIngredientFood | None + unit: IngredientUnit | CreateIngredientUnit | None = None + food: IngredientFood | CreateIngredientFood | None = None note: str | None = "" is_food: bool | None = None @@ -152,14 +151,16 @@ class RecipeIngredientBase(MealieModel): if not self.display: self.display = self._format_display() - @validator("unit", pre=True) + @field_validator("unit", mode="before") + @classmethod def validate_unit(cls, v): if isinstance(v, str): return CreateIngredientUnit(name=v) else: return v - @validator("food", pre=True) + @field_validator("food", mode="before") + @classmethod def validate_food(cls, v): if isinstance(v, str): return CreateIngredientFood(name=v) @@ -260,19 +261,18 @@ class IngredientUnitPagination(PaginationBase): class RecipeIngredient(RecipeIngredientBase): - title: str | None - original_text: str | None + title: str | None = None + original_text: str | None = None disable_amount: bool = True # Ref is used as a way to distinguish between an individual ingredient on the frontend # It is required for the reorder and section titles to function properly because of how # Vue handles reactivity. ref may serve another purpose in the future. reference_id: UUID = Field(default_factory=uuid4) + model_config = ConfigDict(from_attributes=True) - class Config: - orm_mode = True - - @validator("quantity", pre=True) + @field_validator("quantity", mode="before") + @classmethod def validate_quantity(cls, value) -> NoneFloat: """ Sometimes the frontend UI will provide an empty string as a "null" value because of the default @@ -294,7 +294,7 @@ class IngredientConfidence(MealieModel): quantity: NoneFloat = None food: NoneFloat = None - @validator("quantity", pre=True) + @field_validator("quantity", mode="before") @classmethod def validate_quantity(cls, value, values) -> NoneFloat: if isinstance(value, float): @@ -305,7 +305,7 @@ class IngredientConfidence(MealieModel): class ParsedIngredient(MealieModel): - input: str | None + input: str | None = None confidence: IngredientConfidence = IngredientConfidence() ingredient: RecipeIngredient @@ -337,4 +337,4 @@ class MergeUnit(MealieModel): from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary # noqa: E402 -IngredientFood.update_forward_refs() +IngredientFood.model_rebuild() diff --git a/mealie/schema/recipe/recipe_notes.py b/mealie/schema/recipe/recipe_notes.py index ae8633a884b9..6829a2bf4f42 100644 --- a/mealie/schema/recipe/recipe_notes.py +++ b/mealie/schema/recipe/recipe_notes.py @@ -1,9 +1,7 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class RecipeNote(BaseModel): title: str text: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/recipe/recipe_nutrition.py b/mealie/schema/recipe/recipe_nutrition.py index 776b5bf4c084..12908ffb94f8 100644 --- a/mealie/schema/recipe/recipe_nutrition.py +++ b/mealie/schema/recipe/recipe_nutrition.py @@ -1,14 +1,14 @@ +from pydantic import ConfigDict + from mealie.schema._mealie import MealieModel class Nutrition(MealieModel): - calories: str | None - fat_content: str | None - protein_content: str | None - carbohydrate_content: str | None - fiber_content: str | None - sodium_content: str | None - sugar_content: str | None - - class Config: - orm_mode = True + calories: str | None = None + fat_content: str | None = None + protein_content: str | None = None + carbohydrate_content: str | None = None + fiber_content: str | None = None + sodium_content: str | None = None + sugar_content: str | None = None + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/recipe/recipe_scraper.py b/mealie/schema/recipe/recipe_scraper.py index 841e73e47818..bf9e53d330f6 100644 --- a/mealie/schema/recipe/recipe_scraper.py +++ b/mealie/schema/recipe/recipe_scraper.py @@ -1,3 +1,5 @@ +from pydantic import ConfigDict + from mealie.schema._mealie.mealie_model import MealieModel @@ -8,11 +10,11 @@ class ScrapeRecipeTest(MealieModel): class ScrapeRecipe(MealieModel): url: str include_tags: bool = False - - class Config: - schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "url": "https://myfavoriterecipes.com/recipes", "includeTags": True, }, } + ) diff --git a/mealie/schema/recipe/recipe_settings.py b/mealie/schema/recipe/recipe_settings.py index c188235ad3d7..1d90c3397de2 100644 --- a/mealie/schema/recipe/recipe_settings.py +++ b/mealie/schema/recipe/recipe_settings.py @@ -1,3 +1,5 @@ +from pydantic import ConfigDict + from mealie.schema._mealie import MealieModel @@ -9,6 +11,4 @@ class RecipeSettings(MealieModel): disable_comments: bool = True disable_amount: bool = True locked: bool = False - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/recipe/recipe_share_token.py b/mealie/schema/recipe/recipe_share_token.py index b03a48a539a8..6976548661f9 100644 --- a/mealie/schema/recipe/recipe_share_token.py +++ b/mealie/schema/recipe/recipe_share_token.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta -from pydantic import UUID4, Field +from pydantic import UUID4, ConfigDict, Field from sqlalchemy.orm import selectinload from sqlalchemy.orm.interfaces import LoaderOption @@ -26,16 +26,12 @@ class RecipeShareTokenSave(RecipeShareTokenCreate): class RecipeShareTokenSummary(RecipeShareTokenSave): id: UUID4 created_at: datetime - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class RecipeShareToken(RecipeShareTokenSummary): recipe: Recipe - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: diff --git a/mealie/schema/recipe/recipe_step.py b/mealie/schema/recipe/recipe_step.py index 6db07c215edd..d2b09f43e3b2 100644 --- a/mealie/schema/recipe/recipe_step.py +++ b/mealie/schema/recipe/recipe_step.py @@ -1,6 +1,6 @@ from uuid import UUID, uuid4 -from pydantic import UUID4, Field +from pydantic import UUID4, ConfigDict, Field from mealie.schema._mealie import MealieModel @@ -10,10 +10,8 @@ class IngredientReferences(MealieModel): A list of ingredient references. """ - reference_id: UUID4 | None - - class Config: - orm_mode = True + reference_id: UUID4 | None = None + model_config = ConfigDict(from_attributes=True) class RecipeStep(MealieModel): @@ -21,6 +19,4 @@ class RecipeStep(MealieModel): title: str | None = "" text: str ingredient_references: list[IngredientReferences] = [] - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/recipe/recipe_timeline_events.py b/mealie/schema/recipe/recipe_timeline_events.py index f344742dd919..5339491235bf 100644 --- a/mealie/schema/recipe/recipe_timeline_events.py +++ b/mealie/schema/recipe/recipe_timeline_events.py @@ -1,8 +1,9 @@ from datetime import datetime from enum import Enum from pathlib import Path +from typing import Annotated -from pydantic import UUID4, Field +from pydantic import UUID4, ConfigDict, Field from mealie.core.config import get_app_dirs from mealie.schema._mealie.mealie_model import MealieModel @@ -32,12 +33,10 @@ class RecipeTimelineEventIn(MealieModel): event_type: TimelineEventType message: str | None = Field(None, alias="eventMessage") - image: TimelineEventImage | None = TimelineEventImage.does_not_have_image + image: Annotated[TimelineEventImage | None, Field(validate_default=True)] = TimelineEventImage.does_not_have_image timestamp: datetime = datetime.now() - - class Config: - use_enum_values = True + model_config = ConfigDict(use_enum_values=True) class RecipeTimelineEventCreate(RecipeTimelineEventIn): @@ -46,20 +45,16 @@ class RecipeTimelineEventCreate(RecipeTimelineEventIn): class RecipeTimelineEventUpdate(MealieModel): subject: str - message: str | None = Field(alias="eventMessage") + message: str | None = Field(None, alias="eventMessage") image: TimelineEventImage | None = None - - class Config: - use_enum_values = True + model_config = ConfigDict(use_enum_values=True) class RecipeTimelineEventOut(RecipeTimelineEventCreate): id: UUID4 created_at: datetime update_at: datetime - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def image_dir_from_id(cls, recipe_id: UUID4 | str, timeline_event_id: UUID4 | str) -> Path: diff --git a/mealie/schema/recipe/recipe_tool.py b/mealie/schema/recipe/recipe_tool.py index 88c8478573cf..212605b8e9de 100644 --- a/mealie/schema/recipe/recipe_tool.py +++ b/mealie/schema/recipe/recipe_tool.py @@ -1,4 +1,4 @@ -from pydantic import UUID4 +from pydantic import UUID4, ConfigDict from sqlalchemy.orm import selectinload from sqlalchemy.orm.interfaces import LoaderOption @@ -19,16 +19,12 @@ class RecipeToolSave(RecipeToolCreate): class RecipeToolOut(RecipeToolCreate): id: UUID4 slug: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class RecipeToolResponse(RecipeToolOut): recipes: list["RecipeSummary"] = [] - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: @@ -41,4 +37,4 @@ class RecipeToolResponse(RecipeToolOut): from .recipe import RecipeSummary # noqa: E402 -RecipeToolResponse.update_forward_refs() +RecipeToolResponse.model_rebuild() diff --git a/mealie/schema/recipe/request_helpers.py b/mealie/schema/recipe/request_helpers.py index 4b1a8c1a28d8..67df9b253577 100644 --- a/mealie/schema/recipe/request_helpers.py +++ b/mealie/schema/recipe/request_helpers.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from mealie.schema._mealie import MealieModel @@ -10,8 +10,7 @@ class RecipeSlug(MealieModel): class SlugResponse(BaseModel): - class Config: - schema_extra = {"example": "adult-mac-and-cheese"} + model_config = ConfigDict(json_schema_extra={"example": "adult-mac-and-cheese"}) class UpdateImageResponse(BaseModel): @@ -23,4 +22,4 @@ class RecipeZipTokenResponse(BaseModel): class RecipeDuplicate(BaseModel): - name: str | None + name: str | None = None diff --git a/mealie/schema/reports/reports.py b/mealie/schema/reports/reports.py index df6583363073..b4a5aaf17dc6 100644 --- a/mealie/schema/reports/reports.py +++ b/mealie/schema/reports/reports.py @@ -1,7 +1,7 @@ import datetime import enum -from pydantic import Field +from pydantic import ConfigDict, Field from pydantic.types import UUID4 from sqlalchemy.orm import joinedload from sqlalchemy.orm.interfaces import LoaderOption @@ -34,9 +34,7 @@ class ReportEntryCreate(MealieModel): class ReportEntryOut(ReportEntryCreate): id: UUID4 - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class ReportCreate(MealieModel): @@ -53,9 +51,7 @@ class ReportSummary(ReportCreate): class ReportOut(ReportSummary): entries: list[ReportEntryOut] = [] - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: diff --git a/mealie/schema/response/pagination.py b/mealie/schema/response/pagination.py index 74e3b1db1cd5..27aae76e9b61 100644 --- a/mealie/schema/response/pagination.py +++ b/mealie/schema/response/pagination.py @@ -1,10 +1,10 @@ import enum -from typing import Any, Generic, TypeVar +from typing import Annotated, Any, Generic, TypeVar from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from humps import camelize -from pydantic import UUID4, BaseModel, validator -from pydantic.generics import GenericModel +from pydantic import UUID4, BaseModel, Field, field_validator +from pydantic_core.core_schema import ValidationInfo from mealie.schema._mealie import MealieModel @@ -22,12 +22,12 @@ class OrderByNullPosition(str, enum.Enum): class RecipeSearchQuery(MealieModel): - cookbook: UUID4 | str | None + cookbook: UUID4 | str | None = None require_all_categories: bool = False require_all_tags: bool = False require_all_tools: bool = False require_all_foods: bool = False - search: str | None + search: str | None = None _search_seed: str | None = None @@ -38,23 +38,23 @@ class PaginationQuery(MealieModel): order_by_null_position: OrderByNullPosition | None = None order_direction: OrderDirection = OrderDirection.desc query_filter: str | None = None - pagination_seed: str | None = None + pagination_seed: Annotated[str | None, Field(validate_default=True)] = None - @validator("pagination_seed", always=True, pre=True) - def validate_randseed(cls, pagination_seed, values): - if values.get("order_by") == "random" and not pagination_seed: + @field_validator("pagination_seed", mode="before") + def validate_randseed(cls, pagination_seed, info: ValidationInfo): + if info.data.get("order_by") == "random" and not pagination_seed: raise ValueError("paginationSeed is required when orderBy is random") return pagination_seed -class PaginationBase(GenericModel, Generic[DataT]): +class PaginationBase(BaseModel, Generic[DataT]): page: int = 1 per_page: int = 10 total: int = 0 total_pages: int = 0 items: list[DataT] - next: str | None - previous: str | None + next: str | None = None + previous: str | None = None def _set_next(self, route: str, query_params: dict[str, Any]) -> None: if self.page >= self.total_pages: diff --git a/mealie/schema/response/responses.py b/mealie/schema/response/responses.py index b5b781c5c557..519e7ec653ea 100644 --- a/mealie/schema/response/responses.py +++ b/mealie/schema/response/responses.py @@ -14,7 +14,7 @@ class ErrorResponse(BaseModel): This method is an helper to create an object and convert to a dictionary in the same call, for use while providing details to a HTTPException """ - return cls(message=message, exception=exception).dict() + return cls(message=message, exception=exception).model_dump() class SuccessResponse(BaseModel): @@ -27,7 +27,7 @@ class SuccessResponse(BaseModel): This method is an helper to create an object and convert to a dictionary in the same call, for use while providing details to a HTTPException """ - return cls(message=message).dict() + return cls(message=message).model_dump() class FileTokenResponse(MealieModel): @@ -39,4 +39,4 @@ class FileTokenResponse(MealieModel): This method is an helper to create an object and convert to a dictionary in the same call, for use while providing details to a HTTPException """ - return cls(file_token=token).dict() + return cls(file_token=token).model_dump() diff --git a/mealie/schema/server/tasks.py b/mealie/schema/server/tasks.py index 20b5faa4cf18..b467a0655d63 100644 --- a/mealie/schema/server/tasks.py +++ b/mealie/schema/server/tasks.py @@ -2,7 +2,7 @@ import datetime import enum from uuid import UUID -from pydantic import Field +from pydantic import ConfigDict, Field from mealie.schema._mealie import MealieModel from mealie.schema.response.pagination import PaginationBase @@ -43,9 +43,7 @@ class ServerTaskCreate(MealieModel): class ServerTask(ServerTaskCreate): id: int - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class ServerTaskPagination(PaginationBase): diff --git a/mealie/schema/user/auth.py b/mealie/schema/user/auth.py index 657eab590f9f..7ecf0dc2cc09 100644 --- a/mealie/schema/user/auth.py +++ b/mealie/schema/user/auth.py @@ -1,5 +1,6 @@ -from pydantic import UUID4, BaseModel -from pydantic.types import constr +from typing import Annotated + +from pydantic import UUID4, BaseModel, StringConstraints from mealie.schema._mealie.mealie_model import MealieModel @@ -10,8 +11,8 @@ class Token(BaseModel): class TokenData(BaseModel): - user_id: UUID4 | None - username: constr(to_lower=True, strip_whitespace=True) | None = None # type: ignore + user_id: UUID4 | None = None + username: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] | None = None # type: ignore class UnlockResults(MealieModel): diff --git a/mealie/schema/user/registration.py b/mealie/schema/user/registration.py index 6eadda5648e3..7219cd27718b 100644 --- a/mealie/schema/user/registration.py +++ b/mealie/schema/user/registration.py @@ -1,15 +1,17 @@ -from pydantic import validator -from pydantic.types import NoneStr, constr +from typing import Annotated + +from pydantic import Field, StringConstraints, field_validator +from pydantic_core.core_schema import ValidationInfo from mealie.schema._mealie import MealieModel from mealie.schema._mealie.validators import validate_locale class CreateUserRegistration(MealieModel): - group: NoneStr = None - group_token: NoneStr = None - email: constr(to_lower=True, strip_whitespace=True) # type: ignore - username: constr(to_lower=True, strip_whitespace=True) # type: ignore + group: str | None = None + group_token: Annotated[str | None, Field(validate_default=True)] = None + email: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] # type: ignore + username: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] # type: ignore password: str password_confirm: str advanced: bool = False @@ -18,23 +20,23 @@ class CreateUserRegistration(MealieModel): seed_data: bool = False locale: str = "en-US" - @validator("locale") - def valid_locale(cls, v, values, **kwargs): + @field_validator("locale") + def valid_locale(cls, v): if not validate_locale(v): raise ValueError("invalid locale") return v - @validator("password_confirm") + @field_validator("password_confirm") @classmethod - def passwords_match(cls, value, values): - if "password" in values and value != values["password"]: + def passwords_match(cls, value, info: ValidationInfo): + if "password" in info.data and value != info.data["password"]: raise ValueError("passwords do not match") return value - @validator("group_token", always=True) + @field_validator("group_token") @classmethod - def group_or_token(cls, value, values): - if not bool(value) and not bool(values["group"]): + def group_or_token(cls, value, info: ValidationInfo): + if not bool(value) and not bool(info.data["group"]): raise ValueError("group or group_token must be provided") return value diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py index 21ce7a593539..72e0ab1c87f2 100644 --- a/mealie/schema/user/user.py +++ b/mealie/schema/user/user.py @@ -1,10 +1,9 @@ from datetime import datetime, timedelta from pathlib import Path -from typing import Any +from typing import Annotated, Any from uuid import UUID -from pydantic import UUID4, Field, validator -from pydantic.types import constr +from pydantic import UUID4, ConfigDict, Field, StringConstraints, field_validator from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm.interfaces import LoaderOption @@ -18,7 +17,6 @@ from mealie.schema.response.pagination import PaginationBase from ...db.models.group import Group from ...db.models.recipe import RecipeModel -from ..getter_dict import GroupGetterDict, UserGetterDict from ..recipe import CategoryBase DEFAULT_INTEGRATION_ID = "generic" @@ -34,25 +32,19 @@ class LongLiveTokenOut(MealieModel): token: str name: str id: int - created_at: datetime | None - - class Config: - orm_mode = True + created_at: datetime | None = None + model_config = ConfigDict(from_attributes=True) class CreateToken(LongLiveTokenIn): user_id: UUID4 token: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class DeleteTokenResponse(MealieModel): token_delete: str - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) class ChangePassword(MealieModel): @@ -61,31 +53,27 @@ class ChangePassword(MealieModel): class GroupBase(MealieModel): - name: constr(strip_whitespace=True, min_length=1) # type: ignore - - class Config: - orm_mode = True + name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] # type: ignore + model_config = ConfigDict(from_attributes=True) class UserBase(MealieModel): - username: str | None + id: UUID4 | None = None + username: str | None = None full_name: str | None = None - email: constr(to_lower=True, strip_whitespace=True) # type: ignore + email: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] # type: ignore auth_method: AuthMethod = AuthMethod.MEALIE admin: bool = False - group: str | None + group: str | None = None advanced: bool = False favorite_recipes: list[str] | None = [] can_invite: bool = False can_manage: bool = False can_organize: bool = False - - class Config: - orm_mode = True - getter_dict = GroupGetterDict - - schema_extra = { + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ "example": { "username": "ChangeMe", "fullName": "Change Me", @@ -93,7 +81,18 @@ class UserBase(MealieModel): "group": settings.DEFAULT_GROUP, "admin": "false", } - } + }, + ) + + @field_validator("group", mode="before") + def convert_group_to_name(cls, v): + if not v or isinstance(v, str): + return v + + try: + return v.name + except AttributeError: + return v class UserIn(UserBase): @@ -105,14 +104,10 @@ class UserOut(UserBase): group: str group_id: UUID4 group_slug: str - tokens: list[LongLiveTokenOut] | None + tokens: list[LongLiveTokenOut] | None = None cache_key: str - favorite_recipes: list[str] | None = [] - - class Config: - orm_mode = True - - getter_dict = UserGetterDict + favorite_recipes: Annotated[list[str] | None, Field(validate_default=True)] = [] + model_config = ConfigDict(from_attributes=True) @property def is_default_user(self) -> bool: @@ -122,6 +117,10 @@ class UserOut(UserBase): def loader_options(cls) -> list[LoaderOption]: return [joinedload(User.group), joinedload(User.favorite_recipes), joinedload(User.tokens)] + @field_validator("favorite_recipes", mode="before") + def convert_favorite_recipes_to_slugs(cls, v): + return [recipe.slug for recipe in v] if v else v + class UserPagination(PaginationBase): items: list[UserOut] @@ -129,10 +128,7 @@ class UserPagination(PaginationBase): class UserFavorites(UserBase): favorite_recipes: list[RecipeSummary] = [] # type: ignore - - class Config: - orm_mode = True - getter_dict = GroupGetterDict + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: @@ -149,11 +145,10 @@ class PrivateUser(UserOut): group_id: UUID4 login_attemps: int = 0 locked_at: datetime | None = None + model_config = ConfigDict(from_attributes=True) - class Config: - orm_mode = True - - @validator("login_attemps", pre=True) + @field_validator("login_attemps", mode="before") + @classmethod def none_to_zero(cls, v): return 0 if v is None else v @@ -189,11 +184,9 @@ class UpdateGroup(GroupBase): class GroupInDB(UpdateGroup): - users: list[UserOut] | None + users: list[UserOut] | None = None preferences: ReadGroupPreferences | None = None - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @staticmethod def get_directory(id: UUID4) -> Path: @@ -234,6 +227,4 @@ class GroupPagination(PaginationBase): class LongLiveTokenInDB(CreateToken): id: int user: PrivateUser - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) diff --git a/mealie/schema/user/user_passwords.py b/mealie/schema/user/user_passwords.py index 9fe3235ace3a..3e10945eeb1b 100644 --- a/mealie/schema/user/user_passwords.py +++ b/mealie/schema/user/user_passwords.py @@ -1,4 +1,4 @@ -from pydantic import UUID4 +from pydantic import UUID4, ConfigDict from sqlalchemy.orm import selectinload from sqlalchemy.orm.interfaces import LoaderOption @@ -33,9 +33,7 @@ class SavePasswordResetToken(MealieModel): class PrivatePasswordResetToken(SavePasswordResetToken): user: PrivateUser - - class Config: - orm_mode = True + model_config = ConfigDict(from_attributes=True) @classmethod def loader_options(cls) -> list[LoaderOption]: diff --git a/mealie/services/backups_v2/backup_file.py b/mealie/services/backups_v2/backup_file.py index f4a9b458ca9e..f5321483e6e8 100644 --- a/mealie/services/backups_v2/backup_file.py +++ b/mealie/services/backups_v2/backup_file.py @@ -43,7 +43,7 @@ class BackupContents: class BackupFile: - temp_dir: Path | None + temp_dir: Path | None = None def __init__(self, file: Path) -> None: self.zip = file diff --git a/mealie/services/email/email_service.py b/mealie/services/email/email_service.py index b97661274ee3..533d135b2aa4 100644 --- a/mealie/services/email/email_service.py +++ b/mealie/services/email/email_service.py @@ -24,7 +24,7 @@ class EmailTemplate(BaseModel): def render_html(self, template: Path) -> str: tmpl = Template(template.read_text()) - return tmpl.render(data=self.dict()) + return tmpl.render(data=self.model_dump()) class EmailService(BaseService): diff --git a/mealie/services/event_bus_service/event_bus_listeners.py b/mealie/services/event_bus_service/event_bus_listeners.py index ef91378153eb..64352fd22c67 100644 --- a/mealie/services/event_bus_service/event_bus_listeners.py +++ b/mealie/services/event_bus_service/event_bus_listeners.py @@ -23,8 +23,8 @@ from .publisher import ApprisePublisher, PublisherLike, WebhookPublisher class EventListenerBase(ABC): - _session: Session | None - _repos: AllRepositories | None + _session: Session | None = None + _repos: AllRepositories | None = None def __init__(self, group_id: UUID4, publisher: PublisherLike) -> None: self.group_id = group_id diff --git a/mealie/services/event_bus_service/event_bus_service.py b/mealie/services/event_bus_service/event_bus_service.py index ecc8daee0807..c97d73952bad 100644 --- a/mealie/services/event_bus_service/event_bus_service.py +++ b/mealie/services/event_bus_service/event_bus_service.py @@ -38,9 +38,9 @@ class EventSource: class EventBusService: - bg: BackgroundTasks | None - session: Session | None - group_id: UUID4 | None + bg: BackgroundTasks | None = None + session: Session | None = None + group_id: UUID4 | None = None def __init__( self, bg: BackgroundTasks | None = None, session: Session | None = None, group_id: UUID4 | None = None diff --git a/mealie/services/event_bus_service/event_types.py b/mealie/services/event_bus_service/event_types.py index 93529f1db6a2..569eb6932810 100644 --- a/mealie/services/event_bus_service/event_types.py +++ b/mealie/services/event_bus_service/event_types.py @@ -3,7 +3,7 @@ from datetime import date, datetime from enum import Enum, auto from typing import Any -from pydantic import UUID4 +from pydantic import UUID4, field_validator from ...schema._mealie.mealie_model import MealieModel @@ -85,79 +85,79 @@ class EventDocumentDataBase(MealieModel): class EventMealplanCreatedData(EventDocumentDataBase): - document_type = EventDocumentType.mealplan - operation = EventOperation.create + document_type: EventDocumentType = EventDocumentType.mealplan + operation: EventOperation = EventOperation.create mealplan_id: int date: date - recipe_id: UUID4 | None - recipe_name: str | None - recipe_slug: str | None + recipe_id: UUID4 | None = None + recipe_name: str | None = None + recipe_slug: str | None = None class EventUserSignupData(EventDocumentDataBase): - document_type = EventDocumentType.user - operation = EventOperation.create + document_type: EventDocumentType = EventDocumentType.user + operation: EventOperation = EventOperation.create username: str email: str class EventCategoryData(EventDocumentDataBase): - document_type = EventDocumentType.category + document_type: EventDocumentType = EventDocumentType.category category_id: UUID4 class EventCookbookData(EventDocumentDataBase): - document_type = EventDocumentType.cookbook + document_type: EventDocumentType = EventDocumentType.cookbook cookbook_id: UUID4 class EventCookbookBulkData(EventDocumentDataBase): - document_type = EventDocumentType.cookbook + document_type: EventDocumentType = EventDocumentType.cookbook cookbook_ids: list[UUID4] class EventShoppingListData(EventDocumentDataBase): - document_type = EventDocumentType.shopping_list + document_type: EventDocumentType = EventDocumentType.shopping_list shopping_list_id: UUID4 class EventShoppingListItemData(EventDocumentDataBase): - document_type = EventDocumentType.shopping_list_item + document_type: EventDocumentType = EventDocumentType.shopping_list_item shopping_list_id: UUID4 shopping_list_item_id: UUID4 class EventShoppingListItemBulkData(EventDocumentDataBase): - document_type = EventDocumentType.shopping_list_item + document_type: EventDocumentType = EventDocumentType.shopping_list_item shopping_list_id: UUID4 shopping_list_item_ids: list[UUID4] class EventRecipeData(EventDocumentDataBase): - document_type = EventDocumentType.recipe + document_type: EventDocumentType = EventDocumentType.recipe recipe_slug: str class EventRecipeBulkReportData(EventDocumentDataBase): - document_type = EventDocumentType.recipe_bulk_report + document_type: EventDocumentType = EventDocumentType.recipe_bulk_report report_id: UUID4 class EventRecipeTimelineEventData(EventDocumentDataBase): - document_type = EventDocumentType.recipe_timeline_event + document_type: EventDocumentType = EventDocumentType.recipe_timeline_event recipe_slug: str recipe_timeline_event_id: UUID4 class EventTagData(EventDocumentDataBase): - document_type = EventDocumentType.tag + document_type: EventDocumentType = EventDocumentType.tag tag_id: UUID4 class EventWebhookData(EventDocumentDataBase): webhook_start_dt: datetime webhook_end_dt: datetime - webhook_body: Any + webhook_body: Any = None class EventBusMessage(MealieModel): @@ -169,6 +169,11 @@ class EventBusMessage(MealieModel): title = event_type.name.replace("_", " ").title() return cls(title=title, body=body) + @field_validator("body") + def populate_body(v): + # if the body is empty, apprise won't send the notification + return v or "generic" + class Event(MealieModel): message: EventBusMessage @@ -177,8 +182,8 @@ class Event(MealieModel): document_data: EventDocumentDataBase # set at instantiation - event_id: UUID4 | None - timestamp: datetime | None + event_id: UUID4 | None = None + timestamp: datetime | None = None def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) diff --git a/mealie/services/exporter/_abc_exporter.py b/mealie/services/exporter/_abc_exporter.py index 16fe1fbc8d9b..3b40c726ed9d 100644 --- a/mealie/services/exporter/_abc_exporter.py +++ b/mealie/services/exporter/_abc_exporter.py @@ -27,7 +27,7 @@ class ExportedItem: class ABCExporter(BaseService): - write_dir_to_zip: Callable[[Path, str, set[str] | None], None] | None + write_dir_to_zip: Callable[[Path, str, set[str] | None], None] | None = None def __init__(self, db: AllRepositories, group_id: UUID) -> None: self.logger = get_logger() @@ -63,7 +63,7 @@ class ABCExporter(BaseService): self.logger.error("Failed to export item. no item found") continue - zip.writestr(f"{self.destination_dir}/{item.name}/{item.name}.json", item.model.json()) + zip.writestr(f"{self.destination_dir}/{item.name}/{item.name}.json", item.model.model_dump_json()) self._post_export_hook(item.model) diff --git a/mealie/services/migrations/nextcloud.py b/mealie/services/migrations/nextcloud.py index 74d442285c08..b45c4247f0dc 100644 --- a/mealie/services/migrations/nextcloud.py +++ b/mealie/services/migrations/nextcloud.py @@ -18,7 +18,7 @@ from .utils.migration_helpers import MigrationReaders, glob_walker, import_image class NextcloudDir: name: str recipe: dict - image: Path | None + image: Path | None = None @property def slug(self): diff --git a/mealie/services/migrations/utils/database_helpers.py b/mealie/services/migrations/utils/database_helpers.py index a9b7fd2642e0..8386a4700d68 100644 --- a/mealie/services/migrations/utils/database_helpers.py +++ b/mealie/services/migrations/utils/database_helpers.py @@ -45,7 +45,7 @@ class DatabaseMigrationHelpers: ) ) - items_out.append(item_model.dict()) + items_out.append(item_model.model_dump()) return items_out def get_or_set_category(self, categories: Iterable[str]) -> list[RecipeCategory]: diff --git a/mealie/services/parser_services/brute/process.py b/mealie/services/parser_services/brute/process.py index 30ac690bf65d..b2d559f3cb51 100644 --- a/mealie/services/parser_services/brute/process.py +++ b/mealie/services/parser_services/brute/process.py @@ -1,7 +1,7 @@ import string import unicodedata -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from .._helpers import check_char, move_parens_to_end @@ -11,9 +11,7 @@ class BruteParsedIngredient(BaseModel): note: str = "" amount: float = 1.0 unit: str = "" - - class Config: - anystr_strip_whitespace = True + model_config = ConfigDict(str_strip_whitespace=True) def parse_fraction(x): diff --git a/mealie/services/parser_services/crfpp/processor.py b/mealie/services/parser_services/crfpp/processor.py index d0bf1b69cc66..5c114732f31a 100644 --- a/mealie/services/parser_services/crfpp/processor.py +++ b/mealie/services/parser_services/crfpp/processor.py @@ -2,8 +2,10 @@ import subprocess import tempfile from fractions import Fraction from pathlib import Path +from typing import Annotated -from pydantic import BaseModel, validator +from pydantic import BaseModel, Field, field_validator +from pydantic_core.core_schema import ValidationInfo from mealie.schema._mealie.types import NoneFloat @@ -19,7 +21,7 @@ class CRFConfidence(BaseModel): comment: NoneFloat = None name: NoneFloat = None unit: NoneFloat = None - qty: NoneFloat = None + qty: Annotated[NoneFloat, Field(validate_default=True)] = None class CRFIngredient(BaseModel): @@ -31,13 +33,13 @@ class CRFIngredient(BaseModel): unit: str = "" confidence: CRFConfidence - @validator("qty", always=True, pre=True) - def validate_qty(qty, values): # sourcery skip: merge-nested-ifs + @field_validator("qty", mode="before") + def validate_qty(qty, info: ValidationInfo): # sourcery skip: merge-nested-ifs if qty is None or qty == "": # Check if other contains a fraction try: - if values["other"] is not None and values["other"].find("/") != -1: - return round(float(Fraction(values["other"])), 3) + if info.data["other"] is not None and info.data["other"].find("/") != -1: + return round(float(Fraction(info.data["other"])), 3) else: return 1 except Exception: diff --git a/mealie/services/parser_services/ingredient_parser.py b/mealie/services/parser_services/ingredient_parser.py index 26508310353b..079010d41d04 100644 --- a/mealie/services/parser_services/ingredient_parser.py +++ b/mealie/services/parser_services/ingredient_parser.py @@ -228,7 +228,7 @@ class NLPParser(ABCIngredientParser): confidence=IngredientConfidence( quantity=crf_model.confidence.qty, food=crf_model.confidence.name, - **crf_model.confidence.dict(), + **crf_model.confidence.model_dump(), ), ) diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 533120a07711..45a699eded11 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -129,7 +129,7 @@ class RecipeService(BaseService): data: Recipe = self._recipe_creation_factory( self.user, name=create_data.name, - additional_attrs=create_data.dict(), + additional_attrs=create_data.model_dump(), ) if isinstance(create_data, CreateRecipe) or create_data.settings is None: @@ -175,11 +175,11 @@ class RecipeService(BaseService): # if the item exists, return the actual data query = repo.get_one(slug, "slug") if query: - return query.dict() + return query.model_dump() # otherwise, create the item new_item = repo.create(data) - return new_item.dict() + return new_item.model_dump() def _process_recipe_data(self, key: str, data: list | dict | Any): if isinstance(data, list): @@ -250,20 +250,21 @@ class RecipeService(BaseService): """Duplicates a recipe and returns the new recipe.""" old_recipe = self._get_recipe(old_slug) - new_recipe = old_recipe.copy(exclude={"id", "name", "slug", "image", "comments"}) + new_recipe_data = old_recipe.model_dump(exclude={"id", "name", "slug", "image", "comments"}, round_trip=True) + new_recipe = Recipe.model_validate(new_recipe_data) # Asset images in steps directly link to the original recipe, so we # need to update them to references to the assets we copy below def replace_recipe_step(step: RecipeStep) -> RecipeStep: - new_step = step.copy(exclude={"id", "text"}) - new_step.id = uuid4() - new_step.text = step.text.replace(str(old_recipe.id), str(new_recipe.id)) + new_id = uuid4() + new_text = step.text.replace(str(old_recipe.id), str(new_recipe.id)) + new_step = step.model_copy(update={"id": new_id, "text": new_text}) return new_step # Copy ingredients to make them independent of the original def copy_recipe_ingredient(ingredient: RecipeIngredient): - new_ingredient = ingredient.copy(exclude={"reference_id"}) - new_ingredient.reference_id = uuid4() + new_reference_id = uuid4() + new_ingredient = ingredient.model_copy(update={"reference_id": new_reference_id}) return new_ingredient new_name = dup_data.name if dup_data.name else old_recipe.name or "" @@ -284,7 +285,7 @@ class RecipeService(BaseService): new_recipe = self._recipe_creation_factory( self.user, new_name, - additional_attrs=new_recipe.dict(), + additional_attrs=new_recipe.model_dump(), ) new_recipe = self.repos.recipes.create(new_recipe) @@ -350,7 +351,9 @@ class RecipeService(BaseService): if recipe is None: raise exceptions.NoEntryFound("Recipe not found.") - new_data = self.repos.recipes.by_group(self.group.id).patch(recipe.slug, patch_data.dict(exclude_unset=True)) + new_data = self.repos.recipes.by_group(self.group.id).patch( + recipe.slug, patch_data.model_dump(exclude_unset=True) + ) self.check_assets(new_data, recipe.slug) return new_data diff --git a/mealie/services/recipe/template_service.py b/mealie/services/recipe/template_service.py index 3242c4acc6f1..9038ca2f1f54 100644 --- a/mealie/services/recipe/template_service.py +++ b/mealie/services/recipe/template_service.py @@ -92,7 +92,7 @@ class TemplateService(BaseService): save_path = self.temp.joinpath(f"{recipe.slug}.json") with open(save_path, "w") as f: - f.write(recipe.json(indent=4, by_alias=True)) + f.write(recipe.model_dump_json(indent=4, by_alias=True)) return save_path @@ -115,7 +115,7 @@ class TemplateService(BaseService): template_text = f.read() template = Template(template_text) - rendered_text = template.render(recipe=recipe.dict(by_alias=True)) + rendered_text = template.render(recipe=recipe.model_dump(by_alias=True)) save_name = f"{recipe.slug}{j2_path.suffix}" @@ -140,7 +140,7 @@ class TemplateService(BaseService): zip_temp = self.temp.joinpath(f"{recipe.slug}.zip") with ZipFile(zip_temp, "w") as myzip: - myzip.writestr(f"{recipe.slug}.json", recipe.json()) + myzip.writestr(f"{recipe.slug}.json", recipe.model_dump_json()) if image_asset.is_file(): myzip.write(image_asset, arcname=image_asset.name) diff --git a/mealie/services/user_services/registration_service.py b/mealie/services/user_services/registration_service.py index a294e8152e9c..5873e9c0d91d 100644 --- a/mealie/services/user_services/registration_service.py +++ b/mealie/services/user_services/registration_service.py @@ -29,7 +29,7 @@ class RegistrationService: password=hash_password(self.registration.password), full_name=self.registration.username, advanced=self.registration.advanced, - group=group.name, + group=group, can_invite=new_group, can_manage=new_group, can_organize=new_group, diff --git a/poetry.lock b/poetry.lock index 8d304f0ff30b..0f083e74cc13 100644 --- a/poetry.lock +++ b/poetry.lock @@ -44,6 +44,17 @@ files = [ [package.extras] dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"] +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + [[package]] name = "anyio" version = "3.7.1" @@ -1220,13 +1231,13 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp [[package]] name = "mkdocs-material" -version = "9.5.9" +version = "9.5.8" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.9-py3-none-any.whl", hash = "sha256:a5d62b73b3b74349e45472bfadc129c871dd2d4add68d84819580597b2f50d5d"}, - {file = "mkdocs_material-9.5.9.tar.gz", hash = "sha256:635df543c01c25c412d6c22991872267723737d5a2f062490f33b2da1c013c6d"}, + {file = "mkdocs_material-9.5.8-py3-none-any.whl", hash = "sha256:14563314bbf97da4bfafc69053772341babfaeb3329cde01d3e63cec03997af8"}, + {file = "mkdocs_material-9.5.8.tar.gz", hash = "sha256:2a429213e83f84eda7a588e2b186316d806aac602b7f93990042f7a1f3d3cf65"}, ] [package.dependencies] @@ -1567,13 +1578,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.6.1" +version = "3.6.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.6.1-py2.py3-none-any.whl", hash = "sha256:9fe989afcf095d2c4796ce7c553cf28d4d4a9b9346de3cda079bcf40748454a4"}, - {file = "pre_commit-3.6.1.tar.gz", hash = "sha256:c90961d8aa706f75d60935aba09469a6b0bcb8345f127c3fbee4bdc5f114cf4b"}, + {file = "pre_commit-3.6.0-py2.py3-none-any.whl", hash = "sha256:c255039ef399049a5544b6ce13d135caba8f2c28c3b4033277a788f434308376"}, + {file = "pre_commit-3.6.0.tar.gz", hash = "sha256:d30bad9abf165f7785c15a21a1f46da7d0677cb00ee7ff4c579fd38922efe15d"}, ] [package.dependencies] @@ -1691,55 +1702,128 @@ pyasn1 = ">=0.4.6,<0.5.0" [[package]] name = "pydantic" -version = "1.10.14" -description = "Data validation and settings management using python type hints" +version = "2.6.1" +description = "Data validation using Python type hints" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pydantic-1.10.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4fcec873f90537c382840f330b90f4715eebc2bc9925f04cb92de593eae054"}, - {file = "pydantic-1.10.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e3a76f571970fcd3c43ad982daf936ae39b3e90b8a2e96c04113a369869dc87"}, - {file = "pydantic-1.10.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d886bd3c3fbeaa963692ef6b643159ccb4b4cefaf7ff1617720cbead04fd1d"}, - {file = "pydantic-1.10.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:798a3d05ee3b71967844a1164fd5bdb8c22c6d674f26274e78b9f29d81770c4e"}, - {file = "pydantic-1.10.14-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:23d47a4b57a38e8652bcab15a658fdb13c785b9ce217cc3a729504ab4e1d6bc9"}, - {file = "pydantic-1.10.14-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f9f674b5c3bebc2eba401de64f29948ae1e646ba2735f884d1594c5f675d6f2a"}, - {file = "pydantic-1.10.14-cp310-cp310-win_amd64.whl", hash = "sha256:24a7679fab2e0eeedb5a8924fc4a694b3bcaac7d305aeeac72dd7d4e05ecbebf"}, - {file = "pydantic-1.10.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d578ac4bf7fdf10ce14caba6f734c178379bd35c486c6deb6f49006e1ba78a7"}, - {file = "pydantic-1.10.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa7790e94c60f809c95602a26d906eba01a0abee9cc24150e4ce2189352deb1b"}, - {file = "pydantic-1.10.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad4e10efa5474ed1a611b6d7f0d130f4aafadceb73c11d9e72823e8f508e663"}, - {file = "pydantic-1.10.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1245f4f61f467cb3dfeced2b119afef3db386aec3d24a22a1de08c65038b255f"}, - {file = "pydantic-1.10.14-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:21efacc678a11114c765eb52ec0db62edffa89e9a562a94cbf8fa10b5db5c046"}, - {file = "pydantic-1.10.14-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:412ab4a3f6dbd2bf18aefa9f79c7cca23744846b31f1d6555c2ee2b05a2e14ca"}, - {file = "pydantic-1.10.14-cp311-cp311-win_amd64.whl", hash = "sha256:e897c9f35281f7889873a3e6d6b69aa1447ceb024e8495a5f0d02ecd17742a7f"}, - {file = "pydantic-1.10.14-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d604be0f0b44d473e54fdcb12302495fe0467c56509a2f80483476f3ba92b33c"}, - {file = "pydantic-1.10.14-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a42c7d17706911199798d4c464b352e640cab4351efe69c2267823d619a937e5"}, - {file = "pydantic-1.10.14-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:596f12a1085e38dbda5cbb874d0973303e34227b400b6414782bf205cc14940c"}, - {file = "pydantic-1.10.14-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bfb113860e9288d0886e3b9e49d9cf4a9d48b441f52ded7d96db7819028514cc"}, - {file = "pydantic-1.10.14-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bc3ed06ab13660b565eed80887fcfbc0070f0aa0691fbb351657041d3e874efe"}, - {file = "pydantic-1.10.14-cp37-cp37m-win_amd64.whl", hash = "sha256:ad8c2bc677ae5f6dbd3cf92f2c7dc613507eafe8f71719727cbc0a7dec9a8c01"}, - {file = "pydantic-1.10.14-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c37c28449752bb1f47975d22ef2882d70513c546f8f37201e0fec3a97b816eee"}, - {file = "pydantic-1.10.14-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49a46a0994dd551ec051986806122767cf144b9702e31d47f6d493c336462597"}, - {file = "pydantic-1.10.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53e3819bd20a42470d6dd0fe7fc1c121c92247bca104ce608e609b59bc7a77ee"}, - {file = "pydantic-1.10.14-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbb503bbbbab0c588ed3cd21975a1d0d4163b87e360fec17a792f7d8c4ff29f"}, - {file = "pydantic-1.10.14-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:336709883c15c050b9c55a63d6c7ff09be883dbc17805d2b063395dd9d9d0022"}, - {file = "pydantic-1.10.14-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4ae57b4d8e3312d486e2498d42aed3ece7b51848336964e43abbf9671584e67f"}, - {file = "pydantic-1.10.14-cp38-cp38-win_amd64.whl", hash = "sha256:dba49d52500c35cfec0b28aa8b3ea5c37c9df183ffc7210b10ff2a415c125c4a"}, - {file = "pydantic-1.10.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c66609e138c31cba607d8e2a7b6a5dc38979a06c900815495b2d90ce6ded35b4"}, - {file = "pydantic-1.10.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d986e115e0b39604b9eee3507987368ff8148222da213cd38c359f6f57b3b347"}, - {file = "pydantic-1.10.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:646b2b12df4295b4c3148850c85bff29ef6d0d9621a8d091e98094871a62e5c7"}, - {file = "pydantic-1.10.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282613a5969c47c83a8710cc8bfd1e70c9223feb76566f74683af889faadc0ea"}, - {file = "pydantic-1.10.14-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:466669501d08ad8eb3c4fecd991c5e793c4e0bbd62299d05111d4f827cded64f"}, - {file = "pydantic-1.10.14-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:13e86a19dca96373dcf3190fcb8797d40a6f12f154a244a8d1e8e03b8f280593"}, - {file = "pydantic-1.10.14-cp39-cp39-win_amd64.whl", hash = "sha256:08b6ec0917c30861e3fe71a93be1648a2aa4f62f866142ba21670b24444d7fd8"}, - {file = "pydantic-1.10.14-py3-none-any.whl", hash = "sha256:8ee853cd12ac2ddbf0ecbac1c289f95882b2d4482258048079d13be700aa114c"}, - {file = "pydantic-1.10.14.tar.gz", hash = "sha256:46f17b832fe27de7850896f3afee50ea682220dd218f7e9c88d436788419dca6"}, + {file = "pydantic-2.6.1-py3-none-any.whl", hash = "sha256:0b6a909df3192245cb736509a92ff69e4fef76116feffec68e93a567347bae6f"}, + {file = "pydantic-2.6.1.tar.gz", hash = "sha256:4fd5c182a2488dc63e6d32737ff19937888001e2a6d86e94b3f233104a5d1fa9"}, ] [package.dependencies] -typing-extensions = ">=4.2.0" +annotated-types = ">=0.4.0" +pydantic-core = "2.16.2" +typing-extensions = ">=4.6.1" [package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.16.2" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.16.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3fab4e75b8c525a4776e7630b9ee48aea50107fea6ca9f593c98da3f4d11bf7c"}, + {file = "pydantic_core-2.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8bde5b48c65b8e807409e6f20baee5d2cd880e0fad00b1a811ebc43e39a00ab2"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2924b89b16420712e9bb8192396026a8fbd6d8726224f918353ac19c4c043d2a"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16aa02e7a0f539098e215fc193c8926c897175d64c7926d00a36188917717a05"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:936a787f83db1f2115ee829dd615c4f684ee48ac4de5779ab4300994d8af325b"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:459d6be6134ce3b38e0ef76f8a672924460c455d45f1ad8fdade36796df1ddc8"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9ee4febb249c591d07b2d4dd36ebcad0ccd128962aaa1801508320896575ef"}, + {file = "pydantic_core-2.16.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40a0bd0bed96dae5712dab2aba7d334a6c67cbcac2ddfca7dbcc4a8176445990"}, + {file = "pydantic_core-2.16.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:870dbfa94de9b8866b37b867a2cb37a60c401d9deb4a9ea392abf11a1f98037b"}, + {file = "pydantic_core-2.16.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:308974fdf98046db28440eb3377abba274808bf66262e042c412eb2adf852731"}, + {file = "pydantic_core-2.16.2-cp310-none-win32.whl", hash = "sha256:a477932664d9611d7a0816cc3c0eb1f8856f8a42435488280dfbf4395e141485"}, + {file = "pydantic_core-2.16.2-cp310-none-win_amd64.whl", hash = "sha256:8f9142a6ed83d90c94a3efd7af8873bf7cefed2d3d44387bf848888482e2d25f"}, + {file = "pydantic_core-2.16.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:406fac1d09edc613020ce9cf3f2ccf1a1b2f57ab00552b4c18e3d5276c67eb11"}, + {file = "pydantic_core-2.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ce232a6170dd6532096cadbf6185271e4e8c70fc9217ebe105923ac105da9978"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a90fec23b4b05a09ad988e7a4f4e081711a90eb2a55b9c984d8b74597599180f"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8aafeedb6597a163a9c9727d8a8bd363a93277701b7bfd2749fbefee2396469e"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9957433c3a1b67bdd4c63717eaf174ebb749510d5ea612cd4e83f2d9142f3fc8"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0d7a9165167269758145756db43a133608a531b1e5bb6a626b9ee24bc38a8f7"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dffaf740fe2e147fedcb6b561353a16243e654f7fe8e701b1b9db148242e1272"}, + {file = "pydantic_core-2.16.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8ed79883b4328b7f0bd142733d99c8e6b22703e908ec63d930b06be3a0e7113"}, + {file = "pydantic_core-2.16.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cf903310a34e14651c9de056fcc12ce090560864d5a2bb0174b971685684e1d8"}, + {file = "pydantic_core-2.16.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46b0d5520dbcafea9a8645a8164658777686c5c524d381d983317d29687cce97"}, + {file = "pydantic_core-2.16.2-cp311-none-win32.whl", hash = "sha256:70651ff6e663428cea902dac297066d5c6e5423fda345a4ca62430575364d62b"}, + {file = "pydantic_core-2.16.2-cp311-none-win_amd64.whl", hash = "sha256:98dc6f4f2095fc7ad277782a7c2c88296badcad92316b5a6e530930b1d475ebc"}, + {file = "pydantic_core-2.16.2-cp311-none-win_arm64.whl", hash = "sha256:ef6113cd31411eaf9b39fc5a8848e71c72656fd418882488598758b2c8c6dfa0"}, + {file = "pydantic_core-2.16.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:88646cae28eb1dd5cd1e09605680c2b043b64d7481cdad7f5003ebef401a3039"}, + {file = "pydantic_core-2.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7b883af50eaa6bb3299780651e5be921e88050ccf00e3e583b1e92020333304b"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bf26c2e2ea59d32807081ad51968133af3025c4ba5753e6a794683d2c91bf6e"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99af961d72ac731aae2a1b55ccbdae0733d816f8bfb97b41909e143de735f522"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02906e7306cb8c5901a1feb61f9ab5e5c690dbbeaa04d84c1b9ae2a01ebe9379"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5362d099c244a2d2f9659fb3c9db7c735f0004765bbe06b99be69fbd87c3f15"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ac426704840877a285d03a445e162eb258924f014e2f074e209d9b4ff7bf380"}, + {file = "pydantic_core-2.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b94cbda27267423411c928208e89adddf2ea5dd5f74b9528513f0358bba019cb"}, + {file = "pydantic_core-2.16.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6db58c22ac6c81aeac33912fb1af0e930bc9774166cdd56eade913d5f2fff35e"}, + {file = "pydantic_core-2.16.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396fdf88b1b503c9c59c84a08b6833ec0c3b5ad1a83230252a9e17b7dfb4cffc"}, + {file = "pydantic_core-2.16.2-cp312-none-win32.whl", hash = "sha256:7c31669e0c8cc68400ef0c730c3a1e11317ba76b892deeefaf52dcb41d56ed5d"}, + {file = "pydantic_core-2.16.2-cp312-none-win_amd64.whl", hash = "sha256:a3b7352b48fbc8b446b75f3069124e87f599d25afb8baa96a550256c031bb890"}, + {file = "pydantic_core-2.16.2-cp312-none-win_arm64.whl", hash = "sha256:a9e523474998fb33f7c1a4d55f5504c908d57add624599e095c20fa575b8d943"}, + {file = "pydantic_core-2.16.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:ae34418b6b389d601b31153b84dce480351a352e0bb763684a1b993d6be30f17"}, + {file = "pydantic_core-2.16.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:732bd062c9e5d9582a30e8751461c1917dd1ccbdd6cafb032f02c86b20d2e7ec"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b52776a2e3230f4854907a1e0946eec04d41b1fc64069ee774876bbe0eab55"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef551c053692b1e39e3f7950ce2296536728871110e7d75c4e7753fb30ca87f4"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ebb892ed8599b23fa8f1799e13a12c87a97a6c9d0f497525ce9858564c4575a4"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa6c8c582036275997a733427b88031a32ffa5dfc3124dc25a730658c47a572f"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ba0884a91f1aecce75202473ab138724aa4fb26d7707f2e1fa6c3e68c84fbf"}, + {file = "pydantic_core-2.16.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7924e54f7ce5d253d6160090ddc6df25ed2feea25bfb3339b424a9dd591688bc"}, + {file = "pydantic_core-2.16.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69a7b96b59322a81c2203be537957313b07dd333105b73db0b69212c7d867b4b"}, + {file = "pydantic_core-2.16.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7e6231aa5bdacda78e96ad7b07d0c312f34ba35d717115f4b4bff6cb87224f0f"}, + {file = "pydantic_core-2.16.2-cp38-none-win32.whl", hash = "sha256:41dac3b9fce187a25c6253ec79a3f9e2a7e761eb08690e90415069ea4a68ff7a"}, + {file = "pydantic_core-2.16.2-cp38-none-win_amd64.whl", hash = "sha256:f685dbc1fdadb1dcd5b5e51e0a378d4685a891b2ddaf8e2bba89bd3a7144e44a"}, + {file = "pydantic_core-2.16.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:55749f745ebf154c0d63d46c8c58594d8894b161928aa41adbb0709c1fe78b77"}, + {file = "pydantic_core-2.16.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b30b0dd58a4509c3bd7eefddf6338565c4905406aee0c6e4a5293841411a1286"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18de31781cdc7e7b28678df7c2d7882f9692ad060bc6ee3c94eb15a5d733f8f7"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5864b0242f74b9dd0b78fd39db1768bc3f00d1ffc14e596fd3e3f2ce43436a33"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8f9186ca45aee030dc8234118b9c0784ad91a0bb27fc4e7d9d6608a5e3d386c"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc6f6c9be0ab6da37bc77c2dda5f14b1d532d5dbef00311ee6e13357a418e646"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa057095f621dad24a1e906747179a69780ef45cc8f69e97463692adbcdae878"}, + {file = "pydantic_core-2.16.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6ad84731a26bcfb299f9eab56c7932d46f9cad51c52768cace09e92a19e4cf55"}, + {file = "pydantic_core-2.16.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3b052c753c4babf2d1edc034c97851f867c87d6f3ea63a12e2700f159f5c41c3"}, + {file = "pydantic_core-2.16.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e0f686549e32ccdb02ae6f25eee40cc33900910085de6aa3790effd391ae10c2"}, + {file = "pydantic_core-2.16.2-cp39-none-win32.whl", hash = "sha256:7afb844041e707ac9ad9acad2188a90bffce2c770e6dc2318be0c9916aef1469"}, + {file = "pydantic_core-2.16.2-cp39-none-win_amd64.whl", hash = "sha256:9da90d393a8227d717c19f5397688a38635afec89f2e2d7af0df037f3249c39a"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f60f920691a620b03082692c378661947d09415743e437a7478c309eb0e4f82"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:47924039e785a04d4a4fa49455e51b4eb3422d6eaacfde9fc9abf8fdef164e8a"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6294e76b0380bb7a61eb8a39273c40b20beb35e8c87ee101062834ced19c545"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe56851c3f1d6f5384b3051c536cc81b3a93a73faf931f404fef95217cf1e10d"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9d776d30cde7e541b8180103c3f294ef7c1862fd45d81738d156d00551005784"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:72f7919af5de5ecfaf1eba47bf9a5d8aa089a3340277276e5636d16ee97614d7"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:4bfcbde6e06c56b30668a0c872d75a7ef3025dc3c1823a13cf29a0e9b33f67e8"}, + {file = "pydantic_core-2.16.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ff7c97eb7a29aba230389a2661edf2e9e06ce616c7e35aa764879b6894a44b25"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9b5f13857da99325dcabe1cc4e9e6a3d7b2e2c726248ba5dd4be3e8e4a0b6d0e"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a7e41e3ada4cca5f22b478c08e973c930e5e6c7ba3588fb8e35f2398cdcc1545"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60eb8ceaa40a41540b9acae6ae7c1f0a67d233c40dc4359c256ad2ad85bdf5e5"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7beec26729d496a12fd23cf8da9944ee338c8b8a17035a560b585c36fe81af20"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22c5f022799f3cd6741e24f0443ead92ef42be93ffda0d29b2597208c94c3753"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:eca58e319f4fd6df004762419612122b2c7e7d95ffafc37e890252f869f3fb2a"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed957db4c33bc99895f3a1672eca7e80e8cda8bd1e29a80536b4ec2153fa9804"}, + {file = "pydantic_core-2.16.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:459c0d338cc55d099798618f714b21b7ece17eb1a87879f2da20a3ff4c7628e2"}, + {file = "pydantic_core-2.16.2.tar.gz", hash = "sha256:0ba503850d8b8dcc18391f10de896ae51d37fe5fe43dbfb6a35c5c5cad271a06"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydantic-settings" +version = "2.1.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_settings-2.1.0-py3-none-any.whl", hash = "sha256:7621c0cb5d90d1140d2f0ef557bdf03573aac7035948109adf2574770b77605a"}, + {file = "pydantic_settings-2.1.0.tar.gz", hash = "sha256:26b1492e0a24755626ac5e6d715e9077ab7ad4fb5f19a8b7ed7011d52f36141c"}, +] + +[package.dependencies] +pydantic = ">=2.3.0" +python-dotenv = ">=0.21.0" [[package]] name = "pydantic-to-typescript" @@ -1979,13 +2063,13 @@ dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatc [[package]] name = "python-slugify" -version = "8.0.4" +version = "8.0.3" description = "A Python slugify application that also handles Unicode" optional = false python-versions = ">=3.7" files = [ - {file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"}, - {file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"}, + {file = "python-slugify-8.0.3.tar.gz", hash = "sha256:e04cba5f1c562502a1175c84a8bc23890c54cdaf23fccaaf0bf78511508cabed"}, + {file = "python_slugify-8.0.3-py2.py3-none-any.whl", hash = "sha256:c71189c161e8c671f1b141034d9a56308a8a5978cd13d40446c879569212fdd1"}, ] [package.dependencies] @@ -2680,13 +2764,13 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "uvicorn" -version = "0.27.1" +version = "0.27.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"}, - {file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"}, + {file = "uvicorn-0.27.0-py3-none-any.whl", hash = "sha256:890b00f6c537d58695d3bb1f28e23db9d9e7a17cbcc76d7457c499935f933e24"}, + {file = "uvicorn-0.27.0.tar.gz", hash = "sha256:c855578045d45625fd027367f7653d249f7c49f9361ba15cf9624186b26b8eb6"}, ] [package.dependencies] @@ -2944,4 +3028,4 @@ pgsql = ["psycopg2-binary"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "b04b135869e2955f04e9a7929abb3608228ef7d558d7bd949d26c4cf8264628d" +content-hash = "02c2f99d7ead3339de5c02e9b3ec3ac33fc4a7204130b9cd404e749d995e9a7b" diff --git a/pyproject.toml b/pyproject.toml index a41e189ae50d..c0ad34cf870e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ httpx = "^0.26.0" lxml = "^5.0.0" orjson = "^3.8.0" psycopg2-binary = { version = "^2.9.1", optional = true } -pydantic = "^1.10.4" +pydantic = "^2.6.1" pyhumps = "^3.5.3" python = "^3.10" python-dateutil = "^2.8.2" @@ -45,6 +45,7 @@ text-unidecode = "^1.3" rapidfuzz = "^3.2.0" html2text = "^2020.1.16" paho-mqtt = "^1.6.1" +pydantic-settings = "^2.1.0" [tool.poetry.group.postgres.dependencies] psycopg2-binary = { version = "^2.9.1" } @@ -82,7 +83,7 @@ line_length = 120 [tool.vulture] exclude = ["**/models/**/*.py", "dir/"] -ignore_decorators = ["@*router.*", "@app.on_event", "@validator", "@controller"] +ignore_decorators = ["@*router.*", "@app.on_event", "@field_validator", "@controller"] make_whitelist = true min_confidence = 60 paths = ["mealie"] diff --git a/tests/fixtures/fixture_users.py b/tests/fixtures/fixture_users.py index 1bc7fe752632..bf24c6456092 100644 --- a/tests/fixtures/fixture_users.py +++ b/tests/fixtures/fixture_users.py @@ -16,7 +16,7 @@ def build_unique_user(group: str, api_client: TestClient) -> utils.TestUser: group = group or random_string(12) registration = utils.user_registration_factory() - response = api_client.post("/api/users/register", json=registration.dict(by_alias=True)) + response = api_client.post("/api/users/register", json=registration.model_dump(by_alias=True)) assert response.status_code == 201 form_data = {"username": registration.username, "password": registration.password} @@ -84,7 +84,7 @@ def g2_user(admin_token, api_client: TestClient): @fixture(scope="module") def unique_user(api_client: TestClient): registration = utils.user_registration_factory() - response = api_client.post("/api/users/register", json=registration.dict(by_alias=True)) + response = api_client.post("/api/users/register", json=registration.model_dump(by_alias=True)) assert response.status_code == 201 form_data = {"username": registration.username, "password": registration.password} diff --git a/tests/integration_tests/public_explorer_tests/test_public_recipes.py b/tests/integration_tests/public_explorer_tests/test_public_recipes.py index b990e4e78508..205ad053d503 100644 --- a/tests/integration_tests/public_explorer_tests/test_public_recipes.py +++ b/tests/integration_tests/public_explorer_tests/test_public_recipes.py @@ -112,7 +112,7 @@ def test_get_all_public_recipes_filtered( assert random_recipe.settings random_recipe.settings.public = True - database.recipes.update(random_recipe.slug, random_recipe.dict() | recipe_data) + database.recipes.update(random_recipe.slug, random_recipe.model_dump() | recipe_data) ## Query All Recipes response = api_client.get(api_routes.explore_recipes_group_slug(group.slug), params={"queryFilter": query_filter}) diff --git a/tests/integration_tests/user_group_tests/test_group_cookbooks.py b/tests/integration_tests/user_group_tests/test_group_cookbooks.py index 3027f8f533e9..f1187c7264f2 100644 --- a/tests/integration_tests/user_group_tests/test_group_cookbooks.py +++ b/tests/integration_tests/user_group_tests/test_group_cookbooks.py @@ -41,7 +41,7 @@ def cookbooks(database: AllRepositories, unique_user: TestUser) -> list[TestCook for _ in range(3): cb = database.cookbooks.create(SaveCookBook(**get_page_data(unique_user.group_id))) data.append(cb) - yield_data.append(TestCookbook(id=cb.id, slug=cb.slug, name=cb.name, data=cb.dict())) + yield_data.append(TestCookbook(id=cb.id, slug=cb.slug, name=cb.name, data=cb.model_dump())) yield yield_data diff --git a/tests/integration_tests/user_group_tests/test_group_invitation.py b/tests/integration_tests/user_group_tests/test_group_invitation.py index f3960bada9bf..a528521c582d 100644 --- a/tests/integration_tests/user_group_tests/test_group_invitation.py +++ b/tests/integration_tests/user_group_tests/test_group_invitation.py @@ -36,7 +36,7 @@ def register_user(api_client, invite): registration.group = "" registration.group_token = invite - response = api_client.post(api_routes.users_register, json=registration.dict(by_alias=True)) + response = api_client.post(api_routes.users_register, json=registration.model_dump(by_alias=True)) return registration, response 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 7f886727f37d..6964aa0b178f 100644 --- a/tests/integration_tests/user_group_tests/test_group_mealplan.py +++ b/tests/integration_tests/user_group_tests/test_group_mealplan.py @@ -15,7 +15,7 @@ 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).dict() + new_plan = CreatePlanEntry(date=date.today(), entry_type="breakfast", title=title, text=text).model_dump() new_plan["date"] = date.today().strftime("%Y-%m-%d") response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token) @@ -36,7 +36,7 @@ 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).dict(by_alias=True) + 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["recipeId"] = str(recipe_id) @@ -53,7 +53,7 @@ def test_crud_mealplan(api_client: TestClient, unique_user: TestUser): entry_type="breakfast", title=random_string(), text=random_string(), - ).dict() + ).model_dump() # Create new_plan["date"] = date.today().strftime("%Y-%m-%d") @@ -91,7 +91,7 @@ def test_get_all_mealplans(api_client: TestClient, unique_user: TestUser): entry_type="breakfast", title=random_string(), text=random_string(), - ).dict() + ).model_dump() new_plan["date"] = date.today().strftime("%Y-%m-%d") response = api_client.post(api_routes.groups_mealplans, json=new_plan, headers=unique_user.token) @@ -109,7 +109,7 @@ def test_get_slice_mealplans(api_client: TestClient, unique_user: TestUser): # Make a list of 10 meal plans meal_plans = [ - CreatePlanEntry(date=date, entry_type="breakfast", title=random_string(), text=random_string()).dict() + CreatePlanEntry(date=date, entry_type="breakfast", title=random_string(), text=random_string()).model_dump() for date in dates ] @@ -138,7 +138,9 @@ def test_get_slice_mealplans(api_client: TestClient, unique_user: TestUser): 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()).dict() + CreatePlanEntry( + date=date.today(), entry_type="breakfast", title=random_string(), text=random_string() + ).model_dump() for _ in range(3) ] diff --git a/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py b/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py index b966422053c4..c344c1c8c877 100644 --- a/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py +++ b/tests/integration_tests/user_group_tests/test_group_mealplan_rules.py @@ -54,7 +54,7 @@ def test_group_mealplan_rules_create( "groupId": unique_user.group_id, "day": "monday", "entryType": "breakfast", - "categories": [category.dict()], + "categories": [category.model_dump()], } response = api_client.post( diff --git a/tests/integration_tests/user_group_tests/test_group_notifications.py b/tests/integration_tests/user_group_tests/test_group_notifications.py index b3697382be2b..6b8c19765e14 100644 --- a/tests/integration_tests/user_group_tests/test_group_notifications.py +++ b/tests/integration_tests/user_group_tests/test_group_notifications.py @@ -38,14 +38,14 @@ def preferences_generator(): category_created=random_bool(), category_updated=random_bool(), category_deleted=random_bool(), - ).dict(by_alias=True) + ).model_dump(by_alias=True) def notifier_generator(): return GroupEventNotifierCreate( name=random_string(), apprise_url=random_string(), - ).dict(by_alias=True) + ).model_dump(by_alias=True) def event_generator(): diff --git a/tests/integration_tests/user_group_tests/test_group_preferences.py b/tests/integration_tests/user_group_tests/test_group_preferences.py index 1e0b4b0c2791..52f546996665 100644 --- a/tests/integration_tests/user_group_tests/test_group_preferences.py +++ b/tests/integration_tests/user_group_tests/test_group_preferences.py @@ -33,7 +33,7 @@ def test_preferences_in_group(api_client: TestClient, unique_user: TestUser) -> def test_update_preferences(api_client: TestClient, unique_user: TestUser) -> None: new_data = UpdateGroupPreferences(recipe_public=False, recipe_show_nutrition=True) - response = api_client.put(api_routes.groups_preferences, json=new_data.dict(), headers=unique_user.token) + response = api_client.put(api_routes.groups_preferences, json=new_data.model_dump(), headers=unique_user.token) assert response.status_code == 200 @@ -43,4 +43,4 @@ def test_update_preferences(api_client: TestClient, unique_user: TestUser) -> No assert preferences["recipePublic"] is False assert preferences["recipeShowNutrition"] is True - assert_ignore_keys(new_data.dict(by_alias=True), preferences, ["id", "groupId"]) + assert_ignore_keys(new_data.model_dump(by_alias=True), preferences, ["id", "groupId"]) diff --git a/tests/integration_tests/user_group_tests/test_group_registration.py b/tests/integration_tests/user_group_tests/test_group_registration.py index 1f2d6c31d359..0f785d7b469d 100644 --- a/tests/integration_tests/user_group_tests/test_group_registration.py +++ b/tests/integration_tests/user_group_tests/test_group_registration.py @@ -7,7 +7,7 @@ from tests.utils.factories import user_registration_factory def test_user_registration_new_group(api_client: TestClient): registration = user_registration_factory() - response = api_client.post(api_routes.users_register, json=registration.dict(by_alias=True)) + response = api_client.post(api_routes.users_register, json=registration.model_dump(by_alias=True)) assert response.status_code == 201 # Login @@ -23,7 +23,7 @@ def test_user_registration_new_group(api_client: TestClient): def test_new_user_group_permissions(api_client: TestClient): registration = user_registration_factory() - response = api_client.post(api_routes.users_register, json=registration.dict(by_alias=True)) + response = api_client.post(api_routes.users_register, json=registration.model_dump(by_alias=True)) assert response.status_code == 201 # Login diff --git a/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py b/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py index bf8aa541b032..6f18edb9a81c 100644 --- a/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py +++ b/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py @@ -23,7 +23,7 @@ def create_item(list_id: UUID4) -> dict: def serialize_list_items(list_items: list[ShoppingListItemOut]) -> list: as_dict = [] for item in list_items: - item_dict = item.dict(by_alias=True) + item_dict = item.model_dump(by_alias=True) item_dict["shoppingListId"] = str(item.shopping_list_id) item_dict["id"] = str(item.id) as_dict.append(item_dict) @@ -192,7 +192,7 @@ def test_shopping_list_items_update_many_reorder( as_dict = [] for i, item in enumerate(list_items): item.position = i - item_dict = item.dict(by_alias=True) + item_dict = item.model_dump(by_alias=True) item_dict["shoppingListId"] = str(list_with_items.id) item_dict["id"] = str(item.id) as_dict.append(item_dict) @@ -319,7 +319,7 @@ def test_shopping_list_items_update_mergable( item.note = list_with_items.list_items[i - 1].note - payload = utils.jsonify([item.dict() for item in list_with_items.list_items]) + payload = utils.jsonify([item.model_dump() for item in list_with_items.list_items]) response = api_client.put(api_routes.groups_shopping_items, json=payload, headers=unique_user.token) as_json = utils.assert_derserialize(response, 200) @@ -382,7 +382,7 @@ def test_shopping_list_items_checked_off( response = api_client.put( api_routes.groups_shopping_items_item_id(checked_item.id), - json=utils.jsonify(checked_item.dict()), + json=utils.jsonify(checked_item.model_dump()), headers=unique_user.token, ) @@ -396,7 +396,7 @@ def test_shopping_list_items_checked_off( # get the reference item and make sure it didn't change response = api_client.get(api_routes.groups_shopping_items_item_id(reference_item.id), headers=unique_user.token) as_json = utils.assert_derserialize(response, 200) - reference_item_get = ShoppingListItemOut.parse_obj(as_json) + reference_item_get = ShoppingListItemOut.model_validate(as_json) assert reference_item_get.id == reference_item.id assert reference_item_get.shopping_list_id == reference_item.shopping_list_id @@ -407,7 +407,7 @@ def test_shopping_list_items_checked_off( # rename an item to match another item and check both off, and make sure they are not merged response = api_client.get(api_routes.groups_shopping_lists_item_id(list_with_items.id), headers=unique_user.token) as_json = utils.assert_derserialize(response, 200) - updated_list = ShoppingListOut.parse_obj(as_json) + updated_list = ShoppingListOut.model_validate(as_json) item_1, item_2 = random.sample(updated_list.list_items, 2) item_1.checked = True @@ -416,7 +416,7 @@ def test_shopping_list_items_checked_off( response = api_client.put( api_routes.groups_shopping_items, - json=utils.jsonify([item_1.dict(), item_2.dict()]), + json=utils.jsonify([item_1.model_dump(), item_2.model_dump()]), headers=unique_user.token, ) diff --git a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py index 4f96700f6b8a..b3cdd434cf2b 100644 --- a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py +++ b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py @@ -161,7 +161,9 @@ def test_shopping_lists_add_one_with_zero_quantity( response = api_client.put(f"{api_routes.recipes}/{recipe_slug}", json=recipe_data, headers=unique_user.token) utils.assert_derserialize(response, 200) - recipe = Recipe.parse_raw(api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content) + recipe = Recipe.model_validate_json( + api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content + ) assert recipe.id assert len(recipe.recipe_ingredient) == 3 @@ -172,7 +174,7 @@ def test_shopping_lists_add_one_with_zero_quantity( ) response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) - shopping_list_out = ShoppingListOut.parse_obj(utils.assert_derserialize(response, 200)) + shopping_list_out = ShoppingListOut.model_validate(utils.assert_derserialize(response, 200)) assert len(shopping_list_out.list_items) == 3 @@ -285,7 +287,9 @@ def test_shopping_lists_add_recipe_with_merge( response = api_client.put(f"{api_routes.recipes}/{recipe_slug}", json=recipe_data, headers=unique_user.token) utils.assert_derserialize(response, 200) - recipe = Recipe.parse_raw(api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content) + recipe = Recipe.model_validate_json( + api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content + ) assert recipe.id assert len(recipe.recipe_ingredient) == 4 @@ -296,7 +300,7 @@ def test_shopping_lists_add_recipe_with_merge( ) response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) - shopping_list_out = ShoppingListOut.parse_obj(utils.assert_derserialize(response, 200)) + shopping_list_out = ShoppingListOut.model_validate(utils.assert_derserialize(response, 200)) assert len(shopping_list_out.list_items) == 3 @@ -635,7 +639,9 @@ def test_recipe_manipulation_with_zero_quantities( response = api_client.put(f"{api_routes.recipes}/{recipe_slug}", json=recipe_data, headers=unique_user.token) utils.assert_derserialize(response, 200) - recipe = Recipe.parse_raw(api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content) + recipe = Recipe.model_validate_json( + api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content + ) assert recipe.id assert len(recipe.recipe_ingredient) == 4 @@ -653,7 +659,7 @@ def test_recipe_manipulation_with_zero_quantities( utils.assert_derserialize(response, 200) response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) - updated_list = ShoppingListOut.parse_raw(response.content) + updated_list = ShoppingListOut.model_validate_json(response.content) assert len(updated_list.list_items) == 4 found = False @@ -679,7 +685,7 @@ def test_recipe_manipulation_with_zero_quantities( ) response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) - updated_list = ShoppingListOut.parse_raw(response.content) + updated_list = ShoppingListOut.model_validate_json(response.content) assert len(updated_list.list_items) == 4 found = False @@ -705,7 +711,7 @@ def test_recipe_manipulation_with_zero_quantities( ) response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) - updated_list = ShoppingListOut.parse_raw(response.content) + updated_list = ShoppingListOut.model_validate_json(response.content) assert len(updated_list.list_items) == 0 diff --git a/tests/integration_tests/user_group_tests/test_shopping_list_labels.py b/tests/integration_tests/user_group_tests/test_shopping_list_labels.py index 12bbdec1e5af..ea806eb4fbf4 100644 --- a/tests/integration_tests/user_group_tests/test_shopping_list_labels.py +++ b/tests/integration_tests/user_group_tests/test_shopping_list_labels.py @@ -15,7 +15,7 @@ def create_labels(api_client: TestClient, unique_user: TestUser, count: int = 10 labels: list[MultiPurposeLabelOut] = [] for _ in range(count): response = api_client.post(api_routes.groups_labels, json={"name": random_string()}, headers=unique_user.token) - labels.append(MultiPurposeLabelOut.parse_obj(response.json())) + labels.append(MultiPurposeLabelOut.model_validate(response.json())) return labels @@ -25,7 +25,7 @@ def test_new_list_creates_list_labels(api_client: TestClient, unique_user: TestU response = api_client.post( api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token ) - new_list = ShoppingListOut.parse_obj(response.json()) + new_list = ShoppingListOut.model_validate(response.json()) assert len(new_list.label_settings) == len(labels) label_settings_label_ids = [setting.label_id for setting in new_list.label_settings] @@ -39,13 +39,13 @@ def test_new_label_creates_list_labels(api_client: TestClient, unique_user: Test response = api_client.post( api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token ) - new_list = ShoppingListOut.parse_obj(response.json()) + new_list = ShoppingListOut.model_validate(response.json()) existing_label_settings = new_list.label_settings # create more labels and make sure they were added to the list's label settings new_labels = create_labels(api_client, unique_user) response = api_client.get(api_routes.groups_shopping_lists_item_id(new_list.id), headers=unique_user.token) - updated_list = ShoppingListOut.parse_obj(response.json()) + updated_list = ShoppingListOut.model_validate(response.json()) updated_label_settings = updated_list.label_settings assert len(updated_label_settings) == len(existing_label_settings) + len(new_labels) @@ -66,7 +66,7 @@ def test_seed_label_creates_list_labels(database: AllRepositories, api_client: T response = api_client.post( api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token ) - new_list = ShoppingListOut.parse_obj(response.json()) + new_list = ShoppingListOut.model_validate(response.json()) existing_label_settings = new_list.label_settings # seed labels and make sure they were added to the list's label settings @@ -75,7 +75,7 @@ def test_seed_label_creates_list_labels(database: AllRepositories, api_client: T seeder.seed_labels("en-US") response = api_client.get(api_routes.groups_shopping_lists_item_id(new_list.id), headers=unique_user.token) - updated_list = ShoppingListOut.parse_obj(response.json()) + updated_list = ShoppingListOut.model_validate(response.json()) updated_label_settings = updated_list.label_settings assert len(updated_label_settings) == len(existing_label_settings) + CREATED_LABELS @@ -89,14 +89,14 @@ def test_delete_label_deletes_list_labels(api_client: TestClient, unique_user: T response = api_client.post( api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token ) - new_list = ShoppingListOut.parse_obj(response.json()) + new_list = ShoppingListOut.model_validate(response.json()) existing_label_settings = new_list.label_settings label_to_delete = random.choice(new_labels) api_client.delete(api_routes.groups_labels_item_id(label_to_delete.id), headers=unique_user.token) response = api_client.get(api_routes.groups_shopping_lists_item_id(new_list.id), headers=unique_user.token) - updated_list = ShoppingListOut.parse_obj(response.json()) + updated_list = ShoppingListOut.model_validate(response.json()) assert len(updated_list.label_settings) == len(existing_label_settings) - 1 label_settings_label_ids = [setting.label_id for setting in updated_list.label_settings] @@ -116,11 +116,11 @@ def test_update_list_doesnt_change_list_labels(api_client: TestClient, unique_us response = api_client.post( api_routes.groups_shopping_lists, json={"name": original_name}, headers=unique_user.token ) - new_list = ShoppingListOut.parse_obj(response.json()) + new_list = ShoppingListOut.model_validate(response.json()) assert new_list.name == original_name assert new_list.label_settings - updated_list_data = new_list.dict() + updated_list_data = new_list.model_dump() updated_list_data.pop("created_at", None) updated_list_data.pop("update_at", None) @@ -132,7 +132,7 @@ def test_update_list_doesnt_change_list_labels(api_client: TestClient, unique_us json=jsonify(updated_list_data), headers=unique_user.token, ) - updated_list = ShoppingListOut.parse_obj(response.json()) + updated_list = ShoppingListOut.model_validate(response.json()) assert updated_list.name == updated_name assert updated_list.label_settings == new_list.label_settings @@ -142,7 +142,7 @@ def test_update_list_labels(api_client: TestClient, unique_user: TestUser): response = api_client.post( api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token ) - new_list = ShoppingListOut.parse_obj(response.json()) + new_list = ShoppingListOut.model_validate(response.json()) changed_setting = random.choice(new_list.label_settings) changed_setting.position = random_int(999, 9999) @@ -151,7 +151,7 @@ def test_update_list_labels(api_client: TestClient, unique_user: TestUser): json=jsonify(new_list.label_settings), headers=unique_user.token, ) - updated_list = ShoppingListOut.parse_obj(response.json()) + updated_list = ShoppingListOut.model_validate(response.json()) original_settings_by_id = {setting.id: setting for setting in new_list.label_settings} for setting in updated_list.label_settings: @@ -170,7 +170,7 @@ def test_list_label_order(api_client: TestClient, unique_user: TestUser): response = api_client.post( api_routes.groups_shopping_lists, json={"name": random_string()}, headers=unique_user.token ) - new_list = ShoppingListOut.parse_obj(response.json()) + new_list = ShoppingListOut.model_validate(response.json()) for i, setting in enumerate(new_list.label_settings): if not i: continue @@ -183,7 +183,7 @@ def test_list_label_order(api_client: TestClient, unique_user: TestUser): json=jsonify(new_list.label_settings), headers=unique_user.token, ) - updated_list = ShoppingListOut.parse_obj(response.json()) + updated_list = ShoppingListOut.model_validate(response.json()) for i, setting in enumerate(updated_list.label_settings): if not i: continue diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py b/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py index 3ad077fa42ff..0c756a481747 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_bulk_action.py @@ -46,7 +46,7 @@ def test_bulk_tag_recipes( for _ in range(3): tag_name = random_string() tag = database.tags.create(TagSave(group_id=unique_user.group_id, name=tag_name)) - tags.append(tag.dict()) + tags.append(tag.model_dump()) payload = {"recipes": ten_slugs, "tags": tags} @@ -74,7 +74,7 @@ def test_bulk_categorize_recipes( for _ in range(3): cat_name = random_string() cat = database.categories.create(CategorySave(group_id=unique_user.group_id, name=cat_name)) - categories.append(cat.dict()) + categories.append(cat.model_dump()) payload = {"recipes": ten_slugs, "categories": categories} diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py index 273bb7ec13ea..4a431827006f 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -461,7 +461,7 @@ def test_read_update( recipe["notes"] = test_notes - recipe["recipeCategory"] = [x.dict() for x in recipe_categories] + recipe["recipeCategory"] = [x.model_dump() for x in recipe_categories] response = api_client.put(recipe_url, json=utils.jsonify(recipe), headers=unique_user.token) @@ -625,7 +625,7 @@ def test_remove_notes(api_client: TestClient, unique_user: TestUser): assert response.status_code == 200 recipe = json.loads(response.text) - recipe["notes"] = [RecipeNote(title=random_string(), text=random_string()).dict()] + recipe["notes"] = [RecipeNote(title=random_string(), text=random_string()).model_dump()] response = api_client.put(recipe_url, json=recipe, headers=unique_user.token) assert response.status_code == 200 diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_foods.py b/tests/integration_tests/user_recipe_tests/test_recipe_foods.py index f23e7e9e34b4..b7bcf99f7980 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_foods.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_foods.py @@ -15,7 +15,7 @@ def food(api_client: TestClient, unique_user: TestUser) -> Generator[dict, None, data = CreateIngredientFood( name=random_string(10), description=random_string(10), - ).dict(by_alias=True) + ).model_dump(by_alias=True) response = api_client.post(api_routes.foods, json=data, headers=unique_user.token) @@ -30,7 +30,7 @@ def test_create_food(api_client: TestClient, unique_user: TestUser): data = CreateIngredientFood( name=random_string(10), description=random_string(10), - ).dict(by_alias=True) + ).model_dump(by_alias=True) response = api_client.post(api_routes.foods, json=data, headers=unique_user.token) assert response.status_code == 201 diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_steps.py b/tests/integration_tests/user_recipe_tests/test_recipe_steps.py index f1c5ad7c8cf9..4b962a1e79a5 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_steps.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_steps.py @@ -26,7 +26,7 @@ def test_associate_ingredient_with_step(api_client: TestClient, unique_user: Tes response = api_client.put( api_routes.recipes_slug(recipe.slug), - json=jsonify(recipe.dict()), + json=jsonify(recipe.model_dump()), headers=unique_user.token, ) diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py b/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py index bfe6497d9c6e..5e53aec3b9a2 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py @@ -28,7 +28,7 @@ def recipes(api_client: TestClient, unique_user: TestUser): response = api_client.get(f"{api_routes.recipes}/{slug}", headers=unique_user.token) assert response.status_code == 200 - recipe = Recipe.parse_obj(response.json()) + recipe = Recipe.model_validate(response.json()) recipes.append(recipe) yield recipes @@ -52,7 +52,7 @@ def test_create_timeline_event(api_client: TestClient, unique_user: TestUser, re ) assert event_response.status_code == 201 - event = RecipeTimelineEventOut.parse_obj(event_response.json()) + event = RecipeTimelineEventOut.model_validate(event_response.json()) assert event.recipe_id == recipe.id assert str(event.user_id) == str(unique_user.user_id) @@ -77,13 +77,13 @@ def test_get_all_timeline_events(api_client: TestClient, unique_user: TestUser, event_response = api_client.post( api_routes.recipes_timeline_events, params=params, json=event_data, headers=unique_user.token ) - events.append(RecipeTimelineEventOut.parse_obj(event_response.json())) + events.append(RecipeTimelineEventOut.model_validate(event_response.json())) # check that we see them all params = {"page": 1, "perPage": -1} events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token) - events_pagination = RecipeTimelineEventPagination.parse_obj(events_response.json()) + events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json()) event_ids = [event.id for event in events] paginated_event_ids = [event.id for event in events_pagination.items] @@ -109,13 +109,13 @@ def test_get_timeline_event(api_client: TestClient, unique_user: TestUser, recip json=new_event_data, headers=unique_user.token, ) - new_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + new_event = RecipeTimelineEventOut.model_validate(event_response.json()) # fetch the new event event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token) assert event_response.status_code == 200 - event = RecipeTimelineEventOut.parse_obj(event_response.json()) + event = RecipeTimelineEventOut.model_validate(event_response.json()) assert event == new_event @@ -133,7 +133,7 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re } event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token) - new_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + new_event = RecipeTimelineEventOut.model_validate(event_response.json()) assert new_event.subject == old_subject # update the event @@ -146,7 +146,7 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re ) assert event_response.status_code == 200 - updated_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + updated_event = RecipeTimelineEventOut.model_validate(event_response.json()) assert updated_event.id == new_event.id assert updated_event.subject == new_subject assert updated_event.timestamp == new_event.timestamp @@ -164,7 +164,7 @@ def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, re } event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token) - new_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + new_event = RecipeTimelineEventOut.model_validate(event_response.json()) # delete the event event_response = api_client.delete( @@ -172,7 +172,7 @@ def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, re ) assert event_response.status_code == 200 - deleted_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + deleted_event = RecipeTimelineEventOut.model_validate(event_response.json()) assert deleted_event.id == new_event.id # try to get the event @@ -198,7 +198,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU json=new_event_data, headers=unique_user.token, ) - new_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + new_event = RecipeTimelineEventOut.model_validate(event_response.json()) assert str(new_event.user_id) == new_event_data["userId"] assert str(new_event.event_type) == new_event_data["eventType"] assert new_event.message == new_event_data["eventMessage"] @@ -207,7 +207,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token) assert event_response.status_code == 200 - event = RecipeTimelineEventOut.parse_obj(event_response.json()) + event = RecipeTimelineEventOut.model_validate(event_response.json()) assert event == new_event # update the event message @@ -222,7 +222,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU ) assert event_response.status_code == 200 - updated_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + updated_event = RecipeTimelineEventOut.model_validate(event_response.json()) assert updated_event.subject == new_subject assert updated_event.message == new_message @@ -241,7 +241,7 @@ def test_timeline_event_update_image( } event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token) - new_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + new_event = RecipeTimelineEventOut.model_validate(event_response.json()) assert new_event.image == TimelineEventImage.does_not_have_image.value with open(test_image_jpg, "rb") as f: @@ -253,7 +253,7 @@ def test_timeline_event_update_image( ) r.raise_for_status() - update_image_response = UpdateImageResponse.parse_obj(r.json()) + update_image_response = UpdateImageResponse.model_validate(r.json()) assert update_image_response.image == TimelineEventImage.has_image.value event_response = api_client.get( @@ -262,7 +262,7 @@ def test_timeline_event_update_image( ) assert event_response.status_code == 200 - updated_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + updated_event = RecipeTimelineEventOut.model_validate(event_response.json()) assert updated_event.subject == new_event.subject assert updated_event.message == new_event.message assert updated_event.timestamp == new_event.timestamp @@ -274,7 +274,7 @@ def test_create_recipe_with_timeline_event(api_client: TestClient, unique_user: for recipe in recipes: params = {"queryFilter": f"recipe_id={recipe.id}"} events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token) - events_pagination = RecipeTimelineEventPagination.parse_obj(events_response.json()) + events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json()) assert events_pagination.items diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_units.py b/tests/integration_tests/user_recipe_tests/test_recipe_units.py index 717cc4dc9f0b..0da97930fa5e 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_units.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_units.py @@ -15,7 +15,7 @@ def unit(api_client: TestClient, unique_user: TestUser): fraction=random_bool(), abbreviation=f"{random_string(3)}.", use_abbreviation=random_bool(), - ).dict(by_alias=True) + ).model_dump(by_alias=True) response = api_client.post(api_routes.units, json=data, headers=unique_user.token) @@ -30,7 +30,7 @@ def test_create_unit(api_client: TestClient, unique_user: TestUser): data = CreateIngredientUnit( name=random_string(10), description=random_string(10), - ).dict(by_alias=True) + ).model_dump(by_alias=True) response = api_client.post(api_routes.units, json=data, headers=unique_user.token) assert response.status_code == 201 diff --git a/tests/integration_tests/user_tests/test_user_registration.py b/tests/integration_tests/user_tests/test_user_registration.py index 596d2a57d531..58b499bd0621 100644 --- a/tests/integration_tests/user_tests/test_user_registration.py +++ b/tests/integration_tests/user_tests/test_user_registration.py @@ -2,6 +2,7 @@ import random import string from fastapi.testclient import TestClient + from mealie.core.config import get_app_settings from tests.utils import api_routes from tests.utils.factories import user_registration_factory @@ -14,21 +15,21 @@ def test_register_user(api_client: TestClient, monkeypatch): # signup disabled but valid request monkeypatch.setenv("ALLOW_SIGNUP", "False") get_app_settings.cache_clear() - response = api_client.post(api_routes.users_register, json=registration.dict(by_alias=True)) + response = api_client.post(api_routes.users_register, json=registration.model_dump(by_alias=True)) assert response.status_code == 403 # signup disabled, request includes non valid group token registration.group_token = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(10)).strip() - response = api_client.post(api_routes.users_register, json=registration.dict(by_alias=True)) + response = api_client.post(api_routes.users_register, json=registration.model_dump(by_alias=True)) assert response.status_code == 400 # signup enabled but contains non valid group token monkeypatch.setenv("ALLOW_SIGNUP", "True") get_app_settings.cache_clear() - response = api_client.post(api_routes.users_register, json=registration.dict(by_alias=True)) + response = api_client.post(api_routes.users_register, json=registration.model_dump(by_alias=True)) assert response.status_code == 400 # signup enabled and valid request registration.group_token = None - response = api_client.post(api_routes.users_register, json=registration.dict(by_alias=True)) + response = api_client.post(api_routes.users_register, json=registration.model_dump(by_alias=True)) assert response.status_code == 201 diff --git a/tests/unit_tests/repository_tests/test_pagination.py b/tests/unit_tests/repository_tests/test_pagination.py index 2174f3cf57e7..8d46134de8f3 100644 --- a/tests/unit_tests/repository_tests/test_pagination.py +++ b/tests/unit_tests/repository_tests/test_pagination.py @@ -135,14 +135,14 @@ def test_pagination_guides(database: AllRepositories, unique_user: TestUser): query = PaginationQuery(page=1, per_page=1) first_page_of_results = foods_repo.page_all(query) - first_page_of_results.set_pagination_guides(foods_route, query.dict()) + first_page_of_results.set_pagination_guides(foods_route, query.model_dump()) assert first_page_of_results.next is not None assert first_page_of_results.previous is None query = PaginationQuery(page=-1, per_page=1) last_page_of_results = foods_repo.page_all(query) - last_page_of_results.set_pagination_guides(foods_route, query.dict()) + last_page_of_results.set_pagination_guides(foods_route, query.model_dump()) assert last_page_of_results.next is None assert last_page_of_results.previous is not None @@ -150,7 +150,7 @@ def test_pagination_guides(database: AllRepositories, unique_user: TestUser): query = PaginationQuery(page=random_page, per_page=1, filter_string="createdAt>2021-02-22") random_page_of_results = foods_repo.page_all(query) - random_page_of_results.set_pagination_guides(foods_route, query.dict()) + random_page_of_results.set_pagination_guides(foods_route, query.model_dump()) next_params: dict = dict(parse_qsl(urlsplit(random_page_of_results.next).query)) # type: ignore assert int(next_params["page"]) == random_page + 1 @@ -158,7 +158,7 @@ def test_pagination_guides(database: AllRepositories, unique_user: TestUser): prev_params: dict = dict(parse_qsl(urlsplit(random_page_of_results.previous).query)) # type: ignore assert int(prev_params["page"]) == random_page - 1 - source_params = camelize(query.dict()) + source_params = camelize(query.model_dump()) for source_param in source_params: assert source_param in next_params assert source_param in prev_params @@ -835,7 +835,7 @@ def test_pagination_filter_dates(api_client: TestClient, unique_user: TestUser): ) for mealplan_to_create in [mealplan_today, mealplan_tomorrow]: - data = mealplan_to_create.dict() + data = mealplan_to_create.model_dump() data["date"] = data["date"].strftime("%Y-%m-%d") response = api_client.post(api_routes.groups_mealplans, json=data, headers=unique_user.token) assert response.status_code == 201 diff --git a/tests/unit_tests/schema_tests/test_mealie_model.py b/tests/unit_tests/schema_tests/test_mealie_model.py index f36f0b975875..9beb312e5c28 100644 --- a/tests/unit_tests/schema_tests/test_mealie_model.py +++ b/tests/unit_tests/schema_tests/test_mealie_model.py @@ -17,7 +17,7 @@ class TestModel2(MealieModel): def test_camelize_variables(): model = TestModel(long_name="Hello", long_int=1, long_float=1.1) - as_dict = model.dict(by_alias=True) + as_dict = model.model_dump(by_alias=True) assert as_dict["longName"] == "Hello" assert as_dict["longInt"] == 1 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 f87b2af4d5a1..0a458cc6270d 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 @@ -24,7 +24,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) original_recipe_data: dict = response.json() - recipe = RecipeSummary.parse_obj(original_recipe_data) + recipe = RecipeSummary.model_validate(original_recipe_data) recipe_id = recipe.id assert recipe.last_made is None @@ -34,7 +34,7 @@ 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).dict(by_alias=True) + 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["recipeId"] = str(recipe_id) @@ -62,7 +62,7 @@ def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser): # make sure the recipe's last made date was updated response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) new_recipe_data: dict = response.json() - recipe = RecipeSummary.parse_obj(new_recipe_data) + recipe = RecipeSummary.model_validate(new_recipe_data) assert recipe.last_made.date() == date.today() # type: ignore # make sure nothing else was updated @@ -90,7 +90,7 @@ def test_new_mealplan_event_duplicates(api_client: TestClient, unique_user: Test assert response.status_code == 201 response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) - recipe = RecipeSummary.parse_obj(response.json()) + recipe = RecipeSummary.model_validate(response.json()) recipe_id = recipe.id # store the number of events, so we can compare later @@ -99,7 +99,7 @@ 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).dict(by_alias=True) + 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["recipeId"] = str(recipe_id) @@ -130,7 +130,7 @@ def test_new_mealplan_events_with_multiple_recipes(api_client: TestClient, uniqu assert response.status_code == 201 response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) - recipes.append(RecipeSummary.parse_obj(response.json())) + recipes.append(RecipeSummary.model_validate(response.json())) # store the number of events, so we can compare later params = {"queryFilter": f"recipe_id={recipes[0].id}"} @@ -143,7 +143,7 @@ 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)).dict( + 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() @@ -193,7 +193,7 @@ def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser assert response.status_code == 201 response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) - recipe = RecipeSummary.parse_obj(response.json()) + recipe = RecipeSummary.model_validate(response.json()) recipe_id = str(recipe.id) future_dt = datetime.now() + timedelta(days=random_int(1, 10)) @@ -203,7 +203,7 @@ def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser ) assert response.status_code == 200 - new_plan = CreatePlanEntry(date=date.today(), entry_type="dinner", recipe_id=recipe_id).dict(by_alias=True) + 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["recipeId"] = str(recipe_id) @@ -214,5 +214,5 @@ def test_preserve_future_made_date(api_client: TestClient, unique_user: TestUser create_mealplan_timeline_events() response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) - recipe = RecipeSummary.parse_obj(response.json()) + recipe = RecipeSummary.model_validate(response.json()) assert recipe.last_made == future_dt diff --git a/tests/unit_tests/test_alembic.py b/tests/unit_tests/test_alembic.py index 16b5675665ff..1bcc621ff532 100644 --- a/tests/unit_tests/test_alembic.py +++ b/tests/unit_tests/test_alembic.py @@ -7,8 +7,8 @@ from tests.utils.alembic_reader import ALEMBIC_MIGRATIONS, import_file class AlembicMigration(BaseModel): path: pathlib.Path - revision: str | None - down_revision: str | None + revision: str | None = None + down_revision: str | None = None def test_alembic_revisions_are_in_order() -> None: From f945cb8d2d289056bb3abd89706c34d6ae0db35e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 11 Feb 2024 16:49:36 +0000 Subject: [PATCH 05/13] chore(deps): update dependency mkdocs-material to v9.5.9 --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0f083e74cc13..c8262947dc11 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1231,13 +1231,13 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp [[package]] name = "mkdocs-material" -version = "9.5.8" +version = "9.5.9" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.8-py3-none-any.whl", hash = "sha256:14563314bbf97da4bfafc69053772341babfaeb3329cde01d3e63cec03997af8"}, - {file = "mkdocs_material-9.5.8.tar.gz", hash = "sha256:2a429213e83f84eda7a588e2b186316d806aac602b7f93990042f7a1f3d3cf65"}, + {file = "mkdocs_material-9.5.9-py3-none-any.whl", hash = "sha256:a5d62b73b3b74349e45472bfadc129c871dd2d4add68d84819580597b2f50d5d"}, + {file = "mkdocs_material-9.5.9.tar.gz", hash = "sha256:635df543c01c25c412d6c22991872267723737d5a2f062490f33b2da1c013c6d"}, ] [package.dependencies] From 39eab01885ca79f44e036878f615ea126775e43c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 11 Feb 2024 19:28:05 +0000 Subject: [PATCH 06/13] fix(deps): update dependency python-slugify to v8.0.4 --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index c8262947dc11..ee8f17a46c92 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2063,13 +2063,13 @@ dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatc [[package]] name = "python-slugify" -version = "8.0.3" +version = "8.0.4" description = "A Python slugify application that also handles Unicode" optional = false python-versions = ">=3.7" files = [ - {file = "python-slugify-8.0.3.tar.gz", hash = "sha256:e04cba5f1c562502a1175c84a8bc23890c54cdaf23fccaaf0bf78511508cabed"}, - {file = "python_slugify-8.0.3-py2.py3-none-any.whl", hash = "sha256:c71189c161e8c671f1b141034d9a56308a8a5978cd13d40446c879569212fdd1"}, + {file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"}, + {file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"}, ] [package.dependencies] From df75cb40343f9e0c6843886c65195d69c8d51495 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Sun, 11 Feb 2024 17:34:56 -0600 Subject: [PATCH 07/13] fix: Pydantic Serialization Issues (#3157) * replaced pydantic inits with validators * fixed serialization dropping food and unit ids --- mealie/schema/group/group_shopping_list.py | 9 +++++---- mealie/schema/recipe/recipe.py | 17 +++++------------ mealie/schema/recipe/recipe_ingredient.py | 10 ++++++---- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/mealie/schema/group/group_shopping_list.py b/mealie/schema/group/group_shopping_list.py index d91f79e90b29..ae5a27da5ece 100644 --- a/mealie/schema/group/group_shopping_list.py +++ b/mealie/schema/group/group_shopping_list.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime -from pydantic import UUID4, ConfigDict, field_validator +from pydantic import UUID4, ConfigDict, field_validator, model_validator from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm.interfaces import LoaderOption @@ -100,14 +100,15 @@ class ShoppingListItemOut(ShoppingListItemBase): created_at: datetime | None = None update_at: datetime | None = None - def __init__(self, **kwargs): - super().__init__(**kwargs) - + @model_validator(mode="after") + def post_validate(self): # if we're missing a label, but the food has a label, use that as the label if (not self.label) and (self.food and self.food.label): self.label = self.food.label self.label_id = self.label.id + return self + model_config = ConfigDict(from_attributes=True) @classmethod diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 7bf253e8de22..483247d07f20 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Annotated, Any, ClassVar from uuid import uuid4 -from pydantic import UUID4, BaseModel, ConfigDict, Field, field_validator +from pydantic import UUID4, BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic_core.core_schema import ValidationInfo from slugify import slugify from sqlalchemy import Select, desc, func, or_, select, text @@ -183,17 +183,8 @@ class Recipe(RecipeSummary): model_config = ConfigDict(from_attributes=True) - @classmethod - def model_validate(cls, obj): - recipe = super().model_validate(obj) - recipe.__post_init__() - return recipe - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - self.__post_init__() - - def __post_init__(self) -> None: + @model_validator(mode="after") + def post_validate(self): # the ingredient disable_amount property is unreliable, # so we set it here and recalculate the display property disable_amount = self.settings.disable_amount if self.settings else True @@ -202,6 +193,8 @@ class Recipe(RecipeSummary): ingredient.is_food = not ingredient.disable_amount ingredient.display = ingredient._format_display() + return self + @field_validator("slug", mode="before") def validate_slug(slug: str, info: ValidationInfo): if not info.data.get("name"): diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index 8a260a6e43a1..2d0ec633dace 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -6,7 +6,7 @@ from fractions import Fraction from typing import ClassVar from uuid import UUID, uuid4 -from pydantic import UUID4, ConfigDict, Field, field_validator +from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator from sqlalchemy.orm import joinedload from sqlalchemy.orm.interfaces import LoaderOption @@ -31,6 +31,7 @@ def display_fraction(fraction: Fraction): class UnitFoodBase(MealieModel): + id: UUID4 | None = None name: str plural_name: str | None = None description: str = "" @@ -134,9 +135,8 @@ class RecipeIngredientBase(MealieModel): Automatically calculated after the object is created, unless overwritten """ - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) - + @model_validator(mode="after") + def post_validate(self): # calculate missing is_food and disable_amount values # we can't do this in a validator since they depend on each other if self.is_food is None and self.disable_amount is not None: @@ -151,6 +151,8 @@ class RecipeIngredientBase(MealieModel): if not self.display: self.display = self._format_display() + return self + @field_validator("unit", mode="before") @classmethod def validate_unit(cls, v): From f6167b1d81a22b4b4a791e87c1b7a08d13005bc6 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 12 Feb 2024 05:26:53 +0000 Subject: [PATCH 08/13] add id validator for empty strings --- mealie/schema/recipe/recipe_ingredient.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index 2d0ec633dace..5eaa0e9f12c3 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -37,6 +37,15 @@ class UnitFoodBase(MealieModel): description: str = "" extras: dict | None = {} + @field_validator("id", mode="before") + def convert_empty_id_to_none(cls, v): + # sometimes the frontend will give us an empty string instead of null, so we convert it to None, + # otherwise Pydantic will try to convert it to a UUID and fail + if not v: + v = None + + return v + @field_validator("extras", mode="before") def convert_extras_to_dict(cls, v): if isinstance(v, dict): From 0ce05c781ccc40bbbe485ec1b9c760d5a7aaae05 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Mon, 12 Feb 2024 04:24:12 -0600 Subject: [PATCH 09/13] New Crowdin updates (#3161) * New translations en-us.json (Danish) * New translations en-us.json (Hungarian) --- mealie/lang/messages/da-DK.json | 14 +++++++------- mealie/lang/messages/hu-HU.json | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mealie/lang/messages/da-DK.json b/mealie/lang/messages/da-DK.json index 987166defe69..498f9f1b5a8a 100644 --- a/mealie/lang/messages/da-DK.json +++ b/mealie/lang/messages/da-DK.json @@ -33,12 +33,12 @@ "generic-deleted": "{name} er blevet slettet" }, "datetime": { - "year": "year|years", - "day": "day|days", - "hour": "hour|hours", - "minute": "minute|minutes", - "second": "second|seconds", - "millisecond": "millisecond|milliseconds", - "microsecond": "microsecond|microseconds" + "year": "år|år", + "day": "dag|dage", + "hour": "time|timer", + "minute": "minut|minutter", + "second": "sekund|sekunder", + "millisecond": "millisekund|millisekunder", + "microsecond": "mikrosekund|mikrosekunder" } } diff --git a/mealie/lang/messages/hu-HU.json b/mealie/lang/messages/hu-HU.json index d6c613245fcb..67cc7a600803 100644 --- a/mealie/lang/messages/hu-HU.json +++ b/mealie/lang/messages/hu-HU.json @@ -33,12 +33,12 @@ "generic-deleted": "{name} törölve lett" }, "datetime": { - "year": "év|év", - "day": "nap/nap", - "hour": "óra|óra", - "minute": "perc/perc", - "second": "másodperc|másodperc", - "millisecond": "ezredmásodperc|ezredmásodperc", - "microsecond": "mikroszekundum|mikroszekundum" + "year": "év", + "day": "nap", + "hour": "óra", + "minute": "perc", + "second": "másodperc", + "millisecond": "ezredmásodperc", + "microsecond": "mikroszekundum" } } From e35b2e9fbf2434d968e096de0f338db10e821ba4 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 12 Feb 2024 16:40:12 +0000 Subject: [PATCH 10/13] add fallback to urlencode the postgres password if it fails --- mealie/core/settings/db_providers.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/mealie/core/settings/db_providers.py b/mealie/core/settings/db_providers.py index fff25bdcc14a..7de9a63c03e4 100644 --- a/mealie/core/settings/db_providers.py +++ b/mealie/core/settings/db_providers.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from pathlib import Path +from urllib import parse as urlparse from pydantic import BaseModel, PostgresDsn from pydantic_settings import BaseSettings, SettingsConfigDict @@ -44,15 +45,28 @@ class PostgresProvider(AbstractDBProvider, BaseSettings): @property def db_url(self) -> str: host = f"{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}" - return str( - PostgresDsn.build( + try: + url = PostgresDsn.build( scheme="postgresql", username=self.POSTGRES_USER, password=self.POSTGRES_PASSWORD, host=host, path=f"{self.POSTGRES_DB or ''}", ) - ) + except ValueError as outer_error: + try: + # if the password contains special characters, it needs to be URL encoded + url = PostgresDsn.build( + scheme="postgresql", + username=self.POSTGRES_USER, + password=urlparse.quote_plus(self.POSTGRES_PASSWORD), + host=host, + path=f"{self.POSTGRES_DB or ''}", + ) + except Exception: + raise outer_error + + return str(url) @property def db_url_public(self) -> str: From a384e6716d08b8bd2a67469405a647e75498787e Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 12 Feb 2024 16:40:17 +0000 Subject: [PATCH 11/13] added test --- tests/unit_tests/test_config.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 3d7a17205f52..552733757c1c 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -38,6 +38,15 @@ def test_pg_connection_args(monkeypatch): assert app_settings.DB_URL == "postgresql://mealie:mealie@postgres:5432/mealie" +def test_pg_connection_url_encode_password(monkeypatch): + monkeypatch.setenv("DB_ENGINE", "postgres") + monkeypatch.setenv("POSTGRES_SERVER", "postgres") + monkeypatch.setenv("POSTGRES_PASSWORD", "please,url#encode/this?password") + get_app_settings.cache_clear() + app_settings = get_app_settings() + assert app_settings.DB_URL == "postgresql://mealie:please%2Curl%23encode%2Fthis%3Fpassword@postgres:5432/mealie" + + @dataclass(slots=True) class SMTPValidationCase: host: str From 8db08c21e529f347f8e6048db2089d5817c6194c Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 12 Feb 2024 16:58:03 +0000 Subject: [PATCH 12/13] removed try/catch --- mealie/core/settings/db_providers.py | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/mealie/core/settings/db_providers.py b/mealie/core/settings/db_providers.py index 7de9a63c03e4..9cd1a5aea101 100644 --- a/mealie/core/settings/db_providers.py +++ b/mealie/core/settings/db_providers.py @@ -45,28 +45,15 @@ class PostgresProvider(AbstractDBProvider, BaseSettings): @property def db_url(self) -> str: host = f"{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}" - try: - url = PostgresDsn.build( + return str( + PostgresDsn.build( scheme="postgresql", username=self.POSTGRES_USER, - password=self.POSTGRES_PASSWORD, + password=urlparse.quote_plus(self.POSTGRES_PASSWORD), host=host, path=f"{self.POSTGRES_DB or ''}", ) - except ValueError as outer_error: - try: - # if the password contains special characters, it needs to be URL encoded - url = PostgresDsn.build( - scheme="postgresql", - username=self.POSTGRES_USER, - password=urlparse.quote_plus(self.POSTGRES_PASSWORD), - host=host, - path=f"{self.POSTGRES_DB or ''}", - ) - except Exception: - raise outer_error - - return str(url) + ) @property def db_url_public(self) -> str: From 719a33352a38e91fe163268528c4854073cfde5a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 12 Feb 2024 18:05:09 -0600 Subject: [PATCH 13/13] chore(deps): update dependency black to v24.2.0 (#3164) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- poetry.lock | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/poetry.lock b/poetry.lock index ee8f17a46c92..b43c4b24e343 100644 --- a/poetry.lock +++ b/poetry.lock @@ -197,33 +197,33 @@ lxml = ["lxml"] [[package]] name = "black" -version = "24.1.1" +version = "24.2.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-24.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c"}, - {file = "black-24.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445"}, - {file = "black-24.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a"}, - {file = "black-24.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4"}, - {file = "black-24.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7"}, - {file = "black-24.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8"}, - {file = "black-24.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161"}, - {file = "black-24.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d"}, - {file = "black-24.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8"}, - {file = "black-24.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e"}, - {file = "black-24.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6"}, - {file = "black-24.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b"}, - {file = "black-24.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62"}, - {file = "black-24.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5"}, - {file = "black-24.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6"}, - {file = "black-24.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717"}, - {file = "black-24.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9"}, - {file = "black-24.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024"}, - {file = "black-24.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2"}, - {file = "black-24.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac"}, - {file = "black-24.1.1-py3-none-any.whl", hash = "sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168"}, - {file = "black-24.1.1.tar.gz", hash = "sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b"}, + {file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"}, + {file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"}, + {file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"}, + {file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"}, + {file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"}, + {file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"}, + {file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"}, + {file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"}, + {file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"}, + {file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"}, + {file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"}, + {file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"}, + {file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"}, + {file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"}, + {file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"}, + {file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"}, + {file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"}, + {file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"}, + {file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"}, + {file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"}, + {file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"}, + {file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"}, ] [package.dependencies]