diff --git a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue index 14b2fa422abf..b57882f6953a 100644 --- a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue +++ b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue @@ -21,7 +21,13 @@ type="number" placeholder="Quantity" > - + {{ $globals.icons.delete }} diff --git a/frontend/components/Domain/Recipe/RecipeInstructions.vue b/frontend/components/Domain/Recipe/RecipeInstructions.vue index 4c6572b12ce3..644f0ecdcf25 100644 --- a/frontend/components/Domain/Recipe/RecipeInstructions.vue +++ b/frontend/components/Domain/Recipe/RecipeInstructions.vue @@ -180,7 +180,7 @@ import draggable from "vuedraggable"; // @ts-ignore vue-markdown has no types import VueMarkdown from "@adapttive/vue-markdown"; -import { ref, toRefs, reactive, defineComponent, watch, onMounted } from "@nuxtjs/composition-api"; +import { ref, toRefs, reactive, defineComponent, watch, onMounted, watchEffect } from "@nuxtjs/composition-api"; import { RecipeStep, IngredientReferences, RecipeIngredient } from "~/types/api-types/recipe"; import { parseIngredientText } from "~/composables/recipes"; import { uuid4 } from "~/composables/use-utils"; @@ -247,8 +247,9 @@ export default defineComponent({ // =============================================================== // UI State Helpers + function validateTitle(title: string | undefined) { - return !(title === null || title === ""); + return !(title === null || title === "" || title === undefined); } watch(props.value, (v) => { @@ -267,6 +268,8 @@ export default defineComponent({ if (element.id !== undefined) { showTitleEditor.value[element.id] = validateTitle(element.title); } + + showTitleEditor.value = { ...showTitleEditor.value }; }); }); @@ -283,17 +286,20 @@ export default defineComponent({ state.disabledSteps.push(stepIndex); } } + function isChecked(stepIndex: number) { if (state.disabledSteps.includes(stepIndex) && !props.edit) { return "disabled-card"; } } + function toggleShowTitle(id: string) { showTitleEditor.value[id] = !showTitleEditor.value[id]; const temp = { ...showTitleEditor.value }; showTitleEditor.value = temp; } + function updateIndex(data: RecipeStep) { context.emit("input", data); } @@ -475,4 +481,3 @@ export default defineComponent({ background: none; } - diff --git a/frontend/pages/recipe/_slug/ingredient-parser.vue b/frontend/pages/recipe/_slug/ingredient-parser.vue index 76b5333ff9a0..0911533c67ba 100644 --- a/frontend/pages/recipe/_slug/ingredient-parser.vue +++ b/frontend/pages/recipe/_slug/ingredient-parser.vue @@ -16,15 +16,14 @@ confidence score is displayed on the right of the title item. This is an average of all scores and may not be wholey accurate. -
+
Alerts will be displayed if a matching foods or unit is found but does not exists in the database.
- -
- Select Parser +
+
Select Parser
diff --git a/mealie/db/models/_model_utils/helpers.py b/mealie/db/models/_model_utils/helpers.py index 0f6d7a79154d..f62a063f3f5d 100644 --- a/mealie/db/models/_model_utils/helpers.py +++ b/mealie/db/models/_model_utils/helpers.py @@ -28,7 +28,7 @@ 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, **kwargs) -> Any: +def safe_call(func, dict_args: dict, **kwargs) -> Any: """ Safely calls the supplied function with the supplied dictionary of arguments. by removing any invalid arguments. diff --git a/mealie/db/models/recipe/instruction.py b/mealie/db/models/recipe/instruction.py index d9ef7d2d8218..06a5d21e57a3 100644 --- a/mealie/db/models/recipe/instruction.py +++ b/mealie/db/models/recipe/instruction.py @@ -33,5 +33,5 @@ class RecipeInstruction(SqlAlchemyBase): } @auto_init() - def __init__(self, ingredient_references, **_) -> None: - self.ingredient_references = [RecipeIngredientRefLink(**ref) for ref in ingredient_references] + def __init__(self, ingredient_references, session, **_) -> None: + self.ingredient_references = [RecipeIngredientRefLink(**ref, session=session) for ref in ingredient_references] diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index e7d4ad731e98..8e7e2d594f22 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -129,6 +129,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): "notes", "nutrition", "recipe_ingredient", + "recipe_instructions", "settings", } @@ -146,10 +147,12 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): notes: list[dict] = None, nutrition: dict = None, recipe_ingredient: list[dict] = None, + recipe_instructions: list[dict] = None, settings: dict = None, **_, ) -> None: self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition() + self.recipe_instructions = [RecipeInstruction(**step, session=session) for step in recipe_instructions] self.recipe_ingredient = [RecipeIngredient(**ingr, session=session) for ingr in recipe_ingredient] self.assets = [RecipeAsset(**a) for a in assets] diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 0cc042c59dfc..ce5cf87f7441 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -16,6 +16,7 @@ from mealie.core import exceptions from mealie.core.dependencies import temporary_zip_path from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token from mealie.core.security import create_recipe_slug_token +from mealie.pkgs import cache from mealie.repos.all_repositories import get_repositories from mealie.repos.repository_recipes import RepositoryRecipes from mealie.routes._base import BaseUserController, controller @@ -267,6 +268,9 @@ class RecipeController(BaseRecipeController): data_service = RecipeDataService(recipe.id) data_service.scrape_image(url.url) + recipe.image = cache.cache_key.new_key() + self.service.update_one(recipe.slug, recipe) + @router.put("/{slug}/image", response_model=UpdateImageResponse, tags=["Recipe: Images and Assets"]) def update_recipe_image(self, slug: str, image: bytes = File(...), extension: str = Form(...)): recipe = self.mixins.get_one(slug) @@ -286,7 +290,7 @@ class RecipeController(BaseRecipeController): file: UploadFile = File(...), ): """Upload a file to store as a recipe asset""" - file_name = slugify(name) + "." + extension + file_name = f"{slugify(name)}.{extension}" asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name) recipe = self.mixins.get_one(slug) diff --git a/mealie/schema/recipe/recipe_step.py b/mealie/schema/recipe/recipe_step.py index efbc288ed117..ac08c2454d7b 100644 --- a/mealie/schema/recipe/recipe_step.py +++ b/mealie/schema/recipe/recipe_step.py @@ -2,7 +2,7 @@ from typing import Optional from uuid import UUID, uuid4 from fastapi_camelcase import CamelModel -from pydantic import Field +from pydantic import UUID4, Field class IngredientReferences(CamelModel): @@ -10,7 +10,7 @@ class IngredientReferences(CamelModel): A list of ingredient references. """ - reference_id: UUID = None + reference_id: Optional[UUID4] class Config: orm_mode = True diff --git a/tests/fixtures/fixture_recipe.py b/tests/fixtures/fixture_recipe.py index cfbd4c70328d..a8eceff5178f 100644 --- a/tests/fixtures/fixture_recipe.py +++ b/tests/fixtures/fixture_recipe.py @@ -5,6 +5,7 @@ from mealie.repos.repository_factory import AllRepositories from mealie.schema.recipe.recipe import Recipe, RecipeCategory from mealie.schema.recipe.recipe_category import CategorySave from mealie.schema.recipe.recipe_ingredient import RecipeIngredient +from mealie.schema.recipe.recipe_step import RecipeStep from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser from tests.utils.recipe_data import get_raw_no_image, get_raw_recipe, get_recipe_test_cases @@ -70,3 +71,31 @@ def recipe_categories(database: AllRepositories, unique_user: TestUser) -> list[ database.categories.delete(model.id) except sqlalchemy.exc.NoResultFound: pass + + +@fixture(scope="function") +def random_recipe(database: AllRepositories, unique_user: TestUser) -> Recipe: + recipe = Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(10), + recipe_ingredient=[ + RecipeIngredient(note="Ingredient 1"), + RecipeIngredient(note="Ingredient 2"), + RecipeIngredient(note="Ingredient 3"), + ], + recipe_instructions=[ + RecipeStep(text="Step 1"), + RecipeStep(text="Step 2"), + RecipeStep(text="Step 3"), + ], + ) + + model = database.recipes.create(recipe) + + yield model + + try: + database.recipes.delete(model.slug) + except sqlalchemy.exc.NoResultFound: + pass diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_steps.py b/tests/integration_tests/user_recipe_tests/test_recipe_steps.py new file mode 100644 index 000000000000..0c1c2cff50e0 --- /dev/null +++ b/tests/integration_tests/user_recipe_tests/test_recipe_steps.py @@ -0,0 +1,49 @@ +import json +import random + +from fastapi.testclient import TestClient + +from mealie.schema.recipe.recipe import Recipe +from mealie.schema.recipe.recipe_step import IngredientReferences +from tests.utils import jsonify, routes +from tests.utils.fixture_schemas import TestUser + + +def test_associate_ingredient_with_step(api_client: TestClient, unique_user: TestUser, random_recipe: Recipe): + recipe: Recipe = random_recipe + + # Associate an ingredient with a step + + steps = {} # key=step_id, value=ingredient_id + + for idx, step in enumerate(recipe.recipe_instructions): + ingredients = random.choices(recipe.recipe_ingredient, k=2) + + step.ingredient_references = [ + IngredientReferences(reference_id=ingredient.reference_id) for ingredient in ingredients + ] + + steps[idx] = [str(ingredient.reference_id) for ingredient in ingredients] + + response = api_client.put( + routes.RoutesRecipe.item(recipe.slug), + json=jsonify(recipe.dict()), + headers=unique_user.token, + ) + + assert response.status_code == 200 + + # Get Recipe and check that the ingredient is associated with the step + + response = api_client.get(routes.RoutesRecipe.item(recipe.slug), headers=unique_user.token) + + assert response.status_code == 200 + + recipe = json.loads(response.text) + + for idx, step in enumerate(recipe.get("recipeInstructions")): + all_refs = [ref["referenceId"] for ref in step.get("ingredientReferences")] + + assert len(all_refs) == 2 + + assert all(ref in steps[idx] for ref in all_refs)