diff --git a/docs/entities.rst b/docs/entities.rst index 6568aeb58d..3af529122a 100644 --- a/docs/entities.rst +++ b/docs/entities.rst @@ -235,6 +235,31 @@ Body: ] } +Download EntityList +------------------- +.. raw:: html + +
GET api/v2/entity-lists/<entity_list_id>/download
+ +or + +.. raw:: html + +
GET api/v2/entity-lists/<entity_list_id>.csv
+ + +This endpoints are used to download the dataset in CSV format. + +**Example** + +.. code-block:: bash + + curl -X GET https://api.ona.io/api/v2/entity-lists/1/download \ + -H "Authorization: Token ACCESS_TOKEN" + +**Response** + +Status: ``200 OK`` Delete EntityList ----------------- diff --git a/onadata/apps/api/tests/viewsets/test_entity_list_viewset.py b/onadata/apps/api/tests/viewsets/test_entity_list_viewset.py index 87cf5da9d9..3e074f2088 100644 --- a/onadata/apps/api/tests/viewsets/test_entity_list_viewset.py +++ b/onadata/apps/api/tests/viewsets/test_entity_list_viewset.py @@ -505,6 +505,25 @@ def test_soft_deleted(self): response = self.view(request, pk=self.entity_list.pk) self.assertEqual(response.status_code, 404) + def test_render_csv(self): + """Render in CSV format""" + request = self.factory.get("/", **self.extra) + # Using `.csv` suffix + response = self.view(request, pk=self.entity_list.pk, format="csv") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.get("Content-Disposition"), "attachment; filename=trees.csv" + ) + self.assertEqual(response["Content-Type"], "application/csv") + # Using `Accept` header + request = self.factory.get("/", HTTP_ACCEPT="text/csv", **self.extra) + response = self.view(request, pk=self.entity_list.pk) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.get("Content-Disposition"), "attachment; filename=trees.csv" + ) + self.assertEqual(response["Content-Type"], "application/csv") + class DeleteEntityListTestCase(TestAbstractViewSet): """Tests for deleting a single EntityList""" @@ -1331,3 +1350,99 @@ def test_delete_via_kwarg_invalid(self): request = self.factory.delete("/", **self.extra) response = self.view(request, pk=self.entity_list.pk, entity_pk=self.entity.pk) self.assertEqual(response.status_code, 405) + + +class DownloadEntityListTestCase(TestAbstractViewSet): + """Tests for `download` action""" + + def setUp(self): + super().setUp() + + self.view = EntityListViewSet.as_view({"get": "download"}) + self.project = get_user_default_project(self.user) + self.entity_list = EntityList.objects.create(name="trees", project=self.project) + OwnerRole.add(self.user, self.entity_list) + + def test_download(self): + """EntityList dataset is downloaded""" + request = self.factory.get("/", **self.extra) + response = self.view(request, pk=self.entity_list.pk) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response["Content-Disposition"], "attachment; filename=trees.csv" + ) + self.assertEqual(response["Content-Type"], "application/csv") + # Using `.csv` suffix + request = self.factory.get("/", **self.extra) + response = self.view(request, pk=self.entity_list.pk, format="csv") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response["Content-Disposition"], "attachment; filename=trees.csv" + ) + self.assertEqual(response["Content-Type"], "application/csv") + # Using `Accept` header + request = self.factory.get("/", HTTP_ACCEPT="text/csv", **self.extra) + response = self.view(request, pk=self.entity_list.pk) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.get("Content-Disposition"), "attachment; filename=trees.csv" + ) + self.assertEqual(response["Content-Type"], "application/csv") + # Unsupported suffix + request = self.factory.get("/", **self.extra) + response = self.view(request, pk=self.entity_list.pk, format="json") + self.assertEqual(response.status_code, 404) + # Unsupported accept header + request = self.factory.get("/", HTTP_ACCEPT="application/json", **self.extra) + response = self.view(request, pk=self.entity_list.pk) + self.assertEqual(response.status_code, 404) + + def test_anonymous_user(self): + """Anonymous user cannot download a private EntityList""" + # Anonymous user cannot view private EntityList + request = self.factory.get("/") + response = self.view(request, pk=self.entity_list.pk) + self.assertEqual(response.status_code, 404) + # Anonymous user can view public EntityList + self.project.shared = True + self.project.save() + request = self.factory.get("/") + response = self.view(request, pk=self.entity_list.pk) + self.assertEqual(response.status_code, 200) + + def test_invalid_entity_list(self): + """Invalid EntityList is handled""" + request = self.factory.get("/", **self.extra) + response = self.view(request, pk=sys.maxsize) + self.assertEqual(response.status_code, 404) + + def test_object_permissions(self): + """User must have object view level permissions""" + alice_data = { + "username": "alice", + "email": "aclie@example.com", + "password1": "password12345", + "password2": "password12345", + "first_name": "Alice", + "last_name": "Hughes", + } + alice_profile = self._create_user_profile(alice_data) + extra = {"HTTP_AUTHORIZATION": f"Token {alice_profile.user.auth_token}"} + + for role in ROLES: + ShareProject(self.project, "alice", role).save() + request = self.factory.get("/", **extra) + response = self.view(request, pk=self.entity_list.pk) + + if role in ["owner", "manager"]: + self.assertEqual(response.status_code, 200) + + else: + self.assertEqual(response.status_code, 404) + + def test_soft_deleted(self): + """Soft deleted dataset cannot be retrieved""" + self.entity_list.soft_delete() + request = self.factory.get("/", **self.extra) + response = self.view(request, pk=self.entity_list.pk) + self.assertEqual(response.status_code, 404) diff --git a/onadata/apps/api/viewsets/entity_list_viewset.py b/onadata/apps/api/viewsets/entity_list_viewset.py index 2fb4e46f5c..c5343f6d94 100644 --- a/onadata/apps/api/viewsets/entity_list_viewset.py +++ b/onadata/apps/api/viewsets/entity_list_viewset.py @@ -3,6 +3,7 @@ from rest_framework import status from rest_framework.decorators import action +from rest_framework.exceptions import NotFound from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.viewsets import GenericViewSet @@ -13,6 +14,7 @@ ListModelMixin, ) + from onadata.apps.api.permissions import DjangoObjectPermissionsIgnoreModelPerm from onadata.apps.api.tools import get_baseviewset_class from onadata.apps.logger.models import Entity, EntityList @@ -23,6 +25,7 @@ StandardPageNumberPagination, ) from onadata.libs.permissions import CAN_ADD_PROJECT_ENTITYLIST +from onadata.libs.renderers import renderers from onadata.libs.serializers.entity_serializer import ( EntityArraySerializer, EntitySerializer, @@ -31,6 +34,7 @@ EntityListDetailSerializer, EntityDeleteSerializer, ) +from onadata.libs.utils.api_export_tools import get_entity_list_export_response BaseViewset = get_baseviewset_class() @@ -172,3 +176,30 @@ def get_queryset_entities(self, request, entity_list): queryset = queryset.order_by("id") return queryset + + @action( + methods=["GET"], + detail=True, + renderer_classes=[renderers.CSVRenderer], + ) + def download(self, request, *args, **kwargs): + """Provides `download` action for dataset""" + accept_header = request.headers.get("Accept", "") + + if ( + kwargs.get("format") is not None or accept_header + ) and not request.accepted_renderer.format == "csv": + raise NotFound(code=status.HTTP_404_NOT_FOUND) + + entity_list = self.get_object() + + return get_entity_list_export_response(request, entity_list, entity_list.name) + + def retrieve(self, request, *args, **kwargs): + """Override `retrieve` method""" + instance = self.get_object() + + if kwargs.get("format") == "csv" or request.accepted_renderer.format == "csv": + return get_entity_list_export_response(request, instance, instance.name) + + return super().retrieve(request, format, *args, **kwargs)