From 2809cef3b1d2ef5c50d31c9848b6b09d31905a71 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Sat, 2 Jul 2022 12:44:01 -0500 Subject: [PATCH] fix: mealplan pagination (#1464) * added pagination to get_slice route * updated mealplan tests * renamed vars to match pagination query --- frontend/composables/use-group-mealplan.ts | 12 ++-- mealie/repos/repository_meals.py | 69 +++++++++++++++++-- mealie/routes/groups/controller_mealplan.py | 22 ++++-- mealie/schema/meal_plan/new_meal.py | 5 ++ .../user_group_tests/test_group_mealplan.py | 23 ++++--- 5 files changed, 102 insertions(+), 29 deletions(-) diff --git a/frontend/composables/use-group-mealplan.ts b/frontend/composables/use-group-mealplan.ts index 00d230337..9f590cef4 100644 --- a/frontend/composables/use-group-mealplan.ts +++ b/frontend/composables/use-group-mealplan.ts @@ -26,11 +26,11 @@ export const useMealplans = function (range: Ref) { loading.value = true; const units = useAsync(async () => { const query = { - start: format(range.value.start, "yyyy-MM-dd"), - limit: format(range.value.end, "yyyy-MM-dd"), + start_date: format(range.value.start, "yyyy-MM-dd"), + end_date: 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(1, -1, { start: query.start, limit: query.limit }); + const { data } = await api.mealplans.getAll(1, -1, { start_date: query.start_date, end_date: query.end_date }); if (data) { return data.items; @@ -45,11 +45,11 @@ export const useMealplans = function (range: Ref) { async refreshAll(this: void) { loading.value = true; const query = { - start: format(range.value.start, "yyyy-MM-dd"), - limit: format(range.value.end, "yyyy-MM-dd"), + start_date: format(range.value.start, "yyyy-MM-dd"), + end_date: 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(1, -1, { start: query.start, limit: query.limit }); + const { data } = await api.mealplans.getAll(1, -1, { start_date: query.start_date, end_date: query.end_date }); if (data && data.items) { mealplans.value = data.items; diff --git a/mealie/repos/repository_meals.py b/mealie/repos/repository_meals.py index a1a9a5078..f594ac4b8 100644 --- a/mealie/repos/repository_meals.py +++ b/mealie/repos/repository_meals.py @@ -1,8 +1,13 @@ from datetime import date +from math import ceil from uuid import UUID +from sqlalchemy import func +from sqlalchemy.sql import sqltypes + from mealie.db.models.group import GroupMealPlan -from mealie.schema.meal_plan.new_meal import ReadPlanEntry +from mealie.schema.meal_plan.new_meal import PlanEntryPagination, ReadPlanEntry +from mealie.schema.response.pagination import OrderDirection, PaginationQuery from .repository_generic import RepositoryGeneric @@ -11,15 +16,67 @@ class RepositoryMeals(RepositoryGeneric[ReadPlanEntry, GroupMealPlan]): def by_group(self, group_id: UUID) -> "RepositoryMeals": return super().by_group(group_id) # type: ignore - def get_slice(self, start: date, end: date, group_id: UUID) -> list[ReadPlanEntry]: - start_str = start.strftime("%Y-%m-%d") - end_str = end.strftime("%Y-%m-%d") - qry = self.session.query(GroupMealPlan).filter( + def get_slice( + self, pagination: PaginationQuery, start_date: date, end_date: date, group_id: UUID + ) -> PlanEntryPagination: + start_str = start_date.strftime("%Y-%m-%d") + end_str = end_date.strftime("%Y-%m-%d") + + # get the total number of documents + q = self.session.query(GroupMealPlan).filter( GroupMealPlan.date.between(start_str, end_str), GroupMealPlan.group_id == group_id, ) - return [self.schema.from_orm(x) for x in qry.all()] + 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 = [self.schema.from_orm(x) for x in q.all()] + except Exception as e: + self._log_exception(e) + self.session.rollback() + raise e + + return PlanEntryPagination( + page=pagination.page, + per_page=pagination.per_page, + total=count, + total_pages=total_pages, + items=data, + ) def get_today(self, group_id: UUID) -> list[ReadPlanEntry]: today = date.today() diff --git a/mealie/routes/groups/controller_mealplan.py b/mealie/routes/groups/controller_mealplan.py index 6e00ac188..32e18cb3c 100644 --- a/mealie/routes/groups/controller_mealplan.py +++ b/mealie/routes/groups/controller_mealplan.py @@ -1,7 +1,8 @@ from datetime import date, timedelta from functools import cached_property +from typing import Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from mealie.core.exceptions import mealie_registered_exceptions from mealie.repos.repository_meals import RepositoryMeals @@ -9,9 +10,10 @@ from mealie.routes._base import BaseUserController, controller from mealie.routes._base.mixins import HttpRepo from mealie.schema import mapper from mealie.schema.meal_plan import CreatePlanEntry, ReadPlanEntry, SavePlanEntry, UpdatePlanEntry -from mealie.schema.meal_plan.new_meal import CreateRandomEntry +from mealie.schema.meal_plan.new_meal import CreateRandomEntry, PlanEntryPagination from mealie.schema.meal_plan.plan_rules import PlanRulesDay from mealie.schema.recipe.recipe import Recipe +from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.responses import ErrorResponse router = APIRouter(prefix="/groups/mealplans", tags=["Groups: Mealplans"]) @@ -85,11 +87,17 @@ class GroupMealplanController(BaseUserController): except IndexError: raise HTTPException(status_code=404, detail=ErrorResponse.respond(message="No recipes match your rules")) - @router.get("", response_model=list[ReadPlanEntry]) - def get_all(self, start: date = None, limit: date = None): - start = start or date.today() - timedelta(days=999) - limit = limit or date.today() + timedelta(days=999) - return self.repo.get_slice(start, limit, group_id=self.group.id) + @router.get("", response_model=PlanEntryPagination) + def get_all( + self, + q: PaginationQuery = Depends(PaginationQuery), + start_date: Optional[date] = None, + end_date: Optional[date] = None, + ): + start_date = start_date or date.today() - timedelta(days=999) + end_date = end_date or date.today() + timedelta(days=999) + + return self.repo.get_slice(pagination=q, start_date=start_date, end_date=end_date, group_id=self.group.id) @router.post("", response_model=ReadPlanEntry, status_code=201) def create_one(self, data: CreatePlanEntry): diff --git a/mealie/schema/meal_plan/new_meal.py b/mealie/schema/meal_plan/new_meal.py index 0fa29e03f..221bfb5aa 100644 --- a/mealie/schema/meal_plan/new_meal.py +++ b/mealie/schema/meal_plan/new_meal.py @@ -7,6 +7,7 @@ from pydantic import validator from mealie.schema._mealie import MealieModel from mealie.schema.recipe.recipe import RecipeSummary +from mealie.schema.response.pagination import PaginationBase class PlanEntryType(str, Enum): @@ -54,3 +55,7 @@ class ReadPlanEntry(UpdatePlanEntry): class Config: orm_mode = True + + +class PlanEntryPagination(PaginationBase): + items: list[ReadPlanEntry] diff --git a/tests/integration_tests/user_group_tests/test_group_mealplan.py b/tests/integration_tests/user_group_tests/test_group_mealplan.py index 2d153a68c..ab39f9d5a 100644 --- a/tests/integration_tests/user_group_tests/test_group_mealplan.py +++ b/tests/integration_tests/user_group_tests/test_group_mealplan.py @@ -12,14 +12,17 @@ class Routes: recipe = "/api/recipes" today = "/api/groups/mealplans/today" - def all_slice(start: str, end: str): - return f"{Routes.base}?start={start}&limit={end}" + @staticmethod + def all_slice(page: int, perPage: int, start_date: str, end_date: str): + return f"{Routes.base}?page={page}&perPage={perPage}&start_date={start_date}&end_date={end_date}" + @staticmethod def item(item_id: int) -> str: return f"{Routes.base}/{item_id}" - def recipe_slug(recipe_id: int) -> str: - return f"{Routes.recipe}/{recipe_id}" + @staticmethod + def recipe_slug(recipe_name: str) -> str: + return f"{Routes.recipe}/{recipe_name}" def test_create_mealplan_no_recipe(api_client: TestClient, unique_user: TestUser): @@ -106,10 +109,10 @@ def test_get_all_mealplans(api_client: TestClient, unique_user: TestUser): response = api_client.post(Routes.base, json=new_plan, headers=unique_user.token) assert response.status_code == 201 - response = api_client.get(Routes.base, headers=unique_user.token) + response = api_client.get(Routes.base, headers=unique_user.token, params={"page": 1, "perPage": -1}) assert response.status_code == 200 - assert len(response.json()) >= 3 + assert len(response.json()["items"]) >= 3 def test_get_slice_mealplans(api_client: TestClient, unique_user: TestUser): @@ -132,15 +135,15 @@ def test_get_slice_mealplans(api_client: TestClient, unique_user: TestUser): slices = [dates, dates[1:2], dates[2:3], dates[3:4], dates[4:5]] for date_range in slices: - start = date_range[0].strftime("%Y-%m-%d") - end = date_range[-1].strftime("%Y-%m-%d") + start_date = date_range[0].strftime("%Y-%m-%d") + end_date = date_range[-1].strftime("%Y-%m-%d") - response = api_client.get(Routes.all_slice(start, end), headers=unique_user.token) + response = api_client.get(Routes.all_slice(1, -1, start_date, end_date), headers=unique_user.token) assert response.status_code == 200 response_json = response.json() - for meal_plan in response_json: + for meal_plan in response_json["items"]: assert meal_plan["date"] in [date.strftime("%Y-%m-%d") for date in date_range]