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>
This commit is contained in:
Michael Genson 2022-06-25 14:39:38 -05:00 committed by GitHub
parent c158672d12
commit cb15db2d27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 683 additions and 197 deletions

View File

@ -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"

View File

@ -1,4 +1,4 @@
import { ApiRequestInstance } from "~/types/api";
import { ApiRequestInstance, PaginationData } from "~/types/api";
export interface CrudAPIInterface {
requests: ApiRequestInstance;
@ -22,9 +22,9 @@ export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType=CreateType> e
abstract baseRoute: string;
abstract itemRoute(itemId: string | number): string;
async getAll(start = 0, limit = 9999, params = {} as any) {
return await this.requests.get<ReadType[]>(this.baseRoute, {
params: { start, limit, ...params },
async getAll(page = 1, perPage = -1, params = {} as any) {
return await this.requests.get<PaginationData<ReadType>>(this.baseRoute, {
params: { page, perPage, ...params },
});
}

View File

@ -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 ?? [];
}
}

View File

@ -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 ?? [];
}
}

View File

@ -30,11 +30,15 @@ export function useStoreActions<T extends BoundT>(
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<T extends BoundT>(
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;

View File

@ -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;

View File

@ -18,8 +18,8 @@ function swap(t: Array<unknown>, i: number, j: number) {
export const useSorter = () => {
function sortAToZ(list: Array<Recipe>) {
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<Recipe[]>([]);
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;
}
}

View File

@ -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;

View File

@ -30,9 +30,13 @@ export const useMealplans = function (range: Ref<DateRange>) {
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<DateRange>) {
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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -126,7 +126,7 @@ export default defineComponent({
const { data } = await api.mealplanRules.getAll();
if (data) {
allRules.value = data;
allRules.value = data.items ?? [];
}
}

View File

@ -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);

View File

@ -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();

View File

@ -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() {

View File

@ -13,3 +13,11 @@ export interface ApiRequestInstance {
patch<T, U = Partial<T>>(url: string, data: U): Promise<RequestResponse<T>>;
delete<T>(url: string): Promise<RequestResponse<T>>;
}
export interface PaginationData<T> {
page: number;
per_page: number;
total: number;
total_pages: number;
items: T[];
}

View File

@ -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],
)

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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)

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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]

View File

@ -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

View File

@ -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] = []

View File

@ -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]

View File

@ -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] = []

View File

@ -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]

View File

@ -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

View File

@ -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]] = []

View File

@ -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]

View File

@ -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]

View File

@ -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))

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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