Skip to content

Commit

Permalink
Add support for downloading EntityList dataset (#2678)
Browse files Browse the repository at this point in the history
* add support for downloading EntityList dataset

* update docs

* remove whitespace in docs

* download EntityList on  endpoint `/api/v2/entity-lists/<id>.csv

* refactor code

* refactor code
  • Loading branch information
kelvin-muchiri authored Aug 26, 2024
1 parent 87f43ec commit 085b54e
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 0 deletions.
25 changes: 25 additions & 0 deletions docs/entities.rst
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,31 @@ Body:
]
}
Download EntityList
-------------------
.. raw:: html

<pre class="prettyprint"><b>GET</b> api/v2/entity-lists/&lt;entity_list_id&gt;/download</pre>

or

.. raw:: html

<pre class="prettyprint"><b>GET</b> api/v2/entity-lists/&lt;entity_list_id&gt;.csv</pre>


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
-----------------
Expand Down
115 changes: 115 additions & 0 deletions onadata/apps/api/tests/viewsets/test_entity_list_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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": "[email protected]",
"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)
31 changes: 31 additions & 0 deletions onadata/apps/api/viewsets/entity_list_viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -31,6 +34,7 @@
EntityListDetailSerializer,
EntityDeleteSerializer,
)
from onadata.libs.utils.api_export_tools import get_entity_list_export_response


BaseViewset = get_baseviewset_class()
Expand Down Expand Up @@ -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)

0 comments on commit 085b54e

Please sign in to comment.