1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-19 13:19:41 +02:00

feat: Recipe Finder (aka Cocktail Builder) (#4542)

This commit is contained in:
Michael Genson 2024-12-03 07:27:41 -06:00 committed by GitHub
parent d26e29d1c5
commit 4e0cf985bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1959 additions and 151 deletions

View file

@ -48,3 +48,11 @@
.v-card__title { .v-card__title {
word-break: normal !important; word-break: normal !important;
} }
.text-hide-overflow {
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}

View file

@ -253,7 +253,7 @@
</v-container> </v-container>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-container fluid class="d-flex justify-end pa-0"> <v-container fluid class="d-flex justify-end pa-0 mx-2">
<v-checkbox <v-checkbox
v-model="showAdvanced" v-model="showAdvanced"
hide-details hide-details
@ -431,6 +431,7 @@ export default defineComponent({
state.qfValid = !!qf; state.qfValid = !!qf;
context.emit("input", qf || undefined); context.emit("input", qf || undefined);
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
}, },
{ {
deep: true deep: true
@ -543,6 +544,32 @@ export default defineComponent({
initFieldsError(`Error initializing fields: ${(error || "").toString()}`); initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
} }
function buildQueryFilterJSON(): QueryFilterJSON {
const parts = fields.value.map((field) => {
const part: QueryFilterJSONPart = {
attributeName: field.name,
leftParenthesis: field.leftParenthesis,
rightParenthesis: field.rightParenthesis,
logicalOperator: field.logicalOperator?.value,
relationalOperator: field.relationalOperatorValue?.value,
};
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
part.value = field.values.map((value) => value.toString());
} else if (field.type === "boolean") {
part.value = field.value ? "true" : "false";
} else {
part.value = (field.value || "").toString();
}
return part;
});
const qfJSON = { parts } as QueryFilterJSON;
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
return qfJSON;
}
const attrs = computed(() => { const attrs = computed(() => {
const baseColMaxWidth = 55; const baseColMaxWidth = 55;

View file

@ -0,0 +1,118 @@
<template>
<v-container class="elevation-3">
<v-row no-gutters>
<v-col cols="12">
<RecipeCardMobile
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:recipe-id="recipe.id"
/>
</v-col>
<div v-for="(organizer, idx) in missingOrganizers" :key="idx">
<v-col
v-if="organizer.show"
cols="12"
>
<div class="d-flex flex-row flex-wrap align-center pt-2">
<v-icon class="ma-0 pa-0">{{ organizer.icon }}</v-icon>
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content;">
{{ $tc("recipe-finder.missing") }}:
</v-card-text>
<v-chip
v-for="item in organizer.items"
:key="item.item.id"
label
color="secondary custom-transparent"
class="mr-2 my-1"
>
<v-checkbox dark :ripple="false" @click="handleCheckbox(item)">
<template #label>
{{ organizer.getLabel(item.item) }}
</template>
</v-checkbox>
</v-chip>
</div>
</v-col>
</div>
</v-row>
</v-container>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
import RecipeCardMobile from "./RecipeCardMobile.vue";
import { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
interface Organizer {
type: "food" | "tool";
item: IngredientFood | RecipeTool;
selected: boolean;
}
export default defineComponent({
components: { RecipeCardMobile },
props: {
recipe: {
type: Object as () => RecipeSummary,
required: true,
},
missingFoods: {
type: Array as () => IngredientFood[] | null,
default: null,
},
missingTools: {
type: Array as () => RecipeTool[] | null,
default: null,
},
disableCheckbox: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const { $globals } = useContext();
const missingOrganizers = computed(() => [
{
type: "food",
show: props.missingFoods?.length,
icon: $globals.icons.foods,
items: props.missingFoods ? props.missingFoods.map((food) => {
return reactive({type: "food", item: food, selected: false} as Organizer);
}) : [],
getLabel: (item: IngredientFood) => item.pluralName || item.name,
},
{
type: "tool",
show: props.missingTools?.length,
icon: $globals.icons.tools,
items: props.missingTools ? props.missingTools.map((tool) => {
return reactive({type: "tool", item: tool, selected: false} as Organizer);
}) : [],
getLabel: (item: RecipeTool) => item.name,
}
])
function handleCheckbox(organizer: Organizer) {
if (props.disableCheckbox) {
return;
}
organizer.selected = !organizer.selected;
if (organizer.selected) {
context.emit(`add-${organizer.type}`, organizer.item);
}
else {
context.emit(`remove-${organizer.type}`, organizer.item);
}
}
return {
missingOrganizers,
handleCheckbox,
};
}
});
</script>

View file

@ -221,7 +221,13 @@ export default defineComponent({
icon: $globals.icons.silverwareForkKnife, icon: $globals.icons.silverwareForkKnife,
to: `/g/${groupSlug.value}`, to: `/g/${groupSlug.value}`,
title: i18n.tc("general.recipes"), title: i18n.tc("general.recipes"),
restricted: true, restricted: false,
},
{
icon: $globals.icons.search,
to: `/g/${groupSlug.value}/recipes/finder`,
title: i18n.tc("recipe-finder.recipe-finder"),
restricted: false,
}, },
{ {
icon: $globals.icons.calendarMultiselect, icon: $globals.icons.calendarMultiselect,

View file

@ -45,11 +45,13 @@
</v-btn> </v-btn>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<slot name="custom-card-action"></slot>
<BaseButton v-if="$listeners.delete" delete secondary @click="deleteEvent" /> <BaseButton v-if="$listeners.delete" delete secondary @click="deleteEvent" />
<BaseButton <BaseButton
v-if="$listeners.confirm" v-if="$listeners.confirm"
:color="color" :color="color"
type="submit" type="submit"
:disabled="submitDisabled"
@click=" @click="
$emit('confirm'); $emit('confirm');
dialog = false; dialog = false;
@ -60,8 +62,12 @@
</template> </template>
{{ $t("general.confirm") }} {{ $t("general.confirm") }}
</BaseButton> </BaseButton>
<slot name="custom-card-action"></slot> <BaseButton
<BaseButton v-if="$listeners.submit" type="submit" :disabled="submitDisabled" @click="submitEvent"> v-if="$listeners.submit"
type="submit"
:disabled="submitDisabled"
@click="submitEvent"
>
{{ submitText }} {{ submitText }}
<template v-if="submitIcon" #icon> <template v-if="submitIcon" #icon>
{{ submitIcon }} {{ submitIcon }}

View file

@ -1,6 +1,7 @@
import { Ref, useContext } from "@nuxtjs/composition-api"; import { Ref, useContext } from "@nuxtjs/composition-api";
import { useLocalStorage, useSessionStorage } from "@vueuse/core"; import { useLocalStorage, useSessionStorage } from "@vueuse/core";
import { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe"; import { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
import { QueryFilterJSON } from "~/lib/api/types/response";
export interface UserPrintPreferences { export interface UserPrintPreferences {
imagePosition: string; imagePosition: string;
@ -49,6 +50,17 @@ export interface UserCookbooksPreferences {
hideOtherHouseholds: boolean; hideOtherHouseholds: boolean;
} }
export interface UserRecipeFinderPreferences {
foodIds: string[];
toolIds: string[];
queryFilter: string;
queryFilterJSON: QueryFilterJSON;
maxMissingFoods: number;
maxMissingTools: number;
includeFoodsOnHand: boolean;
includeToolsOnHand: boolean;
}
export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> { export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> {
const fromStorage = useLocalStorage( const fromStorage = useLocalStorage(
"meal-planner-preferences", "meal-planner-preferences",
@ -171,3 +183,24 @@ export function useCookbookPreferences(): Ref<UserCookbooksPreferences> {
return fromStorage; return fromStorage;
} }
export function useRecipeFinderPreferences(): Ref<UserRecipeFinderPreferences> {
const fromStorage = useLocalStorage(
"recipe-finder-preferences",
{
foodIds: [],
toolIds: [],
queryFilter: "",
queryFilterJSON: { parts: [] } as QueryFilterJSON,
maxMissingFoods: 20,
maxMissingTools: 20,
includeFoodsOnHand: true,
includeToolsOnHand: true,
},
{ 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<UserRecipeFinderPreferences>;
return fromStorage;
}

View file

@ -671,6 +671,23 @@
"reset-servings-count": "Reset Servings Count", "reset-servings-count": "Reset Servings Count",
"not-linked-ingredients": "Additional Ingredients" "not-linked-ingredients": "Additional Ingredients"
}, },
"recipe-finder": {
"recipe-finder": "Recipe Finder",
"recipe-finder-description": "Search for recipes based on ingredients you have on hand. You can also filter by tools you have available, and set a maximum number of missing ingredients or tools.",
"selected-ingredients": "Selected Ingredients",
"no-ingredients-selected": "No ingredients selected",
"missing": "Missing",
"no-recipes-found": "No recipes found",
"no-recipes-found-description": "Try adding more ingredients to your search or adjusting your filters",
"include-ingredients-on-hand": "Include Ingredients On Hand",
"include-tools-on-hand": "Include Tools On Hand",
"max-missing-ingredients": "Max Missing Ingredients",
"max-missing-tools": "Max Missing Tools",
"selected-tools": "Selected Tools",
"other-filters": "Other Filters",
"ready-to-make": "Ready to Make",
"almost-ready-to-make": "Almost Ready to Make"
},
"search": { "search": {
"advanced-search": "Advanced Search", "advanced-search": "Advanced Search",
"and": "and", "and": "and",

View file

@ -1,6 +1,6 @@
import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
import { route } from "../../base"; import { route } from "../../base";
import { Recipe } from "~/lib/api/types/recipe"; import { Recipe, RecipeSuggestionQuery, RecipeSuggestionResponse } from "~/lib/api/types/recipe";
import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated"; import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
import { RecipeSearchQuery } from "../../user/recipes/recipe"; import { RecipeSearchQuery } from "../../user/recipes/recipe";
@ -23,4 +23,10 @@ export class PublicRecipeApi extends BaseCRUDAPIReadOnly<Recipe> {
async search(rsq: RecipeSearchQuery) { async search(rsq: RecipeSearchQuery) {
return await this.requests.get<PaginationData<Recipe>>(route(routes.recipesGroupSlug(this.groupSlug), rsq)); return await this.requests.get<PaginationData<Recipe>>(route(routes.recipesGroupSlug(this.groupSlug), rsq));
} }
async getSuggestions(q: RecipeSuggestionQuery, foods: string[] | null = null, tools: string[]| null = null) {
return await this.requests.get<RecipeSuggestionResponse>(
route(`${this.baseRoute}/suggestions`, { ...q, foods, tools })
);
}
} }

View file

@ -7,6 +7,8 @@
export type ExportTypes = "json"; export type ExportTypes = "json";
export type RegisteredParser = "nlp" | "brute" | "openai"; export type RegisteredParser = "nlp" | "brute" | "openai";
export type OrderByNullPosition = "first" | "last";
export type OrderDirection = "asc" | "desc";
export type TimelineEventType = "system" | "info" | "comment"; export type TimelineEventType = "system" | "info" | "comment";
export type TimelineEventImage = "has image" | "does not have image"; export type TimelineEventImage = "has image" | "does not have image";
@ -380,6 +382,26 @@ export interface RecipeShareTokenSummary {
export interface RecipeSlug { export interface RecipeSlug {
slug: string; slug: string;
} }
export interface RecipeSuggestionQuery {
orderBy?: string | null;
orderByNullPosition?: OrderByNullPosition | null;
orderDirection?: OrderDirection;
queryFilter?: string | null;
paginationSeed?: string | null;
limit?: number;
maxMissingFoods?: number;
maxMissingTools?: number;
includeFoodsOnHand?: boolean;
includeToolsOnHand?: boolean;
}
export interface RecipeSuggestionResponse {
items: RecipeSuggestionResponseItem[];
}
export interface RecipeSuggestionResponseItem {
recipe: RecipeSummary;
missingFoods: IngredientFood[];
missingTools: RecipeTool[];
}
export interface RecipeTagResponse { export interface RecipeTagResponse {
name: string; name: string;
id: string; id: string;
@ -519,3 +541,10 @@ export interface UnitFoodBase {
export interface UpdateImageResponse { export interface UpdateImageResponse {
image: string; image: string;
} }
export interface RequestQuery {
orderBy?: string | null;
orderByNullPosition?: OrderByNullPosition | null;
orderDirection?: OrderDirection;
queryFilter?: string | null;
paginationSeed?: string | null;
}

View file

@ -20,13 +20,13 @@ export interface FileTokenResponse {
fileToken: string; fileToken: string;
} }
export interface PaginationQuery { export interface PaginationQuery {
page?: number;
perPage?: number;
orderBy?: string | null; orderBy?: string | null;
orderByNullPosition?: OrderByNullPosition | null; orderByNullPosition?: OrderByNullPosition | null;
orderDirection?: OrderDirection; orderDirection?: OrderDirection;
queryFilter?: string | null; queryFilter?: string | null;
paginationSeed?: string | null; paginationSeed?: string | null;
page?: number;
perPage?: number;
} }
export interface QueryFilterJSON { export interface QueryFilterJSON {
parts?: QueryFilterJSONPart[]; parts?: QueryFilterJSONPart[];
@ -47,6 +47,13 @@ export interface RecipeSearchQuery {
requireAllFoods?: boolean; requireAllFoods?: boolean;
search?: string | null; search?: string | null;
} }
export interface RequestQuery {
orderBy?: string | null;
orderByNullPosition?: OrderByNullPosition | null;
orderDirection?: OrderDirection;
queryFilter?: string | null;
paginationSeed?: string | null;
}
export interface SuccessResponse { export interface SuccessResponse {
message: string; message: string;
error?: boolean; error?: boolean;

View file

@ -11,6 +11,8 @@ import {
UpdateImageResponse, UpdateImageResponse,
RecipeZipTokenResponse, RecipeZipTokenResponse,
RecipeLastMade, RecipeLastMade,
RecipeSuggestionQuery,
RecipeSuggestionResponse,
RecipeTimelineEventIn, RecipeTimelineEventIn,
RecipeTimelineEventOut, RecipeTimelineEventOut,
RecipeTimelineEventUpdate, RecipeTimelineEventUpdate,
@ -31,6 +33,7 @@ const prefix = "/api";
const routes = { const routes = {
recipesCreate: `${prefix}/recipes/create`, recipesCreate: `${prefix}/recipes/create`,
recipesBase: `${prefix}/recipes`, recipesBase: `${prefix}/recipes`,
recipesSuggestions: `${prefix}/recipes/suggestions`,
recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`, recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`,
recipesCreateUrl: `${prefix}/recipes/create/url`, recipesCreateUrl: `${prefix}/recipes/create/url`,
recipesCreateUrlBulk: `${prefix}/recipes/create/url/bulk`, recipesCreateUrlBulk: `${prefix}/recipes/create/url/bulk`,
@ -109,6 +112,12 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
}); });
} }
async getSuggestions(q: RecipeSuggestionQuery, foods: string[] | null = null, tools: string[]| null = null) {
return await this.requests.get<RecipeSuggestionResponse>(
route(routes.recipesSuggestions, { ...q, foods, tools })
);
}
async createAsset(recipeSlug: string, payload: CreateAsset) { async createAsset(recipeSlug: string, payload: CreateAsset) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", payload.file); formData.append("file", payload.file);

View file

@ -0,0 +1,596 @@
<template>
<v-container>
<BasePageTitle divider>
<template #header>
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img>
</template>
<template #title> {{ $tc('recipe-finder.recipe-finder') }} </template>
{{ $t('recipe-finder.recipe-finder-description') }}
</BasePageTitle>
<v-container v-if="ready">
<v-row>
<v-col :cols="useMobile ? 12 : 3">
<v-container class="ma-0 pa-0">
<v-row no-gutters>
<v-col cols="12" no-gutters :class="attrs.searchFilter.colClass">
<SearchFilter v-if="foods" v-model="selectedFoods" :items="foods" :class="attrs.searchFilter.filterClass">
<v-icon left>
{{ $globals.icons.foods }}
</v-icon>
{{ $t("general.foods") }}
</SearchFilter>
<SearchFilter v-if="tools" v-model="selectedTools" :items="tools" :class="attrs.searchFilter.filterClass">
<v-icon left>
{{ $globals.icons.potSteam }}
</v-icon>
{{ $t("tool.tools") }}
</SearchFilter>
<div :class="attrs.searchFilter.filterClass">
<v-badge
:value="queryFilterJSON.parts && queryFilterJSON.parts.length"
small
overlap
color="primary"
:content="(queryFilterJSON.parts || []).length"
>
<v-btn
small
color="accent"
dark
@click="queryFilterMenu = !queryFilterMenu"
>
<v-icon left>
{{ $globals.icons.filter }}
</v-icon>
{{ $tc("recipe-finder.other-filters") }}
<BaseDialog
v-model="queryFilterMenu"
:title="$tc('recipe-finder.other-filters')"
:icon="$globals.icons.filter"
width="100%"
max-width="1100px"
:submit-disabled="!queryFilterEditorValue"
@confirm="saveQueryFilter"
>
<QueryFilterBuilder
:key="queryFilterMenuKey"
:initial-query-filter="queryFilterJSON"
:field-defs="queryFilterBuilderFields"
@input="(value) => queryFilterEditorValue = value"
@inputJSON="(value) => queryFilterEditorValueJSON = value"
/>
<template #custom-card-action>
<BaseButton color="error" type="submit" @click="clearQueryFilter">
<template #icon>
{{ $globals.icons.close }}
</template>
{{ $t("search.clear-selection") }}
</BaseButton>
</template>
</BaseDialog>
</v-btn>
</v-badge>
</div>
</v-col>
</v-row>
<!-- Settings Menu -->
<v-row no-gutters class="mb-2">
<v-col cols="12" :class="attrs.settings.colClass">
<v-menu
v-model="settingsMenu"
offset-y
nudge-bottom="3"
:close-on-content-click="false"
>
<template #activator="{ on, attrs: activatorAttrs}">
<v-btn small color="primary" dark v-bind="activatorAttrs" v-on="on">
<v-icon left>
{{ $globals.icons.cog }}
</v-icon>
{{ $t("general.settings") }}
</v-btn>
</template>
<v-card>
<v-card-text>
<div>
<v-text-field
v-model="settings.maxMissingFoods"
type="number"
hide-details
hide-spin-buttons
:label="$tc('recipe-finder.max-missing-ingredients')"
/>
<v-text-field
v-model="settings.maxMissingTools"
type="number"
hide-details
hide-spin-buttons
:label="$tc('recipe-finder.max-missing-tools')"
class="mt-4"
/>
</div>
<div class="mt-1">
<v-checkbox
v-if="isOwnGroup"
v-model="settings.includeFoodsOnHand"
dense
small
hide-details
class="my-auto"
:label="$tc('recipe-finder.include-ingredients-on-hand')"
/>
<v-checkbox
v-if="isOwnGroup"
v-model="settings.includeToolsOnHand"
dense
small
hide-details
class="my-auto"
:label="$tc('recipe-finder.include-tools-on-hand')"
/>
</div>
</v-card-text>
</v-card>
</v-menu>
</v-col>
</v-row>
<v-row no-gutters class="my-2">
<v-col cols="12">
<v-divider />
</v-col>
</v-row>
<v-row no-gutters class="mt-5">
<v-card-title class="ma-0 pa-0">
{{ $tc("recipe-finder.selected-ingredients") }}
</v-card-title>
<v-container class="ma-0 pa-0" style="max-height: 60vh; overflow-y: auto;">
<v-card-text v-if="!selectedFoods.length" class="ma-0 pa-0">
{{ $tc("recipe-finder.no-ingredients-selected") }}
</v-card-text>
<div v-if="useMobile">
<v-row no-gutters>
<v-col cols="12" class="d-flex flex-wrap justify-end">
<v-chip
v-for="food in selectedFoods"
:key="food.id"
label
class="ma-1"
color="accent custom-transparent"
close
@click:close="removeFood(food)"
>
<span class="text-hide-overflow">{{ food.pluralName || food.name }}</span>
</v-chip>
</v-col>
</v-row>
</div>
<div v-else>
<v-row v-for="food in selectedFoods" :key="food.id" no-gutters class="mb-1">
<v-col cols="12">
<v-chip
label
color="accent custom-transparent"
close
@click:close="removeFood(food)"
>
<span class="text-hide-overflow">{{ food.pluralName || food.name }}</span>
</v-chip>
</v-col>
</v-row>
</div>
</v-container>
</v-row>
<v-row v-if="selectedTools.length" no-gutters class="mt-5">
<v-card-title class="ma-0 pa-0">
{{ $tc("recipe-finder.selected-tools") }}
</v-card-title>
<v-container class="ma-0 pa-0">
<div v-if="useMobile">
<v-row no-gutters>
<v-col cols="12" class="d-flex flex-wrap justify-end">
<v-chip
v-for="tool in selectedTools"
:key="tool.id"
label
class="ma-1"
color="accent custom-transparent"
close
@click:close="removeTool(tool)"
>
<span class="text-hide-overflow">{{ tool.name }}</span>
</v-chip>
</v-col>
</v-row>
</div>
<div v-else>
<v-row v-for="tool in selectedTools" :key="tool.id" no-gutters class="mb-1">
<v-col cols="12">
<v-chip
label
color="accent custom-transparent"
close
@click:close="removeTool(tool)"
>
<span class="text-hide-overflow">{{ tool.name }}</span>
</v-chip>
</v-col>
</v-row>
</div>
</v-container>
</v-row>
</v-container>
</v-col>
<v-col :cols="useMobile ? 12 : 9">
<v-container
v-if="recipeSuggestions.readyToMake.length || recipeSuggestions.missingItems.length"
class="ma-0 pa-0"
>
<v-row v-if="recipeSuggestions.readyToMake.length" dense>
<v-col cols="12">
<v-card-title :class="attrs.title.class.readyToMake">
{{ $tc("recipe-finder.ready-to-make") }}
</v-card-title>
</v-col>
<v-col
v-for="(item, idx) in recipeSuggestions.readyToMake"
:key="`${idx}-ready`"
cols="12"
>
<v-lazy>
<RecipeSuggestion
:recipe="item.recipe"
:missing-foods="item.missingFoods"
:missing-tools="item.missingTools"
:disable-checkbox="loading"
@add-food="addFood"
@remove-food="removeFood"
@add-tool="addTool"
@remove-tool="removeTool"
/>
</v-lazy>
</v-col>
</v-row>
<v-row v-if="recipeSuggestions.missingItems.length" dense>
<v-col cols="12">
<v-card-title :class="attrs.title.class.missingItems">
{{ $tc("recipe-finder.almost-ready-to-make") }}
</v-card-title>
</v-col>
<v-col
v-for="(item, idx) in recipeSuggestions.missingItems"
:key="`${idx}-missing`"
cols="12"
>
<v-lazy>
<RecipeSuggestion
:recipe="item.recipe"
:missing-foods="item.missingFoods"
:missing-tools="item.missingTools"
:disable-checkbox="loading"
@add-food="addFood"
@remove-food="removeFood"
@add-tool="addTool"
@remove-tool="removeTool"
/>
</v-lazy>
</v-col>
</v-row>
</v-container>
<v-container v-else-if="!recipesReady">
<v-row>
<v-col cols="12" class="d-flex justify-center">
<div class="text-center">
<AppLoader waiting-text="" />
</div>
</v-col>
</v-row>
</v-container>
<v-container v-else>
<v-row>
<v-col cols="12" class="d-flex flex-row flex-wrap justify-center">
<v-card-title class="ma-0 pa-0">{{ $tc("recipe-finder.no-recipes-found") }}</v-card-title>
<v-card-text class="ma-0 pa-0 text-center">
{{ $tc("recipe-finder.no-recipes-found-description") }}
</v-card-text>
</v-col>
</v-row>
</v-container>
</v-col>
</v-row>
</v-container>
<v-container v-else>
<v-row>
<v-col cols="12" class="d-flex justify-center">
<div class="text-center">
<AppLoader waiting-text="" />
</div>
</v-col>
</v-row>
</v-container>
</v-container>
</template>
<script lang="ts">
import {
computed,
defineComponent,
onMounted,
reactive,
ref,
toRefs,
useContext,
useRoute,
watch
} from "@nuxtjs/composition-api";
import { watchDebounced } from "@vueuse/core";
import { useUserApi } from "~/composables/api";
import { usePublicExploreApi } from "~/composables/api/api-client";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useFoodStore, usePublicFoodStore, useToolStore, usePublicToolStore } from "~/composables/store";
import { IngredientFood, RecipeSuggestionQuery, RecipeSuggestionResponseItem, RecipeTool } from "~/lib/api/types/recipe";
import { Organizer } from "~/lib/api/types/non-generated";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import RecipeSuggestion from "~/components/Domain/Recipe/RecipeSuggestion.vue";
import SearchFilter from "~/components/Domain/SearchFilter.vue";
import { QueryFilterJSON } from "~/lib/api/types/response";
import { FieldDefinition } from "~/composables/use-query-filter-builder";
import { useRecipeFinderPreferences } from "~/composables/use-users/preferences";
interface RecipeSuggestions {
readyToMake: RecipeSuggestionResponseItem[];
missingItems: RecipeSuggestionResponseItem[];
}
export default defineComponent({
components: { QueryFilterBuilder, RecipeSuggestion, SearchFilter },
setup() {
const { $auth, $vuetify, i18n } = useContext();
const route = useRoute();
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const preferences = useRecipeFinderPreferences();
const state = reactive({
ready: false,
loading: false,
recipesReady: false,
settingsMenu: false,
queryFilterMenu: false,
queryFilterMenuKey: 0,
queryFilterEditorValue: "",
queryFilterEditorValueJSON: {},
queryFilterJSON: preferences.value.queryFilterJSON,
settings: {
maxMissingFoods: preferences.value.maxMissingFoods,
maxMissingTools: preferences.value.maxMissingTools,
includeFoodsOnHand: preferences.value.includeFoodsOnHand,
includeToolsOnHand: preferences.value.includeToolsOnHand,
queryFilter: preferences.value.queryFilter,
limit: 10,
},
});
onMounted(() => {
if (!isOwnGroup.value) {
state.settings.includeFoodsOnHand = false;
state.settings.includeToolsOnHand = false;
}
});
watch(
() => state,
(newState) => {
preferences.value.queryFilter = newState.settings.queryFilter;
preferences.value.queryFilterJSON = newState.queryFilterJSON;
preferences.value.maxMissingFoods = newState.settings.maxMissingFoods;
preferences.value.maxMissingTools = newState.settings.maxMissingTools;
preferences.value.includeFoodsOnHand = newState.settings.includeFoodsOnHand;
preferences.value.includeToolsOnHand = newState.settings.includeToolsOnHand;
},
{
deep: true,
},
);
const attrs = computed(() => {
return {
title: {
class: {
readyToMake: "ma-0 pa-0",
missingItems: recipeSuggestions.value.readyToMake.length ? "ma-0 pa-0 mt-5" : "ma-0 pa-0",
},
},
searchFilter: {
colClass: useMobile.value ? "d-flex flex-wrap justify-end" : "d-flex flex-wrap justify-start",
filterClass: useMobile.value ? "ml-4 mb-2" : "mr-4 mb-2",
},
settings: {
colClass: useMobile.value ? "d-flex flex-wrap justify-end" : "d-flex flex-wrap justify-start",
},
};
})
const foodStore = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
const selectedFoods = ref<IngredientFood[]>([]);
function addFood(food: IngredientFood) {
selectedFoods.value.push(food);
}
function removeFood(food: IngredientFood) {
selectedFoods.value = selectedFoods.value.filter((f) => f.id !== food.id);
}
watch(
() => selectedFoods.value,
() => {
selectedFoods.value.sort((a, b) => (a.pluralName || a.name).localeCompare(b.pluralName || b.name));
preferences.value.foodIds = selectedFoods.value.map((food) => food.id);
}
)
const toolStore = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
const selectedTools = ref<RecipeTool[]>([]);
function addTool(tool: RecipeTool) {
selectedTools.value.push(tool);
}
function removeTool(tool: RecipeTool) {
selectedTools.value = selectedTools.value.filter((t) => t.id !== tool.id);
}
watch(
() => selectedTools.value,
() => {
selectedTools.value.sort((a, b) => a.name.localeCompare(b.name));
preferences.value.toolIds = selectedTools.value.map((tool) => tool.id);
}
)
async function hydrateFoods() {
if (!preferences.value.foodIds.length) {
return;
}
if (!foodStore.store.value.length) {
await foodStore.actions.refresh();
}
const foods = preferences.value.foodIds
.map((foodId) => foodStore.store.value.find((food) => food.id === foodId))
.filter((food) => !!food);
selectedFoods.value = foods;
}
async function hydrateTools() {
if (!preferences.value.toolIds.length) {
return;
}
if (!toolStore.store.value.length) {
await toolStore.actions.refresh();
}
const tools = preferences.value.toolIds
.map((toolId) => toolStore.store.value.find((tool) => tool.id === toolId))
.filter((tool) => !!tool);
selectedTools.value = tools;
}
onMounted(async () => {
await Promise.all([hydrateFoods(), hydrateTools()]);
state.ready = true;
if (!selectedFoods.value.length) {
state.recipesReady = true;
};
});
const recipeResponseItems = ref<RecipeSuggestionResponseItem[]>([]);
const recipeSuggestions = computed<RecipeSuggestions>(() => {
const readyToMake: RecipeSuggestionResponseItem[] = [];
const missingItems: RecipeSuggestionResponseItem[] = [];
recipeResponseItems.value.forEach((responseItem) => {
if (responseItem.missingFoods.length === 0 && responseItem.missingTools.length === 0) {
readyToMake.push(responseItem);
} else {
missingItems.push(responseItem);
};
});
return {
readyToMake,
missingItems,
};
})
watchDebounced(
[selectedFoods, selectedTools, state.settings], async () => {
// don't search for suggestions if no foods are selected
if(!selectedFoods.value.length) {
recipeResponseItems.value = [];
state.recipesReady = true;
return;
}
state.loading = true;
const { data } = await api.recipes.getSuggestions(
{
limit: state.settings.limit,
queryFilter: state.settings.queryFilter,
maxMissingFoods: state.settings.maxMissingFoods,
maxMissingTools: state.settings.maxMissingTools,
includeFoodsOnHand: state.settings.includeFoodsOnHand,
includeToolsOnHand: state.settings.includeToolsOnHand,
} as RecipeSuggestionQuery,
selectedFoods.value.map((food) => food.id),
selectedTools.value.map((tool) => tool.id),
);
state.loading = false;
if (!data) {
return;
}
recipeResponseItems.value = data.items;
state.recipesReady = true;
},
{
debounce: 500,
},
);
const queryFilterBuilderFields: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.tc("category.categories"),
type: Organizer.Category,
},
{
name: "tags.id",
label: i18n.tc("tag.tags"),
type: Organizer.Tag,
},
{
name: "household_id",
label: i18n.tc("household.households"),
type: Organizer.Household,
},
];
function clearQueryFilter() {
state.queryFilterEditorValue = "";
state.queryFilterEditorValueJSON = { parts: [] } as QueryFilterJSON;
state.settings.queryFilter = "";
state.queryFilterJSON = { parts: [] } as QueryFilterJSON;
state.queryFilterMenu = false;
state.queryFilterMenuKey += 1;
}
function saveQueryFilter() {
state.settings.queryFilter = state.queryFilterEditorValue || "";
state.queryFilterJSON = state.queryFilterEditorValueJSON || { parts: [] } as QueryFilterJSON;
state.queryFilterMenu = false;
}
return {
...toRefs(state),
useMobile,
attrs,
isOwnGroup,
foods: foodStore.store,
selectedFoods,
addFood,
removeFood,
tools: toolStore.store,
selectedTools,
addTool,
removeTool,
recipeSuggestions,
queryFilterBuilderFields,
clearQueryFilter,
saveQueryFilter,
};
},
head() {
return {
title: this.$tc("recipe-finder.recipe-finder"),
};
},
});
</script>

View file

@ -16,7 +16,13 @@ from sqlalchemy.sql import sqltypes
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import SqlAlchemyBase
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery from mealie.schema.response.pagination import (
OrderByNullPosition,
OrderDirection,
PaginationBase,
PaginationQuery,
RequestQuery,
)
from mealie.schema.response.query_filter import QueryFilterBuilder from mealie.schema.response.query_filter import QueryFilterBuilder
from mealie.schema.response.query_search import SearchFilter from mealie.schema.response.query_search import SearchFilter
@ -404,11 +410,11 @@ class RepositoryGeneric(Generic[Schema, Model]):
return query.order_by(order_attr) return query.order_by(order_attr)
def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> Select: def add_order_by_to_query(self, query: Select, request_query: RequestQuery) -> Select:
if not pagination.order_by: if not request_query.order_by:
return query return query
elif pagination.order_by == "random": elif request_query.order_by == "random":
# randomize outside of database, since not all db's can set random seeds # randomize outside of database, since not all db's can set random seeds
# this solution is db-independent & stable to paging # this solution is db-independent & stable to paging
temp_query = query.with_only_columns(self.model.id) temp_query = query.with_only_columns(self.model.id)
@ -417,14 +423,14 @@ class RepositoryGeneric(Generic[Schema, Model]):
return query return query
order = list(range(len(allids))) order = list(range(len(allids)))
random.seed(pagination.pagination_seed) random.seed(request_query.pagination_seed)
random.shuffle(order) random.shuffle(order)
random_dict = dict(zip(allids, order, strict=True)) random_dict = dict(zip(allids, order, strict=True))
case_stmt = case(random_dict, value=self.model.id) case_stmt = case(random_dict, value=self.model.id)
return query.order_by(case_stmt) return query.order_by(case_stmt)
else: else:
for order_by_val in pagination.order_by.split(","): for order_by_val in request_query.order_by.split(","):
try: try:
order_by_val = order_by_val.strip() order_by_val = order_by_val.strip()
if ":" in order_by_val: if ":" in order_by_val:
@ -432,20 +438,20 @@ class RepositoryGeneric(Generic[Schema, Model]):
order_dir = OrderDirection(order_dir_val) order_dir = OrderDirection(order_dir_val)
else: else:
order_by = order_by_val order_by = order_by_val
order_dir = pagination.order_direction order_dir = request_query.order_direction
_, order_attr, query = QueryFilterBuilder.get_model_and_model_attr_from_attr_string( _, order_attr, query = QueryFilterBuilder.get_model_and_model_attr_from_attr_string(
order_by, self.model, query=query order_by, self.model, query=query
) )
query = self.add_order_attr_to_query( query = self.add_order_attr_to_query(
query, order_attr, order_dir, pagination.order_by_null_position query, order_attr, order_dir, request_query.order_by_null_position
) )
except ValueError as e: except ValueError as e:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f'Invalid order_by statement "{pagination.order_by}": "{order_by_val}" is invalid', detail=f'Invalid order_by statement "{request_query.order_by}": "{order_by_val}" is invalid',
) from e ) from e
return query return query

View file

@ -1,30 +1,37 @@
import re as re import re as re
from collections.abc import Sequence from collections.abc import Sequence
from random import randint from random import randint
from typing import cast
from uuid import UUID from uuid import UUID
import sqlalchemy as sa import sqlalchemy as sa
from fastapi import HTTPException
from pydantic import UUID4 from pydantic import UUID4
from slugify import slugify from slugify import slugify
from sqlalchemy import orm
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import InstrumentedAttribute
from typing_extensions import Self from typing_extensions import Self
from mealie.db.models.household.household import Household from mealie.db.models.household.household import Household
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.ingredient import RecipeIngredientModel from mealie.db.models.recipe.ingredient import IngredientFoodModel, RecipeIngredientModel
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool from mealie.db.models.recipe.tool import Tool, recipes_to_tools
from mealie.db.models.users.user_to_recipe import UserToRecipe from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary
from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import ( from mealie.schema.response.pagination import (
OrderByNullPosition, OrderByNullPosition,
OrderDirection, OrderDirection,
PaginationQuery, PaginationQuery,
) )
from mealie.schema.response.query_filter import QueryFilterBuilder
from ..db.models._model_base import SqlAlchemyBase from ..db.models._model_base import SqlAlchemyBase
from .repository_generic import HouseholdRepositoryGeneric from .repository_generic import HouseholdRepositoryGeneric
@ -100,7 +107,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
def add_order_attr_to_query( def add_order_attr_to_query(
self, self,
query: sa.Select, query: sa.Select,
order_attr: InstrumentedAttribute, order_attr: orm.InstrumentedAttribute,
order_dir: OrderDirection, order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None, order_by_null: OrderByNullPosition | None,
) -> sa.Select: ) -> sa.Select:
@ -297,3 +304,176 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
def all_ids(self, group_id: UUID4) -> Sequence[UUID4]: def all_ids(self, group_id: UUID4) -> Sequence[UUID4]:
stmt = sa.select(RecipeModel.id).filter(RecipeModel.group_id == group_id) stmt = sa.select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
return self.session.execute(stmt).scalars().all() return self.session.execute(stmt).scalars().all()
def find_suggested_recipes(
self,
params: RecipeSuggestionQuery,
food_ids: list[UUID4] | None = None,
tool_ids: list[UUID4] | None = None,
) -> list[RecipeSuggestionResponseItem]:
"""
Queries all recipes and returns the ones that are missing the least amount of foods and tools.
Results are ordered first by number of missing tools, then foods, and finally by the user-specified order.
If foods are provided, the query will prefer recipes with more matches to user-provided foods.
"""
if not params.order_by:
params.order_by = "created_at"
food_ids_with_on_hand = list(set(food_ids or []))
tool_ids_with_on_hand = list(set(tool_ids or []))
# preserve the original lists of ids before we add on_hand items
user_food_ids = food_ids_with_on_hand.copy()
user_tool_ids = tool_ids_with_on_hand.copy()
if params.include_foods_on_hand:
foods_on_hand_query = sa.select(IngredientFoodModel.id).filter(
IngredientFoodModel.on_hand == True, # noqa: E712 - required for SQLAlchemy comparison
sa.not_(IngredientFoodModel.id.in_(food_ids_with_on_hand)),
)
if self.group_id:
foods_on_hand_query = foods_on_hand_query.filter(IngredientFoodModel.group_id == self.group_id)
foods_on_hand = self.session.execute(foods_on_hand_query).scalars().all()
food_ids_with_on_hand.extend(foods_on_hand)
if params.include_tools_on_hand:
tools_on_hand_query = sa.select(Tool.id).filter(
Tool.on_hand == True, # noqa: E712 - required for SQLAlchemy comparison
sa.not_(
Tool.id.in_(tool_ids_with_on_hand),
),
)
if self.group_id:
tools_on_hand_query = tools_on_hand_query.filter(Tool.group_id == self.group_id)
tools_on_hand = self.session.execute(tools_on_hand_query).scalars().all()
tool_ids_with_on_hand.extend(tools_on_hand)
## Build suggestion query
settings_alias = orm.aliased(RecipeSettings)
ingredients_alias = orm.aliased(RecipeIngredientModel)
tools_alias = orm.aliased(Tool)
q = sa.select(self.model)
fltr = self._filter_builder()
q = q.filter_by(**fltr)
# Tools goes first so we can order by missing tools count before foods
if user_tool_ids:
unmatched_tools_query = (
sa.select(recipes_to_tools.c.recipe_id, sa.func.count().label("unmatched_tools_count"))
.join(tools_alias, recipes_to_tools.c.tool_id == tools_alias.id)
.filter(sa.not_(tools_alias.id.in_(tool_ids_with_on_hand)))
.group_by(recipes_to_tools.c.recipe_id)
.subquery()
)
q = (
q.outerjoin(unmatched_tools_query, self.model.id == unmatched_tools_query.c.recipe_id)
.filter(
sa.or_(
unmatched_tools_query.c.unmatched_tools_count.is_(None),
unmatched_tools_query.c.unmatched_tools_count <= params.max_missing_tools,
)
)
.order_by(unmatched_tools_query.c.unmatched_tools_count.asc().nulls_first())
)
if user_food_ids:
unmatched_foods_query = (
sa.select(ingredients_alias.recipe_id, sa.func.count().label("unmatched_foods_count"))
.filter(sa.not_(ingredients_alias.food_id.in_(food_ids_with_on_hand)))
.filter(ingredients_alias.food_id.isnot(None))
.group_by(ingredients_alias.recipe_id)
.subquery()
)
total_user_foods_query = (
sa.select(ingredients_alias.recipe_id, sa.func.count().label("total_user_foods_count"))
.filter(ingredients_alias.food_id.in_(user_food_ids))
.group_by(ingredients_alias.recipe_id)
.subquery()
)
q = (
q.join(settings_alias, self.model.settings)
.filter(settings_alias.disable_amount == False) # noqa: E712 - required for SQLAlchemy comparison
.outerjoin(unmatched_foods_query, self.model.id == unmatched_foods_query.c.recipe_id)
.outerjoin(total_user_foods_query, self.model.id == total_user_foods_query.c.recipe_id)
.filter(
sa.or_(
unmatched_foods_query.c.unmatched_foods_count.is_(None),
unmatched_foods_query.c.unmatched_foods_count <= params.max_missing_foods,
),
)
.order_by(
unmatched_foods_query.c.unmatched_foods_count.asc().nulls_first(),
# favor recipes with more matched foods, in case the user is looking for something specific
total_user_foods_query.c.total_user_foods_count.desc().nulls_last(),
)
)
# only include recipes that have at least one food in the user's list
if user_food_ids:
q = q.filter(total_user_foods_query.c.total_user_foods_count > 0)
## Add filters and loader options
if self.group_id:
q = q.filter(self.model.group_id == self.group_id)
if self.household_id:
q = q.filter(self.model.household_id == self.household_id)
if params.query_filter:
try:
query_filter_builder = QueryFilterBuilder(params.query_filter)
q = query_filter_builder.filter_query(q, model=self.model)
except ValueError as e:
self.logger.error(e)
raise HTTPException(status_code=400, detail=str(e)) from e
q = self.add_order_by_to_query(q, params)
q = q.limit(params.limit).options(*RecipeSummary.loader_options())
## Execute query
try:
data = self.session.execute(q).scalars().unique().all()
except Exception as e:
self._log_exception(e)
self.session.rollback()
raise e
suggestions: list[RecipeSuggestionResponseItem] = []
for result in data:
recipe = cast(RecipeModel, result)
missing_foods: list[IngredientFood] = []
if user_food_ids: # only check for missing foods if the user has provided a list of foods
seen_food_ids: set[UUID4] = set()
seen_food_ids.update(food_ids_with_on_hand)
for ingredient in recipe.recipe_ingredient:
if not ingredient.food:
continue
if ingredient.food.id in seen_food_ids:
continue
seen_food_ids.add(ingredient.food.id)
missing_foods.append(IngredientFood.model_validate(ingredient.food))
missing_tools: list[RecipeToolOut] = []
if user_tool_ids: # only check for missing tools if the user has provided a list of tools
seen_tool_ids: set[UUID4] = set()
seen_tool_ids.update(tool_ids_with_on_hand)
for tool in recipe.tools:
if tool.id in seen_tool_ids:
continue
seen_tool_ids.add(tool.id)
missing_tools.append(RecipeToolOut.model_validate(tool))
suggestion = RecipeSuggestionResponseItem(
recipe=RecipeSummary.model_validate(recipe),
missing_foods=missing_foods,
missing_tools=missing_tools,
)
suggestions.append(suggestion)
return suggestions

View file

@ -11,6 +11,7 @@ from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.make_dependable import make_dependable from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeSummary from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponse
from mealie.schema.response.pagination import PaginationBase, PaginationQuery, RecipeSearchQuery from mealie.schema.response.pagination import PaginationBase, PaginationQuery, RecipeSearchQuery
router = APIRouter(prefix="/recipes") router = APIRouter(prefix="/recipes")
@ -90,6 +91,26 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
# Response is returned directly, to avoid validation and improve performance # Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response) return JSONBytes(content=json_compatible_response)
@router.get("/suggestions", response_model=RecipeSuggestionResponse)
def suggest_recipes(
self,
q: RecipeSuggestionQuery = Depends(make_dependable(RecipeSuggestionQuery)),
foods: list[UUID4] | None = Query(None),
tools: list[UUID4] | None = Query(None),
) -> RecipeSuggestionResponse:
public_filter = "(household.preferences.privateHousehold = FALSE AND settings.public = TRUE)"
if q.query_filter:
q.query_filter = f"({q.query_filter}) AND {public_filter}"
else:
q.query_filter = public_filter
recipes = self.cross_household_recipes.find_suggested_recipes(q, foods, tools)
response = RecipeSuggestionResponse(items=recipes)
json_compatible_response = orjson.dumps(response.model_dump(by_alias=True))
# Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response)
@router.get("/{recipe_slug}", response_model=Recipe) @router.get("/{recipe_slug}", response_model=Recipe)
def get_recipe(self, recipe_slug: str) -> Recipe: def get_recipe(self, recipe_slug: str) -> Recipe:
RECIPE_NOT_FOUND_EXCEPTION = HTTPException(404, "recipe not found") RECIPE_NOT_FOUND_EXCEPTION = HTTPException(404, "recipe not found")

View file

@ -1,14 +1,14 @@
from fastapi import APIRouter from fastapi import APIRouter
from . import bulk_actions, comments, recipe_crud_routes, shared_routes, timeline_events from . import bulk_actions, comments, exports, recipe_crud_routes, shared_routes, timeline_events
prefix = "/recipes" prefix = "/recipes"
router = APIRouter() router = APIRouter()
router.include_router(recipe_crud_routes.router_exports, tags=["Recipe: Exports"]) router.include_router(exports.router, tags=["Recipe: Exports"])
router.include_router(recipe_crud_routes.router, tags=["Recipe: CRUD"]) router.include_router(recipe_crud_routes.router, tags=["Recipe: CRUD"])
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"]) router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Actions"]) router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Actions"])
router.include_router(shared_routes.router, prefix=prefix, tags=["Recipe: Shared"]) router.include_router(shared_routes.router, prefix=prefix, tags=["Recipe: Shared"])
router.include_router(timeline_events.events_router, prefix=prefix, tags=["Recipe: Timeline"]) router.include_router(timeline_events.router, prefix=prefix, tags=["Recipe: Timeline"])

View file

@ -0,0 +1,57 @@
from functools import cached_property
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from mealie.db.models.household.cookbook import CookBook
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_generic import RepositoryGeneric
from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.routes._base import BaseCrudController
from mealie.routes._base.mixins import HttpRepo
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import (
CreateRecipe,
)
from mealie.services.recipe.recipe_service import RecipeService
class JSONBytes(JSONResponse):
"""
JSONBytes overrides the render method to return the bytes instead of a string.
You can use this when you want to use orjson and bypass the jsonable_encoder
"""
media_type = "application/json"
def render(self, content: bytes) -> bytes:
return content
class FormatResponse(BaseModel):
jjson: list[str] = Field(..., alias="json")
zip: list[str]
jinja2: list[str]
class BaseRecipeController(BaseCrudController):
@cached_property
def recipes(self) -> RepositoryRecipes:
return self.repos.recipes
@cached_property
def group_recipes(self) -> RepositoryRecipes:
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
@cached_property
def group_cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return get_repositories(self.session, group_id=self.group_id, household_id=None).cookbooks
@cached_property
def service(self) -> RecipeService:
return RecipeService(self.repos, self.user, self.household, translator=self.translator)
@cached_property
def mixins(self):
return HttpRepo[CreateRecipe, Recipe, Recipe](self.recipes, self.logger)

View file

@ -0,0 +1,76 @@
from shutil import rmtree
from zipfile import ZipFile
from fastapi import (
HTTPException,
)
from starlette.background import BackgroundTask
from starlette.responses import FileResponse
from mealie.core.dependencies import (
get_temporary_path,
get_temporary_zip_path,
validate_recipe_token,
)
from mealie.core.security import create_recipe_slug_token
from mealie.routes._base import controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.recipe import Recipe, RecipeImageTypes
from mealie.schema.recipe.request_helpers import (
RecipeZipTokenResponse,
)
from mealie.services.recipe.template_service import TemplateService
from ._base import BaseRecipeController, FormatResponse
router = UserAPIRouter(prefix="/recipes")
@controller(router)
class RecipeExportController(BaseRecipeController):
# ==================================================================================================================
# Export Operations
@router.get("/exports", response_model=FormatResponse)
def get_recipe_formats_and_templates(self):
return TemplateService().templates
@router.post("/{slug}/exports", response_model=RecipeZipTokenResponse)
def get_recipe_zip_token(self, slug: str):
"""Generates a recipe zip token to be used to download a recipe as a zip file"""
return RecipeZipTokenResponse(token=create_recipe_slug_token(slug))
@router.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format(self, slug: str, template_name: str):
"""
## Parameters
`template_name`: The name of the template to use to use in the exports listed. Template type will automatically
be set on the backend. Because of this, it's important that your templates have unique names. See available
names and formats in the /api/recipes/exports endpoint.
"""
with get_temporary_path(auto_unlink=False) as temp_path:
recipe = self.mixins.get_one(slug)
file = self.service.render_template(recipe, temp_path, template_name)
return FileResponse(file, background=BackgroundTask(rmtree, temp_path))
@router.get("/{slug}/exports/zip")
def get_recipe_as_zip(self, slug: str, token: str):
"""Get a Recipe and Its Original Image as a Zip File"""
with get_temporary_zip_path(auto_unlink=False) as temp_path:
validated_slug = validate_recipe_token(token)
if validated_slug != slug:
raise HTTPException(status_code=400, detail="Invalid Slug")
recipe: Recipe = self.mixins.get_one(validated_slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.model_dump_json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(
temp_path, filename=f"{recipe.slug}.zip", background=BackgroundTask(temp_path.unlink, missing_ok=True)
)

View file

@ -1,8 +1,6 @@
from collections import defaultdict from collections import defaultdict
from functools import cached_property from shutil import copyfileobj
from shutil import copyfileobj, rmtree
from uuid import UUID from uuid import UUID
from zipfile import ZipFile
import orjson import orjson
import sqlalchemy import sqlalchemy
@ -18,30 +16,19 @@ from fastapi import (
status, status,
) )
from fastapi.datastructures import UploadFile from fastapi.datastructures import UploadFile
from fastapi.responses import JSONResponse from pydantic import UUID4
from pydantic import UUID4, BaseModel, Field
from slugify import slugify from slugify import slugify
from starlette.background import BackgroundTask
from starlette.responses import FileResponse
from mealie.core import exceptions from mealie.core import exceptions
from mealie.core.dependencies import ( from mealie.core.dependencies import (
get_temporary_path,
get_temporary_zip_path, get_temporary_zip_path,
validate_recipe_token,
) )
from mealie.core.security import create_recipe_slug_token
from mealie.db.models.household.cookbook import CookBook
from mealie.pkgs import cache from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories from mealie.routes._base import controller
from mealie.repos.repository_generic import RepositoryGeneric
from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.routes._base import BaseCrudController, controller
from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.make_dependable import make_dependable from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe, ScrapeRecipeData from mealie.schema.recipe import Recipe, ScrapeRecipe, ScrapeRecipeData
from mealie.schema.recipe.recipe import ( from mealie.schema.recipe.recipe import (
CreateRecipe, CreateRecipe,
CreateRecipeByUrlBulk, CreateRecipeByUrlBulk,
@ -50,9 +37,9 @@ from mealie.schema.recipe.recipe import (
) )
from mealie.schema.recipe.recipe_asset import RecipeAsset from mealie.schema.recipe.recipe_asset import RecipeAsset
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponse
from mealie.schema.recipe.request_helpers import ( from mealie.schema.recipe.request_helpers import (
RecipeDuplicate, RecipeDuplicate,
RecipeZipTokenResponse,
UpdateImageResponse, UpdateImageResponse,
) )
from mealie.schema.response import PaginationBase, PaginationQuery from mealie.schema.response import PaginationBase, PaginationQuery
@ -71,8 +58,6 @@ from mealie.services.recipe.recipe_data_service import (
NotAnImageError, NotAnImageError,
RecipeDataService, RecipeDataService,
) )
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.recipe.template_service import TemplateService
from mealie.services.scraper.recipe_bulk_scraper import RecipeBulkScraperService from mealie.services.scraper.recipe_bulk_scraper import RecipeBulkScraperService
from mealie.services.scraper.scraped_extras import ScraperContext from mealie.services.scraper.scraped_extras import ScraperContext
from mealie.services.scraper.scraper import create_from_html from mealie.services.scraper.scraper import create_from_html
@ -82,99 +67,7 @@ from mealie.services.scraper.scraper_strategies import (
RecipeScraperPackage, RecipeScraperPackage,
) )
from ._base import BaseRecipeController, JSONBytes
class JSONBytes(JSONResponse):
"""
JSONBytes overrides the render method to return the bytes instead of a string.
You can use this when you want to use orjson and bypass the jsonable_encoder
"""
media_type = "application/json"
def render(self, content: bytes) -> bytes:
return content
class BaseRecipeController(BaseCrudController):
@cached_property
def recipes(self) -> RepositoryRecipes:
return self.repos.recipes
@cached_property
def group_recipes(self) -> RepositoryRecipes:
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
@cached_property
def group_cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return get_repositories(self.session, group_id=self.group_id, household_id=None).cookbooks
@cached_property
def service(self) -> RecipeService:
return RecipeService(self.repos, self.user, self.household, translator=self.translator)
@cached_property
def mixins(self):
return HttpRepo[CreateRecipe, Recipe, Recipe](self.recipes, self.logger)
class FormatResponse(BaseModel):
jjson: list[str] = Field(..., alias="json")
zip: list[str]
jinja2: list[str]
router_exports = UserAPIRouter(prefix="/recipes")
@controller(router_exports)
class RecipeExportController(BaseRecipeController):
# ==================================================================================================================
# Export Operations
@router_exports.get("/exports", response_model=FormatResponse)
def get_recipe_formats_and_templates(self):
return TemplateService().templates
@router_exports.post("/{slug}/exports", response_model=RecipeZipTokenResponse)
def get_recipe_zip_token(self, slug: str):
"""Generates a recipe zip token to be used to download a recipe as a zip file"""
return RecipeZipTokenResponse(token=create_recipe_slug_token(slug))
@router_exports.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format(self, slug: str, template_name: str):
"""
## Parameters
`template_name`: The name of the template to use to use in the exports listed. Template type will automatically
be set on the backend. Because of this, it's important that your templates have unique names. See available
names and formats in the /api/recipes/exports endpoint.
"""
with get_temporary_path(auto_unlink=False) as temp_path:
recipe = self.mixins.get_one(slug)
file = self.service.render_template(recipe, temp_path, template_name)
return FileResponse(file, background=BackgroundTask(rmtree, temp_path))
@router_exports.get("/{slug}/exports/zip")
def get_recipe_as_zip(self, slug: str, token: str):
"""Get a Recipe and Its Original Image as a Zip File"""
with get_temporary_zip_path(auto_unlink=False) as temp_path:
validated_slug = validate_recipe_token(token)
if validated_slug != slug:
raise HTTPException(status_code=400, detail="Invalid Slug")
recipe: Recipe = self.mixins.get_one(validated_slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.model_dump_json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(
temp_path, filename=f"{recipe.slug}.zip", background=BackgroundTask(temp_path.unlink, missing_ok=True)
)
router = UserAPIRouter(prefix="/recipes", route_class=MealieCrudRoute) router = UserAPIRouter(prefix="/recipes", route_class=MealieCrudRoute)
@ -388,6 +281,20 @@ class RecipeController(BaseRecipeController):
# Response is returned directly, to avoid validation and improve performance # Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response) return JSONBytes(content=json_compatible_response)
@router.get("/suggestions", response_model=RecipeSuggestionResponse)
def suggest_recipes(
self,
q: RecipeSuggestionQuery = Depends(make_dependable(RecipeSuggestionQuery)),
foods: list[UUID4] | None = Query(None),
tools: list[UUID4] | None = Query(None),
) -> RecipeSuggestionResponse:
recipes = self.group_recipes.find_suggested_recipes(q, foods, tools)
response = RecipeSuggestionResponse(items=recipes)
json_compatible_response = orjson.dumps(response.model_dump(by_alias=True))
# Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response)
@router.get("/{slug}", response_model=Recipe) @router.get("/{slug}", response_model=Recipe)
def get_one(self, slug: str = Path(..., description="A recipe's slug or id")): 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""" """Takes in a recipe's slug or id and returns all data for a recipe"""

View file

@ -22,10 +22,10 @@ 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 from mealie.services.recipe.recipe_data_service import RecipeDataService
events_router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events") router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events")
@controller(events_router) @controller(router)
class RecipeTimelineEventsController(BaseCrudController): class RecipeTimelineEventsController(BaseCrudController):
@cached_property @cached_property
def repo(self): def repo(self):
@ -43,17 +43,17 @@ class RecipeTimelineEventsController(BaseCrudController):
self.registered_exceptions, self.registered_exceptions,
) )
@events_router.get("", response_model=RecipeTimelineEventPagination) @router.get("", response_model=RecipeTimelineEventPagination)
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
response = self.repo.page_all( response = self.repo.page_all(
pagination=q, pagination=q,
override=RecipeTimelineEventOut, override=RecipeTimelineEventOut,
) )
response.set_pagination_guides(events_router.url_path_for("get_all"), q.model_dump()) response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response return response
@events_router.post("", response_model=RecipeTimelineEventOut, status_code=201) @router.post("", response_model=RecipeTimelineEventOut, status_code=201)
def create_one(self, data: RecipeTimelineEventIn): def create_one(self, data: RecipeTimelineEventIn):
# if the user id is not specified, use the currently-authenticated user # if the user id is not specified, use the currently-authenticated user
data.user_id = data.user_id or self.user.id data.user_id = data.user_id or self.user.id
@ -81,11 +81,11 @@ class RecipeTimelineEventsController(BaseCrudController):
return event return event
@events_router.get("/{item_id}", response_model=RecipeTimelineEventOut) @router.get("/{item_id}", response_model=RecipeTimelineEventOut)
def get_one(self, item_id: UUID4): def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id) return self.mixins.get_one(item_id)
@events_router.put("/{item_id}", response_model=RecipeTimelineEventOut) @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.patch_one(data, item_id) event = self.mixins.patch_one(data, item_id)
recipe = self.group_recipes.get_one(event.recipe_id, "id") recipe = self.group_recipes.get_one(event.recipe_id, "id")
@ -106,7 +106,7 @@ class RecipeTimelineEventsController(BaseCrudController):
return event return event
@events_router.delete("/{item_id}", response_model=RecipeTimelineEventOut) @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(): if event.image_dir.exists():
@ -136,7 +136,7 @@ class RecipeTimelineEventsController(BaseCrudController):
# ================================================================================================================== # ==================================================================================================================
# Image and Assets # Image and Assets
@events_router.put("/{item_id}/image", response_model=UpdateImageResponse) @router.put("/{item_id}/image", response_model=UpdateImageResponse)
def update_event_image(self, item_id: UUID4, image: bytes = File(...), extension: str = Form(...)): def update_event_image(self, item_id: UUID4, image: bytes = File(...), extension: str = Form(...)):
event = self.mixins.get_one(item_id) event = self.mixins.get_one(item_id)
data_service = RecipeDataService(event.recipe_id) data_service = RecipeDataService(event.recipe_id)

View file

@ -75,6 +75,7 @@ from .recipe_scraper import ScrapeRecipe, ScrapeRecipeBase, ScrapeRecipeData, Sc
from .recipe_settings import RecipeSettings from .recipe_settings import RecipeSettings
from .recipe_share_token import RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenSave, RecipeShareTokenSummary from .recipe_share_token import RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenSave, RecipeShareTokenSummary
from .recipe_step import IngredientReferences, RecipeStep from .recipe_step import IngredientReferences, RecipeStep
from .recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponse, RecipeSuggestionResponseItem
from .recipe_timeline_events import ( from .recipe_timeline_events import (
RecipeTimelineEventCreate, RecipeTimelineEventCreate,
RecipeTimelineEventIn, RecipeTimelineEventIn,
@ -109,6 +110,9 @@ __all__ = [
"RecipeTimelineEventUpdate", "RecipeTimelineEventUpdate",
"TimelineEventImage", "TimelineEventImage",
"TimelineEventType", "TimelineEventType",
"RecipeSuggestionQuery",
"RecipeSuggestionResponse",
"RecipeSuggestionResponseItem",
"Nutrition", "Nutrition",
"RecipeShareToken", "RecipeShareToken",
"RecipeShareTokenCreate", "RecipeShareTokenCreate",

View file

@ -0,0 +1,24 @@
from mealie.schema._mealie.mealie_model import MealieModel
from mealie.schema.recipe.recipe import RecipeSummary, RecipeTool
from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.response.pagination import RequestQuery
class RecipeSuggestionQuery(RequestQuery):
limit: int = 10
max_missing_foods: int = 5
max_missing_tools: int = 5
include_foods_on_hand: bool = True
include_tools_on_hand: bool = True
class RecipeSuggestionResponseItem(MealieModel):
recipe: RecipeSummary
missing_foods: list[IngredientFood]
missing_tools: list[RecipeTool]
class RecipeSuggestionResponse(MealieModel):
items: list[RecipeSuggestionResponseItem]

View file

@ -1,5 +1,12 @@
# This file is auto-generated by gen_schema_exports.py # This file is auto-generated by gen_schema_exports.py
from .pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery, RecipeSearchQuery from .pagination import (
OrderByNullPosition,
OrderDirection,
PaginationBase,
PaginationQuery,
RecipeSearchQuery,
RequestQuery,
)
from .query_filter import ( from .query_filter import (
LogicalOperator, LogicalOperator,
QueryFilterBuilder, QueryFilterBuilder,
@ -27,6 +34,7 @@ __all__ = [
"PaginationBase", "PaginationBase",
"PaginationQuery", "PaginationQuery",
"RecipeSearchQuery", "RecipeSearchQuery",
"RequestQuery",
"SearchFilter", "SearchFilter",
"ErrorResponse", "ErrorResponse",
"FileTokenResponse", "FileTokenResponse",

View file

@ -31,9 +31,7 @@ class RecipeSearchQuery(MealieModel):
_search_seed: str | None = None _search_seed: str | None = None
class PaginationQuery(MealieModel): class RequestQuery(MealieModel):
page: int = 1
per_page: int = 50
order_by: str | None = None order_by: str | None = None
order_by_null_position: OrderByNullPosition | None = None order_by_null_position: OrderByNullPosition | None = None
order_direction: OrderDirection = OrderDirection.desc order_direction: OrderDirection = OrderDirection.desc
@ -47,6 +45,11 @@ class PaginationQuery(MealieModel):
return pagination_seed return pagination_seed
class PaginationQuery(RequestQuery):
page: int = 1
per_page: int = 50
class PaginationBase(BaseModel, Generic[DataT]): class PaginationBase(BaseModel, Generic[DataT]):
page: int = 1 page: int = 1
per_page: int = 10 per_page: int = 10

View file

@ -1,5 +1,6 @@
import random import random
from typing import Any from typing import Any
from uuid import uuid4
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -8,6 +9,7 @@ from pydantic import UUID4
from mealie.schema.cookbook.cookbook import SaveCookBook from mealie.schema.cookbook.cookbook import SaveCookBook
from mealie.schema.recipe.recipe import Recipe from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_category import TagSave from mealie.schema.recipe.recipe_category import TagSave
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
from tests.utils import api_routes from tests.utils import api_routes
from tests.utils.factories import random_int, random_string from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser from tests.utils.fixture_schemas import TestUser
@ -335,3 +337,71 @@ def test_public_recipe_cookbook_filter_with_recipes(
assert str(other_household_recipe.id) not in recipe_ids assert str(other_household_recipe.id) not in recipe_ids
else: else:
assert str(other_household_recipe.id) in recipe_ids assert str(other_household_recipe.id) in recipe_ids
@pytest.mark.parametrize("is_private_group", [True, False])
@pytest.mark.parametrize("is_private_household", [True, False])
@pytest.mark.parametrize("is_private_recipe", [True, False])
def test_get_suggested_recipes(
api_client: TestClient,
unique_user: TestUser,
random_recipe: Recipe,
is_private_group: bool,
is_private_household: bool,
is_private_recipe: bool,
):
database = unique_user.repos
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Household
household = database.households.get_one(unique_user.household_id)
assert household and household.preferences
household.preferences.private_household = is_private_household
household.preferences.recipe_public = not is_private_household
database.household_preferences.update(household.id, household.preferences)
## Set Recipe `settings.public` attribute
assert random_recipe.settings
random_recipe.settings.public = not is_private_recipe
database.recipes.update(random_recipe.slug, random_recipe)
## Add a known food to the recipe
known_food = database.ingredient_foods.create(
SaveIngredientFood(id=uuid4(), name=random_string(), group_id=unique_user.group_id)
)
random_recipe.recipe_ingredient = [RecipeIngredient(food_id=known_food.id, food=known_food)]
random_recipe.settings.disable_amount = False
database.recipes.update(random_recipe.slug, random_recipe)
## Try to find suggested recipes
recipe_group = database.groups.get_by_slug_or_id(random_recipe.group_id)
recipe_household = database.households.get_by_slug_or_id(random_recipe.household_id)
assert recipe_group
assert recipe_household
response = api_client.get(
api_routes.explore_groups_group_slug_recipes_suggestions(recipe_group.slug),
params={"maxMissingFoods": 0, "foods": [str(known_food.id)], "includeFoodsOnHand": False},
)
if is_private_group:
assert response.status_code == 404
assert response.json()["detail"] == "group not found"
return
if is_private_household or is_private_recipe:
if is_private_group:
assert response.json()["detail"] == "group not found"
else:
assert response.json()["items"] == []
return
as_json = response.json()
assert len(as_json["items"]) == 1
assert as_json["items"][0]["recipe"]["name"] == random_recipe.name
assert as_json["items"][0]["recipe"]["slug"] == random_recipe.slug

View file

@ -0,0 +1,581 @@
import random
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_ingredient import IngredientFood, RecipeIngredient, SaveIngredientFood
from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.schema.recipe.recipe_tool import RecipeToolOut, RecipeToolSave
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
def create_food(user: TestUser, on_hand: bool = False):
return user.repos.ingredient_foods.create(
SaveIngredientFood(id=uuid4(), name=random_string(), group_id=user.group_id, on_hand=on_hand)
)
def create_tool(user: TestUser, on_hand: bool = False):
return user.repos.tools.create(
RecipeToolSave(id=uuid4(), name=random_string(), group_id=user.group_id, on_hand=on_hand)
)
def create_recipe(
user: TestUser,
*,
foods: list[IngredientFood] | None = None,
tools: list[RecipeToolOut] | None = None,
disable_amount: bool = False,
**kwargs,
):
if foods:
ingredients = [RecipeIngredient(food_id=food.id, food=food) for food in foods]
else:
ingredients = []
recipe = user.repos.recipes.create(
Recipe(
user_id=user.user_id,
group_id=user.group_id,
name=kwargs.pop("name", random_string()),
recipe_ingredient=ingredients,
tools=tools or [],
settings=RecipeSettings(disable_amount=disable_amount),
**kwargs,
)
)
return recipe
@pytest.fixture(autouse=True)
def base_recipes(unique_user: TestUser, h2_user: TestUser):
for user in [unique_user, h2_user]:
for _ in range(10):
create_recipe(
user,
foods=[create_food(user) for _ in range(random_int(5, 10))],
tools=[create_tool(user) for _ in range(random_int(5, 10))],
)
@pytest.mark.parametrize("filter_foods", [True, False])
@pytest.mark.parametrize("filter_tools", [True, False])
def test_suggestion_filter(api_client: TestClient, unique_user: TestUser, filter_foods: bool, filter_tools: bool):
create_params: dict = {}
api_params: dict = {"maxMissingFoods": 0, "maxMissingTools": 0, "limit": 10}
if filter_foods:
known_food = create_food(unique_user)
create_params["foods"] = [known_food]
api_params["foods"] = [str(known_food.id)]
if filter_tools:
known_tool = create_tool(unique_user)
create_params["tools"] = [known_tool]
api_params["tools"] = [str(known_tool.id)]
recipes = [create_recipe(unique_user, **create_params) for _ in range(3)]
try:
expected_recipe_ids = {str(recipe.id) for recipe in recipes if recipe.id}
response = api_client.get(api_routes.recipes_suggestions, params=api_params, headers=unique_user.token)
response.raise_for_status()
data = response.json()
if not filter_foods and not filter_tools:
assert len(data["items"]) == 10
else:
assert len(data["items"]) == 3
for item in data["items"]:
assert item["recipe"]["id"] in expected_recipe_ids
assert item["missingFoods"] == []
assert item["missingTools"] == []
finally:
for recipe in recipes:
unique_user.repos.recipes.delete(recipe.slug)
def test_food_suggestion_filter_with_max(api_client: TestClient, unique_user: TestUser):
food_1, food_2, food_3, food_4 = (create_food(unique_user) for _ in range(4))
recipe_exact = create_recipe(unique_user, foods=[food_1])
recipe_missing_one = create_recipe(unique_user, foods=[food_1, food_2])
recipe_missing_two = create_recipe(unique_user, foods=[food_1, food_2, food_3])
recipe_missing_three = create_recipe(unique_user, foods=[food_1, food_2, food_3, food_4])
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 1, "includeFoodsOnHand": False, "foods": [str(food_1.id)]},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
fetched_recipe_ids = {item["recipe"]["id"] for item in data["items"]}
assert set(fetched_recipe_ids) == {str(recipe_exact.id), str(recipe_missing_one.id)}
for item in data["items"]:
missing_food_ids = [food["id"] for food in item["missingFoods"]]
if item["recipe"]["id"] == str(recipe_exact.id):
assert missing_food_ids == []
else:
assert missing_food_ids == [str(food_2.id)]
finally:
for recipe in [recipe_exact, recipe_missing_one, recipe_missing_two, recipe_missing_three]:
unique_user.repos.recipes.delete(recipe.slug)
def test_tool_suggestion_filter_with_max(api_client: TestClient, unique_user: TestUser):
tool_1, tool_2, tool_3, tool_4 = (create_tool(unique_user) for _ in range(4))
recipe_exact = create_recipe(unique_user, tools=[tool_1])
recipe_missing_one = create_recipe(unique_user, tools=[tool_1, tool_2])
recipe_missing_two = create_recipe(unique_user, tools=[tool_1, tool_2, tool_3])
recipe_missing_three = create_recipe(unique_user, tools=[tool_1, tool_2, tool_3, tool_4])
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingTools": 1, "includeToolsOnHand": False, "tools": [str(tool_1.id)]},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
fetched_recipe_ids = {item["recipe"]["id"] for item in data["items"]}
assert set(fetched_recipe_ids) == {str(recipe_exact.id), str(recipe_missing_one.id)}
for item in data["items"]:
missing_tool_ids = [tool["id"] for tool in item["missingTools"]]
if item["recipe"]["id"] == str(recipe_exact.id):
assert missing_tool_ids == []
else:
assert missing_tool_ids == [str(tool_2.id)]
finally:
for recipe in [recipe_exact, recipe_missing_one, recipe_missing_two, recipe_missing_three]:
unique_user.repos.recipes.delete(recipe.slug)
def test_ignore_empty_food_filter(api_client: TestClient, unique_user: TestUser):
known_tool = create_tool(unique_user)
recipe = create_recipe(
unique_user, foods=[create_food(unique_user) for _ in range(random_int(3, 5))], tools=[known_tool]
)
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "maxMissingTools": 0, "tools": [str(known_tool.id)]},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert len(data["items"]) == 1
item = data["items"][0]
assert item["recipe"]["id"] == str(recipe.id)
assert item["missingFoods"] == []
assert item["missingTools"] == []
finally:
unique_user.repos.recipes.delete(recipe.slug)
def test_ignore_empty_tool_filter(api_client: TestClient, unique_user: TestUser):
known_food = create_food(unique_user)
recipe = create_recipe(
unique_user, foods=[known_food], tools=[create_tool(unique_user) for _ in range(random_int(3, 5))]
)
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "maxMissingTools": 0, "foods": [str(known_food.id)]},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert len(data["items"]) == 1
item = data["items"][0]
assert item["recipe"]["id"] == str(recipe.id)
assert item["missingFoods"] == []
assert item["missingTools"] == []
finally:
unique_user.repos.recipes.delete(recipe.slug)
@pytest.mark.parametrize("include_on_hand", [True, False])
def test_include_foods_on_hand(api_client: TestClient, unique_user: TestUser, include_on_hand: bool):
on_hand_food = create_food(unique_user, on_hand=True)
off_hand_food = create_food(unique_user, on_hand=False)
recipe = create_recipe(unique_user, foods=[on_hand_food, off_hand_food])
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={
"maxMissingFoods": 0,
"maxMissingTools": 0,
"includeFoodsOnHand": include_on_hand,
"foods": [str(off_hand_food.id)],
},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
if not include_on_hand:
assert len(data["items"]) == 0
else:
assert len(data["items"]) == 1
item = data["items"][0]
assert item["recipe"]["id"] == str(recipe.id)
assert item["missingFoods"] == []
finally:
unique_user.repos.recipes.delete(recipe.slug)
@pytest.mark.parametrize("include_on_hand", [True, False])
def test_include_tools_on_hand(api_client: TestClient, unique_user: TestUser, include_on_hand: bool):
on_hand_tool = create_tool(unique_user, on_hand=True)
off_hand_tool = create_tool(unique_user, on_hand=False)
recipe = create_recipe(unique_user, tools=[on_hand_tool, off_hand_tool])
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={
"maxMissingFoods": 0,
"maxMissingTools": 0,
"includeToolsOnHand": include_on_hand,
"tools": [str(off_hand_tool.id)],
},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
if not include_on_hand:
assert len(data["items"]) == 0
else:
assert len(data["items"]) == 1
item = data["items"][0]
assert item["recipe"]["id"] == str(recipe.id)
assert item["missingTools"] == []
finally:
unique_user.repos.recipes.delete(recipe.slug)
def test_exclude_recipes_with_no_foods(api_client: TestClient, unique_user: TestUser):
known_food = create_food(unique_user)
recipe_with_foods = create_recipe(unique_user, foods=[known_food])
recipe_without_foods = create_recipe(unique_user, foods=[])
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "maxMissingTools": 0, "foods": [str(known_food.id)]},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert {item["recipe"]["id"] for item in data["items"]} == {str(recipe_with_foods.id)}
for item in data["items"]:
assert item["missingFoods"] == []
finally:
for recipe in [recipe_with_foods, recipe_without_foods]:
unique_user.repos.recipes.delete(recipe.slug)
def test_include_recipes_with_no_tools(api_client: TestClient, unique_user: TestUser):
known_tool = create_tool(unique_user)
recipe_with_tools = create_recipe(unique_user, tools=[known_tool])
recipe_without_tools = create_recipe(unique_user, tools=[])
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "maxMissingTools": 0, "tools": [str(known_tool.id)]},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert {item["recipe"]["id"] for item in data["items"]} == {
str(recipe_with_tools.id),
str(recipe_without_tools.id),
}
for item in data["items"]:
assert item["missingTools"] == []
finally:
for recipe in [recipe_with_tools, recipe_without_tools]:
unique_user.repos.recipes.delete(recipe.slug)
def test_ignore_recipes_with_ingredient_amounts_disabled_with_foods(api_client: TestClient, unique_user: TestUser):
known_food = create_food(unique_user)
recipe_with_amounts = create_recipe(unique_user, foods=[known_food])
recipe_without_amounts = create_recipe(unique_user, foods=[known_food], disable_amount=True)
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "maxMissingTools": 0, "foods": [str(known_food.id)]},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert {item["recipe"]["id"] for item in data["items"]} == {str(recipe_with_amounts.id)}
for item in data["items"]:
assert item["missingFoods"] == []
finally:
for recipe in [recipe_with_amounts, recipe_without_amounts]:
unique_user.repos.recipes.delete(recipe.slug)
def test_include_recipes_with_ingredient_amounts_disabled_without_foods(api_client: TestClient, unique_user: TestUser):
known_tool = create_tool(unique_user)
recipe_with_amounts = create_recipe(unique_user, tools=[known_tool])
recipe_without_amounts = create_recipe(unique_user, tools=[known_tool], disable_amount=True)
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={
"maxMissingFoods": 0,
"maxMissingTools": 0,
"includeFoodsOnHand": False,
"tools": [str(known_tool.id)],
},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert {item["recipe"]["id"] for item in data["items"]} == {
str(recipe_with_amounts.id),
str(recipe_without_amounts.id),
}
for item in data["items"]:
assert item["missingFoods"] == []
finally:
for recipe in [recipe_with_amounts, recipe_without_amounts]:
unique_user.repos.recipes.delete(recipe.slug)
def test_exclude_recipes_with_no_user_foods(api_client: TestClient, unique_user: TestUser):
known_food = create_food(unique_user)
food_on_hand = create_food(unique_user, on_hand=True)
recipe_with_user_food = create_recipe(unique_user, foods=[known_food])
recipe_with_on_hand_food = create_recipe(unique_user, foods=[food_on_hand])
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 10, "includeFoodsOnHand": True, "foods": [str(known_food.id)]},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert {item["recipe"]["id"] for item in data["items"]} == {str(recipe_with_user_food.id)}
assert data["items"][0]["missingFoods"] == []
finally:
for recipe in [recipe_with_user_food, recipe_with_on_hand_food]:
unique_user.repos.recipes.delete(recipe.slug)
def test_recipe_order(api_client: TestClient, unique_user: TestUser):
user_food_1, user_food_2, other_food_1, other_food_2, other_food_3 = (create_food(unique_user) for _ in range(5))
user_tool_1, other_tool_1, other_tool_2 = (create_tool(unique_user) for _ in range(3))
food_on_hand = create_food(unique_user, on_hand=True)
recipe_lambdas = [
# No missing tools or foods
(0, lambda: create_recipe(unique_user, tools=[user_tool_1], foods=[user_food_1])),
# No missing tools, one missing food
(1, lambda: create_recipe(unique_user, tools=[user_tool_1], foods=[user_food_1, other_food_1])),
# One missing tool, no missing foods
(2, lambda: create_recipe(unique_user, tools=[user_tool_1, other_tool_1], foods=[user_food_1])),
# One missing tool, one missing food
(3, lambda: create_recipe(unique_user, tools=[user_tool_1, other_tool_1], foods=[user_food_1, other_food_1])),
# Two missing tools, two missing foods, two user foods
(
4,
lambda: create_recipe(
unique_user,
tools=[user_tool_1, other_tool_1, other_tool_2],
foods=[user_food_1, user_food_2, other_food_1, other_food_2],
),
),
# Two missing tools, two missing foods, one user food
(
5,
lambda: create_recipe(
unique_user,
tools=[user_tool_1, other_tool_1, other_tool_2],
foods=[user_food_1, other_food_1, other_food_2],
),
),
# Two missing tools, three missing foods, two user foods, don't include food on hand
(
6,
lambda: create_recipe(
unique_user,
tools=[user_tool_1, other_tool_1, other_tool_2],
foods=[user_food_1, user_food_2, other_food_1, other_food_2, other_food_3],
),
),
# Two missing tools, three missing foods, one user food, include food on hand
(
7,
lambda: create_recipe(
unique_user,
tools=[user_tool_1, other_tool_1, other_tool_2],
foods=[food_on_hand, user_food_1, other_food_1, other_food_2, other_food_3],
),
),
]
# create recipes in a random order
random.shuffle(recipe_lambdas)
recipe_tuples: list[tuple[int, Recipe]] = []
for i, recipe_lambda in recipe_lambdas:
recipe_tuples.append((i, recipe_lambda()))
recipe_tuples.sort(key=lambda x: x[0])
recipes = [recipe_tuple[1] for recipe_tuple in recipe_tuples]
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={
"maxMissingFoods": 3,
"maxMissingTools": 3,
"includeFoodsOnHand": True,
"includeToolsOnHand": True,
"limit": 10,
"foods": [str(user_food_1.id), str(user_food_2.id)],
"tools": [str(user_tool_1.id)],
},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert len(data["items"]) == len(recipes)
for i, (item, recipe) in enumerate(zip(data["items"], recipes, strict=True)):
try:
assert item["recipe"]["id"] == str(recipe.id)
except AssertionError as e:
raise AssertionError(f"Recipe in position {i} was incorrect") from e
finally:
for recipe in recipes:
unique_user.repos.recipes.delete(recipe.slug)
def test_respect_user_sort(api_client: TestClient, unique_user: TestUser):
known_food = create_food(unique_user)
# Create recipes with names A, B, C, D out of order
recipe_b = create_recipe(unique_user, foods=[known_food], name="B")
recipe_c = create_recipe(unique_user, foods=[known_food, create_food(unique_user)], name="C")
recipe_a = create_recipe(unique_user, foods=[known_food, create_food(unique_user)], name="A")
recipe_d = create_recipe(unique_user, foods=[known_food, create_food(unique_user)], name="D")
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 1, "foods": [str(known_food.id)], "orderBy": "name", "orderDirection": "desc"},
headers=unique_user.token,
)
response.raise_for_status()
data = response.json()
assert len(data["items"]) == 4
# "B" should come first because it matches all foods, even though the user sort would put it last
assert [item["recipe"]["name"] for item in data["items"]] == ["B", "D", "C", "A"]
finally:
for recipe in [recipe_a, recipe_b, recipe_c, recipe_d]:
unique_user.repos.recipes.delete(recipe.slug)
def test_limit_param(api_client: TestClient, unique_user: TestUser):
known_food = create_food(unique_user)
limit = random_int(12, 20)
recipes = [create_recipe(unique_user, foods=[known_food]) for _ in range(limit)]
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "foods": [str(known_food.id)], "limit": limit},
headers=unique_user.token,
)
response.raise_for_status()
assert len(response.json()["items"]) == limit
finally:
for recipe in recipes:
unique_user.repos.recipes.delete(recipe.slug)
def test_query_filter(api_client: TestClient, unique_user: TestUser):
known_food = create_food(unique_user)
recipes_with_prefix = [
create_recipe(unique_user, foods=[known_food], name=f"MY_PREFIX{random_string()}") for _ in range(10)
]
recipes_without_prefix = [
create_recipe(unique_user, foods=[known_food], name=f"MY_OTHER_PREFIX{random_string()}") for _ in range(10)
]
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "foods": [str(known_food.id)], "queryFilter": 'name LIKE "MY_PREFIX%"'},
headers=unique_user.token,
)
response.raise_for_status()
assert len(response.json()["items"]) == len(recipes_with_prefix)
assert {item["recipe"]["id"] for item in response.json()["items"]} == {
str(recipe.id) for recipe in recipes_with_prefix
}
finally:
for recipe in recipes_with_prefix + recipes_without_prefix:
unique_user.repos.recipes.delete(recipe.slug)
def test_include_cross_household_recipes(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
known_food = create_food(unique_user)
recipe = create_recipe(unique_user, foods=[known_food])
other_recipe = create_recipe(h2_user, foods=[known_food])
try:
response = api_client.get(
api_routes.recipes_suggestions,
params={"maxMissingFoods": 0, "foods": [str(known_food.id)], "includeCrossHousehold": True},
headers=h2_user.token,
)
response.raise_for_status()
data = response.json()
assert len(data["items"]) == 2
assert {item["recipe"]["id"] for item in data["items"]} == {str(recipe.id), str(other_recipe.id)}
finally:
unique_user.repos.recipes.delete(recipe.slug)
h2_user.repos.recipes.delete(other_recipe.slug)

View file

@ -634,7 +634,8 @@ def test_pagination_order_by_multiple(unique_user: TestUser, order_direction: Or
random.shuffle(abbreviations) random.shuffle(abbreviations)
random.shuffle(descriptions) random.shuffle(descriptions)
assert abbreviations != descriptions while abbreviations == descriptions:
random.shuffle(descriptions)
units_to_create: list[SaveIngredientUnit] = [] units_to_create: list[SaveIngredientUnit] = []
for abbreviation in abbreviations: for abbreviation in abbreviations:
@ -694,7 +695,8 @@ def test_pagination_order_by_multiple_directions(
random.shuffle(abbreviations) random.shuffle(abbreviations)
random.shuffle(descriptions) random.shuffle(descriptions)
assert abbreviations != descriptions while abbreviations == descriptions:
random.shuffle(descriptions)
units_to_create: list[SaveIngredientUnit] = [] units_to_create: list[SaveIngredientUnit] = []
for abbreviation in abbreviations: for abbreviation in abbreviations:

View file

@ -161,6 +161,8 @@ recipes_create_zip = "/api/recipes/create/zip"
"""`/api/recipes/create/zip`""" """`/api/recipes/create/zip`"""
recipes_exports = "/api/recipes/exports" recipes_exports = "/api/recipes/exports"
"""`/api/recipes/exports`""" """`/api/recipes/exports`"""
recipes_suggestions = "/api/recipes/suggestions"
"""`/api/recipes/suggestions`"""
recipes_test_scrape_url = "/api/recipes/test-scrape-url" recipes_test_scrape_url = "/api/recipes/test-scrape-url"
"""`/api/recipes/test-scrape-url`""" """`/api/recipes/test-scrape-url`"""
recipes_timeline_events = "/api/recipes/timeline/events" recipes_timeline_events = "/api/recipes/timeline/events"
@ -303,6 +305,11 @@ def explore_groups_group_slug_recipes_recipe_slug(group_slug, recipe_slug):
return f"{prefix}/explore/groups/{group_slug}/recipes/{recipe_slug}" return f"{prefix}/explore/groups/{group_slug}/recipes/{recipe_slug}"
def explore_groups_group_slug_recipes_suggestions(group_slug):
"""`/api/explore/groups/{group_slug}/recipes/suggestions`"""
return f"{prefix}/explore/groups/{group_slug}/recipes/suggestions"
def foods_item_id(item_id): def foods_item_id(item_id):
"""`/api/foods/{item_id}`""" """`/api/foods/{item_id}`"""
return f"{prefix}/foods/{item_id}" return f"{prefix}/foods/{item_id}"