From 0875cd45c5f860395a64fd42cb911784ccfa6a56 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Wed, 11 Sep 2024 13:57:57 +0200 Subject: [PATCH] add rest api pagination (#1994) --- config/settings/base.py | 3 + landingzones/tests/test_views_api.py | 177 ++++++++++++++------------- landingzones/views_api.py | 7 ++ samplesheets/tests/test_views_api.py | 78 ++++++++++-- samplesheets/views_api.py | 17 +++ 5 files changed, 182 insertions(+), 100 deletions(-) diff --git a/config/settings/base.py b/config/settings/base.py index f70ab9c5..c6d943db 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -345,6 +345,9 @@ 'knox.auth.TokenAuthentication', ), 'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler', + 'DEFAULT_PAGINATION_CLASS': ( + 'rest_framework.pagination.PageNumberPagination' + ), 'PAGE_SIZE': env.int('SODAR_API_PAGE_SIZE', 100), } diff --git a/landingzones/tests/test_views_api.py b/landingzones/tests/test_views_api.py index 6282bcaa..d81548a9 100644 --- a/landingzones/tests/test_views_api.py +++ b/landingzones/tests/test_views_api.py @@ -61,7 +61,7 @@ def setUp(self): self.study = self.investigation.studies.first() self.assay = self.study.assays.first() # Create LandingZone - self.landing_zone = self.make_landing_zone( + self.zone = self.make_landing_zone( title=ZONE_TITLE, project=self.project, user=self.user, @@ -74,50 +74,79 @@ def setUp(self): class TestLandingZoneListAPIView(TestLandingZoneAPIViewsBase): """Tests for LandingZoneListAPIView""" - def test_get_owner(self): - """Test LandingZoneListAPIView get() as project owner""" - irods_backend = get_backend_api('omics_irods') - url = reverse( + def setUp(self): + super().setUp() + self.irods_backend = get_backend_api('omics_irods') + self.url = reverse( 'landingzones:api_list', kwargs={'project': self.project.sodar_uuid} ) - response = self.request_knox(url) + def test_get_owner(self): + """Test LandingZoneListAPIView GET as project owner""" + response = self.request_knox(self.url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) expected = { - 'title': self.landing_zone.title, + 'title': self.zone.title, 'project': str(self.project.sodar_uuid), 'user': self.get_serialized_user(self.user), 'assay': str(self.assay.sodar_uuid), - 'status': self.landing_zone.status, - 'status_info': self.landing_zone.status_info, + 'status': self.zone.status, + 'status_info': self.zone.status_info, 'status_locked': False, - 'date_modified': self.get_drf_datetime( - self.landing_zone.date_modified - ), - 'description': self.landing_zone.description, - 'user_message': self.landing_zone.user_message, - 'configuration': self.landing_zone.configuration, - 'config_data': self.landing_zone.config_data, - 'irods_path': irods_backend.get_path(self.landing_zone), - 'sodar_uuid': str(self.landing_zone.sodar_uuid), + 'date_modified': self.get_drf_datetime(self.zone.date_modified), + 'description': self.zone.description, + 'user_message': self.zone.user_message, + 'configuration': self.zone.configuration, + 'config_data': self.zone.config_data, + 'irods_path': self.irods_backend.get_path(self.zone), + 'sodar_uuid': str(self.zone.sodar_uuid), } self.assertEqual(json.loads(response.content)[0], expected) + def test_get_pagination(self): + """Test GET with pagination""" + url = self.url + '?page=1' + response = self.request_knox(url) + self.assertEqual(response.status_code, 200) + expected = { + 'count': 1, + 'next': None, + 'previous': None, + 'results': [ + { + 'title': self.zone.title, + 'project': str(self.project.sodar_uuid), + 'user': self.get_serialized_user(self.user), + 'assay': str(self.assay.sodar_uuid), + 'status': self.zone.status, + 'status_info': self.zone.status_info, + 'status_locked': False, + 'date_modified': self.get_drf_datetime( + self.zone.date_modified + ), + 'description': self.zone.description, + 'user_message': self.zone.user_message, + 'configuration': self.zone.configuration, + 'config_data': self.zone.config_data, + 'irods_path': self.irods_backend.get_path(self.zone), + 'sodar_uuid': str(self.zone.sodar_uuid), + } + ], + } + self.assertEqual(json.loads(response.content), expected) + def test_get_no_own_zones(self): - """Test LandingZoneListAPIView get() as user with no own zones""" - url = reverse( - 'landingzones:api_list', kwargs={'project': self.project.sodar_uuid} - ) + """Test GET as user with no own zones""" response = self.request_knox( - url, token=self.get_token(self.user_contributor) + self.url, token=self.get_token(self.user_contributor) ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 0) def test_get_finished_default(self): - """Test get() with finished zone and no finished parameter""" + """Test GET with finished zone and no finished parameter""" self.make_landing_zone( title=ZONE_TITLE + '_moved', project=self.project, @@ -126,19 +155,16 @@ def test_get_finished_default(self): description=ZONE_DESC, status=ZONE_STATUS_MOVED, ) - url = reverse( - 'landingzones:api_list', kwargs={'project': self.project.sodar_uuid} - ) - response = self.request_knox(url) + response = self.request_knox(self.url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual( json.loads(response.content)[0]['sodar_uuid'], - str(self.landing_zone.sodar_uuid), + str(self.zone.sodar_uuid), ) def test_get_finished_false(self): - """Test get() with finished zone and finished=0""" + """Test GET with finished zone and finished=0""" self.make_landing_zone( title=ZONE_TITLE + '_moved', project=self.project, @@ -147,23 +173,17 @@ def test_get_finished_false(self): description=ZONE_DESC, status=ZONE_STATUS_MOVED, ) - url = ( - reverse( - 'landingzones:api_list', - kwargs={'project': self.project.sodar_uuid}, - ) - + '?finished=0' - ) + url = self.url + '?finished=0' response = self.request_knox(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) self.assertEqual( json.loads(response.content)[0]['sodar_uuid'], - str(self.landing_zone.sodar_uuid), + str(self.zone.sodar_uuid), ) def test_get_finished_true(self): - """Test get() with finished zone and finished=1""" + """Test GET with finished zone and finished=1""" self.make_landing_zone( title=ZONE_TITLE + '_moved', project=self.project, @@ -172,13 +192,7 @@ def test_get_finished_true(self): description=ZONE_DESC, status=ZONE_STATUS_MOVED, ) - url = ( - reverse( - 'landingzones:api_list', - kwargs={'project': self.project.sodar_uuid}, - ) - + '?finished=1' - ) + url = self.url + '?finished=1' response = self.request_knox(url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 2) @@ -188,42 +202,40 @@ class TestLandingZoneRetrieveAPIView(TestLandingZoneAPIViewsBase): """Tests for LandingZoneRetrieveAPIView""" def test_get(self): - """Test LandingZoneRetrieveAPIView get() as zone owner""" + """Test LandingZoneRetrieveAPIView GET as zone owner""" irods_backend = get_backend_api('omics_irods') url = reverse( 'landingzones:api_retrieve', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, + kwargs={'landingzone': self.zone.sodar_uuid}, ) response = self.request_knox(url) self.assertEqual(response.status_code, 200) expected = { - 'title': self.landing_zone.title, + 'title': self.zone.title, 'project': str(self.project.sodar_uuid), 'user': self.get_serialized_user(self.user), 'assay': str(self.assay.sodar_uuid), - 'status': self.landing_zone.status, - 'status_info': self.landing_zone.status_info, + 'status': self.zone.status, + 'status_info': self.zone.status_info, 'status_locked': False, - 'date_modified': self.get_drf_datetime( - self.landing_zone.date_modified - ), - 'description': self.landing_zone.description, - 'user_message': self.landing_zone.user_message, - 'configuration': self.landing_zone.configuration, - 'config_data': self.landing_zone.config_data, - 'irods_path': irods_backend.get_path(self.landing_zone), - 'sodar_uuid': str(self.landing_zone.sodar_uuid), + 'date_modified': self.get_drf_datetime(self.zone.date_modified), + 'description': self.zone.description, + 'user_message': self.zone.user_message, + 'configuration': self.zone.configuration, + 'config_data': self.zone.config_data, + 'irods_path': irods_backend.get_path(self.zone), + 'sodar_uuid': str(self.zone.sodar_uuid), } self.assertEqual(json.loads(response.content), expected) def test_get_locked(self): - """Test get() with locked landing zone status""" - self.landing_zone.status = ZONE_STATUS_MOVING - self.landing_zone.save() + """Test GET with locked landing zone status""" + self.zone.status = ZONE_STATUS_MOVING + self.zone.save() url = reverse( 'landingzones:api_retrieve', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, + kwargs={'landingzone': self.zone.sodar_uuid}, ) response = self.request_knox(url) self.assertEqual(response.status_code, 200) @@ -233,17 +245,20 @@ def test_get_locked(self): class TestLandingZoneUpdateAPIView(TestLandingZoneAPIViewsBase): """Tests for LandingZoneUpdateAPIView""" - def test_patch(self): - """Test LandingZoneUpdateAPIView patch() as zone owner""" - url = reverse( + def setUp(self): + super().setUp() + self.url = reverse( 'landingzones:api_update', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, + kwargs={'landingzone': self.zone.sodar_uuid}, ) + + def test_patch(self): + """Test LandingZoneUpdateAPIView PATCH as zone owner""" data = { 'description': 'New description', 'user_message': 'New user message', } - response = self.request_knox(url, method='PATCH', data=data) + response = self.request_knox(self.url, method='PATCH', data=data) self.assertEqual(response.status_code, 200) self.assertEqual( json.loads(response.content)['description'], 'New description' @@ -253,26 +268,18 @@ def test_patch(self): ) def test_patch_title(self): - """Test updating title with patch() (should fail)""" - url = reverse( - 'landingzones:api_update', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) + """Test PATCH to update title (should fail)""" data = {'title': 'New title'} - response = self.request_knox(url, method='PATCH', data=data) + response = self.request_knox(self.url, method='PATCH', data=data) self.assertEqual(response.status_code, 400) def test_put(self): - """Test LandingZoneUpdateAPIView put() as zone owner""" - url = reverse( - 'landingzones:api_update', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) + """Test PUT as zone owner""" data = { 'description': 'New description', 'user_message': 'New user message', } - response = self.request_knox(url, method='PUT', data=data) + response = self.request_knox(self.url, method='PUT', data=data) self.assertEqual(response.status_code, 200) self.assertEqual( json.loads(response.content)['description'], 'New description' @@ -282,11 +289,7 @@ def test_put(self): ) def test_put_title(self): - """Test updating title with put() (should fail)""" - url = reverse( - 'landingzones:api_update', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) + """Test PUT to update title (should fail)""" data = {'title': 'New title'} - response = self.request_knox(url, method='PUT', data=data) + response = self.request_knox(self.url, method='PUT', data=data) self.assertEqual(response.status_code, 400) diff --git a/landingzones/views_api.py b/landingzones/views_api.py index 74865264..30c08918 100644 --- a/landingzones/views_api.py +++ b/landingzones/views_api.py @@ -23,6 +23,7 @@ from projectroles.views_api import ( SODARAPIBaseProjectMixin, SODARAPIGenericProjectMixin, + SODARPageNumberPagination, ) # Samplesheets dependency @@ -119,6 +120,10 @@ class ZoneListAPIView( finished (meaning moved or deleted) zones if the "finished" parameter is set. + Supports optional pagination for listing by providing the ``page`` query + string. This will return results in the Django Rest Framework + ``PageNumberPagination`` format. + **URL:** ``/landingzones/api/list/{Project.sodar_uuid}?finished={integer}`` **Methods:** ``GET`` @@ -126,10 +131,12 @@ class ZoneListAPIView( **Parameters:** - ``finished``: Include finished zones if 1 (integer) + - ``page``: Page number for paginated results (int, optional) **Returns:** List of landing zone details (see ``ZoneRetrieveAPIView``) """ + pagination_class = SODARPageNumberPagination permission_required = 'landingzones.view_zone_own' serializer_class = LandingZoneSerializer diff --git a/samplesheets/tests/test_views_api.py b/samplesheets/tests/test_views_api.py index 4ebf26d3..7b22f1ea 100644 --- a/samplesheets/tests/test_views_api.py +++ b/samplesheets/tests/test_views_api.py @@ -739,9 +739,6 @@ def test_get(self): with self.login(self.user_contributor): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - local_date_created = self.ticket.date_created.astimezone( - timezone.get_current_timezone() - ) expected = { 'sodar_uuid': str(self.ticket.sodar_uuid), 'label': self.ticket.label, @@ -749,7 +746,7 @@ def test_get(self): 'study': str(self.study.sodar_uuid), 'assay': str(self.assay.sodar_uuid), 'path': self.ticket.path, - 'date_created': local_date_created.isoformat(), + 'date_created': self.get_drf_datetime(self.ticket.date_created), 'date_expires': self.ticket.date_expires, 'user': self.get_serialized_user(self.user), 'is_active': self.ticket.is_active(), @@ -771,9 +768,6 @@ def test_get_active(self): with self.login(self.user_contributor): response = self.client.get(self.url + '?active=1') self.assertEqual(response.status_code, 200) - local_date_created = self.ticket.date_created.astimezone( - timezone.get_current_timezone() - ) expected = { 'sodar_uuid': str(self.ticket.sodar_uuid), 'label': self.ticket.label, @@ -781,13 +775,42 @@ def test_get_active(self): 'study': str(self.study.sodar_uuid), 'assay': str(self.assay.sodar_uuid), 'path': self.ticket.path, - 'date_created': local_date_created.isoformat(), + 'date_created': self.get_drf_datetime(self.ticket.date_created), 'date_expires': self.ticket.date_expires, 'user': self.get_serialized_user(self.user), 'is_active': self.ticket.is_active(), } self.assertEqual(json.loads(response.content), [expected]) + def test_get_pagination(self): + """Test GET with pagination""" + url = self.url + '?page=1' + with self.login(self.user_contributor): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + expected = { + 'count': 1, + 'next': None, + 'previous': None, + 'results': [ + { + 'sodar_uuid': str(self.ticket.sodar_uuid), + 'label': self.ticket.label, + 'ticket': self.ticket.ticket, + 'study': str(self.study.sodar_uuid), + 'assay': str(self.assay.sodar_uuid), + 'path': self.ticket.path, + 'date_created': self.get_drf_datetime( + self.ticket.date_created + ), + 'date_expires': self.ticket.date_expires, + 'user': self.get_serialized_user(self.user), + 'is_active': self.ticket.is_active(), + } + ], + } + self.assertEqual(json.loads(response.content), expected) + class TestIrodsDataRequestRetrieveAPIView( IrodsDataRequestMixin, SampleSheetAPIViewTestBase @@ -868,7 +891,7 @@ def setUp(self): ) def test_get(self): - """Test retrieving iRODS data request list""" + """Test IrodsDataRequestListAPIView GET""" response = self.request_knox(self.url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 1) @@ -887,8 +910,37 @@ def test_get(self): } self.assertEqual(response_data[0], expected) + def test_get_pagination(self): + """Test GET with pagination""" + url = self.url + '?page=1' + response = self.request_knox(url) + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content) + expected = { + 'count': 1, + 'next': None, + 'previous': None, + 'results': [ + { + 'project': str(self.project.sodar_uuid), + 'action': IRODS_REQUEST_ACTION_DELETE, + 'path': self.request.path, + 'target_path': '', + 'user': self.get_serialized_user(self.user_contributor), + 'status': IRODS_REQUEST_STATUS_ACTIVE, + 'status_info': '', + 'description': self.request.description, + 'date_created': self.get_drf_datetime( + self.request.date_created + ), + 'sodar_uuid': str(self.request.sodar_uuid), + } + ], + } + self.assertEqual(response_data, expected) + def test_get_failed_as_superuser(self): - """Test retrieving list as superuser with failed request""" + """Test GET as superuser with failed request""" self.request.status = IRODS_REQUEST_STATUS_FAILED self.request.save() response = self.request_knox(self.url) @@ -896,7 +948,7 @@ def test_get_failed_as_superuser(self): self.assertEqual(len(response.data), 1) def test_get_accepted_as_superuser(self): - """Test retrieving list as superuser with accepted request""" + """Test GET as superuser with accepted request""" self.request.status = IRODS_REQUEST_STATUS_ACCEPTED self.request.save() response = self.request_knox(self.url) @@ -904,7 +956,7 @@ def test_get_accepted_as_superuser(self): self.assertEqual(len(response.data), 0) def test_get_accepted_as_owner(self): - """Test retrieving list as owner with accepted request""" + """Test GET as owner with accepted request""" self.request.status = IRODS_REQUEST_STATUS_ACCEPTED self.request.save() response = self.request_knox( @@ -914,7 +966,7 @@ def test_get_accepted_as_owner(self): self.assertEqual(len(response.data), 0) def test_get_accepted_as_request_creator(self): - """Test retrieving list as request creator with accepted request""" + """Test GET as request creator with accepted request""" self.request.status = IRODS_REQUEST_STATUS_ACCEPTED self.request.save() response = self.request_knox( diff --git a/samplesheets/views_api.py b/samplesheets/views_api.py index f33df8ad..f2a35463 100644 --- a/samplesheets/views_api.py +++ b/samplesheets/views_api.py @@ -38,6 +38,7 @@ SODARAPIBaseMixin, SODARAPIBaseProjectMixin, SODARAPIGenericProjectMixin, + SODARPageNumberPagination, ) from projectroles.utils import build_secret @@ -388,6 +389,10 @@ class IrodsAccessTicketListAPIView( """ List iRODS access tickets for a project. + Supports optional pagination for listing by providing the ``page`` query + string. This will return results in the Django Rest Framework + ``PageNumberPagination`` format. + **URL:** ``/samplesheets/api/irods/ticket/list/{Project.sodar_uuid}`` **Methods:** ``GET`` @@ -395,10 +400,12 @@ class IrodsAccessTicketListAPIView( **Query parameters:** - ``active`` (boolean, optional, default=false) + - ``page``: Page number for paginated results (int, optional) **Returns:** List of ticket dicts, see ``IrodsAccessTicketRetrieveAPIView`` """ + pagination_class = SODARPageNumberPagination permission_required = 'samplesheets.edit_sheet' serializer_class = IrodsAccessTicketSerializer @@ -584,13 +591,22 @@ class IrodsDataRequestListAPIView( all requests with the status of ACTIVE or FAILED. If called as a contributor, returns the user's own requests regardless of the state. + Supports optional pagination for listing by providing the ``page`` query + string. This will return results in the Django Rest Framework + ``PageNumberPagination`` format. + **URL:** ``/samplesheets/api/irods/requests/{Project.sodar_uuid}`` **Methods:** ``GET`` + **Query parameters:** + + - ``page``: Page number for paginated results (int, optional) + **Returns:** List of iRODS data requests (list of dicts) """ + pagination_class = SODARPageNumberPagination permission_required = 'samplesheets.edit_sheet' serializer_class = IrodsDataRequestSerializer @@ -879,6 +895,7 @@ def get(self, request, *args, **kwargs): return Response(ret, status=status.HTTP_200_OK) +# TODO: Add pagination (see #1996, #1997) class ProjectIrodsFileListAPIView( SamplesheetsAPIVersioningMixin, SODARAPIBaseProjectMixin, APIView ):