From a769e2c91f6b55313711167ad6baaee30ad35576 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Sun, 28 Jun 2020 01:39:45 -0400 Subject: [PATCH] 1318 pagination users teams submissions (#1513) * Paginate only the `/api/v1/users`, `/api/v1/teams`, and `/api/v1/submissions` endpoints. * Add a `PaginatedAPIListSuccessResponse` class with a customized `apidoc` method to hack in the pagination scheme * Works on #1318 --- CTFd/api/v1/schemas/__init__.py | 47 ++++++++++++++++++++++++++++++++- CTFd/api/v1/submissions.py | 36 ++++++++++++++++++++----- CTFd/api/v1/teams.py | 30 ++++++++++++++++----- CTFd/api/v1/users.py | 30 ++++++++++++++++----- 4 files changed, 124 insertions(+), 19 deletions(-) diff --git a/CTFd/api/v1/schemas/__init__.py b/CTFd/api/v1/schemas/__init__.py index 7fe42357..c69f83d6 100644 --- a/CTFd/api/v1/schemas/__init__.py +++ b/CTFd/api/v1/schemas/__init__.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel @@ -55,6 +55,51 @@ class APIListSuccessResponse(APIDetailedSuccessResponse): return schema +class PaginatedAPIListSuccessResponse(APIListSuccessResponse): + meta: Dict[str, Any] + + @classmethod + def apidoc(cls): + """ + Helper to inline references from the generated schema + """ + schema = cls.schema() + + schema["properties"]["meta"] = { + "title": "Meta", + "type": "object", + "properties": { + "pagination": { + "title": "Pagination", + "type": "object", + "properties": { + "page": {"title": "Page", "type": "integer"}, + "next": {"title": "Next", "type": "integer"}, + "prev": {"title": "Prev", "type": "integer"}, + "pages": {"title": "Pages", "type": "integer"}, + "per_page": {"title": "Per Page", "type": "integer"}, + "total": {"title": "Total", "type": "integer"}, + }, + "required": ["page", "next", "prev", "pages", "per_page", "total"], + } + }, + "required": ["pagination"], + } + + try: + key = schema["properties"]["data"]["items"]["$ref"] + ref = key.split("/").pop() + definition = schema["definitions"][ref] + schema["properties"]["data"]["items"] = definition + del schema["definitions"][ref] + if bool(schema["definitions"]) is False: + del schema["definitions"] + except KeyError: + pass + + return schema + + class APISimpleErrorResponse(BaseModel): success: bool = False errors: Optional[List[str]] diff --git a/CTFd/api/v1/submissions.py b/CTFd/api/v1/submissions.py index 1c4b2cc4..490797c9 100644 --- a/CTFd/api/v1/submissions.py +++ b/CTFd/api/v1/submissions.py @@ -5,7 +5,10 @@ from flask_restx import Namespace, Resource from CTFd.api.v1.helpers.request import validate_args from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse +from CTFd.api.v1.schemas import ( + APIDetailedSuccessResponse, + PaginatedAPIListSuccessResponse, +) from CTFd.cache import clear_standings from CTFd.models import Submissions, db from CTFd.schemas.submissions import SubmissionSchema @@ -23,7 +26,7 @@ class SubmissionDetailedSuccessResponse(APIDetailedSuccessResponse): data: SubmissionModel -class SubmissionListSuccessResponse(APIListSuccessResponse): +class SubmissionListSuccessResponse(PaginatedAPIListSuccessResponse): data: List[SubmissionModel] @@ -52,17 +55,38 @@ class SubmissionsList(Resource): def get(self): args = request.args.to_dict() schema = SubmissionSchema(many=True) + pagination_args = { + "per_page": int(args.pop("per_page", 50)), + "page": int(args.pop("page", 1)), + } if args: - submissions = Submissions.query.filter_by(**args).all() + submissions = Submissions.query.filter_by(**args).paginate( + **pagination_args, max_per_page=100 + ) else: - submissions = Submissions.query.all() + submissions = Submissions.query.paginate( + **pagination_args, max_per_page=100 + ) - response = schema.dump(submissions) + response = schema.dump(submissions.items) if response.errors: return {"success": False, "errors": response.errors}, 400 - return {"success": True, "data": response.data} + return { + "meta": { + "pagination": { + "page": submissions.page, + "next": submissions.next_num, + "prev": submissions.prev_num, + "pages": submissions.pages, + "per_page": submissions.per_page, + "total": submissions.total, + } + }, + "success": True, + "data": response.data, + } @admins_only @submissions_namespace.doc( diff --git a/CTFd/api/v1/teams.py b/CTFd/api/v1/teams.py index 83b6c05f..2944c54e 100644 --- a/CTFd/api/v1/teams.py +++ b/CTFd/api/v1/teams.py @@ -5,7 +5,10 @@ from flask import abort, request, session from flask_restx import Namespace, Resource from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse +from CTFd.api.v1.schemas import ( + APIDetailedSuccessResponse, + PaginatedAPIListSuccessResponse, +) from CTFd.cache import clear_standings, clear_team_session, clear_user_session from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db from CTFd.schemas.awards import AwardSchema @@ -28,7 +31,7 @@ class TeamDetailedSuccessResponse(APIDetailedSuccessResponse): data: TeamModel -class TeamListSuccessResponse(APIListSuccessResponse): +class TeamListSuccessResponse(PaginatedAPIListSuccessResponse): data: List[TeamModel] @@ -56,19 +59,34 @@ class TeamList(Resource): ) def get(self): if is_admin() and request.args.get("view") == "admin": - teams = Teams.query.filter_by() + teams = Teams.query.filter_by().paginate(per_page=50, max_per_page=100) else: - teams = Teams.query.filter_by(hidden=False, banned=False) + teams = Teams.query.filter_by(hidden=False, banned=False).paginate( + per_page=50, max_per_page=100 + ) user_type = get_current_user_type(fallback="user") view = copy.deepcopy(TeamSchema.views.get(user_type)) view.remove("members") - response = TeamSchema(view=view, many=True).dump(teams) + response = TeamSchema(view=view, many=True).dump(teams.items) if response.errors: return {"success": False, "errors": response.errors}, 400 - return {"success": True, "data": response.data} + return { + "meta": { + "pagination": { + "page": teams.page, + "next": teams.next_num, + "prev": teams.prev_num, + "pages": teams.pages, + "per_page": teams.per_page, + "total": teams.total, + } + }, + "success": True, + "data": response.data, + } @admins_only @teams_namespace.doc( diff --git a/CTFd/api/v1/users.py b/CTFd/api/v1/users.py index ceb363dd..9f211a39 100644 --- a/CTFd/api/v1/users.py +++ b/CTFd/api/v1/users.py @@ -4,7 +4,10 @@ from flask import abort, request from flask_restx import Namespace, Resource from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic -from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse +from CTFd.api.v1.schemas import ( + APIDetailedSuccessResponse, + PaginatedAPIListSuccessResponse, +) from CTFd.cache import clear_standings, clear_user_session from CTFd.models import ( Awards, @@ -40,7 +43,7 @@ class UserDetailedSuccessResponse(APIDetailedSuccessResponse): data: UserModel -class UserListSuccessResponse(APIListSuccessResponse): +class UserListSuccessResponse(PaginatedAPIListSuccessResponse): data: List[UserModel] @@ -68,16 +71,31 @@ class UserList(Resource): ) def get(self): if is_admin() and request.args.get("view") == "admin": - users = Users.query.filter_by() + users = Users.query.filter_by().paginate(per_page=50, max_per_page=100) else: - users = Users.query.filter_by(banned=False, hidden=False) + users = Users.query.filter_by(banned=False, hidden=False).paginate( + per_page=50, max_per_page=100 + ) - response = UserSchema(view="user", many=True).dump(users) + response = UserSchema(view="user", many=True).dump(users.items) if response.errors: return {"success": False, "errors": response.errors}, 400 - return {"success": True, "data": response.data} + return { + "meta": { + "pagination": { + "page": users.page, + "next": users.next_num, + "prev": users.prev_num, + "pages": users.pages, + "per_page": users.per_page, + "total": users.total, + } + }, + "success": True, + "data": response.data, + } @users_namespace.doc() @admins_only