mirror of
				https://github.com/arcan1s/ahriman.git
				synced 2025-10-31 13:53:41 +00:00 
			
		
		
		
	feat: add pagination to packages list
This commit is contained in:
		| @ -18,7 +18,7 @@ | |||||||
| # along with this program. If not, see <http://www.gnu.org/licenses/>. | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||||
| # | # | ||||||
| from aiohttp_cors import CorsViewMixin  # type: ignore[import] | from aiohttp_cors import CorsViewMixin  # type: ignore[import] | ||||||
| from aiohttp.web import Request, StreamResponse, View | from aiohttp.web import HTTPBadRequest, Request, StreamResponse, View | ||||||
| from collections.abc import Awaitable, Callable | from collections.abc import Awaitable, Callable | ||||||
| from typing import Any, TypeVar | from typing import Any, TypeVar | ||||||
|  |  | ||||||
| @ -184,6 +184,30 @@ class BaseView(View, CorsViewMixin): | |||||||
|  |  | ||||||
|         self._raise_allowed_methods() |         self._raise_allowed_methods() | ||||||
|  |  | ||||||
|  |     def page(self) -> tuple[int, int]: | ||||||
|  |         """ | ||||||
|  |         parse limit and offset and return values | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             tuple[int, int]: limit and offset from request | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             HTTPBadRequest: if supplied parameters are invalid | ||||||
|  |         """ | ||||||
|  |         try: | ||||||
|  |             limit = int(self.request.query.getone("limit", default=-1)) | ||||||
|  |             offset = int(self.request.query.getone("offset", default=0)) | ||||||
|  |         except Exception as ex: | ||||||
|  |             raise HTTPBadRequest(reason=str(ex)) | ||||||
|  |  | ||||||
|  |         # some checks | ||||||
|  |         if limit < -1: | ||||||
|  |             raise HTTPBadRequest(reason=f"Limit must be -1 or non-negative, got {limit}") | ||||||
|  |         if offset < 0: | ||||||
|  |             raise HTTPBadRequest(reason=f"Offset must be non-negative, got {offset}") | ||||||
|  |  | ||||||
|  |         return limit, offset | ||||||
|  |  | ||||||
|     async def username(self) -> str | None: |     async def username(self) -> str | None: | ||||||
|         """ |         """ | ||||||
|         extract username from request if any |         extract username from request if any | ||||||
|  | |||||||
| @ -18,11 +18,15 @@ | |||||||
| # along with this program. If not, see <http://www.gnu.org/licenses/>. | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||||
| # | # | ||||||
| import aiohttp_apispec  # type: ignore[import] | import aiohttp_apispec  # type: ignore[import] | ||||||
|  | import itertools | ||||||
|  |  | ||||||
|  | from collections.abc import Callable | ||||||
| from aiohttp.web import HTTPNoContent, Response, json_response | from aiohttp.web import HTTPNoContent, Response, json_response | ||||||
|  |  | ||||||
|  | from ahriman.models.build_status import BuildStatus | ||||||
|  | from ahriman.models.package import Package | ||||||
| from ahriman.models.user_access import UserAccess | from ahriman.models.user_access import UserAccess | ||||||
| from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageStatusSchema | from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageStatusSchema, PaginationSchema | ||||||
| from ahriman.web.views.base import BaseView | from ahriman.web.views.base import BaseView | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -41,9 +45,10 @@ class PackagesView(BaseView): | |||||||
|     @aiohttp_apispec.docs( |     @aiohttp_apispec.docs( | ||||||
|         tags=["Packages"], |         tags=["Packages"], | ||||||
|         summary="Get packages list", |         summary="Get packages list", | ||||||
|         description="Retrieve all packages and their descriptors", |         description="Retrieve packages and their descriptors", | ||||||
|         responses={ |         responses={ | ||||||
|             200: {"description": "Success response", "schema": PackageStatusSchema(many=True)}, |             200: {"description": "Success response", "schema": PackageStatusSchema(many=True)}, | ||||||
|  |             400: {"description": "Bad data is supplied", "schema": ErrorSchema}, | ||||||
|             401: {"description": "Authorization required", "schema": ErrorSchema}, |             401: {"description": "Authorization required", "schema": ErrorSchema}, | ||||||
|             403: {"description": "Access is forbidden", "schema": ErrorSchema}, |             403: {"description": "Access is forbidden", "schema": ErrorSchema}, | ||||||
|             500: {"description": "Internal server error", "schema": ErrorSchema}, |             500: {"description": "Internal server error", "schema": ErrorSchema}, | ||||||
| @ -51,6 +56,7 @@ class PackagesView(BaseView): | |||||||
|         security=[{"token": [GET_PERMISSION]}], |         security=[{"token": [GET_PERMISSION]}], | ||||||
|     ) |     ) | ||||||
|     @aiohttp_apispec.cookies_schema(AuthSchema) |     @aiohttp_apispec.cookies_schema(AuthSchema) | ||||||
|  |     @aiohttp_apispec.querystring_schema(PaginationSchema) | ||||||
|     async def get(self) -> Response: |     async def get(self) -> Response: | ||||||
|         """ |         """ | ||||||
|         get current packages status |         get current packages status | ||||||
| @ -58,12 +64,17 @@ class PackagesView(BaseView): | |||||||
|         Returns: |         Returns: | ||||||
|             Response: 200 with package description on success |             Response: 200 with package description on success | ||||||
|         """ |         """ | ||||||
|  |         limit, offset = self.page() | ||||||
|  |         stop = offset + limit if limit >= 0 else None | ||||||
|  |  | ||||||
|  |         comparator: Callable[[tuple[Package, BuildStatus]], str] = lambda pair: pair[0].base | ||||||
|         response = [ |         response = [ | ||||||
|             { |             { | ||||||
|                 "package": package.view(), |                 "package": package.view(), | ||||||
|                 "status": status.view() |                 "status": status.view() | ||||||
|             } for package, status in self.service.packages |             } for package, status in itertools.islice(sorted(self.service.packages, key=comparator), offset, stop) | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|         return json_response(response) |         return json_response(response) | ||||||
|  |  | ||||||
|     @aiohttp_apispec.docs( |     @aiohttp_apispec.docs( | ||||||
|  | |||||||
| @ -19,7 +19,7 @@ | |||||||
| # | # | ||||||
| import aiohttp_apispec  # type: ignore[import] | import aiohttp_apispec  # type: ignore[import] | ||||||
|  |  | ||||||
| from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response | from aiohttp.web import HTTPNotFound, Response, json_response | ||||||
|  |  | ||||||
| from ahriman.core.exceptions import UnknownPackageError | from ahriman.core.exceptions import UnknownPackageError | ||||||
| from ahriman.models.user_access import UserAccess | from ahriman.models.user_access import UserAccess | ||||||
| @ -63,15 +63,10 @@ class LogsView(BaseView): | |||||||
|             Response: 200 with package logs on success |             Response: 200 with package logs on success | ||||||
|  |  | ||||||
|         Raises: |         Raises: | ||||||
|             HTTPBadRequest: if supplied parameters are invalid |  | ||||||
|             HTTPNotFound: if package base is unknown |             HTTPNotFound: if package base is unknown | ||||||
|         """ |         """ | ||||||
|         package_base = self.request.match_info["package"] |         package_base = self.request.match_info["package"] | ||||||
|         try: |         limit, offset = self.page() | ||||||
|             limit = int(self.request.query.getone("limit", default=-1)) |  | ||||||
|             offset = int(self.request.query.getone("offset", default=0)) |  | ||||||
|         except Exception as ex: |  | ||||||
|             raise HTTPBadRequest(reason=str(ex)) |  | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             _, status = self.service.package_get(package_base) |             _, status = self.service.package_get(package_base) | ||||||
|  | |||||||
| @ -21,7 +21,7 @@ from ahriman.web.web import setup_service | |||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.helpers.register | @pytest.helpers.register | ||||||
| def request(application: Application, path: str, method: str, json: Any = None, data: Any = None, | def request(application: Application, path: str, method: str, params: Any = None, json: Any = None, data: Any = None, | ||||||
|             extra: dict[str, Any] | None = None, resource: Resource | None = None) -> MagicMock: |             extra: dict[str, Any] | None = None, resource: Resource | None = None) -> MagicMock: | ||||||
|     """ |     """ | ||||||
|     request generator helper |     request generator helper | ||||||
| @ -30,6 +30,7 @@ def request(application: Application, path: str, method: str, json: Any = None, | |||||||
|         application(Application): application fixture |         application(Application): application fixture | ||||||
|         path(str): path for the request |         path(str): path for the request | ||||||
|         method(str): method for the request |         method(str): method for the request | ||||||
|  |         params(Any, optional): query parameters (Default value = None) | ||||||
|         json(Any, optional): json payload of the request (Default value = None) |         json(Any, optional): json payload of the request (Default value = None) | ||||||
|         data(Any, optional): form data payload of the request (Default value = None) |         data(Any, optional): form data payload of the request (Default value = None) | ||||||
|         extra(dict[str, Any] | None, optional): extra info which will be injected for ``get_extra_info`` command |         extra(dict[str, Any] | None, optional): extra info which will be injected for ``get_extra_info`` command | ||||||
| @ -42,6 +43,7 @@ def request(application: Application, path: str, method: str, json: Any = None, | |||||||
|     request_mock.app = application |     request_mock.app = application | ||||||
|     request_mock.path = path |     request_mock.path = path | ||||||
|     request_mock.method = method |     request_mock.method = method | ||||||
|  |     request_mock.query = params | ||||||
|     request_mock.json = json |     request_mock.json = json | ||||||
|     request_mock.post = data |     request_mock.post = data | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ import pytest | |||||||
|  |  | ||||||
| from multidict import MultiDict | from multidict import MultiDict | ||||||
| from aiohttp.test_utils import TestClient | from aiohttp.test_utils import TestClient | ||||||
|  | from aiohttp.web import HTTPBadRequest | ||||||
| from pytest_mock import MockerFixture | from pytest_mock import MockerFixture | ||||||
| from unittest.mock import AsyncMock | from unittest.mock import AsyncMock | ||||||
|  |  | ||||||
| @ -150,6 +151,41 @@ async def test_head_not_allowed(client: TestClient) -> None: | |||||||
|     assert response.status == 405 |     assert response.status == 405 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_page(base: BaseView) -> None: | ||||||
|  |     """ | ||||||
|  |     must extract page from query parameters | ||||||
|  |     """ | ||||||
|  |     base._request = pytest.helpers.request(base.request.app, "", "", params=MultiDict(limit=2, offset=3)) | ||||||
|  |     assert base.page() == (2, 3) | ||||||
|  |  | ||||||
|  |     base._request = pytest.helpers.request(base.request.app, "", "", params=MultiDict(offset=3)) | ||||||
|  |     assert base.page() == (-1, 3) | ||||||
|  |  | ||||||
|  |     base._request = pytest.helpers.request(base.request.app, "", "", params=MultiDict(limit=2)) | ||||||
|  |     assert base.page() == (2, 0) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_page_bad_request(base: BaseView) -> None: | ||||||
|  |     """ | ||||||
|  |     must raise HTTPBadRequest in case if parameters are invalid | ||||||
|  |     """ | ||||||
|  |     with pytest.raises(HTTPBadRequest): | ||||||
|  |         base._request = pytest.helpers.request(base.request.app, "", "", params=MultiDict(limit="string")) | ||||||
|  |         base.page() | ||||||
|  |  | ||||||
|  |     with pytest.raises(HTTPBadRequest): | ||||||
|  |         base._request = pytest.helpers.request(base.request.app, "", "", params=MultiDict(offset="string")) | ||||||
|  |         base.page() | ||||||
|  |  | ||||||
|  |     with pytest.raises(HTTPBadRequest): | ||||||
|  |         base._request = pytest.helpers.request(base.request.app, "", "", params=MultiDict(limit=-2)) | ||||||
|  |         base.page() | ||||||
|  |  | ||||||
|  |     with pytest.raises(HTTPBadRequest): | ||||||
|  |         base._request = pytest.helpers.request(base.request.app, "", "", params=MultiDict(offset=-1)) | ||||||
|  |         base.page() | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_username(base: BaseView, mocker: MockerFixture) -> None: | async def test_username(base: BaseView, mocker: MockerFixture) -> None: | ||||||
|     """ |     """ | ||||||
|     must return identity of logged-in user |     must return identity of logged-in user | ||||||
|  | |||||||
| @ -32,7 +32,7 @@ async def test_get(client: TestClient, package_ahriman: Package, package_python_ | |||||||
|     response_schema = pytest.helpers.schema_response(PackagesView.get) |     response_schema = pytest.helpers.schema_response(PackagesView.get) | ||||||
|  |  | ||||||
|     response = await client.get("/api/v1/packages") |     response = await client.get("/api/v1/packages") | ||||||
|     assert response.ok |     assert response.status == 200 | ||||||
|     json = await response.json() |     json = await response.json() | ||||||
|     assert not response_schema.validate(json, many=True) |     assert not response_schema.validate(json, many=True) | ||||||
|  |  | ||||||
| @ -41,6 +41,30 @@ async def test_get(client: TestClient, package_ahriman: Package, package_python_ | |||||||
|     assert {package.base for package in packages} == {package_ahriman.base, package_python_schedule.base} |     assert {package.base for package in packages} == {package_ahriman.base, package_python_schedule.base} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_with_pagination(client: TestClient, package_ahriman: Package, | ||||||
|  |                                    package_python_schedule: Package) -> None: | ||||||
|  |     """ | ||||||
|  |     must return paginated status for packages | ||||||
|  |     """ | ||||||
|  |     await client.post(f"/api/v1/packages/{package_ahriman.base}", | ||||||
|  |                       json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()}) | ||||||
|  |     await client.post(f"/api/v1/packages/{package_python_schedule.base}", | ||||||
|  |                       json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()}) | ||||||
|  |     request_schema = pytest.helpers.schema_request(PackagesView.get, location="querystring") | ||||||
|  |     response_schema = pytest.helpers.schema_response(PackagesView.get) | ||||||
|  |  | ||||||
|  |     payload = {"limit": 1, "offset": 1} | ||||||
|  |     assert not request_schema.validate(payload) | ||||||
|  |     response = await client.get("/api/v1/packages", params=payload) | ||||||
|  |     assert response.status == 200 | ||||||
|  |     json = await response.json() | ||||||
|  |     assert not response_schema.validate(json, many=True) | ||||||
|  |  | ||||||
|  |     packages = [Package.from_json(item["package"]) for item in json] | ||||||
|  |     assert packages | ||||||
|  |     assert {package.base for package in packages} == {package_python_schedule.base} | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_post(client: TestClient, mocker: MockerFixture) -> None: | async def test_post(client: TestClient, mocker: MockerFixture) -> None: | ||||||
|     """ |     """ | ||||||
|     must be able to reload packages |     must be able to reload packages | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user