feat: Recipe Timeline Images (#2444)

* refactored recipe image paths/service

* added routes for updating/fetching timeline images

* make generate

* added event image upload and rendering

* switched update to patch to preserve timestamp

* added tests

* tweaked order of requests

* always reload events when opening the timeline

* re-arranged elements to make them look nicer

* delete files when timeline event is deleted
This commit is contained in:
Michael Genson 2023-08-06 12:49:30 -05:00 committed by GitHub
parent 06962cf865
commit dfe4942451
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 355 additions and 92 deletions

View File

@ -18,30 +18,49 @@
persistent-hint persistent-hint
rows="4" rows="4"
></v-textarea> ></v-textarea>
<v-menu <v-container>
v-model="datePickerMenu" <v-row>
:close-on-content-click="false" <v-col cols="auto">
transition="scale-transition" <v-menu
offset-y v-model="datePickerMenu"
max-width="290px" :close-on-content-click="false"
min-width="auto" transition="scale-transition"
> offset-y
<template #activator="{ on, attrs }"> max-width="290px"
<v-text-field min-width="auto"
v-model="newTimelineEvent.timestamp" >
:prepend-icon="$globals.icons.calendar" <template #activator="{ on, attrs }">
v-bind="attrs" <v-text-field
readonly v-model="newTimelineEventTimestamp"
v-on="on" :prepend-icon="$globals.icons.calendar"
></v-text-field> v-bind="attrs"
</template> readonly
<v-date-picker v-on="on"
v-model="newTimelineEvent.timestamp" ></v-text-field>
no-title </template>
:local="$i18n.locale" <v-date-picker
@input="datePickerMenu = false" v-model="newTimelineEventTimestamp"
/> no-title
</v-menu> :local="$i18n.locale"
@input="datePickerMenu = false"
/>
</v-menu>
</v-col>
<v-spacer />
<v-col cols="auto" align-self="center">
<AppButtonUpload
class="ml-auto"
url="none"
file-name="image"
accept="image/*"
:text="$i18n.tc('recipe.upload-image')"
:text-btn="false"
:post="false"
@uploaded="uploadImage"
/>
</v-col>
</v-row>
</v-container>
</v-form> </v-form>
</v-card-text> </v-card-text>
</BaseDialog> </BaseDialog>
@ -101,34 +120,41 @@ export default defineComponent({
timestamp: undefined, timestamp: undefined,
recipeId: props.recipe?.id || "", recipeId: props.recipe?.id || "",
}); });
const newTimelineEventImage = ref<File>();
const newTimelineEventTimestamp = ref<string>();
whenever( whenever(
() => madeThisDialog.value, () => madeThisDialog.value,
() => { () => {
// Set timestamp to now // Set timestamp to now
newTimelineEvent.value.timestamp = ( newTimelineEventTimestamp.value = (
new Date(Date.now() - (new Date()).getTimezoneOffset() * 60000) new Date(Date.now() - (new Date()).getTimezoneOffset() * 60000)
).toISOString().substring(0, 10); ).toISOString().substring(0, 10);
} }
); );
function uploadImage(fileObject: File) {
newTimelineEventImage.value = fileObject;
}
const state = reactive({datePickerMenu: false}); const state = reactive({datePickerMenu: false});
async function createTimelineEvent() { async function createTimelineEvent() {
if (!(newTimelineEvent.value.timestamp && props.recipe?.id && props.recipe?.slug)) { if (!(newTimelineEventTimestamp.value && props.recipe?.id && props.recipe?.slug)) {
return; return;
} }
newTimelineEvent.value.recipeId = props.recipe.id newTimelineEvent.value.recipeId = props.recipe.id
const actions: Promise<any>[] = [];
// the user only selects the date, so we set the time to end of day local time // 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 // 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(); newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestamp.value + "T23:59:59").toISOString();
actions.push(userApi.recipes.createTimelineEvent(newTimelineEvent.value));
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
const newEvent = eventResponse.data;
// 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) {
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 // 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
@ -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 // reset form
newTimelineEvent.value.eventMessage = ""; newTimelineEvent.value.eventMessage = "";
newTimelineEvent.value.timestamp = undefined;
newTimelineEventImage.value = undefined;
madeThisDialog.value = false; madeThisDialog.value = false;
domMadeThisForm.value?.reset(); domMadeThisForm.value?.reset();
context.emit("eventCreated", newEvent);
} }
return { return {
@ -151,7 +188,10 @@ export default defineComponent({
domMadeThisForm, domMadeThisForm,
madeThisDialog, madeThisDialog,
newTimelineEvent, newTimelineEvent,
newTimelineEventImage,
newTimelineEventTimestamp,
createTimelineEvent, createTimelineEvent,
uploadImage,
}; };
}, },
}); });

View File

@ -107,9 +107,7 @@ export default defineComponent({
whenever( whenever(
() => props.value, () => props.value,
() => { () => {
if (!ready.value) { initializeTimelineEvents();
initializeTimelineEvents();
}
} }
); );

View File

@ -45,7 +45,7 @@
content-class="d-print-none" content-class="d-print-none"
> >
<template #activator="{ on, attrs }"> <template #activator="{ on, attrs }">
<v-btn :fab="fab" :small="fab" :elevation="elevation" :color="color" :icon="!fab" v-bind="attrs" v-on="on" @click.prevent> <v-btn :fab="fab" :x-small="fab" :elevation="elevation" :color="color" :icon="!fab" v-bind="attrs" v-on="on" @click.prevent>
<v-icon>{{ icon }}</v-icon> <v-icon>{{ icon }}</v-icon>
</v-btn> </v-btn>
</template> </template>

View File

@ -6,30 +6,30 @@
:icon="icon" :icon="icon"
> >
<template v-if="!useMobileFormat" #opposite> <template v-if="!useMobileFormat" #opposite>
<v-chip v-if="event.timestamp" label large> <v-chip v-if="event.timestamp" label large>
<v-icon class="mr-1"> {{ $globals.icons.calendar }} </v-icon> <v-icon class="mr-1"> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }} {{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
</v-chip> </v-chip>
</template> </template>
<v-card> <v-card>
<v-sheet> <v-sheet>
<v-card-title> <v-card-title>
<v-row> <v-row>
<v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'" :class="attrs.avatar.class"> <v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'" :class="attrs.avatar.class">
<UserAvatar :user-id="event.userId" :size="attrs.avatar.size" /> <UserAvatar :user-id="event.userId" :size="attrs.avatar.size" />
</v-col> </v-col>
<v-col v-if="useMobileFormat" align-self="center" class="pr-0"> <v-col v-if="useMobileFormat" align-self="center" class="pr-0">
<v-chip label> <v-chip label>
<v-icon> {{ $globals.icons.calendar }} </v-icon> <v-icon> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }} {{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
</v-chip> </v-chip>
</v-col> </v-col>
<v-col v-else cols="9" style="margin: auto; text-align: center;"> <v-col v-else cols="9" style="margin: auto; text-align: center;">
{{ event.subject }} {{ event.subject }}
</v-col> </v-col>
<v-spacer /> <v-spacer />
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0"> <v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0">
<RecipeTimelineContextMenu <RecipeTimelineContextMenu
v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'" v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'"
:menu-top="false" :menu-top="false"
:event="event" :event="event"
@ -44,12 +44,12 @@
}" }"
@update="$emit('update')" @update="$emit('update')"
@delete="$emit('delete')" @delete="$emit('delete')"
/> />
</v-col> </v-col>
</v-row> </v-row>
</v-card-title> </v-card-title>
<v-sheet v-if="showRecipeCards && recipe"> <v-sheet v-if="showRecipeCards && recipe">
<v-row class="pt-3 pb-7 mx-3" style="max-width: 100%;"> <v-row class="pt-3 pb-7 mx-3" style="max-width: 100%;">
<v-col align-self="center" class="pa-0"> <v-col align-self="center" class="pa-0">
<RecipeCardMobile <RecipeCardMobile
:vertical="useMobileFormat" :vertical="useMobileFormat"
@ -61,20 +61,30 @@
:recipe-id="recipe.id" :recipe-id="recipe.id"
/> />
</v-col> </v-col>
</v-row> </v-row>
</v-sheet> </v-sheet>
<v-divider v-if="showRecipeCards && recipe && (useMobileFormat || event.eventMessage)" /> <v-divider v-if="showRecipeCards && recipe && (useMobileFormat || event.eventMessage || (eventImageUrl && !hideImage))" />
<v-card-text> <v-card-text>
<v-row> <v-row>
<v-col> <v-col>
<strong v-if="useMobileFormat">{{ event.subject }}</strong> <strong v-if="useMobileFormat">{{ event.subject }}</strong>
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''"> <v-img
v-if="eventImageUrl"
:src="eventImageUrl"
min-height="50"
:height="hideImage ? undefined : 'auto'"
:max-height="attrs.image.maxHeight"
contain
:class=attrs.image.class
@error="hideImage = true"
/>
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
{{ event.eventMessage }} {{ event.eventMessage }}
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
</v-card-text> </v-card-text>
</v-sheet> </v-sheet>
</v-card> </v-card>
</v-timeline-item> </v-timeline-item>
</template> </template>
@ -83,6 +93,7 @@
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api"; import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue"; import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
import { useStaticRoutes } from "~/composables/api";
import { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe" import { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe"
import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
@ -106,6 +117,7 @@ export default defineComponent({
setup(props) { setup(props) {
const { $globals, $vuetify } = useContext(); const { $globals, $vuetify } = useContext();
const { recipeTimelineEventImage } = useStaticRoutes();
const timelineEvents = ref([] as RecipeTimelineEventOut[]); const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const useMobileFormat = computed(() => { const useMobileFormat = computed(() => {
@ -121,6 +133,10 @@ export default defineComponent({
size: "30px", size: "30px",
class: "pr-0", class: "pr-0",
}, },
image: {
maxHeight: "250",
class: "my-3"
},
} }
} }
else { else {
@ -131,6 +147,10 @@ export default defineComponent({
size: "42px", size: "42px",
class: "", class: "",
}, },
image: {
maxHeight: "300",
class: "mb-5"
},
} }
} }
}) })
@ -151,9 +171,20 @@ export default defineComponent({
}; };
}) })
const hideImage = ref(false);
const eventImageUrl = computed<string>( () => {
if (props.event.image !== "has image") {
return "";
}
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
})
return { return {
attrs, attrs,
icon, icon,
eventImageUrl,
hideImage,
timelineEvents, timelineEvents,
useMobileFormat, useMobileFormat,
}; };

View File

@ -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) { function recipeAssetPath(recipeId: string, assetName: string) {
return `${fullBase}/media/recipes/${recipeId}/assets/${assetName}`; return `${fullBase}/media/recipes/${recipeId}/assets/${assetName}`;
} }
@ -38,6 +50,9 @@ export const useStaticRoutes = () => {
recipeImage, recipeImage,
recipeSmallImage, recipeSmallImage,
recipeTinyImage, recipeTinyImage,
recipeTimelineEventImage,
recipeTimelineEventSmallImage,
recipeTimelineEventTinyImage,
recipeAssetPath, recipeAssetPath,
}; };
}; };

View File

@ -1,13 +1,14 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
/** /**
/* This file was automatically generated from pydantic models by running pydantic2ts. /* 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 /* Do not modify it by hand - just update the pydantic models and then re-run the script
*/ */
export type ExportTypes = "json"; export type ExportTypes = "json";
export type RegisteredParser = "nlp" | "brute"; export type RegisteredParser = "nlp" | "brute";
export type TimelineEventType = "system" | "info" | "comment"; export type TimelineEventType = "system" | "info" | "comment";
export type TimelineEventImage = "has image" | "does not have image";
export interface AssignCategories { export interface AssignCategories {
recipes: string[]; recipes: string[];
@ -351,31 +352,31 @@ export interface RecipeTagResponse {
recipes?: RecipeSummary[]; recipes?: RecipeSummary[];
} }
export interface RecipeTimelineEventCreate { export interface RecipeTimelineEventCreate {
recipeId: string;
userId: string; userId: string;
subject: string; subject: string;
eventType: TimelineEventType; eventType: TimelineEventType;
eventMessage?: string; eventMessage?: string;
image?: string; image?: TimelineEventImage;
timestamp?: string; timestamp?: string;
recipeId: string;
} }
export interface RecipeTimelineEventIn { export interface RecipeTimelineEventIn {
recipeId: string;
userId?: string; userId?: string;
subject: string; subject: string;
eventType: TimelineEventType; eventType: TimelineEventType;
eventMessage?: string; eventMessage?: string;
image?: string; image?: TimelineEventImage;
timestamp?: string; timestamp?: string;
recipeId: string;
} }
export interface RecipeTimelineEventOut { export interface RecipeTimelineEventOut {
recipeId: string;
userId: string; userId: string;
subject: string; subject: string;
eventType: TimelineEventType; eventType: TimelineEventType;
eventMessage?: string; eventMessage?: string;
image?: string; image?: TimelineEventImage;
timestamp?: string; timestamp?: string;
recipeId: string;
id: string; id: string;
createdAt: string; createdAt: string;
updateAt: string; updateAt: string;
@ -383,7 +384,7 @@ export interface RecipeTimelineEventOut {
export interface RecipeTimelineEventUpdate { export interface RecipeTimelineEventUpdate {
subject: string; subject: string;
eventMessage?: string; eventMessage?: string;
image?: string; image?: TimelineEventImage;
} }
export interface RecipeToolCreate { export interface RecipeToolCreate {
name: string; name: string;
@ -400,7 +401,7 @@ export interface RecipeToolResponse {
onHand?: boolean; onHand?: boolean;
id: string; id: string;
slug: string; slug: string;
recipes?: Recipe[]; recipes?: RecipeSummary[];
} }
export interface RecipeToolSave { export interface RecipeToolSave {
name: string; name: string;

View File

@ -52,6 +52,7 @@ const routes = {
recipesSlugLastMade: (slug: string) => `${prefix}/recipes/${slug}/last-made`, recipesSlugLastMade: (slug: string) => `${prefix}/recipes/${slug}/last-made`,
recipesTimelineEventId: (id: string) => `${prefix}/recipes/timeline/events/${id}`, recipesTimelineEventId: (id: string) => `${prefix}/recipes/timeline/events/${id}`,
recipesTimelineEventIdImage: (id: string) => `${prefix}/recipes/timeline/events/${id}/image`,
}; };
export type RecipeSearchQuery = { export type RecipeSearchQuery = {
@ -194,4 +195,12 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
} }
); );
} }
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<UpdateImageResponse, FormData>(routes.recipesTimelineEventIdImage(eventId), formData);
}
} }

View File

@ -98,14 +98,22 @@ class HttpRepo(Generic[C, R, U]):
return item return item
def patch_one(self, data: U, item_id: int | str | UUID4) -> None: def patch_one(self, data: U, item_id: int | str | UUID4) -> R:
self.repo.get_one(item_id) item = self.repo.get_one(item_id)
if not item:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=ErrorResponse.respond(message="Not found."),
)
try: 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: except Exception as ex:
self.handle_exception(ex) self.handle_exception(ex)
return item
def delete_one(self, item_id: int | str | UUID4) -> R | None: def delete_one(self, item_id: int | str | UUID4) -> R | None:
item: R | None = None item: R | None = None
try: try:

View File

@ -5,6 +5,7 @@ from pydantic import UUID4
from starlette.responses import FileResponse from starlette.responses import FileResponse
from mealie.schema.recipe import Recipe 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 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}") @router.get("/{recipe_id}/images/{file_name}")
async def get_recipe_img(recipe_id: str, file_name: ImageType = ImageType.original): 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 and should not hit the API in production
""" """
recipe_image = Recipe.directory_from_id(recipe_id).joinpath("images", file_name.value) 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) 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}") @router.get("/{recipe_id}/assets/{file_name}")
async def get_recipe_asset(recipe_id: UUID4, file_name: str): async def get_recipe_asset(recipe_id: UUID4, file_name: str):
"""Returns a recipe asset""" """Returns a recipe asset"""

View File

@ -1,6 +1,7 @@
import shutil
from functools import cached_property from functools import cached_property
from fastapi import Depends, HTTPException from fastapi import Depends, File, Form, HTTPException
from pydantic import UUID4 from pydantic import UUID4
from mealie.routes._base import BaseCrudController, controller from mealie.routes._base import BaseCrudController, controller
@ -12,10 +13,13 @@ from mealie.schema.recipe.recipe_timeline_events import (
RecipeTimelineEventOut, RecipeTimelineEventOut,
RecipeTimelineEventPagination, RecipeTimelineEventPagination,
RecipeTimelineEventUpdate, RecipeTimelineEventUpdate,
TimelineEventImage,
) )
from mealie.schema.recipe.request_helpers import UpdateImageResponse
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.pagination import PaginationQuery
from mealie.services import urls from mealie.services import urls
from mealie.services.event_bus_service.event_types import EventOperation, EventRecipeTimelineEventData, EventTypes 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") events_router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events")
@ -80,7 +84,7 @@ class RecipeTimelineEventsController(BaseCrudController):
@events_router.put("/{item_id}", response_model=RecipeTimelineEventOut) @events_router.put("/{item_id}", response_model=RecipeTimelineEventOut)
def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate): 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") recipe = self.recipes_repo.get_one(event.recipe_id, "id")
if recipe: if recipe:
self.publish_event( self.publish_event(
@ -100,6 +104,12 @@ class RecipeTimelineEventsController(BaseCrudController):
@events_router.delete("/{item_id}", response_model=RecipeTimelineEventOut) @events_router.delete("/{item_id}", response_model=RecipeTimelineEventOut)
def delete_one(self, item_id: UUID4): def delete_one(self, item_id: UUID4):
event = self.mixins.delete_one(item_id) 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") recipe = self.recipes_repo.get_one(event.recipe_id, "id")
if recipe: if recipe:
self.publish_event( self.publish_event(
@ -115,3 +125,31 @@ class RecipeTimelineEventsController(BaseCrudController):
) )
return event 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)

View File

@ -77,6 +77,7 @@ from .recipe_timeline_events import (
RecipeTimelineEventOut, RecipeTimelineEventOut,
RecipeTimelineEventPagination, RecipeTimelineEventPagination,
RecipeTimelineEventUpdate, RecipeTimelineEventUpdate,
TimelineEventImage,
TimelineEventType, TimelineEventType,
) )
from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, RecipeToolSave
@ -155,6 +156,7 @@ __all__ = [
"RecipeTimelineEventOut", "RecipeTimelineEventOut",
"RecipeTimelineEventPagination", "RecipeTimelineEventPagination",
"RecipeTimelineEventUpdate", "RecipeTimelineEventUpdate",
"TimelineEventImage",
"TimelineEventType", "TimelineEventType",
"RecipeToolCreate", "RecipeToolCreate",
"RecipeToolOut", "RecipeToolOut",

View File

@ -129,29 +129,48 @@ class Recipe(RecipeSummary):
comments: list[RecipeCommentOut] | None = [] comments: list[RecipeCommentOut] | None = []
@staticmethod @staticmethod
def directory_from_id(recipe_id: UUID4 | str) -> Path: def _get_dir(dir: Path) -> Path:
return app_dirs.RECIPE_DATA_DIR.joinpath(str(recipe_id)) """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 @property
def directory(self) -> Path: def directory(self) -> Path:
if not self.id: if not self.id:
raise ValueError("Recipe has no ID") raise ValueError("Recipe has no ID")
folder = app_dirs.RECIPE_DATA_DIR.joinpath(str(self.id)) return self.directory_from_id(self.id)
folder.mkdir(exist_ok=True, parents=True)
return folder
@property @property
def asset_dir(self) -> Path: def asset_dir(self) -> Path:
folder = self.directory.joinpath("assets") if not self.id:
folder.mkdir(exist_ok=True, parents=True) raise ValueError("Recipe has no ID")
return folder
return self.asset_dir_from_id(self.id)
@property @property
def image_dir(self) -> Path: def image_dir(self) -> Path:
folder = self.directory.joinpath("images") if not self.id:
folder.mkdir(exist_ok=True, parents=True) raise ValueError("Recipe has no ID")
return folder
return self.image_dir_from_id(self.id)
class Config: class Config:
orm_mode = True orm_mode = True

View File

@ -1,11 +1,16 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from pathlib import Path
from pydantic import UUID4, Field from pydantic import UUID4, Field
from mealie.core.config import get_app_dirs
from mealie.schema._mealie.mealie_model import MealieModel from mealie.schema._mealie.mealie_model import MealieModel
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.response.pagination import PaginationBase from mealie.schema.response.pagination import PaginationBase
app_dirs = get_app_dirs()
class TimelineEventType(Enum): class TimelineEventType(Enum):
system = "system" system = "system"
@ -13,6 +18,11 @@ class TimelineEventType(Enum):
comment = "comment" comment = "comment"
class TimelineEventImage(Enum):
has_image = "has image"
does_not_have_image = "does not have image"
class RecipeTimelineEventIn(MealieModel): class RecipeTimelineEventIn(MealieModel):
recipe_id: UUID4 recipe_id: UUID4
user_id: UUID4 | None = None user_id: UUID4 | None = None
@ -22,7 +32,7 @@ class RecipeTimelineEventIn(MealieModel):
event_type: TimelineEventType event_type: TimelineEventType
message: str | None = Field(None, alias="eventMessage") message: str | None = Field(None, alias="eventMessage")
image: str | None = None image: TimelineEventImage | None = TimelineEventImage.does_not_have_image
timestamp: datetime = datetime.now() timestamp: datetime = datetime.now()
@ -37,7 +47,10 @@ class RecipeTimelineEventCreate(RecipeTimelineEventIn):
class RecipeTimelineEventUpdate(MealieModel): class RecipeTimelineEventUpdate(MealieModel):
subject: str subject: str
message: str | None = Field(alias="eventMessage") message: str | None = Field(alias="eventMessage")
image: str | None = None image: TimelineEventImage | None = None
class Config:
use_enum_values = True
class RecipeTimelineEventOut(RecipeTimelineEventCreate): class RecipeTimelineEventOut(RecipeTimelineEventCreate):
@ -48,6 +61,14 @@ class RecipeTimelineEventOut(RecipeTimelineEventCreate):
class Config: class Config:
orm_mode = True 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): class RecipeTimelineEventPagination(PaginationBase):
items: list[RecipeTimelineEventOut] items: list[RecipeTimelineEventOut]

View File

@ -65,10 +65,11 @@ class RecipeDataService(BaseService):
self.dir_data = Recipe.directory_from_id(self.recipe_id) self.dir_data = Recipe.directory_from_id(self.recipe_id)
self.dir_image = self.dir_data.joinpath("images") 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_assets = self.dir_data.joinpath("assets")
self.dir_image.mkdir(parents=True, exist_ok=True) for dir in [self.dir_image, self.dir_image_timeline, self.dir_assets]:
self.dir_assets.mkdir(parents=True, exist_ok=True) dir.mkdir(parents=True, exist_ok=True)
def delete_all_data(self) -> None: def delete_all_data(self) -> None:
try: try:
@ -76,9 +77,12 @@ class RecipeDataService(BaseService):
except Exception as e: except Exception as e:
self.logger.exception(f"Failed to delete recipe data: {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(".", "") 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) image_path.unlink(missing_ok=True)
if isinstance(file_data, Path): if isinstance(file_data, Path):

View File

@ -4,7 +4,12 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from mealie.schema.recipe.recipe import Recipe 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 import api_routes
from tests.utils.factories import random_string from tests.utils.factories import random_string
from tests.utils.fixture_schemas import TestUser 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 assert event_response.status_code == 200
updated_event = RecipeTimelineEventOut.parse_obj(event_response.json()) 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.subject == new_subject
assert updated_event.timestamp == new_event.timestamp
def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]): 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 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]): 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 # make sure when the recipes fixture was created that all recipes have at least one event
for recipe in recipes: for recipe in recipes:

View File

@ -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}" 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): def media_users_user_id_file_name(user_id, file_name):
"""`/api/media/users/{user_id}/{file_name}`""" """`/api/media/users/{user_id}/{file_name}`"""
return f"{prefix}/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}" 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): def shared_recipes_item_id(item_id):
"""`/api/shared/recipes/{item_id}`""" """`/api/shared/recipes/{item_id}`"""
return f"{prefix}/shared/recipes/{item_id}" return f"{prefix}/shared/recipes/{item_id}"