mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04: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:
parent
c25b58e404
commit
0f896107f9
@ -11,6 +11,8 @@ function sanitizeIngredientHTML(rawHtml: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1): string {
|
export function parseIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1): string {
|
||||||
|
// TODO: the backend now supplies a "display" property which does this for us, so we don't need this function
|
||||||
|
|
||||||
if (disableAmount) {
|
if (disableAmount) {
|
||||||
return ingredient.note || "";
|
return ingredient.note || "";
|
||||||
}
|
}
|
||||||
|
@ -332,6 +332,10 @@
|
|||||||
"description-long": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.",
|
"description-long": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.",
|
||||||
"title": "Mealie Pre v1.0"
|
"title": "Mealie Pre v1.0"
|
||||||
},
|
},
|
||||||
|
"tandoor": {
|
||||||
|
"description-long": "Mealie can import recipes from Tandoor. Export your data in the \"Default\" format, then upload the .zip below.",
|
||||||
|
"title": "Tandoor Recipes"
|
||||||
|
},
|
||||||
"recipe-data-migrations": "Recipe Data Migrations",
|
"recipe-data-migrations": "Recipe Data Migrations",
|
||||||
"recipe-data-migrations-explanation": "Recipes can be migrated from another supported application to Mealie. This is a great way to get started with Mealie.",
|
"recipe-data-migrations-explanation": "Recipes can be migrated from another supported application to Mealie. This is a great way to get started with Mealie.",
|
||||||
"choose-migration-type": "Choose Migration Type",
|
"choose-migration-type": "Choose Migration Type",
|
||||||
@ -341,8 +345,7 @@
|
|||||||
"recipe-1": "Recipe 1",
|
"recipe-1": "Recipe 1",
|
||||||
"recipe-2": "Recipe 2",
|
"recipe-2": "Recipe 2",
|
||||||
"paprika-text": "Mealie can import recipes from the Paprika application. Export your recipes from paprika, rename the export extension to .zip and upload it below.",
|
"paprika-text": "Mealie can import recipes from the Paprika application. Export your recipes from paprika, rename the export extension to .zip and upload it below.",
|
||||||
"mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export.",
|
"mealie-text": "Mealie can import recipes from the Mealie application from a pre v1.0 release. Export your recipes from your old instance, and upload the zip file below. Note that only recipes can be imported from the export."
|
||||||
"previous-migrations": "Previous Migrations"
|
|
||||||
},
|
},
|
||||||
"new-recipe": {
|
"new-recipe": {
|
||||||
"bulk-add": "Bulk Add",
|
"bulk-add": "Bulk Add",
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type WebhookType = "mealplan";
|
export type WebhookType = "mealplan";
|
||||||
export type SupportedMigrations = "nextcloud" | "chowdown" | "copymethat" | "paprika" | "mealie_alpha";
|
export type SupportedMigrations = "nextcloud" | "chowdown" | "copymethat" | "paprika" | "mealie_alpha" | "tandoor";
|
||||||
|
|
||||||
export interface CreateGroupPreferences {
|
export interface CreateGroupPreferences {
|
||||||
privateGroup?: boolean;
|
privateGroup?: boolean;
|
||||||
@ -247,71 +247,43 @@ export interface SetPermissions {
|
|||||||
}
|
}
|
||||||
export interface ShoppingListAddRecipeParams {
|
export interface ShoppingListAddRecipeParams {
|
||||||
recipeIncrementQuantity?: number;
|
recipeIncrementQuantity?: number;
|
||||||
|
recipeIngredients?: RecipeIngredient[];
|
||||||
}
|
}
|
||||||
export interface ShoppingListCreate {
|
export interface RecipeIngredient {
|
||||||
name?: string;
|
|
||||||
extras?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
createdAt?: string;
|
|
||||||
updateAt?: string;
|
|
||||||
}
|
|
||||||
export interface ShoppingListItemBase {
|
|
||||||
shoppingListId: string;
|
|
||||||
checked?: boolean;
|
|
||||||
position?: number;
|
|
||||||
isFood?: boolean;
|
|
||||||
note?: string;
|
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
foodId?: string;
|
unit?: IngredientUnit | CreateIngredientUnit;
|
||||||
labelId?: string;
|
food?: IngredientFood | CreateIngredientFood;
|
||||||
unitId?: string;
|
|
||||||
extras?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export interface ShoppingListItemCreate {
|
|
||||||
shoppingListId: string;
|
|
||||||
checked?: boolean;
|
|
||||||
position?: number;
|
|
||||||
isFood?: boolean;
|
|
||||||
note?: string;
|
note?: string;
|
||||||
quantity?: number;
|
|
||||||
foodId?: string;
|
|
||||||
labelId?: string;
|
|
||||||
unitId?: string;
|
|
||||||
extras?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
recipeReferences?: ShoppingListItemRecipeRefCreate[];
|
|
||||||
}
|
|
||||||
export interface ShoppingListItemRecipeRefCreate {
|
|
||||||
recipeId: string;
|
|
||||||
recipeQuantity?: number;
|
|
||||||
recipeScale?: number;
|
|
||||||
}
|
|
||||||
export interface ShoppingListItemOut {
|
|
||||||
shoppingListId: string;
|
|
||||||
checked?: boolean;
|
|
||||||
position?: number;
|
|
||||||
isFood?: boolean;
|
isFood?: boolean;
|
||||||
note?: string;
|
disableAmount?: boolean;
|
||||||
quantity?: number;
|
|
||||||
foodId?: string;
|
|
||||||
labelId?: string;
|
|
||||||
unitId?: string;
|
|
||||||
extras?: {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
id: string;
|
|
||||||
display?: string;
|
display?: string;
|
||||||
food?: IngredientFood;
|
title?: string;
|
||||||
label?: MultiPurposeLabelSummary;
|
originalText?: string;
|
||||||
unit?: IngredientUnit;
|
referenceId?: string;
|
||||||
recipeReferences?: ShoppingListItemRecipeRefOut[];
|
}
|
||||||
|
export interface IngredientUnit {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
extras?: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
fraction?: boolean;
|
||||||
|
abbreviation?: string;
|
||||||
|
useAbbreviation?: boolean;
|
||||||
|
id: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updateAt?: string;
|
updateAt?: string;
|
||||||
}
|
}
|
||||||
|
export interface CreateIngredientUnit {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
extras?: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
fraction?: boolean;
|
||||||
|
abbreviation?: string;
|
||||||
|
useAbbreviation?: boolean;
|
||||||
|
}
|
||||||
export interface IngredientFood {
|
export interface IngredientFood {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@ -330,16 +302,84 @@ export interface MultiPurposeLabelSummary {
|
|||||||
groupId: string;
|
groupId: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
export interface IngredientUnit {
|
export interface CreateIngredientFood {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
extras?: {
|
extras?: {
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
};
|
};
|
||||||
fraction?: boolean;
|
labelId?: string;
|
||||||
abbreviation?: string;
|
}
|
||||||
useAbbreviation?: boolean;
|
export interface ShoppingListCreate {
|
||||||
|
name?: string;
|
||||||
|
extras?: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
createdAt?: string;
|
||||||
|
updateAt?: string;
|
||||||
|
}
|
||||||
|
export interface ShoppingListItemBase {
|
||||||
|
quantity?: number;
|
||||||
|
unit?: IngredientUnit | CreateIngredientUnit;
|
||||||
|
food?: IngredientFood | CreateIngredientFood;
|
||||||
|
note?: string;
|
||||||
|
isFood?: boolean;
|
||||||
|
disableAmount?: boolean;
|
||||||
|
display?: string;
|
||||||
|
shoppingListId: string;
|
||||||
|
checked?: boolean;
|
||||||
|
position?: number;
|
||||||
|
foodId?: string;
|
||||||
|
labelId?: string;
|
||||||
|
unitId?: string;
|
||||||
|
extras?: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface ShoppingListItemCreate {
|
||||||
|
quantity?: number;
|
||||||
|
unit?: IngredientUnit | CreateIngredientUnit;
|
||||||
|
food?: IngredientFood | CreateIngredientFood;
|
||||||
|
note?: string;
|
||||||
|
isFood?: boolean;
|
||||||
|
disableAmount?: boolean;
|
||||||
|
display?: string;
|
||||||
|
shoppingListId: string;
|
||||||
|
checked?: boolean;
|
||||||
|
position?: number;
|
||||||
|
foodId?: string;
|
||||||
|
labelId?: string;
|
||||||
|
unitId?: string;
|
||||||
|
extras?: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
recipeReferences?: ShoppingListItemRecipeRefCreate[];
|
||||||
|
}
|
||||||
|
export interface ShoppingListItemRecipeRefCreate {
|
||||||
|
recipeId: string;
|
||||||
|
recipeQuantity?: number;
|
||||||
|
recipeScale?: number;
|
||||||
|
}
|
||||||
|
export interface ShoppingListItemOut {
|
||||||
|
quantity?: number;
|
||||||
|
unit?: IngredientUnit;
|
||||||
|
food?: IngredientFood;
|
||||||
|
note?: string;
|
||||||
|
isFood?: boolean;
|
||||||
|
disableAmount?: boolean;
|
||||||
|
display?: string;
|
||||||
|
shoppingListId: string;
|
||||||
|
checked?: boolean;
|
||||||
|
position?: number;
|
||||||
|
foodId?: string;
|
||||||
|
labelId?: string;
|
||||||
|
unitId?: string;
|
||||||
|
extras?: {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
id: string;
|
id: string;
|
||||||
|
label?: MultiPurposeLabelSummary;
|
||||||
|
recipeReferences?: ShoppingListItemRecipeRefOut[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updateAt?: string;
|
updateAt?: string;
|
||||||
}
|
}
|
||||||
@ -358,12 +398,16 @@ export interface ShoppingListItemRecipeRefUpdate {
|
|||||||
shoppingListItemId: string;
|
shoppingListItemId: string;
|
||||||
}
|
}
|
||||||
export interface ShoppingListItemUpdate {
|
export interface ShoppingListItemUpdate {
|
||||||
|
quantity?: number;
|
||||||
|
unit?: IngredientUnit | CreateIngredientUnit;
|
||||||
|
food?: IngredientFood | CreateIngredientFood;
|
||||||
|
note?: string;
|
||||||
|
isFood?: boolean;
|
||||||
|
disableAmount?: boolean;
|
||||||
|
display?: string;
|
||||||
shoppingListId: string;
|
shoppingListId: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
isFood?: boolean;
|
|
||||||
note?: string;
|
|
||||||
quantity?: number;
|
|
||||||
foodId?: string;
|
foodId?: string;
|
||||||
labelId?: string;
|
labelId?: string;
|
||||||
unitId?: string;
|
unitId?: string;
|
||||||
@ -376,12 +420,16 @@ export interface ShoppingListItemUpdate {
|
|||||||
* Only used for bulk update operations where the shopping list item id isn't already supplied
|
* Only used for bulk update operations where the shopping list item id isn't already supplied
|
||||||
*/
|
*/
|
||||||
export interface ShoppingListItemUpdateBulk {
|
export interface ShoppingListItemUpdateBulk {
|
||||||
|
quantity?: number;
|
||||||
|
unit?: IngredientUnit | CreateIngredientUnit;
|
||||||
|
food?: IngredientFood | CreateIngredientFood;
|
||||||
|
note?: string;
|
||||||
|
isFood?: boolean;
|
||||||
|
disableAmount?: boolean;
|
||||||
|
display?: string;
|
||||||
shoppingListId: string;
|
shoppingListId: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
isFood?: boolean;
|
|
||||||
note?: string;
|
|
||||||
quantity?: number;
|
|
||||||
foodId?: string;
|
foodId?: string;
|
||||||
labelId?: string;
|
labelId?: string;
|
||||||
unitId?: string;
|
unitId?: string;
|
||||||
@ -512,3 +560,12 @@ export interface ShoppingListUpdate {
|
|||||||
id: string;
|
id: string;
|
||||||
listItems?: ShoppingListItemOut[];
|
listItems?: ShoppingListItemOut[];
|
||||||
}
|
}
|
||||||
|
export interface RecipeIngredientBase {
|
||||||
|
quantity?: number;
|
||||||
|
unit?: IngredientUnit | CreateIngredientUnit;
|
||||||
|
food?: IngredientFood | CreateIngredientFood;
|
||||||
|
note?: string;
|
||||||
|
isFood?: boolean;
|
||||||
|
disableAmount?: boolean;
|
||||||
|
display?: string;
|
||||||
|
}
|
||||||
|
@ -178,12 +178,14 @@ export interface ParsedIngredient {
|
|||||||
ingredient: RecipeIngredient;
|
ingredient: RecipeIngredient;
|
||||||
}
|
}
|
||||||
export interface RecipeIngredient {
|
export interface RecipeIngredient {
|
||||||
title?: string;
|
quantity?: number;
|
||||||
note?: string;
|
|
||||||
unit?: IngredientUnit | CreateIngredientUnit;
|
unit?: IngredientUnit | CreateIngredientUnit;
|
||||||
food?: IngredientFood | CreateIngredientFood;
|
food?: IngredientFood | CreateIngredientFood;
|
||||||
|
note?: string;
|
||||||
|
isFood?: boolean;
|
||||||
disableAmount?: boolean;
|
disableAmount?: boolean;
|
||||||
quantity?: number;
|
display?: string;
|
||||||
|
title?: string;
|
||||||
originalText?: string;
|
originalText?: string;
|
||||||
referenceId?: string;
|
referenceId?: string;
|
||||||
}
|
}
|
||||||
@ -303,6 +305,15 @@ export interface RecipeCommentUpdate {
|
|||||||
export interface RecipeDuplicate {
|
export interface RecipeDuplicate {
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
export interface RecipeIngredientBase {
|
||||||
|
quantity?: number;
|
||||||
|
unit?: IngredientUnit | CreateIngredientUnit;
|
||||||
|
food?: IngredientFood | CreateIngredientFood;
|
||||||
|
note?: string;
|
||||||
|
isFood?: boolean;
|
||||||
|
disableAmount?: boolean;
|
||||||
|
display?: string;
|
||||||
|
}
|
||||||
export interface RecipeLastMade {
|
export interface RecipeLastMade {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
@ -80,6 +80,7 @@ const MIGRATIONS = {
|
|||||||
copymethat: "copymethat",
|
copymethat: "copymethat",
|
||||||
paprika: "paprika",
|
paprika: "paprika",
|
||||||
mealie: "mealie_alpha",
|
mealie: "mealie_alpha",
|
||||||
|
tandoor: "tandoor",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@ -118,6 +119,10 @@ export default defineComponent({
|
|||||||
text: i18n.tc("migration.mealie-pre-v1.title"),
|
text: i18n.tc("migration.mealie-pre-v1.title"),
|
||||||
value: MIGRATIONS.mealie,
|
value: MIGRATIONS.mealie,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: i18n.tc("migration.tandoor.title"),
|
||||||
|
value: MIGRATIONS.tandoor,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const _content = {
|
const _content = {
|
||||||
@ -267,6 +272,45 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
[MIGRATIONS.tandoor]: {
|
||||||
|
text: i18n.tc("migration.tandoor.description-long"),
|
||||||
|
tree: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
icon: $globals.icons.zip,
|
||||||
|
name: "tandoor_default_export_full_2023-06-29.zip",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "1.zip",
|
||||||
|
icon: $globals.icons.zip,
|
||||||
|
children: [
|
||||||
|
{ id: 3, name: "image.jpeg", icon: $globals.icons.fileImage },
|
||||||
|
{ id: 4, name: "recipe.json", icon: $globals.icons.codeJson },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "2.zip",
|
||||||
|
icon: $globals.icons.zip,
|
||||||
|
children: [
|
||||||
|
{ id: 6, name: "image.jpeg", icon: $globals.icons.fileImage },
|
||||||
|
{ id: 7, name: "recipe.json", icon: $globals.icons.codeJson },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: "3.zip",
|
||||||
|
icon: $globals.icons.zip,
|
||||||
|
children: [
|
||||||
|
{ id: 9, name: "image.jpeg", icon: $globals.icons.fileImage },
|
||||||
|
{ id: 10, name: "recipe.json", icon: $globals.icons.codeJson },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function setFileObject(fileObject: File) {
|
function setFileObject(fileObject: File) {
|
||||||
|
@ -16,6 +16,7 @@ from mealie.services.migrations import (
|
|||||||
MealieAlphaMigrator,
|
MealieAlphaMigrator,
|
||||||
NextcloudMigrator,
|
NextcloudMigrator,
|
||||||
PaprikaMigrator,
|
PaprikaMigrator,
|
||||||
|
TandoorMigrator,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = UserAPIRouter(prefix="/groups/migrations", tags=["Group: Migrations"])
|
router = UserAPIRouter(prefix="/groups/migrations", tags=["Group: Migrations"])
|
||||||
@ -50,6 +51,7 @@ class GroupMigrationController(BaseUserController):
|
|||||||
SupportedMigrations.mealie_alpha: MealieAlphaMigrator,
|
SupportedMigrations.mealie_alpha: MealieAlphaMigrator,
|
||||||
SupportedMigrations.nextcloud: NextcloudMigrator,
|
SupportedMigrations.nextcloud: NextcloudMigrator,
|
||||||
SupportedMigrations.paprika: PaprikaMigrator,
|
SupportedMigrations.paprika: PaprikaMigrator,
|
||||||
|
SupportedMigrations.tandoor: TandoorMigrator,
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor = table.get(migration_type, None)
|
constructor = table.get(migration_type, None)
|
||||||
|
@ -14,11 +14,7 @@ from .group_events import (
|
|||||||
from .group_exports import GroupDataExport
|
from .group_exports import GroupDataExport
|
||||||
from .group_migration import DataMigrationCreate, SupportedMigrations
|
from .group_migration import DataMigrationCreate, SupportedMigrations
|
||||||
from .group_permissions import SetPermissions
|
from .group_permissions import SetPermissions
|
||||||
from .group_preferences import (
|
from .group_preferences import CreateGroupPreferences, ReadGroupPreferences, UpdateGroupPreferences
|
||||||
CreateGroupPreferences,
|
|
||||||
ReadGroupPreferences,
|
|
||||||
UpdateGroupPreferences,
|
|
||||||
)
|
|
||||||
from .group_seeder import SeederConfig
|
from .group_seeder import SeederConfig
|
||||||
from .group_shopping_list import (
|
from .group_shopping_list import (
|
||||||
ShoppingListAddRecipeParams,
|
ShoppingListAddRecipeParams,
|
||||||
@ -26,6 +22,7 @@ from .group_shopping_list import (
|
|||||||
ShoppingListItemBase,
|
ShoppingListItemBase,
|
||||||
ShoppingListItemCreate,
|
ShoppingListItemCreate,
|
||||||
ShoppingListItemOut,
|
ShoppingListItemOut,
|
||||||
|
ShoppingListItemPagination,
|
||||||
ShoppingListItemRecipeRefCreate,
|
ShoppingListItemRecipeRefCreate,
|
||||||
ShoppingListItemRecipeRefOut,
|
ShoppingListItemRecipeRefOut,
|
||||||
ShoppingListItemRecipeRefUpdate,
|
ShoppingListItemRecipeRefUpdate,
|
||||||
@ -44,31 +41,11 @@ from .group_shopping_list import (
|
|||||||
ShoppingListUpdate,
|
ShoppingListUpdate,
|
||||||
)
|
)
|
||||||
from .group_statistics import GroupStatistics, GroupStorage
|
from .group_statistics import GroupStatistics, GroupStorage
|
||||||
from .invite_token import (
|
from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken
|
||||||
CreateInviteToken,
|
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
|
||||||
EmailInitationResponse,
|
|
||||||
EmailInvitation,
|
|
||||||
ReadInviteToken,
|
|
||||||
SaveInviteToken,
|
|
||||||
)
|
|
||||||
from .webhook import (
|
|
||||||
CreateWebhook,
|
|
||||||
ReadWebhook,
|
|
||||||
SaveWebhook,
|
|
||||||
WebhookPagination,
|
|
||||||
WebhookType,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CreateGroupPreferences",
|
"GroupAdminUpdate",
|
||||||
"ReadGroupPreferences",
|
|
||||||
"UpdateGroupPreferences",
|
|
||||||
"GroupDataExport",
|
|
||||||
"CreateWebhook",
|
|
||||||
"ReadWebhook",
|
|
||||||
"SaveWebhook",
|
|
||||||
"WebhookPagination",
|
|
||||||
"WebhookType",
|
|
||||||
"GroupEventNotifierCreate",
|
"GroupEventNotifierCreate",
|
||||||
"GroupEventNotifierOptions",
|
"GroupEventNotifierOptions",
|
||||||
"GroupEventNotifierOptionsOut",
|
"GroupEventNotifierOptionsOut",
|
||||||
@ -78,14 +55,20 @@ __all__ = [
|
|||||||
"GroupEventNotifierSave",
|
"GroupEventNotifierSave",
|
||||||
"GroupEventNotifierUpdate",
|
"GroupEventNotifierUpdate",
|
||||||
"GroupEventPagination",
|
"GroupEventPagination",
|
||||||
|
"GroupDataExport",
|
||||||
"DataMigrationCreate",
|
"DataMigrationCreate",
|
||||||
"SupportedMigrations",
|
"SupportedMigrations",
|
||||||
|
"SetPermissions",
|
||||||
|
"CreateGroupPreferences",
|
||||||
|
"ReadGroupPreferences",
|
||||||
|
"UpdateGroupPreferences",
|
||||||
"SeederConfig",
|
"SeederConfig",
|
||||||
"ShoppingListAddRecipeParams",
|
"ShoppingListAddRecipeParams",
|
||||||
"ShoppingListCreate",
|
"ShoppingListCreate",
|
||||||
"ShoppingListItemBase",
|
"ShoppingListItemBase",
|
||||||
"ShoppingListItemCreate",
|
"ShoppingListItemCreate",
|
||||||
"ShoppingListItemOut",
|
"ShoppingListItemOut",
|
||||||
|
"ShoppingListItemPagination",
|
||||||
"ShoppingListItemRecipeRefCreate",
|
"ShoppingListItemRecipeRefCreate",
|
||||||
"ShoppingListItemRecipeRefOut",
|
"ShoppingListItemRecipeRefOut",
|
||||||
"ShoppingListItemRecipeRefUpdate",
|
"ShoppingListItemRecipeRefUpdate",
|
||||||
@ -102,8 +85,6 @@ __all__ = [
|
|||||||
"ShoppingListSave",
|
"ShoppingListSave",
|
||||||
"ShoppingListSummary",
|
"ShoppingListSummary",
|
||||||
"ShoppingListUpdate",
|
"ShoppingListUpdate",
|
||||||
"GroupAdminUpdate",
|
|
||||||
"SetPermissions",
|
|
||||||
"GroupStatistics",
|
"GroupStatistics",
|
||||||
"GroupStorage",
|
"GroupStorage",
|
||||||
"CreateInviteToken",
|
"CreateInviteToken",
|
||||||
@ -111,4 +92,9 @@ __all__ = [
|
|||||||
"EmailInvitation",
|
"EmailInvitation",
|
||||||
"ReadInviteToken",
|
"ReadInviteToken",
|
||||||
"SaveInviteToken",
|
"SaveInviteToken",
|
||||||
|
"CreateWebhook",
|
||||||
|
"ReadWebhook",
|
||||||
|
"SaveWebhook",
|
||||||
|
"WebhookPagination",
|
||||||
|
"WebhookType",
|
||||||
]
|
]
|
||||||
|
@ -9,6 +9,7 @@ class SupportedMigrations(str, enum.Enum):
|
|||||||
copymethat = "copymethat"
|
copymethat = "copymethat"
|
||||||
paprika = "paprika"
|
paprika = "paprika"
|
||||||
mealie_alpha = "mealie_alpha"
|
mealie_alpha = "mealie_alpha"
|
||||||
|
tandoor = "tandoor"
|
||||||
|
|
||||||
|
|
||||||
class DataMigrationCreate(MealieModel):
|
class DataMigrationCreate(MealieModel):
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fractions import Fraction
|
|
||||||
|
|
||||||
from pydantic import UUID4, validator
|
from pydantic import UUID4, validator
|
||||||
from sqlalchemy.orm import joinedload, selectinload
|
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.labels.multi_purpose_label import MultiPurposeLabelSummary
|
||||||
from mealie.schema.recipe.recipe import RecipeSummary
|
from mealie.schema.recipe.recipe import RecipeSummary
|
||||||
from mealie.schema.recipe.recipe_ingredient import (
|
from mealie.schema.recipe.recipe_ingredient import (
|
||||||
INGREDIENT_QTY_PRECISION,
|
|
||||||
MAX_INGREDIENT_DENOMINATOR,
|
|
||||||
IngredientFood,
|
IngredientFood,
|
||||||
IngredientUnit,
|
IngredientUnit,
|
||||||
RecipeIngredient,
|
RecipeIngredient,
|
||||||
|
RecipeIngredientBase,
|
||||||
)
|
)
|
||||||
from mealie.schema.response.pagination import PaginationBase
|
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):
|
class ShoppingListItemRecipeRefCreate(MealieModel):
|
||||||
recipe_id: UUID4
|
recipe_id: UUID4
|
||||||
@ -63,20 +50,18 @@ class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate):
|
|||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListItemBase(MealieModel):
|
class ShoppingListItemBase(RecipeIngredientBase):
|
||||||
shopping_list_id: UUID4
|
shopping_list_id: UUID4
|
||||||
checked: bool = False
|
checked: bool = False
|
||||||
position: int = 0
|
position: int = 0
|
||||||
|
|
||||||
is_food: bool = False
|
|
||||||
|
|
||||||
note: str | None = ""
|
|
||||||
quantity: float = 1
|
quantity: float = 1
|
||||||
|
|
||||||
food_id: UUID4 | None = None
|
food_id: UUID4 | None = None
|
||||||
label_id: UUID4 | None = None
|
label_id: UUID4 | None = None
|
||||||
unit_id: UUID4 | None = None
|
unit_id: UUID4 | None = None
|
||||||
|
|
||||||
|
is_food: bool = False
|
||||||
extras: dict | None = {}
|
extras: dict | None = {}
|
||||||
|
|
||||||
|
|
||||||
@ -96,12 +81,6 @@ class ShoppingListItemUpdateBulk(ShoppingListItemUpdate):
|
|||||||
|
|
||||||
class ShoppingListItemOut(ShoppingListItemBase):
|
class ShoppingListItemOut(ShoppingListItemBase):
|
||||||
id: UUID4
|
id: UUID4
|
||||||
display: str = ""
|
|
||||||
"""
|
|
||||||
How the ingredient should be displayed
|
|
||||||
|
|
||||||
Automatically calculated after the object is created
|
|
||||||
"""
|
|
||||||
|
|
||||||
food: IngredientFood | None
|
food: IngredientFood | None
|
||||||
label: MultiPurposeLabelSummary | None
|
label: MultiPurposeLabelSummary | None
|
||||||
@ -120,63 +99,6 @@ class ShoppingListItemOut(ShoppingListItemBase):
|
|||||||
self.label = self.food.label
|
self.label = self.food.label
|
||||||
self.label_id = self.label.id
|
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:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
getter_dict = ExtrasGetterDict
|
getter_dict = ExtrasGetterDict
|
||||||
|
@ -6,6 +6,7 @@ from .recipe import (
|
|||||||
Recipe,
|
Recipe,
|
||||||
RecipeCategory,
|
RecipeCategory,
|
||||||
RecipeCategoryPagination,
|
RecipeCategoryPagination,
|
||||||
|
RecipeLastMade,
|
||||||
RecipePagination,
|
RecipePagination,
|
||||||
RecipeSummary,
|
RecipeSummary,
|
||||||
RecipeTag,
|
RecipeTag,
|
||||||
@ -58,6 +59,7 @@ from .recipe_ingredient import (
|
|||||||
MergeUnit,
|
MergeUnit,
|
||||||
ParsedIngredient,
|
ParsedIngredient,
|
||||||
RecipeIngredient,
|
RecipeIngredient,
|
||||||
|
RecipeIngredientBase,
|
||||||
RegisteredParser,
|
RegisteredParser,
|
||||||
SaveIngredientFood,
|
SaveIngredientFood,
|
||||||
SaveIngredientUnit,
|
SaveIngredientUnit,
|
||||||
@ -81,28 +83,27 @@ from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, Re
|
|||||||
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
|
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"RecipeToolCreate",
|
"CreateRecipe",
|
||||||
"RecipeToolOut",
|
"CreateRecipeBulk",
|
||||||
"RecipeToolResponse",
|
"CreateRecipeByUrlBulk",
|
||||||
"RecipeToolSave",
|
"Recipe",
|
||||||
"RecipeTimelineEventCreate",
|
"RecipeCategory",
|
||||||
"RecipeTimelineEventIn",
|
"RecipeCategoryPagination",
|
||||||
"RecipeTimelineEventOut",
|
"RecipeLastMade",
|
||||||
"RecipeTimelineEventPagination",
|
"RecipePagination",
|
||||||
"RecipeTimelineEventUpdate",
|
"RecipeSummary",
|
||||||
"TimelineEventType",
|
"RecipeTag",
|
||||||
|
"RecipeTagPagination",
|
||||||
|
"RecipeTool",
|
||||||
|
"RecipeToolPagination",
|
||||||
"RecipeAsset",
|
"RecipeAsset",
|
||||||
"RecipeSettings",
|
"AssignCategories",
|
||||||
"RecipeShareToken",
|
"AssignSettings",
|
||||||
"RecipeShareTokenCreate",
|
"AssignTags",
|
||||||
"RecipeShareTokenSave",
|
"DeleteRecipes",
|
||||||
"RecipeShareTokenSummary",
|
"ExportBase",
|
||||||
"RecipeDuplicate",
|
"ExportRecipes",
|
||||||
"RecipeSlug",
|
"ExportTypes",
|
||||||
"RecipeZipTokenResponse",
|
|
||||||
"SlugResponse",
|
|
||||||
"UpdateImageResponse",
|
|
||||||
"RecipeNote",
|
|
||||||
"CategoryBase",
|
"CategoryBase",
|
||||||
"CategoryIn",
|
"CategoryIn",
|
||||||
"CategoryOut",
|
"CategoryOut",
|
||||||
@ -119,17 +120,7 @@ __all__ = [
|
|||||||
"RecipeCommentSave",
|
"RecipeCommentSave",
|
||||||
"RecipeCommentUpdate",
|
"RecipeCommentUpdate",
|
||||||
"UserBase",
|
"UserBase",
|
||||||
"AssignCategories",
|
|
||||||
"AssignSettings",
|
|
||||||
"AssignTags",
|
|
||||||
"DeleteRecipes",
|
|
||||||
"ExportBase",
|
|
||||||
"ExportRecipes",
|
|
||||||
"ExportTypes",
|
|
||||||
"IngredientReferences",
|
|
||||||
"RecipeStep",
|
|
||||||
"RecipeImageTypes",
|
"RecipeImageTypes",
|
||||||
"Nutrition",
|
|
||||||
"CreateIngredientFood",
|
"CreateIngredientFood",
|
||||||
"CreateIngredientUnit",
|
"CreateIngredientUnit",
|
||||||
"IngredientConfidence",
|
"IngredientConfidence",
|
||||||
@ -143,22 +134,35 @@ __all__ = [
|
|||||||
"MergeUnit",
|
"MergeUnit",
|
||||||
"ParsedIngredient",
|
"ParsedIngredient",
|
||||||
"RecipeIngredient",
|
"RecipeIngredient",
|
||||||
|
"RecipeIngredientBase",
|
||||||
"RegisteredParser",
|
"RegisteredParser",
|
||||||
"SaveIngredientFood",
|
"SaveIngredientFood",
|
||||||
"SaveIngredientUnit",
|
"SaveIngredientUnit",
|
||||||
"UnitFoodBase",
|
"UnitFoodBase",
|
||||||
"CreateRecipe",
|
"RecipeNote",
|
||||||
"CreateRecipeBulk",
|
"Nutrition",
|
||||||
"CreateRecipeByUrlBulk",
|
|
||||||
"Recipe",
|
|
||||||
"RecipeCategory",
|
|
||||||
"RecipeCategoryPagination",
|
|
||||||
"RecipePagination",
|
|
||||||
"RecipeSummary",
|
|
||||||
"RecipeTag",
|
|
||||||
"RecipeTagPagination",
|
|
||||||
"RecipeTool",
|
|
||||||
"RecipeToolPagination",
|
|
||||||
"ScrapeRecipe",
|
"ScrapeRecipe",
|
||||||
"ScrapeRecipeTest",
|
"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
|
orm_mode = True
|
||||||
getter_dict = ExtrasGetterDict
|
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)
|
@validator("slug", always=True, pre=True, allow_reuse=True)
|
||||||
def validate_slug(slug: str, values): # type: ignore
|
def validate_slug(slug: str, values): # type: ignore
|
||||||
if not values.get("name"):
|
if not values.get("name"):
|
||||||
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import enum
|
import enum
|
||||||
|
from fractions import Fraction
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from pydantic import UUID4, Field, validator
|
from pydantic import UUID4, Field, validator
|
||||||
@ -17,6 +18,17 @@ from mealie.schema.response.pagination import PaginationBase
|
|||||||
INGREDIENT_QTY_PRECISION = 3
|
INGREDIENT_QTY_PRECISION = 3
|
||||||
MAX_INGREDIENT_DENOMINATOR = 32
|
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):
|
class UnitFoodBase(MealieModel):
|
||||||
name: str
|
name: str
|
||||||
@ -70,18 +82,119 @@ class IngredientUnit(CreateIngredientUnit):
|
|||||||
orm_mode = True
|
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):
|
class IngredientUnitPagination(PaginationBase):
|
||||||
items: list[IngredientUnit]
|
items: list[IngredientUnit]
|
||||||
|
|
||||||
|
|
||||||
class RecipeIngredient(MealieModel):
|
class RecipeIngredient(RecipeIngredientBase):
|
||||||
title: str | None
|
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
|
original_text: str | None
|
||||||
|
disable_amount: bool = True
|
||||||
|
|
||||||
# Ref is used as a way to distinguish between an individual ingredient on the frontend
|
# Ref is used as a way to distinguish between an individual ingredient on the frontend
|
||||||
# It is required for the reorder and section titles to function properly because of how
|
# It is required for the reorder and section titles to function properly because of how
|
||||||
@ -92,8 +205,7 @@ class RecipeIngredient(MealieModel):
|
|||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
|
||||||
@validator("quantity", pre=True)
|
@validator("quantity", pre=True)
|
||||||
@classmethod
|
def validate_quantity(cls, value) -> NoneFloat:
|
||||||
def validate_quantity(cls, value, values) -> NoneFloat:
|
|
||||||
"""
|
"""
|
||||||
Sometimes the frontend UI will provide an empty string as a "null" value because of the default
|
Sometimes the frontend UI will provide an empty string as a "null" value because of the default
|
||||||
bindings in Vue. This validator will ensure that the quantity is set to None if the value is an
|
bindings in Vue. This validator will ensure that the quantity is set to None if the value is an
|
||||||
|
@ -3,3 +3,4 @@ from .copymethat import *
|
|||||||
from .mealie_alpha import *
|
from .mealie_alpha import *
|
||||||
from .nextcloud import *
|
from .nextcloud import *
|
||||||
from .paprika import *
|
from .paprika import *
|
||||||
|
from .tandoor import *
|
||||||
|
147
mealie/services/migrations/tandoor.py
Normal file
147
mealie/services/migrations/tandoor.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mealie.schema.recipe.recipe_ingredient import RecipeIngredientBase
|
||||||
|
from mealie.schema.reports.reports import ReportEntryCreate
|
||||||
|
|
||||||
|
from ._migration_base import BaseMigrator
|
||||||
|
from .utils.migration_alias import MigrationAlias
|
||||||
|
from .utils.migration_helpers import import_image
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ingredient_from_ingredient_data(ingredient_data: dict[str, Any], title: str | None = None) -> dict[str, Any]:
|
||||||
|
quantity = ingredient_data.get("amount", "1")
|
||||||
|
if unit_data := ingredient_data.get("unit"):
|
||||||
|
unit = unit_data.get("plural_name") or unit_data.get("name")
|
||||||
|
else:
|
||||||
|
unit = None
|
||||||
|
|
||||||
|
if food_data := ingredient_data.get("food"):
|
||||||
|
food = food_data.get("plural_name") or food_data.get("name")
|
||||||
|
else:
|
||||||
|
food = None
|
||||||
|
|
||||||
|
base_ingredient = RecipeIngredientBase(quantity=quantity, unit=unit, food=food)
|
||||||
|
return {"title": title, "note": base_ingredient.display}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_instructions_and_ingredients(steps: list[dict[str, Any]]) -> tuple[list[str], list[dict[str, Any]]]:
|
||||||
|
"""Returns a list of instructions and ingredients for a recipe"""
|
||||||
|
|
||||||
|
instructions: list[str] = []
|
||||||
|
ingredients: list[dict[str, Any]] = []
|
||||||
|
for step in steps:
|
||||||
|
if instruction_text := step.get("instruction"):
|
||||||
|
instructions.append(instruction_text)
|
||||||
|
if ingredients_data := step.get("ingredients"):
|
||||||
|
for i, ingredient in enumerate(ingredients_data):
|
||||||
|
if not i and (title := step.get("name")):
|
||||||
|
ingredients.append(_build_ingredient_from_ingredient_data(ingredient, title))
|
||||||
|
else:
|
||||||
|
ingredients.append(_build_ingredient_from_ingredient_data(ingredient))
|
||||||
|
|
||||||
|
return instructions, ingredients
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time(minutes: int) -> str:
|
||||||
|
# TODO: make this translatable
|
||||||
|
hour_label = "hour"
|
||||||
|
hours_label = "hours"
|
||||||
|
minute_label = "minute"
|
||||||
|
minutes_label = "minutes"
|
||||||
|
|
||||||
|
hours, minutes = divmod(minutes, 60)
|
||||||
|
parts: list[str] = []
|
||||||
|
|
||||||
|
if hours:
|
||||||
|
parts.append(f"{int(hours)} {hour_label if hours == 1 else hours_label}")
|
||||||
|
if minutes:
|
||||||
|
parts.append(f"{minutes} {minute_label if minutes == 1 else minutes_label}")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_times(working_time: int, waiting_time: int) -> tuple[str, str]:
|
||||||
|
"""Returns the performTime and totalTime"""
|
||||||
|
|
||||||
|
total_time = working_time + waiting_time
|
||||||
|
return _format_time(working_time), _format_time(total_time)
|
||||||
|
|
||||||
|
|
||||||
|
class TandoorMigrator(BaseMigrator):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
self.name = "tandoor"
|
||||||
|
|
||||||
|
self.key_aliases = [
|
||||||
|
MigrationAlias(key="tags", alias="keywords", func=lambda kws: [kw["name"] for kw in kws if kw.get("name")]),
|
||||||
|
MigrationAlias(key="orgURL", alias="source_url", func=None),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _process_recipe_document(self, source_dir: Path, recipe_data: dict) -> dict:
|
||||||
|
steps_data = recipe_data.pop("steps", [])
|
||||||
|
recipe_data["recipeInstructions"], recipe_data["recipeIngredient"] = extract_instructions_and_ingredients(
|
||||||
|
steps_data
|
||||||
|
)
|
||||||
|
recipe_data["performTime"], recipe_data["totalTime"] = parse_times(
|
||||||
|
recipe_data.pop("working_time", 0), recipe_data.pop("waiting_time", 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
serving_size = recipe_data.pop("servings", 0)
|
||||||
|
serving_text = recipe_data.pop("servings_text", "")
|
||||||
|
if serving_size and serving_text:
|
||||||
|
recipe_data["recipeYield"] = f"{serving_size} {serving_text}"
|
||||||
|
|
||||||
|
recipe_data["image"] = str(source_dir.joinpath("image.jpeg"))
|
||||||
|
return recipe_data
|
||||||
|
|
||||||
|
def _migrate(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with zipfile.ZipFile(self.archive) as zip_file:
|
||||||
|
zip_file.extractall(tmpdir)
|
||||||
|
|
||||||
|
source_dir = Path(tmpdir)
|
||||||
|
|
||||||
|
recipes_as_dicts: list[dict] = []
|
||||||
|
for i, recipe_zip_file in enumerate(source_dir.glob("*.zip")):
|
||||||
|
try:
|
||||||
|
recipe_dir = str(source_dir.joinpath(f"recipe_{i+1}"))
|
||||||
|
os.makedirs(recipe_dir)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(recipe_zip_file) as recipe_zip:
|
||||||
|
recipe_zip.extractall(recipe_dir)
|
||||||
|
|
||||||
|
recipe_source_dir = Path(recipe_dir)
|
||||||
|
recipe_json_path = recipe_source_dir.joinpath("recipe.json")
|
||||||
|
with open(recipe_json_path) as f:
|
||||||
|
recipes_as_dicts.append(self._process_recipe_document(recipe_source_dir, json.load(f)))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.report_entries.append(
|
||||||
|
ReportEntryCreate(
|
||||||
|
report_id=self.report_id,
|
||||||
|
success=False,
|
||||||
|
message="Failed to parse recipe",
|
||||||
|
exception=f"{type(e).__name__}: {e}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
recipes = [self.clean_recipe_dictionary(x) for x in recipes_as_dicts]
|
||||||
|
results = self.import_recipes_to_database(recipes)
|
||||||
|
recipe_lookup = {r.slug: r for r in recipes}
|
||||||
|
for slug, recipe_id, status in results:
|
||||||
|
if status:
|
||||||
|
try:
|
||||||
|
r = recipe_lookup.get(slug)
|
||||||
|
if not r or not r.image:
|
||||||
|
continue
|
||||||
|
|
||||||
|
except StopIteration:
|
||||||
|
continue
|
||||||
|
|
||||||
|
import_image(r.image, recipe_id)
|
@ -234,7 +234,7 @@ def _sanitize_instruction_text(line: str | dict) -> str:
|
|||||||
return clean_line
|
return clean_line
|
||||||
|
|
||||||
|
|
||||||
def clean_ingredients(ingredients: list | str | None, default: list | None = None) -> list[str]:
|
def clean_ingredients(ingredients: list | str | None, default: list | None = None) -> list[str | dict]:
|
||||||
"""
|
"""
|
||||||
ingredient attempts to parse the ingredients field from a recipe and return a list of
|
ingredient attempts to parse the ingredients field from a recipe and return a list of
|
||||||
|
|
||||||
@ -250,6 +250,14 @@ def clean_ingredients(ingredients: list | str | None, default: list | None = Non
|
|||||||
case None:
|
case None:
|
||||||
return default or []
|
return default or []
|
||||||
case list(ingredients):
|
case list(ingredients):
|
||||||
|
cleaned_ingredients: list[str | dict] = []
|
||||||
|
for ing in ingredients:
|
||||||
|
if isinstance(ing, dict):
|
||||||
|
cleaned_ingredients.append({clean_string(k): clean_string(v) for k, v in ing.items()})
|
||||||
|
else:
|
||||||
|
cleaned_ingredients.append(clean_string(ing))
|
||||||
|
return cleaned_ingredients
|
||||||
|
case [str()]:
|
||||||
return [clean_string(ingredient) for ingredient in ingredients]
|
return [clean_string(ingredient) for ingredient in ingredients]
|
||||||
case str(ingredients):
|
case str(ingredients):
|
||||||
return [clean_string(ingredient) for ingredient in ingredients.splitlines()]
|
return [clean_string(ingredient) for ingredient in ingredients.splitlines()]
|
||||||
|
@ -14,6 +14,8 @@ migrations_mealie = CWD / "migrations/mealie.zip"
|
|||||||
|
|
||||||
migrations_nextcloud = CWD / "migrations/nextcloud.zip"
|
migrations_nextcloud = CWD / "migrations/nextcloud.zip"
|
||||||
|
|
||||||
|
migrations_tandoor = CWD / "migrations/tandoor.zip"
|
||||||
|
|
||||||
images_test_image_1 = CWD / "images/test-image-1.jpg"
|
images_test_image_1 = CWD / "images/test-image-1.jpg"
|
||||||
|
|
||||||
images_test_image_2 = CWD / "images/test-image-2.png"
|
images_test_image_2 = CWD / "images/test-image-2.png"
|
||||||
|
BIN
tests/data/migrations/tandoor.zip
Normal file
BIN
tests/data/migrations/tandoor.zip
Normal file
Binary file not shown.
@ -23,6 +23,7 @@ test_cases = [
|
|||||||
MigrationTestData(typ=SupportedMigrations.chowdown, archive=test_data.migrations_chowdown),
|
MigrationTestData(typ=SupportedMigrations.chowdown, archive=test_data.migrations_chowdown),
|
||||||
MigrationTestData(typ=SupportedMigrations.copymethat, archive=test_data.migrations_copymethat),
|
MigrationTestData(typ=SupportedMigrations.copymethat, archive=test_data.migrations_copymethat),
|
||||||
MigrationTestData(typ=SupportedMigrations.mealie_alpha, archive=test_data.migrations_mealie),
|
MigrationTestData(typ=SupportedMigrations.mealie_alpha, archive=test_data.migrations_mealie),
|
||||||
|
MigrationTestData(typ=SupportedMigrations.tandoor, archive=test_data.migrations_tandoor),
|
||||||
]
|
]
|
||||||
|
|
||||||
test_ids = [
|
test_ids = [
|
||||||
@ -31,6 +32,7 @@ test_ids = [
|
|||||||
"chowdown_archive",
|
"chowdown_archive",
|
||||||
"copymethat_archive",
|
"copymethat_archive",
|
||||||
"mealie_alpha_archive",
|
"mealie_alpha_archive",
|
||||||
|
"tandoor_archive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user