mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-04-24 23:37:18 +00:00
feat: add pagination to packages list
This commit is contained in:
parent
657bcdcc0b
commit
98e594df90
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user