diff --git a/alembic/versions/2022-08-05-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py b/alembic/versions/2022-08-13-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py similarity index 100% rename from alembic/versions/2022-08-05-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py rename to alembic/versions/2022-08-13-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py diff --git a/alembic/versions/2023-02-10-21.18.32_16160bf731a0_add_more_indices_necessary_for_search.py b/alembic/versions/2023-02-10-21.18.32_16160bf731a0_add_more_indices_necessary_for_search.py new file mode 100644 index 000000000000..29268c6e3b7d --- /dev/null +++ b/alembic/versions/2023-02-10-21.18.32_16160bf731a0_add_more_indices_necessary_for_search.py @@ -0,0 +1,37 @@ +"""add more indices necessary for search + +Revision ID: 16160bf731a0 +Revises: ff5f73b01a7a +Create Date: 2023-02-10 21:18:32.405130 + +""" +import sqlalchemy as sa + +import mealie.db.migration_types +from alembic import op + +# revision identifiers, used by Alembic. +revision = "16160bf731a0" +down_revision = "ff5f73b01a7a" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f("ix_recipe_instructions_text"), "recipe_instructions", ["text"], unique=False) + op.create_index(op.f("ix_recipes_description"), "recipes", ["description"], unique=False) + op.create_index(op.f("ix_recipes_ingredients_note"), "recipes_ingredients", ["note"], unique=False) + op.create_index( + op.f("ix_recipes_ingredients_original_text"), "recipes_ingredients", ["original_text"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_recipes_ingredients_original_text"), table_name="recipes_ingredients") + op.drop_index(op.f("ix_recipes_ingredients_note"), table_name="recipes_ingredients") + op.drop_index(op.f("ix_recipes_description"), table_name="recipes") + op.drop_index(op.f("ix_recipe_instructions_text"), table_name="recipe_instructions") + # ### end Alembic commands ### diff --git a/frontend/components/Domain/Recipe/RecipeDialogSearch.vue b/frontend/components/Domain/Recipe/RecipeDialogSearch.vue index edf92827471a..54258c7c24ea 100644 --- a/frontend/components/Domain/Recipe/RecipeDialogSearch.vue +++ b/frontend/components/Domain/Recipe/RecipeDialogSearch.vue @@ -35,7 +35,7 @@ import { defineComponent, toRefs, reactive, ref, watch, useRoute } from "@nuxtjs/composition-api"; +import { watchDebounced } from "@vueuse/shared"; import RecipeCardMobile from "./RecipeCardMobile.vue"; -import { useRecipes, allRecipes, useRecipeSearch } from "~/composables/recipes"; import { RecipeSummary } from "~/lib/api/types/recipe"; +import { useUserApi } from "~/composables/api"; const SELECTED_EVENT = "selected"; export default defineComponent({ components: { @@ -65,12 +66,10 @@ export default defineComponent({ }, setup(_, context) { - const { refreshRecipes } = useRecipes(true, false, true); - const state = reactive({ loading: false, selectedIndex: -1, - searchResults: [], + searchResults: [] as RecipeSummary[], }); // =========================================================================== @@ -78,14 +77,11 @@ export default defineComponent({ const dialog = ref(false); // Reset or Grab Recipes on Change - watch(dialog, async (val) => { + watch(dialog, (val) => { if (!val) { search.value = ""; state.selectedIndex = -1; - } else if (allRecipes.value && allRecipes.value.length <= 0) { - state.loading = true; - await refreshRecipes(); - state.loading = false; + state.searchResults = []; } }); @@ -140,13 +136,33 @@ export default defineComponent({ dialog.value = true; } function close() { + dialog.value = false; } // =========================================================================== // Basic Search + const api = useUserApi(); + const search = ref("") + + watchDebounced(search, async (val) => { + console.log(val) + if (val) { + state.loading = true; + // @ts-expect-error - inferred type is wrong + const { data, error } = await api.recipes.search({ search: val as string, page: 1, perPage: 10 }); + + if (error || !data) { + console.error(error); + state.searchResults = []; + } else { + state.searchResults = data.items; + } + + state.loading = false; + } + }, { debounce: 500, maxWait: 1000 }); - const { search, results } = useRecipeSearch(allRecipes); // =========================================================================== // Select Handler @@ -155,7 +171,7 @@ export default defineComponent({ context.emit(SELECTED_EVENT, recipe); } - return { allRecipes, refreshRecipes, ...toRefs(state), dialog, open, close, handleSelect, search, results }; + return { ...toRefs(state), dialog, open, close, handleSelect, search, }; }, }); diff --git a/frontend/components/Domain/SearchFilter.vue b/frontend/components/Domain/SearchFilter.vue new file mode 100644 index 000000000000..82ed48af15f8 --- /dev/null +++ b/frontend/components/Domain/SearchFilter.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue index a4198d632271..5c9a09eb975d 100644 --- a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue +++ b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue @@ -56,9 +56,8 @@ import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition- import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; import MultiPurposeLabel from "./MultiPurposeLabel.vue"; import { ShoppingListItemOut } from "~/lib/api/types/group"; -import { MultiPurposeLabelOut } from "~/lib/api/types/labels"; +import { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels"; import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe"; -import { MultiPurposeLabelSummary } from "~/lib/api/types/user"; interface actions { text: string; diff --git a/frontend/components/Layout/AppSidebar.vue b/frontend/components/Layout/AppSidebar.vue index 203f8347e5d3..de822c52bbab 100644 --- a/frontend/components/Layout/AppSidebar.vue +++ b/frontend/components/Layout/AppSidebar.vue @@ -176,20 +176,19 @@ export default defineComponent({ }, }, setup(props, context) { - // V-Model Support - const drawer = computed({ + // V-Model Support + const drawer = computed({ get: () => { return props.value; }, set: (val) => { - if(window.innerWidth < 760 && state.hasOpenedBefore === false){ - state.hasOpenedBefore = true; - val = false - context.emit("input", val); - } - else{ - context.emit("input", val); - } + if (window.innerWidth < 760 && state.hasOpenedBefore === false) { + state.hasOpenedBefore = true; + val = false; + context.emit("input", val); + } else { + context.emit("input", val); + } }, }); diff --git a/frontend/composables/recipes/index.ts b/frontend/composables/recipes/index.ts index e632cc4cfc21..2d58768320df 100644 --- a/frontend/composables/recipes/index.ts +++ b/frontend/composables/recipes/index.ts @@ -2,6 +2,5 @@ export { useFraction } from "./use-fraction"; export { useRecipe } from "./use-recipe"; export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-recipes"; export { parseIngredientText } from "./use-recipe-ingredients"; -export { useRecipeSearch } from "./use-recipe-search"; export { useTools } from "./use-recipe-tools"; export { useRecipeMeta } from "./use-recipe-meta"; diff --git a/frontend/composables/recipes/use-recipe-search.ts b/frontend/composables/recipes/use-recipe-search.ts deleted file mode 100644 index 19d003bfd408..000000000000 --- a/frontend/composables/recipes/use-recipe-search.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { computed, reactive, ref, Ref } from "@nuxtjs/composition-api"; -import Fuse from "fuse.js"; -import { Recipe } from "~/lib/api/types/recipe"; - -export const useRecipeSearch = (recipes: Ref) => { - const localState = reactive({ - options: { - ignoreLocation: true, - shouldSort: true, - threshold: 0.6, - location: 0, - distance: 100, - findAllMatches: true, - maxPatternLength: 32, - minMatchCharLength: 2, - ignoreFieldNorm: true, - keys: [{ name: "name", weight: 1.3 }, { name: "description", weight: 1.2 }, "recipeIngredient.note", "recipeIngredient.food.name"], - }, - }); - - const search = ref(""); - - const fuse = computed(() => { - return new Fuse(recipes.value || [], localState.options); - }); - - const fuzzyRecipes = computed(() => { - if (search.value.trim() === "") { - return recipes.value; - } - const result = fuse.value.search(search.value.trim()); - return result.map((x) => x.item); - }); - - const results = computed(() => { - if (!fuzzyRecipes.value) { - return []; - } - - if (fuzzyRecipes.value.length > 0 && search.value.length != null && search.value.length >= 1) { - return fuzzyRecipes.value; - } else { - return recipes.value; - } - }); - - return { results, search }; -}; diff --git a/frontend/composables/use-locales/available-locales.ts b/frontend/composables/use-locales/available-locales.ts index 0f0991dde89b..7615813fc0aa 100644 --- a/frontend/composables/use-locales/available-locales.ts +++ b/frontend/composables/use-locales/available-locales.ts @@ -3,12 +3,12 @@ export const LOCALES = [ { name: "繁體中文 (Chinese traditional)", value: "zh-TW", - progress: 68, + progress: 50, }, { name: "简体中文 (Chinese simplified)", value: "zh-CN", - progress: 56, + progress: 41, }, { name: "Tiếng Việt (Vietnamese)", @@ -18,72 +18,72 @@ export const LOCALES = [ { name: "Українська (Ukrainian)", value: "uk-UA", - progress: 99, + progress: 88, }, { name: "Türkçe (Turkish)", value: "tr-TR", - progress: 47, + progress: 41, }, { name: "Svenska (Swedish)", value: "sv-SE", - progress: 91, + progress: 66, }, { name: "српски (Serbian)", value: "sr-SP", - progress: 11, + progress: 8, }, { name: "Slovenian", value: "sl-SI", - progress: 94, + progress: 73, }, { name: "Slovak", value: "sk-SK", - progress: 85, + progress: 78, }, { name: "Pусский (Russian)", value: "ru-RU", - progress: 57, + progress: 49, }, { name: "Română (Romanian)", value: "ro-RO", - progress: 3, + progress: 7, }, { name: "Português (Portuguese)", value: "pt-PT", - progress: 9, + progress: 27, }, { name: "Português do Brasil (Brazilian Portuguese)", value: "pt-BR", - progress: 40, + progress: 31, }, { name: "Polski (Polish)", value: "pl-PL", - progress: 89, + progress: 69, }, { name: "Norsk (Norwegian)", value: "no-NO", - progress: 87, + progress: 73, }, { name: "Nederlands (Dutch)", value: "nl-NL", - progress: 97, + progress: 81, }, { name: "Lithuanian", value: "lt-LT", - progress: 64, + progress: 65, }, { name: "한국어 (Korean)", @@ -98,37 +98,37 @@ export const LOCALES = [ { name: "Italiano (Italian)", value: "it-IT", - progress: 82, + progress: 81, }, { name: "Magyar (Hungarian)", value: "hu-HU", - progress: 77, + progress: 60, }, { name: "עברית (Hebrew)", value: "he-IL", - progress: 33, + progress: 24, }, { name: "Français (French)", value: "fr-FR", - progress: 99, + progress: 100, }, { name: "French, Canada", value: "fr-CA", - progress: 84, + progress: 61, }, { name: "Suomi (Finnish)", value: "fi-FI", - progress: 22, + progress: 45, }, { name: "Español (Spanish)", value: "es-ES", - progress: 94, + progress: 70, }, { name: "American English", @@ -138,12 +138,12 @@ export const LOCALES = [ { name: "British English", value: "en-GB", - progress: 31, + progress: 23, }, { name: "Ελληνικά (Greek)", value: "el-GR", - progress: 70, + progress: 51, }, { name: "Deutsch (German)", @@ -153,31 +153,31 @@ export const LOCALES = [ { name: "Dansk (Danish)", value: "da-DK", - progress: 99, + progress: 76, }, { name: "Čeština (Czech)", value: "cs-CZ", - progress: 89, + progress: 75, }, { name: "Català (Catalan)", value: "ca-ES", - progress: 95, + progress: 69, }, { name: "Bulgarian", value: "bg-BG", - progress: 0, + progress: 25, }, { name: "العربية (Arabic)", value: "ar-SA", - progress: 24, + progress: 18, }, { name: "Afrikaans (Afrikaans)", value: "af-ZA", - progress: 9, + progress: 6, }, ] diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 6bf3ccf83a64..582a08302588 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -474,6 +474,8 @@ "include": "Include", "max-results": "Max Results", "or": "Or", + "has-any": "Has Any", + "has-all": "Has All", "results": "Results", "search": "Search", "search-mealie": "Search Mealie (press /)", diff --git a/frontend/lib/api/base/route.ts b/frontend/lib/api/base/route.ts index ebb4b9c73103..49eea50554e2 100644 --- a/frontend/lib/api/base/route.ts +++ b/frontend/lib/api/base/route.ts @@ -1,6 +1,6 @@ const parts = { host: "http://localhost.com", - prefix: "/api", + prefix: "", }; export function overrideParts(host: string, prefix: string) { diff --git a/frontend/lib/api/base/routes.test.ts b/frontend/lib/api/base/routes.test.ts index aae87002690f..e9826d388e28 100644 --- a/frontend/lib/api/base/routes.test.ts +++ b/frontend/lib/api/base/routes.test.ts @@ -4,21 +4,21 @@ import { route } from "."; describe("UrlBuilder", () => { it("basic query parameter", () => { const result = route("/test", { a: "b" }); - expect(result).toBe("/api/test?a=b"); + expect(result).toBe("/test?a=b"); }); it("multiple query parameters", () => { const result = route("/test", { a: "b", c: "d" }); - expect(result).toBe("/api/test?a=b&c=d"); + expect(result).toBe("/test?a=b&c=d"); }); it("no query parameters", () => { const result = route("/test"); - expect(result).toBe("/api/test"); + expect(result).toBe("/test"); }); it("list-like query parameters", () => { const result = route("/test", { a: ["b", "c"] }); - expect(result).toBe("/api/test?a=b&a=c"); + expect(result).toBe("/test?a=b&a=c"); }); }); diff --git a/frontend/lib/api/types/admin.ts b/frontend/lib/api/types/admin.ts index 7becd0712b6d..6275df0f9253 100644 --- a/frontend/lib/api/types/admin.ts +++ b/frontend/lib/api/types/admin.ts @@ -98,7 +98,6 @@ export interface RecipeSummary { tools?: RecipeTool[]; rating?: number; orgURL?: string; - recipeIngredient?: RecipeIngredient[]; dateAdded?: string; dateUpdated?: string; createdAt?: string; @@ -121,65 +120,6 @@ export interface RecipeTool { slug: string; onHand?: boolean; } -export interface RecipeIngredient { - title?: string; - note?: string; - unit?: IngredientUnit | CreateIngredientUnit; - food?: IngredientFood | CreateIngredientFood; - disableAmount?: boolean; - quantity?: number; - originalText?: string; - referenceId?: string; -} -export interface IngredientUnit { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; - id: string; - createdAt?: string; - updateAt?: string; -} -export interface CreateIngredientUnit { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; -} -export interface IngredientFood { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - labelId?: string; - id: string; - label?: MultiPurposeLabelSummary; - createdAt?: string; - updateAt?: string; -} -export interface MultiPurposeLabelSummary { - name: string; - color?: string; - groupId: string; - id: string; -} -export interface CreateIngredientFood { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - labelId?: string; -} export interface CustomPageImport { name: string; status: boolean; diff --git a/frontend/lib/api/types/cookbook.ts b/frontend/lib/api/types/cookbook.ts index 178c9c3bb4ed..b04c5c03b75c 100644 --- a/frontend/lib/api/types/cookbook.ts +++ b/frontend/lib/api/types/cookbook.ts @@ -83,7 +83,6 @@ export interface RecipeSummary { tools?: RecipeTool[]; rating?: number; orgURL?: string; - recipeIngredient?: RecipeIngredient[]; dateAdded?: string; dateUpdated?: string; createdAt?: string; @@ -100,65 +99,6 @@ export interface RecipeTag { name: string; slug: string; } -export interface RecipeIngredient { - title?: string; - note?: string; - unit?: IngredientUnit | CreateIngredientUnit; - food?: IngredientFood | CreateIngredientFood; - disableAmount?: boolean; - quantity?: number; - originalText?: string; - referenceId?: string; -} -export interface IngredientUnit { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; - id: string; - createdAt?: string; - updateAt?: string; -} -export interface CreateIngredientUnit { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; -} -export interface IngredientFood { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - labelId?: string; - id: string; - label?: MultiPurposeLabelSummary; - createdAt?: string; - updateAt?: string; -} -export interface MultiPurposeLabelSummary { - name: string; - color?: string; - groupId: string; - id: string; -} -export interface CreateIngredientFood { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - labelId?: string; -} export interface SaveCookBook { name: string; description?: string; diff --git a/frontend/lib/api/types/group.ts b/frontend/lib/api/types/group.ts index 5e7cdeaf8a06..2440a4323c74 100644 --- a/frontend/lib/api/types/group.ts +++ b/frontend/lib/api/types/group.ts @@ -436,7 +436,6 @@ export interface RecipeSummary { tools?: RecipeTool[]; rating?: number; orgURL?: string; - recipeIngredient?: RecipeIngredient[]; dateAdded?: string; dateUpdated?: string; createdAt?: string; @@ -459,34 +458,6 @@ export interface RecipeTool { slug: string; onHand?: boolean; } -export interface RecipeIngredient { - title?: string; - note?: string; - unit?: IngredientUnit | CreateIngredientUnit; - food?: IngredientFood | CreateIngredientFood; - disableAmount?: boolean; - quantity?: number; - originalText?: string; - referenceId?: string; -} -export interface CreateIngredientUnit { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; -} -export interface CreateIngredientFood { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - labelId?: string; -} export interface ShoppingListRemoveRecipeParams { recipeDecrementQuantity?: number; } diff --git a/frontend/lib/api/types/meal-plan.ts b/frontend/lib/api/types/meal-plan.ts index 29c4d616bea9..be645215b127 100644 --- a/frontend/lib/api/types/meal-plan.ts +++ b/frontend/lib/api/types/meal-plan.ts @@ -93,6 +93,7 @@ export interface ReadPlanEntry { recipeId?: string; id: number; groupId: string; + userId?: string; recipe?: RecipeSummary; } export interface RecipeSummary { @@ -113,7 +114,6 @@ export interface RecipeSummary { tools?: RecipeTool[]; rating?: number; orgURL?: string; - recipeIngredient?: RecipeIngredient[]; dateAdded?: string; dateUpdated?: string; createdAt?: string; @@ -136,65 +136,6 @@ export interface RecipeTool { slug: string; onHand?: boolean; } -export interface RecipeIngredient { - title?: string; - note?: string; - unit?: IngredientUnit | CreateIngredientUnit; - food?: IngredientFood | CreateIngredientFood; - disableAmount?: boolean; - quantity?: number; - originalText?: string; - referenceId?: string; -} -export interface IngredientUnit { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; - id: string; - createdAt?: string; - updateAt?: string; -} -export interface CreateIngredientUnit { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; -} -export interface IngredientFood { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - labelId?: string; - id: string; - label?: MultiPurposeLabelSummary; - createdAt?: string; - updateAt?: string; -} -export interface MultiPurposeLabelSummary { - name: string; - color?: string; - groupId: string; - id: string; -} -export interface CreateIngredientFood { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - labelId?: string; -} export interface SavePlanEntry { date: string; entryType?: PlanEntryType & string; @@ -202,6 +143,7 @@ export interface SavePlanEntry { text?: string; recipeId?: string; groupId: string; + userId?: string; } export interface ShoppingListIn { name: string; @@ -222,4 +164,5 @@ export interface UpdatePlanEntry { recipeId?: string; id: number; groupId: string; + userId?: string; } diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index 9bda203ae01f..f5d7cc808460 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -7,7 +7,6 @@ export type ExportTypes = "json"; export type RegisteredParser = "nlp" | "brute"; -export type OrderDirection = "asc" | "desc"; export type TimelineEventType = "system" | "info" | "comment"; export interface AssignCategories { @@ -206,12 +205,12 @@ export interface Recipe { tools?: RecipeTool[]; rating?: number; orgURL?: string; - recipeIngredient?: RecipeIngredient[]; dateAdded?: string; dateUpdated?: string; createdAt?: string; updateAt?: string; lastMade?: string; + recipeIngredient?: RecipeIngredient[]; recipeInstructions?: RecipeStep[]; nutrition?: Nutrition; settings?: RecipeSettings; @@ -282,7 +281,6 @@ export interface RecipeSummary { tools?: RecipeTool[]; rating?: number; orgURL?: string; - recipeIngredient?: RecipeIngredient[]; dateAdded?: string; dateUpdated?: string; createdAt?: string; @@ -305,14 +303,6 @@ export interface RecipeCommentUpdate { export interface RecipeDuplicate { name?: string; } -export interface RecipePaginationQuery { - page?: number; - perPage?: number; - orderBy?: string; - orderDirection?: OrderDirection & string; - queryFilter?: string; - loadFood?: boolean; -} export interface RecipeShareToken { recipeId: string; expiresAt?: string; @@ -456,10 +446,3 @@ export interface UnitFoodBase { export interface UpdateImageResponse { image: string; } -export interface PaginationQuery { - page?: number; - perPage?: number; - orderBy?: string; - orderDirection?: OrderDirection & string; - queryFilter?: string; -} diff --git a/frontend/lib/api/types/user.ts b/frontend/lib/api/types/user.ts index 0fdbdd90f9b0..b3987474883c 100644 --- a/frontend/lib/api/types/user.ts +++ b/frontend/lib/api/types/user.ts @@ -188,7 +188,6 @@ export interface RecipeSummary { tools?: RecipeTool[]; rating?: number; orgURL?: string; - recipeIngredient?: RecipeIngredient[]; dateAdded?: string; dateUpdated?: string; createdAt?: string; @@ -211,65 +210,6 @@ export interface RecipeTool { slug: string; onHand?: boolean; } -export interface RecipeIngredient { - title?: string; - note?: string; - unit?: IngredientUnit | CreateIngredientUnit; - food?: IngredientFood | CreateIngredientFood; - disableAmount?: boolean; - quantity?: number; - originalText?: string; - referenceId?: string; -} -export interface IngredientUnit { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; - id: string; - createdAt?: string; - updateAt?: string; -} -export interface CreateIngredientUnit { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; -} -export interface IngredientFood { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - labelId?: string; - id: string; - label?: MultiPurposeLabelSummary; - createdAt?: string; - updateAt?: string; -} -export interface MultiPurposeLabelSummary { - name: string; - color?: string; - groupId: string; - id: string; -} -export interface CreateIngredientFood { - name: string; - description?: string; - extras?: { - [k: string]: unknown; - }; - labelId?: string; -} export interface UserIn { username?: string; fullName?: string; diff --git a/frontend/lib/api/user/recipes/recipe.ts b/frontend/lib/api/user/recipes/recipe.ts index fa3fb3ec28ce..b840b47b8c53 100644 --- a/frontend/lib/api/user/recipes/recipe.ts +++ b/frontend/lib/api/user/recipes/recipe.ts @@ -1,7 +1,7 @@ import { BaseCRUDAPI } from "../../base/base-clients"; +import { route } from "../../base"; import { CommentsApi } from "./recipe-comments"; import { RecipeShareApi } from "./recipe-share"; - import { Recipe, CreateRecipe, @@ -52,6 +52,33 @@ const routes = { recipesSlugTimelineEventId: (slug: string, id: string) => `${prefix}/recipes/${slug}/timeline/events/${id}`, }; +export type RecipeSearchQuery ={ + search: string; + orderDirection? : "asc" | "desc"; + groupId?: string; + + queryFilter?: string; + + cookbook?: string; + + categories?: string[]; + requireAllCategories?: boolean; + + tags?: string[]; + requireAllTags?: boolean; + + tools?: string[]; + requireAllTools?: boolean; + + foods?: string[]; + requireAllFoods?: boolean; + + page: number; + perPage: number; + orderBy?: string; +} + + export class RecipeAPI extends BaseCRUDAPI { baseRoute: string = routes.recipesBase; itemRoute = routes.recipesRecipeSlug; @@ -66,6 +93,10 @@ export class RecipeAPI extends BaseCRUDAPI { this.share = new RecipeShareApi(requests); } + async search(rsq : RecipeSearchQuery) { + return await this.requests.get>(route(routes.recipesBase, rsq)); + } + async getAllByCategory(categories: string[]) { return await this.requests.get(routes.recipesCategory, { categories, diff --git a/frontend/pages/recipe/_slug/ingredient-parser.vue b/frontend/pages/recipe/_slug/ingredient-parser.vue index f47bfcb3f1dc..cec981321d9f 100644 --- a/frontend/pages/recipe/_slug/ingredient-parser.vue +++ b/frontend/pages/recipe/_slug/ingredient-parser.vue @@ -94,11 +94,11 @@ import { IngredientFood, IngredientUnit, ParsedIngredient, + RecipeIngredient, } from "~/lib/api/types/recipe"; import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; import { useUserApi } from "~/composables/api"; import { useRecipe } from "~/composables/recipes"; -import { RecipeIngredient } from "~/lib/api/types/admin"; import { useFoodData, useFoodStore, useUnitStore } from "~/composables/store"; import { Parser } from "~/lib/api/user/recipes/recipe"; diff --git a/frontend/pages/search.vue b/frontend/pages/search.vue index ba11dfcf4dcb..d608c7ec16b3 100644 --- a/frontend/pages/search.vue +++ b/frontend/pages/search.vue @@ -1,309 +1,449 @@ - + diff --git a/mealie/db/models/recipe/ingredient.py b/mealie/db/models/recipe/ingredient.py index 4485c681df27..b9e763c5c210 100644 --- a/mealie/db/models/recipe/ingredient.py +++ b/mealie/db/models/recipe/ingredient.py @@ -63,7 +63,7 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins): recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id")) title: Mapped[str | None] = mapped_column(String) # Section Header - Shows if Present - note: Mapped[str | None] = mapped_column(String) # Force Show Text - Overrides Concat + note: Mapped[str | None] = mapped_column(String, index=True) # Force Show Text - Overrides Concat # Scaling Items unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"), index=True) @@ -73,7 +73,7 @@ class RecipeIngredient(SqlAlchemyBase, BaseMixins): food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False) quantity: Mapped[float | None] = mapped_column(Float) - original_text: Mapped[str | None] = mapped_column(String) + original_text: Mapped[str | None] = mapped_column(String, index=True) reference_id: Mapped[GUID | None] = mapped_column(GUID) # Reference Links diff --git a/mealie/db/models/recipe/instruction.py b/mealie/db/models/recipe/instruction.py index 74c7a8bcd4cc..01592ae30d67 100644 --- a/mealie/db/models/recipe/instruction.py +++ b/mealie/db/models/recipe/instruction.py @@ -23,7 +23,7 @@ class RecipeInstruction(SqlAlchemyBase): position: Mapped[int | None] = mapped_column(Integer, index=True) type: Mapped[str | None] = mapped_column(String, default="") title: Mapped[str | None] = mapped_column(String) - text: Mapped[str | None] = mapped_column(String) + text: Mapped[str | None] = mapped_column(String, index=True) ingredient_references: Mapped[list[RecipeIngredientRefLink]] = orm.relationship( RecipeIngredientRefLink, cascade="all, delete-orphan" diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 5f4cb723b110..224446e1598a 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -55,7 +55,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): # General Recipe Properties name: Mapped[str] = mapped_column(sa.String, nullable=False, index=True) - description: Mapped[str | None] = mapped_column(sa.String) + description: Mapped[str | None] = mapped_column(sa.String, index=True) image: Mapped[str | None] = mapped_column(sa.String) # Time Related Properties diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index c5dcc4e0d867..c88958327828 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -4,7 +4,7 @@ from uuid import UUID from pydantic import UUID4 from slugify import slugify -from sqlalchemy import and_, func, select +from sqlalchemy import Select, and_, desc, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload @@ -20,13 +20,14 @@ from mealie.schema.recipe.recipe import ( RecipeCategory, RecipePagination, RecipeSummary, - RecipeSummaryWithIngredients, RecipeTag, RecipeTool, ) from mealie.schema.recipe.recipe_category import CategoryBase, TagBase from mealie.schema.response.pagination import PaginationQuery +from ..db.models._model_base import SqlAlchemyBase +from ..schema._mealie.mealie_model import extract_uuids from .repository_generic import RepositoryGeneric @@ -134,16 +135,59 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): ) return self.session.execute(stmt).scalars().all() + def _uuids_for_items(self, items: list[UUID | str] | None, model: type[SqlAlchemyBase]) -> list[UUID] | None: + if not items: + return None + ids: list[UUID] = [] + slugs: list[str] = [] + + for i in items: + if isinstance(i, UUID): + ids.append(i) + else: + slugs.append(i) + additional_ids = self.session.execute(select(model.id).filter(model.slug.in_(slugs))).scalars().all() + return ids + additional_ids + + def _add_search_to_query(self, query: Select, search: str) -> Select: + # I would prefer to just do this in the recipe_ingredient.any part of the main query, but it turns out + # that at least sqlite wont use indexes for that correctly anymore and takes a big hit, so prefiltering it is + ingredient_ids = ( + self.session.execute( + select(RecipeIngredient.id).filter( + or_(RecipeIngredient.note.ilike(f"%{search}%"), RecipeIngredient.original_text.ilike(f"%{search}%")) + ) + ) + .scalars() + .all() + ) + + q = query.filter( + or_( + RecipeModel.name.ilike(f"%{search}%"), + RecipeModel.description.ilike(f"%{search}%"), + RecipeModel.recipe_ingredient.any(RecipeIngredient.id.in_(ingredient_ids)), + ) + ).order_by(desc(RecipeModel.name.ilike(f"%{search}%"))) + return q + def page_all( self, pagination: PaginationQuery, override=None, - load_food=False, cookbook: ReadCookBook | None = None, categories: list[UUID4 | str] | None = None, tags: list[UUID4 | str] | None = None, tools: list[UUID4 | str] | None = None, + foods: list[UUID4 | str] | None = None, + require_all_categories=True, + require_all_tags=True, + require_all_tools=True, + require_all_foods=True, + search: str | None = None, ) -> RecipePagination: + # Copy this, because calling methods (e.g. tests) might rely on it not getting mutated + pagination_result = pagination.copy() q = select(self.model) args = [ @@ -152,57 +196,41 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): joinedload(RecipeModel.tools), ] - item_class: type[RecipeSummary | RecipeSummaryWithIngredients] - - if load_food: - args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food))) - args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.unit))) - item_class = RecipeSummaryWithIngredients - else: - item_class = RecipeSummary - q = q.options(*args) fltr = self._filter_builder() q = q.filter_by(**fltr) if cookbook: - cb_filters = self._category_tag_filters( - cookbook.categories, - cookbook.tags, - cookbook.tools, - cookbook.require_all_categories, - cookbook.require_all_tags, - cookbook.require_all_tools, + cb_filters = self._build_recipe_filter( + categories=extract_uuids(cookbook.categories), + tags=extract_uuids(cookbook.tags), + tools=extract_uuids(cookbook.tools), + require_all_categories=cookbook.require_all_categories, + require_all_tags=cookbook.require_all_tags, + require_all_tools=cookbook.require_all_tools, ) q = q.filter(*cb_filters) + else: + category_ids = self._uuids_for_items(categories, Category) + tag_ids = self._uuids_for_items(tags, Tag) + tool_ids = self._uuids_for_items(tools, Tool) + filters = self._build_recipe_filter( + categories=category_ids, + tags=tag_ids, + tools=tool_ids, + foods=foods, + require_all_categories=require_all_categories, + require_all_tags=require_all_tags, + require_all_tools=require_all_tools, + require_all_foods=require_all_foods, + ) + q = q.filter(*filters) + if search: + q = self._add_search_to_query(q, search) - if categories: - for category in categories: - if isinstance(category, UUID): - q = q.filter(RecipeModel.recipe_category.any(Category.id == category)) - - else: - q = q.filter(RecipeModel.recipe_category.any(Category.slug == category)) - - if tags: - for tag in tags: - if isinstance(tag, UUID): - q = q.filter(RecipeModel.tags.any(Tag.id == tag)) - - else: - q = q.filter(RecipeModel.tags.any(Tag.slug == tag)) - - if tools: - for tool in tools: - if isinstance(tool, UUID): - q = q.filter(RecipeModel.tools.any(Tool.id == tool)) - - else: - q = q.filter(RecipeModel.tools.any(Tool.slug == tool)) - - q, count, total_pages = self.add_pagination_to_query(q, pagination) + q, count, total_pages = self.add_pagination_to_query(q, pagination_result) try: data = self.session.execute(q).scalars().unique().all() @@ -211,10 +239,10 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): self.session.rollback() raise e - items = [item_class.from_orm(item) for item in data] + items = [RecipeSummary.from_orm(item) for item in data] return RecipePagination( - page=pagination.page, - per_page=pagination.per_page, + page=pagination_result.page, + per_page=pagination_result.per_page, total=count, total_pages=total_pages, items=items, @@ -233,41 +261,46 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): ) return [RecipeSummary.from_orm(x) for x in self.session.execute(stmt).unique().scalars().all()] - def _category_tag_filters( + def _build_recipe_filter( self, - categories: list[CategoryBase] | None = None, - tags: list[TagBase] | None = None, - tools: list[RecipeTool] | None = None, + categories: list[UUID4] | None = None, + tags: list[UUID4] | None = None, + tools: list[UUID4] | None = None, + foods: list[UUID4] | None = None, require_all_categories: bool = True, require_all_tags: bool = True, require_all_tools: bool = True, + require_all_foods: bool = True, ) -> list: - fltr = [ - RecipeModel.group_id == self.group_id, - ] + if self.group_id: + fltr = [ + RecipeModel.group_id == self.group_id, + ] + else: + fltr = [] if categories: - cat_ids = [x.id for x in categories] if require_all_categories: - fltr.extend(RecipeModel.recipe_category.any(Category.id == cat_id) for cat_id in cat_ids) + fltr.extend(RecipeModel.recipe_category.any(Category.id == cat_id) for cat_id in categories) else: - fltr.append(RecipeModel.recipe_category.any(Category.id.in_(cat_ids))) + fltr.append(RecipeModel.recipe_category.any(Category.id.in_(categories))) if tags: - tag_ids = [x.id for x in tags] if require_all_tags: - fltr.extend(RecipeModel.tags.any(Tag.id.is_(tag_id)) for tag_id in tag_ids) + fltr.extend(RecipeModel.tags.any(Tag.id == tag_id) for tag_id in tags) else: - fltr.append(RecipeModel.tags.any(Tag.id.in_(tag_ids))) + fltr.append(RecipeModel.tags.any(Tag.id.in_(tags))) if tools: - tool_ids = [x.id for x in tools] - if require_all_tools: - fltr.extend(RecipeModel.tools.any(Tool.id == tool_id) for tool_id in tool_ids) + fltr.extend(RecipeModel.tools.any(Tool.id == tool_id) for tool_id in tools) else: - fltr.append(RecipeModel.tools.any(Tool.id.in_(tool_ids))) - + fltr.append(RecipeModel.tools.any(Tool.id.in_(tools))) + if foods: + if require_all_foods: + fltr.extend(RecipeModel.recipe_ingredient.any(RecipeIngredient.food_id == food) for food in foods) + else: + fltr.append(RecipeModel.recipe_ingredient.any(RecipeIngredient.food_id.in_(foods))) return fltr def by_category_and_tags( @@ -279,8 +312,13 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): require_all_tags: bool = True, require_all_tools: bool = True, ) -> list[Recipe]: - fltr = self._category_tag_filters( - categories, tags, tools, require_all_categories, require_all_tags, require_all_tools + fltr = self._build_recipe_filter( + categories=extract_uuids(categories) if categories else None, + tags=extract_uuids(tags) if tags else None, + tools=extract_uuids(tools) if tools else None, + require_all_categories=require_all_categories, + require_all_tags=require_all_tags, + require_all_tools=require_all_tools, ) stmt = select(RecipeModel).filter(*fltr) return [self.schema.from_orm(x) for x in self.session.execute(stmt).scalars().all()] @@ -297,7 +335,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): # See Also: # - https://stackoverflow.com/questions/60805/getting-random-row-through-sqlalchemy - filters = self._category_tag_filters(categories, tags) # type: ignore + filters = self._build_recipe_filter(extract_uuids(categories), extract_uuids(tags)) # type: ignore stmt = ( select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific ) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index cee99580e1dd..13be88be5313 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -24,20 +24,15 @@ from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter from mealie.schema.cookbook.cookbook import ReadCookBook from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe -from mealie.schema.recipe.recipe import ( - CreateRecipe, - CreateRecipeByUrlBulk, - RecipePaginationQuery, - RecipeSummary, - RecipeSummaryWithIngredients, -) +from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary from mealie.schema.recipe.recipe_asset import RecipeAsset from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest from mealie.schema.recipe.recipe_settings import RecipeSettings from mealie.schema.recipe.recipe_step import RecipeStep from mealie.schema.recipe.request_helpers import RecipeDuplicate, RecipeZipTokenResponse, UpdateImageResponse -from mealie.schema.response import PaginationBase +from mealie.schema.response import PaginationBase, PaginationQuery +from mealie.schema.response.pagination import RecipeSearchQuery from mealie.schema.response.responses import ErrorResponse from mealie.services import urls from mealie.services.event_bus_service.event_types import ( @@ -238,31 +233,37 @@ class RecipeController(BaseRecipeController): # ================================================================================================================== # CRUD Operations - @router.get("", response_model=PaginationBase[RecipeSummary | RecipeSummaryWithIngredients]) + @router.get("", response_model=PaginationBase[RecipeSummary]) def get_all( self, request: Request, - q: RecipePaginationQuery = Depends(), - cookbook: UUID4 | str | None = Query(None), + q: PaginationQuery = Depends(), + search_query: RecipeSearchQuery = Depends(), categories: list[UUID4 | str] | None = Query(None), tags: list[UUID4 | str] | None = Query(None), tools: list[UUID4 | str] | None = Query(None), + foods: list[UUID4 | str] | None = Query(None), ): cookbook_data: ReadCookBook | None = None - if cookbook: - cb_match_attr = "slug" if isinstance(cookbook, str) else "id" - cookbook_data = self.cookbooks_repo.get_one(cookbook, cb_match_attr) + if search_query.cookbook: + cb_match_attr = "slug" if isinstance(search_query.cookbook, str) else "id" + cookbook_data = self.cookbooks_repo.get_one(search_query.cookbook, cb_match_attr) - if cookbook is None: + if search_query.cookbook is None: raise HTTPException(status_code=404, detail="cookbook not found") pagination_response = self.repo.page_all( pagination=q, - load_food=q.load_food, cookbook=cookbook_data, categories=categories, tags=tags, tools=tools, + foods=foods, + require_all_categories=search_query.require_all_categories, + require_all_tags=search_query.require_all_tags, + require_all_tools=search_query.require_all_tools, + require_all_foods=search_query.require_all_foods, + search=search_query.search, ) # merge default pagination with the request's query params diff --git a/mealie/schema/_mealie/__init__.py b/mealie/schema/_mealie/__init__.py index 37d00bed96b6..bcca9f6eab3d 100644 --- a/mealie/schema/_mealie/__init__.py +++ b/mealie/schema/_mealie/__init__.py @@ -1,6 +1,7 @@ # This file is auto-generated by gen_schema_exports.py -from .mealie_model import MealieModel +from .mealie_model import HasUUID, MealieModel __all__ = [ + "HasUUID", "MealieModel", ] diff --git a/mealie/schema/_mealie/mealie_model.py b/mealie/schema/_mealie/mealie_model.py index 9a948fce97aa..7a9bab277dd7 100644 --- a/mealie/schema/_mealie/mealie_model.py +++ b/mealie/schema/_mealie/mealie_model.py @@ -1,9 +1,10 @@ from __future__ import annotations -from typing import TypeVar +from collections.abc import Sequence +from typing import Protocol, TypeVar from humps.main import camelize -from pydantic import BaseModel +from pydantic import UUID4, BaseModel T = TypeVar("T", bound=BaseModel) @@ -52,3 +53,11 @@ class MealieModel(BaseModel): val = getattr(src, field) if field in self.__fields__ and (val is not None or replace_null): setattr(self, field, val) + + +class HasUUID(Protocol): + id: UUID4 + + +def extract_uuids(models: Sequence[HasUUID]) -> list[UUID4]: + return [x.id for x in models] diff --git a/mealie/schema/group/__init__.py b/mealie/schema/group/__init__.py index dfec67a932a7..7439a38423f1 100644 --- a/mealie/schema/group/__init__.py +++ b/mealie/schema/group/__init__.py @@ -14,11 +14,7 @@ from .group_events import ( from .group_exports import GroupDataExport from .group_migration import DataMigrationCreate, SupportedMigrations from .group_permissions import SetPermissions -from .group_preferences import ( - CreateGroupPreferences, - ReadGroupPreferences, - UpdateGroupPreferences, -) +from .group_preferences import CreateGroupPreferences, ReadGroupPreferences, UpdateGroupPreferences from .group_seeder import SeederConfig from .group_shopping_list import ( ShoppingListAddRecipeParams, @@ -41,23 +37,19 @@ from .group_shopping_list import ( ShoppingListUpdate, ) from .group_statistics import GroupStatistics, GroupStorage -from .invite_token import ( - CreateInviteToken, - EmailInitationResponse, - EmailInvitation, - ReadInviteToken, - SaveInviteToken, -) -from .webhook import ( - CreateWebhook, - ReadWebhook, - SaveWebhook, - WebhookPagination, - WebhookType, -) +from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken +from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType __all__ = [ - "GroupAdminUpdate", + "CreateGroupPreferences", + "ReadGroupPreferences", + "UpdateGroupPreferences", + "GroupDataExport", + "CreateWebhook", + "ReadWebhook", + "SaveWebhook", + "WebhookPagination", + "WebhookType", "GroupEventNotifierCreate", "GroupEventNotifierOptions", "GroupEventNotifierOptionsOut", @@ -67,13 +59,8 @@ __all__ = [ "GroupEventNotifierSave", "GroupEventNotifierUpdate", "GroupEventPagination", - "GroupDataExport", "DataMigrationCreate", "SupportedMigrations", - "SetPermissions", - "CreateGroupPreferences", - "ReadGroupPreferences", - "UpdateGroupPreferences", "SeederConfig", "ShoppingListAddRecipeParams", "ShoppingListCreate", @@ -83,9 +70,9 @@ __all__ = [ "ShoppingListItemRecipeRefCreate", "ShoppingListItemRecipeRefOut", "ShoppingListItemRecipeRefUpdate", - "ShoppingListItemsCollectionOut", "ShoppingListItemUpdate", "ShoppingListItemUpdateBulk", + "ShoppingListItemsCollectionOut", "ShoppingListOut", "ShoppingListPagination", "ShoppingListRecipeRefOut", @@ -93,6 +80,8 @@ __all__ = [ "ShoppingListSave", "ShoppingListSummary", "ShoppingListUpdate", + "GroupAdminUpdate", + "SetPermissions", "GroupStatistics", "GroupStorage", "CreateInviteToken", @@ -100,9 +89,4 @@ __all__ = [ "EmailInvitation", "ReadInviteToken", "SaveInviteToken", - "CreateWebhook", - "ReadWebhook", - "SaveWebhook", - "WebhookPagination", - "WebhookType", ] diff --git a/mealie/schema/recipe/__init__.py b/mealie/schema/recipe/__init__.py index 09a67ece16a6..7473d59633ee 100644 --- a/mealie/schema/recipe/__init__.py +++ b/mealie/schema/recipe/__init__.py @@ -7,7 +7,6 @@ from .recipe import ( RecipeCategory, RecipeCategoryPagination, RecipePagination, - RecipePaginationQuery, RecipeSummary, RecipeTag, RecipeTagPagination, @@ -155,7 +154,6 @@ __all__ = [ "RecipeCategory", "RecipeCategoryPagination", "RecipePagination", - "RecipePaginationQuery", "RecipeSummary", "RecipeTag", "RecipeTagPagination", diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index f3b4c94df7bb..3338add425a9 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -12,7 +12,7 @@ from slugify import slugify from mealie.core.config import get_app_dirs from mealie.db.models.recipe.recipe import RecipeModel from mealie.schema._mealie import MealieModel -from mealie.schema.response.pagination import PaginationBase, PaginationQuery +from mealie.schema.response.pagination import PaginationBase from .recipe_asset import RecipeAsset from .recipe_comments import RecipeCommentOut @@ -102,14 +102,6 @@ class RecipeSummary(MealieModel): orm_mode = True -class RecipeSummaryWithIngredients(RecipeSummary): - recipe_ingredient: list[RecipeIngredient] | None = [] - - -class RecipePaginationQuery(PaginationQuery): - load_food: bool = False - - class RecipePagination(PaginationBase): items: list[RecipeSummary] @@ -211,5 +203,4 @@ class Recipe(RecipeSummary): from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402 RecipeSummary.update_forward_refs() -RecipeSummaryWithIngredients.update_forward_refs() Recipe.update_forward_refs() diff --git a/mealie/schema/response/pagination.py b/mealie/schema/response/pagination.py index d80baf9334fa..11b23c3fa968 100644 --- a/mealie/schema/response/pagination.py +++ b/mealie/schema/response/pagination.py @@ -3,7 +3,7 @@ from typing import Any, Generic, TypeVar from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from humps import camelize -from pydantic import BaseModel +from pydantic import UUID4, BaseModel from pydantic.generics import GenericModel from mealie.schema._mealie import MealieModel @@ -16,6 +16,15 @@ class OrderDirection(str, enum.Enum): desc = "desc" +class RecipeSearchQuery(MealieModel): + cookbook: UUID4 | str | None + require_all_categories: bool = False + require_all_tags: bool = False + require_all_tools: bool = False + require_all_foods: bool = False + search: str | None + + class PaginationQuery(MealieModel): page: int = 1 per_page: int = 50 diff --git a/tests/unit_tests/repository_tests/test_recipe_repository.py b/tests/unit_tests/repository_tests/test_recipe_repository.py index b1ad8522c674..3c63d908621a 100644 --- a/tests/unit_tests/repository_tests/test_recipe_repository.py +++ b/tests/unit_tests/repository_tests/test_recipe_repository.py @@ -2,9 +2,11 @@ from typing import cast from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_recipes import RepositoryRecipes -from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipePaginationQuery, RecipeSummary +from mealie.schema.recipe import RecipeIngredient, SaveIngredientFood, RecipeStep +from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave from mealie.schema.recipe.recipe_tool import RecipeToolSave +from mealie.schema.response import OrderDirection, PaginationQuery from tests.utils.factories import random_string from tests.utils.fixture_schemas import TestUser @@ -164,7 +166,7 @@ def test_recipe_repo_pagination_by_categories(database: AllRepositories, unique_ for recipe in recipes: database.recipes.create(recipe) - pagination_query = RecipePaginationQuery( + pagination_query = PaginationQuery( page=1, per_page=-1, ) @@ -245,7 +247,7 @@ def test_recipe_repo_pagination_by_tags(database: AllRepositories, unique_user: for recipe in recipes: database.recipes.create(recipe) - pagination_query = RecipePaginationQuery( + pagination_query = PaginationQuery( page=1, per_page=-1, ) @@ -324,7 +326,7 @@ def test_recipe_repo_pagination_by_tools(database: AllRepositories, unique_user: for recipe in recipes: database.recipes.create(recipe) - pagination_query = RecipePaginationQuery( + pagination_query = PaginationQuery( page=1, per_page=-1, ) @@ -357,3 +359,138 @@ def test_recipe_repo_pagination_by_tools(database: AllRepositories, unique_user: tool_ids = [tool.id for tool in recipe_summary.tools] for tool in created_tools: assert tool.id in tool_ids + + +def test_recipe_repo_pagination_by_foods(database: AllRepositories, unique_user: TestUser): + slug1, slug2 = (random_string(10) for _ in range(2)) + + foods = [ + SaveIngredientFood(group_id=unique_user.group_id, name=slug1), + SaveIngredientFood(group_id=unique_user.group_id, name=slug2), + ] + + created_foods = [database.ingredient_foods.create(food) for food in foods] + + # Bootstrap the database with recipes + recipes = [] + + for i in range(10): + # None of the foods + recipes.append( + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(), + ) + ) + + # Only one of the foods + recipes.append( + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(), + recipe_ingredient=[RecipeIngredient(food=created_foods[i % 2])], + ), + ) + + # Both of the foods + recipes.append( + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(), + recipe_ingredient=[RecipeIngredient(food=created_foods[0]), RecipeIngredient(food=created_foods[1])], + ) + ) + + for recipe in recipes: + database.recipes.create(recipe) + + pagination_query = PaginationQuery( + page=1, + per_page=-1, + ) + + # Get all recipes with only one food by UUID + food_id = created_foods[0].id + recipes_with_one_food = database.recipes.page_all(pagination_query, foods=[food_id]).items + assert len(recipes_with_one_food) == 15 + + # Get all recipes with both foods + recipes_with_both_foods = database.recipes.page_all( + pagination_query, foods=[food.id for food in created_foods] + ).items + assert len(recipes_with_both_foods) == 10 + + # Get all recipes with either foods + recipes_with_either_food = database.recipes.page_all( + pagination_query, foods=[food.id for food in created_foods], require_all_foods=False + ).items + + assert len(recipes_with_either_food) == 20 + + +def test_recipe_repo_search(database: AllRepositories, unique_user: TestUser): + ingredient_1 = random_string(10) + ingredient_2 = random_string(10) + name_part_1 = random_string(10) + name_1 = f"{name_part_1} soup" + name_part_2 = random_string(10) + name_2 = f"Rustic {name_part_2} stew" + name_3 = f"{ingredient_1} Soup" + description_part_1 = random_string(10) + recipes = [ + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=name_1, + description=f"My favorite {description_part_1}", + recipe_ingredient=[ + RecipeIngredient(note=ingredient_1), + ], + ), + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=name_2, + recipe_ingredient=[ + RecipeIngredient(note=ingredient_2), + ], + ), + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=name_3, + ), + ] + + for recipe in recipes: + database.recipes.create(recipe) + + pagination_query = PaginationQuery(page=1, per_page=-1, order_by="created_at", order_direction=OrderDirection.asc) + + # No hits + empty_result = database.recipes.page_all(pagination_query, search=random_string(10)).items + assert len(empty_result) == 0 + + # Search by title + title_result = database.recipes.page_all(pagination_query, search=name_part_2).items + assert len(title_result) == 1 + assert title_result[0].name == name_2 + + # Search by description + description_result = database.recipes.page_all(pagination_query, search=description_part_1).items + assert len(description_result) == 1 + assert description_result[0].name == name_1 + + # Search by ingredient + ingredient_result = database.recipes.page_all(pagination_query, search=ingredient_2).items + assert len(ingredient_result) == 1 + assert ingredient_result[0].name == name_2 + + # Make sure title matches are ordered in front + ordered_result = database.recipes.page_all(pagination_query, search=ingredient_1).items + assert len(ordered_result) == 2 + assert ordered_result[0].name == name_3 + assert ordered_result[1].name == name_1 diff --git a/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py b/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py index 84bc366204c3..2d958daca9bf 100644 --- a/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py +++ b/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py @@ -2,10 +2,7 @@ import json from mealie.core.config import get_app_settings from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter - -ALEMBIC_VERSIONS = [ - {"version_num": "ff5f73b01a7a"}, -] +from tests.utils.alembic_reader import alembic_versions def test_alchemy_exporter(): @@ -13,16 +10,16 @@ def test_alchemy_exporter(): exporter = AlchemyExporter(settings.DB_URL) data = exporter.dump() - assert data["alembic_version"] == ALEMBIC_VERSIONS + assert data["alembic_version"] == alembic_versions() assert json.dumps(data, indent=4) # Make sure data is json-serializable def test_validate_schemas(): schema = { - "alembic_version": ALEMBIC_VERSIONS, + "alembic_version": alembic_versions(), } match = { - "alembic_version": ALEMBIC_VERSIONS, + "alembic_version": alembic_versions(), } invalid_version = { @@ -33,7 +30,7 @@ def test_validate_schemas(): assert not AlchemyExporter.validate_schemas(schema, invalid_version) schema_with_tables = { - "alembic_version": ALEMBIC_VERSIONS, + "alembic_version": alembic_versions(), "recipes": [ { "id": 1, @@ -41,7 +38,7 @@ def test_validate_schemas(): ], } match_with_tables = { - "alembic_version": ALEMBIC_VERSIONS, + "alembic_version": alembic_versions(), "recipes": [ { "id": 2, @@ -50,3 +47,5 @@ def test_validate_schemas(): } assert AlchemyExporter.validate_schemas(schema_with_tables, match_with_tables) + assert AlchemyExporter.validate_schemas(schema_with_tables, match_with_tables) + assert AlchemyExporter.validate_schemas(schema_with_tables, match_with_tables) diff --git a/tests/unit_tests/test_alembic.py b/tests/unit_tests/test_alembic.py index 331785d6399d..16b5675665ff 100644 --- a/tests/unit_tests/test_alembic.py +++ b/tests/unit_tests/test_alembic.py @@ -1,14 +1,47 @@ -import pytest +import pathlib -# Test that alembic revisions are applicable and result in the current database -# See https://github.com/sqlalchemy/alembic/issues/724 for inspiration +from pydantic import BaseModel + +from tests.utils.alembic_reader import ALEMBIC_MIGRATIONS, import_file -@pytest.mark.skip("TODO: Implement") -def test_alembic_revisions_are_applicable(): - pass +class AlembicMigration(BaseModel): + path: pathlib.Path + revision: str | None + down_revision: str | None -@pytest.mark.skip("TODO: Implement") -def test_alembic_revisions_are_up_to_date(): - pass +def test_alembic_revisions_are_in_order() -> None: + # read all files + paths = sorted(ALEMBIC_MIGRATIONS.glob("*.py")) + + # convert to sorted list of AlembicMigration + migrations: list[AlembicMigration] = [] + + for path in paths: + mod = import_file("alembic_version", path) + + revision = getattr(mod, "revision", None) + down_revision = getattr(mod, "down_revision", None) + + migrations.append( + AlembicMigration( + path=path, + revision=revision, + down_revision=down_revision, + ) + ) + + # step through each migration and check + # - revision is in order + # - down_revision is in order + # - down_revision is the previous revision + last = None + for migration in migrations: + if last is not None: + assert ( + last.revision == migration.down_revision + ), f"{last.revision} != {migration.down_revision} for {migration.path}" + + last = migration + last = migration diff --git a/tests/utils/alembic_reader.py b/tests/utils/alembic_reader.py new file mode 100644 index 000000000000..2bba446f4561 --- /dev/null +++ b/tests/utils/alembic_reader.py @@ -0,0 +1,38 @@ +import importlib.util +import pathlib +from functools import lru_cache + +from mealie.db.init_db import PROJECT_DIR + +ALEMBIC_MIGRATIONS = PROJECT_DIR / "alembic" / "versions" + + +def import_file(module_name: str, file_path: pathlib.Path): + spec = importlib.util.spec_from_file_location(module_name, file_path) + + if spec is None or spec.loader is None: + raise ImportError(f"Unable to import {module_name} from {file_path}") + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def latest_alembic_version() -> str: + latest = sorted(ALEMBIC_MIGRATIONS.glob("*.py"))[-1] # Assumes files are named in order + + mod = import_file("alembic_version", latest) + + revision = getattr(mod, "revision", None) + + if revision is None: + raise Exception(f"Unable to find revision in {latest}") + + return revision + + +@lru_cache(1) +def alembic_versions(): + return [ + {"version_num": latest_alembic_version()}, + ]