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:
parent
d26e29d1c5
commit
4e0cf985bc
28 changed files with 1959 additions and 151 deletions
|
@ -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%;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
118
frontend/components/Domain/Recipe/RecipeSuggestion.vue
Normal file
118
frontend/components/Domain/Recipe/RecipeSuggestion.vue
Normal 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>
|
|
@ -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,
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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 })
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
596
frontend/pages/g/_groupSlug/recipes/finder/index.vue
Normal file
596
frontend/pages/g/_groupSlug/recipes/finder/index.vue
Normal 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>
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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"])
|
||||||
|
|
57
mealie/routes/recipe/_base.py
Normal file
57
mealie/routes/recipe/_base.py
Normal 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)
|
76
mealie/routes/recipe/exports.py
Normal file
76
mealie/routes/recipe/exports.py
Normal 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)
|
||||||
|
)
|
|
@ -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"""
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
24
mealie/schema/recipe/recipe_suggestion.py
Normal file
24
mealie/schema/recipe/recipe_suggestion.py
Normal 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]
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
@ -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:
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue