mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-19 05:09:40 +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 {
|
||||
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-card-text>
|
||||
<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-model="showAdvanced"
|
||||
hide-details
|
||||
|
@ -431,6 +431,7 @@ export default defineComponent({
|
|||
state.qfValid = !!qf;
|
||||
|
||||
context.emit("input", qf || undefined);
|
||||
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
|
@ -543,6 +544,32 @@ export default defineComponent({
|
|||
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 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,
|
||||
to: `/g/${groupSlug.value}`,
|
||||
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,
|
||||
|
|
|
@ -45,11 +45,13 @@
|
|||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<slot name="custom-card-action"></slot>
|
||||
<BaseButton v-if="$listeners.delete" delete secondary @click="deleteEvent" />
|
||||
<BaseButton
|
||||
v-if="$listeners.confirm"
|
||||
:color="color"
|
||||
type="submit"
|
||||
:disabled="submitDisabled"
|
||||
@click="
|
||||
$emit('confirm');
|
||||
dialog = false;
|
||||
|
@ -60,8 +62,12 @@
|
|||
</template>
|
||||
{{ $t("general.confirm") }}
|
||||
</BaseButton>
|
||||
<slot name="custom-card-action"></slot>
|
||||
<BaseButton v-if="$listeners.submit" type="submit" :disabled="submitDisabled" @click="submitEvent">
|
||||
<BaseButton
|
||||
v-if="$listeners.submit"
|
||||
type="submit"
|
||||
:disabled="submitDisabled"
|
||||
@click="submitEvent"
|
||||
>
|
||||
{{ submitText }}
|
||||
<template v-if="submitIcon" #icon>
|
||||
{{ submitIcon }}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Ref, useContext } from "@nuxtjs/composition-api";
|
||||
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
||||
import { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe";
|
||||
import { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
|
||||
export interface UserPrintPreferences {
|
||||
imagePosition: string;
|
||||
|
@ -49,6 +50,17 @@ export interface UserCookbooksPreferences {
|
|||
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> {
|
||||
const fromStorage = useLocalStorage(
|
||||
"meal-planner-preferences",
|
||||
|
@ -171,3 +183,24 @@ export function useCookbookPreferences(): Ref<UserCookbooksPreferences> {
|
|||
|
||||
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",
|
||||
"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": {
|
||||
"advanced-search": "Advanced Search",
|
||||
"and": "and",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
|
||||
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 { RecipeSearchQuery } from "../../user/recipes/recipe";
|
||||
|
||||
|
@ -23,4 +23,10 @@ export class PublicRecipeApi extends BaseCRUDAPIReadOnly<Recipe> {
|
|||
async search(rsq: RecipeSearchQuery) {
|
||||
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 RegisteredParser = "nlp" | "brute" | "openai";
|
||||
export type OrderByNullPosition = "first" | "last";
|
||||
export type OrderDirection = "asc" | "desc";
|
||||
export type TimelineEventType = "system" | "info" | "comment";
|
||||
export type TimelineEventImage = "has image" | "does not have image";
|
||||
|
||||
|
@ -380,6 +382,26 @@ export interface RecipeShareTokenSummary {
|
|||
export interface RecipeSlug {
|
||||
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 {
|
||||
name: string;
|
||||
id: string;
|
||||
|
@ -519,3 +541,10 @@ export interface UnitFoodBase {
|
|||
export interface UpdateImageResponse {
|
||||
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;
|
||||
}
|
||||
export interface PaginationQuery {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: string | null;
|
||||
orderByNullPosition?: OrderByNullPosition | null;
|
||||
orderDirection?: OrderDirection;
|
||||
queryFilter?: string | null;
|
||||
paginationSeed?: string | null;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
export interface QueryFilterJSON {
|
||||
parts?: QueryFilterJSONPart[];
|
||||
|
@ -47,6 +47,13 @@ export interface RecipeSearchQuery {
|
|||
requireAllFoods?: boolean;
|
||||
search?: string | null;
|
||||
}
|
||||
export interface RequestQuery {
|
||||
orderBy?: string | null;
|
||||
orderByNullPosition?: OrderByNullPosition | null;
|
||||
orderDirection?: OrderDirection;
|
||||
queryFilter?: string | null;
|
||||
paginationSeed?: string | null;
|
||||
}
|
||||
export interface SuccessResponse {
|
||||
message: string;
|
||||
error?: boolean;
|
||||
|
|
|
@ -11,6 +11,8 @@ import {
|
|||
UpdateImageResponse,
|
||||
RecipeZipTokenResponse,
|
||||
RecipeLastMade,
|
||||
RecipeSuggestionQuery,
|
||||
RecipeSuggestionResponse,
|
||||
RecipeTimelineEventIn,
|
||||
RecipeTimelineEventOut,
|
||||
RecipeTimelineEventUpdate,
|
||||
|
@ -31,6 +33,7 @@ const prefix = "/api";
|
|||
const routes = {
|
||||
recipesCreate: `${prefix}/recipes/create`,
|
||||
recipesBase: `${prefix}/recipes`,
|
||||
recipesSuggestions: `${prefix}/recipes/suggestions`,
|
||||
recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`,
|
||||
recipesCreateUrl: `${prefix}/recipes/create/url`,
|
||||
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) {
|
||||
const formData = new FormData();
|
||||
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.db.models._model_base import SqlAlchemyBase
|
||||
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_search import SearchFilter
|
||||
|
||||
|
@ -404,11 +410,11 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
|||
|
||||
return query.order_by(order_attr)
|
||||
|
||||
def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> Select:
|
||||
if not pagination.order_by:
|
||||
def add_order_by_to_query(self, query: Select, request_query: RequestQuery) -> Select:
|
||||
if not request_query.order_by:
|
||||
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
|
||||
# this solution is db-independent & stable to paging
|
||||
temp_query = query.with_only_columns(self.model.id)
|
||||
|
@ -417,14 +423,14 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
|||
return query
|
||||
|
||||
order = list(range(len(allids)))
|
||||
random.seed(pagination.pagination_seed)
|
||||
random.seed(request_query.pagination_seed)
|
||||
random.shuffle(order)
|
||||
random_dict = dict(zip(allids, order, strict=True))
|
||||
case_stmt = case(random_dict, value=self.model.id)
|
||||
return query.order_by(case_stmt)
|
||||
|
||||
else:
|
||||
for order_by_val in pagination.order_by.split(","):
|
||||
for order_by_val in request_query.order_by.split(","):
|
||||
try:
|
||||
order_by_val = order_by_val.strip()
|
||||
if ":" in order_by_val:
|
||||
|
@ -432,20 +438,20 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
|||
order_dir = OrderDirection(order_dir_val)
|
||||
else:
|
||||
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_by, self.model, query=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:
|
||||
raise HTTPException(
|
||||
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
|
||||
|
||||
return query
|
||||
|
|
|
@ -1,30 +1,37 @@
|
|||
import re as re
|
||||
from collections.abc import Sequence
|
||||
from random import randint
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from fastapi import HTTPException
|
||||
from pydantic import UUID4
|
||||
from slugify import slugify
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
from typing_extensions import Self
|
||||
|
||||
from mealie.db.models.household.household import Household
|
||||
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.settings import RecipeSettings
|
||||
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.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.recipe import Recipe
|
||||
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 (
|
||||
OrderByNullPosition,
|
||||
OrderDirection,
|
||||
PaginationQuery,
|
||||
)
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder
|
||||
|
||||
from ..db.models._model_base import SqlAlchemyBase
|
||||
from .repository_generic import HouseholdRepositoryGeneric
|
||||
|
@ -100,7 +107,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
|||
def add_order_attr_to_query(
|
||||
self,
|
||||
query: sa.Select,
|
||||
order_attr: InstrumentedAttribute,
|
||||
order_attr: orm.InstrumentedAttribute,
|
||||
order_dir: OrderDirection,
|
||||
order_by_null: OrderByNullPosition | None,
|
||||
) -> sa.Select:
|
||||
|
@ -297,3 +304,176 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
|||
def all_ids(self, group_id: UUID4) -> Sequence[UUID4]:
|
||||
stmt = sa.select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
|
||||
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.recipe import Recipe
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/recipes")
|
||||
|
@ -90,6 +91,26 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
|
|||
# Response is returned directly, to avoid validation and improve performance
|
||||
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)
|
||||
def get_recipe(self, recipe_slug: str) -> Recipe:
|
||||
RECIPE_NOT_FOUND_EXCEPTION = HTTPException(404, "recipe not found")
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
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"
|
||||
|
||||
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(comments.router, prefix=prefix, tags=["Recipe: Comments"])
|
||||
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(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 functools import cached_property
|
||||
from shutil import copyfileobj, rmtree
|
||||
from shutil import copyfileobj
|
||||
from uuid import UUID
|
||||
from zipfile import ZipFile
|
||||
|
||||
import orjson
|
||||
import sqlalchemy
|
||||
|
@ -18,30 +16,19 @@ from fastapi import (
|
|||
status,
|
||||
)
|
||||
from fastapi.datastructures import UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import UUID4, BaseModel, Field
|
||||
from pydantic import UUID4
|
||||
from slugify import slugify
|
||||
from starlette.background import BackgroundTask
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from mealie.core import exceptions
|
||||
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.db.models.household.cookbook import CookBook
|
||||
from mealie.pkgs import cache
|
||||
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, controller
|
||||
from mealie.routes._base.mixins import HttpRepo
|
||||
from mealie.routes._base import controller
|
||||
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
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 (
|
||||
CreateRecipe,
|
||||
CreateRecipeByUrlBulk,
|
||||
|
@ -50,9 +37,9 @@ from mealie.schema.recipe.recipe import (
|
|||
)
|
||||
from mealie.schema.recipe.recipe_asset import RecipeAsset
|
||||
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
|
||||
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponse
|
||||
from mealie.schema.recipe.request_helpers import (
|
||||
RecipeDuplicate,
|
||||
RecipeZipTokenResponse,
|
||||
UpdateImageResponse,
|
||||
)
|
||||
from mealie.schema.response import PaginationBase, PaginationQuery
|
||||
|
@ -71,8 +58,6 @@ from mealie.services.recipe.recipe_data_service import (
|
|||
NotAnImageError,
|
||||
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.scraped_extras import ScraperContext
|
||||
from mealie.services.scraper.scraper import create_from_html
|
||||
|
@ -82,99 +67,7 @@ from mealie.services.scraper.scraper_strategies import (
|
|||
RecipeScraperPackage,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
from ._base import BaseRecipeController, JSONBytes
|
||||
|
||||
router = UserAPIRouter(prefix="/recipes", route_class=MealieCrudRoute)
|
||||
|
||||
|
@ -388,6 +281,20 @@ class RecipeController(BaseRecipeController):
|
|||
# Response is returned directly, to avoid validation and improve performance
|
||||
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)
|
||||
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"""
|
||||
|
|
|
@ -22,10 +22,10 @@ from mealie.services import urls
|
|||
from mealie.services.event_bus_service.event_types import EventOperation, EventRecipeTimelineEventData, EventTypes
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
|
||||
events_router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events")
|
||||
router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events")
|
||||
|
||||
|
||||
@controller(events_router)
|
||||
@controller(router)
|
||||
class RecipeTimelineEventsController(BaseCrudController):
|
||||
@cached_property
|
||||
def repo(self):
|
||||
|
@ -43,17 +43,17 @@ class RecipeTimelineEventsController(BaseCrudController):
|
|||
self.registered_exceptions,
|
||||
)
|
||||
|
||||
@events_router.get("", response_model=RecipeTimelineEventPagination)
|
||||
@router.get("", response_model=RecipeTimelineEventPagination)
|
||||
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
|
||||
response = self.repo.page_all(
|
||||
pagination=q,
|
||||
override=RecipeTimelineEventOut,
|
||||
)
|
||||
|
||||
response.set_pagination_guides(events_router.url_path_for("get_all"), q.model_dump())
|
||||
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
|
||||
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):
|
||||
# if the user id is not specified, use the currently-authenticated user
|
||||
data.user_id = data.user_id or self.user.id
|
||||
|
@ -81,11 +81,11 @@ class RecipeTimelineEventsController(BaseCrudController):
|
|||
|
||||
return event
|
||||
|
||||
@events_router.get("/{item_id}", response_model=RecipeTimelineEventOut)
|
||||
@router.get("/{item_id}", response_model=RecipeTimelineEventOut)
|
||||
def get_one(self, item_id: UUID4):
|
||||
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):
|
||||
event = self.mixins.patch_one(data, item_id)
|
||||
recipe = self.group_recipes.get_one(event.recipe_id, "id")
|
||||
|
@ -106,7 +106,7 @@ class RecipeTimelineEventsController(BaseCrudController):
|
|||
|
||||
return event
|
||||
|
||||
@events_router.delete("/{item_id}", response_model=RecipeTimelineEventOut)
|
||||
@router.delete("/{item_id}", response_model=RecipeTimelineEventOut)
|
||||
def delete_one(self, item_id: UUID4):
|
||||
event = self.mixins.delete_one(item_id)
|
||||
if event.image_dir.exists():
|
||||
|
@ -136,7 +136,7 @@ class RecipeTimelineEventsController(BaseCrudController):
|
|||
# ==================================================================================================================
|
||||
# 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(...)):
|
||||
event = self.mixins.get_one(item_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_share_token import RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenSave, RecipeShareTokenSummary
|
||||
from .recipe_step import IngredientReferences, RecipeStep
|
||||
from .recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponse, RecipeSuggestionResponseItem
|
||||
from .recipe_timeline_events import (
|
||||
RecipeTimelineEventCreate,
|
||||
RecipeTimelineEventIn,
|
||||
|
@ -109,6 +110,9 @@ __all__ = [
|
|||
"RecipeTimelineEventUpdate",
|
||||
"TimelineEventImage",
|
||||
"TimelineEventType",
|
||||
"RecipeSuggestionQuery",
|
||||
"RecipeSuggestionResponse",
|
||||
"RecipeSuggestionResponseItem",
|
||||
"Nutrition",
|
||||
"RecipeShareToken",
|
||||
"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
|
||||
from .pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery, RecipeSearchQuery
|
||||
from .pagination import (
|
||||
OrderByNullPosition,
|
||||
OrderDirection,
|
||||
PaginationBase,
|
||||
PaginationQuery,
|
||||
RecipeSearchQuery,
|
||||
RequestQuery,
|
||||
)
|
||||
from .query_filter import (
|
||||
LogicalOperator,
|
||||
QueryFilterBuilder,
|
||||
|
@ -27,6 +34,7 @@ __all__ = [
|
|||
"PaginationBase",
|
||||
"PaginationQuery",
|
||||
"RecipeSearchQuery",
|
||||
"RequestQuery",
|
||||
"SearchFilter",
|
||||
"ErrorResponse",
|
||||
"FileTokenResponse",
|
||||
|
|
|
@ -31,9 +31,7 @@ class RecipeSearchQuery(MealieModel):
|
|||
_search_seed: str | None = None
|
||||
|
||||
|
||||
class PaginationQuery(MealieModel):
|
||||
page: int = 1
|
||||
per_page: int = 50
|
||||
class RequestQuery(MealieModel):
|
||||
order_by: str | None = None
|
||||
order_by_null_position: OrderByNullPosition | None = None
|
||||
order_direction: OrderDirection = OrderDirection.desc
|
||||
|
@ -47,6 +45,11 @@ class PaginationQuery(MealieModel):
|
|||
return pagination_seed
|
||||
|
||||
|
||||
class PaginationQuery(RequestQuery):
|
||||
page: int = 1
|
||||
per_page: int = 50
|
||||
|
||||
|
||||
class PaginationBase(BaseModel, Generic[DataT]):
|
||||
page: int = 1
|
||||
per_page: int = 10
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import random
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
@ -8,6 +9,7 @@ from pydantic import UUID4
|
|||
from mealie.schema.cookbook.cookbook import SaveCookBook
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
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.factories import random_int, random_string
|
||||
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
|
||||
else:
|
||||
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(descriptions)
|
||||
assert abbreviations != descriptions
|
||||
while abbreviations == descriptions:
|
||||
random.shuffle(descriptions)
|
||||
|
||||
units_to_create: list[SaveIngredientUnit] = []
|
||||
for abbreviation in abbreviations:
|
||||
|
@ -694,7 +695,8 @@ def test_pagination_order_by_multiple_directions(
|
|||
|
||||
random.shuffle(abbreviations)
|
||||
random.shuffle(descriptions)
|
||||
assert abbreviations != descriptions
|
||||
while abbreviations == descriptions:
|
||||
random.shuffle(descriptions)
|
||||
|
||||
units_to_create: list[SaveIngredientUnit] = []
|
||||
for abbreviation in abbreviations:
|
||||
|
|
|
@ -161,6 +161,8 @@ recipes_create_zip = "/api/recipes/create/zip"
|
|||
"""`/api/recipes/create/zip`"""
|
||||
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"
|
||||
"""`/api/recipes/test-scrape-url`"""
|
||||
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}"
|
||||
|
||||
|
||||
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):
|
||||
"""`/api/foods/{item_id}`"""
|
||||
return f"{prefix}/foods/{item_id}"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue