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:
Michael Genson
2023-07-23 12:52:09 -05:00
committed by GitHub
parent c25b58e404
commit 0f896107f9
18 changed files with 559 additions and 236 deletions

View File

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

View File

@@ -9,6 +9,7 @@ class SupportedMigrations(str, enum.Enum):
copymethat = "copymethat"
paprika = "paprika"
mealie_alpha = "mealie_alpha"
tandoor = "tandoor"
class DataMigrationCreate(MealieModel):

View File

@@ -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

View File

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

View File

@@ -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"):

View File

@@ -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