mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	feat: category and tag filters to recipe pagination route (#1508)
* fixed incorrect response model * added category and tag filters * moved categories and tags params to route and changed to query array param * type fixes * added category and tag tests
This commit is contained in:
		| @@ -129,7 +129,14 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): | |||||||
|             .all() |             .all() | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def page_all(self, pagination: PaginationQuery, override=None, load_food=False) -> RecipePagination: |     def page_all( | ||||||
|  |         self, | ||||||
|  |         pagination: PaginationQuery, | ||||||
|  |         override=None, | ||||||
|  |         load_food=False, | ||||||
|  |         categories: Optional[list[UUID4 | str]] = None, | ||||||
|  |         tags: Optional[list[UUID4 | str]] = None, | ||||||
|  |     ) -> RecipePagination: | ||||||
|         q = self.session.query(self.model) |         q = self.session.query(self.model) | ||||||
|  |  | ||||||
|         args = [ |         args = [ | ||||||
| @@ -145,6 +152,23 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): | |||||||
|  |  | ||||||
|         fltr = self._filter_builder() |         fltr = self._filter_builder() | ||||||
|         q = q.filter_by(**fltr) |         q = q.filter_by(**fltr) | ||||||
|  |  | ||||||
|  |         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)) | ||||||
|  |  | ||||||
|         q, count, total_pages = self.add_pagination_to_query(q, pagination) |         q, count, total_pages = self.add_pagination_to_query(q, pagination) | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|   | |||||||
| @@ -1,13 +1,14 @@ | |||||||
| from functools import cached_property | from functools import cached_property | ||||||
| from shutil import copyfileobj | from shutil import copyfileobj | ||||||
|  | from typing import Optional | ||||||
| from zipfile import ZipFile | from zipfile import ZipFile | ||||||
|  |  | ||||||
| import sqlalchemy | import sqlalchemy | ||||||
| from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, status | from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, Query, status | ||||||
| from fastapi.datastructures import UploadFile | from fastapi.datastructures import UploadFile | ||||||
| from fastapi.encoders import jsonable_encoder | from fastapi.encoders import jsonable_encoder | ||||||
| from fastapi.responses import JSONResponse | from fastapi.responses import JSONResponse | ||||||
| from pydantic import BaseModel, Field | from pydantic import UUID4, BaseModel, Field | ||||||
| from slugify import slugify | from slugify import slugify | ||||||
| from starlette.responses import FileResponse | from starlette.responses import FileResponse | ||||||
|  |  | ||||||
| @@ -21,7 +22,13 @@ from mealie.routes._base import BaseUserController, controller | |||||||
| from mealie.routes._base.mixins import HttpRepo | from mealie.routes._base.mixins import HttpRepo | ||||||
| from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter | from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter | ||||||
| from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe | from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe | ||||||
| from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipePaginationQuery, RecipeSummary | from mealie.schema.recipe.recipe import ( | ||||||
|  |     CreateRecipe, | ||||||
|  |     CreateRecipeByUrlBulk, | ||||||
|  |     RecipePagination, | ||||||
|  |     RecipePaginationQuery, | ||||||
|  |     RecipeSummary, | ||||||
|  | ) | ||||||
| from mealie.schema.recipe.recipe_asset import RecipeAsset | from mealie.schema.recipe.recipe_asset import RecipeAsset | ||||||
| from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest | from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest | ||||||
| from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse | from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse | ||||||
| @@ -200,11 +207,18 @@ class RecipeController(BaseRecipeController): | |||||||
|     # ================================================================================================================== |     # ================================================================================================================== | ||||||
|     # CRUD Operations |     # CRUD Operations | ||||||
|  |  | ||||||
|     @router.get("", response_model=list[RecipeSummary]) |     @router.get("", response_model=RecipePagination) | ||||||
|     def get_all(self, q: RecipePaginationQuery = Depends(RecipePaginationQuery)): |     def get_all( | ||||||
|  |         self, | ||||||
|  |         q: RecipePaginationQuery = Depends(RecipePaginationQuery), | ||||||
|  |         categories: Optional[list[UUID4 | str]] = Query(None), | ||||||
|  |         tags: Optional[list[UUID4 | str]] = Query(None), | ||||||
|  |     ): | ||||||
|         response = self.repo.page_all( |         response = self.repo.page_all( | ||||||
|             pagination=q, |             pagination=q, | ||||||
|             load_food=q.load_food, |             load_food=q.load_food, | ||||||
|  |             categories=categories, | ||||||
|  |             tags=tags, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) |         response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) | ||||||
|   | |||||||
| @@ -1,7 +1,9 @@ | |||||||
|  | from typing import cast | ||||||
|  |  | ||||||
| from mealie.repos.repository_factory import AllRepositories | from mealie.repos.repository_factory import AllRepositories | ||||||
| from mealie.repos.repository_recipes import RepositoryRecipes | from mealie.repos.repository_recipes import RepositoryRecipes | ||||||
| from mealie.schema.recipe.recipe import Recipe | from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipePaginationQuery, RecipeSummary | ||||||
| from mealie.schema.recipe.recipe_category import CategorySave | from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave | ||||||
| from tests.utils.factories import random_string | from tests.utils.factories import random_string | ||||||
| from tests.utils.fixture_schemas import TestUser | from tests.utils.fixture_schemas import TestUser | ||||||
|  |  | ||||||
| @@ -10,20 +12,20 @@ def test_recipe_repo_get_by_categories_basic(database: AllRepositories, unique_u | |||||||
|     # Bootstrap the database with categories |     # Bootstrap the database with categories | ||||||
|     slug1, slug2, slug3 = [random_string(10) for _ in range(3)] |     slug1, slug2, slug3 = [random_string(10) for _ in range(3)] | ||||||
|  |  | ||||||
|     categories = [ |     categories: list[CategoryOut | CategorySave] = [ | ||||||
|         CategorySave(group_id=unique_user.group_id, name=slug1, slug=slug1), |         CategorySave(group_id=unique_user.group_id, name=slug1, slug=slug1), | ||||||
|         CategorySave(group_id=unique_user.group_id, name=slug2, slug=slug2), |         CategorySave(group_id=unique_user.group_id, name=slug2, slug=slug2), | ||||||
|         CategorySave(group_id=unique_user.group_id, name=slug3, slug=slug3), |         CategorySave(group_id=unique_user.group_id, name=slug3, slug=slug3), | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     created_categories = [] |     created_categories: list[CategoryOut] = [] | ||||||
|  |  | ||||||
|     for category in categories: |     for category in categories: | ||||||
|         model = database.categories.create(category) |         model = database.categories.create(category) | ||||||
|         created_categories.append(model) |         created_categories.append(model) | ||||||
|  |  | ||||||
|     # Bootstrap the database with recipes |     # Bootstrap the database with recipes | ||||||
|     recipes = [] |     recipes: list[Recipe | RecipeSummary] = [] | ||||||
|  |  | ||||||
|     for idx in range(15): |     for idx in range(15): | ||||||
|         if idx % 3 == 0: |         if idx % 3 == 0: | ||||||
| @@ -45,14 +47,14 @@ def test_recipe_repo_get_by_categories_basic(database: AllRepositories, unique_u | |||||||
|     created_recipes = [] |     created_recipes = [] | ||||||
|  |  | ||||||
|     for recipe in recipes: |     for recipe in recipes: | ||||||
|         models = database.recipes.create(recipe) |         models = database.recipes.create(cast(Recipe, recipe)) | ||||||
|         created_recipes.append(models) |         created_recipes.append(models) | ||||||
|  |  | ||||||
|     # Get all recipes by category |     # Get all recipes by category | ||||||
|  |  | ||||||
|     for category in created_categories: |     for category in created_categories: | ||||||
|         repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id) |         repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id)  # type: ignore | ||||||
|         recipes = repo.get_by_categories([category]) |         recipes = repo.get_by_categories([cast(RecipeCategory, category)]) | ||||||
|  |  | ||||||
|         assert len(recipes) == 5 |         assert len(recipes) == 5 | ||||||
|  |  | ||||||
| @@ -106,11 +108,171 @@ def test_recipe_repo_get_by_categories_multi(database: AllRepositories, unique_u | |||||||
|         database.recipes.create(recipe) |         database.recipes.create(recipe) | ||||||
|  |  | ||||||
|     # Get all recipes by both categories |     # Get all recipes by both categories | ||||||
|     repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id) |     repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id)  # type: ignore | ||||||
|     by_category = repo.get_by_categories(created_categories) |     by_category = repo.get_by_categories(cast(list[RecipeCategory], created_categories)) | ||||||
|  |  | ||||||
|     assert len(by_category) == 10 |     assert len(by_category) == 10 | ||||||
|  |  | ||||||
|     for recipe in by_category: |     for recipe_summary in by_category: | ||||||
|         for category in recipe.recipe_category: |         for recipe_category in recipe_summary.recipe_category: | ||||||
|             assert category.id in known_category_ids |             assert recipe_category.id in known_category_ids | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_recipe_repo_pagination_by_categories(database: AllRepositories, unique_user: TestUser): | ||||||
|  |     slug1, slug2 = [random_string(10) for _ in range(2)] | ||||||
|  |  | ||||||
|  |     categories = [ | ||||||
|  |         CategorySave(group_id=unique_user.group_id, name=slug1, slug=slug1), | ||||||
|  |         CategorySave(group_id=unique_user.group_id, name=slug2, slug=slug2), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     created_categories = [database.categories.create(category) for category in categories] | ||||||
|  |  | ||||||
|  |     # Bootstrap the database with recipes | ||||||
|  |     recipes = [] | ||||||
|  |  | ||||||
|  |     for i in range(10): | ||||||
|  |         # None of the categories | ||||||
|  |         recipes.append( | ||||||
|  |             Recipe( | ||||||
|  |                 user_id=unique_user.user_id, | ||||||
|  |                 group_id=unique_user.group_id, | ||||||
|  |                 name=random_string(), | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Only one of the categories | ||||||
|  |         recipes.append( | ||||||
|  |             Recipe( | ||||||
|  |                 user_id=unique_user.user_id, | ||||||
|  |                 group_id=unique_user.group_id, | ||||||
|  |                 name=random_string(), | ||||||
|  |                 recipe_category=[created_categories[i % 2]], | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Both of the categories | ||||||
|  |         recipes.append( | ||||||
|  |             Recipe( | ||||||
|  |                 user_id=unique_user.user_id, | ||||||
|  |                 group_id=unique_user.group_id, | ||||||
|  |                 name=random_string(), | ||||||
|  |                 recipe_category=created_categories, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     for recipe in recipes: | ||||||
|  |         database.recipes.create(recipe) | ||||||
|  |  | ||||||
|  |     pagination_query = RecipePaginationQuery( | ||||||
|  |         page=1, | ||||||
|  |         per_page=-1, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     # Get all recipes with only one category by UUID | ||||||
|  |     category_id = created_categories[0].id | ||||||
|  |     recipes_with_one_category = database.recipes.page_all(pagination_query, categories=[category_id]).items | ||||||
|  |     assert len(recipes_with_one_category) == 15 | ||||||
|  |  | ||||||
|  |     for recipe_summary in recipes_with_one_category: | ||||||
|  |         category_ids = [category.id for category in recipe_summary.recipe_category] | ||||||
|  |         assert category_id in category_ids | ||||||
|  |  | ||||||
|  |     # Get all recipes with only one category by slug | ||||||
|  |     category_slug = created_categories[1].slug | ||||||
|  |     recipes_with_one_category = database.recipes.page_all(pagination_query, categories=[category_slug]).items | ||||||
|  |     assert len(recipes_with_one_category) == 15 | ||||||
|  |  | ||||||
|  |     for recipe_summary in recipes_with_one_category: | ||||||
|  |         category_slugs = [category.slug for category in recipe_summary.recipe_category] | ||||||
|  |         assert category_slug in category_slugs | ||||||
|  |  | ||||||
|  |     # Get all recipes with both categories | ||||||
|  |     recipes_with_both_categories = database.recipes.page_all( | ||||||
|  |         pagination_query, categories=[category.id for category in created_categories] | ||||||
|  |     ).items | ||||||
|  |     assert len(recipes_with_both_categories) == 10 | ||||||
|  |  | ||||||
|  |     for recipe_summary in recipes_with_both_categories: | ||||||
|  |         category_ids = [category.id for category in recipe_summary.recipe_category] | ||||||
|  |         for category in created_categories: | ||||||
|  |             assert category.id in category_ids | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_recipe_repo_pagination_by_tags(database: AllRepositories, unique_user: TestUser): | ||||||
|  |     slug1, slug2 = [random_string(10) for _ in range(2)] | ||||||
|  |  | ||||||
|  |     tags = [ | ||||||
|  |         TagSave(group_id=unique_user.group_id, name=slug1, slug=slug1), | ||||||
|  |         TagSave(group_id=unique_user.group_id, name=slug2, slug=slug2), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     created_tags = [database.tags.create(tag) for tag in tags] | ||||||
|  |  | ||||||
|  |     # Bootstrap the database with recipes | ||||||
|  |     recipes = [] | ||||||
|  |  | ||||||
|  |     for i in range(10): | ||||||
|  |         # None of the tags | ||||||
|  |         recipes.append( | ||||||
|  |             Recipe( | ||||||
|  |                 user_id=unique_user.user_id, | ||||||
|  |                 group_id=unique_user.group_id, | ||||||
|  |                 name=random_string(), | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Only one of the tags | ||||||
|  |         recipes.append( | ||||||
|  |             Recipe( | ||||||
|  |                 user_id=unique_user.user_id, | ||||||
|  |                 group_id=unique_user.group_id, | ||||||
|  |                 name=random_string(), | ||||||
|  |                 tags=[created_tags[i % 2]], | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Both of the tags | ||||||
|  |         recipes.append( | ||||||
|  |             Recipe( | ||||||
|  |                 user_id=unique_user.user_id, | ||||||
|  |                 group_id=unique_user.group_id, | ||||||
|  |                 name=random_string(), | ||||||
|  |                 tags=created_tags, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     for recipe in recipes: | ||||||
|  |         database.recipes.create(recipe) | ||||||
|  |  | ||||||
|  |     pagination_query = RecipePaginationQuery( | ||||||
|  |         page=1, | ||||||
|  |         per_page=-1, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     # Get all recipes with only one tag by UUID | ||||||
|  |     tag_id = created_tags[0].id | ||||||
|  |     recipes_with_one_tag = database.recipes.page_all(pagination_query, tags=[tag_id]).items | ||||||
|  |     assert len(recipes_with_one_tag) == 15 | ||||||
|  |  | ||||||
|  |     for recipe_summary in recipes_with_one_tag: | ||||||
|  |         tag_ids = [tag.id for tag in recipe_summary.tags] | ||||||
|  |         assert tag_id in tag_ids | ||||||
|  |  | ||||||
|  |     # Get all recipes with only one tag by slug | ||||||
|  |     tag_slug = created_tags[1].slug | ||||||
|  |     recipes_with_one_tag = database.recipes.page_all(pagination_query, tags=[tag_slug]).items | ||||||
|  |     assert len(recipes_with_one_tag) == 15 | ||||||
|  |  | ||||||
|  |     for recipe_summary in recipes_with_one_tag: | ||||||
|  |         tag_slugs = [tag.slug for tag in recipe_summary.tags] | ||||||
|  |         assert tag_slug in tag_slugs | ||||||
|  |  | ||||||
|  |     # Get all recipes with both tags | ||||||
|  |     recipes_with_both_tags = database.recipes.page_all(pagination_query, tags=[tag.id for tag in created_tags]).items | ||||||
|  |     assert len(recipes_with_both_tags) == 10 | ||||||
|  |  | ||||||
|  |     for recipe_summary in recipes_with_both_tags: | ||||||
|  |         tag_ids = [tag.id for tag in recipe_summary.tags] | ||||||
|  |         for tag in created_tags: | ||||||
|  |             assert tag.id in tag_ids | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user