From f45e2587a0d0c12b17494b93456fec57568f8be6 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 9 Aug 2022 17:01:59 -0500 Subject: [PATCH] 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 --- mealie/repos/repository_recipes.py | 26 ++- mealie/routes/recipe/recipe_crud_routes.py | 24 ++- .../test_recipe_repository.py | 188 ++++++++++++++++-- 3 files changed, 219 insertions(+), 19 deletions(-) diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index 0492bc2574f1..6b221d1ea278 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -129,7 +129,14 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): .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) args = [ @@ -145,6 +152,23 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): fltr = self._filter_builder() 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) try: diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index c25b71c77793..8e60f257f48c 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -1,13 +1,14 @@ from functools import cached_property from shutil import copyfileobj +from typing import Optional from zipfile import ZipFile 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.encoders import jsonable_encoder from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field +from pydantic import UUID4, BaseModel, Field from slugify import slugify 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.routers import MealieCrudRoute, UserAPIRouter 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_scraper import ScrapeRecipeTest from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse @@ -200,11 +207,18 @@ class RecipeController(BaseRecipeController): # ================================================================================================================== # CRUD Operations - @router.get("", response_model=list[RecipeSummary]) - def get_all(self, q: RecipePaginationQuery = Depends(RecipePaginationQuery)): + @router.get("", response_model=RecipePagination) + 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( pagination=q, load_food=q.load_food, + categories=categories, + tags=tags, ) response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) diff --git a/tests/unit_tests/repository_tests/test_recipe_repository.py b/tests/unit_tests/repository_tests/test_recipe_repository.py index 1434ccbc4b6b..fdc2a1df8c2e 100644 --- a/tests/unit_tests/repository_tests/test_recipe_repository.py +++ b/tests/unit_tests/repository_tests/test_recipe_repository.py @@ -1,7 +1,9 @@ +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 -from mealie.schema.recipe.recipe_category import CategorySave +from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipePaginationQuery, RecipeSummary +from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave from tests.utils.factories import random_string 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 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=slug2, slug=slug2), CategorySave(group_id=unique_user.group_id, name=slug3, slug=slug3), ] - created_categories = [] + created_categories: list[CategoryOut] = [] for category in categories: model = database.categories.create(category) created_categories.append(model) # Bootstrap the database with recipes - recipes = [] + recipes: list[Recipe | RecipeSummary] = [] for idx in range(15): if idx % 3 == 0: @@ -45,14 +47,14 @@ def test_recipe_repo_get_by_categories_basic(database: AllRepositories, unique_u created_recipes = [] for recipe in recipes: - models = database.recipes.create(recipe) + models = database.recipes.create(cast(Recipe, recipe)) created_recipes.append(models) # Get all recipes by category for category in created_categories: - repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id) - recipes = repo.get_by_categories([category]) + repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id) # type: ignore + recipes = repo.get_by_categories([cast(RecipeCategory, category)]) assert len(recipes) == 5 @@ -106,11 +108,171 @@ def test_recipe_repo_get_by_categories_multi(database: AllRepositories, unique_u database.recipes.create(recipe) # Get all recipes by both categories - repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id) - by_category = repo.get_by_categories(created_categories) + repo: RepositoryRecipes = database.recipes.by_group(unique_user.group_id) # type: ignore + by_category = repo.get_by_categories(cast(list[RecipeCategory], created_categories)) assert len(by_category) == 10 - for recipe in by_category: - for category in recipe.recipe_category: - assert category.id in known_category_ids + for recipe_summary in by_category: + for recipe_category in recipe_summary.recipe_category: + 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