diff --git a/frontend/composables/recipes/use-recipe-search.ts b/frontend/composables/recipes/use-recipe-search.ts index c18f27328b0..cb27062bead 100644 --- a/frontend/composables/recipes/use-recipe-search.ts +++ b/frontend/composables/recipes/use-recipe-search.ts @@ -31,6 +31,7 @@ export function useRecipeSearch(api: UserApi): UseRecipeSearchReturn { orderBy: "name", orderDirection: "asc", perPage: 20, + _searchSeed: Date.now().toString(), }); if (error) { diff --git a/frontend/composables/recipes/use-recipes.ts b/frontend/composables/recipes/use-recipes.ts index b353a55ef8d..1c721e3f228 100644 --- a/frontend/composables/recipes/use-recipes.ts +++ b/frontend/composables/recipes/use-recipes.ts @@ -1,8 +1,8 @@ import { useAsync, ref } from "@nuxtjs/composition-api"; import { useAsyncKey } from "../use-utils"; import { useUserApi } from "~/composables/api"; -import {Recipe} from "~/lib/api/types/recipe"; -import {RecipeSearchQuery} from "~/lib/api/user/recipes/recipe"; +import { Recipe } from "~/lib/api/types/recipe"; +import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe"; export const allRecipes = ref([]); export const recentRecipes = ref([]); @@ -23,6 +23,8 @@ export const useLazyRecipes = function () { const { data } = await api.recipes.getAll(page, perPage, { orderBy, orderDirection, + paginationSeed: query?._searchSeed, // propagate searchSeed to stabilize random order pagination + searchSeed: query?._searchSeed, // unused, but pass it along for completeness of data search: query?.search, cookbook: query?.cookbook, categories: query?.categories, diff --git a/frontend/lib/api/user/recipes/recipe.ts b/frontend/lib/api/user/recipes/recipe.ts index 5499fd98b65..879ffecdc37 100644 --- a/frontend/lib/api/user/recipes/recipe.ts +++ b/frontend/lib/api/user/recipes/recipe.ts @@ -78,6 +78,8 @@ export type RecipeSearchQuery = { page?: number; perPage?: number; orderBy?: string; + + _searchSeed?: string; }; export class RecipeAPI extends BaseCRUDAPI { diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index 56852cda944..a306e852464 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -212,7 +212,6 @@ export default defineComponent({ foods: toIDArray(selectedFoods.value), tags: toIDArray(selectedTags.value), tools: toIDArray(selectedTools.value), - // Only add the query param if it's or not default ...{ auto: state.value.auto ? undefined : "false", @@ -239,6 +238,7 @@ export default defineComponent({ requireAllFoods: state.value.requireAllFoods, orderBy: state.value.orderBy, orderDirection: state.value.orderDirection, + _searchSeed: Date.now().toString() }; } @@ -303,6 +303,11 @@ export default defineComponent({ name: i18n.tc("general.updated"), value: "update_at", }, + { + icon: $globals.icons.diceMultiple, + name: i18n.tc("general.random"), + value: "random", + }, ]; onMounted(() => { diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index e96a1f11d54..76abb55b7b5 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -1,12 +1,13 @@ from __future__ import annotations +import random from collections.abc import Iterable from math import ceil from typing import Any, Generic, TypeVar from fastapi import HTTPException from pydantic import UUID4, BaseModel -from sqlalchemy import Select, delete, func, select +from sqlalchemy import Select, case, delete, func, select from sqlalchemy.orm.session import Session from sqlalchemy.sql import sqltypes @@ -378,4 +379,16 @@ def add_pagination_to_query(self, query: Select, pagination: PaginationQuery) -> query = query.order_by(order_attr) + elif pagination.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) + allids = self.session.execute(temp_query).scalars().all() # fast because id is indexed + order = list(range(len(allids))) + random.seed(pagination.pagination_seed) + random.shuffle(order) + random_dict = dict(zip(allids, order, strict=True)) + case_stmt = case(random_dict, value=self.model.id) + query = query.order_by(case_stmt) + return query.limit(pagination.per_page).offset((pagination.page - 1) * pagination.per_page), count, total_pages diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index ce3538b7f54..6c462d30fca 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -23,6 +23,7 @@ from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter from mealie.schema.cookbook.cookbook import ReadCookBook +from mealie.schema.make_dependable import make_dependable from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeLastMade, RecipeSummary from mealie.schema.recipe.recipe_asset import RecipeAsset @@ -237,8 +238,8 @@ def create_recipe_from_zip(self, temp_path=Depends(temporary_zip_path), archive: def get_all( self, request: Request, - q: PaginationQuery = Depends(), - search_query: RecipeSearchQuery = Depends(), + q: PaginationQuery = Depends(make_dependable(PaginationQuery)), + search_query: RecipeSearchQuery = Depends(make_dependable(RecipeSearchQuery)), categories: list[UUID4 | str] | None = Query(None), tags: list[UUID4 | str] | None = Query(None), tools: list[UUID4 | str] | None = Query(None), diff --git a/mealie/schema/make_dependable.py b/mealie/schema/make_dependable.py new file mode 100644 index 00000000000..c7254cc78c9 --- /dev/null +++ b/mealie/schema/make_dependable.py @@ -0,0 +1,34 @@ +from inspect import signature + +from fastapi.exceptions import HTTPException, ValidationError + + +def make_dependable(cls): + """ + Pydantic BaseModels are very powerful because we get lots of validations and type checking right out of the box. + FastAPI can accept a BaseModel as a route Dependency and it will automatically handle things like documentation + and error handling. However, if we define custom validators then the errors they raise are not handled, leading + to HTTP 500's being returned. + + To better understand this issue, you can visit https://github.com/tiangolo/fastapi/issues/1474 for context. + + A workaround proposed there adds a classmethod which attempts to init the BaseModel and handles formatting of + any raised ValidationErrors, custom or otherwise. However, this means essentially duplicating the class's + signature. This function automates the creation of a workaround method with a matching signature so that you + can avoid code duplication. + + usage: + async def fetch(thing_request: ThingRequest = Depends(make_dependable(ThingRequest))): + """ + + def init_cls_and_handle_errors(*args, **kwargs): + try: + signature(init_cls_and_handle_errors).bind(*args, **kwargs) + return cls(*args, **kwargs) + except ValidationError as e: + for error in e.errors(): + error["loc"] = ["query"] + list(error["loc"]) + raise HTTPException(422, detail=e.errors()) from None + + init_cls_and_handle_errors.__signature__ = signature(cls) + return init_cls_and_handle_errors diff --git a/mealie/schema/response/pagination.py b/mealie/schema/response/pagination.py index 11b23c3fa96..f6605ed67dd 100644 --- a/mealie/schema/response/pagination.py +++ b/mealie/schema/response/pagination.py @@ -3,7 +3,7 @@ from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from humps import camelize -from pydantic import UUID4, BaseModel +from pydantic import UUID4, BaseModel, validator from pydantic.generics import GenericModel from mealie.schema._mealie import MealieModel @@ -23,6 +23,7 @@ class RecipeSearchQuery(MealieModel): require_all_tools: bool = False require_all_foods: bool = False search: str | None + _search_seed: str | None = None class PaginationQuery(MealieModel): @@ -31,6 +32,13 @@ class PaginationQuery(MealieModel): order_by: str = "created_at" order_direction: OrderDirection = OrderDirection.desc query_filter: str | None = None + pagination_seed: str | None = None + + @validator("pagination_seed", always=True, pre=True) + def validate_randseed(cls, pagination_seed, values): + if values.get("order_by") == "random" and not pagination_seed: + raise ValueError("paginationSeed is required when orderBy is random") + return pagination_seed class PaginationBase(GenericModel, Generic[DataT]): diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py index 305311ed89d..03aee6944f6 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -424,3 +424,28 @@ def test_get_recipe_by_slug_or_id(api_client: TestClient, unique_user: utils.Tes recipe_data = response.json() assert recipe_data["slug"] == slug assert recipe_data["id"] == recipe_id + + +def test_get_random_order(api_client: TestClient, unique_user: utils.TestUser): + # Create more recipes for stable random ordering + slugs = [random_string(10) for _ in range(7)] + for slug in slugs: + response = api_client.post(api_routes.recipes, json={"name": slug}, headers=unique_user.token) + assert response.status_code == 201 + assert json.loads(response.text) == slug + + goodparams: dict[str, int | str] = {"page": 1, "perPage": -1, "orderBy": "random", "paginationSeed": "abcdefg"} + response = api_client.get(api_routes.recipes, params=goodparams, headers=unique_user.token) + assert response.status_code == 200 + + seed1_params: dict[str, int | str] = {"page": 1, "perPage": -1, "orderBy": "random", "paginationSeed": "abcdefg"} + seed2_params: dict[str, int | str] = {"page": 1, "perPage": -1, "orderBy": "random", "paginationSeed": "gfedcba"} + data1 = api_client.get(api_routes.recipes, params=seed1_params, headers=unique_user.token).json() + data2 = api_client.get(api_routes.recipes, params=seed2_params, headers=unique_user.token).json() + data1_new = api_client.get(api_routes.recipes, params=seed1_params, headers=unique_user.token).json() + assert data1["items"][0]["slug"] != data2["items"][0]["slug"] # new seed -> new order + assert data1["items"][0]["slug"] == data1_new["items"][0]["slug"] # same seed -> same order + + badparams: dict[str, int | str] = {"page": 1, "perPage": -1, "orderBy": "random"} + response = api_client.get(api_routes.recipes, params=badparams, headers=unique_user.token) + assert response.status_code == 422 diff --git a/tests/unit_tests/repository_tests/test_recipe_repository.py b/tests/unit_tests/repository_tests/test_recipe_repository.py index 5ffcc232920..9cce9dd04b5 100644 --- a/tests/unit_tests/repository_tests/test_recipe_repository.py +++ b/tests/unit_tests/repository_tests/test_recipe_repository.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import cast from mealie.repos.repository_factory import AllRepositories @@ -200,6 +201,20 @@ def test_recipe_repo_pagination_by_categories(database: AllRepositories, unique_ for category in created_categories: assert category.id in category_ids + # Test random ordering with category filter + pagination_query = PaginationQuery( + page=1, + per_page=-1, + order_by="random", + pagination_seed=str(datetime.now()), + order_direction=OrderDirection.asc, + ) + random_ordered = [] + for i in range(5): + pagination_query.pagination_seed = str(datetime.now()) + random_ordered.append(database.recipes.page_all(pagination_query, categories=[category_slug]).items) + assert not all(i == random_ordered[0] for i in random_ordered) + def test_recipe_repo_pagination_by_tags(database: AllRepositories, unique_user: TestUser): slug1, slug2 = (random_string(10) for _ in range(2)) @@ -279,6 +294,21 @@ def test_recipe_repo_pagination_by_tags(database: AllRepositories, unique_user: for tag in created_tags: assert tag.id in tag_ids + # Test random ordering with tag filter + pagination_query = PaginationQuery( + page=1, + per_page=-1, + order_by="random", + pagination_seed=str(datetime.now()), + order_direction=OrderDirection.asc, + ) + random_ordered = [] + for i in range(5): + pagination_query.pagination_seed = str(datetime.now()) + random_ordered.append(database.recipes.page_all(pagination_query, tags=[tag_slug]).items) + assert len(random_ordered[0]) == 15 + assert not all(i == random_ordered[0] for i in random_ordered) + def test_recipe_repo_pagination_by_tools(database: AllRepositories, unique_user: TestUser): slug1, slug2 = (random_string(10) for _ in range(2)) @@ -360,6 +390,21 @@ def test_recipe_repo_pagination_by_tools(database: AllRepositories, unique_user: for tool in created_tools: assert tool.id in tool_ids + # Test random ordering with tools filter + pagination_query = PaginationQuery( + page=1, + per_page=-1, + order_by="random", + pagination_seed=str(datetime.now()), + order_direction=OrderDirection.asc, + ) + random_ordered = [] + for i in range(5): + pagination_query.pagination_seed = str(datetime.now()) + random_ordered.append(database.recipes.page_all(pagination_query, tools=[tool_id]).items) + assert len(random_ordered[0]) == 15 + assert not all(i == random_ordered[0] for i in random_ordered) + def test_recipe_repo_pagination_by_foods(database: AllRepositories, unique_user: TestUser): slug1, slug2 = (random_string(10) for _ in range(2)) @@ -430,6 +475,20 @@ def test_recipe_repo_pagination_by_foods(database: AllRepositories, unique_user: assert len(recipes_with_either_food) == 20 + pagination_query = PaginationQuery( + page=1, + per_page=-1, + order_by="random", + pagination_seed=str(datetime.now()), + order_direction=OrderDirection.asc, + ) + random_ordered = [] + for i in range(5): + pagination_query.pagination_seed = str(datetime.now()) + random_ordered.append(database.recipes.page_all(pagination_query, foods=[food_id]).items) + assert len(random_ordered[0]) == 15 + assert not all(i == random_ordered[0] for i in random_ordered) + def test_recipe_repo_search(database: AllRepositories, unique_user: TestUser): recipes = [ @@ -461,6 +520,37 @@ def test_recipe_repo_search(database: AllRepositories, unique_user: TestUser): group_id=unique_user.group_id, name="Rátàtôuile", ), + # Add a bunch of recipes for stable randomization + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=f"{random_string(10)} soup", + ), + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=f"{random_string(10)} soup", + ), + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=f"{random_string(10)} soup", + ), + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=f"{random_string(10)} soup", + ), + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=f"{random_string(10)} soup", + ), + Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=f"{random_string(10)} soup", + ), ] for recipe in recipes: @@ -520,3 +610,17 @@ def test_recipe_repo_search(database: AllRepositories, unique_user: TestUser): fuzzy_result = database.recipes.page_all(pagination_query, search="Steinbuck").items assert len(fuzzy_result) == 1 assert fuzzy_result[0].name == "Steinbock Sloop" + + # Test random ordering with search + pagination_query = PaginationQuery( + page=1, + per_page=-1, + order_by="random", + pagination_seed=str(datetime.now()), + order_direction=OrderDirection.asc, + ) + random_ordered = [] + for i in range(5): + pagination_query.pagination_seed = str(datetime.now()) + random_ordered.append(database.recipes.page_all(pagination_query, search="soup").items) + assert not all(i == random_ordered[0] for i in random_ordered)