diff --git a/pagure/api/__init__.py b/pagure/api/__init__.py index 2d20891..8eab66f 100644 --- a/pagure/api/__init__.py +++ b/pagure/api/__init__.py @@ -88,6 +88,7 @@ class APIERROR(enum.Enum): ENOGROUP = 'Group not found' ENOTMAINADMIN = 'Only the main admin can set the main admin of a project' EMODIFYPROJECTNOTALLOWED = 'You are not allowed to modify this project' + EINVALIDPERPAGEVALUE = 'The per_page value must be between 1 and 100' def get_authorized_api_project(SESSION, repo, user=None, namespace=None): diff --git a/pagure/api/project.py b/pagure/api/project.py index 5590a1a..aea1361 100644 --- a/pagure/api/project.py +++ b/pagure/api/project.py @@ -260,6 +260,10 @@ def api_projects(): GET /api/0/projects?tags=fedora-infra + :: + + GET /api/0/projects?page=1&per_page=50 + Parameters ^^^^^^^^^^ @@ -291,6 +295,16 @@ def api_projects(): | | | | entrie project JSON | | | | | or just a sub-set | +---------------+----------+---------------+--------------------------+ + | ``page`` | int | Optional | | Specifies that | + | | | | pagination should be | + | | | | turned on and that | + | | | | this specific page | + | | | | should be displayed | + +---------------+----------+---------------+--------------------------+ + | ``per_page`` | int | Optional | | The number of projects | + | | | | to return per page. | + | | | | The maximum is 100. | + +---------------+----------+---------------+--------------------------+ Sample response ^^^^^^^^^^^^^^^ @@ -378,6 +392,98 @@ def api_projects(): ] } + Sample Response With Pagination + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + :: + + { + "args": { + "fork": null, + "namespace": null, + "owner": null, + "page": 1, + "pattern": null, + "per_page": 2, + "short": false, + "tags": [], + "username": null + }, + "pagination": { + "first": "http://127.0.0.1:5000/api/0/projects?per_page=2&page=1", + "last": "http://127.0.0.1:5000/api/0/projects?per_page=2&page=500", + "next": "http://127.0.0.1:5000/api/0/projects?per_page=2&page=2", + "page": 1, + "pages": 500, + "per_page": 2, + "prev": null + }, + "projects": [ + { + "access_groups": { + "admin": [], + "commit": [], + "ticket": [] + }, + "access_users": { + "admin": [], + "commit": [], + "owner": [ + "mprahl" + ], + "ticket": [] + }, + "close_status": [], + "custom_keys": [], + "date_created": "1498841289", + "description": "test1", + "fullname": "test1", + "id": 1, + "milestones": {}, + "name": "test1", + "namespace": null, + "parent": null, + "priorities": {}, + "tags": [], + "user": { + "fullname": "Matt Prahl", + "name": "mprahl" + } + }, + { + "access_groups": { + "admin": [], + "commit": [], + "ticket": [] + }, + "access_users": { + "admin": [], + "commit": [], + "owner": [ + "mprahl" + ], + "ticket": [] + }, + "close_status": [], + "custom_keys": [], + "date_created": "1499795310", + "description": "test2", + "fullname": "test2", + "id": 2, + "milestones": {}, + "name": "test2", + "namespace": null, + "parent": null, + "priorities": {}, + "tags": [], + "user": { + "fullname": "Matt Prahl", + "name": "mprahl" + } + } + ], + "total_projects": 1000 + } """ tags = flask.request.values.getlist('tags') username = flask.request.values.get('username', None) @@ -386,6 +492,8 @@ def api_projects(): owner = flask.request.values.get('owner', None) pattern = flask.request.values.get('pattern', None) short = flask.request.values.get('short', None) + page = flask.request.values.get('page', None) + per_page = flask.request.values.get('per_page', None) if str(fork).lower() in ['1', 'true']: fork = True @@ -400,9 +508,46 @@ def api_projects(): if authenticated() and username == flask.g.fas_user.username: private = flask.g.fas_user.username + project_count = pagure.lib.search_projects( + SESSION, username=username, fork=fork, tags=tags, pattern=pattern, + private=private, namespace=namespace, owner=owner, count=True) + # Pagination code inspired by Flask-SQLAlchemy + pagination_metadata = None + query_start = None + query_limit = None + if page: + try: + page = int(page) + except (TypeError, ValueError): + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.EINVALIDREQ) + + if page < 1: + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.EINVALIDREQ) + + if per_page: + try: + per_page = int(per_page) + except (TypeError, ValueError): + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.EINVALIDREQ) + + if per_page < 1 or per_page > 100: + raise pagure.exceptions.APIError( + 400, error_code=APIERROR.EINVALIDPERPAGEVALUE) + else: + per_page = 20 + + pagination_metadata = pagure.lib.get_pagination_metadata( + flask.request, page, per_page, project_count) + query_start = (page - 1) * per_page + query_limit = per_page + projects = pagure.lib.search_projects( SESSION, username=username, fork=fork, tags=tags, pattern=pattern, - private=private, namespace=namespace, owner=owner) + private=private, namespace=namespace, owner=owner, limit=query_limit, + start=query_start) if not projects: raise pagure.exceptions.APIError( @@ -422,8 +567,8 @@ def api_projects(): for p in projects ] - jsonout = flask.jsonify({ - 'total_projects': len(projects), + jsonout = { + 'total_projects': project_count, 'projects': projects, 'args': { 'tags': tags, @@ -434,8 +579,12 @@ def api_projects(): 'owner': owner, 'short': short, } - }) - return jsonout + } + if pagination_metadata: + jsonout['args']['page'] = page + jsonout['args']['per_page'] = per_page + jsonout['pagination'] = pagination_metadata + return flask.jsonify(jsonout) @API.route('/') diff --git a/pagure/lib/__init__.py b/pagure/lib/__init__.py index c669604..b67068b 100644 --- a/pagure/lib/__init__.py +++ b/pagure/lib/__init__.py @@ -32,6 +32,8 @@ import urlparse import uuid import markdown import werkzeug +from math import ceil +import copy import bleach import redis @@ -43,6 +45,7 @@ from sqlalchemy import asc from sqlalchemy.orm import aliased from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import scoped_session +from flask import url_for import pagure import pagure.exceptions @@ -4271,3 +4274,56 @@ def set_project_owner(session, project, user): ''' project.user = user session.add(project) + + +def get_pagination_metadata(flask_request, page, per_page, total): + """ + Returns pagination metadata for an API. The code was inspired by + Flask-SQLAlchemy. + :param flask_request: flask.request object + :param page: int of the current page + :param per_page: int of results per page + :param total: int of total results + :return: dictionary of pagination metadata + """ + pages = int(ceil(total / float(per_page))) + request_args_wo_page = dict(copy.deepcopy(flask_request.args)) + # Remove pagination related args because those are handled elsewhere + # Also, remove any args that url_for accepts in case the user entered + # those in + for key in ['page', 'per_page', 'endpoint']: + if key in request_args_wo_page: + request_args_wo_page.pop(key) + for key in flask_request.args: + if key.startswith('_'): + request_args_wo_page.pop(key) + + next_page = None + if page < pages: + next_page = url_for( + flask_request.endpoint, page=page + 1, per_page=per_page, + _external=True, **request_args_wo_page) + + prev_page = None + if page > 1: + prev_page = url_for( + flask_request.endpoint, page=page - 1, per_page=per_page, + _external=True, **request_args_wo_page) + + first_page = url_for( + flask_request.endpoint, page=1, per_page=per_page, _external=True, + **request_args_wo_page) + + last_page = url_for( + flask_request.endpoint, page=pages, per_page=per_page, + _external=True, **request_args_wo_page) + + return { + 'page': page, + 'pages': pages, + 'per_page': per_page, + 'prev': prev_page, + 'next': next_page, + 'first': first_page, + 'last': last_page + } diff --git a/tests/test_pagure_flask_api_project.py b/tests/test_pagure_flask_api_project.py index 636d028..0747f8a 100644 --- a/tests/test_pagure_flask_api_project.py +++ b/tests/test_pagure_flask_api_project.py @@ -830,6 +830,266 @@ class PagureFlaskApiProjecttests(tests.Modeltests): } self.assertDictEqual(data, expected_data) + def test_api_projects_pagination(self): + """ Test the api_projects method of the flask api with pagination. """ + tests.create_projects(self.session) + + output = self.app.get('/api/0/projects?page=1') + self.assertEqual(output.status_code, 200) + data = json.loads(output.data) + data['projects'][0]['date_created'] = "1436527638" + data['projects'][1]['date_created'] = "1436527638" + data['projects'][2]['date_created'] = "1436527638" + expected_data = { + "args": { + "fork": None, + "namespace": None, + "owner": None, + "page": 1, + "per_page": 20, + "pattern": None, + "short": False, + "tags": [], + "username": None + }, + "pagination": { + "first": "http://localhost/api/0/projects?per_page=20&page=1", + "last": "http://localhost/api/0/projects?per_page=20&page=1", + "next": None, + "page": 1, + "pages": 1, + "per_page": 20, + "prev": None + }, + "projects": [ + { + "access_groups": { + "admin": [], + "commit": [], + "ticket": []}, + "access_users": { + "admin": [], + "commit": [], + "owner": ["pingou"], + "ticket": [] + }, + "close_status": [ + "Invalid", + "Insufficient data", + "Fixed", + "Duplicate" + ], + "custom_keys": [], + "date_created": "1436527638", + "description": "test project #1", + "fullname": "test", + "id": 1, + "milestones": {}, + "name": "test", + "namespace": None, + "parent": None, + "priorities": {}, + "tags": [], + "user": { + "fullname": "PY C", + "name": "pingou" + } + }, + { + "access_groups": { + "admin": [], + "commit": [], + "ticket": [] + }, + "access_users": { + "admin": [], + "commit": [], + "owner": ["pingou"], + "ticket": [] + }, + "close_status": [ + "Invalid", + "Insufficient data", + "Fixed", + "Duplicate" + ], + "custom_keys": [], + "date_created": "1436527638", + "description": "test project #2", + "fullname": "test2", + "id": 2, + "milestones": {}, + "name": "test2", + "namespace": None, + "parent": None, + "priorities": {}, + "tags": [], + "user": { + "fullname": "PY C", + "name": "pingou" + } + }, + { + "access_groups": { + "admin": [], + "commit": [], + "ticket": []}, + "access_users": { + "admin": [], + "commit": [], + "owner": ["pingou"], + "ticket": []}, + "close_status": [ + "Invalid", + "Insufficient data", + "Fixed", + "Duplicate" + ], + "custom_keys": [], + "date_created": "1436527638", + "description": "namespaced test project", + "fullname": "somenamespace/test3", + "id": 3, + "milestones": {}, + "name": "test3", + "namespace": "somenamespace", + "parent": None, + "priorities": {}, + "tags": [], + "user": { + "fullname": "PY C", + "name": "pingou" + } + } + ], + "total_projects": 3 + } + self.assertDictEqual(data, expected_data) + + def test_api_projects_pagination_per_page(self): + """ Test the api_projects method of the flask api with pagination and + the `per_page` argument set. """ + tests.create_projects(self.session) + + output = self.app.get('/api/0/projects?page=2&per_page=2') + self.assertEqual(output.status_code, 200) + data = json.loads(output.data) + data['projects'][0]['date_created'] = "1436527638" + expected_data = { + "args": { + "fork": None, + "namespace": None, + "owner": None, + "page": 2, + "per_page": 2, + "pattern": None, + "short": False, + "tags": [], + "username": None + }, + "pagination": { + "first": "http://localhost/api/0/projects?per_page=2&page=1", + "last": "http://localhost/api/0/projects?per_page=2&page=2", + "next": None, + "page": 2, + "pages": 2, + "per_page": 2, + "prev": "http://localhost/api/0/projects?per_page=2&page=1", + }, + "projects": [ + { + "access_groups": { + "admin": [], + "commit": [], + "ticket": [] + }, + "access_users": { + "admin": [], + "commit": [], + "owner": ["pingou"], + "ticket": [] + }, + "close_status": [ + "Invalid", + "Insufficient data", + "Fixed", + "Duplicate" + ], + "custom_keys": [], + "date_created": "1436527638", + "description": "namespaced test project", + "fullname": "somenamespace/test3", + "id": 3, + "milestones": {}, + "name": "test3", + "namespace": "somenamespace", + "parent": None, + "priorities": {}, + "tags": [], + "user": { + "fullname": "PY C", + "name": "pingou" + } + } + ], + "total_projects": 3 + } + self.assertDictEqual(data, expected_data) + + def test_api_projects_pagination_invalid_page(self): + """ Test the api_projects method of the flask api when an invalid page + value is entered. """ + tests.create_projects(self.session) + + output = self.app.get('/api/0/projects?page=-3') + self.assertEqual(output.status_code, 400) + + def test_api_projects_pagination_invalid_page_str(self): + """ Test the api_projects method of the flask api when an invalid type + for the page value is entered. """ + tests.create_projects(self.session) + + output = self.app.get('/api/0/projects?page=abcd') + self.assertEqual(output.status_code, 400) + + def test_api_projects_pagination_invalid_per_page_too_low(self): + """ Test the api_projects method of the flask api when a per_page + value is below 1. """ + tests.create_projects(self.session) + + output = self.app.get('/api/0/projects?page=1&per_page=0') + self.assertEqual(output.status_code, 400) + error = json.loads(output.data) + self.assertEqual( + error['error'], 'The per_page value must be between 1 and 100') + + def test_api_projects_pagination_invalid_per_page_too_high(self): + """ Test the api_projects method of the flask api when a per_page + value is above 100. """ + tests.create_projects(self.session) + + output = self.app.get('/api/0/projects?page=1&per_page=101') + self.assertEqual(output.status_code, 400) + error = json.loads(output.data) + self.assertEqual( + error['error'], 'The per_page value must be between 1 and 100') + + def test_api_projects_pagination_invalid_per_page_str(self): + """ Test the api_projects method of the flask api when an invalid type + for the per_page value is entered. """ + tests.create_projects(self.session) + + output = self.app.get('/api/0/projects?page=1&per_page=abcd') + self.assertEqual(output.status_code, 400) + + def test_api_projects_pagination_beyond_last_page(self): + """ Test the api_projects method of the flask api when a page value + that is larger than the last page is entered. """ + tests.create_projects(self.session) + + output = self.app.get('/api/0/projects?page=99999') + self.assertEqual(output.status_code, 404) + def test_api_modify_project_main_admin(self): """ Test the api_modify_project method of the flask api when the request is to change the main_admin of the project. """