Merge branch 'mealie-next' into feat--send-reset-email-from-admin-dashboard

This commit is contained in:
Kuchenpirat 2024-02-13 09:15:41 +01:00 committed by GitHub
commit 99db24cdec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
132 changed files with 1199 additions and 879 deletions

View File

@ -112,7 +112,7 @@ async def system_startup():
logger.info("-----SYSTEM STARTUP----- \n") logger.info("-----SYSTEM STARTUP----- \n")
logger.info("------APP SETTINGS------") logger.info("------APP SETTINGS------")
logger.info( logger.info(
settings.json( settings.model_dump_json(
indent=4, indent=4,
exclude={ exclude={
"SECRET", "SECRET",

View File

@ -1,7 +1,9 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from urllib import parse as urlparse
from pydantic import BaseModel, BaseSettings, PostgresDsn from pydantic import BaseModel, PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class AbstractDBProvider(ABC): class AbstractDBProvider(ABC):
@ -38,15 +40,19 @@ class PostgresProvider(AbstractDBProvider, BaseSettings):
POSTGRES_PORT: str = "5432" POSTGRES_PORT: str = "5432"
POSTGRES_DB: str = "mealie" POSTGRES_DB: str = "mealie"
model_config = SettingsConfigDict(arbitrary_types_allowed=True, extra="allow")
@property @property
def db_url(self) -> str: def db_url(self) -> str:
host = f"{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}" host = f"{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}"
return PostgresDsn.build( return str(
scheme="postgresql", PostgresDsn.build(
user=self.POSTGRES_USER, scheme="postgresql",
password=self.POSTGRES_PASSWORD, username=self.POSTGRES_USER,
host=host, password=urlparse.quote_plus(self.POSTGRES_PASSWORD),
path=f"/{self.POSTGRES_DB or ''}", host=host,
path=f"{self.POSTGRES_DB or ''}",
)
) )
@property @property

View File

@ -1,7 +1,8 @@
import secrets import secrets
from pathlib import Path 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 from mealie.core.settings.themes import Theme
@ -55,7 +56,8 @@ class AppSettings(BaseSettings):
SECURITY_USER_LOCKOUT_TIME: int = 24 SECURITY_USER_LOCKOUT_TIME: int = 24
"time in hours" "time in hours"
@validator("BASE_URL") @field_validator("BASE_URL")
@classmethod
def remove_trailing_slash(cls, v: str) -> str: def remove_trailing_slash(cls, v: str) -> str:
if v and v[-1] == "/": if v and v[-1] == "/":
return v[:-1] return v[:-1]
@ -100,12 +102,12 @@ class AppSettings(BaseSettings):
# =============================================== # ===============================================
# Email Configuration # Email Configuration
SMTP_HOST: str | None SMTP_HOST: str | None = None
SMTP_PORT: str | None = "587" SMTP_PORT: str | None = "587"
SMTP_FROM_NAME: str | None = "Mealie" SMTP_FROM_NAME: str | None = "Mealie"
SMTP_FROM_EMAIL: str | None SMTP_FROM_EMAIL: str | None = None
SMTP_USER: str | None SMTP_USER: str | None = None
SMTP_PASSWORD: str | None SMTP_PASSWORD: str | None = None
SMTP_AUTH_STRATEGY: str | None = "TLS" # Options: 'TLS', 'SSL', 'NONE' SMTP_AUTH_STRATEGY: str | None = "TLS" # Options: 'TLS', 'SSL', 'NONE'
@property @property
@ -122,11 +124,11 @@ class AppSettings(BaseSettings):
@staticmethod @staticmethod
def validate_smtp( def validate_smtp(
host: str | None, host: str | None = None,
port: str | None, port: str | None = None,
from_name: str | None, from_name: str | None = None,
from_email: str | None, from_email: str | None = None,
strategy: str | None, strategy: str | None = None,
user: str | None = None, user: str | None = None,
password: str | None = None, password: str | None = None,
) -> bool: ) -> bool:
@ -143,15 +145,15 @@ class AppSettings(BaseSettings):
# LDAP Configuration # LDAP Configuration
LDAP_AUTH_ENABLED: bool = False LDAP_AUTH_ENABLED: bool = False
LDAP_SERVER_URL: NoneStr = None LDAP_SERVER_URL: str | None = None
LDAP_TLS_INSECURE: bool = False LDAP_TLS_INSECURE: bool = False
LDAP_TLS_CACERTFILE: NoneStr = None LDAP_TLS_CACERTFILE: str | None = None
LDAP_ENABLE_STARTTLS: bool = False LDAP_ENABLE_STARTTLS: bool = False
LDAP_BASE_DN: NoneStr = None LDAP_BASE_DN: str | None = None
LDAP_QUERY_BIND: NoneStr = None LDAP_QUERY_BIND: str | None = None
LDAP_QUERY_PASSWORD: NoneStr = None LDAP_QUERY_PASSWORD: str | None = None
LDAP_USER_FILTER: NoneStr = None LDAP_USER_FILTER: str | None = None
LDAP_ADMIN_FILTER: NoneStr = None LDAP_ADMIN_FILTER: str | None = None
LDAP_ID_ATTRIBUTE: str = "uid" LDAP_ID_ATTRIBUTE: str = "uid"
LDAP_MAIL_ATTRIBUTE: str = "mail" LDAP_MAIL_ATTRIBUTE: str = "mail"
LDAP_NAME_ATTRIBUTE: str = "name" LDAP_NAME_ATTRIBUTE: str = "name"
@ -173,9 +175,7 @@ class AppSettings(BaseSettings):
# Testing Config # Testing Config
TESTING: bool = False TESTING: bool = False
model_config = SettingsConfigDict(arbitrary_types_allowed=True, extra="allow")
class Config:
arbitrary_types_allowed = True
def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings: def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings:

View File

@ -1,4 +1,4 @@
from pydantic import BaseSettings from pydantic_settings import BaseSettings, SettingsConfigDict
class Theme(BaseSettings): class Theme(BaseSettings):
@ -17,6 +17,4 @@ class Theme(BaseSettings):
dark_info: str = "#1976D2" dark_info: str = "#1976D2"
dark_warning: str = "#FF6D00" dark_warning: str = "#FF6D00"
dark_error: str = "#EF5350" dark_error: str = "#EF5350"
model_config = SettingsConfigDict(env_prefix="theme_", extra="allow")
class Config:
env_prefix = "theme_"

View File

@ -1,7 +1,7 @@
from functools import wraps from functools import wraps
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field, NoneStr from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import MANYTOMANY, MANYTOONE, ONETOMANY, Session from sqlalchemy.orm import MANYTOMANY, MANYTOONE, ONETOMANY, Session
from sqlalchemy.orm.mapper import Mapper from sqlalchemy.orm.mapper import Mapper
@ -21,7 +21,7 @@ class AutoInitConfig(BaseModel):
Config class for `auto_init` decorator. Config class for `auto_init` decorator.
""" """
get_attr: NoneStr = None get_attr: str | None = None
exclude: set = Field(default_factory=_default_exclusion) exclude: set = Field(default_factory=_default_exclusion)
# auto_create: bool = False # auto_create: bool = False
@ -31,16 +31,16 @@ def _get_config(relation_cls: type[SqlAlchemyBase]) -> AutoInitConfig:
Returns the config for the given class. Returns the config for the given class.
""" """
cfg = AutoInitConfig() cfg = AutoInitConfig()
cfgKeys = cfg.dict().keys() cfgKeys = cfg.model_dump().keys()
# Get the config for the class # Get the config for the class
try: try:
class_config: AutoInitConfig = relation_cls.Config class_config: ConfigDict = relation_cls.model_config
except AttributeError: except AttributeError:
return cfg return cfg
# Map all matching attributes in Config to all AutoInitConfig attributes # 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: if attr in cfgKeys:
setattr(cfg, attr, getattr(class_config, attr)) setattr(cfg, attr, class_config[attr])
return cfg return cfg
@ -97,7 +97,7 @@ def handle_one_to_many_list(
updated_elems.append(existing_elem) 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 return new_elems + updated_elems
@ -164,7 +164,7 @@ def auto_init(): # sourcery no-metrics
setattr(self, key, instances) setattr(self, key, instances)
elif relation_dir == ONETOMANY: 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) setattr(self, key, instance)
elif relation_dir == MANYTOONE and not use_list: elif relation_dir == MANYTOONE and not use_list:

View File

@ -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} 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. Safely calls the supplied function with the supplied dictionary of arguments.
by removing any invalid arguments. by removing any invalid arguments.
""" """
if dict_args is None:
dict_args = {}
if kwargs: if kwargs:
dict_args.update(kwargs) dict_args.update(kwargs)

View File

@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Optional
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from pydantic import ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -79,9 +80,8 @@ class Group(SqlAlchemyBase, BaseMixins):
ingredient_foods: Mapped[list["IngredientFoodModel"]] = orm.relationship("IngredientFoodModel", **common_args) ingredient_foods: Mapped[list["IngredientFoodModel"]] = orm.relationship("IngredientFoodModel", **common_args)
tools: Mapped[list["Tool"]] = orm.relationship("Tool", **common_args) tools: Mapped[list["Tool"]] = orm.relationship("Tool", **common_args)
tags: Mapped[list["Tag"]] = orm.relationship("Tag", **common_args) tags: Mapped[list["Tag"]] = orm.relationship("Tag", **common_args)
model_config = ConfigDict(
class Config: exclude={
exclude = {
"users", "users",
"webhooks", "webhooks",
"shopping_lists", "shopping_lists",
@ -91,6 +91,7 @@ class Group(SqlAlchemyBase, BaseMixins):
"mealplans", "mealplans",
"data_exports", "data_exports",
} }
)
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pydantic import ConfigDict
from sqlalchemy import ForeignKey, orm from sqlalchemy import ForeignKey, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql.sqltypes import Boolean, DateTime, String from sqlalchemy.sql.sqltypes import Boolean, DateTime, String
@ -47,9 +48,7 @@ class ReportModel(SqlAlchemyBase, BaseMixins):
# Relationships # Relationships
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True) 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) group: Mapped["Group"] = orm.relationship("Group", back_populates="group_reports", single_parent=True)
model_config = ConfigDict(exclude=["entries"])
class Config:
exclude = ["entries"]
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:

View File

@ -1,5 +1,6 @@
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from pydantic import ConfigDict
from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, UniqueConstraint, orm from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, UniqueConstraint, orm
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@ -69,9 +70,7 @@ class ShoppingListItem(SqlAlchemyBase, BaseMixins):
recipe_references: Mapped[list[ShoppingListItemRecipeReference]] = orm.relationship( recipe_references: Mapped[list[ShoppingListItemRecipeReference]] = orm.relationship(
ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan" ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan"
) )
model_config = ConfigDict(exclude={"id", "label", "food", "unit"})
class Config:
exclude = {"id", "label", "food", "unit"}
@api_extras @api_extras
@auto_init() @auto_init()
@ -91,9 +90,7 @@ class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
) )
recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False) recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False)
model_config = ConfigDict(exclude={"id", "recipe"})
class Config:
exclude = {"id", "recipe"}
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
@ -112,9 +109,7 @@ class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
"MultiPurposeLabel", back_populates="shopping_lists_label_settings" "MultiPurposeLabel", back_populates="shopping_lists_label_settings"
) )
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
model_config = ConfigDict(exclude={"label"})
class Config:
exclude = {"label"}
@auto_init() @auto_init()
def __init__(self, **_) -> None: def __init__(self, **_) -> None:
@ -146,9 +141,7 @@ class ShoppingList(SqlAlchemyBase, BaseMixins):
collection_class=ordering_list("position"), collection_class=ordering_list("position"),
) )
extras: Mapped[list[ShoppingListExtras]] = orm.relationship("ShoppingListExtras", cascade="all, delete-orphan") extras: Mapped[list[ShoppingListExtras]] = orm.relationship("ShoppingListExtras", cascade="all, delete-orphan")
model_config = ConfigDict(exclude={"id", "list_items"})
class Config:
exclude = {"id", "list_items"}
@api_extras @api_extras
@auto_init() @auto_init()

View File

@ -1,3 +1,4 @@
from pydantic import ConfigDict
from sqlalchemy import ForeignKey, Integer, String, orm from sqlalchemy import ForeignKey, Integer, String, orm
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@ -28,12 +29,12 @@ class RecipeInstruction(SqlAlchemyBase):
ingredient_references: Mapped[list[RecipeIngredientRefLink]] = orm.relationship( ingredient_references: Mapped[list[RecipeIngredientRefLink]] = orm.relationship(
RecipeIngredientRefLink, cascade="all, delete-orphan" RecipeIngredientRefLink, cascade="all, delete-orphan"
) )
model_config = ConfigDict(
class Config: exclude={
exclude = {
"id", "id",
"ingredient_references", "ingredient_references",
} }
)
@auto_init() @auto_init()
def __init__(self, ingredient_references, session, **_) -> None: def __init__(self, ingredient_references, session, **_) -> None:

View File

@ -3,6 +3,7 @@ from typing import TYPE_CHECKING
import sqlalchemy as sa import sqlalchemy as sa
import sqlalchemy.orm as orm import sqlalchemy.orm as orm
from pydantic import ConfigDict
from sqlalchemy import event from sqlalchemy import event
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column, validates 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 # Automatically updated by sqlalchemy event, do not write to this manually
name_normalized: Mapped[str] = mapped_column(sa.String, nullable=False, index=True) name_normalized: Mapped[str] = mapped_column(sa.String, nullable=False, index=True)
description_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) description_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
model_config = ConfigDict(
class Config: get_attr="slug",
get_attr = "slug" exclude={
exclude = {
"assets", "assets",
"notes", "notes",
"nutrition", "nutrition",
@ -146,7 +146,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
"settings", "settings",
"comments", "comments",
"timeline_events", "timeline_events",
} },
)
@validates("name") @validates("name")
def validate_name(self, _, name): def validate_name(self, _, name):

View File

@ -2,6 +2,7 @@ import enum
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from pydantic import ConfigDict
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, orm from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, orm
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@ -84,9 +85,8 @@ class User(SqlAlchemyBase, BaseMixins):
favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship( favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=users_to_favorites, back_populates="favorited_by" "RecipeModel", secondary=users_to_favorites, back_populates="favorited_by"
) )
model_config = ConfigDict(
class Config: exclude={
exclude = {
"password", "password",
"admin", "admin",
"can_manage", "can_manage",
@ -94,6 +94,7 @@ class User(SqlAlchemyBase, BaseMixins):
"can_organize", "can_organize",
"group", "group",
} }
)
@hybrid_property @hybrid_property
def group_slug(self) -> str: def group_slug(self) -> str:

View File

@ -33,12 +33,12 @@
"generic-deleted": "{name} er blevet slettet" "generic-deleted": "{name} er blevet slettet"
}, },
"datetime": { "datetime": {
"year": "year|years", "year": "år|år",
"day": "day|days", "day": "dag|dage",
"hour": "hour|hours", "hour": "time|timer",
"minute": "minute|minutes", "minute": "minut|minutter",
"second": "second|seconds", "second": "sekund|sekunder",
"millisecond": "millisecond|milliseconds", "millisecond": "millisekund|millisekunder",
"microsecond": "microsecond|microseconds" "microsecond": "mikrosekund|mikrosekunder"
} }
} }

View File

@ -33,12 +33,12 @@
"generic-deleted": "{name} törölve lett" "generic-deleted": "{name} törölve lett"
}, },
"datetime": { "datetime": {
"year": "év|év", "year": "év",
"day": "nap/nap", "day": "nap",
"hour": "óra|óra", "hour": "óra",
"minute": "perc/perc", "minute": "perc",
"second": "másodperc|másodperc", "second": "másodperc",
"millisecond": "ezredmásodperc|ezredmásodperc", "millisecond": "ezredmásodperc",
"microsecond": "mikroszekundum|mikroszekundum" "microsecond": "mikroszekundum"
} }
} }

View File

@ -106,7 +106,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
except AttributeError: except AttributeError:
self.logger.info(f'Attempted to sort by unknown sort property "{order_by}"; ignoring') 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() 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( def multi_query(
self, self,
@ -129,7 +129,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
q = q.offset(start).limit(limit) q = q.offset(start).limit(limit)
result = self.session.execute(q).unique().scalars().all() 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: 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: if not result:
return None return None
return eff_schema.from_orm(result) return eff_schema.model_validate(result)
def create(self, data: Schema | BaseModel | dict) -> Schema: def create(self, data: Schema | BaseModel | dict) -> Schema:
try: 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) new_document = self.model(session=self.session, **data)
self.session.add(new_document) self.session.add(new_document)
self.session.commit() self.session.commit()
@ -175,12 +175,12 @@ class RepositoryGeneric(Generic[Schema, Model]):
self.session.refresh(new_document) 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]: def create_many(self, data: Iterable[Schema | dict]) -> list[Schema]:
new_documents = [] new_documents = []
for document in data: 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_document = self.model(session=self.session, **document)
new_documents.append(new_document) new_documents.append(new_document)
@ -190,7 +190,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
for created_document in new_documents: for created_document in new_documents:
self.session.refresh(created_document) 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: def update(self, match_value: str | int | UUID4, new_data: dict | BaseModel) -> Schema:
"""Update a database entry. """Update a database entry.
@ -202,18 +202,18 @@ class RepositoryGeneric(Generic[Schema, Model]):
Returns: Returns:
dict: Returns a dictionary representation of the database entry 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 = self._query_one(match_value=match_value)
entry.update(session=self.session, **new_data) entry.update(session=self.session, **new_data)
self.session.commit() 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]: def update_many(self, data: Iterable[Schema | dict]) -> list[Schema]:
document_data_by_id: dict[str, dict] = {} document_data_by_id: dict[str, dict] = {}
for document in data: 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 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()))) 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) updated_documents.append(document_to_update)
self.session.commit() 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: 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 = 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) entry_as_dict.update(new_data)
return self.update(match_value, entry_as_dict) 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 match_key = match_key or self.primary_key
result = self._query_one(value, match_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: try:
self.session.delete(result) self.session.delete(result)
@ -256,7 +256,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
def delete_many(self, values: Iterable) -> Schema: def delete_many(self, values: Iterable) -> Schema:
query = self._query().filter(self.model.id.in_(values)) # type: ignore query = self._query().filter(self.model.id.in_(values)) # type: ignore
results = self.session.execute(query).unique().scalars().all() 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: try:
# we create a delete statement for each row # we create a delete statement for each row
@ -295,7 +295,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
return self.session.scalar(q) return self.session.scalar(q)
else: else:
q = self._query(override_schema=eff_schema).filter(attribute_name == attr_match) 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]: 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 eff_schema = override or self.schema
# Copy this, because calling methods (e.g. tests) might rely on it not getting mutated # 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) q = self._query(override_schema=eff_schema, with_options=False)
fltr = self._filter_builder() fltr = self._filter_builder()
@ -336,7 +336,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
per_page=pagination_result.per_page, per_page=pagination_result.per_page,
total=count, total=count,
total_pages=total_pages, 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]: def add_pagination_to_query(self, query: Select, pagination: PaginationQuery) -> tuple[Select, int, int]:

View File

@ -23,7 +23,7 @@ from .repository_generic import RepositoryGeneric
class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]): class RepositoryGroup(RepositoryGeneric[GroupInDB, Group]):
def create(self, data: GroupBase | dict) -> GroupInDB: def create(self, data: GroupBase | dict) -> GroupInDB:
if isinstance(data, GroupBase): if isinstance(data, GroupBase):
data = data.dict() data = data.model_dump()
max_attempts = 10 max_attempts = 10
original_name = cast(str, data["name"]) 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() dbgroup = self.session.execute(select(self.model).filter_by(name=name)).scalars().one_or_none()
if dbgroup is None: if dbgroup is None:
return 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: def get_by_slug_or_id(self, slug_or_id: str | UUID) -> GroupInDB | None:
if isinstance(slug_or_id, str): if isinstance(slug_or_id, str):

View File

@ -28,4 +28,4 @@ class RepositoryMealPlanRules(RepositoryGeneric[PlanRulesOut, GroupMealPlanRules
rules = self.session.execute(stmt).scalars().all() 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]

View File

@ -17,4 +17,4 @@ class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]):
today = date.today() today = date.today()
stmt = select(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id) stmt = select(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id)
plans = self.session.execute(stmt).scalars().all() 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]

View File

@ -58,7 +58,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.offset(start) .offset(start)
.limit(limit) .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 = ( stmt = (
select(self.model) select(self.model)
@ -67,7 +67,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.offset(start) .offset(start)
.limit(limit) .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: def update_image(self, slug: str, _: str | None = None) -> int:
entry: RecipeModel = self._query_one(match_value=slug) entry: RecipeModel = self._query_one(match_value=slug)
@ -160,7 +160,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
search: str | None = None, search: str | None = None,
) -> RecipePagination: ) -> RecipePagination:
# Copy this, because calling methods (e.g. tests) might rely on it not getting mutated # 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) q = select(self.model)
args = [ args = [
@ -216,7 +216,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
self.session.rollback() self.session.rollback()
raise e raise e
items = [RecipeSummary.from_orm(item) for item in data] items = [RecipeSummary.model_validate(item) for item in data]
return RecipePagination( return RecipePagination(
page=pagination_result.page, page=pagination_result.page,
per_page=pagination_result.per_page, per_page=pagination_result.per_page,
@ -236,7 +236,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.join(RecipeModel.recipe_category) .join(RecipeModel.recipe_category)
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids))) .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( def _build_recipe_filter(
self, self,
@ -298,7 +298,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
require_all_tools=require_all_tools, require_all_tools=require_all_tools,
) )
stmt = select(RecipeModel).filter(*fltr) 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( def get_random_by_categories_and_tags(
self, categories: list[RecipeCategory], tags: list[RecipeTag] self, categories: list[RecipeCategory], tags: list[RecipeTag]
@ -316,7 +316,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
stmt = ( stmt = (
select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific 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]: def get_random(self, limit=1) -> list[Recipe]:
stmt = ( stmt = (
@ -325,14 +325,14 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.order_by(func.random()) # Postgres and SQLite specific .order_by(func.random()) # Postgres and SQLite specific
.limit(limit) .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: 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) stmt = select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
dbrecipe = self.session.execute(stmt).scalars().one_or_none() dbrecipe = self.session.execute(stmt).scalars().one_or_none()
if dbrecipe is None: if dbrecipe is None:
return None return None
return self.schema.from_orm(dbrecipe) return self.schema.model_validate(dbrecipe)
def all_ids(self, group_id: UUID4) -> Sequence[UUID4]: def all_ids(self, group_id: UUID4) -> Sequence[UUID4]:
stmt = select(RecipeModel.id).filter(RecipeModel.group_id == group_id) stmt = select(RecipeModel.id).filter(RecipeModel.group_id == group_id)

View File

@ -18,7 +18,7 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
def update_password(self, id, password: str): def update_password(self, id, password: str):
entry = self._query_one(match_value=id) entry = self._query_one(match_value=id)
if settings.IS_DEMO: 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: if user_to_update.is_default_user:
# do not update the default user in demo mode # do not update the default user in demo mode
return user_to_update return user_to_update
@ -26,7 +26,7 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
entry.update_password(password) entry.update_password(password)
self.session.commit() self.session.commit()
return self.schema.from_orm(entry) return self.schema.model_validate(entry)
def create(self, user: PrivateUser | dict): # type: ignore def create(self, user: PrivateUser | dict): # type: ignore
new_user = super().create(user) new_user = super().create(user)
@ -66,9 +66,9 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
def get_by_username(self, username: str) -> PrivateUser | None: def get_by_username(self, username: str) -> PrivateUser | None:
stmt = select(User).filter(User.username == username) stmt = select(User).filter(User.username == username)
dbuser = self.session.execute(stmt).scalars().one_or_none() 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]: def get_locked_users(self) -> list[PrivateUser]:
stmt = select(User).filter(User.locked_at != None) # noqa E711 stmt = select(User).filter(User.locked_at != None) # noqa E711
results = self.session.execute(stmt).scalars().all() 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]

View File

@ -2,7 +2,7 @@ from abc import ABC
from logging import Logger from logging import Logger
from fastapi import Depends from fastapi import Depends
from pydantic import UUID4 from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from mealie.core.config import get_app_dirs, get_app_settings from mealie.core.config import get_app_dirs, get_app_settings
@ -25,10 +25,10 @@ class _BaseController(ABC):
session: Session = Depends(generate_session) session: Session = Depends(generate_session)
translator: Translator = Depends(local_provider) translator: Translator = Depends(local_provider)
_repos: AllRepositories | None _repos: AllRepositories | None = None
_logger: Logger | None _logger: Logger | None = None
_settings: AppSettings | None _settings: AppSettings | None = None
_folders: AppDirectories | None _folders: AppDirectories | None = None
@property @property
def t(self): def t(self):
@ -58,8 +58,7 @@ class _BaseController(ABC):
self._folders = get_app_dirs() self._folders = get_app_dirs()
return self._folders return self._folders
class Config: model_config = ConfigDict(arbitrary_types_allowed=True)
arbitrary_types_allowed = True
class BasePublicController(_BaseController): class BasePublicController(_BaseController):

View File

@ -6,11 +6,10 @@ See their repository for details -> https://github.com/dmontagu/fastapi-utils
import inspect import inspect
from collections.abc import Callable 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 import APIRouter, Depends
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from pydantic.typing import is_classvar
from starlette.routing import Route, WebSocketRoute from starlette.routing import Route, WebSocketRoute
T = TypeVar("T") T = TypeVar("T")
@ -47,6 +46,25 @@ def _cbv(router: APIRouter, cls: type[T], *urls: str, instance: Any | None = Non
return cls 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: def _init_cbv(cls: type[Any], instance: Any | None = None) -> None:
""" """
Idempotently modifies the provided `cls`, performing the following modifications: 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] = [] dependency_names: list[str] = []
for name, hint in get_type_hints(cls).items(): for name, hint in get_type_hints(cls).items():
if is_classvar(hint): if _is_classvar(hint):
continue continue
if name.startswith("_"): if name.startswith("_"):

View File

@ -108,7 +108,7 @@ class HttpRepo(Generic[C, R, U]):
) )
try: 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: except Exception as ex:
self.handle_exception(ex) self.handle_exception(ex)

View File

@ -43,7 +43,7 @@ class AdminUserManagementRoutes(BaseAdminController):
override=GroupInDB, 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 return response
@router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED) @router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED)

View File

@ -37,7 +37,7 @@ class AdminUserManagementRoutes(BaseAdminController):
override=UserOut, 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 return response
@router.post("", response_model=UserOut, status_code=201) @router.post("", response_model=UserOut, status_code=201)

View File

@ -18,7 +18,7 @@ class AdminServerTasksController(BaseAdminController):
override=ServerTask, 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 return response
@router.post("/server-tasks", response_model=ServerTask, status_code=201) @router.post("/server-tasks", response_model=ServerTask, status_code=201)

View File

@ -53,4 +53,4 @@ def get_app_theme(resp: Response):
settings = get_app_settings() settings = get_app_settings()
resp.headers["Cache-Control"] = "public, max-age=604800" resp.headers["Cache-Control"] = "public, max-age=604800"
return AppTheme(**settings.theme.dict()) return AppTheme(**settings.theme.model_dump())

View File

@ -48,7 +48,7 @@ class MealieAuthToken(BaseModel):
@classmethod @classmethod
def respond(cls, token: str, token_type: str = "bearer") -> dict: 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") @public_router.post("/token")

View File

@ -47,7 +47,7 @@ class RecipeCommentRoutes(BaseUserController):
override=RecipeCommentOut, 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 return response
@router.post("", response_model=RecipeCommentOut, status_code=201) @router.post("", response_model=RecipeCommentOut, status_code=201)

View File

@ -1,3 +1,5 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4 from pydantic import UUID4
@ -36,12 +38,19 @@ class PublicCookbooksController(BasePublicExploreController):
search=search, 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 return response
@router.get("/{item_id}", response_model=RecipeCookBook) @router.get("/{item_id}", response_model=RecipeCookBook)
def get_one(self, item_id: UUID4 | str) -> 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) cookbook = self.cookbooks.get_one(item_id, match_attr)
if not cookbook or not cookbook.public: if not cookbook or not cookbook.public:

View File

@ -26,7 +26,7 @@ class PublicFoodsController(BasePublicExploreController):
search=search, 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 return response
@router.get("/{item_id}", response_model=IngredientFood) @router.get("/{item_id}", response_model=IngredientFood)

View File

@ -31,7 +31,9 @@ class PublicCategoriesController(BasePublicExploreController):
search=search, 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 return response
@categories_router.get("/{item_id}", response_model=CategoryOut) @categories_router.get("/{item_id}", response_model=CategoryOut)
@ -59,7 +61,7 @@ class PublicTagsController(BasePublicExploreController):
search=search, 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 return response
@tags_router.get("/{item_id}", response_model=TagOut) @tags_router.get("/{item_id}", response_model=TagOut)
@ -87,7 +89,7 @@ class PublicToolsController(BasePublicExploreController):
search=search, 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 return response
@tools_router.get("/{item_id}", response_model=RecipeToolOut) @tools_router.get("/{item_id}", response_model=RecipeToolOut)

View File

@ -1,3 +1,5 @@
from uuid import UUID
import orjson import orjson
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import UUID4 from pydantic import UUID4
@ -37,7 +39,14 @@ class PublicRecipesController(BasePublicExploreController):
) -> PaginationBase[RecipeSummary]: ) -> PaginationBase[RecipeSummary]:
cookbook_data: ReadCookBook | None = None cookbook_data: ReadCookBook | None = None
if search_query.cookbook: 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) cookbook_data = self.cookbooks.get_one(search_query.cookbook, cb_match_attr)
if cookbook_data is None or not cookbook_data.public: 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 # 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( pagination_response.set_pagination_guides(
router.url_path_for("get_all", group_slug=self.group.slug), 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}, {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 # Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response) return JSONBytes(content=json_compatible_response)

View File

@ -1,4 +1,5 @@
from functools import cached_property from functools import cached_property
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4 from pydantic import UUID4
@ -48,7 +49,7 @@ class GroupCookbookController(BaseCrudController):
override=ReadCookBook, 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 return response
@router.post("", response_model=ReadCookBook, status_code=201) @router.post("", response_model=ReadCookBook, status_code=201)
@ -85,7 +86,15 @@ class GroupCookbookController(BaseCrudController):
@router.get("/{item_id}", response_model=RecipeCookBook) @router.get("/{item_id}", response_model=RecipeCookBook)
def get_one(self, item_id: UUID4 | str): 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) cookbook = self.repo.get_one(item_id, match_attr)
if cookbook is None: if cookbook is None:

View File

@ -58,7 +58,7 @@ class GroupEventsNotifierController(BaseUserController):
override=GroupEventNotifierOut, 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 return response
@router.post("", response_model=GroupEventNotifierOut, status_code=201) @router.post("", response_model=GroupEventNotifierOut, status_code=201)

View File

@ -48,7 +48,7 @@ class MultiPurposeLabelsController(BaseUserController):
search=search, 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 return response
@router.post("", response_model=MultiPurposeLabelOut) @router.post("", response_model=MultiPurposeLabelOut)

View File

@ -31,7 +31,7 @@ class GroupMealplanConfigController(BaseUserController):
override=PlanRulesOut, 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 return response
@router.post("", response_model=PlanRulesOut, status_code=201) @router.post("", response_model=PlanRulesOut, status_code=201)

View File

@ -105,7 +105,7 @@ class ShoppingListItemController(BaseCrudController):
@item_router.get("", response_model=ShoppingListItemPagination) @item_router.get("", response_model=ShoppingListItemPagination)
def get_all(self, q: PaginationQuery = Depends()): def get_all(self, q: PaginationQuery = Depends()):
response = self.repo.page_all(pagination=q, override=ShoppingListItemOut) 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 return response
@item_router.post("/create-bulk", response_model=ShoppingListItemsCollectionOut, status_code=201) @item_router.post("/create-bulk", response_model=ShoppingListItemsCollectionOut, status_code=201)
@ -174,7 +174,7 @@ class ShoppingListController(BaseCrudController):
override=ShoppingListSummary, 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 return response
@router.post("", response_model=ShoppingListOut, status_code=201) @router.post("", response_model=ShoppingListOut, status_code=201)

View File

@ -32,7 +32,7 @@ class ReadWebhookController(BaseUserController):
override=ReadWebhook, 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 return response
@router.post("", response_model=ReadWebhook, status_code=201) @router.post("", response_model=ReadWebhook, status_code=201)

View File

@ -1,7 +1,7 @@
from functools import cached_property from functools import cached_property
from fastapi import APIRouter, Depends 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 import BaseCrudController, controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.mixins import HttpRepo
@ -20,9 +20,7 @@ class CategorySummary(BaseModel):
id: UUID4 id: UUID4
slug: str slug: str
name: str name: str
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@controller(router) @controller(router)
@ -46,7 +44,7 @@ class RecipeCategoryController(BaseCrudController):
search=search, 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 return response
@router.post("", status_code=201) @router.post("", status_code=201)
@ -71,7 +69,7 @@ class RecipeCategoryController(BaseCrudController):
def get_one(self, item_id: UUID4): def get_one(self, item_id: UUID4):
"""Returns a list of recipes associated with the provided category.""" """Returns a list of recipes associated with the provided category."""
category_obj = self.mixins.get_one(item_id) 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 return category_obj
@router.put("/{item_id}", response_model=CategorySummary) @router.put("/{item_id}", response_model=CategorySummary)
@ -119,7 +117,7 @@ class RecipeCategoryController(BaseCrudController):
def get_one_by_slug(self, category_slug: str): def get_one_by_slug(self, category_slug: str):
"""Returns a category object with the associated recieps relating to the category""" """Returns a category object with the associated recieps relating to the category"""
category: RecipeCategory = self.mixins.get_one(category_slug, "slug") category: RecipeCategory = self.mixins.get_one(category_slug, "slug")
return RecipeCategoryResponse.construct( return RecipeCategoryResponse.model_construct(
id=category.id, id=category.id,
slug=category.slug, slug=category.slug,
name=category.name, name=category.name,

View File

@ -35,7 +35,7 @@ class TagController(BaseCrudController):
search=search, 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 return response
@router.get("/empty") @router.get("/empty")

View File

@ -32,7 +32,7 @@ class RecipeToolController(BaseUserController):
search=search, 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 return response
@router.post("", response_model=RecipeTool, status_code=201) @router.post("", response_model=RecipeTool, status_code=201)

View File

@ -1,5 +1,6 @@
from functools import cached_property from functools import cached_property
from shutil import copyfileobj from shutil import copyfileobj
from uuid import UUID
from zipfile import ZipFile from zipfile import ZipFile
import orjson import orjson
@ -125,7 +126,7 @@ class RecipeExportController(BaseRecipeController):
recipe: Recipe = self.mixins.get_one(slug) recipe: Recipe = self.mixins.get_one(slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value) image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip: 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(): if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name) myzip.write(image_asset, arcname=image_asset.name)
@ -244,7 +245,14 @@ class RecipeController(BaseRecipeController):
): ):
cookbook_data: ReadCookBook | None = None cookbook_data: ReadCookBook | None = None
if search_query.cookbook: 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) cookbook_data = self.cookbooks_repo.get_one(search_query.cookbook, cb_match_attr)
if cookbook_data is None: if cookbook_data is None:
@ -265,13 +273,13 @@ class RecipeController(BaseRecipeController):
) )
# merge default pagination with the request's query params # 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( pagination_response.set_pagination_guides(
router.url_path_for("get_all"), router.url_path_for("get_all"),
{k: v for k, v in query_params.items() if v is not None}, {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 # Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response) return JSONBytes(content=json_compatible_response)

View File

@ -49,7 +49,7 @@ class RecipeTimelineEventsController(BaseCrudController):
override=RecipeTimelineEventOut, 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 return response
@events_router.post("", response_model=RecipeTimelineEventOut, status_code=201) @events_router.post("", response_model=RecipeTimelineEventOut, status_code=201)

View File

@ -30,7 +30,7 @@ class RecipeSharedController(BaseUserController):
@router.post("", response_model=RecipeShareToken, status_code=201) @router.post("", response_model=RecipeShareToken, status_code=201)
def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken: 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) return self.mixins.create_one(save_data)
@router.get("/{item_id}", response_model=RecipeShareToken) @router.get("/{item_id}", response_model=RecipeShareToken)

View File

@ -52,7 +52,7 @@ class IngredientFoodsController(BaseUserController):
search=search, 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 return response
@router.post("", response_model=IngredientFood, status_code=201) @router.post("", response_model=IngredientFood, status_code=201)

View File

@ -52,7 +52,7 @@ class IngredientUnitsController(BaseUserController):
search=search, 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 return response
@router.post("", response_model=IngredientUnit, status_code=201) @router.post("", response_model=IngredientUnit, status_code=201)

View File

@ -29,7 +29,7 @@ class AdminUserController(BaseAdminController):
override=UserOut, 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 return response
@admin_router.post("", response_model=UserOut, status_code=201) @admin_router.post("", response_model=UserOut, status_code=201)
@ -103,7 +103,7 @@ class UserController(BaseUserController):
) )
try: try:
self.repos.users.update(item_id, new_data.dict()) self.repos.users.update(item_id, new_data.model_dump())
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST,

View File

@ -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<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})"
time_expr = (
r"(?P<hour>\d{1,2}):(?P<minute>\d{1,2})"
r"(?::(?P<second>\d{1,2})(?:\.(?P<microsecond>\d{1,6})\d{0,6})?)?"
r"(?P<tzinfo>Z|[+-]\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<days>-?\d+) (days?, )?)?"
r"((?:(?P<hours>-?\d+):)(?=\d+:\d+))?"
r"(?:(?P<minutes>-?\d+):)?"
r"(?P<seconds>-?\d+)"
r"(?:\.(?P<microseconds>\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<sign>[-+]?)"
r"P"
r"(?:(?P<days>\d+(.\d+)?)D)?"
r"(?:T"
r"(?:(?P<hours>\d+(.\d+)?)H)?"
r"(?:(?P<minutes>\d+(.\d+)?)M)?"
r"(?:(?P<seconds>\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_)

View File

@ -5,7 +5,7 @@ from enum import Enum
from typing import ClassVar, Protocol, TypeVar from typing import ClassVar, Protocol, TypeVar
from humps.main import camelize 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 import Select, desc, func, or_, text
from sqlalchemy.orm import InstrumentedAttribute, Session from sqlalchemy.orm import InstrumentedAttribute, Session
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -28,10 +28,7 @@ class MealieModel(BaseModel):
Searchable properties for the search API. Searchable properties for the search API.
The first property will be used for sorting (order_by) The first property will be used for sorting (order_by)
""" """
model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
class Config:
alias_generator = camelize
allow_population_by_field_name = True
def cast(self, cls: type[T], **kwargs) -> T: def cast(self, cls: type[T], **kwargs) -> T:
""" """
@ -48,8 +45,8 @@ class MealieModel(BaseModel):
for method chaining. for method chaining.
""" """
for field in self.__fields__: for field in self.model_fields:
if field in dest.__fields__: if field in dest.model_fields:
setattr(dest, field, getattr(self, field)) setattr(dest, field, getattr(self, field))
return dest return dest
@ -59,8 +56,8 @@ class MealieModel(BaseModel):
Map matching values from another model to the current model. Map matching values from another model to the current model.
""" """
for field in src.__fields__: for field in src.model_fields:
if field in self.__fields__: if field in self.model_fields:
setattr(self, field, getattr(src, field)) setattr(self, field, getattr(src, field))
def merge(self, src: T, replace_null=False): 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. 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) 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) setattr(self, field, val)
@classmethod @classmethod

View File

@ -49,7 +49,7 @@ class AdminAboutInfo(AppInfo):
api_port: int api_port: int
api_docs: bool api_docs: bool
db_type: str db_type: str
db_url: str | None db_url: str | None = None
default_group: str default_group: str
build_id: str build_id: str
recipe_scraper_version: str recipe_scraper_version: str

View File

@ -19,9 +19,9 @@ class ImportJob(BackupOptions):
class CreateBackup(BaseModel): class CreateBackup(BaseModel):
tag: str | None tag: str | None = None
options: BackupOptions options: BackupOptions
templates: list[str] | None templates: list[str] | None = None
class BackupFile(BaseModel): class BackupFile(BaseModel):

View File

@ -4,11 +4,11 @@ from pydantic.main import BaseModel
class ImportBase(BaseModel): class ImportBase(BaseModel):
name: str name: str
status: bool status: bool
exception: str | None exception: str | None = None
class RecipeImport(ImportBase): class RecipeImport(ImportBase):
slug: str | None slug: str | None = None
class CommentImport(ImportBase): class CommentImport(ImportBase):

View File

@ -1,4 +1,6 @@
from pydantic import validator from typing import Annotated
from pydantic import ConfigDict, Field, field_validator
from slugify import slugify from slugify import slugify
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
@ -8,14 +10,12 @@ from ..recipe.recipe_category import RecipeCategoryResponse
class CustomPageBase(MealieModel): class CustomPageBase(MealieModel):
name: str name: str
slug: str | None slug: Annotated[str | None, Field(validate_default=True)]
position: int position: int
categories: list[RecipeCategoryResponse] = [] categories: list[RecipeCategoryResponse] = []
model_config = ConfigDict(from_attributes=True)
class Config: @field_validator("slug", mode="before")
orm_mode = True
@validator("slug", always=True, pre=True)
def validate_slug(slug: str, values): def validate_slug(slug: str, values):
name: str = values["name"] name: str = values["name"]
calc_slug: str = slugify(name) calc_slug: str = slugify(name)
@ -28,6 +28,4 @@ class CustomPageBase(MealieModel):
class CustomPageOut(CustomPageBase): class CustomPageOut(CustomPageBase):
id: int id: int
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -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 slugify import slugify
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -14,9 +17,9 @@ from ..recipe.recipe_category import CategoryBase, TagBase
class CreateCookBook(MealieModel): class CreateCookBook(MealieModel):
name: str name: str
description: str = "" description: str = ""
slug: str | None = None slug: Annotated[str | None, Field(validate_default=True)] = None
position: int = 1 position: int = 1
public: bool = False public: Annotated[bool, Field(validate_default=True)] = False
categories: list[CategoryBase] = [] categories: list[CategoryBase] = []
tags: list[TagBase] = [] tags: list[TagBase] = []
tools: list[RecipeTool] = [] tools: list[RecipeTool] = []
@ -24,13 +27,13 @@ class CreateCookBook(MealieModel):
require_all_tags: bool = True require_all_tags: bool = True
require_all_tools: bool = True require_all_tools: bool = True
@validator("public", always=True, pre=True) @field_validator("public", mode="before")
def validate_public(public: bool | None, values: dict) -> bool: # type: ignore def validate_public(public: bool | None) -> bool:
return False if public is None else public return False if public is None else public
@validator("slug", always=True, pre=True) @field_validator("slug", mode="before")
def validate_slug(slug: str, values): # type: ignore def validate_slug(slug: str, info: ValidationInfo):
name: str = values["name"] name: str = info.data["name"]
calc_slug: str = slugify(name) calc_slug: str = slugify(name)
if slug != calc_slug: if slug != calc_slug:
@ -50,9 +53,7 @@ class UpdateCookBook(SaveCookBook):
class ReadCookBook(UpdateCookBook): class ReadCookBook(UpdateCookBook):
group_id: UUID4 group_id: UUID4
categories: list[CategoryBase] = [] categories: list[CategoryBase] = []
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
@ -66,6 +67,4 @@ class CookBookPagination(PaginationBase):
class RecipeCookBook(ReadCookBook): class RecipeCookBook(ReadCookBook):
group_id: UUID4 group_id: UUID4
recipes: list[RecipeSummary] recipes: list[RecipeSummary]
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

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

View File

@ -1,4 +1,4 @@
from pydantic import UUID4, NoneStr from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -54,9 +54,7 @@ class GroupEventNotifierOptionsSave(GroupEventNotifierOptions):
class GroupEventNotifierOptionsOut(GroupEventNotifierOptions): class GroupEventNotifierOptionsOut(GroupEventNotifierOptions):
id: UUID4 id: UUID4
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
# ======================================================================= # =======================================================================
@ -65,7 +63,7 @@ class GroupEventNotifierOptionsOut(GroupEventNotifierOptions):
class GroupEventNotifierCreate(MealieModel): class GroupEventNotifierCreate(MealieModel):
name: str name: str
apprise_url: str apprise_url: str | None = None
class GroupEventNotifierSave(GroupEventNotifierCreate): class GroupEventNotifierSave(GroupEventNotifierCreate):
@ -76,7 +74,7 @@ class GroupEventNotifierSave(GroupEventNotifierCreate):
class GroupEventNotifierUpdate(GroupEventNotifierSave): class GroupEventNotifierUpdate(GroupEventNotifierSave):
id: UUID4 id: UUID4
apprise_url: NoneStr = None apprise_url: str | None = None
class GroupEventNotifierOut(MealieModel): class GroupEventNotifierOut(MealieModel):
@ -85,9 +83,7 @@ class GroupEventNotifierOut(MealieModel):
enabled: bool enabled: bool
group_id: UUID4 group_id: UUID4
options: GroupEventNotifierOptionsOut options: GroupEventNotifierOptionsOut
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
@ -100,6 +96,4 @@ class GroupEventPagination(PaginationBase):
class GroupEventNotifierPrivate(GroupEventNotifierOut): class GroupEventNotifierPrivate(GroupEventNotifierOut):
apprise_url: str apprise_url: str
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from pydantic import UUID4 from pydantic import UUID4, ConfigDict
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
@ -13,6 +13,4 @@ class GroupDataExport(MealieModel):
path: str path: str
size: str size: str
expires: datetime expires: datetime
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -1,6 +1,6 @@
from uuid import UUID from uuid import UUID
from pydantic import UUID4 from pydantic import UUID4, ConfigDict
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
@ -24,6 +24,4 @@ class CreateGroupPreferences(UpdateGroupPreferences):
class ReadGroupPreferences(CreateGroupPreferences): class ReadGroupPreferences(CreateGroupPreferences):
id: UUID4 id: UUID4
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -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.mealie_model import MealieModel
from mealie.schema._mealie.validators import validate_locale from mealie.schema._mealie.validators import validate_locale
@ -7,8 +7,8 @@ from mealie.schema._mealie.validators import validate_locale
class SeederConfig(MealieModel): class SeederConfig(MealieModel):
locale: str locale: str
@validator("locale") @field_validator("locale")
def valid_locale(cls, v, values, **kwargs): def valid_locale(cls, v):
if not validate_locale(v): if not validate_locale(v):
raise ValueError("invalid locale") raise ValueError("invalid locale")
return v return v

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from pydantic import UUID4, validator from pydantic import UUID4, ConfigDict, field_validator, model_validator
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption 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.db.models.recipe import IngredientFoodModel, RecipeModel
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.types import NoneFloat 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.labels.multi_purpose_label import MultiPurposeLabelSummary
from mealie.schema.recipe.recipe import RecipeSummary from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.recipe.recipe_ingredient import ( from mealie.schema.recipe.recipe_ingredient import (
@ -38,7 +37,8 @@ class ShoppingListItemRecipeRefCreate(MealieModel):
recipe_note: str | None = None recipe_note: str | None = None
"""the original note from the recipe""" """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): def default_none_to_zero(cls, v):
return 0 if v is None else v return 0 if v is None else v
@ -49,8 +49,7 @@ class ShoppingListItemRecipeRefUpdate(ShoppingListItemRecipeRefCreate):
class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate): class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate):
class Config: model_config = ConfigDict(from_attributes=True)
orm_mode = True
class ShoppingListItemBase(RecipeIngredientBase): class ShoppingListItemBase(RecipeIngredientBase):
@ -67,6 +66,13 @@ class ShoppingListItemBase(RecipeIngredientBase):
is_food: bool = False is_food: bool = False
extras: dict | None = {} 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): class ShoppingListItemCreate(ShoppingListItemBase):
recipe_references: list[ShoppingListItemRecipeRefCreate] = [] recipe_references: list[ShoppingListItemRecipeRefCreate] = []
@ -85,26 +91,25 @@ class ShoppingListItemUpdateBulk(ShoppingListItemUpdate):
class ShoppingListItemOut(ShoppingListItemBase): class ShoppingListItemOut(ShoppingListItemBase):
id: UUID4 id: UUID4
food: IngredientFood | None food: IngredientFood | None = None
label: MultiPurposeLabelSummary | None label: MultiPurposeLabelSummary | None = None
unit: IngredientUnit | None unit: IngredientUnit | None = None
recipe_references: list[ShoppingListItemRecipeRefOut] = [] recipe_references: list[ShoppingListItemRecipeRefOut] = []
created_at: datetime | None created_at: datetime | None = None
update_at: datetime | 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 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): if (not self.label) and (self.food and self.food.label):
self.label = self.food.label self.label = self.food.label
self.label_id = self.label.id self.label_id = self.label.id
class Config: return self
orm_mode = True
getter_dict = ExtrasGetterDict model_config = ConfigDict(from_attributes=True)
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
@ -138,9 +143,7 @@ class ShoppingListMultiPurposeLabelUpdate(ShoppingListMultiPurposeLabelCreate):
class ShoppingListMultiPurposeLabelOut(ShoppingListMultiPurposeLabelUpdate): class ShoppingListMultiPurposeLabelOut(ShoppingListMultiPurposeLabelUpdate):
label: MultiPurposeLabelSummary label: MultiPurposeLabelSummary
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
@ -155,8 +158,15 @@ class ShoppingListCreate(MealieModel):
name: str | None = None name: str | None = None
extras: dict | None = {} extras: dict | None = {}
created_at: datetime | None created_at: datetime | None = None
update_at: datetime | 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): class ShoppingListRecipeRefOut(MealieModel):
@ -167,9 +177,7 @@ class ShoppingListRecipeRefOut(MealieModel):
"""the number of times this recipe has been added""" """the number of times this recipe has been added"""
recipe: RecipeSummary recipe: RecipeSummary
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
@ -188,10 +196,7 @@ class ShoppingListSummary(ShoppingListSave):
id: UUID4 id: UUID4
recipe_references: list[ShoppingListRecipeRefOut] recipe_references: list[ShoppingListRecipeRefOut]
label_settings: list[ShoppingListMultiPurposeLabelOut] label_settings: list[ShoppingListMultiPurposeLabelOut]
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
getter_dict = ExtrasGetterDict
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
@ -222,10 +227,7 @@ class ShoppingListUpdate(ShoppingListSave):
class ShoppingListOut(ShoppingListUpdate): class ShoppingListOut(ShoppingListUpdate):
recipe_references: list[ShoppingListRecipeRefOut] recipe_references: list[ShoppingListRecipeRefOut]
label_settings: list[ShoppingListMultiPurposeLabelOut] label_settings: list[ShoppingListMultiPurposeLabelOut]
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
getter_dict = ExtrasGetterDict
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:

View File

@ -1,6 +1,6 @@
from uuid import UUID from uuid import UUID
from pydantic import NoneStr from pydantic import ConfigDict
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
@ -19,9 +19,7 @@ class ReadInviteToken(MealieModel):
token: str token: str
uses_left: int uses_left: int
group_id: UUID group_id: UUID
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class EmailInvitation(MealieModel): class EmailInvitation(MealieModel):
@ -31,4 +29,4 @@ class EmailInvitation(MealieModel):
class EmailInitationResponse(MealieModel): class EmailInitationResponse(MealieModel):
success: bool success: bool
error: NoneStr = None error: str | None = None

View File

@ -3,10 +3,10 @@ import enum
from uuid import UUID from uuid import UUID
from isodate import parse_time from isodate import parse_time
from pydantic import UUID4, validator from pydantic import UUID4, ConfigDict, field_validator
from pydantic.datetime_parse import parse_datetime
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.datetime_parse import parse_datetime
from mealie.schema.response.pagination import PaginationBase from mealie.schema.response.pagination import PaginationBase
@ -22,7 +22,7 @@ class CreateWebhook(MealieModel):
webhook_type: WebhookType = WebhookType.mealplan webhook_type: WebhookType = WebhookType.mealplan
scheduled_time: datetime.time scheduled_time: datetime.time
@validator("scheduled_time", pre=True) @field_validator("scheduled_time", mode="before")
@classmethod @classmethod
def validate_scheduled_time(cls, v): def validate_scheduled_time(cls, v):
""" """
@ -55,9 +55,7 @@ class SaveWebhook(CreateWebhook):
class ReadWebhook(SaveWebhook): class ReadWebhook(SaveWebhook):
id: UUID4 id: UUID4
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class WebhookPagination(PaginationBase): class WebhookPagination(PaginationBase):

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import ClassVar from typing import ClassVar
from pydantic import UUID4 from pydantic import UUID4, ConfigDict
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase from mealie.schema.response.pagination import PaginationBase
@ -23,9 +23,7 @@ class MultiPurposeLabelUpdate(MultiPurposeLabelSave):
class MultiPurposeLabelSummary(MultiPurposeLabelUpdate): class MultiPurposeLabelSummary(MultiPurposeLabelUpdate):
_searchable_properties: ClassVar[list[str]] = ["name"] _searchable_properties: ClassVar[list[str]] = ["name"]
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class MultiPurposeLabelPagination(PaginationBase): class MultiPurposeLabelPagination(PaginationBase):
@ -33,5 +31,4 @@ class MultiPurposeLabelPagination(PaginationBase):
class MultiPurposeLabelOut(MultiPurposeLabelUpdate): class MultiPurposeLabelOut(MultiPurposeLabelUpdate):
class Config: model_config = ConfigDict(from_attributes=True)
orm_mode = True

View File

@ -4,6 +4,10 @@ from fastapi.exceptions import HTTPException, RequestValidationError
from pydantic import ValidationError from pydantic import ValidationError
def format_exception(ex: Exception) -> str:
return f"{ex.__class__.__name__}: {ex}"
def make_dependable(cls): def make_dependable(cls):
""" """
Pydantic BaseModels are very powerful because we get lots of validations and type checking right out of the box. 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: except (ValidationError, RequestValidationError) as e:
for error in e.errors(): for error in e.errors():
error["loc"] = ["query"] + list(error["loc"]) 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) init_cls_and_handle_errors.__signature__ = signature(cls)
return init_cls_and_handle_errors return init_cls_and_handle_errors

View File

@ -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. Map a source model to a destination model. Only top-level fields are mapped.
""" """
for field in source.__fields__: for field in source.model_fields:
if field in dest.__fields__: if field in dest.model_fields:
setattr(dest, field, getattr(source, field)) setattr(dest, field, getattr(source, field))
return dest return dest
def cast(source: U, dest: type[T], **kwargs) -> T: 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 {}) create_data.update(kwargs or {})
return dest(**create_data) return dest(**create_data)

View File

@ -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 from mealie.schema._mealie import MealieModel
class MealIn(MealieModel): class MealIn(MealieModel):
slug: str | None slug: str | None = None
name: str | None name: str | None = None
description: str | None description: str | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class MealDayIn(MealieModel): class MealDayIn(MealieModel):
date: date | None date: datetime.date | None = None
meals: list[MealIn] meals: list[MealIn]
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class MealDayOut(MealDayIn): class MealDayOut(MealDayIn):
id: int id: int
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class MealPlanIn(MealieModel): class MealPlanIn(MealieModel):
group: str group: str
start_date: date start_date: datetime.date
end_date: date end_date: datetime.date
plan_days: list[MealDayIn] plan_days: list[MealDayIn]
@validator("end_date") @field_validator("end_date")
def end_date_after_start_date(v, values, config, field): def end_date_after_start_date(v, info: ValidationInfo):
if "start_date" in values and v < values["start_date"]: if "start_date" in info.data and v < info.data["start_date"]:
raise ValueError("EndDate should be greater than StartDate") raise ValueError("EndDate should be greater than StartDate")
return v return v
class Config: model_config = ConfigDict(from_attributes=True)
orm_mode = True
class MealPlanOut(MealPlanIn): class MealPlanOut(MealPlanIn):
id: int id: int
shopping_list: int | None shopping_list: int | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -1,8 +1,10 @@
from datetime import date from datetime import date
from enum import Enum from enum import Enum
from typing import Annotated
from uuid import UUID 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 import selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -30,13 +32,13 @@ class CreatePlanEntry(MealieModel):
entry_type: PlanEntryType = PlanEntryType.breakfast entry_type: PlanEntryType = PlanEntryType.breakfast
title: str = "" title: str = ""
text: 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 @classmethod
def id_or_title(cls, value, values): def id_or_title(cls, value, info: ValidationInfo):
if bool(value) is False and bool(values["title"]) is False: if bool(value) is False and bool(info.data["title"]) is False:
raise ValueError(f"`recipe_id={value}` or `title={values['title']}` must be provided") raise ValueError(f"`recipe_id={value}` or `title={info.data['title']}` must be provided")
return value return value
@ -44,22 +46,18 @@ class CreatePlanEntry(MealieModel):
class UpdatePlanEntry(CreatePlanEntry): class UpdatePlanEntry(CreatePlanEntry):
id: int id: int
group_id: UUID group_id: UUID
user_id: UUID | None user_id: UUID | None = None
class SavePlanEntry(CreatePlanEntry): class SavePlanEntry(CreatePlanEntry):
group_id: UUID group_id: UUID
user_id: UUID | None user_id: UUID | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class ReadPlanEntry(UpdatePlanEntry): class ReadPlanEntry(UpdatePlanEntry):
recipe: RecipeSummary | None recipe: RecipeSummary | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:

View File

@ -1,7 +1,7 @@
import datetime import datetime
from enum import Enum from enum import Enum
from pydantic import UUID4 from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -14,14 +14,11 @@ class Category(MealieModel):
id: UUID4 id: UUID4
name: str name: str
slug: str slug: str
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class Tag(Category): class Tag(Category):
class Config: model_config = ConfigDict(from_attributes=True)
orm_mode = True
class PlanRulesDay(str, Enum): class PlanRulesDay(str, Enum):
@ -64,9 +61,7 @@ class PlanRulesSave(PlanRulesCreate):
class PlanRulesOut(PlanRulesSave): class PlanRulesOut(PlanRulesSave):
id: UUID4 id: UUID4
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:

View File

@ -1,26 +1,22 @@
from pydantic import ConfigDict
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.getter_dict import GroupGetterDict
class ListItem(MealieModel): class ListItem(MealieModel):
title: str | None title: str | None = None
text: str = "" text: str = ""
quantity: int = 1 quantity: int = 1
checked: bool = False checked: bool = False
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class ShoppingListIn(MealieModel): class ShoppingListIn(MealieModel):
name: str name: str
group: str | None group: str | None = None
items: list[ListItem] items: list[ListItem]
class ShoppingListOut(ShoppingListIn): class ShoppingListOut(ShoppingListIn):
id: int id: int
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
getter_dict = GroupGetterDict

View File

@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
from numbers import Number
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar from typing import Annotated, Any, ClassVar
from uuid import uuid4 from uuid import uuid4
from pydantic import UUID4, BaseModel, 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 slugify import slugify
from sqlalchemy import Select, desc, func, or_, select, text from sqlalchemy import Select, desc, func, or_, select, text
from sqlalchemy.orm import Session, joinedload, selectinload from sqlalchemy.orm import Session, joinedload, selectinload
@ -22,7 +24,6 @@ from ...db.models.recipe import (
RecipeInstruction, RecipeInstruction,
RecipeModel, RecipeModel,
) )
from ..getter_dict import ExtrasGetterDict
from .recipe_asset import RecipeAsset from .recipe_asset import RecipeAsset
from .recipe_comments import RecipeCommentOut from .recipe_comments import RecipeCommentOut
from .recipe_notes import RecipeNote from .recipe_notes import RecipeNote
@ -39,9 +40,7 @@ class RecipeTag(MealieModel):
slug: str slug: str
_searchable_properties: ClassVar[list[str]] = ["name"] _searchable_properties: ClassVar[list[str]] = ["name"]
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class RecipeTagPagination(PaginationBase): class RecipeTagPagination(PaginationBase):
@ -80,16 +79,16 @@ class CreateRecipe(MealieModel):
class RecipeSummary(MealieModel): class RecipeSummary(MealieModel):
id: UUID4 | None id: UUID4 | None = None
_normalize_search: ClassVar[bool] = True _normalize_search: ClassVar[bool] = True
user_id: UUID4 = Field(default_factory=uuid4) user_id: UUID4 = Field(default_factory=uuid4, validate_default=True)
group_id: UUID4 = Field(default_factory=uuid4) group_id: UUID4 = Field(default_factory=uuid4, validate_default=True)
name: str | None name: str | None = None
slug: str = "" slug: Annotated[str, Field(validate_default=True)] = ""
image: Any | None image: Any | None = None
recipe_yield: str | None recipe_yield: str | None = None
total_time: str | None = None total_time: str | None = None
prep_time: str | None = None prep_time: str | None = None
@ -97,21 +96,28 @@ class RecipeSummary(MealieModel):
perform_time: str | None = None perform_time: str | None = None
description: str | None = "" description: str | None = ""
recipe_category: list[RecipeCategory] | None = [] recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = []
tags: list[RecipeTag] | None = [] tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = []
tools: list[RecipeTool] = [] tools: list[RecipeTool] = []
rating: int | None rating: int | None = None
org_url: str | None = Field(None, alias="orgURL") org_url: str | None = Field(None, alias="orgURL")
date_added: datetime.date | None date_added: datetime.date | None = None
date_updated: datetime.datetime | None date_updated: datetime.datetime | None = None
created_at: datetime.datetime | None created_at: datetime.datetime | None = None
update_at: datetime.datetime | None update_at: datetime.datetime | None = None
last_made: datetime.datetime | None last_made: datetime.datetime | None = None
model_config = ConfigDict(from_attributes=True)
class Config: @field_validator("recipe_yield", "total_time", "prep_time", "cook_time", "perform_time", mode="before")
orm_mode = True def clean_strings(val: Any):
if val is None:
return val
if isinstance(val, Number):
return str(val)
return val
class RecipePagination(PaginationBase): class RecipePagination(PaginationBase):
@ -119,9 +125,9 @@ class RecipePagination(PaginationBase):
class Recipe(RecipeSummary): class Recipe(RecipeSummary):
recipe_ingredient: list[RecipeIngredient] = [] recipe_ingredient: Annotated[list[RecipeIngredient], Field(validate_default=True)] = []
recipe_instructions: list[RecipeStep] | None = [] recipe_instructions: list[RecipeStep] | None = []
nutrition: Nutrition | None nutrition: Nutrition | None = None
# Mealie Specific # Mealie Specific
settings: RecipeSettings | None = None settings: RecipeSettings | None = None
@ -175,21 +181,10 @@ class Recipe(RecipeSummary):
return self.image_dir_from_id(self.id) return self.image_dir_from_id(self.id)
class Config: model_config = ConfigDict(from_attributes=True)
orm_mode = True
getter_dict = ExtrasGetterDict
@classmethod @model_validator(mode="after")
def from_orm(cls, obj): def post_validate(self):
recipe = super().from_orm(obj)
recipe.__post_init__()
return recipe
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.__post_init__()
def __post_init__(self) -> None:
# the ingredient disable_amount property is unreliable, # the ingredient disable_amount property is unreliable,
# so we set it here and recalculate the display property # so we set it here and recalculate the display property
disable_amount = self.settings.disable_amount if self.settings else True disable_amount = self.settings.disable_amount if self.settings else True
@ -198,15 +193,17 @@ class Recipe(RecipeSummary):
ingredient.is_food = not ingredient.disable_amount ingredient.is_food = not ingredient.disable_amount
ingredient.display = ingredient._format_display() ingredient.display = ingredient._format_display()
@validator("slug", always=True, pre=True, allow_reuse=True) return self
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 slug
return slugify(values["name"]) return slugify(info.data["name"])
@validator("recipe_ingredient", always=True, pre=True, allow_reuse=True) @field_validator("recipe_ingredient", mode="before")
def validate_ingredients(recipe_ingredient, values): def validate_ingredients(recipe_ingredient):
if not recipe_ingredient or not isinstance(recipe_ingredient, list): if not recipe_ingredient or not isinstance(recipe_ingredient, list):
return recipe_ingredient return recipe_ingredient
@ -215,30 +212,37 @@ class Recipe(RecipeSummary):
return recipe_ingredient return recipe_ingredient
@validator("tags", always=True, pre=True, allow_reuse=True) @field_validator("tags", mode="before")
def validate_tags(cats: list[Any]): # type: ignore def validate_tags(cats: list[Any]):
if isinstance(cats, list) and cats and isinstance(cats[0], str): 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 [RecipeTag(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
return cats return cats
@validator("recipe_category", always=True, pre=True, allow_reuse=True) @field_validator("recipe_category", mode="before")
def validate_categories(cats: list[Any]): # type: ignore def validate_categories(cats: list[Any]):
if isinstance(cats, list) and cats and isinstance(cats[0], str): 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 [RecipeCategory(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
return 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): def validate_group_id(group_id: Any):
if isinstance(group_id, int): if isinstance(group_id, int):
return uuid4() return uuid4()
return group_id 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): def validate_user_id(user_id: Any):
if isinstance(user_id, int): if isinstance(user_id, int):
return uuid4() return uuid4()
return user_id 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 @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
return [ return [
@ -332,5 +336,5 @@ class RecipeLastMade(BaseModel):
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402 from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402
RecipeSummary.update_forward_refs() RecipeSummary.model_rebuild()
Recipe.update_forward_refs() Recipe.model_rebuild()

View File

@ -1,10 +1,10 @@
from pydantic import ConfigDict
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
class RecipeAsset(MealieModel): class RecipeAsset(MealieModel):
name: str name: str
icon: str icon: str
file_name: str | None file_name: str | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -1,4 +1,4 @@
from pydantic import UUID4 from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -17,24 +17,18 @@ class CategorySave(CategoryIn):
class CategoryBase(CategoryIn): class CategoryBase(CategoryIn):
id: UUID4 id: UUID4
slug: str slug: str
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class CategoryOut(CategoryBase): class CategoryOut(CategoryBase):
slug: str slug: str
group_id: UUID4 group_id: UUID4
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class RecipeCategoryResponse(CategoryBase): class RecipeCategoryResponse(CategoryBase):
recipes: "list[RecipeSummary]" = [] recipes: "list[RecipeSummary]" = []
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class TagIn(CategoryIn): class TagIn(CategoryIn):
@ -52,9 +46,7 @@ class TagBase(CategoryBase):
class TagOut(TagSave): class TagOut(TagSave):
id: UUID4 id: UUID4
slug: str slug: str
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class RecipeTagResponse(RecipeCategoryResponse): class RecipeTagResponse(RecipeCategoryResponse):
@ -69,5 +61,5 @@ class RecipeTagResponse(RecipeCategoryResponse):
from mealie.schema.recipe.recipe import RecipeSummary # noqa: E402 from mealie.schema.recipe.recipe import RecipeSummary # noqa: E402
RecipeCategoryResponse.update_forward_refs() RecipeCategoryResponse.model_rebuild()
RecipeTagResponse.update_forward_refs() RecipeTagResponse.model_rebuild()

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from pydantic import UUID4 from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -11,11 +11,9 @@ from mealie.schema.response.pagination import PaginationBase
class UserBase(MealieModel): class UserBase(MealieModel):
id: UUID4 id: UUID4
username: str | None username: str | None = None
admin: bool admin: bool
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class RecipeCommentCreate(MealieModel): class RecipeCommentCreate(MealieModel):
@ -39,9 +37,7 @@ class RecipeCommentOut(RecipeCommentCreate):
update_at: datetime update_at: datetime
user_id: UUID4 user_id: UUID4
user: UserBase user: UserBase
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:

View File

@ -6,14 +6,13 @@ from fractions import Fraction
from typing import ClassVar from typing import ClassVar
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import UUID4, Field, validator from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models.recipe import IngredientFoodModel from mealie.db.models.recipe import IngredientFoodModel
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.types import NoneFloat from mealie.schema._mealie.types import NoneFloat
from mealie.schema.getter_dict import ExtrasGetterDict
from mealie.schema.response.pagination import PaginationBase from mealie.schema.response.pagination import PaginationBase
INGREDIENT_QTY_PRECISION = 3 INGREDIENT_QTY_PRECISION = 3
@ -32,19 +31,35 @@ def display_fraction(fraction: Fraction):
class UnitFoodBase(MealieModel): class UnitFoodBase(MealieModel):
id: UUID4 | None = None
name: str name: str
plural_name: str | None = None plural_name: str | None = None
description: str = "" description: str = ""
extras: dict | None = {} 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):
return v
return {x.key_name: x.value for x in v} if v else {}
class CreateIngredientFoodAlias(MealieModel): class CreateIngredientFoodAlias(MealieModel):
name: str name: str
class IngredientFoodAlias(CreateIngredientFoodAlias): class IngredientFoodAlias(CreateIngredientFoodAlias):
class Config: model_config = ConfigDict(from_attributes=True)
orm_mode = True
class CreateIngredientFood(UnitFoodBase): class CreateIngredientFood(UnitFoodBase):
@ -61,15 +76,12 @@ class IngredientFood(CreateIngredientFood):
label: MultiPurposeLabelSummary | None = None label: MultiPurposeLabelSummary | None = None
aliases: list[IngredientFoodAlias] = [] aliases: list[IngredientFoodAlias] = []
created_at: datetime.datetime | None created_at: datetime.datetime | None = None
update_at: datetime.datetime | None update_at: datetime.datetime | None = None
_searchable_properties: ClassVar[list[str]] = ["name_normalized", "plural_name_normalized"] _searchable_properties: ClassVar[list[str]] = ["name_normalized", "plural_name_normalized"]
_normalize_search: ClassVar[bool] = True _normalize_search: ClassVar[bool] = True
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
getter_dict = ExtrasGetterDict
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
@ -85,8 +97,7 @@ class CreateIngredientUnitAlias(MealieModel):
class IngredientUnitAlias(CreateIngredientUnitAlias): class IngredientUnitAlias(CreateIngredientUnitAlias):
class Config: model_config = ConfigDict(from_attributes=True)
orm_mode = True
class CreateIngredientUnit(UnitFoodBase): class CreateIngredientUnit(UnitFoodBase):
@ -105,8 +116,8 @@ class IngredientUnit(CreateIngredientUnit):
id: UUID4 id: UUID4
aliases: list[IngredientUnitAlias] = [] aliases: list[IngredientUnitAlias] = []
created_at: datetime.datetime | None created_at: datetime.datetime | None = None
update_at: datetime.datetime | None update_at: datetime.datetime | None = None
_searchable_properties: ClassVar[list[str]] = [ _searchable_properties: ClassVar[list[str]] = [
"name_normalized", "name_normalized",
@ -115,15 +126,13 @@ class IngredientUnit(CreateIngredientUnit):
"plural_abbreviation_normalized", "plural_abbreviation_normalized",
] ]
_normalize_search: ClassVar[bool] = True _normalize_search: ClassVar[bool] = True
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class RecipeIngredientBase(MealieModel): class RecipeIngredientBase(MealieModel):
quantity: NoneFloat = 1 quantity: NoneFloat = 1
unit: IngredientUnit | CreateIngredientUnit | None unit: IngredientUnit | CreateIngredientUnit | None = None
food: IngredientFood | CreateIngredientFood | None food: IngredientFood | CreateIngredientFood | None = None
note: str | None = "" note: str | None = ""
is_food: bool | None = None is_food: bool | None = None
@ -135,9 +144,8 @@ class RecipeIngredientBase(MealieModel):
Automatically calculated after the object is created, unless overwritten Automatically calculated after the object is created, unless overwritten
""" """
def __init__(self, **kwargs) -> None: @model_validator(mode="after")
super().__init__(**kwargs) def post_validate(self):
# calculate missing is_food and disable_amount values # calculate missing is_food and disable_amount values
# we can't do this in a validator since they depend on each other # 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: if self.is_food is None and self.disable_amount is not None:
@ -152,14 +160,18 @@ class RecipeIngredientBase(MealieModel):
if not self.display: if not self.display:
self.display = self._format_display() self.display = self._format_display()
@validator("unit", pre=True) return self
@field_validator("unit", mode="before")
@classmethod
def validate_unit(cls, v): def validate_unit(cls, v):
if isinstance(v, str): if isinstance(v, str):
return CreateIngredientUnit(name=v) return CreateIngredientUnit(name=v)
else: else:
return v return v
@validator("food", pre=True) @field_validator("food", mode="before")
@classmethod
def validate_food(cls, v): def validate_food(cls, v):
if isinstance(v, str): if isinstance(v, str):
return CreateIngredientFood(name=v) return CreateIngredientFood(name=v)
@ -260,19 +272,18 @@ class IngredientUnitPagination(PaginationBase):
class RecipeIngredient(RecipeIngredientBase): class RecipeIngredient(RecipeIngredientBase):
title: str | None title: str | None = None
original_text: str | None original_text: str | None = None
disable_amount: bool = True disable_amount: bool = True
# Ref is used as a way to distinguish between an individual ingredient on the frontend # 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 # 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. # Vue handles reactivity. ref may serve another purpose in the future.
reference_id: UUID = Field(default_factory=uuid4) reference_id: UUID = Field(default_factory=uuid4)
model_config = ConfigDict(from_attributes=True)
class Config: @field_validator("quantity", mode="before")
orm_mode = True @classmethod
@validator("quantity", pre=True)
def validate_quantity(cls, value) -> NoneFloat: def validate_quantity(cls, value) -> NoneFloat:
""" """
Sometimes the frontend UI will provide an empty string as a "null" value because of the default Sometimes the frontend UI will provide an empty string as a "null" value because of the default
@ -294,7 +305,7 @@ class IngredientConfidence(MealieModel):
quantity: NoneFloat = None quantity: NoneFloat = None
food: NoneFloat = None food: NoneFloat = None
@validator("quantity", pre=True) @field_validator("quantity", mode="before")
@classmethod @classmethod
def validate_quantity(cls, value, values) -> NoneFloat: def validate_quantity(cls, value, values) -> NoneFloat:
if isinstance(value, float): if isinstance(value, float):
@ -305,7 +316,7 @@ class IngredientConfidence(MealieModel):
class ParsedIngredient(MealieModel): class ParsedIngredient(MealieModel):
input: str | None input: str | None = None
confidence: IngredientConfidence = IngredientConfidence() confidence: IngredientConfidence = IngredientConfidence()
ingredient: RecipeIngredient ingredient: RecipeIngredient
@ -337,4 +348,4 @@ class MergeUnit(MealieModel):
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary # noqa: E402 from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary # noqa: E402
IngredientFood.update_forward_refs() IngredientFood.model_rebuild()

View File

@ -1,9 +1,7 @@
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
class RecipeNote(BaseModel): class RecipeNote(BaseModel):
title: str title: str
text: str text: str
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -1,14 +1,14 @@
from pydantic import ConfigDict
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
class Nutrition(MealieModel): class Nutrition(MealieModel):
calories: str | None calories: str | None = None
fat_content: str | None fat_content: str | None = None
protein_content: str | None protein_content: str | None = None
carbohydrate_content: str | None carbohydrate_content: str | None = None
fiber_content: str | None fiber_content: str | None = None
sodium_content: str | None sodium_content: str | None = None
sugar_content: str | None sugar_content: str | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -1,3 +1,5 @@
from pydantic import ConfigDict
from mealie.schema._mealie.mealie_model import MealieModel from mealie.schema._mealie.mealie_model import MealieModel
@ -8,11 +10,11 @@ class ScrapeRecipeTest(MealieModel):
class ScrapeRecipe(MealieModel): class ScrapeRecipe(MealieModel):
url: str url: str
include_tags: bool = False include_tags: bool = False
model_config = ConfigDict(
class Config: json_schema_extra={
schema_extra = {
"example": { "example": {
"url": "https://myfavoriterecipes.com/recipes", "url": "https://myfavoriterecipes.com/recipes",
"includeTags": True, "includeTags": True,
}, },
} }
)

View File

@ -1,3 +1,5 @@
from pydantic import ConfigDict
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
@ -9,6 +11,4 @@ class RecipeSettings(MealieModel):
disable_comments: bool = True disable_comments: bool = True
disable_amount: bool = True disable_amount: bool = True
locked: bool = False locked: bool = False
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -1,6 +1,6 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pydantic import UUID4, Field from pydantic import UUID4, ConfigDict, Field
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -26,16 +26,12 @@ class RecipeShareTokenSave(RecipeShareTokenCreate):
class RecipeShareTokenSummary(RecipeShareTokenSave): class RecipeShareTokenSummary(RecipeShareTokenSave):
id: UUID4 id: UUID4
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class RecipeShareToken(RecipeShareTokenSummary): class RecipeShareToken(RecipeShareTokenSummary):
recipe: Recipe recipe: Recipe
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:

View File

@ -1,6 +1,6 @@
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import UUID4, Field from pydantic import UUID4, ConfigDict, Field
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
@ -10,10 +10,8 @@ class IngredientReferences(MealieModel):
A list of ingredient references. A list of ingredient references.
""" """
reference_id: UUID4 | None reference_id: UUID4 | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class RecipeStep(MealieModel): class RecipeStep(MealieModel):
@ -21,6 +19,4 @@ class RecipeStep(MealieModel):
title: str | None = "" title: str | None = ""
text: str text: str
ingredient_references: list[IngredientReferences] = [] ingredient_references: list[IngredientReferences] = []
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -1,8 +1,9 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from pathlib import Path 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.core.config import get_app_dirs
from mealie.schema._mealie.mealie_model import MealieModel from mealie.schema._mealie.mealie_model import MealieModel
@ -32,12 +33,10 @@ class RecipeTimelineEventIn(MealieModel):
event_type: TimelineEventType event_type: TimelineEventType
message: str | None = Field(None, alias="eventMessage") 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() timestamp: datetime = datetime.now()
model_config = ConfigDict(use_enum_values=True)
class Config:
use_enum_values = True
class RecipeTimelineEventCreate(RecipeTimelineEventIn): class RecipeTimelineEventCreate(RecipeTimelineEventIn):
@ -46,20 +45,16 @@ class RecipeTimelineEventCreate(RecipeTimelineEventIn):
class RecipeTimelineEventUpdate(MealieModel): class RecipeTimelineEventUpdate(MealieModel):
subject: str subject: str
message: str | None = Field(alias="eventMessage") message: str | None = Field(None, alias="eventMessage")
image: TimelineEventImage | None = None image: TimelineEventImage | None = None
model_config = ConfigDict(use_enum_values=True)
class Config:
use_enum_values = True
class RecipeTimelineEventOut(RecipeTimelineEventCreate): class RecipeTimelineEventOut(RecipeTimelineEventCreate):
id: UUID4 id: UUID4
created_at: datetime created_at: datetime
update_at: datetime update_at: datetime
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def image_dir_from_id(cls, recipe_id: UUID4 | str, timeline_event_id: UUID4 | str) -> Path: def image_dir_from_id(cls, recipe_id: UUID4 | str, timeline_event_id: UUID4 | str) -> Path:

View File

@ -1,4 +1,4 @@
from pydantic import UUID4 from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -19,16 +19,12 @@ class RecipeToolSave(RecipeToolCreate):
class RecipeToolOut(RecipeToolCreate): class RecipeToolOut(RecipeToolCreate):
id: UUID4 id: UUID4
slug: str slug: str
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class RecipeToolResponse(RecipeToolOut): class RecipeToolResponse(RecipeToolOut):
recipes: list["RecipeSummary"] = [] recipes: list["RecipeSummary"] = []
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
@ -41,4 +37,4 @@ class RecipeToolResponse(RecipeToolOut):
from .recipe import RecipeSummary # noqa: E402 from .recipe import RecipeSummary # noqa: E402
RecipeToolResponse.update_forward_refs() RecipeToolResponse.model_rebuild()

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
@ -10,8 +10,7 @@ class RecipeSlug(MealieModel):
class SlugResponse(BaseModel): class SlugResponse(BaseModel):
class Config: model_config = ConfigDict(json_schema_extra={"example": "adult-mac-and-cheese"})
schema_extra = {"example": "adult-mac-and-cheese"}
class UpdateImageResponse(BaseModel): class UpdateImageResponse(BaseModel):
@ -23,4 +22,4 @@ class RecipeZipTokenResponse(BaseModel):
class RecipeDuplicate(BaseModel): class RecipeDuplicate(BaseModel):
name: str | None name: str | None = None

View File

@ -1,7 +1,7 @@
import datetime import datetime
import enum import enum
from pydantic import Field from pydantic import ConfigDict, Field
from pydantic.types import UUID4 from pydantic.types import UUID4
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -34,9 +34,7 @@ class ReportEntryCreate(MealieModel):
class ReportEntryOut(ReportEntryCreate): class ReportEntryOut(ReportEntryCreate):
id: UUID4 id: UUID4
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class ReportCreate(MealieModel): class ReportCreate(MealieModel):
@ -53,9 +51,7 @@ class ReportSummary(ReportCreate):
class ReportOut(ReportSummary): class ReportOut(ReportSummary):
entries: list[ReportEntryOut] = [] entries: list[ReportEntryOut] = []
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:

View File

@ -1,10 +1,10 @@
import enum 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 urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
from humps import camelize from humps import camelize
from pydantic import UUID4, BaseModel, validator from pydantic import UUID4, BaseModel, Field, field_validator
from pydantic.generics import GenericModel from pydantic_core.core_schema import ValidationInfo
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
@ -22,12 +22,12 @@ class OrderByNullPosition(str, enum.Enum):
class RecipeSearchQuery(MealieModel): class RecipeSearchQuery(MealieModel):
cookbook: UUID4 | str | None cookbook: UUID4 | str | None = None
require_all_categories: bool = False require_all_categories: bool = False
require_all_tags: bool = False require_all_tags: bool = False
require_all_tools: bool = False require_all_tools: bool = False
require_all_foods: bool = False require_all_foods: bool = False
search: str | None search: str | None = None
_search_seed: str | None = None _search_seed: str | None = None
@ -38,23 +38,23 @@ class PaginationQuery(MealieModel):
order_by_null_position: OrderByNullPosition | None = None order_by_null_position: OrderByNullPosition | None = None
order_direction: OrderDirection = OrderDirection.desc order_direction: OrderDirection = OrderDirection.desc
query_filter: str | None = None 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) @field_validator("pagination_seed", mode="before")
def validate_randseed(cls, pagination_seed, values): def validate_randseed(cls, pagination_seed, info: ValidationInfo):
if values.get("order_by") == "random" and not pagination_seed: if info.data.get("order_by") == "random" and not pagination_seed:
raise ValueError("paginationSeed is required when orderBy is random") raise ValueError("paginationSeed is required when orderBy is random")
return pagination_seed return pagination_seed
class PaginationBase(GenericModel, Generic[DataT]): class PaginationBase(BaseModel, Generic[DataT]):
page: int = 1 page: int = 1
per_page: int = 10 per_page: int = 10
total: int = 0 total: int = 0
total_pages: int = 0 total_pages: int = 0
items: list[DataT] items: list[DataT]
next: str | None next: str | None = None
previous: str | None previous: str | None = None
def _set_next(self, route: str, query_params: dict[str, Any]) -> None: def _set_next(self, route: str, query_params: dict[str, Any]) -> None:
if self.page >= self.total_pages: if self.page >= self.total_pages:

View File

@ -14,7 +14,7 @@ class ErrorResponse(BaseModel):
This method is an helper to create an object and convert to a dictionary 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 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): class SuccessResponse(BaseModel):
@ -27,7 +27,7 @@ class SuccessResponse(BaseModel):
This method is an helper to create an object and convert to a dictionary 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 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): class FileTokenResponse(MealieModel):
@ -39,4 +39,4 @@ class FileTokenResponse(MealieModel):
This method is an helper to create an object and convert to a dictionary 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 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()

View File

@ -2,7 +2,7 @@ import datetime
import enum import enum
from uuid import UUID from uuid import UUID
from pydantic import Field from pydantic import ConfigDict, Field
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase from mealie.schema.response.pagination import PaginationBase
@ -43,9 +43,7 @@ class ServerTaskCreate(MealieModel):
class ServerTask(ServerTaskCreate): class ServerTask(ServerTaskCreate):
id: int id: int
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class ServerTaskPagination(PaginationBase): class ServerTaskPagination(PaginationBase):

View File

@ -1,5 +1,6 @@
from pydantic import UUID4, BaseModel from typing import Annotated
from pydantic.types import constr
from pydantic import UUID4, BaseModel, StringConstraints
from mealie.schema._mealie.mealie_model import MealieModel from mealie.schema._mealie.mealie_model import MealieModel
@ -10,8 +11,8 @@ class Token(BaseModel):
class TokenData(BaseModel): class TokenData(BaseModel):
user_id: UUID4 | None user_id: UUID4 | None = None
username: constr(to_lower=True, strip_whitespace=True) | None = None # type: ignore username: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] | None = None # type: ignore
class UnlockResults(MealieModel): class UnlockResults(MealieModel):

View File

@ -1,15 +1,17 @@
from pydantic import validator from typing import Annotated
from pydantic.types import NoneStr, constr
from pydantic import Field, StringConstraints, field_validator
from pydantic_core.core_schema import ValidationInfo
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.validators import validate_locale from mealie.schema._mealie.validators import validate_locale
class CreateUserRegistration(MealieModel): class CreateUserRegistration(MealieModel):
group: NoneStr = None group: str | None = None
group_token: NoneStr = None group_token: Annotated[str | None, Field(validate_default=True)] = None
email: constr(to_lower=True, strip_whitespace=True) # type: ignore email: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] # type: ignore
username: constr(to_lower=True, strip_whitespace=True) # type: ignore username: Annotated[str, StringConstraints(to_lower=True, strip_whitespace=True)] # type: ignore
password: str password: str
password_confirm: str password_confirm: str
advanced: bool = False advanced: bool = False
@ -18,23 +20,23 @@ class CreateUserRegistration(MealieModel):
seed_data: bool = False seed_data: bool = False
locale: str = "en-US" locale: str = "en-US"
@validator("locale") @field_validator("locale")
def valid_locale(cls, v, values, **kwargs): def valid_locale(cls, v):
if not validate_locale(v): if not validate_locale(v):
raise ValueError("invalid locale") raise ValueError("invalid locale")
return v return v
@validator("password_confirm") @field_validator("password_confirm")
@classmethod @classmethod
def passwords_match(cls, value, values): def passwords_match(cls, value, info: ValidationInfo):
if "password" in values and value != values["password"]: if "password" in info.data and value != info.data["password"]:
raise ValueError("passwords do not match") raise ValueError("passwords do not match")
return value return value
@validator("group_token", always=True) @field_validator("group_token")
@classmethod @classmethod
def group_or_token(cls, value, values): def group_or_token(cls, value, info: ValidationInfo):
if not bool(value) and not bool(values["group"]): if not bool(value) and not bool(info.data["group"]):
raise ValueError("group or group_token must be provided") raise ValueError("group or group_token must be provided")
return value return value

View File

@ -1,10 +1,9 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Annotated, Any
from uuid import UUID from uuid import UUID
from pydantic import UUID4, Field, validator from pydantic import UUID4, ConfigDict, Field, StringConstraints, field_validator
from pydantic.types import constr
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption 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.group import Group
from ...db.models.recipe import RecipeModel from ...db.models.recipe import RecipeModel
from ..getter_dict import GroupGetterDict, UserGetterDict
from ..recipe import CategoryBase from ..recipe import CategoryBase
DEFAULT_INTEGRATION_ID = "generic" DEFAULT_INTEGRATION_ID = "generic"
@ -34,25 +32,19 @@ class LongLiveTokenOut(MealieModel):
token: str token: str
name: str name: str
id: int id: int
created_at: datetime | None created_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class CreateToken(LongLiveTokenIn): class CreateToken(LongLiveTokenIn):
user_id: UUID4 user_id: UUID4
token: str token: str
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class DeleteTokenResponse(MealieModel): class DeleteTokenResponse(MealieModel):
token_delete: str token_delete: str
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class ChangePassword(MealieModel): class ChangePassword(MealieModel):
@ -61,31 +53,27 @@ class ChangePassword(MealieModel):
class GroupBase(MealieModel): class GroupBase(MealieModel):
name: constr(strip_whitespace=True, min_length=1) # type: ignore name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] # type: ignore
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
class UserBase(MealieModel): class UserBase(MealieModel):
username: str | None id: UUID4 | None = None
username: str | None = None
full_name: 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 auth_method: AuthMethod = AuthMethod.MEALIE
admin: bool = False admin: bool = False
group: str | None group: str | None = None
advanced: bool = False advanced: bool = False
favorite_recipes: list[str] | None = [] favorite_recipes: list[str] | None = []
can_invite: bool = False can_invite: bool = False
can_manage: bool = False can_manage: bool = False
can_organize: bool = False can_organize: bool = False
model_config = ConfigDict(
class Config: from_attributes=True,
orm_mode = True json_schema_extra={
getter_dict = GroupGetterDict
schema_extra = {
"example": { "example": {
"username": "ChangeMe", "username": "ChangeMe",
"fullName": "Change Me", "fullName": "Change Me",
@ -93,7 +81,18 @@ class UserBase(MealieModel):
"group": settings.DEFAULT_GROUP, "group": settings.DEFAULT_GROUP,
"admin": "false", "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): class UserIn(UserBase):
@ -105,14 +104,10 @@ class UserOut(UserBase):
group: str group: str
group_id: UUID4 group_id: UUID4
group_slug: str group_slug: str
tokens: list[LongLiveTokenOut] | None tokens: list[LongLiveTokenOut] | None = None
cache_key: str cache_key: str
favorite_recipes: list[str] | None = [] favorite_recipes: Annotated[list[str] | None, Field(validate_default=True)] = []
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
getter_dict = UserGetterDict
@property @property
def is_default_user(self) -> bool: def is_default_user(self) -> bool:
@ -122,6 +117,10 @@ class UserOut(UserBase):
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
return [joinedload(User.group), joinedload(User.favorite_recipes), joinedload(User.tokens)] 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): class UserPagination(PaginationBase):
items: list[UserOut] items: list[UserOut]
@ -129,10 +128,7 @@ class UserPagination(PaginationBase):
class UserFavorites(UserBase): class UserFavorites(UserBase):
favorite_recipes: list[RecipeSummary] = [] # type: ignore favorite_recipes: list[RecipeSummary] = [] # type: ignore
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
getter_dict = GroupGetterDict
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:
@ -149,11 +145,10 @@ class PrivateUser(UserOut):
group_id: UUID4 group_id: UUID4
login_attemps: int = 0 login_attemps: int = 0
locked_at: datetime | None = None locked_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class Config: @field_validator("login_attemps", mode="before")
orm_mode = True @classmethod
@validator("login_attemps", pre=True)
def none_to_zero(cls, v): def none_to_zero(cls, v):
return 0 if v is None else v return 0 if v is None else v
@ -189,11 +184,9 @@ class UpdateGroup(GroupBase):
class GroupInDB(UpdateGroup): class GroupInDB(UpdateGroup):
users: list[UserOut] | None users: list[UserOut] | None = None
preferences: ReadGroupPreferences | None = None preferences: ReadGroupPreferences | None = None
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@staticmethod @staticmethod
def get_directory(id: UUID4) -> Path: def get_directory(id: UUID4) -> Path:
@ -234,6 +227,4 @@ class GroupPagination(PaginationBase):
class LongLiveTokenInDB(CreateToken): class LongLiveTokenInDB(CreateToken):
id: int id: int
user: PrivateUser user: PrivateUser
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True

View File

@ -1,4 +1,4 @@
from pydantic import UUID4 from pydantic import UUID4, ConfigDict
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.orm.interfaces import LoaderOption from sqlalchemy.orm.interfaces import LoaderOption
@ -33,9 +33,7 @@ class SavePasswordResetToken(MealieModel):
class PrivatePasswordResetToken(SavePasswordResetToken): class PrivatePasswordResetToken(SavePasswordResetToken):
user: PrivateUser user: PrivateUser
model_config = ConfigDict(from_attributes=True)
class Config:
orm_mode = True
@classmethod @classmethod
def loader_options(cls) -> list[LoaderOption]: def loader_options(cls) -> list[LoaderOption]:

View File

@ -43,7 +43,7 @@ class BackupContents:
class BackupFile: class BackupFile:
temp_dir: Path | None temp_dir: Path | None = None
def __init__(self, file: Path) -> None: def __init__(self, file: Path) -> None:
self.zip = file self.zip = file

View File

@ -24,7 +24,7 @@ class EmailTemplate(BaseModel):
def render_html(self, template: Path) -> str: def render_html(self, template: Path) -> str:
tmpl = Template(template.read_text()) tmpl = Template(template.read_text())
return tmpl.render(data=self.dict()) return tmpl.render(data=self.model_dump())
class EmailService(BaseService): class EmailService(BaseService):

View File

@ -23,8 +23,8 @@ from .publisher import ApprisePublisher, PublisherLike, WebhookPublisher
class EventListenerBase(ABC): class EventListenerBase(ABC):
_session: Session | None _session: Session | None = None
_repos: AllRepositories | None _repos: AllRepositories | None = None
def __init__(self, group_id: UUID4, publisher: PublisherLike) -> None: def __init__(self, group_id: UUID4, publisher: PublisherLike) -> None:
self.group_id = group_id self.group_id = group_id

View File

@ -38,9 +38,9 @@ class EventSource:
class EventBusService: class EventBusService:
bg: BackgroundTasks | None bg: BackgroundTasks | None = None
session: Session | None session: Session | None = None
group_id: UUID4 | None group_id: UUID4 | None = None
def __init__( def __init__(
self, bg: BackgroundTasks | None = None, session: Session | None = None, group_id: UUID4 | None = None self, bg: BackgroundTasks | None = None, session: Session | None = None, group_id: UUID4 | None = None

View File

@ -3,7 +3,7 @@ from datetime import date, datetime
from enum import Enum, auto from enum import Enum, auto
from typing import Any from typing import Any
from pydantic import UUID4 from pydantic import UUID4, field_validator
from ...schema._mealie.mealie_model import MealieModel from ...schema._mealie.mealie_model import MealieModel
@ -85,79 +85,79 @@ class EventDocumentDataBase(MealieModel):
class EventMealplanCreatedData(EventDocumentDataBase): class EventMealplanCreatedData(EventDocumentDataBase):
document_type = EventDocumentType.mealplan document_type: EventDocumentType = EventDocumentType.mealplan
operation = EventOperation.create operation: EventOperation = EventOperation.create
mealplan_id: int mealplan_id: int
date: date date: date
recipe_id: UUID4 | None recipe_id: UUID4 | None = None
recipe_name: str | None recipe_name: str | None = None
recipe_slug: str | None recipe_slug: str | None = None
class EventUserSignupData(EventDocumentDataBase): class EventUserSignupData(EventDocumentDataBase):
document_type = EventDocumentType.user document_type: EventDocumentType = EventDocumentType.user
operation = EventOperation.create operation: EventOperation = EventOperation.create
username: str username: str
email: str email: str
class EventCategoryData(EventDocumentDataBase): class EventCategoryData(EventDocumentDataBase):
document_type = EventDocumentType.category document_type: EventDocumentType = EventDocumentType.category
category_id: UUID4 category_id: UUID4
class EventCookbookData(EventDocumentDataBase): class EventCookbookData(EventDocumentDataBase):
document_type = EventDocumentType.cookbook document_type: EventDocumentType = EventDocumentType.cookbook
cookbook_id: UUID4 cookbook_id: UUID4
class EventCookbookBulkData(EventDocumentDataBase): class EventCookbookBulkData(EventDocumentDataBase):
document_type = EventDocumentType.cookbook document_type: EventDocumentType = EventDocumentType.cookbook
cookbook_ids: list[UUID4] cookbook_ids: list[UUID4]
class EventShoppingListData(EventDocumentDataBase): class EventShoppingListData(EventDocumentDataBase):
document_type = EventDocumentType.shopping_list document_type: EventDocumentType = EventDocumentType.shopping_list
shopping_list_id: UUID4 shopping_list_id: UUID4
class EventShoppingListItemData(EventDocumentDataBase): class EventShoppingListItemData(EventDocumentDataBase):
document_type = EventDocumentType.shopping_list_item document_type: EventDocumentType = EventDocumentType.shopping_list_item
shopping_list_id: UUID4 shopping_list_id: UUID4
shopping_list_item_id: UUID4 shopping_list_item_id: UUID4
class EventShoppingListItemBulkData(EventDocumentDataBase): class EventShoppingListItemBulkData(EventDocumentDataBase):
document_type = EventDocumentType.shopping_list_item document_type: EventDocumentType = EventDocumentType.shopping_list_item
shopping_list_id: UUID4 shopping_list_id: UUID4
shopping_list_item_ids: list[UUID4] shopping_list_item_ids: list[UUID4]
class EventRecipeData(EventDocumentDataBase): class EventRecipeData(EventDocumentDataBase):
document_type = EventDocumentType.recipe document_type: EventDocumentType = EventDocumentType.recipe
recipe_slug: str recipe_slug: str
class EventRecipeBulkReportData(EventDocumentDataBase): class EventRecipeBulkReportData(EventDocumentDataBase):
document_type = EventDocumentType.recipe_bulk_report document_type: EventDocumentType = EventDocumentType.recipe_bulk_report
report_id: UUID4 report_id: UUID4
class EventRecipeTimelineEventData(EventDocumentDataBase): class EventRecipeTimelineEventData(EventDocumentDataBase):
document_type = EventDocumentType.recipe_timeline_event document_type: EventDocumentType = EventDocumentType.recipe_timeline_event
recipe_slug: str recipe_slug: str
recipe_timeline_event_id: UUID4 recipe_timeline_event_id: UUID4
class EventTagData(EventDocumentDataBase): class EventTagData(EventDocumentDataBase):
document_type = EventDocumentType.tag document_type: EventDocumentType = EventDocumentType.tag
tag_id: UUID4 tag_id: UUID4
class EventWebhookData(EventDocumentDataBase): class EventWebhookData(EventDocumentDataBase):
webhook_start_dt: datetime webhook_start_dt: datetime
webhook_end_dt: datetime webhook_end_dt: datetime
webhook_body: Any webhook_body: Any = None
class EventBusMessage(MealieModel): class EventBusMessage(MealieModel):
@ -169,6 +169,11 @@ class EventBusMessage(MealieModel):
title = event_type.name.replace("_", " ").title() title = event_type.name.replace("_", " ").title()
return cls(title=title, body=body) 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): class Event(MealieModel):
message: EventBusMessage message: EventBusMessage
@ -177,8 +182,8 @@ class Event(MealieModel):
document_data: EventDocumentDataBase document_data: EventDocumentDataBase
# set at instantiation # set at instantiation
event_id: UUID4 | None event_id: UUID4 | None = None
timestamp: datetime | None timestamp: datetime | None = None
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -27,7 +27,7 @@ class ExportedItem:
class ABCExporter(BaseService): 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: def __init__(self, db: AllRepositories, group_id: UUID) -> None:
self.logger = get_logger() self.logger = get_logger()
@ -63,7 +63,7 @@ class ABCExporter(BaseService):
self.logger.error("Failed to export item. no item found") self.logger.error("Failed to export item. no item found")
continue 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) self._post_export_hook(item.model)

View File

@ -18,7 +18,7 @@ from .utils.migration_helpers import MigrationReaders, glob_walker, import_image
class NextcloudDir: class NextcloudDir:
name: str name: str
recipe: dict recipe: dict
image: Path | None image: Path | None = None
@property @property
def slug(self): def slug(self):

View File

@ -45,7 +45,7 @@ class DatabaseMigrationHelpers:
) )
) )
items_out.append(item_model.dict()) items_out.append(item_model.model_dump())
return items_out return items_out
def get_or_set_category(self, categories: Iterable[str]) -> list[RecipeCategory]: def get_or_set_category(self, categories: Iterable[str]) -> list[RecipeCategory]:

Some files were not shown because too many files have changed in this diff Show More