diff --git a/frontend/components/Domain/Recipe/RecipeLastMade.vue b/frontend/components/Domain/Recipe/RecipeLastMade.vue index 5e0863552519..4b054e43d664 100644 --- a/frontend/components/Domain/Recipe/RecipeLastMade.vue +++ b/frontend/components/Domain/Recipe/RecipeLastMade.vue @@ -18,30 +18,49 @@ persistent-hint rows="4" > - - - - + + + + + + + + + + + + + + @@ -101,34 +120,41 @@ export default defineComponent({ timestamp: undefined, recipeId: props.recipe?.id || "", }); + const newTimelineEventImage = ref(); + const newTimelineEventTimestamp = ref(); whenever( () => madeThisDialog.value, () => { // Set timestamp to now - newTimelineEvent.value.timestamp = ( + newTimelineEventTimestamp.value = ( new Date(Date.now() - (new Date()).getTimezoneOffset() * 60000) ).toISOString().substring(0, 10); } ); + function uploadImage(fileObject: File) { + newTimelineEventImage.value = fileObject; + } + const state = reactive({datePickerMenu: false}); async function createTimelineEvent() { - if (!(newTimelineEvent.value.timestamp && props.recipe?.id && props.recipe?.slug)) { + if (!(newTimelineEventTimestamp.value && props.recipe?.id && props.recipe?.slug)) { return; } newTimelineEvent.value.recipeId = props.recipe.id - const actions: Promise[] = []; // the user only selects the date, so we set the time to end of day local time // we choose the end of day so it always comes after "new recipe" events - newTimelineEvent.value.timestamp = new Date(newTimelineEvent.value.timestamp + "T23:59:59").toISOString(); - actions.push(userApi.recipes.createTimelineEvent(newTimelineEvent.value)); + newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestamp.value + "T23:59:59").toISOString(); + + const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value); + const newEvent = eventResponse.data; // we also update the recipe's last made value if (!props.value || newTimelineEvent.value.timestamp > props.value) { - actions.push(userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp)); + await userApi.recipes.updateLastMade(props.recipe.slug, 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 @@ -138,12 +164,23 @@ export default defineComponent({ ); } - await Promise.allSettled(actions); + // update the image, if provided + if (newTimelineEventImage.value && newEvent) { + const imageResponse = await userApi.recipes.updateTimelineEventImage(newEvent.id, newTimelineEventImage.value); + if (imageResponse.data) { + // @ts-ignore the image response data will always match a value of TimelineEventImage + newEvent.image = imageResponse.data.image; + } + } // reset form newTimelineEvent.value.eventMessage = ""; + newTimelineEvent.value.timestamp = undefined; + newTimelineEventImage.value = undefined; madeThisDialog.value = false; domMadeThisForm.value?.reset(); + + context.emit("eventCreated", newEvent); } return { @@ -151,7 +188,10 @@ export default defineComponent({ domMadeThisForm, madeThisDialog, newTimelineEvent, + newTimelineEventImage, + newTimelineEventTimestamp, createTimelineEvent, + uploadImage, }; }, }); diff --git a/frontend/components/Domain/Recipe/RecipeTimeline.vue b/frontend/components/Domain/Recipe/RecipeTimeline.vue index 1f6c302c6037..9089d0f0f1f1 100644 --- a/frontend/components/Domain/Recipe/RecipeTimeline.vue +++ b/frontend/components/Domain/Recipe/RecipeTimeline.vue @@ -107,9 +107,7 @@ export default defineComponent({ whenever( () => props.value, () => { - if (!ready.value) { - initializeTimelineEvents(); - } + initializeTimelineEvents(); } ); diff --git a/frontend/components/Domain/Recipe/RecipeTimelineContextMenu.vue b/frontend/components/Domain/Recipe/RecipeTimelineContextMenu.vue index 501c44819f3e..57ae9d9d8c21 100644 --- a/frontend/components/Domain/Recipe/RecipeTimelineContextMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeTimelineContextMenu.vue @@ -45,7 +45,7 @@ content-class="d-print-none" > diff --git a/frontend/components/Domain/Recipe/RecipeTimelineItem.vue b/frontend/components/Domain/Recipe/RecipeTimelineItem.vue index f5674607cfdb..778f52309cc6 100644 --- a/frontend/components/Domain/Recipe/RecipeTimelineItem.vue +++ b/frontend/components/Domain/Recipe/RecipeTimelineItem.vue @@ -6,30 +6,30 @@ :icon="icon" > - + - + - + - + {{ $globals.icons.calendar }} {{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }} - + - {{ event.subject }} + {{ event.subject }} - - + + /> - + - + - + - + - + - {{ event.subject }} -
+ {{ event.subject }} + +
{{ event.eventMessage }} -
+
-
+
-
+
@@ -83,6 +93,7 @@ import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api"; import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue"; +import { useStaticRoutes } from "~/composables/api"; import { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe" import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; @@ -106,6 +117,7 @@ export default defineComponent({ setup(props) { const { $globals, $vuetify } = useContext(); + const { recipeTimelineEventImage } = useStaticRoutes(); const timelineEvents = ref([] as RecipeTimelineEventOut[]); const useMobileFormat = computed(() => { @@ -121,6 +133,10 @@ export default defineComponent({ size: "30px", class: "pr-0", }, + image: { + maxHeight: "250", + class: "my-3" + }, } } else { @@ -131,6 +147,10 @@ export default defineComponent({ size: "42px", class: "", }, + image: { + maxHeight: "300", + class: "mb-5" + }, } } }) @@ -151,9 +171,20 @@ export default defineComponent({ }; }) + const hideImage = ref(false); + const eventImageUrl = computed( () => { + if (props.event.image !== "has image") { + return ""; + } + + return recipeTimelineEventImage(props.event.recipeId, props.event.id); + }) + return { attrs, icon, + eventImageUrl, + hideImage, timelineEvents, useMobileFormat, }; diff --git a/frontend/composables/api/static-routes.ts b/frontend/composables/api/static-routes.ts index 65429fd3390e..749a50bb4809 100644 --- a/frontend/composables/api/static-routes.ts +++ b/frontend/composables/api/static-routes.ts @@ -30,6 +30,18 @@ export const useStaticRoutes = () => { )}`; } + function recipeTimelineEventImage(recipeId: string, timelineEventId: string) { + return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/original.webp`; + } + + function recipeTimelineEventSmallImage(recipeId: string, timelineEventId: string) { + return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/min-original.webp`; + } + + function recipeTimelineEventTinyImage(recipeId: string, timelineEventId: string) { + return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/tiny-original.webp`; + } + function recipeAssetPath(recipeId: string, assetName: string) { return `${fullBase}/media/recipes/${recipeId}/assets/${assetName}`; } @@ -38,6 +50,9 @@ export const useStaticRoutes = () => { recipeImage, recipeSmallImage, recipeTinyImage, + recipeTimelineEventImage, + recipeTimelineEventSmallImage, + recipeTimelineEventTinyImage, recipeAssetPath, }; }; diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index a281dcd0b603..6b73e604a81d 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -1,13 +1,14 @@ /* tslint:disable */ /* eslint-disable */ /** - /* This file was automatically generated from pydantic models by running pydantic2ts. - /* Do not modify it by hand - just update the pydantic models and then re-run the script - */ +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ export type ExportTypes = "json"; export type RegisteredParser = "nlp" | "brute"; export type TimelineEventType = "system" | "info" | "comment"; +export type TimelineEventImage = "has image" | "does not have image"; export interface AssignCategories { recipes: string[]; @@ -351,31 +352,31 @@ export interface RecipeTagResponse { recipes?: RecipeSummary[]; } export interface RecipeTimelineEventCreate { + recipeId: string; userId: string; subject: string; eventType: TimelineEventType; eventMessage?: string; - image?: string; + image?: TimelineEventImage; timestamp?: string; - recipeId: string; } export interface RecipeTimelineEventIn { + recipeId: string; userId?: string; subject: string; eventType: TimelineEventType; eventMessage?: string; - image?: string; + image?: TimelineEventImage; timestamp?: string; - recipeId: string; } export interface RecipeTimelineEventOut { + recipeId: string; userId: string; subject: string; eventType: TimelineEventType; eventMessage?: string; - image?: string; + image?: TimelineEventImage; timestamp?: string; - recipeId: string; id: string; createdAt: string; updateAt: string; @@ -383,7 +384,7 @@ export interface RecipeTimelineEventOut { export interface RecipeTimelineEventUpdate { subject: string; eventMessage?: string; - image?: string; + image?: TimelineEventImage; } export interface RecipeToolCreate { name: string; @@ -400,7 +401,7 @@ export interface RecipeToolResponse { onHand?: boolean; id: string; slug: string; - recipes?: Recipe[]; + recipes?: RecipeSummary[]; } export interface RecipeToolSave { name: string; diff --git a/frontend/lib/api/user/recipes/recipe.ts b/frontend/lib/api/user/recipes/recipe.ts index 879ffecdc37b..eef097671fef 100644 --- a/frontend/lib/api/user/recipes/recipe.ts +++ b/frontend/lib/api/user/recipes/recipe.ts @@ -52,6 +52,7 @@ const routes = { recipesSlugLastMade: (slug: string) => `${prefix}/recipes/${slug}/last-made`, recipesTimelineEventId: (id: string) => `${prefix}/recipes/timeline/events/${id}`, + recipesTimelineEventIdImage: (id: string) => `${prefix}/recipes/timeline/events/${id}/image`, }; export type RecipeSearchQuery = { @@ -194,4 +195,12 @@ export class RecipeAPI extends BaseCRUDAPI { } ); } + + async updateTimelineEventImage(eventId: string, fileObject: File) { + const formData = new FormData(); + formData.append("image", fileObject); + formData.append("extension", fileObject.name.split(".").pop() ?? ""); + + return await this.requests.put(routes.recipesTimelineEventIdImage(eventId), formData); + } } diff --git a/mealie/routes/_base/mixins.py b/mealie/routes/_base/mixins.py index 715d5279fd25..3de57a171ed9 100644 --- a/mealie/routes/_base/mixins.py +++ b/mealie/routes/_base/mixins.py @@ -98,14 +98,22 @@ class HttpRepo(Generic[C, R, U]): return item - def patch_one(self, data: U, item_id: int | str | UUID4) -> None: - self.repo.get_one(item_id) + def patch_one(self, data: U, item_id: int | str | UUID4) -> R: + item = self.repo.get_one(item_id) + + if not item: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=ErrorResponse.respond(message="Not found."), + ) try: - self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True)) + item = self.repo.patch(item_id, data.dict(exclude_unset=True, exclude_defaults=True)) except Exception as ex: self.handle_exception(ex) + return item + def delete_one(self, item_id: int | str | UUID4) -> R | None: item: R | None = None try: diff --git a/mealie/routes/media/media_recipe.py b/mealie/routes/media/media_recipe.py index d0f152a677d2..8eb28593b0a7 100644 --- a/mealie/routes/media/media_recipe.py +++ b/mealie/routes/media/media_recipe.py @@ -5,6 +5,7 @@ from pydantic import UUID4 from starlette.responses import FileResponse from mealie.schema.recipe import Recipe +from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventOut """ These routes are for development only! These assets are served by Caddy when not @@ -23,7 +24,7 @@ class ImageType(str, Enum): @router.get("/{recipe_id}/images/{file_name}") async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.original): """ - Takes in a recipe recipe_id, returns the static image. This route is proxied in the docker image + Takes in a recipe id, returns the static image. This route is proxied in the docker image and should not hit the API in production """ recipe_image = Recipe.directory_from_id(recipe_id).joinpath("images", file_name.value) @@ -34,6 +35,24 @@ async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.origin raise HTTPException(status.HTTP_404_NOT_FOUND) +@router.get("/{recipe_id}/images/timeline/{timeline_event_id}/{file_name}") +async def get_recipe_timeline_event_img( + recipe_id: str, timeline_event_id: str, file_name: ImageType = ImageType.original +): + """ + Takes in a recipe id and event timeline id, returns the static image. This route is proxied in the docker image + and should not hit the API in production + """ + timeline_event_image = RecipeTimelineEventOut.image_dir_from_id(recipe_id, timeline_event_id).joinpath( + file_name.value + ) + + if timeline_event_image.exists(): + return FileResponse(timeline_event_image) + else: + raise HTTPException(status.HTTP_404_NOT_FOUND) + + @router.get("/{recipe_id}/assets/{file_name}") async def get_recipe_asset(recipe_id: UUID4, file_name: str): """Returns a recipe asset""" diff --git a/mealie/routes/recipe/timeline_events.py b/mealie/routes/recipe/timeline_events.py index c6d820ac6b15..958a75fb66d6 100644 --- a/mealie/routes/recipe/timeline_events.py +++ b/mealie/routes/recipe/timeline_events.py @@ -1,6 +1,7 @@ +import shutil from functools import cached_property -from fastapi import Depends, HTTPException +from fastapi import Depends, File, Form, HTTPException from pydantic import UUID4 from mealie.routes._base import BaseCrudController, controller @@ -12,10 +13,13 @@ from mealie.schema.recipe.recipe_timeline_events import ( RecipeTimelineEventOut, RecipeTimelineEventPagination, RecipeTimelineEventUpdate, + TimelineEventImage, ) +from mealie.schema.recipe.request_helpers import UpdateImageResponse from mealie.schema.response.pagination import PaginationQuery from mealie.services import urls from mealie.services.event_bus_service.event_types import EventOperation, EventRecipeTimelineEventData, EventTypes +from mealie.services.recipe.recipe_data_service import RecipeDataService events_router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events") @@ -80,7 +84,7 @@ class RecipeTimelineEventsController(BaseCrudController): @events_router.put("/{item_id}", response_model=RecipeTimelineEventOut) def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate): - event = self.mixins.update_one(data, item_id) + event = self.mixins.patch_one(data, item_id) recipe = self.recipes_repo.get_one(event.recipe_id, "id") if recipe: self.publish_event( @@ -100,6 +104,12 @@ class RecipeTimelineEventsController(BaseCrudController): @events_router.delete("/{item_id}", response_model=RecipeTimelineEventOut) def delete_one(self, item_id: UUID4): event = self.mixins.delete_one(item_id) + if event.image_dir.exists(): + try: + shutil.rmtree(event.image_dir) + except FileNotFoundError: + pass + recipe = self.recipes_repo.get_one(event.recipe_id, "id") if recipe: self.publish_event( @@ -115,3 +125,31 @@ class RecipeTimelineEventsController(BaseCrudController): ) return event + + # ================================================================================================================== + # Image and Assets + + @events_router.put("/{item_id}/image", response_model=UpdateImageResponse) + def update_event_image(self, item_id: UUID4, image: bytes = File(...), extension: str = Form(...)): + event = self.mixins.get_one(item_id) + data_service = RecipeDataService(event.recipe_id) + data_service.write_image(image, extension, event.image_dir) + + if event.image != TimelineEventImage.has_image.value: + event.image = TimelineEventImage.has_image + event = self.mixins.patch_one(event.cast(RecipeTimelineEventUpdate), event.id) + recipe = self.recipes_repo.get_one(event.recipe_id, "id") + if recipe: + self.publish_event( + event_type=EventTypes.recipe_updated, + document_data=EventRecipeTimelineEventData( + operation=EventOperation.update, recipe_slug=recipe.slug, recipe_timeline_event_id=event.id + ), + message=self.t( + "notifications.generic-updated-with-url", + name=recipe.name, + url=urls.recipe_url(recipe.slug, self.settings.BASE_URL), + ), + ) + + return UpdateImageResponse(image=TimelineEventImage.has_image.value) diff --git a/mealie/schema/recipe/__init__.py b/mealie/schema/recipe/__init__.py index 272ce0a11244..b080b81b2ec5 100644 --- a/mealie/schema/recipe/__init__.py +++ b/mealie/schema/recipe/__init__.py @@ -77,6 +77,7 @@ from .recipe_timeline_events import ( RecipeTimelineEventOut, RecipeTimelineEventPagination, RecipeTimelineEventUpdate, + TimelineEventImage, TimelineEventType, ) from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave @@ -155,6 +156,7 @@ __all__ = [ "RecipeTimelineEventOut", "RecipeTimelineEventPagination", "RecipeTimelineEventUpdate", + "TimelineEventImage", "TimelineEventType", "RecipeToolCreate", "RecipeToolOut", diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 33a0b7e01c15..22c812e4e5f9 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -129,29 +129,48 @@ class Recipe(RecipeSummary): comments: list[RecipeCommentOut] | None = [] @staticmethod - def directory_from_id(recipe_id: UUID4 | str) -> Path: - return app_dirs.RECIPE_DATA_DIR.joinpath(str(recipe_id)) + def _get_dir(dir: Path) -> Path: + """Gets a directory and creates it if it doesn't exist""" + + dir.mkdir(exist_ok=True, parents=True) + return dir + + @classmethod + def directory_from_id(cls, recipe_id: UUID4 | str) -> Path: + return cls._get_dir(app_dirs.RECIPE_DATA_DIR.joinpath(str(recipe_id))) + + @classmethod + def asset_dir_from_id(cls, recipe_id: UUID4 | str) -> Path: + return cls._get_dir(cls.directory_from_id(recipe_id).joinpath("assets")) + + @classmethod + def image_dir_from_id(cls, recipe_id: UUID4 | str) -> Path: + return cls._get_dir(cls.directory_from_id(recipe_id).joinpath("images")) + + @classmethod + def timeline_image_dir_from_id(cls, recipe_id: UUID4 | str, timeline_event_id: UUID4 | str) -> Path: + return cls._get_dir(cls.image_dir_from_id(recipe_id).joinpath("timeline").joinpath(str(timeline_event_id))) @property def directory(self) -> Path: if not self.id: raise ValueError("Recipe has no ID") - folder = app_dirs.RECIPE_DATA_DIR.joinpath(str(self.id)) - folder.mkdir(exist_ok=True, parents=True) - return folder + return self.directory_from_id(self.id) @property def asset_dir(self) -> Path: - folder = self.directory.joinpath("assets") - folder.mkdir(exist_ok=True, parents=True) - return folder + if not self.id: + raise ValueError("Recipe has no ID") + + return self.asset_dir_from_id(self.id) @property def image_dir(self) -> Path: - folder = self.directory.joinpath("images") - folder.mkdir(exist_ok=True, parents=True) - return folder + if not self.id: + raise ValueError("Recipe has no ID") + + return self.image_dir_from_id(self.id) class Config: orm_mode = True diff --git a/mealie/schema/recipe/recipe_timeline_events.py b/mealie/schema/recipe/recipe_timeline_events.py index 8789432dc3d6..f344742dd919 100644 --- a/mealie/schema/recipe/recipe_timeline_events.py +++ b/mealie/schema/recipe/recipe_timeline_events.py @@ -1,11 +1,16 @@ from datetime import datetime from enum import Enum +from pathlib import Path from pydantic import UUID4, Field +from mealie.core.config import get_app_dirs from mealie.schema._mealie.mealie_model import MealieModel +from mealie.schema.recipe.recipe import Recipe from mealie.schema.response.pagination import PaginationBase +app_dirs = get_app_dirs() + class TimelineEventType(Enum): system = "system" @@ -13,6 +18,11 @@ class TimelineEventType(Enum): comment = "comment" +class TimelineEventImage(Enum): + has_image = "has image" + does_not_have_image = "does not have image" + + class RecipeTimelineEventIn(MealieModel): recipe_id: UUID4 user_id: UUID4 | None = None @@ -22,7 +32,7 @@ class RecipeTimelineEventIn(MealieModel): event_type: TimelineEventType message: str | None = Field(None, alias="eventMessage") - image: str | None = None + image: TimelineEventImage | None = TimelineEventImage.does_not_have_image timestamp: datetime = datetime.now() @@ -37,7 +47,10 @@ class RecipeTimelineEventCreate(RecipeTimelineEventIn): class RecipeTimelineEventUpdate(MealieModel): subject: str message: str | None = Field(alias="eventMessage") - image: str | None = None + image: TimelineEventImage | None = None + + class Config: + use_enum_values = True class RecipeTimelineEventOut(RecipeTimelineEventCreate): @@ -48,6 +61,14 @@ class RecipeTimelineEventOut(RecipeTimelineEventCreate): class Config: orm_mode = True + @classmethod + def image_dir_from_id(cls, recipe_id: UUID4 | str, timeline_event_id: UUID4 | str) -> Path: + return Recipe.timeline_image_dir_from_id(recipe_id, timeline_event_id) + + @property + def image_dir(self) -> Path: + return self.image_dir_from_id(self.recipe_id, self.id) + class RecipeTimelineEventPagination(PaginationBase): items: list[RecipeTimelineEventOut] diff --git a/mealie/services/recipe/recipe_data_service.py b/mealie/services/recipe/recipe_data_service.py index 7ac58f0578ba..59d42feb9b44 100644 --- a/mealie/services/recipe/recipe_data_service.py +++ b/mealie/services/recipe/recipe_data_service.py @@ -65,10 +65,11 @@ class RecipeDataService(BaseService): self.dir_data = Recipe.directory_from_id(self.recipe_id) self.dir_image = self.dir_data.joinpath("images") + self.dir_image_timeline = self.dir_image.joinpath("timeline") self.dir_assets = self.dir_data.joinpath("assets") - self.dir_image.mkdir(parents=True, exist_ok=True) - self.dir_assets.mkdir(parents=True, exist_ok=True) + for dir in [self.dir_image, self.dir_image_timeline, self.dir_assets]: + dir.mkdir(parents=True, exist_ok=True) def delete_all_data(self) -> None: try: @@ -76,9 +77,12 @@ class RecipeDataService(BaseService): except Exception as e: self.logger.exception(f"Failed to delete recipe data: {e}") - def write_image(self, file_data: bytes | Path, extension: str) -> Path: + def write_image(self, file_data: bytes | Path, extension: str, image_dir: Path | None = None) -> Path: + if not image_dir: + image_dir = self.dir_image + extension = extension.replace(".", "") - image_path = self.dir_image.joinpath(f"original.{extension}") + image_path = image_dir.joinpath(f"original.{extension}") image_path.unlink(missing_ok=True) if isinstance(file_data, Path): diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py b/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py index a50d7131f58b..bfe6497d9c6e 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_timeline_events.py @@ -4,7 +4,12 @@ import pytest from fastapi.testclient import TestClient from mealie.schema.recipe.recipe import Recipe -from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventOut, RecipeTimelineEventPagination +from mealie.schema.recipe.recipe_timeline_events import ( + RecipeTimelineEventOut, + RecipeTimelineEventPagination, + TimelineEventImage, +) +from mealie.schema.recipe.request_helpers import UpdateImageResponse from tests.utils import api_routes from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser @@ -142,8 +147,9 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re assert event_response.status_code == 200 updated_event = RecipeTimelineEventOut.parse_obj(event_response.json()) - assert new_event.id == updated_event.id + assert updated_event.id == new_event.id assert updated_event.subject == new_subject + assert updated_event.timestamp == new_event.timestamp def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]): @@ -221,6 +227,48 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU assert updated_event.message == new_message +def test_timeline_event_update_image( + api_client: TestClient, unique_user: TestUser, recipes: list[Recipe], test_image_jpg: str +): + # create an event + recipe = recipes[0] + new_event_data = { + "recipe_id": str(recipe.id), + "user_id": unique_user.user_id, + "subject": random_string(), + "message": random_string(), + "event_type": "info", + } + + event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token) + new_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + assert new_event.image == TimelineEventImage.does_not_have_image.value + + with open(test_image_jpg, "rb") as f: + r = api_client.put( + api_routes.recipes_timeline_events_item_id_image(new_event.id), + files={"image": ("test_image_jpg.jpg", f, "image/jpeg")}, + data={"extension": "jpg"}, + headers=unique_user.token, + ) + r.raise_for_status() + + update_image_response = UpdateImageResponse.parse_obj(r.json()) + assert update_image_response.image == TimelineEventImage.has_image.value + + event_response = api_client.get( + api_routes.recipes_timeline_events_item_id(new_event.id), + headers=unique_user.token, + ) + assert event_response.status_code == 200 + + updated_event = RecipeTimelineEventOut.parse_obj(event_response.json()) + assert updated_event.subject == new_event.subject + assert updated_event.message == new_event.message + assert updated_event.timestamp == new_event.timestamp + assert updated_event.image == TimelineEventImage.has_image.value + + def test_create_recipe_with_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]): # make sure when the recipes fixture was created that all recipes have at least one event for recipe in recipes: diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index fbc46c9b6195..b0d787737e1d 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -310,6 +310,11 @@ def media_recipes_recipe_id_images_file_name(recipe_id, file_name): return f"{prefix}/media/recipes/{recipe_id}/images/{file_name}" +def media_recipes_recipe_id_images_timeline_timeline_event_id_file_name(recipe_id, timeline_event_id, file_name): + """`/api/media/recipes/{recipe_id}/images/timeline/{timeline_event_id}/{file_name}`""" + return f"{prefix}/media/recipes/{recipe_id}/images/timeline/{timeline_event_id}/{file_name}" + + def media_users_user_id_file_name(user_id, file_name): """`/api/media/users/{user_id}/{file_name}`""" return f"{prefix}/media/users/{user_id}/{file_name}" @@ -395,6 +400,11 @@ def recipes_timeline_events_item_id(item_id): return f"{prefix}/recipes/timeline/events/{item_id}" +def recipes_timeline_events_item_id_image(item_id): + """`/api/recipes/timeline/events/{item_id}/image`""" + return f"{prefix}/recipes/timeline/events/{item_id}/image" + + def shared_recipes_item_id(item_id): """`/api/shared/recipes/{item_id}`""" return f"{prefix}/shared/recipes/{item_id}"