From cb15db2d270cf156edaf7735a5240130764fd1b1 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Sat, 25 Jun 2022 14:39:38 -0500 Subject: [PATCH] feat: re-write get all routes to use pagination (#1424) rewrite get_all routes to use a pagination pattern to allow for better implementations of search, filter, and sorting on the frontend or by any client without fetching all the data. Additionally we added a CI check for running the Nuxt built to confirm that no TS errors were present. Finally, I had to remove the header support for the Shopping lists as the browser caching based off last_updated header was not allowing it to read recent updates due to how we're handling the updated_at property in the database with nested fields. This will have to be looked at in the future to reimplement. I'm unsure how many other routes have a similar issue. Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com> --- .github/workflows/frontend-lint.yml | 41 +++++++- frontend/api/_base.ts | 10 +- .../Domain/Recipe/RecipeContextMenu.vue | 2 +- .../Domain/Recipe/RecipeDialogShare.vue | 4 +- .../partials/use-actions-factory.ts | 14 ++- .../composables/recipes/use-recipe-tools.ts | 9 +- frontend/composables/recipes/use-recipes.ts | 24 ++--- frontend/composables/use-group-cookbooks.ts | 10 +- frontend/composables/use-group-mealplan.ts | 14 ++- frontend/composables/use-group-webhooks.ts | 10 +- frontend/composables/use-groups.ts | 15 ++- frontend/composables/use-user.ts | 14 ++- frontend/pages/group/mealplan/settings.vue | 2 +- frontend/pages/recipes/all.vue | 16 ++- frontend/pages/shopping-lists/_id.vue | 21 ++-- frontend/pages/shopping-lists/index.vue | 7 +- frontend/types/api.ts | 8 ++ mealie/repos/repository_generic.py | 33 ++++++- mealie/repos/repository_recipes.py | 71 +++++++++++++- .../routes/admin/admin_management_groups.py | 16 ++- mealie/routes/admin/admin_management_users.py | 16 ++- mealie/routes/admin/admin_server_tasks.py | 17 +++- mealie/routes/comments/__init__.py | 17 +++- mealie/routes/groups/controller_cookbooks.py | 16 ++- .../groups/controller_group_notifications.py | 15 ++- mealie/routes/groups/controller_labels.py | 15 ++- .../groups/controller_mealplan_rules.py | 16 ++- .../groups/controller_shopping_lists.py | 24 ++--- mealie/routes/groups/controller_webhooks.py | 16 ++- .../organizers/controller_categories.py | 15 ++- mealie/routes/organizers/controller_tags.py | 15 ++- mealie/routes/organizers/controller_tools.py | 16 ++- mealie/routes/recipe/recipe_crud_routes.py | 28 +++--- mealie/routes/unit_and_foods/foods.py | 22 ++++- mealie/routes/unit_and_foods/units.py | 22 ++++- mealie/routes/users/crud.py | 16 ++- mealie/schema/cookbook/cookbook.py | 5 + mealie/schema/group/group_events.py | 5 + mealie/schema/group/group_shopping_list.py | 5 + mealie/schema/group/webhook.py | 5 + mealie/schema/labels/multi_purpose_label.py | 5 + mealie/schema/meal_plan/plan_rules.py | 5 + mealie/schema/query.py | 10 -- mealie/schema/recipe/recipe.py | 21 ++++ mealie/schema/recipe/recipe_comments.py | 5 + mealie/schema/recipe/recipe_ingredient.py | 9 ++ mealie/schema/response/pagination.py | 53 +++++++++- mealie/schema/server/tasks.py | 5 + mealie/schema/user/user.py | 9 ++ .../test_admin_background_tasks.py | 4 +- .../user_group_tests/test_group_cookbooks.py | 2 +- .../test_group_shopping_lists.py | 2 +- .../user_recipe_tests/test_recipe_owner.py | 2 +- .../test_multitenant_cases.py | 4 +- .../repository_tests/test_pagination.py | 97 +++++++++++++++++-- 55 files changed, 683 insertions(+), 197 deletions(-) delete mode 100644 mealie/schema/query.py diff --git a/.github/workflows/frontend-lint.yml b/.github/workflows/frontend-lint.yml index 32e3370c71d6..7cfe4ae844d7 100644 --- a/.github/workflows/frontend-lint.yml +++ b/.github/workflows/frontend-lint.yml @@ -9,7 +9,7 @@ on: - mealie-next jobs: - ci: + lint: runs-on: ${{ matrix.os }} strategy: @@ -47,3 +47,42 @@ jobs: - name: Run linter 👀 run: yarn lint working-directory: "frontend" + + build: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest] + node: [16] + + steps: + - name: Checkout 🛎 + uses: actions/checkout@master + + - name: Setup node env 🏗 + uses: actions/setup-node@v2.1.5 + with: + node-version: ${{ matrix.node }} + check-latest: true + + - name: Get yarn cache directory path 🛠 + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + + - name: Cache node_modules 📦 + uses: actions/cache@v2.1.4 + id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies 👨🏻‍💻 + run: yarn + working-directory: "frontend" + + - name: Run Build 🚚 + run: yarn build + working-directory: "frontend" diff --git a/frontend/api/_base.ts b/frontend/api/_base.ts index 3b3e40c81cca..7b2514472106 100644 --- a/frontend/api/_base.ts +++ b/frontend/api/_base.ts @@ -1,4 +1,4 @@ -import { ApiRequestInstance } from "~/types/api"; +import { ApiRequestInstance, PaginationData } from "~/types/api"; export interface CrudAPIInterface { requests: ApiRequestInstance; @@ -18,13 +18,13 @@ export abstract class BaseAPI { } } -export abstract class BaseCRUDAPI extends BaseAPI implements CrudAPIInterface { +export abstract class BaseCRUDAPI extends BaseAPI implements CrudAPIInterface { abstract baseRoute: string; abstract itemRoute(itemId: string | number): string; - async getAll(start = 0, limit = 9999, params = {} as any) { - return await this.requests.get(this.baseRoute, { - params: { start, limit, ...params }, + async getAll(page = 1, perPage = -1, params = {} as any) { + return await this.requests.get>(this.baseRoute, { + params: { page, perPage, ...params }, }); } diff --git a/frontend/components/Domain/Recipe/RecipeContextMenu.vue b/frontend/components/Domain/Recipe/RecipeContextMenu.vue index e3f10f221c23..c8a2a1c157ed 100644 --- a/frontend/components/Domain/Recipe/RecipeContextMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeContextMenu.vue @@ -261,7 +261,7 @@ export default defineComponent({ async function getShoppingLists() { const { data } = await api.shopping.lists.getAll(); if (data) { - shoppingLists.value = data; + shoppingLists.value = data.items ?? []; } } diff --git a/frontend/components/Domain/Recipe/RecipeDialogShare.vue b/frontend/components/Domain/Recipe/RecipeDialogShare.vue index 8851860b8143..f9303dc0afd7 100644 --- a/frontend/components/Domain/Recipe/RecipeDialogShare.vue +++ b/frontend/components/Domain/Recipe/RecipeDialogShare.vue @@ -131,10 +131,10 @@ export default defineComponent({ } async function refreshTokens() { - const { data } = await userApi.recipes.share.getAll(0, 999, { recipe_id: props.recipeId }); + const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId }); if (data) { - state.tokens = data; + state.tokens = data.items ?? []; } } diff --git a/frontend/composables/partials/use-actions-factory.ts b/frontend/composables/partials/use-actions-factory.ts index 884f6217b26f..e0a48693c954 100644 --- a/frontend/composables/partials/use-actions-factory.ts +++ b/frontend/composables/partials/use-actions-factory.ts @@ -30,11 +30,15 @@ export function useStoreActions( const allItems = useAsync(async () => { const { data } = await api.getAll(); - if (allRef) { - allRef.value = data; + if (data && allRef) { + allRef.value = data.items; } - return data ?? []; + if (data) { + return data.items ?? []; + } else { + return []; + } }, useAsyncKey()); loading.value = false; @@ -45,8 +49,8 @@ export function useStoreActions( loading.value = true; const { data } = await api.getAll(); - if (data && allRef) { - allRef.value = data; + if (data && data.items && allRef) { + allRef.value = data.items; } loading.value = false; diff --git a/frontend/composables/recipes/use-recipe-tools.ts b/frontend/composables/recipes/use-recipe-tools.ts index cd770799196d..f2a19830c6a7 100644 --- a/frontend/composables/recipes/use-recipe-tools.ts +++ b/frontend/composables/recipes/use-recipe-tools.ts @@ -21,7 +21,12 @@ export const useTools = function (eager = true) { loading.value = true; const units = useAsync(async () => { const { data } = await api.tools.getAll(); - return data; + + if (data) { + return data.items; + } else { + return null; + } }, useAsyncKey()); loading.value = false; @@ -33,7 +38,7 @@ export const useTools = function (eager = true) { const { data } = await api.tools.getAll(); if (data) { - tools.value = data; + tools.value = data.items; } loading.value = false; diff --git a/frontend/composables/recipes/use-recipes.ts b/frontend/composables/recipes/use-recipes.ts index db199dd7ac6a..e17962509d68 100644 --- a/frontend/composables/recipes/use-recipes.ts +++ b/frontend/composables/recipes/use-recipes.ts @@ -18,8 +18,8 @@ function swap(t: Array, i: number, j: number) { export const useSorter = () => { function sortAToZ(list: Array) { list.sort((a, b) => { - const textA = a.name?.toUpperCase() ?? ""; - const textB = b.name?.toUpperCase() ?? ""; + const textA: string = a.name?.toUpperCase() ?? ""; + const textB: string = b.name?.toUpperCase() ?? ""; return textA < textB ? -1 : textA > textB ? 1 : 0; }); } @@ -61,10 +61,10 @@ export const useLazyRecipes = function () { const recipes = ref([]); - async function fetchMore(start: number, limit: number, orderBy: string | null = null, orderDescending = true) { - const { data } = await api.recipes.getAll(start, limit, { orderBy, orderDescending }); + async function fetchMore(page: number, perPage: number, orderBy: string | null = null, orderDirection = "desc") { + const { data } = await api.recipes.getAll(page, perPage, { orderBy, orderDirection }); if (data) { - data.forEach((recipe) => { + data.items.forEach((recipe) => { recipes.value?.push(recipe); }); } @@ -80,26 +80,26 @@ export const useRecipes = (all = false, fetchRecipes = true) => { const api = useUserApi(); // recipes is non-reactive!! - const { recipes, start, end } = (() => { + const { recipes, page, perPage } = (() => { if (all) { return { recipes: allRecipes, - start: 0, - end: 9999, + page: 1, + perPage: -1, }; } else { return { recipes: recentRecipes, - start: 0, - end: 30, + page: 1, + perPage: 30, }; } })(); async function refreshRecipes() { - const { data } = await api.recipes.getAll(start, end, { loadFood: true, orderBy: "created_at" }); + const { data } = await api.recipes.getAll(page, perPage, { loadFood: true, orderBy: "created_at" }); if (data) { - recipes.value = data; + recipes.value = data.items; } } diff --git a/frontend/composables/use-group-cookbooks.ts b/frontend/composables/use-group-cookbooks.ts index ec65b2e33bec..1e636c571f60 100644 --- a/frontend/composables/use-group-cookbooks.ts +++ b/frontend/composables/use-group-cookbooks.ts @@ -31,7 +31,11 @@ export const useCookbooks = function () { const units = useAsync(async () => { const { data } = await api.cookbooks.getAll(); - return data; + if (data) { + return data.items; + } else { + return null; + } }, useAsyncKey()); loading.value = false; @@ -41,8 +45,8 @@ export const useCookbooks = function () { loading.value = true; const { data } = await api.cookbooks.getAll(); - if (data && cookbookStore) { - cookbookStore.value = data; + if (data && data.items && cookbookStore) { + cookbookStore.value = data.items; } loading.value = false; diff --git a/frontend/composables/use-group-mealplan.ts b/frontend/composables/use-group-mealplan.ts index 70aa01bfa5ee..00d230337750 100644 --- a/frontend/composables/use-group-mealplan.ts +++ b/frontend/composables/use-group-mealplan.ts @@ -30,9 +30,13 @@ export const useMealplans = function (range: Ref) { limit: format(range.value.end, "yyyy-MM-dd"), }; // @ts-ignore TODO Modify typing to allow for string start+limit for mealplans - const { data } = await api.mealplans.getAll(query.start, query.limit); + const { data } = await api.mealplans.getAll(1, -1, { start: query.start, limit: query.limit }); - return data; + if (data) { + return data.items; + } else { + return null; + } }, useAsyncKey()); loading.value = false; @@ -45,10 +49,10 @@ export const useMealplans = function (range: Ref) { limit: format(range.value.end, "yyyy-MM-dd"), }; // @ts-ignore TODO Modify typing to allow for string start+limit for mealplans - const { data } = await api.mealplans.getAll(query.start, query.limit); + const { data } = await api.mealplans.getAll(1, -1, { start: query.start, limit: query.limit }); - if (data) { - mealplans.value = data; + if (data && data.items) { + mealplans.value = data.items; } loading.value = false; diff --git a/frontend/composables/use-group-webhooks.ts b/frontend/composables/use-group-webhooks.ts index 12fcc3493b49..156e74a2b2bc 100644 --- a/frontend/composables/use-group-webhooks.ts +++ b/frontend/composables/use-group-webhooks.ts @@ -14,7 +14,11 @@ export const useGroupWebhooks = function () { const units = useAsync(async () => { const { data } = await api.groupWebhooks.getAll(); - return data; + if (data) { + return data.items; + } else { + return null; + } }, useAsyncKey()); loading.value = false; @@ -24,8 +28,8 @@ export const useGroupWebhooks = function () { loading.value = true; const { data } = await api.groupWebhooks.getAll(); - if (data) { - webhooks.value = data; + if (data && data.items) { + webhooks.value = data.items; } loading.value = false; diff --git a/frontend/composables/use-groups.ts b/frontend/composables/use-groups.ts index 1fa65239039d..5ee60c679fb0 100644 --- a/frontend/composables/use-groups.ts +++ b/frontend/composables/use-groups.ts @@ -43,7 +43,12 @@ export const useGroups = function () { const asyncKey = String(Date.now()); const groups = useAsync(async () => { const { data } = await api.groups.getAll(); - return data; + + if (data) { + return data.items; + } else { + return null; + } }, asyncKey); loading.value = false; @@ -53,7 +58,13 @@ export const useGroups = function () { async function refreshAllGroups() { loading.value = true; const { data } = await api.groups.getAll(); - groups.value = data; + + if (data) { + groups.value = data.items; + } else { + groups.value = null; + } + loading.value = false; } diff --git a/frontend/composables/use-user.ts b/frontend/composables/use-user.ts index a2dfbaccdb5c..f893c384a73f 100644 --- a/frontend/composables/use-user.ts +++ b/frontend/composables/use-user.ts @@ -17,7 +17,11 @@ export const useAllUsers = function () { const asyncKey = String(Date.now()); const allUsers = useAsync(async () => { const { data } = await api.users.getAll(); - return data; + if (data) { + return data.items; + } else { + return null; + } }, asyncKey); loading.value = false; @@ -27,7 +31,13 @@ export const useAllUsers = function () { async function refreshAllUsers() { loading.value = true; const { data } = await api.users.getAll(); - users.value = data; + + if (data) { + users.value = data.items; + } else { + users.value = null; + } + loading.value = false; } diff --git a/frontend/pages/group/mealplan/settings.vue b/frontend/pages/group/mealplan/settings.vue index afb170d4a894..430612ed1d90 100644 --- a/frontend/pages/group/mealplan/settings.vue +++ b/frontend/pages/group/mealplan/settings.vue @@ -126,7 +126,7 @@ export default defineComponent({ const { data } = await api.mealplanRules.getAll(); if (data) { - allRules.value = data; + allRules.value = data.items ?? []; } } diff --git a/frontend/pages/recipes/all.vue b/frontend/pages/recipes/all.vue index 30062e1a5fd1..efd7b3cc24c8 100644 --- a/frontend/pages/recipes/all.vue +++ b/frontend/pages/recipes/all.vue @@ -22,21 +22,18 @@ import { useLazyRecipes } from "~/composables/recipes"; export default defineComponent({ components: { RecipeCardSection }, setup() { - // paging and sorting params + const page = ref(1); + const perPage = ref(30); const orderBy = "name"; - const orderDescending = false; - const increment = ref(30); + const orderDirection = "asc"; - const start = ref(0); - const offset = ref(increment.value); - const limit = ref(increment.value); const ready = ref(false); const loading = ref(false); const { recipes, fetchMore } = useLazyRecipes(); onMounted(async () => { - await fetchMore(start.value, limit.value, orderBy, orderDescending); + await fetchMore(page.value, perPage.value, orderBy, orderDirection); ready.value = true; }); @@ -45,9 +42,8 @@ export default defineComponent({ return; } loading.value = true; - start.value = offset.value + 1; - offset.value = offset.value + increment.value; - fetchMore(start.value, limit.value, orderBy, orderDescending); + page.value = page.value + 1; + fetchMore(page.value, perPage.value, orderBy, orderDirection); loading.value = false; }, 500); diff --git a/frontend/pages/shopping-lists/_id.vue b/frontend/pages/shopping-lists/_id.vue index 8fab7a55f496..4f5497146cc0 100644 --- a/frontend/pages/shopping-lists/_id.vue +++ b/frontend/pages/shopping-lists/_id.vue @@ -193,11 +193,11 @@ import { useCopyList } from "~/composables/use-copy"; import { useUserApi } from "~/composables/api"; import { useAsyncKey } from "~/composables/use-utils"; import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; -import { MultiPurposeLabelOut } from "~/types/api-types/labels"; import { ShoppingListItemCreate, ShoppingListItemOut } from "~/types/api-types/group"; import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; import { getDisplayText } from "~/composables/use-display-text"; +import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store"; type CopyTypes = "plain" | "markdown"; @@ -336,17 +336,9 @@ export default defineComponent({ // Labels, Units, Foods // TODO: Extract to Composable - const allLabels = ref([] as MultiPurposeLabelOut[]); - - const allUnits = useAsync(async () => { - const { data } = await userApi.units.getAll(); - return data ?? []; - }, useAsyncKey()); - - const allFoods = useAsync(async () => { - const { data } = await userApi.foods.getAll(); - return data ?? []; - }, useAsyncKey()); + const { labels: allLabels } = useLabelStore(); + const { units: allUnits } = useUnitStore(); + const { foods: allFoods } = useFoodStore(); function sortByLabels() { byLabel.value = !byLabel.value; @@ -405,7 +397,10 @@ export default defineComponent({ async function refreshLabels() { const { data } = await userApi.multiPurposeLabels.getAll(); - allLabels.value = data ?? []; + + if (data) { + allLabels.value = data.items ?? []; + } } refreshLabels(); diff --git a/frontend/pages/shopping-lists/index.vue b/frontend/pages/shopping-lists/index.vue index e1d074a2c586..72aedec5b0f7 100644 --- a/frontend/pages/shopping-lists/index.vue +++ b/frontend/pages/shopping-lists/index.vue @@ -60,7 +60,12 @@ export default defineComponent({ async function fetchShoppingLists() { const { data } = await userApi.shopping.lists.getAll(); - return data; + + if (!data) { + return []; + } + + return data.items; } async function refresh() { diff --git a/frontend/types/api.ts b/frontend/types/api.ts index 5614c5793303..6d13a2572887 100644 --- a/frontend/types/api.ts +++ b/frontend/types/api.ts @@ -13,3 +13,11 @@ export interface ApiRequestInstance { patch>(url: string, data: U): Promise>; delete(url: string): Promise>; } + +export interface PaginationData { + page: number; + per_page: number; + total: number; + total_pages: number; + items: T[]; +} diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index 5061e18066b9..13a8f2534b5c 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -1,8 +1,10 @@ +from math import ceil from typing import Any, Generic, TypeVar, Union from pydantic import UUID4, BaseModel from sqlalchemy import func from sqlalchemy.orm.session import Session +from sqlalchemy.sql import sqltypes from mealie.core.root_logger import get_logger from mealie.schema.response.pagination import OrderDirection, PaginationBase, PaginationQuery @@ -59,6 +61,8 @@ class RepositoryGeneric(Generic[Schema, Model]): def get_all( self, limit: int = None, order_by: str = None, order_descending: bool = True, start=0, override=None ) -> list[Schema]: + self.logger.warning('"get_all" method is deprecated; use "page_all" instead') + # sourcery skip: remove-unnecessary-cast eff_schema = override or self.schema @@ -224,7 +228,7 @@ class RepositoryGeneric(Generic[Schema, Model]): else: return [eff_schema.from_orm(x) for x in q.all()] - def pagination(self, pagination: PaginationQuery, override=None) -> PaginationBase[Schema]: + def page_all(self, pagination: PaginationQuery, override=None) -> PaginationBase[Schema]: """ pagination is a method to interact with the filtered database table and return a paginated result using the PaginationBase that provides several data points that are needed to manage pagination @@ -240,11 +244,32 @@ class RepositoryGeneric(Generic[Schema, Model]): fltr = self._filter_builder() q = q.filter_by(**fltr) - count = q.count() + # interpret -1 as "get_all" + if pagination.per_page == -1: + pagination.per_page = count + + try: + total_pages = ceil(count / pagination.per_page) + + except ZeroDivisionError: + total_pages = 0 + + # interpret -1 as "last page" + if pagination.page == -1: + pagination.page = total_pages + + # failsafe for user input error + if pagination.page < 1: + pagination.page = 1 + if pagination.order_by: if order_attr := getattr(self.model, pagination.order_by, None): + # queries handle uppercase and lowercase differently, which is undesirable + if isinstance(order_attr.type, sqltypes.String): + order_attr = func.lower(order_attr) + if pagination.order_direction == OrderDirection.asc: order_attr = order_attr.asc() elif pagination.order_direction == OrderDirection.desc: @@ -265,6 +290,6 @@ class RepositoryGeneric(Generic[Schema, Model]): page=pagination.page, per_page=pagination.per_page, total=count, - total_pages=int(count / pagination.per_page) + 1, - data=[eff_schema.from_orm(s) for s in data], + total_pages=total_pages, + items=[eff_schema.from_orm(s) for s in data], ) diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index eea86177d35c..152a7fdaef2a 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -1,3 +1,4 @@ +from math import ceil from random import randint from typing import Any, Optional from uuid import UUID @@ -7,6 +8,7 @@ from slugify import slugify from sqlalchemy import and_, func from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload +from sqlalchemy.sql import sqltypes from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.ingredient import RecipeIngredient @@ -15,8 +17,9 @@ from mealie.db.models.recipe.settings import RecipeSettings from mealie.db.models.recipe.tag import Tag from mealie.db.models.recipe.tool import Tool from mealie.schema.recipe import Recipe -from mealie.schema.recipe.recipe import RecipeCategory, RecipeSummary, RecipeTag, RecipeTool +from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, RecipeTag, RecipeTool from mealie.schema.recipe.recipe_category import CategoryBase, TagBase +from mealie.schema.response.pagination import OrderDirection, PaginationQuery from .repository_generic import RepositoryGeneric @@ -128,6 +131,72 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]): .all() ) + def page_all(self, pagination: PaginationQuery, override=None, load_food=False) -> RecipePagination: + q = self.session.query(self.model) + + args = [ + joinedload(RecipeModel.recipe_category), + joinedload(RecipeModel.tags), + joinedload(RecipeModel.tools), + ] + + if load_food: + args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food))) + + q = q.options(*args) + + fltr = self._filter_builder() + q = q.filter_by(**fltr) + count = q.count() + + # interpret -1 as "get_all" + if pagination.per_page == -1: + pagination.per_page = count + + try: + total_pages = ceil(count / pagination.per_page) + + except ZeroDivisionError: + total_pages = 0 + + # interpret -1 as "last page" + if pagination.page == -1: + pagination.page = total_pages + + # failsafe for user input error + if pagination.page < 1: + pagination.page = 1 + + if pagination.order_by: + if order_attr := getattr(self.model, pagination.order_by, None): + # queries handle uppercase and lowercase differently, which is undesirable + if isinstance(order_attr.type, sqltypes.String): + order_attr = func.lower(order_attr) + + if pagination.order_direction == OrderDirection.asc: + order_attr = order_attr.asc() + elif pagination.order_direction == OrderDirection.desc: + order_attr = order_attr.desc() + + q = q.order_by(order_attr) + + q = q.limit(pagination.per_page).offset((pagination.page - 1) * pagination.per_page) + + try: + data = q.all() + except Exception as e: + self._log_exception(e) + self.session.rollback() + raise e + + return RecipePagination( + page=pagination.page, + per_page=pagination.per_page, + total=count, + total_pages=total_pages, + items=data, + ) + def get_by_categories(self, categories: list[RecipeCategory]) -> list[RecipeSummary]: """ get_by_categories returns all the Recipes that contain every category provided in the list diff --git a/mealie/routes/admin/admin_management_groups.py b/mealie/routes/admin/admin_management_groups.py index 620754853d74..3abc158208c8 100644 --- a/mealie/routes/admin/admin_management_groups.py +++ b/mealie/routes/admin/admin_management_groups.py @@ -5,9 +5,9 @@ from pydantic import UUID4 from mealie.schema.group.group import GroupAdminUpdate from mealie.schema.mapper import mapper -from mealie.schema.query import GetAll +from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.responses import ErrorResponse -from mealie.schema.user.user import GroupBase, GroupInDB +from mealie.schema.user.user import GroupBase, GroupInDB, GroupPagination from mealie.services.group_services.group_service import GroupService from .._base import BaseAdminController, controller @@ -39,9 +39,15 @@ class AdminUserManagementRoutes(BaseAdminController): self.registered_exceptions, ) - @router.get("", response_model=list[GroupInDB]) - def get_all(self, q: GetAll = Depends(GetAll)): - return self.repo.get_all(start=q.start, limit=q.limit, override=GroupInDB) + @router.get("", response_model=GroupPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=GroupInDB, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED) def create_one(self, data: GroupBase): diff --git a/mealie/routes/admin/admin_management_users.py b/mealie/routes/admin/admin_management_users.py index b0d47d1b1285..1d60f1d8906a 100644 --- a/mealie/routes/admin/admin_management_users.py +++ b/mealie/routes/admin/admin_management_users.py @@ -7,9 +7,9 @@ from mealie.core import security from mealie.routes._base import BaseAdminController, controller from mealie.routes._base.dependencies import SharedDependencies from mealie.routes._base.mixins import HttpRepo -from mealie.schema.query import GetAll +from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.responses import ErrorResponse -from mealie.schema.user.user import UserIn, UserOut +from mealie.schema.user.user import UserIn, UserOut, UserPagination router = APIRouter(prefix="/users", tags=["Admin: Users"]) @@ -32,9 +32,15 @@ class AdminUserManagementRoutes(BaseAdminController): def mixins(self): return HttpRepo[UserIn, UserOut, UserOut](self.repo, self.deps.logger, self.registered_exceptions) - @router.get("", response_model=list[UserOut]) - def get_all(self, q: GetAll = Depends(GetAll)): - return self.repo.get_all(start=q.start, limit=q.limit, override=UserOut) + @router.get("", response_model=UserPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=UserOut, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=UserOut, status_code=201) def create_one(self, data: UserIn): diff --git a/mealie/routes/admin/admin_server_tasks.py b/mealie/routes/admin/admin_server_tasks.py index e8a8bc15edec..0750dd4e3375 100644 --- a/mealie/routes/admin/admin_server_tasks.py +++ b/mealie/routes/admin/admin_server_tasks.py @@ -1,8 +1,9 @@ -from fastapi import BackgroundTasks +from fastapi import BackgroundTasks, Depends from mealie.routes._base import BaseAdminController, controller from mealie.routes._base.routers import UserAPIRouter -from mealie.schema.server.tasks import ServerTask, ServerTaskNames +from mealie.schema.response.pagination import PaginationQuery +from mealie.schema.server.tasks import ServerTask, ServerTaskNames, ServerTaskPagination from mealie.services.server_tasks import BackgroundExecutor, test_executor_func router = UserAPIRouter() @@ -10,9 +11,15 @@ router = UserAPIRouter() @controller(router) class AdminServerTasksController(BaseAdminController): - @router.get("/server-tasks", response_model=list[ServerTask]) - def get_all(self): - return self.repos.server_tasks.get_all(order_by="created_at") + @router.get("/server-tasks", response_model=ServerTaskPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repos.server_tasks.page_all( + pagination=q, + override=ServerTask, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("/server-tasks", response_model=ServerTask, status_code=201) def create_test_tasks(self, bg_tasks: BackgroundTasks): diff --git a/mealie/routes/comments/__init__.py b/mealie/routes/comments/__init__.py index 93eba12781ce..489159a1e44e 100644 --- a/mealie/routes/comments/__init__.py +++ b/mealie/routes/comments/__init__.py @@ -8,13 +8,14 @@ from mealie.core.exceptions import mealie_registered_exceptions from mealie.routes._base.base_controllers import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import HttpRepo -from mealie.schema.query import GetAll from mealie.schema.recipe.recipe_comments import ( RecipeCommentCreate, RecipeCommentOut, + RecipeCommentPagination, RecipeCommentSave, RecipeCommentUpdate, ) +from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.responses import ErrorResponse, SuccessResponse router = APIRouter(prefix="/comments", tags=["Recipe: Comments"]) @@ -38,12 +39,18 @@ class RecipeCommentRoutes(BaseUserController): if comment.user_id != self.deps.acting_user.id and not self.deps.acting_user.admin: raise HTTPException( status_code=403, - detail=ErrorResponse.response(message="Comment does not belong to user"), + detail=ErrorResponse(message="Comment does not belong to user"), ) - @router.get("", response_model=list[RecipeCommentOut]) - def get_all(self, q: GetAll = Depends(GetAll)): - return self.repo.get_all(start=q.start, limit=q.limit, override=RecipeCommentOut) + @router.get("", response_model=RecipeCommentPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=RecipeCommentOut, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=RecipeCommentOut, status_code=201) def create_one(self, data: RecipeCommentCreate): diff --git a/mealie/routes/groups/controller_cookbooks.py b/mealie/routes/groups/controller_cookbooks.py index a371e75e6d9b..6f7a31289e9d 100644 --- a/mealie/routes/groups/controller_cookbooks.py +++ b/mealie/routes/groups/controller_cookbooks.py @@ -9,6 +9,8 @@ from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.routers import MealieCrudRoute from mealie.schema import mapper from mealie.schema.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook +from mealie.schema.cookbook.cookbook import CookBookPagination +from mealie.schema.response.pagination import PaginationQuery from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource from mealie.services.event_bus_service.message_types import EventTypes @@ -38,11 +40,15 @@ class GroupCookbookController(BaseUserController): self.registered_exceptions, ) - @router.get("", response_model=list[ReadCookBook]) - def get_all(self): - items = self.repo.get_all() - items.sort(key=lambda x: x.position) - return items + @router.get("", response_model=CookBookPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=ReadCookBook, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=ReadCookBook, status_code=201) def create_one(self, data: CreateCookBook): diff --git a/mealie/routes/groups/controller_group_notifications.py b/mealie/routes/groups/controller_group_notifications.py index f501344b91f6..8afa24979c91 100644 --- a/mealie/routes/groups/controller_group_notifications.py +++ b/mealie/routes/groups/controller_group_notifications.py @@ -13,9 +13,10 @@ from mealie.schema.group.group_events import ( GroupEventNotifierPrivate, GroupEventNotifierSave, GroupEventNotifierUpdate, + GroupEventPagination, ) from mealie.schema.mapper import cast -from mealie.schema.query import GetAll +from mealie.schema.response.pagination import PaginationQuery from mealie.services.event_bus_service.event_bus_service import EventBusService router = APIRouter( @@ -41,9 +42,15 @@ class GroupEventsNotifierController(BaseUserController): def mixins(self) -> HttpRepo: return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") - @router.get("", response_model=list[GroupEventNotifierOut]) - def get_all(self, q: GetAll = Depends(GetAll)): - return self.repo.get_all(start=q.start, limit=q.limit) + @router.get("", response_model=GroupEventPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=GroupEventNotifierOut, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=GroupEventNotifierOut, status_code=201) def create_one(self, data: GroupEventNotifierCreate): diff --git a/mealie/routes/groups/controller_labels.py b/mealie/routes/groups/controller_labels.py index 7d888f7e8f44..c8036934b5ff 100644 --- a/mealie/routes/groups/controller_labels.py +++ b/mealie/routes/groups/controller_labels.py @@ -14,8 +14,9 @@ from mealie.schema.labels import ( MultiPurposeLabelSummary, MultiPurposeLabelUpdate, ) +from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelPagination from mealie.schema.mapper import cast -from mealie.schema.query import GetAll +from mealie.schema.response.pagination import PaginationQuery router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"], route_class=MealieCrudRoute) @@ -36,9 +37,15 @@ class MultiPurposeLabelsController(BaseUserController): def mixins(self) -> HttpRepo: return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") - @router.get("", response_model=list[MultiPurposeLabelSummary]) - def get_all(self, q: GetAll = Depends(GetAll)): - return self.repo.get_all(start=q.start, limit=q.limit, override=MultiPurposeLabelSummary) + @router.get("", response_model=MultiPurposeLabelPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=MultiPurposeLabelSummary, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=MultiPurposeLabelOut) def create_one(self, data: MultiPurposeLabelCreate): diff --git a/mealie/routes/groups/controller_mealplan_rules.py b/mealie/routes/groups/controller_mealplan_rules.py index 58a57e905cdf..e3ea9ecac745 100644 --- a/mealie/routes/groups/controller_mealplan_rules.py +++ b/mealie/routes/groups/controller_mealplan_rules.py @@ -1,5 +1,6 @@ from functools import cached_property +from fastapi import Depends from pydantic import UUID4 from mealie.routes._base.base_controllers import BaseUserController @@ -7,7 +8,8 @@ from mealie.routes._base.controller import controller from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.routers import UserAPIRouter from mealie.schema import mapper -from mealie.schema.meal_plan.plan_rules import PlanRulesCreate, PlanRulesOut, PlanRulesSave +from mealie.schema.meal_plan.plan_rules import PlanRulesCreate, PlanRulesOut, PlanRulesPagination, PlanRulesSave +from mealie.schema.response.pagination import PaginationQuery router = UserAPIRouter(prefix="/groups/mealplans/rules", tags=["Groups: Mealplan Rules"]) @@ -22,9 +24,15 @@ class GroupMealplanConfigController(BaseUserController): def mixins(self): return HttpRepo[PlanRulesCreate, PlanRulesOut, PlanRulesOut](self.repo, self.deps.logger) - @router.get("", response_model=list[PlanRulesOut]) - def get_all(self): - return self.repo.get_all(override=PlanRulesOut) + @router.get("", response_model=PlanRulesPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=PlanRulesOut, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=PlanRulesOut, status_code=201) def create_one(self, data: PlanRulesCreate): diff --git a/mealie/routes/groups/controller_shopping_lists.py b/mealie/routes/groups/controller_shopping_lists.py index 6cd8f34d4a4a..438b5e60023e 100644 --- a/mealie/routes/groups/controller_shopping_lists.py +++ b/mealie/routes/groups/controller_shopping_lists.py @@ -6,27 +6,25 @@ from pydantic import UUID4 from mealie.routes._base.base_controllers import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import HttpRepo -from mealie.routes._base.routers import MealieCrudRoute from mealie.schema.group.group_shopping_list import ( ShoppingListCreate, ShoppingListItemCreate, ShoppingListItemOut, ShoppingListItemUpdate, ShoppingListOut, + ShoppingListPagination, ShoppingListSave, ShoppingListSummary, ShoppingListUpdate, ) from mealie.schema.mapper import cast -from mealie.schema.query import GetAll +from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.responses import SuccessResponse from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource from mealie.services.event_bus_service.message_types import EventTypes from mealie.services.group_services.shopping_lists import ShoppingListService -item_router = APIRouter( - prefix="/groups/shopping/items", tags=["Group: Shopping List Items"], route_class=MealieCrudRoute -) +item_router = APIRouter(prefix="/groups/shopping/items", tags=["Group: Shopping List Items"]) @controller(item_router) @@ -98,7 +96,6 @@ class ShoppingListItemController(BaseUserController): return shopping_list_item - @item_router.head("/{item_id}", response_model=ShoppingListItemOut) @item_router.get("/{item_id}", response_model=ShoppingListItemOut) def get_one(self, item_id: UUID4): return self.mixins.get_one(item_id) @@ -148,7 +145,7 @@ class ShoppingListItemController(BaseUserController): return shopping_list_item -router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"], route_class=MealieCrudRoute) +router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"]) @controller(router) @@ -170,9 +167,15 @@ class ShoppingListController(BaseUserController): def mixins(self) -> HttpRepo[ShoppingListCreate, ShoppingListOut, ShoppingListSave]: return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") - @router.get("", response_model=list[ShoppingListSummary]) - def get_all(self, q: GetAll = Depends(GetAll)): - return self.repo.get_all(start=q.start, limit=q.limit, override=ShoppingListSummary) + @router.get("", response_model=ShoppingListPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=ShoppingListSummary, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=ShoppingListOut, status_code=201) def create_one(self, data: ShoppingListCreate): @@ -193,7 +196,6 @@ class ShoppingListController(BaseUserController): return val - @router.head("/{item_id}", response_model=ShoppingListOut) @router.get("/{item_id}", response_model=ShoppingListOut) def get_one(self, item_id: UUID4): return self.mixins.get_one(item_id) diff --git a/mealie/routes/groups/controller_webhooks.py b/mealie/routes/groups/controller_webhooks.py index 7b1779811710..6dba4506cab4 100644 --- a/mealie/routes/groups/controller_webhooks.py +++ b/mealie/routes/groups/controller_webhooks.py @@ -7,8 +7,8 @@ from mealie.routes._base.base_controllers import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import HttpRepo from mealie.schema import mapper -from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook -from mealie.schema.query import GetAll +from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination +from mealie.schema.response.pagination import PaginationQuery router = APIRouter(prefix="/groups/webhooks", tags=["Groups: Webhooks"]) @@ -23,9 +23,15 @@ class ReadWebhookController(BaseUserController): def mixins(self) -> HttpRepo: return HttpRepo[CreateWebhook, SaveWebhook, CreateWebhook](self.repo, self.deps.logger) - @router.get("", response_model=list[ReadWebhook]) - def get_all(self, q: GetAll = Depends(GetAll)): - return self.repo.get_all(start=q.start, limit=q.limit, override=ReadWebhook) + @router.get("", response_model=WebhookPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=ReadWebhook, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=ReadWebhook, status_code=201) def create_one(self, data: CreateWebhook): diff --git a/mealie/routes/organizers/controller_categories.py b/mealie/routes/organizers/controller_categories.py index afdecfb5737c..4ad6b749e6c1 100644 --- a/mealie/routes/organizers/controller_categories.py +++ b/mealie/routes/organizers/controller_categories.py @@ -7,8 +7,9 @@ from mealie.routes._base import BaseUserController, controller from mealie.routes._base.mixins import HttpRepo from mealie.schema import mapper from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse -from mealie.schema.recipe.recipe import RecipeCategory +from mealie.schema.recipe.recipe import RecipeCategory, RecipeCategoryPagination from mealie.schema.recipe.recipe_category import CategoryBase, CategorySave +from mealie.schema.response.pagination import PaginationQuery from mealie.services import urls from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource from mealie.services.event_bus_service.message_types import EventTypes @@ -40,10 +41,16 @@ class RecipeCategoryController(BaseUserController): def mixins(self): return HttpRepo(self.repo, self.deps.logger) - @router.get("", response_model=list[RecipeCategory]) - def get_all(self): + @router.get("", response_model=RecipeCategoryPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): """Returns a list of available categories in the database""" - return self.repo.get_all(override=RecipeCategory) + response = self.repo.page_all( + pagination=q, + override=RecipeCategory, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", status_code=201) def create_one(self, category: CategoryIn): diff --git a/mealie/routes/organizers/controller_tags.py b/mealie/routes/organizers/controller_tags.py index 6fffbc6069dc..92c318ecb726 100644 --- a/mealie/routes/organizers/controller_tags.py +++ b/mealie/routes/organizers/controller_tags.py @@ -7,8 +7,9 @@ from mealie.routes._base import BaseUserController, controller from mealie.routes._base.mixins import HttpRepo from mealie.schema import mapper from mealie.schema.recipe import RecipeTagResponse, TagIn -from mealie.schema.recipe.recipe import RecipeTag +from mealie.schema.recipe.recipe import RecipeTag, RecipeTagPagination from mealie.schema.recipe.recipe_category import TagSave +from mealie.schema.response.pagination import PaginationQuery from mealie.services import urls from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource from mealie.services.event_bus_service.message_types import EventTypes @@ -29,10 +30,16 @@ class TagController(BaseUserController): def mixins(self): return HttpRepo(self.repo, self.deps.logger) - @router.get("") - async def get_all(self): + @router.get("", response_model=RecipeTagPagination) + async def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): """Returns a list of available tags in the database""" - return self.repo.get_all(override=RecipeTag) + response = self.repo.page_all( + pagination=q, + override=RecipeTag, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.get("/empty") def get_empty_tags(self): diff --git a/mealie/routes/organizers/controller_tools.py b/mealie/routes/organizers/controller_tools.py index e0fc744cdd00..70f17081ae54 100644 --- a/mealie/routes/organizers/controller_tools.py +++ b/mealie/routes/organizers/controller_tools.py @@ -7,9 +7,9 @@ from mealie.routes._base.base_controllers import BaseUserController from mealie.routes._base.controller import controller from mealie.routes._base.mixins import HttpRepo from mealie.schema import mapper -from mealie.schema.query import GetAll -from mealie.schema.recipe.recipe import RecipeTool +from mealie.schema.recipe.recipe import RecipeTool, RecipeToolPagination from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse, RecipeToolSave +from mealie.schema.response.pagination import PaginationQuery router = APIRouter(prefix="/tools", tags=["Organizer: Tools"]) @@ -24,9 +24,15 @@ class RecipeToolController(BaseUserController): def mixins(self) -> HttpRepo: return HttpRepo[RecipeToolCreate, RecipeTool, RecipeToolCreate](self.repo, self.deps.logger) - @router.get("", response_model=list[RecipeTool]) - def get_all(self, q: GetAll = Depends(GetAll)): - return self.repo.get_all(start=q.start, limit=q.limit, override=RecipeTool) + @router.get("", response_model=RecipeToolPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=RecipeTool, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=RecipeTool, status_code=201) def create_one(self, data: RecipeToolCreate): diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index d558a2cc9ba1..170fc76913f6 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -20,9 +20,8 @@ from mealie.repos.repository_recipes import RepositoryRecipes 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.query import GetAll from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe -from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary +from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, 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 @@ -53,10 +52,6 @@ class BaseRecipeController(BaseUserController): return HttpRepo[CreateRecipe, Recipe, Recipe](self.repo, self.deps.logger) -class RecipeGetAll(GetAll): - load_food: bool = False - - class FormatResponse(BaseModel): jjson: list[str] = Field(..., alias="json") zip: list[str] @@ -196,18 +191,16 @@ class RecipeController(BaseRecipeController): # CRUD Operations @router.get("", response_model=list[RecipeSummary]) - def get_all(self, q: RecipeGetAll = Depends(RecipeGetAll)): - items = self.repo.summary( - self.user.group_id, - start=q.start, - limit=q.limit, - load_foods=q.load_food, - order_by=q.order_by, - order_descending=q.order_descending, + def get_all(self, q: RecipePaginationQuery = Depends(RecipePaginationQuery)): + response = self.repo.page_all( + pagination=q, + load_food=q.load_food, ) + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + new_items = [] - for item in items: + for item in response.items: # Pydantic/FastAPI can't seem to serialize the ingredient field on thier own. new_item = item.__dict__ @@ -216,10 +209,11 @@ class RecipeController(BaseRecipeController): new_items.append(new_item) - json_compatible_item_data = jsonable_encoder(RecipeSummary.construct(**x) for x in new_items) + response.items = [RecipeSummary.construct(**x) for x in new_items] + json_compatible_response = jsonable_encoder(response) # Response is returned directly, to avoid validation and improve performance - return JSONResponse(content=json_compatible_item_data) + return JSONResponse(content=json_compatible_response) @router.get("/{slug}", response_model=Recipe) def get_one(self, slug: str): diff --git a/mealie/routes/unit_and_foods/foods.py b/mealie/routes/unit_and_foods/foods.py index b8fbdffd1399..4438ada0b55c 100644 --- a/mealie/routes/unit_and_foods/foods.py +++ b/mealie/routes/unit_and_foods/foods.py @@ -8,8 +8,14 @@ from mealie.routes._base.controller import controller from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.routers import MealieCrudRoute from mealie.schema import mapper -from mealie.schema.query import GetAll -from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood, MergeFood, SaveIngredientFood +from mealie.schema.recipe.recipe_ingredient import ( + CreateIngredientFood, + IngredientFood, + IngredientFoodPagination, + MergeFood, + SaveIngredientFood, +) +from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.responses import SuccessResponse router = APIRouter(prefix="/foods", tags=["Recipes: Foods"], route_class=MealieCrudRoute) @@ -38,9 +44,15 @@ class IngredientFoodsController(BaseUserController): self.deps.logger.error(e) raise HTTPException(500, "Failed to merge foods") from e - @router.get("", response_model=list[IngredientFood]) - def get_all(self, q: GetAll = Depends(GetAll)): - return self.repo.get_all(start=q.start, limit=q.limit, order_by=q.order_by, order_descending=q.order_descending) + @router.get("", response_model=IngredientFoodPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=IngredientFood, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=IngredientFood, status_code=201) def create_one(self, data: CreateIngredientFood): diff --git a/mealie/routes/unit_and_foods/units.py b/mealie/routes/unit_and_foods/units.py index f9e1e616fa5f..7203523f46fe 100644 --- a/mealie/routes/unit_and_foods/units.py +++ b/mealie/routes/unit_and_foods/units.py @@ -8,8 +8,14 @@ from mealie.routes._base.controller import controller from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.routers import MealieCrudRoute from mealie.schema import mapper -from mealie.schema.query import GetAll -from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit, MergeUnit, SaveIngredientUnit +from mealie.schema.recipe.recipe_ingredient import ( + CreateIngredientUnit, + IngredientUnit, + IngredientUnitPagination, + MergeUnit, + SaveIngredientUnit, +) +from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.responses import SuccessResponse router = APIRouter(prefix="/units", tags=["Recipes: Units"], route_class=MealieCrudRoute) @@ -38,9 +44,15 @@ class IngredientUnitsController(BaseUserController): self.deps.logger.error(e) raise HTTPException(500, "Failed to merge units") from e - @router.get("", response_model=list[IngredientUnit]) - def get_all(self, q: GetAll = Depends(GetAll)): - return self.repo.get_all(start=q.start, limit=q.limit, order_by=q.order_by, order_descending=q.order_descending) + @router.get("", response_model=IngredientUnitPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repo.page_all( + pagination=q, + override=IngredientUnit, + ) + + response.set_pagination_guides(router.url_path_for("get_all"), q.dict()) + return response @router.post("", response_model=IngredientUnit, status_code=201) def create_one(self, data: CreateIngredientUnit): diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py index d68d8c24e312..1a0560b61ba6 100644 --- a/mealie/routes/users/crud.py +++ b/mealie/routes/users/crud.py @@ -1,4 +1,4 @@ -from fastapi import HTTPException, status +from fastapi import Depends, HTTPException, status from pydantic import UUID4 from mealie.core.security import hash_password, verify_password @@ -8,7 +8,9 @@ from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.routers import AdminAPIRouter, UserAPIRouter from mealie.routes.users._helpers import assert_user_change_allowed from mealie.schema.response import ErrorResponse, SuccessResponse +from mealie.schema.response.pagination import PaginationQuery from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut +from mealie.schema.user.user import UserPagination user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"]) admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"]) @@ -20,9 +22,15 @@ class AdminUserController(BaseAdminController): def mixins(self) -> HttpRepo: return HttpRepo[UserIn, UserOut, UserBase](self.repos.users, self.deps.logger) - @admin_router.get("", response_model=list[UserOut]) - def get_all_users(self): - return self.repos.users.get_all() + @admin_router.get("", response_model=UserPagination) + def get_all(self, q: PaginationQuery = Depends(PaginationQuery)): + response = self.repos.users.page_all( + pagination=q, + override=UserOut, + ) + + response.set_pagination_guides(admin_router.url_path_for("get_all"), q.dict()) + return response @admin_router.post("", response_model=UserOut, status_code=201) def create_user(self, new_user: UserIn): diff --git a/mealie/schema/cookbook/cookbook.py b/mealie/schema/cookbook/cookbook.py index 63a032fdcecc..073fcc8d3b6d 100644 --- a/mealie/schema/cookbook/cookbook.py +++ b/mealie/schema/cookbook/cookbook.py @@ -3,6 +3,7 @@ from slugify import slugify from mealie.schema._mealie import MealieModel from mealie.schema.recipe.recipe import RecipeSummary, RecipeTool +from mealie.schema.response.pagination import PaginationBase from ..recipe.recipe_category import CategoryBase, TagBase @@ -51,6 +52,10 @@ class ReadCookBook(UpdateCookBook): orm_mode = True +class CookBookPagination(PaginationBase): + items: list[ReadCookBook] + + class RecipeCookBook(ReadCookBook): group_id: UUID4 recipes: list[RecipeSummary] diff --git a/mealie/schema/group/group_events.py b/mealie/schema/group/group_events.py index d5f78bd4151a..86513a99d13d 100644 --- a/mealie/schema/group/group_events.py +++ b/mealie/schema/group/group_events.py @@ -1,6 +1,7 @@ from pydantic import UUID4, NoneStr from mealie.schema._mealie import MealieModel +from mealie.schema.response.pagination import PaginationBase # ============================================================================= # Group Events Notifier Options @@ -83,6 +84,10 @@ class GroupEventNotifierOut(MealieModel): orm_mode = True +class GroupEventPagination(PaginationBase): + items: list[GroupEventNotifierOut] + + class GroupEventNotifierPrivate(GroupEventNotifierOut): apprise_url: str diff --git a/mealie/schema/group/group_shopping_list.py b/mealie/schema/group/group_shopping_list.py index cbd014523753..ca9036490554 100644 --- a/mealie/schema/group/group_shopping_list.py +++ b/mealie/schema/group/group_shopping_list.py @@ -7,6 +7,7 @@ from pydantic import UUID4 from mealie.schema._mealie import MealieModel from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit +from mealie.schema.response.pagination import PaginationBase class ShoppingListItemRecipeRef(MealieModel): @@ -84,6 +85,10 @@ class ShoppingListSummary(ShoppingListSave): orm_mode = True +class ShoppingListPagination(PaginationBase): + items: list[ShoppingListSummary] + + class ShoppingListUpdate(ShoppingListSummary): list_items: list[ShoppingListItemOut] = [] diff --git a/mealie/schema/group/webhook.py b/mealie/schema/group/webhook.py index d9847651ab2f..b72769862635 100644 --- a/mealie/schema/group/webhook.py +++ b/mealie/schema/group/webhook.py @@ -7,6 +7,7 @@ from pydantic import UUID4, validator from pydantic.datetime_parse import parse_datetime from mealie.schema._mealie import MealieModel +from mealie.schema.response.pagination import PaginationBase class WebhookType(str, enum.Enum): @@ -57,3 +58,7 @@ class ReadWebhook(SaveWebhook): class Config: orm_mode = True + + +class WebhookPagination(PaginationBase): + items: list[ReadWebhook] diff --git a/mealie/schema/labels/multi_purpose_label.py b/mealie/schema/labels/multi_purpose_label.py index fee425ec9b2d..9faea5d9879f 100644 --- a/mealie/schema/labels/multi_purpose_label.py +++ b/mealie/schema/labels/multi_purpose_label.py @@ -3,6 +3,7 @@ from __future__ import annotations from pydantic import UUID4 from mealie.schema._mealie import MealieModel +from mealie.schema.response.pagination import PaginationBase class MultiPurposeLabelCreate(MealieModel): @@ -25,6 +26,10 @@ class MultiPurposeLabelSummary(MultiPurposeLabelUpdate): orm_mode = True +class MultiPurposeLabelPagination(PaginationBase): + items: list[MultiPurposeLabelSummary] + + class MultiPurposeLabelOut(MultiPurposeLabelUpdate): # shopping_list_items: list[ShoppingListItemOut] = [] # foods: list[IngredientFood] = [] diff --git a/mealie/schema/meal_plan/plan_rules.py b/mealie/schema/meal_plan/plan_rules.py index 829d7c13f78b..4dd5f53d38da 100644 --- a/mealie/schema/meal_plan/plan_rules.py +++ b/mealie/schema/meal_plan/plan_rules.py @@ -4,6 +4,7 @@ from enum import Enum from pydantic import UUID4 from mealie.schema._mealie import MealieModel +from mealie.schema.response.pagination import PaginationBase class Category(MealieModel): @@ -63,3 +64,7 @@ class PlanRulesOut(PlanRulesSave): class Config: orm_mode = True + + +class PlanRulesPagination(PaginationBase): + items: list[PlanRulesOut] diff --git a/mealie/schema/query.py b/mealie/schema/query.py deleted file mode 100644 index d0342e5c5880..000000000000 --- a/mealie/schema/query.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Optional - -from mealie.schema._mealie import MealieModel - - -class GetAll(MealieModel): - start: int = 0 - limit: int = 999 - order_by: Optional[str] - order_descending: Optional[bool] = True diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 5015b07c72ab..7b0238cc284f 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -12,6 +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 .recipe_asset import RecipeAsset from .recipe_comments import RecipeCommentOut @@ -32,15 +33,27 @@ class RecipeTag(MealieModel): orm_mode = True +class RecipeTagPagination(PaginationBase): + items: list[RecipeTag] + + class RecipeCategory(RecipeTag): pass +class RecipeCategoryPagination(PaginationBase): + items: list[RecipeCategory] + + class RecipeTool(RecipeTag): id: UUID4 on_hand: bool = False +class RecipeToolPagination(PaginationBase): + items: list[RecipeTool] + + class CreateRecipeBulk(BaseModel): url: str categories: list[RecipeCategory] = None @@ -114,6 +127,14 @@ class RecipeSummary(MealieModel): return user_id +class RecipePaginationQuery(PaginationQuery): + load_food: bool = False + + +class RecipePagination(PaginationBase): + items: list[RecipeSummary] + + class Recipe(RecipeSummary): recipe_ingredient: list[RecipeIngredient] = [] recipe_instructions: Optional[list[RecipeStep]] = [] diff --git a/mealie/schema/recipe/recipe_comments.py b/mealie/schema/recipe/recipe_comments.py index 996fa4c6f4c2..54a9f4207270 100644 --- a/mealie/schema/recipe/recipe_comments.py +++ b/mealie/schema/recipe/recipe_comments.py @@ -4,6 +4,7 @@ from typing import Optional from pydantic import UUID4 from mealie.schema._mealie import MealieModel +from mealie.schema.response.pagination import PaginationBase class UserBase(MealieModel): @@ -39,3 +40,7 @@ class RecipeCommentOut(RecipeCommentCreate): class Config: orm_mode = True + + +class RecipeCommentPagination(PaginationBase): + items: list[RecipeCommentOut] diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index e779d9cbd906..280c8889a594 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -8,6 +8,7 @@ from pydantic import UUID4, Field, validator from mealie.schema._mealie import MealieModel from mealie.schema._mealie.types import NoneFloat +from mealie.schema.response.pagination import PaginationBase class UnitFoodBase(MealieModel): @@ -31,6 +32,10 @@ class IngredientFood(CreateIngredientFood): orm_mode = True +class IngredientFoodPagination(PaginationBase): + items: list[IngredientFood] + + class CreateIngredientUnit(UnitFoodBase): fraction: bool = True abbreviation: str = "" @@ -48,6 +53,10 @@ class IngredientUnit(CreateIngredientUnit): orm_mode = True +class IngredientUnitPagination(PaginationBase): + items: list[IngredientUnit] + + class RecipeIngredient(MealieModel): title: Optional[str] note: Optional[str] diff --git a/mealie/schema/response/pagination.py b/mealie/schema/response/pagination.py index a8f5c9dbf179..816d642d9e32 100644 --- a/mealie/schema/response/pagination.py +++ b/mealie/schema/response/pagination.py @@ -1,9 +1,13 @@ import enum -from typing import Generic, TypeVar +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.generics import GenericModel +from mealie.schema._mealie import MealieModel + DataT = TypeVar("DataT", bound=BaseModel) @@ -12,11 +16,11 @@ class OrderDirection(str, enum.Enum): desc = "desc" -class PaginationQuery(BaseModel): +class PaginationQuery(MealieModel): page: int = 1 + per_page: int = 50 order_by: str = "created_at" order_direction: OrderDirection = OrderDirection.desc - per_page: int = 50 class PaginationBase(GenericModel, Generic[DataT]): @@ -24,4 +28,45 @@ class PaginationBase(GenericModel, Generic[DataT]): per_page: int = 10 total: int = 0 total_pages: int = 0 - data: list[DataT] + items: list[DataT] + next: str | None + previous: str | None + + def _set_next(self, route: str, query_params: dict[str, Any]) -> None: + if self.page >= self.total_pages: + self.next = None + return + + # combine params with base route + query_params["page"] = self.page + 1 + self.next = PaginationBase.merge_query_parameters(route, query_params) + + def _set_prev(self, route: str, query_params: dict[str, Any]) -> None: + if self.page <= 1: + self.previous = None + return + + # combine params with base route + query_params["page"] = self.page - 1 + self.previous = PaginationBase.merge_query_parameters(route, query_params) + + def set_pagination_guides(self, route: str, query_params: dict[str, Any] | None) -> None: + if not query_params: + query_params = {} + + query_params = camelize(query_params) + + # sanitize user input + self.page = max(self.page, 1) + self._set_next(route, query_params) + self._set_prev(route, query_params) + + @staticmethod + def merge_query_parameters(url: str, params: dict[str, Any]): + scheme, netloc, path, query_string, fragment = urlsplit(url) + + query_params = parse_qs(query_string) + query_params.update(params) + new_query_string = urlencode(query_params, doseq=True) + + return urlunsplit((scheme, netloc, path, new_query_string, fragment)) diff --git a/mealie/schema/server/tasks.py b/mealie/schema/server/tasks.py index 9ea4e5ce51a1..20b5faa4cf18 100644 --- a/mealie/schema/server/tasks.py +++ b/mealie/schema/server/tasks.py @@ -5,6 +5,7 @@ from uuid import UUID from pydantic import Field from mealie.schema._mealie import MealieModel +from mealie.schema.response.pagination import PaginationBase class ServerTaskNames(str, enum.Enum): @@ -45,3 +46,7 @@ class ServerTask(ServerTaskCreate): class Config: orm_mode = True + + +class ServerTaskPagination(PaginationBase): + items: list[ServerTask] diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py index 2cfc7eaaf29a..19e656d40ee4 100644 --- a/mealie/schema/user/user.py +++ b/mealie/schema/user/user.py @@ -11,6 +11,7 @@ from mealie.db.models.users import User from mealie.schema._mealie import MealieModel from mealie.schema.group.group_preferences import ReadGroupPreferences from mealie.schema.recipe import RecipeSummary +from mealie.schema.response.pagination import PaginationBase from ..recipe import CategoryBase @@ -113,6 +114,10 @@ class UserOut(UserBase): } +class UserPagination(PaginationBase): + items: list[UserOut] + + class UserFavorites(UserBase): favorite_recipes: list[RecipeSummary] = [] # type: ignore @@ -180,6 +185,10 @@ class GroupInDB(UpdateGroup): return GroupInDB.get_export_directory(self.id) +class GroupPagination(PaginationBase): + items: list[GroupInDB] + + class LongLiveTokenInDB(CreateToken): id: int user: PrivateUser diff --git a/tests/integration_tests/admin_tests/test_admin_background_tasks.py b/tests/integration_tests/admin_tests/test_admin_background_tasks.py index ae0e813b1a0e..e29ea9644a58 100644 --- a/tests/integration_tests/admin_tests/test_admin_background_tasks.py +++ b/tests/integration_tests/admin_tests/test_admin_background_tasks.py @@ -10,13 +10,13 @@ class Routes: def test_admin_server_tasks_test_and_get(api_client: TestClient, admin_user: TestUser): # Bootstrap Timer - BackgroundExecutor.sleep_time = 0.1 + BackgroundExecutor.sleep_time = 1 response = api_client.post(Routes.base, headers=admin_user.token) assert response.status_code == 201 response = api_client.get(Routes.base, headers=admin_user.token) - as_dict = response.json() + as_dict = response.json()["items"] assert len(as_dict) == 1 diff --git a/tests/integration_tests/user_group_tests/test_group_cookbooks.py b/tests/integration_tests/user_group_tests/test_group_cookbooks.py index 53019c3df790..45de69fe833e 100644 --- a/tests/integration_tests/user_group_tests/test_group_cookbooks.py +++ b/tests/integration_tests/user_group_tests/test_group_cookbooks.py @@ -111,7 +111,7 @@ def test_update_cookbooks_many(api_client: TestClient, unique_user: TestUser, co known_ids = [x.id for x in cookbooks] - server_ids = [x["id"] for x in response.json()] + server_ids = [x["id"] for x in response.json()["items"]] for know in known_ids: # Hacky check, because other tests don't cleanup after themselves :( assert str(know) in server_ids diff --git a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py index a45c336ad66d..779863e381f1 100644 --- a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py +++ b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py @@ -22,7 +22,7 @@ class Routes: def test_shopping_lists_get_all(api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]): all_lists = api_client.get(Routes.base, headers=unique_user.token) assert all_lists.status_code == 200 - all_lists = all_lists.json() + all_lists = all_lists.json()["items"] assert len(all_lists) == len(shopping_lists) diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py index aefc18d54f35..4b124cd4696e 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py @@ -44,7 +44,7 @@ def test_get_all_only_includes_group_recipes(api_client: TestClient, unique_user assert response.status_code == 200 - recipes = response.json() + recipes = response.json()["items"] assert len(recipes) == 5 diff --git a/tests/multitenant_tests/test_multitenant_cases.py b/tests/multitenant_tests/test_multitenant_cases.py index 7d7dd64019a0..01bb6278ce17 100644 --- a/tests/multitenant_tests/test_multitenant_cases.py +++ b/tests/multitenant_tests/test_multitenant_cases.py @@ -47,7 +47,7 @@ def test_multitenant_cases_get_all( response = test_case.get_all(token) assert response.status_code == 200 - data = response.json() + data = response.json()["items"] assert len(data) == len(item_ids) @@ -84,7 +84,7 @@ def test_multitenant_cases_same_named_resources( response = test_case.get_all(token) assert response.status_code == 200 - data = response.json() + data = response.json()["items"] assert len(data) == len(item_ids) diff --git a/tests/unit_tests/repository_tests/test_pagination.py b/tests/unit_tests/repository_tests/test_pagination.py index 7661055815f3..9d7040bd463d 100644 --- a/tests/unit_tests/repository_tests/test_pagination.py +++ b/tests/unit_tests/repository_tests/test_pagination.py @@ -1,3 +1,8 @@ +from random import randint +from urllib.parse import parse_qsl, urlsplit + +from humps import camelize + from mealie.repos.repository_factory import AllRepositories from mealie.schema.response.pagination import PaginationQuery from mealie.services.seeder.seeder_service import SeederService @@ -21,18 +26,98 @@ def test_repository_pagination(database: AllRepositories, unique_user: TestUser) seen = [] for _ in range(10): - results = foods_repo.pagination(query) + results = foods_repo.page_all(query) - assert len(results.data) == 10 + assert len(results.items) == 10 - for result in results.data: + for result in results.items: assert result.id not in seen - seen += [result.id for result in results.data] + seen += [result.id for result in results.items] query.page += 1 - results = foods_repo.pagination(query) + results = foods_repo.page_all(query) - for result in results.data: + for result in results.items: assert result.id not in seen + + +def test_pagination_response_and_metadata(database: AllRepositories, unique_user: TestUser): + group = database.groups.get_one(unique_user.group_id) + + seeder = SeederService(database, None, group) + seeder.seed_foods("en-US") + + foods_repo = database.ingredient_foods.by_group(unique_user.group_id) # type: ignore + + # this should get all results + query = PaginationQuery( + page=1, + per_page=-1, + ) + + all_results = foods_repo.page_all(query) + assert all_results.total == len(all_results.items) + + # this should get the last page of results + query = PaginationQuery( + page=-1, + per_page=1, + ) + + last_page_of_results = foods_repo.page_all(query) + assert last_page_of_results.page == last_page_of_results.total_pages + assert last_page_of_results.items[-1] == all_results.items[-1] + + +def test_pagination_guides(database: AllRepositories, unique_user: TestUser): + group = database.groups.get_one(unique_user.group_id) + + seeder = SeederService(database, None, group) + seeder.seed_foods("en-US") + + foods_repo = database.ingredient_foods.by_group(unique_user.group_id) # type: ignore + foods_route = ( + "/foods" # this doesn't actually have to be accurate, it's just a placeholder to test for query params + ) + + query = PaginationQuery( + page=1, + per_page=1, + ) + + first_page_of_results = foods_repo.page_all(query) + first_page_of_results.set_pagination_guides(foods_route, query.dict()) + assert first_page_of_results.next is not None + assert first_page_of_results.previous is None + + query = PaginationQuery( + page=-1, + per_page=1, + ) + + last_page_of_results = foods_repo.page_all(query) + last_page_of_results.set_pagination_guides(foods_route, query.dict()) + assert last_page_of_results.next is None + assert last_page_of_results.previous is not None + + random_page = randint(2, first_page_of_results.total_pages - 1) + query = PaginationQuery( + page=random_page, + per_page=1, + ) + + random_page_of_results = foods_repo.page_all(query) + random_page_of_results.set_pagination_guides(foods_route, query.dict()) + + next_params = dict(parse_qsl(urlsplit(random_page_of_results.next).query)) + assert int(next_params["page"]) == random_page + 1 + + prev_params = dict(parse_qsl(urlsplit(random_page_of_results.previous).query)) + assert int(prev_params["page"]) == random_page - 1 + + source_params = camelize(query.dict()) + for source_param in source_params: + assert source_param in next_params + assert source_param in prev_params