Fix: Allow Last Made to be Updated on Locked Recipes (#2140)

* allow certain props to be updated on locked recipe

* pytest

* added "last_made" to hardcoded datetime fields

* refactored last made to its own route

* codegen/types

* updated pytest
This commit is contained in:
Michael Genson 2023-02-21 21:59:22 -06:00 committed by GitHub
parent a6c46a7420
commit fd03d468d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 88 additions and 5 deletions

View File

@ -126,8 +126,7 @@ export default defineComponent({
// we also update the recipe's last made value // we also update the recipe's last made value
if (!props.value || newTimelineEvent.value.timestamp > props.value) { if (!props.value || newTimelineEvent.value.timestamp > props.value) {
const payload = {lastMade: newTimelineEvent.value.timestamp}; actions.push(userApi.recipes.updateLastMade(props.recipeSlug, newTimelineEvent.value.timestamp));
actions.push(userApi.recipes.patchOne(props.recipeSlug, payload));
// update recipe in parent so the user can see it // update recipe in parent so the user can see it
// we remove the trailing "Z" since this is how the API returns it // we remove the trailing "Z" since this is how the API returns it

View File

@ -303,6 +303,9 @@ export interface RecipeCommentUpdate {
export interface RecipeDuplicate { export interface RecipeDuplicate {
name?: string; name?: string;
} }
export interface RecipeLastMade {
timestamp: string;
}
export interface RecipeShareToken { export interface RecipeShareToken {
recipeId: string; recipeId: string;
expiresAt?: string; expiresAt?: string;

View File

@ -10,6 +10,7 @@ import {
ParsedIngredient, ParsedIngredient,
UpdateImageResponse, UpdateImageResponse,
RecipeZipTokenResponse, RecipeZipTokenResponse,
RecipeLastMade,
RecipeTimelineEventIn, RecipeTimelineEventIn,
RecipeTimelineEventOut, RecipeTimelineEventOut,
RecipeTimelineEventUpdate, RecipeTimelineEventUpdate,
@ -48,6 +49,8 @@ const routes = {
recipesSlugComments: (slug: string) => `${prefix}/recipes/${slug}/comments`, recipesSlugComments: (slug: string) => `${prefix}/recipes/${slug}/comments`,
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`, 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`, recipesSlugTimelineEvent: (slug: string) => `${prefix}/recipes/${slug}/timeline/events`,
recipesSlugTimelineEventId: (slug: string, id: string) => `${prefix}/recipes/${slug}/timeline/events/${id}`, recipesSlugTimelineEventId: (slug: string, id: string) => `${prefix}/recipes/${slug}/timeline/events/${id}`,
}; };
@ -164,6 +167,10 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post(routes.recipesCreateFromOcr, formData); return await this.requests.post(routes.recipesCreateFromOcr, formData);
} }
async updateLastMade(recipeSlug: string, timestamp: string) {
return await this.requests.patch<Recipe, RecipeLastMade>(routes.recipesSlugLastMade(recipeSlug), { timestamp })
}
async createTimelineEvent(recipeSlug: string, payload: RecipeTimelineEventIn) { async createTimelineEvent(recipeSlug: string, payload: RecipeTimelineEventIn) {
return await this.requests.post<RecipeTimelineEventOut>(routes.recipesSlugTimelineEvent(recipeSlug), payload); return await this.requests.post<RecipeTimelineEventOut>(routes.recipesSlugTimelineEvent(recipeSlug), payload);
} }

View File

@ -24,7 +24,12 @@ from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe 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_asset import RecipeAsset
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
@ -367,6 +372,28 @@ class RecipeController(BaseRecipeController):
return recipe 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}") @router.delete("/{slug}")
def delete_one(self, slug: str): def delete_one(self, slug: str):
"""Deletes a recipe by slug""" """Deletes a recipe by slug"""

View File

@ -200,6 +200,10 @@ class Recipe(RecipeSummary):
return user_id return user_id
class RecipeLastMade(BaseModel):
timestamp: datetime.datetime
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402 from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402
RecipeSummary.update_forward_refs() RecipeSummary.update_forward_refs()

View File

@ -16,7 +16,7 @@ class AlchemyExporter(BaseService):
engine: base.Engine engine: base.Engine
meta: MetaData 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_date = {"date_added", "date"}
look_for_time = {"scheduled_time"} look_for_time = {"scheduled_time"}

View File

@ -247,6 +247,7 @@ class RecipeService(BaseService):
Args: Args:
slug (str): recipe slug slug (str): recipe slug
new_data (Recipe): the new recipe data
Raises: Raises:
exceptions.PermissionDenied (403) exceptions.PermissionDenied (403)
@ -275,7 +276,7 @@ class RecipeService(BaseService):
def patch_one(self, slug: str, patch_data: Recipe) -> Recipe: def patch_one(self, slug: str, patch_data: Recipe) -> Recipe:
recipe: Recipe | None = self._pre_update_check(slug, patch_data) 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: if recipe is None:
raise exceptions.NoEntryFound("Recipe not found.") raise exceptions.NoEntryFound("Recipe not found.")
@ -285,6 +286,11 @@ class RecipeService(BaseService):
self.check_assets(new_data, recipe.slug) self.check_assets(new_data, recipe.slug)
return new_data 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: def delete_one(self, slug) -> Recipe:
recipe = self._get_recipe(slug) recipe = self._get_recipe(slug)

View File

@ -1,3 +1,5 @@
from datetime import datetime
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.utils import api_routes 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 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: def test_other_user_cant_lock_recipe(api_client: TestClient, user_tuple: list[TestUser]) -> None:
usr_1, usr_2 = user_tuple usr_1, usr_2 = user_tuple

View File

@ -381,6 +381,11 @@ def recipes_slug_image(slug):
return f"{prefix}/recipes/{slug}/image" 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): def recipes_slug_timeline_events(slug):
"""`/api/recipes/{slug}/timeline/events`""" """`/api/recipes/{slug}/timeline/events`"""
return f"{prefix}/recipes/{slug}/timeline/events" return f"{prefix}/recipes/{slug}/timeline/events"