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("------APP SETTINGS------")
logger.info(
settings.json(
settings.model_dump_json(
indent=4,
exclude={
"SECRET",

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,4 +28,4 @@ class RepositoryMealPlanRules(RepositoryGeneric[PlanRulesOut, GroupMealPlanRules
rules = self.session.execute(stmt).scalars().all()
return [self.schema.from_orm(x) for x in rules]
return [self.schema.model_validate(x) for x in rules]

View File

@ -17,4 +17,4 @@ class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]):
today = date.today()
stmt = select(GroupMealPlan).filter(GroupMealPlan.date == today, GroupMealPlan.group_id == group_id)
plans = self.session.execute(stmt).scalars().all()
return [self.schema.from_orm(x) for x in plans]
return [self.schema.model_validate(x) for x in plans]

View File

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

View File

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

View File

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

View File

@ -6,11 +6,10 @@ See their repository for details -> https://github.com/dmontagu/fastapi-utils
import inspect
from collections.abc import Callable
from typing import Any, TypeVar, cast, get_type_hints
from typing import Any, ClassVar, ForwardRef, TypeVar, cast, get_origin, get_type_hints
from fastapi import APIRouter, Depends
from fastapi.routing import APIRoute
from pydantic.typing import is_classvar
from starlette.routing import Route, WebSocketRoute
T = TypeVar("T")
@ -47,6 +46,25 @@ def _cbv(router: APIRouter, cls: type[T], *urls: str, instance: Any | None = Non
return cls
# copied from Pydantic V1 Source: https://github.com/pydantic/pydantic/blob/1c91c8627b541b22354b9ed56b9ef1bb21ac6fbd/pydantic/v1/typing.py
def _check_classvar(v: type[Any] | None) -> bool:
if v is None:
return False
return v.__class__ == ClassVar.__class__ and getattr(v, "_name", None) == "ClassVar"
# copied from Pydantic V1 Source: https://github.com/pydantic/pydantic/blob/1c91c8627b541b22354b9ed56b9ef1bb21ac6fbd/pydantic/v1/typing.py
def _is_classvar(ann_type: type[Any]) -> bool:
if _check_classvar(ann_type) or _check_classvar(get_origin(ann_type)):
return True
if ann_type.__class__ == ForwardRef and ann_type.__forward_arg__.startswith("ClassVar["): # type: ignore
return True
return False
def _init_cbv(cls: type[Any], instance: Any | None = None) -> None:
"""
Idempotently modifies the provided `cls`, performing the following modifications:
@ -67,7 +85,7 @@ def _init_cbv(cls: type[Any], instance: Any | None = None) -> None:
dependency_names: list[str] = []
for name, hint in get_type_hints(cls).items():
if is_classvar(hint):
if _is_classvar(hint):
continue
if name.startswith("_"):

View File

@ -108,7 +108,7 @@ class HttpRepo(Generic[C, R, U]):
)
try:
item = self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True))
item = self.repo.patch(item_id, data.model_dump(exclude_unset=True, exclude_defaults=True))
except Exception as ex:
self.handle_exception(ex)

View File

@ -43,7 +43,7 @@ class AdminUserManagementRoutes(BaseAdminController):
override=GroupInDB,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED)

View File

@ -37,7 +37,7 @@ class AdminUserManagementRoutes(BaseAdminController):
override=UserOut,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=UserOut, status_code=201)

View File

@ -18,7 +18,7 @@ class AdminServerTasksController(BaseAdminController):
override=ServerTask,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("/server-tasks", response_model=ServerTask, status_code=201)

View File

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

View File

@ -48,7 +48,7 @@ class MealieAuthToken(BaseModel):
@classmethod
def respond(cls, token: str, token_type: str = "bearer") -> dict:
return cls(access_token=token, token_type=token_type).dict()
return cls(access_token=token, token_type=token_type).model_dump()
@public_router.post("/token")

View File

@ -47,7 +47,7 @@ class RecipeCommentRoutes(BaseUserController):
override=RecipeCommentOut,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=RecipeCommentOut, status_code=201)

View File

@ -1,3 +1,5 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
@ -36,12 +38,19 @@ class PublicCookbooksController(BasePublicExploreController):
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump())
return response
@router.get("/{item_id}", response_model=RecipeCookBook)
def get_one(self, item_id: UUID4 | str) -> RecipeCookBook:
match_attr = "slug" if isinstance(item_id, str) else "id"
if isinstance(item_id, UUID):
match_attr = "id"
else:
try:
UUID(item_id)
match_attr = "id"
except ValueError:
match_attr = "slug"
cookbook = self.cookbooks.get_one(item_id, match_attr)
if not cookbook or not cookbook.public:

View File

@ -26,7 +26,7 @@ class PublicFoodsController(BasePublicExploreController):
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump())
return response
@router.get("/{item_id}", response_model=IngredientFood)

View File

@ -31,7 +31,9 @@ class PublicCategoriesController(BasePublicExploreController):
search=search,
)
response.set_pagination_guides(categories_router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
response.set_pagination_guides(
categories_router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump()
)
return response
@categories_router.get("/{item_id}", response_model=CategoryOut)
@ -59,7 +61,7 @@ class PublicTagsController(BasePublicExploreController):
search=search,
)
response.set_pagination_guides(tags_router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
response.set_pagination_guides(tags_router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump())
return response
@tags_router.get("/{item_id}", response_model=TagOut)
@ -87,7 +89,7 @@ class PublicToolsController(BasePublicExploreController):
search=search,
)
response.set_pagination_guides(tools_router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
response.set_pagination_guides(tools_router.url_path_for("get_all", group_slug=self.group.slug), q.model_dump())
return response
@tools_router.get("/{item_id}", response_model=RecipeToolOut)

View File

@ -1,3 +1,5 @@
from uuid import UUID
import orjson
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import UUID4
@ -37,7 +39,14 @@ class PublicRecipesController(BasePublicExploreController):
) -> PaginationBase[RecipeSummary]:
cookbook_data: ReadCookBook | None = None
if search_query.cookbook:
cb_match_attr = "slug" if isinstance(search_query.cookbook, str) else "id"
if isinstance(search_query.cookbook, UUID):
cb_match_attr = "id"
else:
try:
UUID(search_query.cookbook)
cb_match_attr = "id"
except ValueError:
cb_match_attr = "slug"
cookbook_data = self.cookbooks.get_one(search_query.cookbook, cb_match_attr)
if cookbook_data is None or not cookbook_data.public:
@ -64,13 +73,13 @@ class PublicRecipesController(BasePublicExploreController):
)
# merge default pagination with the request's query params
query_params = q.dict() | {**request.query_params}
query_params = q.model_dump() | {**request.query_params}
pagination_response.set_pagination_guides(
router.url_path_for("get_all", group_slug=self.group.slug),
{k: v for k, v in query_params.items() if v is not None},
)
json_compatible_response = orjson.dumps(pagination_response.dict(by_alias=True))
json_compatible_response = orjson.dumps(pagination_response.model_dump(by_alias=True))
# Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response)

View File

@ -1,4 +1,5 @@
from functools import cached_property
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
@ -48,7 +49,7 @@ class GroupCookbookController(BaseCrudController):
override=ReadCookBook,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=ReadCookBook, status_code=201)
@ -85,7 +86,15 @@ class GroupCookbookController(BaseCrudController):
@router.get("/{item_id}", response_model=RecipeCookBook)
def get_one(self, item_id: UUID4 | str):
match_attr = "slug" if isinstance(item_id, str) else "id"
if isinstance(item_id, UUID):
match_attr = "id"
else:
try:
UUID(item_id)
match_attr = "id"
except ValueError:
match_attr = "slug"
cookbook = self.repo.get_one(item_id, match_attr)
if cookbook is None:

View File

@ -58,7 +58,7 @@ class GroupEventsNotifierController(BaseUserController):
override=GroupEventNotifierOut,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=GroupEventNotifierOut, status_code=201)

View File

@ -48,7 +48,7 @@ class MultiPurposeLabelsController(BaseUserController):
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=MultiPurposeLabelOut)

View File

@ -31,7 +31,7 @@ class GroupMealplanConfigController(BaseUserController):
override=PlanRulesOut,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=PlanRulesOut, status_code=201)

View File

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

View File

@ -32,7 +32,7 @@ class ReadWebhookController(BaseUserController):
override=ReadWebhook,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=ReadWebhook, status_code=201)

View File

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

View File

@ -35,7 +35,7 @@ class TagController(BaseCrudController):
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.get("/empty")

View File

@ -32,7 +32,7 @@ class RecipeToolController(BaseUserController):
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=RecipeTool, status_code=201)

View File

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

View File

@ -49,7 +49,7 @@ class RecipeTimelineEventsController(BaseCrudController):
override=RecipeTimelineEventOut,
)
response.set_pagination_guides(events_router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(events_router.url_path_for("get_all"), q.model_dump())
return response
@events_router.post("", response_model=RecipeTimelineEventOut, status_code=201)

View File

@ -30,7 +30,7 @@ class RecipeSharedController(BaseUserController):
@router.post("", response_model=RecipeShareToken, status_code=201)
def create_one(self, data: RecipeShareTokenCreate) -> RecipeShareToken:
save_data = RecipeShareTokenSave(**data.dict(), group_id=self.group_id)
save_data = RecipeShareTokenSave(**data.model_dump(), group_id=self.group_id)
return self.mixins.create_one(save_data)
@router.get("/{item_id}", response_model=RecipeShareToken)

View File

@ -52,7 +52,7 @@ class IngredientFoodsController(BaseUserController):
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=IngredientFood, status_code=201)

View File

@ -52,7 +52,7 @@ class IngredientUnitsController(BaseUserController):
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@router.post("", response_model=IngredientUnit, status_code=201)

View File

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

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.
"""
for field in source.__fields__:
if field in dest.__fields__:
for field in source.model_fields:
if field in dest.model_fields:
setattr(dest, field, getattr(source, field))
return dest
def cast(source: U, dest: type[T], **kwargs) -> T:
create_data = {field: getattr(source, field) for field in source.__fields__ if field in dest.__fields__}
create_data = {field: getattr(source, field) for field in source.model_fields if field in dest.model_fields}
create_data.update(kwargs or {})
return dest(**create_data)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ class ErrorResponse(BaseModel):
This method is an helper to create an object and convert to a dictionary
in the same call, for use while providing details to a HTTPException
"""
return cls(message=message, exception=exception).dict()
return cls(message=message, exception=exception).model_dump()
class SuccessResponse(BaseModel):
@ -27,7 +27,7 @@ class SuccessResponse(BaseModel):
This method is an helper to create an object and convert to a dictionary
in the same call, for use while providing details to a HTTPException
"""
return cls(message=message).dict()
return cls(message=message).model_dump()
class FileTokenResponse(MealieModel):
@ -39,4 +39,4 @@ class FileTokenResponse(MealieModel):
This method is an helper to create an object and convert to a dictionary
in the same call, for use while providing details to a HTTPException
"""
return cls(file_token=token).dict()
return cls(file_token=token).model_dump()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ class ExportedItem:
class ABCExporter(BaseService):
write_dir_to_zip: Callable[[Path, str, set[str] | None], None] | None
write_dir_to_zip: Callable[[Path, str, set[str] | None], None] | None = None
def __init__(self, db: AllRepositories, group_id: UUID) -> None:
self.logger = get_logger()
@ -63,7 +63,7 @@ class ABCExporter(BaseService):
self.logger.error("Failed to export item. no item found")
continue
zip.writestr(f"{self.destination_dir}/{item.name}/{item.name}.json", item.model.json())
zip.writestr(f"{self.destination_dir}/{item.name}/{item.name}.json", item.model.model_dump_json())
self._post_export_hook(item.model)

View File

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

View File

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

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