Skip to content

Commit

Permalink
Merge branch 'main' into fix-2676
Browse files Browse the repository at this point in the history
  • Loading branch information
FrankApiyo authored Aug 26, 2024
2 parents 734345d + 4b8ef74 commit ad12aee
Show file tree
Hide file tree
Showing 5 changed files with 176 additions and 4 deletions.
4 changes: 2 additions & 2 deletions docker/onadata-uwsgi/Dockerfile.ubuntu
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM onaio/python-deps:3.10.14-20240703 AS base
FROM onaio/python-deps:3.10.14-20240820 AS base

ARG optional_packages

Expand Down Expand Up @@ -58,7 +58,7 @@ RUN python -m pip install --no-cache-dir -r requirements/docs.pip && \
make -C docs html


FROM debian:bookworm-20240722 as runtime
FROM debian:bookworm-20240812 as runtime

ENV DEBIAN_FRONTEND=noninteractive

Expand Down
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)
5 changes: 3 additions & 2 deletions onadata/apps/main/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from onadata.libs.utils.analytics import init_analytics

TESTING = len(sys.argv) > 1 and sys.argv[1] == "test"
ADMIN_URL_PATH = getattr(settings, "ADMIN_URL_PATH", "admin")

admin.autodiscover()

Expand All @@ -50,8 +51,8 @@
re_path(r"^api/v1$", RedirectView.as_view(url="/api/v1/", permanent=True)),
# django default stuff
re_path(r"^accounts/", include(registration_patterns)),
re_path(r"^admin/", admin.site.urls),
re_path(r"^admin/doc/", include("django.contrib.admindocs.urls")),
re_path(f"^{ADMIN_URL_PATH}/", admin.site.urls),
re_path(f"^{ADMIN_URL_PATH}/doc/", include("django.contrib.admindocs.urls")),
# oath2_provider
re_path(
r"^o/authorize/$",
Expand Down

0 comments on commit ad12aee

Please sign in to comment.