Feature: Global Timeline (#2265)

* extended query filter to accept nested tables

* decoupled timeline api from recipe slug

* modified frontend to use simplified events api

* fixed nested loop index ghosting

* updated existing tests

* gave mypy a snack

* added tests for nested queries

* fixed "last made" render error

* decoupled recipe timeline from dialog

* removed unused props

* tweaked recipe get_all to accept ids

* created group global timeline
added new timeline page to sidebar
reformatted the recipe timeline
added vertical option to recipe card mobile

* extracted timeline item into its own component

* fixed apploader centering

* added paginated scrolling to recipe timeline

* added sort direction config
fixed infinite scroll on dialog
fixed hasMore var not resetting during instantiation

* added sort direction to user preferences

* updated API docs with new query filter feature

* better error tracing

* fix for recipe not found response

* simplified recipe crud route for slug/id
added test for fetching by slug/id

* made query filter UUID validation clearer

* moved timeline menu option below shopping lists

---------

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
Michael Genson 2023-04-25 12:46:00 -05:00 committed by GitHub
parent 0e397b34fd
commit fe17922bb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 871 additions and 506 deletions

View File

@ -72,6 +72,13 @@ This filter will find all recipes created on or after a particular date: <br>
This filter will find all units that have `useAbbreviation` disabled: <br>
`useAbbreviation = false`
##### Nested Property filters
When querying tables with relationships, you can filter properties on related tables. For instance, if you want to query all recipes owned by a particular user: <br>
`user.username = "SousChef20220320"`
This timeline event filter will return all timeline events for recipes that were created after a particular date: <br>
`recipe.createdAt >= "2023-02-25"`
##### Compound Filters
You can combine multiple filter statements using logical operators (`AND`, `OR`).

View File

@ -7,8 +7,18 @@
:to="$listeners.selected ? undefined : `/recipe/${slug}`"
@click="$emit('selected')"
>
<v-img v-if="vertical">
<RecipeCardImage
:icon-size="100"
:height="75"
:slug="slug"
:recipe-id="recipeId"
small
:image-version="image"
/>
</v-img>
<v-list-item three-line>
<slot name="avatar">
<slot v-if="!vertical" name="avatar">
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
<RecipeCardImage
:icon-size="100"
@ -17,7 +27,7 @@
:recipe-id="recipeId"
small
:image-version="image"
></RecipeCardImage>
/>
</v-list-item-avatar>
</slot>
<v-list-item-content>
@ -25,7 +35,7 @@
<v-list-item-subtitle>
<SafeMarkdown :source="description" />
</v-list-item-subtitle>
<div class="d-flex justify-center align-center">
<div class="d-flex flex-wrap justify-end align-center">
<slot name="actions">
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<v-rating
@ -107,6 +117,10 @@ export default defineComponent({
type: String,
required: true,
},
vertical: {
type: Boolean,
default: false,
}
},
setup() {
const { $auth } = useContext();

View File

@ -1,245 +0,0 @@
<template>
<BaseDialog
v-model="dialog"
:title="attrs.title"
:icon="$globals.icons.timelineText"
width="70%"
>
<v-card
v-if="timelineEvents && timelineEvents.length"
height="fit-content"
max-height="70vh"
width="100%"
style="overflow-y: auto;"
>
<v-timeline :dense="attrs.timeline.dense">
<v-timeline-item
v-for="(event, index) in timelineEvents"
:key="event.id"
:class="attrs.timeline.item.class"
fill-dot
:small="attrs.timeline.item.small"
:icon="chooseEventIcon(event)"
>
<template v-if="!useMobileFormat" #opposite>
<v-chip v-if="event.timestamp" label large>
<v-icon class="mr-1"> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
</v-chip>
</template>
<v-card>
<v-sheet>
<v-card-title>
<v-row>
<v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'">
<UserAvatar :user-id="event.userId" />
</v-col>
<v-col v-if="useMobileFormat" align-self="center" class="ml-3">
<v-chip label>
<v-icon> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
</v-chip>
</v-col>
<v-col v-else cols="9">
{{ event.subject }}
</v-col>
<v-spacer />
<v-col :cols="useMobileFormat ? 'auto' : '1'" :class="useMobileFormat ? '' : 'pa-0'">
<RecipeTimelineContextMenu
v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'"
:menu-top="false"
:slug="slug"
:event="event"
:menu-icon="$globals.icons.dotsVertical"
fab
color="transparent"
:elevation="0"
:card-menu="false"
:use-items="{
edit: true,
delete: true,
}"
@update="updateTimelineEvent(index)"
@delete="deleteTimelineEvent(index)"
/>
</v-col>
</v-row>
</v-card-title>
<v-card-text>
<v-row>
<v-col>
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
{{ event.eventMessage }}
</div>
</v-col>
</v-row>
</v-card-text>
</v-sheet>
</v-card>
</v-timeline-item>
</v-timeline>
</v-card>
<v-card v-else>
<v-card-title class="justify-center pa-9">
{{ $t("recipe.timeline-is-empty") }}
</v-card-title>
</v-card>
</BaseDialog>
</template>
<script lang="ts">
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core";
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
import { RecipeTimelineEventOut, RecipeTimelineEventUpdate } from "~/lib/api/types/recipe"
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({
components: { RecipeTimelineContextMenu, UserAvatar },
props: {
value: {
type: Boolean,
default: false,
},
slug: {
type: String,
default: "",
},
recipeName: {
type: String,
default: "",
},
},
setup(props, context) {
const api = useUserApi();
const { $globals, $vuetify, i18n } = useContext();
const timelineEvents = ref([{}] as RecipeTimelineEventOut[])
const useMobileFormat = computed(() => {
return $vuetify.breakpoint.smAndDown;
});
const attrs = computed(() => {
if (useMobileFormat.value) {
return {
title: i18n.tc("recipe.timeline"),
timeline: {
dense: true,
item: {
class: "pr-3",
small: true
}
}
}
}
else {
return {
title: `${i18n.tc("recipe.timeline")} ${props.recipeName}`,
timeline: {
dense: false,
item: {
class: "px-3",
small: false
}
}
}
}
})
// V-Model Support
const dialog = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
},
});
whenever(
() => props.value,
() => {
refreshTimelineEvents();
}
);
function chooseEventIcon(event: RecipeTimelineEventOut) {
switch (event.eventType) {
case "comment":
return $globals.icons.commentTextMultiple;
case "info":
return $globals.icons.informationVariant;
case "system":
return $globals.icons.cog;
default:
return $globals.icons.informationVariant;
};
};
async function updateTimelineEvent(index: number) {
const event = timelineEvents.value[index]
const payload: RecipeTimelineEventUpdate = {
subject: event.subject,
eventMessage: event.eventMessage,
image: event.image,
};
const { response } = await api.recipes.updateTimelineEvent(props.slug, event.id, payload);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
alert.success(i18n.t("events.event-updated") as string);
};
async function deleteTimelineEvent(index: number) {
const { response } = await api.recipes.deleteTimelineEvent(props.slug, timelineEvents.value[index].id);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
timelineEvents.value.splice(index, 1);
alert.success(i18n.t("events.event-deleted") as string);
};
async function refreshTimelineEvents() {
// TODO: implement infinite scroll and paginate instead of loading all events at once
const page = 1;
const perPage = -1;
const orderBy = "timestamp";
const orderDirection = "asc";
const response = await api.recipes.getAllTimelineEvents(props.slug, page, perPage, { orderBy, orderDirection });
if (!response?.data) {
return;
}
timelineEvents.value = response.data.items;
};
// preload events
refreshTimelineEvents();
return {
attrs,
chooseEventIcon,
deleteTimelineEvent,
dialog,
refreshTimelineEvents,
timelineEvents,
updateTimelineEvent,
useMobileFormat,
};
},
});
</script>

View File

@ -75,7 +75,7 @@ import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/comp
import { whenever } from "@vueuse/core";
import { VForm } from "~/types/vuetify";
import { useUserApi } from "~/composables/api";
import { RecipeTimelineEventIn } from "~/lib/api/types/recipe";
import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
export default defineComponent({
props: {
@ -83,9 +83,9 @@ export default defineComponent({
type: String,
default: null,
},
recipeSlug: {
type: String,
required: true,
recipe: {
type: Object as () => Recipe,
default: null,
},
},
setup(props, context) {
@ -99,6 +99,7 @@ export default defineComponent({
eventType: "comment",
eventMessage: "",
timestamp: undefined,
recipeId: props.recipe?.id || "",
});
whenever(
@ -113,20 +114,21 @@ export default defineComponent({
const state = reactive({datePickerMenu: false});
async function createTimelineEvent() {
if (!newTimelineEvent.value.timestamp) {
if (!(newTimelineEvent.value.timestamp && props.recipe?.id && props.recipe?.slug)) {
return;
}
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
// 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(props.recipeSlug, newTimelineEvent.value));
actions.push(userApi.recipes.createTimelineEvent(newTimelineEvent.value));
// we also update the recipe's last made value
if (!props.value || newTimelineEvent.value.timestamp > props.value) {
actions.push(userApi.recipes.updateLastMade(props.recipeSlug, newTimelineEvent.value.timestamp));
actions.push(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

View File

@ -13,7 +13,7 @@
<div v-if="user.id" class="d-flex justify-center mt-5">
<RecipeLastMade
v-model="recipe.lastMade"
:recipe-slug="recipe.slug"
:recipe="recipe"
class="d-flex justify-center flex-wrap"
:class="true ? undefined : 'force-bottom'"
/>

View File

@ -0,0 +1,266 @@
<template>
<div :style="maxHeight ? `max-height: ${maxHeight}; overflow-y: auto;` : ''" @scroll="onScroll($event)">
<v-row class="my-0 mx-7">
<v-spacer />
<v-col class="text-right">
<v-btn fab small color="info" @click="reverseSort">
<v-icon> {{ preferences.orderDirection === "asc" ? $globals.icons.sortCalendarAscending : $globals.icons.sortCalendarDescending }} </v-icon>
</v-btn>
</v-col>
</v-row>
<v-divider v-if="timelineEvents.length" />
<v-card
v-if="timelineEvents.length"
id="timeline-container"
height="fit-content"
width="100%"
class="px-1"
>
<v-timeline :dense="$vuetify.breakpoint.smAndDown" class="timeline">
<RecipeTimelineItem
v-for="(event, index) in timelineEvents"
:key="event.id"
:event="event"
:recipe="recipes.get(event.recipeId)"
:show-recipe-cards="showRecipeCards"
@update="updateTimelineEvent(index)"
@delete="deleteTimelineEvent(index)"
/>
</v-timeline>
</v-card>
<v-card v-else-if="!loading">
<v-card-title class="justify-center pa-9">
{{ $t("recipe.timeline-is-empty") }}
</v-card-title>
</v-card>
<div v-if="loading" class="pb-3">
<AppLoader :loading="loading" :waiting-text="$tc('general.loading-events')" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, useAsync, useContext } from "@nuxtjs/composition-api";
import { useThrottleFn, whenever } from "@vueuse/core";
import RecipeTimelineItem from "./RecipeTimelineItem.vue"
import { useTimelinePreferences } from "~/composables/use-users/preferences";
import { useAsyncKey } from "~/composables/use-utils";
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
import { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate } from "~/lib/api/types/recipe"
export default defineComponent({
components: { RecipeTimelineItem },
props: {
value: {
type: Boolean,
default: false,
},
queryFilter: {
type: String,
required: true,
},
maxHeight: {
type: [Number, String],
default: undefined,
},
showRecipeCards: {
type: Boolean,
default: false,
}
},
setup(props) {
const api = useUserApi();
const { i18n } = useContext();
const preferences = useTimelinePreferences();
const loading = ref(true);
const ready = ref(false);
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const recipes = new Map<string, Recipe>();
interface ScrollEvent extends Event {
target: HTMLInputElement;
}
const screenBuffer = 4;
const onScroll = (event: ScrollEvent) => {
if (!event.target) {
return;
}
const { scrollTop, offsetHeight, scrollHeight } = event.target;
// trigger when the user is getting close to the bottom
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight*screenBuffer);
if (bottomOfElement) {
infiniteScroll();
}
};
document.onscroll = () => {
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
const timelineContainerElement = document.getElementById("timeline-container");
if (timelineContainerElement) {
const { clientHeight, scrollHeight } = timelineContainerElement
// if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position
// if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here
if (scrollHeight > clientHeight) {
return;
}
}
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight*screenBuffer);
if (bottomOfWindow) {
infiniteScroll();
}
};
whenever(
() => props.value,
() => {
if (!ready.value) {
initializeTimelineEvents();
}
}
);
// Sorting
function reverseSort() {
if (loading.value) {
return;
}
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
initializeTimelineEvents();
}
// Timeline Actions
async function updateTimelineEvent(index: number) {
const event = timelineEvents.value[index]
const payload: RecipeTimelineEventUpdate = {
subject: event.subject,
eventMessage: event.eventMessage,
image: event.image,
};
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
alert.success(i18n.t("events.event-updated") as string);
};
async function deleteTimelineEvent(index: number) {
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
return;
}
timelineEvents.value.splice(index, 1);
alert.success(i18n.t("events.event-deleted") as string);
};
async function getRecipe(recipeId: string): Promise<Recipe | null> {
const { data } = await api.recipes.getOne(recipeId);
return data
};
async function updateRecipes(events: RecipeTimelineEventOut[]) {
const recipePromises: Promise<Recipe | null>[] = [];
const seenRecipeIds: string[] = [];
events.forEach(event => {
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
return;
}
seenRecipeIds.push(event.recipeId);
recipePromises.push(getRecipe(event.recipeId));
})
const results = await Promise.all(recipePromises);
results.forEach(result => {
if (result && result.id) {
recipes.set(result.id, result);
}
})
}
async function scrollTimelineEvents() {
const orderBy = "timestamp";
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter: props.queryFilter });
page.value += 1;
if (!response?.data) {
return;
}
const events = response.data.items;
if (events.length < perPage) {
hasMore.value = false;
if (!events.length) {
return;
}
}
// fetch recipes
if (props.showRecipeCards) {
await updateRecipes(events);
}
// this is set last so Vue knows to re-render
timelineEvents.value.push(...events);
};
async function initializeTimelineEvents() {
loading.value = true;
ready.value = false;
page.value = 1;
hasMore.value = true;
timelineEvents.value = [];
await scrollTimelineEvents();
ready.value = true;
loading.value = false;
}
const infiniteScroll = useThrottleFn(() => {
useAsync(async () => {
if (!hasMore.value || loading.value) {
return;
}
loading.value = true;
await scrollTimelineEvents();
loading.value = false;
}, useAsyncKey());
}, 500);
// preload events
initializeTimelineEvents();
return {
deleteTimelineEvent,
loading,
onScroll,
preferences,
recipes,
reverseSort,
timelineEvents,
updateTimelineEvent,
};
},
});
</script>

View File

@ -14,17 +14,20 @@
{{ $globals.icons.timelineText }}
</v-icon>
</v-btn>
<RecipeDialogTimeline v-model="showTimeline" :slug="slug" :recipe-name="recipeName" />
<BaseDialog v-model="showTimeline" :title="timelineAttrs.title" :icon="$globals.icons.timelineText" width="70%">
<RecipeTimeline v-model="showTimeline" :query-filter="timelineAttrs.queryFilter" max-height="70vh" />
</BaseDialog>
</template>
<span>{{ $t('recipe.open-timeline') }}</span>
</v-tooltip>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import RecipeDialogTimeline from "./RecipeDialogTimeline.vue";
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeTimeline from "./RecipeTimeline.vue";
export default defineComponent({
components: { RecipeDialogTimeline },
components: { RecipeTimeline },
props: {
buttonStyle: {
@ -41,13 +44,26 @@ export default defineComponent({
},
},
setup() {
setup(props) {
const { $vuetify, i18n } = useContext();
const showTimeline = ref(false);
function toggleTimeline() {
showTimeline.value = !showTimeline.value;
}
return { showTimeline, toggleTimeline };
const timelineAttrs = computed(() => {
let title = i18n.tc("recipe.timeline")
if ($vuetify.breakpoint.smAndDown) {
title += ` ${props.recipeName}`
}
return {
title,
queryFilter: `recipe.slug="${props.slug}"`,
}
})
return { showTimeline, timelineAttrs, toggleTimeline };
},
});
</script>

View File

@ -113,10 +113,6 @@ export default defineComponent({
type: String,
default: "primary",
},
slug: {
type: String,
required: true,
},
event: {
type: Object as () => RecipeTimelineEventOut,
required: true,

View File

@ -0,0 +1,162 @@
<template>
<v-timeline-item
:class="attrs.class"
fill-dot
:small="attrs.small"
:icon="icon"
>
<template v-if="!useMobileFormat" #opposite>
<v-chip v-if="event.timestamp" label large>
<v-icon class="mr-1"> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
</v-chip>
</template>
<v-card>
<v-sheet>
<v-card-title>
<v-row>
<v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'" :class="attrs.avatar.class">
<UserAvatar :user-id="event.userId" :size="attrs.avatar.size" />
</v-col>
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
<v-chip label>
<v-icon> {{ $globals.icons.calendar }} </v-icon>
{{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
</v-chip>
</v-col>
<v-col v-else cols="9" style="margin: auto; text-align: center;">
{{ event.subject }}
</v-col>
<v-spacer />
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0">
<RecipeTimelineContextMenu
v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'"
:menu-top="false"
:event="event"
:menu-icon="$globals.icons.dotsVertical"
fab
color="transparent"
:elevation="0"
:card-menu="false"
:use-items="{
edit: true,
delete: true,
}"
@update="$emit('update')"
@delete="$emit('delete')"
/>
</v-col>
</v-row>
</v-card-title>
<v-sheet v-if="showRecipeCards && recipe">
<v-row class="pt-3 pb-7 mx-3" style="max-width: 100%;">
<v-col align-self="center" class="pa-0">
<RecipeCardMobile
:vertical="useMobileFormat"
:name="recipe.name"
:slug="recipe.slug"
:description="recipe.description"
:rating="recipe.rating"
:image="recipe.image"
:recipe-id="recipe.id"
/>
</v-col>
</v-row>
</v-sheet>
<v-divider v-if="showRecipeCards && recipe && (useMobileFormat || event.eventMessage)" />
<v-card-text>
<v-row>
<v-col>
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
{{ event.eventMessage }}
</div>
</v-col>
</v-row>
</v-card-text>
</v-sheet>
</v-card>
</v-timeline-item>
</template>
<script lang="ts">
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeCardMobile from "./RecipeCardMobile.vue";
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
import { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe"
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineComponent({
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar },
props: {
event: {
type: Object as () => RecipeTimelineEventOut,
required: true,
},
recipe: {
type: Object as () => Recipe,
default: undefined,
},
showRecipeCards: {
type: Boolean,
default: false,
}
},
setup(props) {
const { $globals, $vuetify } = useContext();
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const useMobileFormat = computed(() => {
return $vuetify.breakpoint.smAndDown;
});
const attrs = computed(() => {
if (useMobileFormat.value) {
return {
class: "px-0",
small: false,
avatar: {
size: "30px",
class: "pr-0",
},
}
}
else {
return {
class: "px-3",
small: false,
avatar: {
size: "42px",
class: "",
},
}
}
})
const icon = computed( () => {
switch (props.event.eventType) {
case "comment":
return $globals.icons.commentTextMultiple;
case "info":
return $globals.icons.informationVariant;
case "system":
return $globals.icons.cog;
default:
return $globals.icons.informationVariant;
};
})
return {
attrs,
icon,
timelineEvents,
useMobileFormat,
};
},
});
</script>

View File

@ -1,21 +1,23 @@
<template>
<div class="mx-auto">
<v-progress-circular :width="size.width" :size="size.size" color="primary lighten-2" indeterminate>
<div class="text-center">
<v-icon :size="size.icon" color="primary lighten-2">
{{ $globals.icons.primary }}
</v-icon>
<div v-if="large" class="text-small">
<slot>
{{ small ? "" : waitingText }}
</slot>
<div class="mx-auto my-3 justify-center" style="display: flex;">
<div style="display: inline;">
<v-progress-circular :width="size.width" :size="size.size" color="primary lighten-2" indeterminate>
<div class="text-center">
<v-icon :size="size.icon" color="primary lighten-2">
{{ $globals.icons.primary }}
</v-icon>
<div v-if="large" class="text-small">
<slot>
{{ small ? "" : waitingText }}
</slot>
</div>
</div>
</v-progress-circular>
<div v-if="!large" class="text-small">
<slot>
{{ small ? "" : waitingTextCalculated }}
</slot>
</div>
</v-progress-circular>
<div v-if="!large" class="text-small">
<slot>
{{ small ? "" : waitingText }}
</slot>
</div>
</div>
</template>
@ -41,6 +43,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
waitingText: {
type: String,
default: undefined,
}
},
setup(props) {
const size = computed(() => {
@ -65,11 +71,11 @@ export default defineComponent({
});
const { i18n } = useContext();
const waitingText = i18n.t("general.loading-recipes");
const waitingTextCalculated = props.waitingText == null ? i18n.t("general.loading-recipes") : props.waitingText;
return {
size,
waitingText,
waitingTextCalculated,
};
},
});

View File

@ -25,6 +25,10 @@ export interface UserShoppingListPreferences {
viewByLabel: boolean;
}
export interface UserTimelinePreferences {
orderDirection: string;
}
export function useUserPrintPreferences(): Ref<UserPrintPreferences> {
const fromStorage = useLocalStorage(
"recipe-print-preferences",
@ -75,3 +79,17 @@ export function useShoppingListPreferences(): Ref<UserShoppingListPreferences> {
return fromStorage;
}
export function useTimelinePreferences(): Ref<UserTimelinePreferences> {
const fromStorage = useLocalStorage(
"timeline-preferences",
{
orderDirection: "asc",
},
{ mergeDefaults: true }
// we cast to a Ref because by default it will return an optional type ref
// but since we pass defaults we know all properties are set.
) as unknown as Ref<UserTimelinePreferences>;
return fromStorage;
}

View File

@ -113,6 +113,7 @@
"json": "JSON",
"keyword": "Keyword",
"link-copied": "Link Copied",
"loading-events": "Loading Events",
"loading-recipes": "Loading Recipes",
"message": "Message",
"monday": "Monday",
@ -478,6 +479,7 @@
"edit-timeline-event": "Edit Timeline Event",
"timeline": "Timeline",
"timeline-is-empty": "Nothing on the timeline yet. Try making this recipe!",
"group-global-timeline": "{groupName} Global Timeline",
"open-timeline": "Open Timeline",
"made-this": "I Made This",
"how-did-it-turn-out": "How did it turn out?",

View File

@ -169,6 +169,12 @@ export default defineComponent({
to: "/shopping-lists",
restricted: true,
},
{
icon: $globals.icons.timelineText,
title: i18n.t("recipe.timeline"),
to: "/group/timeline",
restricted: true,
},
{
icon: $globals.icons.tags,
to: "/recipes/categories",

View File

@ -355,6 +355,7 @@ export interface RecipeTimelineEventIn {
eventMessage?: string;
image?: string;
timestamp?: string;
recipeId: string;
}
export interface RecipeTimelineEventOut {
userId: string;

View File

@ -39,6 +39,7 @@ const routes = {
recipesParseIngredient: `${prefix}/parser/ingredient`,
recipesParseIngredients: `${prefix}/parser/ingredients`,
recipesCreateFromOcr: `${prefix}/recipes/create-ocr`,
recipesTimelineEvent: `${prefix}/recipes/timeline/events`,
recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugExport: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports`,
@ -50,9 +51,7 @@ const routes = {
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
recipesSlugLastMade: (slug: string) => `${prefix}/recipes/${slug}/last-made`,
recipesSlugTimelineEvent: (slug: string) => `${prefix}/recipes/${slug}/timeline/events`,
recipesSlugTimelineEventId: (slug: string, id: string) => `${prefix}/recipes/${slug}/timeline/events/${id}`,
recipesTimelineEventId: (id: string) => `${prefix}/recipes/timeline/events/${id}`,
};
export type RecipeSearchQuery = {
@ -170,24 +169,24 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.patch<Recipe, RecipeLastMade>(routes.recipesSlugLastMade(recipeSlug), { timestamp })
}
async createTimelineEvent(recipeSlug: string, payload: RecipeTimelineEventIn) {
return await this.requests.post<RecipeTimelineEventOut>(routes.recipesSlugTimelineEvent(recipeSlug), payload);
async createTimelineEvent(payload: RecipeTimelineEventIn) {
return await this.requests.post<RecipeTimelineEventOut>(routes.recipesTimelineEvent, payload);
}
async updateTimelineEvent(recipeSlug: string, eventId: string, payload: RecipeTimelineEventUpdate) {
async updateTimelineEvent(eventId: string, payload: RecipeTimelineEventUpdate) {
return await this.requests.put<RecipeTimelineEventOut, RecipeTimelineEventUpdate>(
routes.recipesSlugTimelineEventId(recipeSlug, eventId),
routes.recipesTimelineEventId(eventId),
payload
);
}
async deleteTimelineEvent(recipeSlug: string, eventId: string) {
return await this.requests.delete<RecipeTimelineEventOut>(routes.recipesSlugTimelineEventId(recipeSlug, eventId));
async deleteTimelineEvent(eventId: string) {
return await this.requests.delete<RecipeTimelineEventOut>(routes.recipesTimelineEventId(eventId));
}
async getAllTimelineEvents(recipeSlug: string, page = 1, perPage = -1, params = {} as any) {
async getAllTimelineEvents(page = 1, perPage = -1, params = {} as any) {
return await this.requests.get<PaginationData<RecipeTimelineEventOut>>(
routes.recipesSlugTimelineEvent(recipeSlug),
routes.recipesTimelineEvent,
{
params: { page, perPage, ...params },
}

View File

@ -0,0 +1,52 @@
<template>
<v-sheet :class="$vuetify.breakpoint.smAndDown ? 'pa-0' : 'px-3 py-0'">
<BasePageTitle v-if="groupName" divider>
<template #header>
<v-img max-height="200" max-width="150" :src="require('~/static/svgs/manage-members.svg')" />
</template>
<template #title> {{ $t("recipe.group-global-timeline", { groupName }) }} </template>
</BasePageTitle>
<RecipeTimeline v-model="ready" show-recipe-cards :query-filter="queryFilter" />
</v-sheet>
</template>
<script lang="ts">
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import RecipeTimeline from "~/components/Domain/Recipe/RecipeTimeline.vue";
export default defineComponent({
components: { RecipeTimeline },
setup() {
const { $auth } = useContext();
const api = useUserApi();
const ready = ref<boolean>(false);
// @ts-expect-error - TS doesn't like the $auth global user attribute
const groupId: string = $auth.user.groupId;
const queryFilter = `recipe.group_id="${groupId}"`
const groupName = ref<string>("");
async function refreshGroupName() {
const { data } = await api.groups.getCurrentUserGroup();
if (data) {
groupName.value = data.name;
}
}
refreshGroupName();
ready.value = true;
return {
groupName,
queryFilter,
ready,
};
},
head() {
return {
title: this.$t("recipe.timeline") as string,
};
},
});
</script>

View File

@ -4,7 +4,7 @@ from zipfile import ZipFile
import orjson
import sqlalchemy
from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, Query, Request, status
from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, Path, Query, Request, status
from fastapi.datastructures import UploadFile
from fastapi.responses import JSONResponse
from pydantic import UUID4, BaseModel, Field
@ -24,12 +24,7 @@ from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe
from mealie.schema.recipe.recipe import (
CreateRecipe,
CreateRecipeByUrlBulk,
RecipeLastMade,
RecipeSummary,
)
from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeLastMade, RecipeSummary
from mealie.schema.recipe.recipe_asset import RecipeAsset
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
@ -284,9 +279,15 @@ class RecipeController(BaseRecipeController):
return JSONBytes(content=json_compatible_response)
@router.get("/{slug}", response_model=Recipe)
def get_one(self, slug: str):
"""Takes in a recipe slug, returns all data for a recipe"""
return self.mixins.get_one(slug)
def get_one(self, slug: str = Path(..., description="A recipe's slug or id")):
"""Takes in a recipe's slug or id and returns all data for a recipe"""
try:
recipe = self.service.get_one_by_slug_or_id(slug)
except Exception as e:
self.handle_exceptions(e)
return None
return recipe
@router.post("", status_code=201, response_model=str)
def create_one(self, data: CreateRecipe) -> str | None:

View File

@ -6,7 +6,6 @@ from pydantic import UUID4
from mealie.routes._base import BaseCrudController, controller
from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_timeline_events import (
RecipeTimelineEventCreate,
RecipeTimelineEventIn,
@ -18,7 +17,7 @@ from mealie.schema.response.pagination import PaginationQuery
from mealie.services import urls
from mealie.services.event_bus_service.event_types import EventOperation, EventRecipeTimelineEventData, EventTypes
events_router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/{slug}/timeline/events")
events_router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events")
@controller(events_router)
@ -27,6 +26,10 @@ class RecipeTimelineEventsController(BaseCrudController):
def repo(self):
return self.repos.recipe_timeline_events
@cached_property
def recipes_repo(self):
return self.repos.recipes.by_group(self.group_id)
@cached_property
def mixins(self):
return HttpRepo[RecipeTimelineEventCreate, RecipeTimelineEventOut, RecipeTimelineEventUpdate](
@ -35,39 +38,26 @@ class RecipeTimelineEventsController(BaseCrudController):
self.registered_exceptions,
)
def get_recipe_from_slug(self, slug: str) -> Recipe:
recipe = self.repos.recipes.by_group(self.group_id).get_one(slug)
if not recipe or self.group_id != recipe.group_id:
raise HTTPException(status_code=404, detail="recipe not found")
return recipe
@events_router.get("", response_model=RecipeTimelineEventPagination)
def get_all(self, slug: str, q: PaginationQuery = Depends(PaginationQuery)):
recipe = self.get_recipe_from_slug(slug)
recipe_filter = f"recipe_id = {recipe.id}"
if q.query_filter:
q.query_filter = f"({q.query_filter}) AND {recipe_filter}"
else:
q.query_filter = recipe_filter
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
response = self.repo.page_all(
pagination=q,
override=RecipeTimelineEventOut,
)
response.set_pagination_guides(events_router.url_path_for("get_all", slug=slug), q.dict())
response.set_pagination_guides(events_router.url_path_for("get_all"), q.dict())
return response
@events_router.post("", response_model=RecipeTimelineEventOut, status_code=201)
def create_one(self, slug: str, data: RecipeTimelineEventIn):
def create_one(self, data: RecipeTimelineEventIn):
# if the user id is not specified, use the currently-authenticated user
data.user_id = data.user_id or self.user.id
recipe = self.get_recipe_from_slug(slug)
event_data = data.cast(RecipeTimelineEventCreate, recipe_id=recipe.id)
recipe = self.recipes_repo.get_one(data.recipe_id, "id")
if not recipe:
raise HTTPException(status_code=404, detail="recipe not found")
event_data = data.cast(RecipeTimelineEventCreate)
event = self.mixins.create_one(event_data)
self.publish_event(
@ -78,69 +68,50 @@ class RecipeTimelineEventsController(BaseCrudController):
message=self.t(
"notifications.generic-updated-with-url",
name=recipe.name,
url=urls.recipe_url(slug, self.settings.BASE_URL),
url=urls.recipe_url(recipe.slug, self.settings.BASE_URL),
),
)
return event
@events_router.get("/{item_id}", response_model=RecipeTimelineEventOut)
def get_one(self, slug: str, item_id: UUID4):
recipe = self.get_recipe_from_slug(slug)
event = self.mixins.get_one(item_id)
# validate that this event belongs to the given recipe slug
if event.recipe_id != recipe.id:
raise HTTPException(status_code=404, detail="recipe event not found")
return event
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@events_router.put("/{item_id}", response_model=RecipeTimelineEventOut)
def update_one(self, slug: str, item_id: UUID4, data: RecipeTimelineEventUpdate):
recipe = self.get_recipe_from_slug(slug)
event = self.mixins.get_one(item_id)
# validate that this event belongs to the given recipe slug
if event.recipe_id != recipe.id:
raise HTTPException(status_code=404, detail="recipe event not found")
def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate):
event = self.mixins.update_one(data, item_id)
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(slug, self.settings.BASE_URL),
),
)
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 event
@events_router.delete("/{item_id}", response_model=RecipeTimelineEventOut)
def delete_one(self, slug: str, item_id: UUID4):
recipe = self.get_recipe_from_slug(slug)
event = self.mixins.get_one(item_id)
# validate that this event belongs to the given recipe slug
if event.recipe_id != recipe.id:
raise HTTPException(status_code=404, detail="recipe event not found")
def delete_one(self, item_id: UUID4):
event = self.mixins.delete_one(item_id)
self.publish_event(
event_type=EventTypes.recipe_updated,
document_data=EventRecipeTimelineEventData(
operation=EventOperation.delete, 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(slug, self.settings.BASE_URL),
),
)
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.delete, 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 event

View File

@ -14,6 +14,7 @@ class TimelineEventType(Enum):
class RecipeTimelineEventIn(MealieModel):
recipe_id: UUID4
user_id: UUID4 | None = None
"""can be inferred in some contexts, so it's not required"""
@ -30,7 +31,6 @@ class RecipeTimelineEventIn(MealieModel):
class RecipeTimelineEventCreate(RecipeTimelineEventIn):
recipe_id: UUID4
user_id: UUID4

View File

@ -4,14 +4,18 @@ import datetime
import re
from enum import Enum
from typing import Any, TypeVar, cast
from uuid import UUID
from dateutil import parser as date_parser
from dateutil.parser import ParserError
from humps import decamelize
from sqlalchemy import Select, bindparam, text
from sqlalchemy import Select, bindparam, inspect, text
from sqlalchemy.orm import Mapper
from sqlalchemy.sql import sqltypes
from sqlalchemy.sql.expression import BindParameter
from mealie.db.models._model_utils.guid import GUID
Model = TypeVar("Model")
@ -87,14 +91,51 @@ class QueryFilter:
# we explicitly mark this as a filter component instead cast doesn't
# actually do anything at runtime
component = cast(QueryFilterComponent, component)
attribute_chain = component.attribute_name.split(".")
if not attribute_chain:
raise ValueError("invalid query string: attribute name cannot be empty")
if not hasattr(model, component.attribute_name):
raise ValueError(f"invalid query string: '{component.attribute_name}' does not exist on this schema")
attr_model: Any = model
for j, attribute_link in enumerate(attribute_chain):
# last element
if j == len(attribute_chain) - 1:
if not hasattr(attr_model, attribute_link):
raise ValueError(
f"invalid query string: '{component.attribute_name}' does not exist on this schema"
)
attr_value = attribute_link
if j:
# use the nested table name, rather than the dot notation
component.attribute_name = f"{attr_model.__table__.name}.{attr_value}"
continue
# join on nested model
try:
query = query.join(getattr(attr_model, attribute_link))
mapper: Mapper = inspect(attr_model)
relationship = mapper.relationships[attribute_link]
attr_model = relationship.mapper.class_
except (AttributeError, KeyError) as e:
raise ValueError(
f"invalid query string: '{component.attribute_name}' does not exist on this schema"
) from e
# convert values to their proper types
attr = getattr(model, component.attribute_name)
attr = getattr(attr_model, attr_value)
value: Any = component.value
if isinstance(attr.type, (GUID)):
try:
# we don't set value since a UUID is functionally identical to a string here
UUID(value)
except ValueError as e:
raise ValueError(f"invalid query string: invalid UUID '{component.value}'") from e
if isinstance(attr.type, (sqltypes.Date, sqltypes.DateTime)):
# TODO: add support for IS NULL and IS NOT NULL
# in the meantime, this will work for the specific usecase of non-null dates/datetimes

View File

@ -3,7 +3,7 @@ import shutil
from datetime import datetime
from pathlib import Path
from shutil import copytree, rmtree
from uuid import uuid4
from uuid import UUID, uuid4
from zipfile import ZipFile
from fastapi import UploadFile
@ -42,8 +42,8 @@ class RecipeService(BaseService):
self.group = group
super().__init__()
def _get_recipe(self, slug: str) -> Recipe:
recipe = self.repos.recipes.by_group(self.group.id).get_one(slug)
def _get_recipe(self, data: str | UUID, key: str | None = None) -> Recipe:
recipe = self.repos.recipes.by_group(self.group.id).get_one(data, key)
if recipe is None:
raise exceptions.NoEntryFound("Recipe not found.")
return recipe
@ -107,6 +107,19 @@ class RecipeService(BaseService):
return Recipe(**additional_attrs)
def get_one_by_slug_or_id(self, slug_or_id: str | UUID) -> Recipe | None:
if isinstance(slug_or_id, str):
try:
slug_or_id = UUID(slug_or_id)
except ValueError:
pass
if isinstance(slug_or_id, UUID):
return self._get_recipe(slug_or_id, "id")
else:
return self._get_recipe(slug_or_id, "slug")
def create_one(self, create_data: Recipe | CreateRecipe) -> Recipe:
if create_data.name is None:
create_data.name = "New Recipe"

View File

@ -6,10 +6,7 @@ from mealie.db.db_setup import session_context
from mealie.repos.all_repositories import get_repositories
from mealie.schema.meal_plan.new_meal import PlanEntryType
from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.recipe.recipe_timeline_events import (
RecipeTimelineEventCreate,
TimelineEventType,
)
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.user.user import DEFAULT_INTEGRATION_ID
from mealie.services.event_bus_service.event_bus_service import EventBusService

View File

@ -65,9 +65,11 @@ def test_recipe_migration(api_client: TestClient, unique_user: TestUser, mig: Mi
response = api_client.get(api_routes.recipes, params=params, headers=unique_user.token)
query_data = assert_derserialize(response)
assert len(query_data["items"])
slug = query_data["items"][0]["slug"]
response = api_client.get(api_routes.recipes_slug_timeline_events(slug), headers=unique_user.token)
recipe_id = query_data["items"][0]["id"]
params = {"queryFilter": f"recipe_id={recipe_id}"}
response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
query_data = assert_derserialize(response)
events = query_data["items"]
assert len(events)

View File

@ -397,3 +397,30 @@ def test_delete_recipe_same_name(api_client: TestClient, unique_user: utils.Test
response = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token)
response = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token)
assert response.status_code == 404
def test_get_recipe_by_slug_or_id(api_client: TestClient, unique_user: utils.TestUser):
slugs = [random_string(10) for _ in range(3)]
# Create recipes
for slug in slugs:
response = api_client.post(api_routes.recipes, json={"name": slug}, headers=unique_user.token)
assert response.status_code == 201
assert json.loads(response.text) == slug
# Get recipes by slug
recipe_ids = []
for slug in slugs:
response = api_client.get(api_routes.recipes_slug(slug), headers=unique_user.token)
assert response.status_code == 200
recipe_data = response.json()
assert recipe_data["slug"] == slug
recipe_ids.append(recipe_data["id"])
# Get recipes by id
for recipe_id, slug in zip(recipe_ids, slugs, strict=True):
response = api_client.get(api_routes.recipes_slug(recipe_id), headers=unique_user.token)
assert response.status_code == 200
recipe_data = response.json()
assert recipe_data["slug"] == slug
assert recipe_data["id"] == recipe_id

View File

@ -1,3 +1,5 @@
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
@ -31,6 +33,7 @@ def recipes(api_client: TestClient, unique_user: TestUser):
def test_create_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
recipe = recipes[0]
new_event = {
"recipe_id": str(recipe.id),
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
@ -38,7 +41,7 @@ def test_create_timeline_event(api_client: TestClient, unique_user: TestUser, re
}
event_response = api_client.post(
api_routes.recipes_slug_timeline_events(recipe.slug),
api_routes.recipes_timeline_events,
json=new_event,
headers=unique_user.token,
)
@ -54,6 +57,7 @@ def test_get_all_timeline_events(api_client: TestClient, unique_user: TestUser,
recipe = recipes[0]
events_data = [
{
"recipe_id": str(recipe.id),
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
@ -64,17 +68,16 @@ def test_get_all_timeline_events(api_client: TestClient, unique_user: TestUser,
events: list[RecipeTimelineEventOut] = []
for event_data in events_data:
params: dict = {"queryFilter": f"recipe_id={event_data['recipe_id']}"}
event_response = api_client.post(
api_routes.recipes_slug_timeline_events(recipe.slug), json=event_data, headers=unique_user.token
api_routes.recipes_timeline_events, params=params, json=event_data, headers=unique_user.token
)
events.append(RecipeTimelineEventOut.parse_obj(event_response.json()))
# check that we see them all
params = {"page": 1, "perPage": -1}
events_response = api_client.get(
api_routes.recipes_slug_timeline_events(recipe.slug), params=params, headers=unique_user.token
)
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
events_pagination = RecipeTimelineEventPagination.parse_obj(events_response.json())
event_ids = [event.id for event in events]
@ -89,6 +92,7 @@ def test_get_timeline_event(api_client: TestClient, unique_user: TestUser, recip
# create an event
recipe = recipes[0]
new_event_data = {
"recipe_id": str(recipe.id),
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
@ -96,16 +100,14 @@ def test_get_timeline_event(api_client: TestClient, unique_user: TestUser, recip
}
event_response = api_client.post(
api_routes.recipes_slug_timeline_events(recipe.slug),
api_routes.recipes_timeline_events,
json=new_event_data,
headers=unique_user.token,
)
new_event = RecipeTimelineEventOut.parse_obj(event_response.json())
# fetch the new event
event_response = api_client.get(
api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id), headers=unique_user.token
)
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
event = RecipeTimelineEventOut.parse_obj(event_response.json())
@ -119,14 +121,13 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re
# create an event
recipe = recipes[0]
new_event_data = {
"recipe_id": str(recipe.id),
"user_id": unique_user.user_id,
"subject": old_subject,
"event_type": "info",
}
event_response = api_client.post(
api_routes.recipes_slug_timeline_events(recipe.slug), json=new_event_data, headers=unique_user.token
)
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.subject == old_subject
@ -134,7 +135,7 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re
updated_event_data = {"subject": new_subject}
event_response = api_client.put(
api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id),
api_routes.recipes_timeline_events_item_id(new_event.id),
json=updated_event_data,
headers=unique_user.token,
)
@ -149,20 +150,19 @@ def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, re
# create an event
recipe = recipes[0]
new_event_data = {
"recipe_id": str(recipe.id),
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
"message": random_string(),
}
event_response = api_client.post(
api_routes.recipes_slug_timeline_events(recipe.slug), json=new_event_data, headers=unique_user.token
)
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())
# delete the event
event_response = api_client.delete(
api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id), headers=unique_user.token
api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token
)
assert event_response.status_code == 200
@ -171,7 +171,7 @@ def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, re
# try to get the event
event_response = api_client.get(
api_routes.recipes_slug_timeline_events_item_id(recipe.slug, deleted_event.id), headers=unique_user.token
api_routes.recipes_timeline_events_item_id(deleted_event.id), headers=unique_user.token
)
assert event_response.status_code == 404
@ -180,6 +180,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
# create an event using aliases
recipe = recipes[0]
new_event_data = {
"recipeId": str(recipe.id),
"userId": unique_user.user_id,
"subject": random_string(),
"eventType": "info",
@ -187,7 +188,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
}
event_response = api_client.post(
api_routes.recipes_slug_timeline_events(recipe.slug),
api_routes.recipes_timeline_events,
json=new_event_data,
headers=unique_user.token,
)
@ -197,9 +198,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
assert new_event.message == new_event_data["eventMessage"]
# fetch the new event
event_response = api_client.get(
api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id), headers=unique_user.token
)
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
event = RecipeTimelineEventOut.parse_obj(event_response.json())
@ -211,7 +210,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
updated_event_data = {"subject": new_subject, "eventMessage": new_message}
event_response = api_client.put(
api_routes.recipes_slug_timeline_events_item_id(recipe.slug, new_event.id),
api_routes.recipes_timeline_events_item_id(new_event.id),
json=updated_event_data,
headers=unique_user.token,
)
@ -225,71 +224,20 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
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:
events_response = api_client.get(
api_routes.recipes_slug_timeline_events(recipe.slug), headers=unique_user.token
)
params = {"queryFilter": f"recipe_id={recipe.id}"}
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
events_pagination = RecipeTimelineEventPagination.parse_obj(events_response.json())
assert events_pagination.items
def test_invalid_recipe_slug(api_client: TestClient, unique_user: TestUser):
def test_invalid_recipe_id(api_client: TestClient, unique_user: TestUser):
new_event_data = {
"recipe_id": str(uuid4()),
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
"message": random_string(),
}
event_response = api_client.post(
api_routes.recipes_slug_timeline_events(random_string()), json=new_event_data, headers=unique_user.token
)
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
assert event_response.status_code == 404
def test_recipe_slug_mismatch(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
# get new recipes
recipe = recipes[0]
invalid_recipe = recipes[1]
# create a new event
new_event_data = {
"user_id": unique_user.user_id,
"subject": random_string(),
"event_type": "info",
"message": random_string(),
}
event_response = api_client.post(
api_routes.recipes_slug_timeline_events(recipe.slug), json=new_event_data, headers=unique_user.token
)
event = RecipeTimelineEventOut.parse_obj(event_response.json())
# try to perform operations on the event using the wrong recipe
event_response = api_client.get(
api_routes.recipes_slug_timeline_events_item_id(invalid_recipe.slug, event.id),
headers=unique_user.token,
)
assert event_response.status_code == 404
event_response = api_client.put(
api_routes.recipes_slug_timeline_events_item_id(invalid_recipe.slug, event.id),
json=new_event_data,
headers=unique_user.token,
)
assert event_response.status_code == 404
event_response = api_client.delete(
api_routes.recipes_slug_timeline_events_item_id(invalid_recipe.slug, event.id),
headers=unique_user.token,
)
assert event_response.status_code == 404
# make sure the event still exists and is unmodified
event_response = api_client.get(
api_routes.recipes_slug_timeline_events_item_id(recipe.slug, event.id),
headers=unique_user.token,
)
assert event_response.status_code == 200
existing_event = RecipeTimelineEventOut.parse_obj(event_response.json())
assert existing_event == event

View File

@ -1,4 +1,5 @@
import time
from collections import defaultdict
from random import randint
from urllib.parse import parse_qsl, urlsplit
@ -11,13 +12,16 @@ from mealie.repos.repository_units import RepositoryUnit
from mealie.schema.recipe.recipe_ingredient import IngredientUnit, SaveIngredientUnit
from mealie.schema.response.pagination import PaginationQuery
from mealie.services.seeder.seeder_service import SeederService
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
def test_repository_pagination(database: AllRepositories, unique_user: TestUser):
group = database.groups.get_one(unique_user.group_id)
assert group
seeder = SeederService(database, None, group)
seeder = SeederService(database, None, group) # type: ignore
seeder.seed_foods("en-US")
foods_repo = database.ingredient_foods.by_group(unique_user.group_id) # type: ignore
@ -50,8 +54,9 @@ def test_repository_pagination(database: AllRepositories, unique_user: TestUser)
def test_pagination_response_and_metadata(database: AllRepositories, unique_user: TestUser):
group = database.groups.get_one(unique_user.group_id)
assert group
seeder = SeederService(database, None, group)
seeder = SeederService(database, None, group) # type: ignore
seeder.seed_foods("en-US")
foods_repo = database.ingredient_foods.by_group(unique_user.group_id) # type: ignore
@ -78,8 +83,9 @@ def test_pagination_response_and_metadata(database: AllRepositories, unique_user
def test_pagination_guides(database: AllRepositories, unique_user: TestUser):
group = database.groups.get_one(unique_user.group_id)
assert group
seeder = SeederService(database, None, group)
seeder = SeederService(database, None, group) # type: ignore
seeder.seed_foods("en-US")
foods_repo = database.ingredient_foods.by_group(unique_user.group_id) # type: ignore
@ -107,10 +113,10 @@ def test_pagination_guides(database: AllRepositories, unique_user: TestUser):
random_page_of_results = foods_repo.page_all(query)
random_page_of_results.set_pagination_guides(foods_route, query.dict())
next_params = dict(parse_qsl(urlsplit(random_page_of_results.next).query))
next_params: dict = dict(parse_qsl(urlsplit(random_page_of_results.next).query)) # type: ignore
assert int(next_params["page"]) == random_page + 1
prev_params = dict(parse_qsl(urlsplit(random_page_of_results.previous).query))
prev_params: dict = dict(parse_qsl(urlsplit(random_page_of_results.previous).query)) # type: ignore
assert int(prev_params["page"]) == random_page - 1
source_params = camelize(query.dict())
@ -173,7 +179,7 @@ def test_pagination_filter_datetimes(
unit_1 = query_units[1]
unit_2 = query_units[2]
dt = unit_2.created_at.isoformat()
dt = unit_2.created_at.isoformat() # type: ignore
query = PaginationQuery(page=1, per_page=-1, query_filter=f'createdAt>="{dt}"')
unit_results = units_repo.page_all(query).items
assert len(unit_results) == 2
@ -194,7 +200,7 @@ def test_pagination_filter_advanced(query_units: tuple[RepositoryUnit, Ingredien
units_repo = query_units[0]
unit_3 = query_units[3]
dt = unit_3.created_at.isoformat()
dt = str(unit_3.created_at.isoformat()) # type: ignore
qf = f'name="test unit 1" OR (useAbbreviation=f AND (name="test unit 2" OR createdAt > "{dt}"))'
query = PaginationQuery(page=1, per_page=-1, query_filter=qf)
unit_results = units_repo.page_all(query).items
@ -206,8 +212,11 @@ def test_pagination_filter_advanced(query_units: tuple[RepositoryUnit, Ingredien
"qf",
[
pytest.param('(name="test name" AND useAbbreviation=f))', id="unbalanced parenthesis"),
pytest.param('id="this is not a valid UUID"', id="invalid UUID"),
pytest.param('createdAt="this is not a valid datetime format"', id="invalid datetime format"),
pytest.param('badAttribute="test value"', id="invalid attribute"),
pytest.param('group.badAttribute="test value"', id="bad nested attribute"),
pytest.param('group.preferences.badAttribute="test value"', id="bad double nested attribute"),
],
)
def test_malformed_query_filters(api_client: TestClient, unique_user: TestUser, qf: str):
@ -216,3 +225,46 @@ def test_malformed_query_filters(api_client: TestClient, unique_user: TestUser,
response = api_client.get(route, params={"queryFilter": qf}, headers=unique_user.token)
assert response.status_code == 400
def test_pagination_filter_nested(api_client: TestClient, user_tuple: list[TestUser]):
# create a few recipes for each user
slugs: defaultdict[int, list[str]] = defaultdict(list)
for i, user in enumerate(user_tuple):
for _ in range(random_int(3, 5)):
slug: str = random_string()
response = api_client.post(api_routes.recipes, json={"name": slug}, headers=user.token)
assert response.status_code == 201
slugs[i].append(slug)
# query recipes with a nested user filter
recipe_ids: defaultdict[int, list[str]] = defaultdict(list)
for i, user in enumerate(user_tuple):
params = {"page": 1, "perPage": -1, "queryFilter": f'user.id="{user.user_id}"'}
response = api_client.get(api_routes.recipes, params=params, headers=user.token)
assert response.status_code == 200
recipes_data: list[dict] = response.json()["items"]
assert recipes_data
for recipe_data in recipes_data:
slug = recipe_data["slug"]
assert slug in slugs[i]
assert slug not in slugs[(i + 1) % len(user_tuple)]
recipe_ids[i].append(recipe_data["id"])
# query timeline events with a double nested recipe.user filter
for i, user in enumerate(user_tuple):
params = {"page": 1, "perPage": -1, "queryFilter": f'recipe.user.id="{user.user_id}"'}
response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=user.token)
assert response.status_code == 200
events_data: list[dict] = response.json()["items"]
assert events_data
for event_data in events_data:
recipe_id = event_data["recipeId"]
assert recipe_id in recipe_ids[i]
assert recipe_id not in recipe_ids[(i + 1) % len(user_tuple)]

View File

@ -5,9 +5,7 @@ from pydantic import UUID4
from mealie.schema.meal_plan.new_meal import CreatePlanEntry
from mealie.schema.recipe.recipe import RecipeSummary
from mealie.services.scheduler.tasks.create_timeline_events import (
create_mealplan_timeline_events,
)
from mealie.services.scheduler.tasks.create_timeline_events import create_mealplan_timeline_events
from tests import utils
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
@ -31,7 +29,8 @@ def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser):
assert recipe.last_made is None
# store the number of events, so we can compare later
response = api_client.get(api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token)
params = {"queryFilter": f"recipe_id={recipe_id}"}
response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
response_json = response.json()
initial_event_count = len(response_json["items"])
@ -45,10 +44,14 @@ def test_new_mealplan_event(api_client: TestClient, unique_user: TestUser):
# run the task and check to make sure a new event was created from the mealplan
create_mealplan_timeline_events()
params = {"page": "1", "perPage": "-1", "orderBy": "created_at", "orderDirection": "desc"}
response = api_client.get(
api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token, params=params
)
params = {
"page": "1",
"perPage": "-1",
"orderBy": "created_at",
"orderDirection": "desc",
"queryFilter": f"recipe_id={recipe_id}",
}
response = api_client.get(api_routes.recipes_timeline_events, headers=unique_user.token, params=params)
response_json = response.json()
assert len(response_json["items"]) == initial_event_count + 1
@ -91,7 +94,8 @@ def test_new_mealplan_event_duplicates(api_client: TestClient, unique_user: Test
recipe_id = recipe.id
# store the number of events, so we can compare later
response = api_client.get(api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token)
params = {"queryFilter": f"recipe_id={recipe_id}"}
response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
response_json = response.json()
initial_event_count = len(response_json["items"])
@ -106,10 +110,14 @@ def test_new_mealplan_event_duplicates(api_client: TestClient, unique_user: Test
for _ in range(3):
create_mealplan_timeline_events()
params = {"page": "1", "perPage": "-1", "orderBy": "created_at", "orderDirection": "desc"}
response = api_client.get(
api_routes.recipes_slug_timeline_events(recipe_name), headers=unique_user.token, params=params
)
params = {
"page": "1",
"perPage": "-1",
"orderBy": "created_at",
"orderDirection": "desc",
"queryFilter": f"recipe_id={recipe_id}",
}
response = api_client.get(api_routes.recipes_timeline_events, headers=unique_user.token, params=params)
response_json = response.json()
assert len(response_json["items"]) == initial_event_count + 1
@ -125,7 +133,8 @@ def test_new_mealplan_events_with_multiple_recipes(api_client: TestClient, uniqu
recipes.append(RecipeSummary.parse_obj(response.json()))
# store the number of events, so we can compare later
response = api_client.get(api_routes.recipes_slug_timeline_events(str(recipes[0].slug)), headers=unique_user.token)
params = {"queryFilter": f"recipe_id={recipes[0].id}"}
response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
response_json = response.json()
initial_event_count = len(response_json["items"])
@ -149,10 +158,14 @@ def test_new_mealplan_events_with_multiple_recipes(api_client: TestClient, uniqu
for recipe in recipes:
target_count = initial_event_count + mealplan_count_by_recipe_id[recipe.id] # type: ignore
params = {"page": "1", "perPage": "-1", "orderBy": "created_at", "orderDirection": "desc"}
response = api_client.get(
api_routes.recipes_slug_timeline_events(recipe.slug), headers=unique_user.token, params=params
)
params = {
"page": "1",
"perPage": "-1",
"orderBy": "created_at",
"orderDirection": "desc",
"queryFilter": f"recipe_id={recipe.id}",
}
response = api_client.get(api_routes.recipes_timeline_events, headers=unique_user.token, params=params)
response_json = response.json()
assert len(response_json["items"]) == target_count
@ -167,10 +180,9 @@ def test_new_mealplan_events_with_multiple_recipes(api_client: TestClient, uniqu
"perPage": "-1",
"orderBy": "created_at",
"orderDirection": "desc",
"queryFilter": f"recipe_id={recipe.id}",
}
response = api_client.get(
api_routes.recipes_slug_timeline_events(recipe.slug), headers=unique_user.token, params=params
)
response = api_client.get(api_routes.recipes_timeline_events, headers=unique_user.token, params=params)
response_json = response.json()
assert len(response_json["items"]) == target_count

View File

@ -39,6 +39,8 @@ admin_server_tasks = "/api/admin/server-tasks"
"""`/api/admin/server-tasks`"""
admin_users = "/api/admin/users"
"""`/api/admin/users`"""
admin_users_password_reset_token = "/api/admin/users/password-reset-token"
"""`/api/admin/users/password-reset-token`"""
admin_users_unlock = "/api/admin/users/unlock"
"""`/api/admin/users/unlock`"""
app_about = "/api/app/about"
@ -159,6 +161,8 @@ recipes_summary_untagged = "/api/recipes/summary/untagged"
"""`/api/recipes/summary/untagged`"""
recipes_test_scrape_url = "/api/recipes/test-scrape-url"
"""`/api/recipes/test-scrape-url`"""
recipes_timeline_events = "/api/recipes/timeline/events"
"""`/api/recipes/timeline/events`"""
shared_recipes = "/api/shared/recipes"
"""`/api/shared/recipes`"""
units = "/api/units"
@ -386,14 +390,9 @@ def recipes_slug_last_made(slug):
return f"{prefix}/recipes/{slug}/last-made"
def recipes_slug_timeline_events(slug):
"""`/api/recipes/{slug}/timeline/events`"""
return f"{prefix}/recipes/{slug}/timeline/events"
def recipes_slug_timeline_events_item_id(slug, item_id):
"""`/api/recipes/{slug}/timeline/events/{item_id}`"""
return f"{prefix}/recipes/{slug}/timeline/events/{item_id}"
def recipes_timeline_events_item_id(item_id):
"""`/api/recipes/timeline/events/{item_id}`"""
return f"{prefix}/recipes/timeline/events/{item_id}"
def shared_recipes_item_id(item_id):