mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-14 06:15:26 -05:00
feat: random sort option for front page (#2363)
* Add hook for random sorting * Add random sorting to front page * Add multiple tests for random sorting. * Be extra sure that all recipes are returned. * Too stable random. seed doesn't reach backend. * add timestamp to useRecipeSearch * Update randomization tests for timestamp seeding * ruff cleanup * pass timestamp separately in getAll * remove debugging log items * remove timestamp from address bar * remove defaults from backend timestamps * timestamp should be optional * fix edge case: query without timestamp * similar edge case: no timestamp in pagination * ruff :/ * better edge case handling * stabilize random search test w/more recipes * better pagination seeding * update pagination seed test * remove redundant random/seed check * Test for api routes to random sorting. * please the typing gods * hack to make query parameters throw correct exc * ruff * fix validator message typo * black reformatting --------- Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
@@ -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 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
|
||||
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
|
||||
|
||||
@@ -23,6 +23,7 @@ from mealie.routes._base import BaseCrudController, controller
|
||||
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 @@ class RecipeController(BaseRecipeController):
|
||||
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),
|
||||
|
||||
34
mealie/schema/make_dependable.py
Normal file
34
mealie/schema/make_dependable.py
Normal file
@@ -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
|
||||
@@ -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 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]):
|
||||
|
||||
Reference in New Issue
Block a user