diff --git a/mealie/core/exceptions.py b/mealie/core/exceptions.py index 89ed9538e..077a8644d 100644 --- a/mealie/core/exceptions.py +++ b/mealie/core/exceptions.py @@ -46,3 +46,11 @@ class UserLockedOut(Exception): ... class MissingClaimException(Exception): ... + + +class IncompleteData(Exception): + """ + This exception is raised when a user sends incomplete data to the API + """ + + pass diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index c5946c49b..377897e30 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -84,6 +84,13 @@ class RecipeController(BaseRecipeController): elif thrownType == exceptions.NoEntryFound: self.logger.error("No Entry Found on recipe controller action") raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No Entry Found")) + elif thrownType == exceptions.IncompleteData: + missing = ex.args[-1] + self.logger.error("Incomplete data provided to API route:", missing) + raise HTTPException( + status_code=400, + detail=ErrorResponse.respond(message=f"Field '{missing}' missing on the body of this API request"), + ) elif thrownType == sqlalchemy.exc.IntegrityError: self.logger.error("SQL Integrity Error on recipe controller action") raise HTTPException(status_code=400, detail=ErrorResponse.respond(message="Recipe already exists")) @@ -360,7 +367,13 @@ class RecipeController(BaseRecipeController): @router.put("/{slug}") def update_one(self, slug: str, data: Recipe): - """Updates a recipe by existing slug and data.""" + """ + Updates a recipe by existing slug and data. + + Requires all the fields of the recipe to be passed in the body + + If you wish to update parts of the recipe only, use a PATCH request to this route + """ try: recipe = self.service.update_one(slug, data) except Exception as e: diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 381793eb3..a5dcf7f77 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -36,6 +36,15 @@ from mealie.services.scraper import cleaner from .template_service import TemplateService +MANDATORY_FIELDS = [ + "name", + "user_id", + "household_id", + "group_id", + "recipe_ingredient", + "recipe_instructions", +] + class RecipeServiceBase(BaseService): def __init__(self, repos: AllRepositories, user: PrivateUser, household: HouseholdInDB, translator: Translator): @@ -144,6 +153,7 @@ class RecipeService(RecipeServiceBase): if not additional_attrs.get("recipe_instructions"): additional_attrs["recipe_instructions"] = [RecipeStep(text=self.t("recipe.recipe-defaults.step-text"))] + assert all(f in additional_attrs for f in MANDATORY_FIELDS) return Recipe(**additional_attrs) def get_one(self, slug_or_id: str | UUID) -> Recipe: @@ -369,7 +379,7 @@ class RecipeService(RecipeServiceBase): return new_recipe - def _pre_update_check(self, slug_or_id: str | UUID, new_data: Recipe) -> Recipe: + def _pre_update_check(self, slug_or_id: str | UUID, new_data: Recipe, strict: bool) -> Recipe: """ gets the recipe from the database and performs a check to see if the user can update the recipe. If the user can't update the recipe, an exception is raised. @@ -382,6 +392,7 @@ class RecipeService(RecipeServiceBase): Args: slug_or_id (str | UUID): recipe slug or id new_data (Recipe): the new recipe data + strict: if the data provided must contain ALL fields, or can be incomplete Raises: exceptions.PermissionDenied (403) @@ -389,6 +400,11 @@ class RecipeService(RecipeServiceBase): recipe = self.get_one(slug_or_id) + if strict: + for field in new_data.__class__.model_fields: + if field in MANDATORY_FIELDS and getattr(new_data, field) is None: + raise exceptions.IncompleteData("Incomplete recipe", field) + if recipe is None or recipe.settings is None: raise exceptions.NoEntryFound("Recipe not found.") @@ -402,7 +418,7 @@ class RecipeService(RecipeServiceBase): return recipe def update_one(self, slug_or_id: str | UUID, update_data: Recipe) -> Recipe: - recipe = self._pre_update_check(slug_or_id, update_data) + recipe = self._pre_update_check(slug_or_id, update_data, True) new_data = self.group_recipes.update(recipe.slug, update_data) self.check_assets(new_data, recipe.slug) @@ -419,7 +435,7 @@ class RecipeService(RecipeServiceBase): return self.group_recipes.update_image(slug, extension) def patch_one(self, slug_or_id: str | UUID, patch_data: Recipe) -> Recipe: - recipe: Recipe = self._pre_update_check(slug_or_id, patch_data) + recipe: Recipe = self._pre_update_check(slug_or_id, patch_data, False) new_data = self.group_recipes.patch(recipe.slug, patch_data.model_dump(exclude_unset=True)) diff --git a/tests/integration_tests/category_tag_tool_tests/test_organizers_common.py b/tests/integration_tests/category_tag_tool_tests/test_organizers_common.py index 733bdebd2..7f558bd9a 100644 --- a/tests/integration_tests/category_tag_tool_tests/test_organizers_common.py +++ b/tests/integration_tests/category_tag_tool_tests/test_organizers_common.py @@ -109,6 +109,7 @@ def test_organizer_update( item[key] = update_data[key] response = api_client.put(route.item(item_id), json=item, headers=unique_user.token) + print(response) assert response.status_code == 200 response = api_client.get(route.item(item_id), headers=unique_user.token) @@ -175,7 +176,7 @@ def test_organizer_association( ] # Update Recipe - response = api_client.put(api_routes.recipes_slug(slug), json=as_json, headers=unique_user.token) + response = api_client.patch(api_routes.recipes_slug(slug), json=as_json, headers=unique_user.token) assert response.status_code == 200 # Get Recipe Data @@ -223,7 +224,7 @@ def test_organizer_get_by_slug( {"id": item["id"], "group_id": unique_user.group_id, "name": item["name"], "slug": item["slug"]} ] - response = api_client.put(api_routes.recipes_slug(slug), json=as_json, headers=unique_user.token) + response = api_client.patch(api_routes.recipes_slug(slug), json=as_json, headers=unique_user.token) assert response.status_code == 200 # Get Organizer by Slug diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py index b87200fc6..d98152019 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -470,6 +470,11 @@ def test_read_update( assert response.status_code == 200 assert json.loads(response.text).get("slug") == recipe_data.expected_slug + recipe_incomplete = recipe.copy() + del recipe_incomplete["group_id"] + response = api_client.put(recipe_url, json=utils.jsonify(recipe_incomplete), headers=unique_user.token) + assert response.status_code == 400 + response = api_client.get(recipe_url, headers=unique_user.token) assert response.status_code == 200 recipe = json.loads(response.text)