diff --git a/frontend/components/Domain/Recipe/RecipeLastMade.vue b/frontend/components/Domain/Recipe/RecipeLastMade.vue index c72b9ccf172e..a30912d8c19d 100644 --- a/frontend/components/Domain/Recipe/RecipeLastMade.vue +++ b/frontend/components/Domain/Recipe/RecipeLastMade.vue @@ -126,8 +126,7 @@ export default defineComponent({ // we also update the recipe's last made value if (!props.value || newTimelineEvent.value.timestamp > props.value) { - const payload = {lastMade: newTimelineEvent.value.timestamp}; - actions.push(userApi.recipes.patchOne(props.recipeSlug, payload)); + actions.push(userApi.recipes.updateLastMade(props.recipeSlug, newTimelineEvent.value.timestamp)); // update recipe in parent so the user can see it // we remove the trailing "Z" since this is how the API returns it diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index f5d7cc808460..3b5a8617a448 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -303,6 +303,9 @@ export interface RecipeCommentUpdate { export interface RecipeDuplicate { name?: string; } +export interface RecipeLastMade { + timestamp: string; +} export interface RecipeShareToken { recipeId: string; expiresAt?: string; diff --git a/frontend/lib/api/user/recipes/recipe.ts b/frontend/lib/api/user/recipes/recipe.ts index b840b47b8c53..65080b143340 100644 --- a/frontend/lib/api/user/recipes/recipe.ts +++ b/frontend/lib/api/user/recipes/recipe.ts @@ -10,6 +10,7 @@ import { ParsedIngredient, UpdateImageResponse, RecipeZipTokenResponse, + RecipeLastMade, RecipeTimelineEventIn, RecipeTimelineEventOut, RecipeTimelineEventUpdate, @@ -48,6 +49,8 @@ const routes = { recipesSlugComments: (slug: string) => `${prefix}/recipes/${slug}/comments`, recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`, + recipesSlugLastMade: (slug: string) => `${prefix}/recipes/${slug}/last-made`, + recipesSlugTimelineEvent: (slug: string) => `${prefix}/recipes/${slug}/timeline/events`, recipesSlugTimelineEventId: (slug: string, id: string) => `${prefix}/recipes/${slug}/timeline/events/${id}`, }; @@ -164,6 +167,10 @@ export class RecipeAPI extends BaseCRUDAPI { return await this.requests.post(routes.recipesCreateFromOcr, formData); } + async updateLastMade(recipeSlug: string, timestamp: string) { + return await this.requests.patch(routes.recipesSlugLastMade(recipeSlug), { timestamp }) + } + async createTimelineEvent(recipeSlug: string, payload: RecipeTimelineEventIn) { return await this.requests.post(routes.recipesSlugTimelineEvent(recipeSlug), payload); } diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 13be88be5313..a72888563027 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -24,7 +24,12 @@ from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe -from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary +from mealie.schema.recipe.recipe import ( + CreateRecipe, + CreateRecipeByUrlBulk, + RecipeLastMade, + RecipeSummary, +) from mealie.schema.recipe.recipe_asset import RecipeAsset from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest @@ -367,6 +372,28 @@ class RecipeController(BaseRecipeController): return recipe + @router.patch("/{slug}/last-made") + def update_last_made(self, slug: str, data: RecipeLastMade): + """Update a recipe's last made timestamp""" + + try: + recipe = self.service.update_last_made(slug, data.timestamp) + except Exception as e: + self.handle_exceptions(e) + + if recipe: + self.publish_event( + event_type=EventTypes.recipe_updated, + document_data=EventRecipeData(operation=EventOperation.update, recipe_slug=recipe.slug), + message=self.t( + "notifications.generic-updated-with-url", + name=recipe.name, + url=urls.recipe_url(recipe.slug, self.settings.BASE_URL), + ), + ) + + return recipe + @router.delete("/{slug}") def delete_one(self, slug: str): """Deletes a recipe by slug""" diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 3338add425a9..4a04a0c1df78 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -200,6 +200,10 @@ class Recipe(RecipeSummary): return user_id +class RecipeLastMade(BaseModel): + timestamp: datetime.datetime + + from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402 RecipeSummary.update_forward_refs() diff --git a/mealie/services/backups_v2/alchemy_exporter.py b/mealie/services/backups_v2/alchemy_exporter.py index d4aa1dc7218c..10a4a66ab641 100644 --- a/mealie/services/backups_v2/alchemy_exporter.py +++ b/mealie/services/backups_v2/alchemy_exporter.py @@ -16,7 +16,7 @@ class AlchemyExporter(BaseService): engine: base.Engine meta: MetaData - look_for_datetime = {"created_at", "update_at", "date_updated", "timestamp", "expires_at", "locked_at"} + look_for_datetime = {"created_at", "update_at", "date_updated", "timestamp", "expires_at", "locked_at", "last_made"} look_for_date = {"date_added", "date"} look_for_time = {"scheduled_time"} diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 5e8d75240e55..08df0c06cfad 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -247,6 +247,7 @@ class RecipeService(BaseService): Args: slug (str): recipe slug + new_data (Recipe): the new recipe data Raises: exceptions.PermissionDenied (403) @@ -275,7 +276,7 @@ class RecipeService(BaseService): def patch_one(self, slug: str, patch_data: Recipe) -> Recipe: recipe: Recipe | None = self._pre_update_check(slug, patch_data) - recipe = self.repos.recipes.by_group(self.group.id).get_one(slug) + recipe = self._get_recipe(slug) if recipe is None: raise exceptions.NoEntryFound("Recipe not found.") @@ -285,6 +286,11 @@ class RecipeService(BaseService): self.check_assets(new_data, recipe.slug) return new_data + def update_last_made(self, slug: str, timestamp: datetime) -> Recipe: + # we bypass the pre update check since any user can update a recipe's last made date, even if it's locked + recipe = self._get_recipe(slug) + return self.repos.recipes.by_group(self.group.id).patch(recipe.slug, {"last_made": timestamp}) + def delete_one(self, slug) -> Recipe: recipe = self._get_recipe(slug) diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py index a9a2f843167e..3a1e0daff155 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py @@ -1,3 +1,5 @@ +from datetime import datetime + from fastapi.testclient import TestClient from tests.utils import api_routes @@ -86,6 +88,36 @@ def test_user_locked_recipe(api_client: TestClient, user_tuple: list[TestUser]) assert response.status_code == 403 +def test_user_update_last_made(api_client: TestClient, user_tuple: list[TestUser]) -> None: + usr_1, usr_2 = user_tuple + + # Setup Recipe + recipe_name = random_string() + response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=usr_1.token) + assert response.status_code == 201 + + # Get Recipe + response = api_client.get(api_routes.recipes + f"/{recipe_name}", headers=usr_1.token) + assert response.status_code == 200 + recipe = response.json() + + # Lock Recipe + recipe["settings"]["locked"] = True + response = api_client.put(api_routes.recipes + f"/{recipe_name}", json=recipe, headers=usr_1.token) + + # User 2 should be able to update the last made timestamp + last_made_json = {"timestamp": datetime.now().isoformat()} + response = api_client.patch( + api_routes.recipes_slug_last_made(recipe_name), json=last_made_json, headers=usr_2.token + ) + assert response.status_code == 200 + + response = api_client.get(api_routes.recipes + f"/{recipe_name}", headers=usr_1.token) + assert response.status_code == 200 + recipe = response.json() + assert recipe["lastMade"] == last_made_json["timestamp"] + + def test_other_user_cant_lock_recipe(api_client: TestClient, user_tuple: list[TestUser]) -> None: usr_1, usr_2 = user_tuple diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index 8d8cebf17347..44fef50ba26a 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -381,6 +381,11 @@ def recipes_slug_image(slug): return f"{prefix}/recipes/{slug}/image" +def recipes_slug_last_made(slug): + """`/api/recipes/{slug}/last-made`""" + return f"{prefix}/recipes/{slug}/last-made" + + def recipes_slug_timeline_events(slug): """`/api/recipes/{slug}/timeline/events`""" return f"{prefix}/recipes/{slug}/timeline/events"