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 - mealie-next
jobs: jobs:
ci: lint:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
@ -47,3 +47,42 @@ jobs:
- name: Run linter 👀 - name: Run linter 👀
run: yarn lint run: yarn lint
working-directory: "frontend" 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 { export interface CrudAPIInterface {
requests: ApiRequestInstance; requests: ApiRequestInstance;
@ -18,13 +18,13 @@ export abstract class BaseAPI {
} }
} }
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType=CreateType> extends BaseAPI implements CrudAPIInterface { export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType> extends BaseAPI implements CrudAPIInterface {
abstract baseRoute: string; abstract baseRoute: string;
abstract itemRoute(itemId: string | number): string; abstract itemRoute(itemId: string | number): string;
async getAll(start = 0, limit = 9999, params = {} as any) { async getAll(page = 1, perPage = -1, params = {} as any) {
return await this.requests.get<ReadType[]>(this.baseRoute, { return await this.requests.get<PaginationData<ReadType>>(this.baseRoute, {
params: { start, limit, ...params }, params: { page, perPage, ...params },
}); });
} }

View File

@ -261,7 +261,7 @@ export default defineComponent({
async function getShoppingLists() { async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(); const { data } = await api.shopping.lists.getAll();
if (data) { if (data) {
shoppingLists.value = data; shoppingLists.value = data.items ?? [];
} }
} }

View File

@ -131,10 +131,10 @@ export default defineComponent({
} }
async function refreshTokens() { 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) { 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 allItems = useAsync(async () => {
const { data } = await api.getAll(); const { data } = await api.getAll();
if (allRef) { if (data && allRef) {
allRef.value = data; allRef.value = data.items;
} }
return data ?? []; if (data) {
return data.items ?? [];
} else {
return [];
}
}, useAsyncKey()); }, useAsyncKey());
loading.value = false; loading.value = false;
@ -45,8 +49,8 @@ export function useStoreActions<T extends BoundT>(
loading.value = true; loading.value = true;
const { data } = await api.getAll(); const { data } = await api.getAll();
if (data && allRef) { if (data && data.items && allRef) {
allRef.value = data; allRef.value = data.items;
} }
loading.value = false; loading.value = false;

View File

@ -21,7 +21,12 @@ export const useTools = function (eager = true) {
loading.value = true; loading.value = true;
const units = useAsync(async () => { const units = useAsync(async () => {
const { data } = await api.tools.getAll(); const { data } = await api.tools.getAll();
return data;
if (data) {
return data.items;
} else {
return null;
}
}, useAsyncKey()); }, useAsyncKey());
loading.value = false; loading.value = false;
@ -33,7 +38,7 @@ export const useTools = function (eager = true) {
const { data } = await api.tools.getAll(); const { data } = await api.tools.getAll();
if (data) { if (data) {
tools.value = data; tools.value = data.items;
} }
loading.value = false; loading.value = false;

View File

@ -18,8 +18,8 @@ function swap(t: Array<unknown>, i: number, j: number) {
export const useSorter = () => { export const useSorter = () => {
function sortAToZ(list: Array<Recipe>) { function sortAToZ(list: Array<Recipe>) {
list.sort((a, b) => { list.sort((a, b) => {
const textA = a.name?.toUpperCase() ?? ""; const textA: string = a.name?.toUpperCase() ?? "";
const textB = b.name?.toUpperCase() ?? ""; const textB: string = b.name?.toUpperCase() ?? "";
return textA < textB ? -1 : textA > textB ? 1 : 0; return textA < textB ? -1 : textA > textB ? 1 : 0;
}); });
} }
@ -61,10 +61,10 @@ export const useLazyRecipes = function () {
const recipes = ref<Recipe[]>([]); const recipes = ref<Recipe[]>([]);
async function fetchMore(start: number, limit: number, orderBy: string | null = null, orderDescending = true) { async function fetchMore(page: number, perPage: number, orderBy: string | null = null, orderDirection = "desc") {
const { data } = await api.recipes.getAll(start, limit, { orderBy, orderDescending }); const { data } = await api.recipes.getAll(page, perPage, { orderBy, orderDirection });
if (data) { if (data) {
data.forEach((recipe) => { data.items.forEach((recipe) => {
recipes.value?.push(recipe); recipes.value?.push(recipe);
}); });
} }
@ -80,26 +80,26 @@ export const useRecipes = (all = false, fetchRecipes = true) => {
const api = useUserApi(); const api = useUserApi();
// recipes is non-reactive!! // recipes is non-reactive!!
const { recipes, start, end } = (() => { const { recipes, page, perPage } = (() => {
if (all) { if (all) {
return { return {
recipes: allRecipes, recipes: allRecipes,
start: 0, page: 1,
end: 9999, perPage: -1,
}; };
} else { } else {
return { return {
recipes: recentRecipes, recipes: recentRecipes,
start: 0, page: 1,
end: 30, perPage: 30,
}; };
} }
})(); })();
async function refreshRecipes() { 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) { if (data) {
recipes.value = data; recipes.value = data.items;
} }
} }

View File

@ -31,7 +31,11 @@ export const useCookbooks = function () {
const units = useAsync(async () => { const units = useAsync(async () => {
const { data } = await api.cookbooks.getAll(); const { data } = await api.cookbooks.getAll();
return data; if (data) {
return data.items;
} else {
return null;
}
}, useAsyncKey()); }, useAsyncKey());
loading.value = false; loading.value = false;
@ -41,8 +45,8 @@ export const useCookbooks = function () {
loading.value = true; loading.value = true;
const { data } = await api.cookbooks.getAll(); const { data } = await api.cookbooks.getAll();
if (data && cookbookStore) { if (data && data.items && cookbookStore) {
cookbookStore.value = data; cookbookStore.value = data.items;
} }
loading.value = false; loading.value = false;

View File

@ -30,9 +30,13 @@ export const useMealplans = function (range: Ref<DateRange>) {
limit: format(range.value.end, "yyyy-MM-dd"), limit: format(range.value.end, "yyyy-MM-dd"),
}; };
// @ts-ignore TODO Modify typing to allow for string start+limit for mealplans // @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()); }, useAsyncKey());
loading.value = false; loading.value = false;
@ -45,10 +49,10 @@ export const useMealplans = function (range: Ref<DateRange>) {
limit: format(range.value.end, "yyyy-MM-dd"), limit: format(range.value.end, "yyyy-MM-dd"),
}; };
// @ts-ignore TODO Modify typing to allow for string start+limit for mealplans // @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) { if (data && data.items) {
mealplans.value = data; mealplans.value = data.items;
} }
loading.value = false; loading.value = false;

View File

@ -14,7 +14,11 @@ export const useGroupWebhooks = function () {
const units = useAsync(async () => { const units = useAsync(async () => {
const { data } = await api.groupWebhooks.getAll(); const { data } = await api.groupWebhooks.getAll();
return data; if (data) {
return data.items;
} else {
return null;
}
}, useAsyncKey()); }, useAsyncKey());
loading.value = false; loading.value = false;
@ -24,8 +28,8 @@ export const useGroupWebhooks = function () {
loading.value = true; loading.value = true;
const { data } = await api.groupWebhooks.getAll(); const { data } = await api.groupWebhooks.getAll();
if (data) { if (data && data.items) {
webhooks.value = data; webhooks.value = data.items;
} }
loading.value = false; loading.value = false;

View File

@ -43,7 +43,12 @@ export const useGroups = function () {
const asyncKey = String(Date.now()); const asyncKey = String(Date.now());
const groups = useAsync(async () => { const groups = useAsync(async () => {
const { data } = await api.groups.getAll(); const { data } = await api.groups.getAll();
return data;
if (data) {
return data.items;
} else {
return null;
}
}, asyncKey); }, asyncKey);
loading.value = false; loading.value = false;
@ -53,7 +58,13 @@ export const useGroups = function () {
async function refreshAllGroups() { async function refreshAllGroups() {
loading.value = true; loading.value = true;
const { data } = await api.groups.getAll(); const { data } = await api.groups.getAll();
groups.value = data;
if (data) {
groups.value = data.items;
} else {
groups.value = null;
}
loading.value = false; loading.value = false;
} }

View File

@ -17,7 +17,11 @@ export const useAllUsers = function () {
const asyncKey = String(Date.now()); const asyncKey = String(Date.now());
const allUsers = useAsync(async () => { const allUsers = useAsync(async () => {
const { data } = await api.users.getAll(); const { data } = await api.users.getAll();
return data; if (data) {
return data.items;
} else {
return null;
}
}, asyncKey); }, asyncKey);
loading.value = false; loading.value = false;
@ -27,7 +31,13 @@ export const useAllUsers = function () {
async function refreshAllUsers() { async function refreshAllUsers() {
loading.value = true; loading.value = true;
const { data } = await api.users.getAll(); const { data } = await api.users.getAll();
users.value = data;
if (data) {
users.value = data.items;
} else {
users.value = null;
}
loading.value = false; loading.value = false;
} }

View File

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

View File

@ -22,21 +22,18 @@ import { useLazyRecipes } from "~/composables/recipes";
export default defineComponent({ export default defineComponent({
components: { RecipeCardSection }, components: { RecipeCardSection },
setup() { setup() {
// paging and sorting params const page = ref(1);
const perPage = ref(30);
const orderBy = "name"; const orderBy = "name";
const orderDescending = false; const orderDirection = "asc";
const increment = ref(30);
const start = ref(0);
const offset = ref(increment.value);
const limit = ref(increment.value);
const ready = ref(false); const ready = ref(false);
const loading = ref(false); const loading = ref(false);
const { recipes, fetchMore } = useLazyRecipes(); const { recipes, fetchMore } = useLazyRecipes();
onMounted(async () => { onMounted(async () => {
await fetchMore(start.value, limit.value, orderBy, orderDescending); await fetchMore(page.value, perPage.value, orderBy, orderDirection);
ready.value = true; ready.value = true;
}); });
@ -45,9 +42,8 @@ export default defineComponent({
return; return;
} }
loading.value = true; loading.value = true;
start.value = offset.value + 1; page.value = page.value + 1;
offset.value = offset.value + increment.value; fetchMore(page.value, perPage.value, orderBy, orderDirection);
fetchMore(start.value, limit.value, orderBy, orderDescending);
loading.value = false; loading.value = false;
}, 500); }, 500);

View File

@ -193,11 +193,11 @@ import { useCopyList } from "~/composables/use-copy";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils"; import { useAsyncKey } from "~/composables/use-utils";
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
import { MultiPurposeLabelOut } from "~/types/api-types/labels";
import { ShoppingListItemCreate, ShoppingListItemOut } from "~/types/api-types/group"; import { ShoppingListItemCreate, ShoppingListItemOut } from "~/types/api-types/group";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { getDisplayText } from "~/composables/use-display-text"; import { getDisplayText } from "~/composables/use-display-text";
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
type CopyTypes = "plain" | "markdown"; type CopyTypes = "plain" | "markdown";
@ -336,17 +336,9 @@ export default defineComponent({
// Labels, Units, Foods // Labels, Units, Foods
// TODO: Extract to Composable // TODO: Extract to Composable
const allLabels = ref([] as MultiPurposeLabelOut[]); const { labels: allLabels } = useLabelStore();
const { units: allUnits } = useUnitStore();
const allUnits = useAsync(async () => { const { foods: allFoods } = useFoodStore();
const { data } = await userApi.units.getAll();
return data ?? [];
}, useAsyncKey());
const allFoods = useAsync(async () => {
const { data } = await userApi.foods.getAll();
return data ?? [];
}, useAsyncKey());
function sortByLabels() { function sortByLabels() {
byLabel.value = !byLabel.value; byLabel.value = !byLabel.value;
@ -405,7 +397,10 @@ export default defineComponent({
async function refreshLabels() { async function refreshLabels() {
const { data } = await userApi.multiPurposeLabels.getAll(); const { data } = await userApi.multiPurposeLabels.getAll();
allLabels.value = data ?? [];
if (data) {
allLabels.value = data.items ?? [];
}
} }
refreshLabels(); refreshLabels();

View File

@ -60,7 +60,12 @@ export default defineComponent({
async function fetchShoppingLists() { async function fetchShoppingLists() {
const { data } = await userApi.shopping.lists.getAll(); const { data } = await userApi.shopping.lists.getAll();
return data;
if (!data) {
return [];
}
return data.items;
} }
async function refresh() { async function refresh() {

View File

@ -13,3 +13,11 @@ export interface ApiRequestInstance {
patch<T, U = Partial<T>>(url: string, data: U): Promise<RequestResponse<T>>; patch<T, U = Partial<T>>(url: string, data: U): Promise<RequestResponse<T>>;
delete<T>(url: string): 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 typing import Any, Generic, TypeVar, Union
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.sql import sqltypes
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.schema.response.pagination import OrderDirection, PaginationBase, PaginationQuery from mealie.schema.response.pagination import OrderDirection, PaginationBase, PaginationQuery
@ -59,6 +61,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
def get_all( def get_all(
self, limit: int = None, order_by: str = None, order_descending: bool = True, start=0, override=None self, limit: int = None, order_by: str = None, order_descending: bool = True, start=0, override=None
) -> list[Schema]: ) -> list[Schema]:
self.logger.warning('"get_all" method is deprecated; use "page_all" instead')
# sourcery skip: remove-unnecessary-cast # sourcery skip: remove-unnecessary-cast
eff_schema = override or self.schema eff_schema = override or self.schema
@ -224,7 +228,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
else: else:
return [eff_schema.from_orm(x) for x in q.all()] 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 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 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() fltr = self._filter_builder()
q = q.filter_by(**fltr) q = q.filter_by(**fltr)
count = q.count() 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 pagination.order_by:
if order_attr := getattr(self.model, pagination.order_by, None): 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: if pagination.order_direction == OrderDirection.asc:
order_attr = order_attr.asc() order_attr = order_attr.asc()
elif pagination.order_direction == OrderDirection.desc: elif pagination.order_direction == OrderDirection.desc:
@ -265,6 +290,6 @@ class RepositoryGeneric(Generic[Schema, Model]):
page=pagination.page, page=pagination.page,
per_page=pagination.per_page, per_page=pagination.per_page,
total=count, total=count,
total_pages=int(count / pagination.per_page) + 1, total_pages=total_pages,
data=[eff_schema.from_orm(s) for s in data], 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 random import randint
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID from uuid import UUID
@ -7,6 +8,7 @@ from slugify import slugify
from sqlalchemy import and_, func from sqlalchemy import and_, func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.sql import sqltypes
from mealie.db.models.recipe.category import Category from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.ingredient import RecipeIngredient 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.tag import Tag
from mealie.db.models.recipe.tool import Tool from mealie.db.models.recipe.tool import Tool
from mealie.schema.recipe import Recipe 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.recipe.recipe_category import CategoryBase, TagBase
from mealie.schema.response.pagination import OrderDirection, PaginationQuery
from .repository_generic import RepositoryGeneric from .repository_generic import RepositoryGeneric
@ -128,6 +131,72 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
.all() .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]: 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 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.group.group import GroupAdminUpdate
from mealie.schema.mapper import mapper 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.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 mealie.services.group_services.group_service import GroupService
from .._base import BaseAdminController, controller from .._base import BaseAdminController, controller
@ -39,9 +39,15 @@ class AdminUserManagementRoutes(BaseAdminController):
self.registered_exceptions, self.registered_exceptions,
) )
@router.get("", response_model=list[GroupInDB]) @router.get("", response_model=GroupPagination)
def get_all(self, q: GetAll = Depends(GetAll)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(start=q.start, limit=q.limit, override=GroupInDB) 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) @router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED)
def create_one(self, data: GroupBase): 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 import BaseAdminController, controller
from mealie.routes._base.dependencies import SharedDependencies from mealie.routes._base.dependencies import SharedDependencies
from mealie.routes._base.mixins import HttpRepo 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.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"]) router = APIRouter(prefix="/users", tags=["Admin: Users"])
@ -32,9 +32,15 @@ class AdminUserManagementRoutes(BaseAdminController):
def mixins(self): def mixins(self):
return HttpRepo[UserIn, UserOut, UserOut](self.repo, self.deps.logger, self.registered_exceptions) return HttpRepo[UserIn, UserOut, UserOut](self.repo, self.deps.logger, self.registered_exceptions)
@router.get("", response_model=list[UserOut]) @router.get("", response_model=UserPagination)
def get_all(self, q: GetAll = Depends(GetAll)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(start=q.start, limit=q.limit, override=UserOut) 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) @router.post("", response_model=UserOut, status_code=201)
def create_one(self, data: UserIn): 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 import BaseAdminController, controller
from mealie.routes._base.routers import UserAPIRouter 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 from mealie.services.server_tasks import BackgroundExecutor, test_executor_func
router = UserAPIRouter() router = UserAPIRouter()
@ -10,9 +11,15 @@ router = UserAPIRouter()
@controller(router) @controller(router)
class AdminServerTasksController(BaseAdminController): class AdminServerTasksController(BaseAdminController):
@router.get("/server-tasks", response_model=list[ServerTask]) @router.get("/server-tasks", response_model=ServerTaskPagination)
def get_all(self): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repos.server_tasks.get_all(order_by="created_at") 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) @router.post("/server-tasks", response_model=ServerTask, status_code=201)
def create_test_tasks(self, bg_tasks: BackgroundTasks): 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.base_controllers import BaseUserController
from mealie.routes._base.controller import controller from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.mixins import HttpRepo
from mealie.schema.query import GetAll
from mealie.schema.recipe.recipe_comments import ( from mealie.schema.recipe.recipe_comments import (
RecipeCommentCreate, RecipeCommentCreate,
RecipeCommentOut, RecipeCommentOut,
RecipeCommentPagination,
RecipeCommentSave, RecipeCommentSave,
RecipeCommentUpdate, RecipeCommentUpdate,
) )
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.responses import ErrorResponse, SuccessResponse from mealie.schema.response.responses import ErrorResponse, SuccessResponse
router = APIRouter(prefix="/comments", tags=["Recipe: Comments"]) 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: if comment.user_id != self.deps.acting_user.id and not self.deps.acting_user.admin:
raise HTTPException( raise HTTPException(
status_code=403, 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]) @router.get("", response_model=RecipeCommentPagination)
def get_all(self, q: GetAll = Depends(GetAll)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(start=q.start, limit=q.limit, override=RecipeCommentOut) 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) @router.post("", response_model=RecipeCommentOut, status_code=201)
def create_one(self, data: RecipeCommentCreate): 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.routes._base.routers import MealieCrudRoute
from mealie.schema import mapper from mealie.schema import mapper
from mealie.schema.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook 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.event_bus_service import EventBusService, EventSource
from mealie.services.event_bus_service.message_types import EventTypes from mealie.services.event_bus_service.message_types import EventTypes
@ -38,11 +40,15 @@ class GroupCookbookController(BaseUserController):
self.registered_exceptions, self.registered_exceptions,
) )
@router.get("", response_model=list[ReadCookBook]) @router.get("", response_model=CookBookPagination)
def get_all(self): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
items = self.repo.get_all() response = self.repo.page_all(
items.sort(key=lambda x: x.position) pagination=q,
return items override=ReadCookBook,
)
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
return response
@router.post("", response_model=ReadCookBook, status_code=201) @router.post("", response_model=ReadCookBook, status_code=201)
def create_one(self, data: CreateCookBook): def create_one(self, data: CreateCookBook):

View File

@ -13,9 +13,10 @@ from mealie.schema.group.group_events import (
GroupEventNotifierPrivate, GroupEventNotifierPrivate,
GroupEventNotifierSave, GroupEventNotifierSave,
GroupEventNotifierUpdate, GroupEventNotifierUpdate,
GroupEventPagination,
) )
from mealie.schema.mapper import cast 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 from mealie.services.event_bus_service.event_bus_service import EventBusService
router = APIRouter( router = APIRouter(
@ -41,9 +42,15 @@ class GroupEventsNotifierController(BaseUserController):
def mixins(self) -> HttpRepo: def mixins(self) -> HttpRepo:
return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
@router.get("", response_model=list[GroupEventNotifierOut]) @router.get("", response_model=GroupEventPagination)
def get_all(self, q: GetAll = Depends(GetAll)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(start=q.start, limit=q.limit) 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) @router.post("", response_model=GroupEventNotifierOut, status_code=201)
def create_one(self, data: GroupEventNotifierCreate): def create_one(self, data: GroupEventNotifierCreate):

View File

@ -14,8 +14,9 @@ from mealie.schema.labels import (
MultiPurposeLabelSummary, MultiPurposeLabelSummary,
MultiPurposeLabelUpdate, MultiPurposeLabelUpdate,
) )
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelPagination
from mealie.schema.mapper import cast 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) router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"], route_class=MealieCrudRoute)
@ -36,9 +37,15 @@ class MultiPurposeLabelsController(BaseUserController):
def mixins(self) -> HttpRepo: def mixins(self) -> HttpRepo:
return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
@router.get("", response_model=list[MultiPurposeLabelSummary]) @router.get("", response_model=MultiPurposeLabelPagination)
def get_all(self, q: GetAll = Depends(GetAll)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(start=q.start, limit=q.limit, override=MultiPurposeLabelSummary) 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) @router.post("", response_model=MultiPurposeLabelOut)
def create_one(self, data: MultiPurposeLabelCreate): def create_one(self, data: MultiPurposeLabelCreate):

View File

@ -1,5 +1,6 @@
from functools import cached_property from functools import cached_property
from fastapi import Depends
from pydantic import UUID4 from pydantic import UUID4
from mealie.routes._base.base_controllers import BaseUserController 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.mixins import HttpRepo
from mealie.routes._base.routers import UserAPIRouter from mealie.routes._base.routers import UserAPIRouter
from mealie.schema import mapper 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"]) router = UserAPIRouter(prefix="/groups/mealplans/rules", tags=["Groups: Mealplan Rules"])
@ -22,9 +24,15 @@ class GroupMealplanConfigController(BaseUserController):
def mixins(self): def mixins(self):
return HttpRepo[PlanRulesCreate, PlanRulesOut, PlanRulesOut](self.repo, self.deps.logger) return HttpRepo[PlanRulesCreate, PlanRulesOut, PlanRulesOut](self.repo, self.deps.logger)
@router.get("", response_model=list[PlanRulesOut]) @router.get("", response_model=PlanRulesPagination)
def get_all(self): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(override=PlanRulesOut) 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) @router.post("", response_model=PlanRulesOut, status_code=201)
def create_one(self, data: PlanRulesCreate): 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.base_controllers import BaseUserController
from mealie.routes._base.controller import controller from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute
from mealie.schema.group.group_shopping_list import ( from mealie.schema.group.group_shopping_list import (
ShoppingListCreate, ShoppingListCreate,
ShoppingListItemCreate, ShoppingListItemCreate,
ShoppingListItemOut, ShoppingListItemOut,
ShoppingListItemUpdate, ShoppingListItemUpdate,
ShoppingListOut, ShoppingListOut,
ShoppingListPagination,
ShoppingListSave, ShoppingListSave,
ShoppingListSummary, ShoppingListSummary,
ShoppingListUpdate, ShoppingListUpdate,
) )
from mealie.schema.mapper import cast 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.schema.response.responses import SuccessResponse
from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource 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.event_bus_service.message_types import EventTypes
from mealie.services.group_services.shopping_lists import ShoppingListService from mealie.services.group_services.shopping_lists import ShoppingListService
item_router = APIRouter( item_router = APIRouter(prefix="/groups/shopping/items", tags=["Group: Shopping List Items"])
prefix="/groups/shopping/items", tags=["Group: Shopping List Items"], route_class=MealieCrudRoute
)
@controller(item_router) @controller(item_router)
@ -98,7 +96,6 @@ class ShoppingListItemController(BaseUserController):
return shopping_list_item return shopping_list_item
@item_router.head("/{item_id}", response_model=ShoppingListItemOut)
@item_router.get("/{item_id}", response_model=ShoppingListItemOut) @item_router.get("/{item_id}", response_model=ShoppingListItemOut)
def get_one(self, item_id: UUID4): def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id) return self.mixins.get_one(item_id)
@ -148,7 +145,7 @@ class ShoppingListItemController(BaseUserController):
return shopping_list_item 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) @controller(router)
@ -170,9 +167,15 @@ class ShoppingListController(BaseUserController):
def mixins(self) -> HttpRepo[ShoppingListCreate, ShoppingListOut, ShoppingListSave]: def mixins(self) -> HttpRepo[ShoppingListCreate, ShoppingListOut, ShoppingListSave]:
return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.") return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
@router.get("", response_model=list[ShoppingListSummary]) @router.get("", response_model=ShoppingListPagination)
def get_all(self, q: GetAll = Depends(GetAll)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(start=q.start, limit=q.limit, override=ShoppingListSummary) 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) @router.post("", response_model=ShoppingListOut, status_code=201)
def create_one(self, data: ShoppingListCreate): def create_one(self, data: ShoppingListCreate):
@ -193,7 +196,6 @@ class ShoppingListController(BaseUserController):
return val return val
@router.head("/{item_id}", response_model=ShoppingListOut)
@router.get("/{item_id}", response_model=ShoppingListOut) @router.get("/{item_id}", response_model=ShoppingListOut)
def get_one(self, item_id: UUID4): def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id) 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.controller import controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.mixins import HttpRepo
from mealie.schema import mapper from mealie.schema import mapper
from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination
from mealie.schema.query import GetAll from mealie.schema.response.pagination import PaginationQuery
router = APIRouter(prefix="/groups/webhooks", tags=["Groups: Webhooks"]) router = APIRouter(prefix="/groups/webhooks", tags=["Groups: Webhooks"])
@ -23,9 +23,15 @@ class ReadWebhookController(BaseUserController):
def mixins(self) -> HttpRepo: def mixins(self) -> HttpRepo:
return HttpRepo[CreateWebhook, SaveWebhook, CreateWebhook](self.repo, self.deps.logger) return HttpRepo[CreateWebhook, SaveWebhook, CreateWebhook](self.repo, self.deps.logger)
@router.get("", response_model=list[ReadWebhook]) @router.get("", response_model=WebhookPagination)
def get_all(self, q: GetAll = Depends(GetAll)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(start=q.start, limit=q.limit, override=ReadWebhook) 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) @router.post("", response_model=ReadWebhook, status_code=201)
def create_one(self, data: CreateWebhook): 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.routes._base.mixins import HttpRepo
from mealie.schema import mapper from mealie.schema import mapper
from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse 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.recipe.recipe_category import CategoryBase, CategorySave
from mealie.schema.response.pagination import PaginationQuery
from mealie.services import urls from mealie.services import urls
from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource 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.event_bus_service.message_types import EventTypes
@ -40,10 +41,16 @@ class RecipeCategoryController(BaseUserController):
def mixins(self): def mixins(self):
return HttpRepo(self.repo, self.deps.logger) return HttpRepo(self.repo, self.deps.logger)
@router.get("", response_model=list[RecipeCategory]) @router.get("", response_model=RecipeCategoryPagination)
def get_all(self): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
"""Returns a list of available categories in the database""" """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) @router.post("", status_code=201)
def create_one(self, category: CategoryIn): 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.routes._base.mixins import HttpRepo
from mealie.schema import mapper from mealie.schema import mapper
from mealie.schema.recipe import RecipeTagResponse, TagIn 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.recipe.recipe_category import TagSave
from mealie.schema.response.pagination import PaginationQuery
from mealie.services import urls from mealie.services import urls
from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource 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.event_bus_service.message_types import EventTypes
@ -29,10 +30,16 @@ class TagController(BaseUserController):
def mixins(self): def mixins(self):
return HttpRepo(self.repo, self.deps.logger) return HttpRepo(self.repo, self.deps.logger)
@router.get("") @router.get("", response_model=RecipeTagPagination)
async def get_all(self): async def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
"""Returns a list of available tags in the database""" """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") @router.get("/empty")
def get_empty_tags(self): 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.controller import controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.mixins import HttpRepo
from mealie.schema import mapper from mealie.schema import mapper
from mealie.schema.query import GetAll from mealie.schema.recipe.recipe import RecipeTool, RecipeToolPagination
from mealie.schema.recipe.recipe import RecipeTool
from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse, RecipeToolSave from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse, RecipeToolSave
from mealie.schema.response.pagination import PaginationQuery
router = APIRouter(prefix="/tools", tags=["Organizer: Tools"]) router = APIRouter(prefix="/tools", tags=["Organizer: Tools"])
@ -24,9 +24,15 @@ class RecipeToolController(BaseUserController):
def mixins(self) -> HttpRepo: def mixins(self) -> HttpRepo:
return HttpRepo[RecipeToolCreate, RecipeTool, RecipeToolCreate](self.repo, self.deps.logger) return HttpRepo[RecipeToolCreate, RecipeTool, RecipeToolCreate](self.repo, self.deps.logger)
@router.get("", response_model=list[RecipeTool]) @router.get("", response_model=RecipeToolPagination)
def get_all(self, q: GetAll = Depends(GetAll)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(start=q.start, limit=q.limit, override=RecipeTool) 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) @router.post("", response_model=RecipeTool, status_code=201)
def create_one(self, data: RecipeToolCreate): 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 import BaseUserController, controller
from mealie.routes._base.mixins import HttpRepo from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
from mealie.schema.query import GetAll
from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe 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_asset import RecipeAsset
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse
@ -53,10 +52,6 @@ class BaseRecipeController(BaseUserController):
return HttpRepo[CreateRecipe, Recipe, Recipe](self.repo, self.deps.logger) return HttpRepo[CreateRecipe, Recipe, Recipe](self.repo, self.deps.logger)
class RecipeGetAll(GetAll):
load_food: bool = False
class FormatResponse(BaseModel): class FormatResponse(BaseModel):
jjson: list[str] = Field(..., alias="json") jjson: list[str] = Field(..., alias="json")
zip: list[str] zip: list[str]
@ -196,18 +191,16 @@ class RecipeController(BaseRecipeController):
# CRUD Operations # CRUD Operations
@router.get("", response_model=list[RecipeSummary]) @router.get("", response_model=list[RecipeSummary])
def get_all(self, q: RecipeGetAll = Depends(RecipeGetAll)): def get_all(self, q: RecipePaginationQuery = Depends(RecipePaginationQuery)):
items = self.repo.summary( response = self.repo.page_all(
self.user.group_id, pagination=q,
start=q.start, load_food=q.load_food,
limit=q.limit,
load_foods=q.load_food,
order_by=q.order_by,
order_descending=q.order_descending,
) )
response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
new_items = [] new_items = []
for item in items: for item in response.items:
# Pydantic/FastAPI can't seem to serialize the ingredient field on thier own. # Pydantic/FastAPI can't seem to serialize the ingredient field on thier own.
new_item = item.__dict__ new_item = item.__dict__
@ -216,10 +209,11 @@ class RecipeController(BaseRecipeController):
new_items.append(new_item) 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 # 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) @router.get("/{slug}", response_model=Recipe)
def get_one(self, slug: str): 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.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute from mealie.routes._base.routers import MealieCrudRoute
from mealie.schema import mapper from mealie.schema import mapper
from mealie.schema.query import GetAll from mealie.schema.recipe.recipe_ingredient import (
from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood, MergeFood, SaveIngredientFood CreateIngredientFood,
IngredientFood,
IngredientFoodPagination,
MergeFood,
SaveIngredientFood,
)
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.responses import SuccessResponse from mealie.schema.response.responses import SuccessResponse
router = APIRouter(prefix="/foods", tags=["Recipes: Foods"], route_class=MealieCrudRoute) router = APIRouter(prefix="/foods", tags=["Recipes: Foods"], route_class=MealieCrudRoute)
@ -38,9 +44,15 @@ class IngredientFoodsController(BaseUserController):
self.deps.logger.error(e) self.deps.logger.error(e)
raise HTTPException(500, "Failed to merge foods") from e raise HTTPException(500, "Failed to merge foods") from e
@router.get("", response_model=list[IngredientFood]) @router.get("", response_model=IngredientFoodPagination)
def get_all(self, q: GetAll = Depends(GetAll)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(start=q.start, limit=q.limit, order_by=q.order_by, order_descending=q.order_descending) 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) @router.post("", response_model=IngredientFood, status_code=201)
def create_one(self, data: CreateIngredientFood): 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.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute from mealie.routes._base.routers import MealieCrudRoute
from mealie.schema import mapper from mealie.schema import mapper
from mealie.schema.query import GetAll from mealie.schema.recipe.recipe_ingredient import (
from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit, MergeUnit, SaveIngredientUnit CreateIngredientUnit,
IngredientUnit,
IngredientUnitPagination,
MergeUnit,
SaveIngredientUnit,
)
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.responses import SuccessResponse from mealie.schema.response.responses import SuccessResponse
router = APIRouter(prefix="/units", tags=["Recipes: Units"], route_class=MealieCrudRoute) router = APIRouter(prefix="/units", tags=["Recipes: Units"], route_class=MealieCrudRoute)
@ -38,9 +44,15 @@ class IngredientUnitsController(BaseUserController):
self.deps.logger.error(e) self.deps.logger.error(e)
raise HTTPException(500, "Failed to merge units") from e raise HTTPException(500, "Failed to merge units") from e
@router.get("", response_model=list[IngredientUnit]) @router.get("", response_model=IngredientUnitPagination)
def get_all(self, q: GetAll = Depends(GetAll)): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repo.get_all(start=q.start, limit=q.limit, order_by=q.order_by, order_descending=q.order_descending) 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) @router.post("", response_model=IngredientUnit, status_code=201)
def create_one(self, data: CreateIngredientUnit): 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 pydantic import UUID4
from mealie.core.security import hash_password, verify_password 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._base.routers import AdminAPIRouter, UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.response import ErrorResponse, SuccessResponse 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 import ChangePassword, UserBase, UserIn, UserOut
from mealie.schema.user.user import UserPagination
user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"]) user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"]) admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
@ -20,9 +22,15 @@ class AdminUserController(BaseAdminController):
def mixins(self) -> HttpRepo: def mixins(self) -> HttpRepo:
return HttpRepo[UserIn, UserOut, UserBase](self.repos.users, self.deps.logger) return HttpRepo[UserIn, UserOut, UserBase](self.repos.users, self.deps.logger)
@admin_router.get("", response_model=list[UserOut]) @admin_router.get("", response_model=UserPagination)
def get_all_users(self): def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
return self.repos.users.get_all() 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) @admin_router.post("", response_model=UserOut, status_code=201)
def create_user(self, new_user: UserIn): 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._mealie import MealieModel
from mealie.schema.recipe.recipe import RecipeSummary, RecipeTool from mealie.schema.recipe.recipe import RecipeSummary, RecipeTool
from mealie.schema.response.pagination import PaginationBase
from ..recipe.recipe_category import CategoryBase, TagBase from ..recipe.recipe_category import CategoryBase, TagBase
@ -51,6 +52,10 @@ class ReadCookBook(UpdateCookBook):
orm_mode = True orm_mode = True
class CookBookPagination(PaginationBase):
items: list[ReadCookBook]
class RecipeCookBook(ReadCookBook): class RecipeCookBook(ReadCookBook):
group_id: UUID4 group_id: UUID4
recipes: list[RecipeSummary] recipes: list[RecipeSummary]

View File

@ -1,6 +1,7 @@
from pydantic import UUID4, NoneStr from pydantic import UUID4, NoneStr
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase
# ============================================================================= # =============================================================================
# Group Events Notifier Options # Group Events Notifier Options
@ -83,6 +84,10 @@ class GroupEventNotifierOut(MealieModel):
orm_mode = True orm_mode = True
class GroupEventPagination(PaginationBase):
items: list[GroupEventNotifierOut]
class GroupEventNotifierPrivate(GroupEventNotifierOut): class GroupEventNotifierPrivate(GroupEventNotifierOut):
apprise_url: str apprise_url: str

View File

@ -7,6 +7,7 @@ from pydantic import UUID4
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
from mealie.schema.response.pagination import PaginationBase
class ShoppingListItemRecipeRef(MealieModel): class ShoppingListItemRecipeRef(MealieModel):
@ -84,6 +85,10 @@ class ShoppingListSummary(ShoppingListSave):
orm_mode = True orm_mode = True
class ShoppingListPagination(PaginationBase):
items: list[ShoppingListSummary]
class ShoppingListUpdate(ShoppingListSummary): class ShoppingListUpdate(ShoppingListSummary):
list_items: list[ShoppingListItemOut] = [] list_items: list[ShoppingListItemOut] = []

View File

@ -7,6 +7,7 @@ from pydantic import UUID4, validator
from pydantic.datetime_parse import parse_datetime from pydantic.datetime_parse import parse_datetime
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase
class WebhookType(str, enum.Enum): class WebhookType(str, enum.Enum):
@ -57,3 +58,7 @@ class ReadWebhook(SaveWebhook):
class Config: class Config:
orm_mode = True orm_mode = True
class WebhookPagination(PaginationBase):
items: list[ReadWebhook]

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from pydantic import UUID4 from pydantic import UUID4
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase
class MultiPurposeLabelCreate(MealieModel): class MultiPurposeLabelCreate(MealieModel):
@ -25,6 +26,10 @@ class MultiPurposeLabelSummary(MultiPurposeLabelUpdate):
orm_mode = True orm_mode = True
class MultiPurposeLabelPagination(PaginationBase):
items: list[MultiPurposeLabelSummary]
class MultiPurposeLabelOut(MultiPurposeLabelUpdate): class MultiPurposeLabelOut(MultiPurposeLabelUpdate):
# shopping_list_items: list[ShoppingListItemOut] = [] # shopping_list_items: list[ShoppingListItemOut] = []
# foods: list[IngredientFood] = [] # foods: list[IngredientFood] = []

View File

@ -4,6 +4,7 @@ from enum import Enum
from pydantic import UUID4 from pydantic import UUID4
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase
class Category(MealieModel): class Category(MealieModel):
@ -63,3 +64,7 @@ class PlanRulesOut(PlanRulesSave):
class Config: class Config:
orm_mode = True 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.core.config import get_app_dirs
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
from .recipe_asset import RecipeAsset from .recipe_asset import RecipeAsset
from .recipe_comments import RecipeCommentOut from .recipe_comments import RecipeCommentOut
@ -32,15 +33,27 @@ class RecipeTag(MealieModel):
orm_mode = True orm_mode = True
class RecipeTagPagination(PaginationBase):
items: list[RecipeTag]
class RecipeCategory(RecipeTag): class RecipeCategory(RecipeTag):
pass pass
class RecipeCategoryPagination(PaginationBase):
items: list[RecipeCategory]
class RecipeTool(RecipeTag): class RecipeTool(RecipeTag):
id: UUID4 id: UUID4
on_hand: bool = False on_hand: bool = False
class RecipeToolPagination(PaginationBase):
items: list[RecipeTool]
class CreateRecipeBulk(BaseModel): class CreateRecipeBulk(BaseModel):
url: str url: str
categories: list[RecipeCategory] = None categories: list[RecipeCategory] = None
@ -114,6 +127,14 @@ class RecipeSummary(MealieModel):
return user_id return user_id
class RecipePaginationQuery(PaginationQuery):
load_food: bool = False
class RecipePagination(PaginationBase):
items: list[RecipeSummary]
class Recipe(RecipeSummary): class Recipe(RecipeSummary):
recipe_ingredient: list[RecipeIngredient] = [] recipe_ingredient: list[RecipeIngredient] = []
recipe_instructions: Optional[list[RecipeStep]] = [] recipe_instructions: Optional[list[RecipeStep]] = []

View File

@ -4,6 +4,7 @@ from typing import Optional
from pydantic import UUID4 from pydantic import UUID4
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase
class UserBase(MealieModel): class UserBase(MealieModel):
@ -39,3 +40,7 @@ class RecipeCommentOut(RecipeCommentCreate):
class Config: class Config:
orm_mode = True 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 import MealieModel
from mealie.schema._mealie.types import NoneFloat from mealie.schema._mealie.types import NoneFloat
from mealie.schema.response.pagination import PaginationBase
class UnitFoodBase(MealieModel): class UnitFoodBase(MealieModel):
@ -31,6 +32,10 @@ class IngredientFood(CreateIngredientFood):
orm_mode = True orm_mode = True
class IngredientFoodPagination(PaginationBase):
items: list[IngredientFood]
class CreateIngredientUnit(UnitFoodBase): class CreateIngredientUnit(UnitFoodBase):
fraction: bool = True fraction: bool = True
abbreviation: str = "" abbreviation: str = ""
@ -48,6 +53,10 @@ class IngredientUnit(CreateIngredientUnit):
orm_mode = True orm_mode = True
class IngredientUnitPagination(PaginationBase):
items: list[IngredientUnit]
class RecipeIngredient(MealieModel): class RecipeIngredient(MealieModel):
title: Optional[str] title: Optional[str]
note: Optional[str] note: Optional[str]

View File

@ -1,9 +1,13 @@
import enum 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 import BaseModel
from pydantic.generics import GenericModel from pydantic.generics import GenericModel
from mealie.schema._mealie import MealieModel
DataT = TypeVar("DataT", bound=BaseModel) DataT = TypeVar("DataT", bound=BaseModel)
@ -12,11 +16,11 @@ class OrderDirection(str, enum.Enum):
desc = "desc" desc = "desc"
class PaginationQuery(BaseModel): class PaginationQuery(MealieModel):
page: int = 1 page: int = 1
per_page: int = 50
order_by: str = "created_at" order_by: str = "created_at"
order_direction: OrderDirection = OrderDirection.desc order_direction: OrderDirection = OrderDirection.desc
per_page: int = 50
class PaginationBase(GenericModel, Generic[DataT]): class PaginationBase(GenericModel, Generic[DataT]):
@ -24,4 +28,45 @@ class PaginationBase(GenericModel, Generic[DataT]):
per_page: int = 10 per_page: int = 10
total: int = 0 total: int = 0
total_pages: 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 pydantic import Field
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import PaginationBase
class ServerTaskNames(str, enum.Enum): class ServerTaskNames(str, enum.Enum):
@ -45,3 +46,7 @@ class ServerTask(ServerTaskCreate):
class Config: class Config:
orm_mode = True 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._mealie import MealieModel
from mealie.schema.group.group_preferences import ReadGroupPreferences from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.recipe import RecipeSummary from mealie.schema.recipe import RecipeSummary
from mealie.schema.response.pagination import PaginationBase
from ..recipe import CategoryBase from ..recipe import CategoryBase
@ -113,6 +114,10 @@ class UserOut(UserBase):
} }
class UserPagination(PaginationBase):
items: list[UserOut]
class UserFavorites(UserBase): class UserFavorites(UserBase):
favorite_recipes: list[RecipeSummary] = [] # type: ignore favorite_recipes: list[RecipeSummary] = [] # type: ignore
@ -180,6 +185,10 @@ class GroupInDB(UpdateGroup):
return GroupInDB.get_export_directory(self.id) return GroupInDB.get_export_directory(self.id)
class GroupPagination(PaginationBase):
items: list[GroupInDB]
class LongLiveTokenInDB(CreateToken): class LongLiveTokenInDB(CreateToken):
id: int id: int
user: PrivateUser user: PrivateUser

View File

@ -10,13 +10,13 @@ class Routes:
def test_admin_server_tasks_test_and_get(api_client: TestClient, admin_user: TestUser): def test_admin_server_tasks_test_and_get(api_client: TestClient, admin_user: TestUser):
# Bootstrap Timer # Bootstrap Timer
BackgroundExecutor.sleep_time = 0.1 BackgroundExecutor.sleep_time = 1
response = api_client.post(Routes.base, headers=admin_user.token) response = api_client.post(Routes.base, headers=admin_user.token)
assert response.status_code == 201 assert response.status_code == 201
response = api_client.get(Routes.base, headers=admin_user.token) response = api_client.get(Routes.base, headers=admin_user.token)
as_dict = response.json() as_dict = response.json()["items"]
assert len(as_dict) == 1 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] 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 :( for know in known_ids: # Hacky check, because other tests don't cleanup after themselves :(
assert str(know) in server_ids 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]): 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) all_lists = api_client.get(Routes.base, headers=unique_user.token)
assert all_lists.status_code == 200 assert all_lists.status_code == 200
all_lists = all_lists.json() all_lists = all_lists.json()["items"]
assert len(all_lists) == len(shopping_lists) 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 assert response.status_code == 200
recipes = response.json() recipes = response.json()["items"]
assert len(recipes) == 5 assert len(recipes) == 5

View File

@ -47,7 +47,7 @@ def test_multitenant_cases_get_all(
response = test_case.get_all(token) response = test_case.get_all(token)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()["items"]
assert len(data) == len(item_ids) assert len(data) == len(item_ids)
@ -84,7 +84,7 @@ def test_multitenant_cases_same_named_resources(
response = test_case.get_all(token) response = test_case.get_all(token)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()["items"]
assert len(data) == len(item_ids) 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.repos.repository_factory import AllRepositories
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.pagination import PaginationQuery
from mealie.services.seeder.seeder_service import SeederService from mealie.services.seeder.seeder_service import SeederService
@ -21,18 +26,98 @@ def test_repository_pagination(database: AllRepositories, unique_user: TestUser)
seen = [] seen = []
for _ in range(10): 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 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 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 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