mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-26 12:15:19 -05:00
feat: Migrate from Tandoor (#2438)
* added tandoor migration to backend * added tandoor migration to frontend * updated tests * ignore 0 amounts * refactored ingredient display calculation * fix parsing tandoor recipes with optional data * generated frontend types * fixed inconsistent default handling and from_orm * removed unused imports
This commit is contained in:
@@ -14,11 +14,7 @@ from .group_events import (
|
||||
from .group_exports import GroupDataExport
|
||||
from .group_migration import DataMigrationCreate, SupportedMigrations
|
||||
from .group_permissions import SetPermissions
|
||||
from .group_preferences import (
|
||||
CreateGroupPreferences,
|
||||
ReadGroupPreferences,
|
||||
UpdateGroupPreferences,
|
||||
)
|
||||
from .group_preferences import CreateGroupPreferences, ReadGroupPreferences, UpdateGroupPreferences
|
||||
from .group_seeder import SeederConfig
|
||||
from .group_shopping_list import (
|
||||
ShoppingListAddRecipeParams,
|
||||
@@ -26,6 +22,7 @@ from .group_shopping_list import (
|
||||
ShoppingListItemBase,
|
||||
ShoppingListItemCreate,
|
||||
ShoppingListItemOut,
|
||||
ShoppingListItemPagination,
|
||||
ShoppingListItemRecipeRefCreate,
|
||||
ShoppingListItemRecipeRefOut,
|
||||
ShoppingListItemRecipeRefUpdate,
|
||||
@@ -44,31 +41,11 @@ from .group_shopping_list import (
|
||||
ShoppingListUpdate,
|
||||
)
|
||||
from .group_statistics import GroupStatistics, GroupStorage
|
||||
from .invite_token import (
|
||||
CreateInviteToken,
|
||||
EmailInitationResponse,
|
||||
EmailInvitation,
|
||||
ReadInviteToken,
|
||||
SaveInviteToken,
|
||||
)
|
||||
from .webhook import (
|
||||
CreateWebhook,
|
||||
ReadWebhook,
|
||||
SaveWebhook,
|
||||
WebhookPagination,
|
||||
WebhookType,
|
||||
)
|
||||
from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken
|
||||
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
|
||||
|
||||
__all__ = [
|
||||
"CreateGroupPreferences",
|
||||
"ReadGroupPreferences",
|
||||
"UpdateGroupPreferences",
|
||||
"GroupDataExport",
|
||||
"CreateWebhook",
|
||||
"ReadWebhook",
|
||||
"SaveWebhook",
|
||||
"WebhookPagination",
|
||||
"WebhookType",
|
||||
"GroupAdminUpdate",
|
||||
"GroupEventNotifierCreate",
|
||||
"GroupEventNotifierOptions",
|
||||
"GroupEventNotifierOptionsOut",
|
||||
@@ -78,14 +55,20 @@ __all__ = [
|
||||
"GroupEventNotifierSave",
|
||||
"GroupEventNotifierUpdate",
|
||||
"GroupEventPagination",
|
||||
"GroupDataExport",
|
||||
"DataMigrationCreate",
|
||||
"SupportedMigrations",
|
||||
"SetPermissions",
|
||||
"CreateGroupPreferences",
|
||||
"ReadGroupPreferences",
|
||||
"UpdateGroupPreferences",
|
||||
"SeederConfig",
|
||||
"ShoppingListAddRecipeParams",
|
||||
"ShoppingListCreate",
|
||||
"ShoppingListItemBase",
|
||||
"ShoppingListItemCreate",
|
||||
"ShoppingListItemOut",
|
||||
"ShoppingListItemPagination",
|
||||
"ShoppingListItemRecipeRefCreate",
|
||||
"ShoppingListItemRecipeRefOut",
|
||||
"ShoppingListItemRecipeRefUpdate",
|
||||
@@ -102,8 +85,6 @@ __all__ = [
|
||||
"ShoppingListSave",
|
||||
"ShoppingListSummary",
|
||||
"ShoppingListUpdate",
|
||||
"GroupAdminUpdate",
|
||||
"SetPermissions",
|
||||
"GroupStatistics",
|
||||
"GroupStorage",
|
||||
"CreateInviteToken",
|
||||
@@ -111,4 +92,9 @@ __all__ = [
|
||||
"EmailInvitation",
|
||||
"ReadInviteToken",
|
||||
"SaveInviteToken",
|
||||
"CreateWebhook",
|
||||
"ReadWebhook",
|
||||
"SaveWebhook",
|
||||
"WebhookPagination",
|
||||
"WebhookType",
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ class SupportedMigrations(str, enum.Enum):
|
||||
copymethat = "copymethat"
|
||||
paprika = "paprika"
|
||||
mealie_alpha = "mealie_alpha"
|
||||
tandoor = "tandoor"
|
||||
|
||||
|
||||
class DataMigrationCreate(MealieModel):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from fractions import Fraction
|
||||
|
||||
from pydantic import UUID4, validator
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
@@ -20,25 +19,13 @@ 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 (
|
||||
INGREDIENT_QTY_PRECISION,
|
||||
MAX_INGREDIENT_DENOMINATOR,
|
||||
IngredientFood,
|
||||
IngredientUnit,
|
||||
RecipeIngredient,
|
||||
RecipeIngredientBase,
|
||||
)
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
|
||||
SUPERSCRIPT = dict(zip("1234567890", "¹²³⁴⁵⁶⁷⁸⁹⁰", strict=False))
|
||||
SUBSCRIPT = dict(zip("1234567890", "₁₂₃₄₅₆₇₈₉₀", strict=False))
|
||||
|
||||
|
||||
def display_fraction(fraction: Fraction):
|
||||
return (
|
||||
"".join([SUPERSCRIPT[c] for c in str(fraction.numerator)])
|
||||
+ "/"
|
||||
+ "".join([SUBSCRIPT[c] for c in str(fraction.denominator)])
|
||||
)
|
||||
|
||||
|
||||
class ShoppingListItemRecipeRefCreate(MealieModel):
|
||||
recipe_id: UUID4
|
||||
@@ -63,20 +50,18 @@ class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate):
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class ShoppingListItemBase(MealieModel):
|
||||
class ShoppingListItemBase(RecipeIngredientBase):
|
||||
shopping_list_id: UUID4
|
||||
checked: bool = False
|
||||
position: int = 0
|
||||
|
||||
is_food: bool = False
|
||||
|
||||
note: str | None = ""
|
||||
quantity: float = 1
|
||||
|
||||
food_id: UUID4 | None = None
|
||||
label_id: UUID4 | None = None
|
||||
unit_id: UUID4 | None = None
|
||||
|
||||
is_food: bool = False
|
||||
extras: dict | None = {}
|
||||
|
||||
|
||||
@@ -96,12 +81,6 @@ class ShoppingListItemUpdateBulk(ShoppingListItemUpdate):
|
||||
|
||||
class ShoppingListItemOut(ShoppingListItemBase):
|
||||
id: UUID4
|
||||
display: str = ""
|
||||
"""
|
||||
How the ingredient should be displayed
|
||||
|
||||
Automatically calculated after the object is created
|
||||
"""
|
||||
|
||||
food: IngredientFood | None
|
||||
label: MultiPurposeLabelSummary | None
|
||||
@@ -120,63 +99,6 @@ class ShoppingListItemOut(ShoppingListItemBase):
|
||||
self.label = self.food.label
|
||||
self.label_id = self.label.id
|
||||
|
||||
# format the display property
|
||||
if not self.display:
|
||||
self.display = self._format_display()
|
||||
|
||||
def _format_quantity_for_display(self) -> str:
|
||||
"""How the quantity should be displayed"""
|
||||
|
||||
qty: float | Fraction
|
||||
|
||||
# decimal
|
||||
if not self.unit or not self.unit.fraction:
|
||||
qty = round(self.quantity, INGREDIENT_QTY_PRECISION)
|
||||
if qty.is_integer():
|
||||
return str(int(qty))
|
||||
|
||||
else:
|
||||
return str(qty)
|
||||
|
||||
# fraction
|
||||
qty = Fraction(self.quantity).limit_denominator(MAX_INGREDIENT_DENOMINATOR)
|
||||
if qty.denominator == 1:
|
||||
return str(qty.numerator)
|
||||
|
||||
if qty.numerator <= qty.denominator:
|
||||
return display_fraction(qty)
|
||||
|
||||
# convert an improper fraction into a mixed fraction (e.g. 11/4 --> 2 3/4)
|
||||
whole_number = 0
|
||||
while qty.numerator > qty.denominator:
|
||||
whole_number += 1
|
||||
qty -= 1
|
||||
|
||||
return f"{whole_number} {display_fraction(qty)}"
|
||||
|
||||
def _format_display(self) -> str:
|
||||
components = []
|
||||
|
||||
# ingredients with no food come across with a qty of 1, which looks weird
|
||||
# e.g. "1 2 tbsp of olive oil"
|
||||
if self.quantity and (self.is_food or self.quantity != 1):
|
||||
components.append(self._format_quantity_for_display())
|
||||
|
||||
if not self.is_food:
|
||||
components.append(self.note or "")
|
||||
|
||||
else:
|
||||
if self.quantity and self.unit:
|
||||
components.append(self.unit.abbreviation if self.unit.use_abbreviation else self.unit.name)
|
||||
|
||||
if self.food:
|
||||
components.append(self.food.name)
|
||||
|
||||
if self.note:
|
||||
components.append(self.note)
|
||||
|
||||
return " ".join(components)
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
getter_dict = ExtrasGetterDict
|
||||
|
||||
@@ -6,6 +6,7 @@ from .recipe import (
|
||||
Recipe,
|
||||
RecipeCategory,
|
||||
RecipeCategoryPagination,
|
||||
RecipeLastMade,
|
||||
RecipePagination,
|
||||
RecipeSummary,
|
||||
RecipeTag,
|
||||
@@ -58,6 +59,7 @@ from .recipe_ingredient import (
|
||||
MergeUnit,
|
||||
ParsedIngredient,
|
||||
RecipeIngredient,
|
||||
RecipeIngredientBase,
|
||||
RegisteredParser,
|
||||
SaveIngredientFood,
|
||||
SaveIngredientUnit,
|
||||
@@ -81,28 +83,27 @@ from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, Re
|
||||
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
|
||||
|
||||
__all__ = [
|
||||
"RecipeToolCreate",
|
||||
"RecipeToolOut",
|
||||
"RecipeToolResponse",
|
||||
"RecipeToolSave",
|
||||
"RecipeTimelineEventCreate",
|
||||
"RecipeTimelineEventIn",
|
||||
"RecipeTimelineEventOut",
|
||||
"RecipeTimelineEventPagination",
|
||||
"RecipeTimelineEventUpdate",
|
||||
"TimelineEventType",
|
||||
"CreateRecipe",
|
||||
"CreateRecipeBulk",
|
||||
"CreateRecipeByUrlBulk",
|
||||
"Recipe",
|
||||
"RecipeCategory",
|
||||
"RecipeCategoryPagination",
|
||||
"RecipeLastMade",
|
||||
"RecipePagination",
|
||||
"RecipeSummary",
|
||||
"RecipeTag",
|
||||
"RecipeTagPagination",
|
||||
"RecipeTool",
|
||||
"RecipeToolPagination",
|
||||
"RecipeAsset",
|
||||
"RecipeSettings",
|
||||
"RecipeShareToken",
|
||||
"RecipeShareTokenCreate",
|
||||
"RecipeShareTokenSave",
|
||||
"RecipeShareTokenSummary",
|
||||
"RecipeDuplicate",
|
||||
"RecipeSlug",
|
||||
"RecipeZipTokenResponse",
|
||||
"SlugResponse",
|
||||
"UpdateImageResponse",
|
||||
"RecipeNote",
|
||||
"AssignCategories",
|
||||
"AssignSettings",
|
||||
"AssignTags",
|
||||
"DeleteRecipes",
|
||||
"ExportBase",
|
||||
"ExportRecipes",
|
||||
"ExportTypes",
|
||||
"CategoryBase",
|
||||
"CategoryIn",
|
||||
"CategoryOut",
|
||||
@@ -119,17 +120,7 @@ __all__ = [
|
||||
"RecipeCommentSave",
|
||||
"RecipeCommentUpdate",
|
||||
"UserBase",
|
||||
"AssignCategories",
|
||||
"AssignSettings",
|
||||
"AssignTags",
|
||||
"DeleteRecipes",
|
||||
"ExportBase",
|
||||
"ExportRecipes",
|
||||
"ExportTypes",
|
||||
"IngredientReferences",
|
||||
"RecipeStep",
|
||||
"RecipeImageTypes",
|
||||
"Nutrition",
|
||||
"CreateIngredientFood",
|
||||
"CreateIngredientUnit",
|
||||
"IngredientConfidence",
|
||||
@@ -143,22 +134,35 @@ __all__ = [
|
||||
"MergeUnit",
|
||||
"ParsedIngredient",
|
||||
"RecipeIngredient",
|
||||
"RecipeIngredientBase",
|
||||
"RegisteredParser",
|
||||
"SaveIngredientFood",
|
||||
"SaveIngredientUnit",
|
||||
"UnitFoodBase",
|
||||
"CreateRecipe",
|
||||
"CreateRecipeBulk",
|
||||
"CreateRecipeByUrlBulk",
|
||||
"Recipe",
|
||||
"RecipeCategory",
|
||||
"RecipeCategoryPagination",
|
||||
"RecipePagination",
|
||||
"RecipeSummary",
|
||||
"RecipeTag",
|
||||
"RecipeTagPagination",
|
||||
"RecipeTool",
|
||||
"RecipeToolPagination",
|
||||
"RecipeNote",
|
||||
"Nutrition",
|
||||
"ScrapeRecipe",
|
||||
"ScrapeRecipeTest",
|
||||
"RecipeSettings",
|
||||
"RecipeShareToken",
|
||||
"RecipeShareTokenCreate",
|
||||
"RecipeShareTokenSave",
|
||||
"RecipeShareTokenSummary",
|
||||
"IngredientReferences",
|
||||
"RecipeStep",
|
||||
"RecipeTimelineEventCreate",
|
||||
"RecipeTimelineEventIn",
|
||||
"RecipeTimelineEventOut",
|
||||
"RecipeTimelineEventPagination",
|
||||
"RecipeTimelineEventUpdate",
|
||||
"TimelineEventType",
|
||||
"RecipeToolCreate",
|
||||
"RecipeToolOut",
|
||||
"RecipeToolResponse",
|
||||
"RecipeToolSave",
|
||||
"RecipeDuplicate",
|
||||
"RecipeSlug",
|
||||
"RecipeZipTokenResponse",
|
||||
"SlugResponse",
|
||||
"UpdateImageResponse",
|
||||
]
|
||||
|
||||
@@ -157,6 +157,25 @@ class Recipe(RecipeSummary):
|
||||
orm_mode = True
|
||||
getter_dict = ExtrasGetterDict
|
||||
|
||||
@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:
|
||||
# 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
|
||||
for ingredient in self.recipe_ingredient:
|
||||
ingredient.disable_amount = disable_amount
|
||||
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"):
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
from fractions import Fraction
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import UUID4, Field, validator
|
||||
@@ -17,6 +18,17 @@ from mealie.schema.response.pagination import PaginationBase
|
||||
INGREDIENT_QTY_PRECISION = 3
|
||||
MAX_INGREDIENT_DENOMINATOR = 32
|
||||
|
||||
SUPERSCRIPT = dict(zip("1234567890", "¹²³⁴⁵⁶⁷⁸⁹⁰", strict=False))
|
||||
SUBSCRIPT = dict(zip("1234567890", "₁₂₃₄₅₆₇₈₉₀", strict=False))
|
||||
|
||||
|
||||
def display_fraction(fraction: Fraction):
|
||||
return (
|
||||
"".join([SUPERSCRIPT[c] for c in str(fraction.numerator)])
|
||||
+ "/"
|
||||
+ "".join([SUBSCRIPT[c] for c in str(fraction.denominator)])
|
||||
)
|
||||
|
||||
|
||||
class UnitFoodBase(MealieModel):
|
||||
name: str
|
||||
@@ -70,18 +82,119 @@ class IngredientUnit(CreateIngredientUnit):
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class RecipeIngredientBase(MealieModel):
|
||||
quantity: NoneFloat = 1
|
||||
unit: IngredientUnit | CreateIngredientUnit | None
|
||||
food: IngredientFood | CreateIngredientFood | None
|
||||
note: str | None = ""
|
||||
|
||||
is_food: bool | None = None
|
||||
disable_amount: bool | None = None
|
||||
display: str = ""
|
||||
"""
|
||||
How the ingredient should be displayed
|
||||
|
||||
Automatically calculated after the object is created, unless overwritten
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# 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:
|
||||
self.is_food = not self.disable_amount
|
||||
elif self.disable_amount is None and self.is_food is not None:
|
||||
self.disable_amount = not self.is_food
|
||||
elif self.is_food is None and self.disable_amount is None:
|
||||
self.is_food = bool(self.food)
|
||||
self.disable_amount = not self.is_food
|
||||
|
||||
# format the display property
|
||||
if not self.display:
|
||||
self.display = self._format_display()
|
||||
|
||||
@validator("unit", pre=True)
|
||||
def validate_unit(cls, v):
|
||||
if isinstance(v, str):
|
||||
return CreateIngredientUnit(name=v)
|
||||
else:
|
||||
return v
|
||||
|
||||
@validator("food", pre=True)
|
||||
def validate_food(cls, v):
|
||||
if isinstance(v, str):
|
||||
return CreateIngredientFood(name=v)
|
||||
else:
|
||||
return v
|
||||
|
||||
def _format_quantity_for_display(self) -> str:
|
||||
"""How the quantity should be displayed"""
|
||||
|
||||
qty: float | Fraction
|
||||
|
||||
# decimal
|
||||
if not self.unit or not self.unit.fraction:
|
||||
qty = round(self.quantity or 0, INGREDIENT_QTY_PRECISION)
|
||||
if qty.is_integer():
|
||||
return str(int(qty))
|
||||
|
||||
else:
|
||||
return str(qty)
|
||||
|
||||
# fraction
|
||||
qty = Fraction(self.quantity or 0).limit_denominator(MAX_INGREDIENT_DENOMINATOR)
|
||||
if qty.denominator == 1:
|
||||
return str(qty.numerator)
|
||||
|
||||
if qty.numerator <= qty.denominator:
|
||||
return display_fraction(qty)
|
||||
|
||||
# convert an improper fraction into a mixed fraction (e.g. 11/4 --> 2 3/4)
|
||||
whole_number = 0
|
||||
while qty.numerator > qty.denominator:
|
||||
whole_number += 1
|
||||
qty -= 1
|
||||
|
||||
return f"{whole_number} {display_fraction(qty)}"
|
||||
|
||||
def _format_display(self) -> str:
|
||||
components = []
|
||||
|
||||
use_food = True
|
||||
if self.is_food is False:
|
||||
use_food = False
|
||||
elif self.disable_amount is True:
|
||||
use_food = False
|
||||
|
||||
# ingredients with no food come across with a qty of 1, which looks weird
|
||||
# e.g. "1 2 tbsp of olive oil"
|
||||
if self.quantity and (use_food or self.quantity != 1):
|
||||
components.append(self._format_quantity_for_display())
|
||||
|
||||
if not use_food:
|
||||
components.append(self.note or "")
|
||||
else:
|
||||
if self.quantity and self.unit:
|
||||
components.append(self.unit.abbreviation if self.unit.use_abbreviation else self.unit.name)
|
||||
|
||||
if self.food:
|
||||
components.append(self.food.name)
|
||||
|
||||
if self.note:
|
||||
components.append(self.note)
|
||||
|
||||
return " ".join(components)
|
||||
|
||||
|
||||
class IngredientUnitPagination(PaginationBase):
|
||||
items: list[IngredientUnit]
|
||||
|
||||
|
||||
class RecipeIngredient(MealieModel):
|
||||
class RecipeIngredient(RecipeIngredientBase):
|
||||
title: str | None
|
||||
note: str | None
|
||||
unit: IngredientUnit | CreateIngredientUnit | None
|
||||
food: IngredientFood | CreateIngredientFood | None
|
||||
disable_amount: bool = True
|
||||
quantity: NoneFloat = 1
|
||||
original_text: str | 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
|
||||
@@ -92,8 +205,7 @@ class RecipeIngredient(MealieModel):
|
||||
orm_mode = True
|
||||
|
||||
@validator("quantity", pre=True)
|
||||
@classmethod
|
||||
def validate_quantity(cls, value, values) -> NoneFloat:
|
||||
def validate_quantity(cls, value) -> NoneFloat:
|
||||
"""
|
||||
Sometimes the frontend UI will provide an empty string as a "null" value because of the default
|
||||
bindings in Vue. This validator will ensure that the quantity is set to None if the value is an
|
||||
|
||||
Reference in New Issue
Block a user