mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
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:
parent
06962cf865
commit
dfe4942451
@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -107,9 +107,7 @@ export default defineComponent({
|
|||||||
whenever(
|
whenever(
|
||||||
() => props.value,
|
() => props.value,
|
||||||
() => {
|
() => {
|
||||||
if (!ready.value) {
|
initializeTimelineEvents();
|
||||||
initializeTimelineEvents();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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:
|
||||||
|
@ -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"""
|
||||||
|
@ -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)
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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]
|
||||||
|
@ -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):
|
||||||
|
@ -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:
|
||||||
|
@ -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}"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user