Skip to content

Commit

Permalink
feat: add export taxonomy rest api (#97)
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido authored Oct 13, 2023
1 parent d66d629 commit 1304904
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 2 deletions.
8 changes: 8 additions & 0 deletions openedx_tagging/core/tagging/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ class TaxonomyListQueryParamsSerializer(serializers.Serializer): # pylint: disa
enabled = serializers.BooleanField(required=False)


class TaxonomyExportQueryParamsSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer for the query params for the GET view
"""
download = serializers.BooleanField(required=False, default=False)
output_format = serializers.RegexField(r"(?i)^(json|csv)$", allow_blank=False)


class TaxonomySerializer(serializers.ModelSerializer):
"""
Serializer for the Taxonomy model.
Expand Down
58 changes: 56 additions & 2 deletions openedx_tagging/core/tagging/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
from __future__ import annotations

from django.db import models
from django.http import Http404
from django.http import Http404, HttpResponse
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied, ValidationError
from rest_framework.generics import ListAPIView
from rest_framework.response import Response
Expand All @@ -23,6 +24,8 @@
search_tags,
tag_object,
)
from ...import_export.api import export_tags
from ...import_export.parsers import ParserFormat
from ...models import Taxonomy
from ...rules import ChangeObjectTagPermissionItem
from ..paginators import SEARCH_TAGS_THRESHOLD, TAGS_THRESHOLD, DisabledTagsPagination, TagsPagination
Expand All @@ -35,6 +38,7 @@
TagsForSearchSerializer,
TagsSerializer,
TagsWithSubTagsSerializer,
TaxonomyExportQueryParamsSerializer,
TaxonomyListQueryParamsSerializer,
TaxonomySerializer,
)
Expand All @@ -44,7 +48,7 @@
@view_auth_classes
class TaxonomyView(ModelViewSet):
"""
View to list, create, retrieve, update, or delete Taxonomies.
View to list, create, retrieve, update, delete or export Taxonomies.
**List Query Parameters**
* enabled (optional) - Filter by enabled status. Valid values: true,
Expand Down Expand Up @@ -143,6 +147,23 @@ class TaxonomyView(ModelViewSet):
* 404 - Taxonomy not found
* 403 - Permission denied
**Export Query Parameters**
* output_format - Define the output format. Valid values: json, csv
* download (optional) - Add headers on the response to let the browser
automatically download the file.
**Export Example Requests**
GET api/tagging/v1/taxonomy/:pk/export?output_format=csv - Export taxonomy as CSV
GET api/tagging/v1/taxonomy/:pk/export?output_format=json - Export taxonomy as JSON
GET api/tagging/v1/taxonomy/:pk/export?output_format=csv&download=1 - Export and downloads taxonomy as CSV
GET api/tagging/v1/taxonomy/:pk/export?output_format=json&download=1 - Export and downloads taxonomy as JSON
**Export Query Returns**
* 200 - Success
* 400 - Invalid query parameter
* 403 - Permission denied
"""

serializer_class = TaxonomySerializer
Expand Down Expand Up @@ -183,6 +204,39 @@ def perform_create(self, serializer) -> None:
"""
serializer.instance = create_taxonomy(**serializer.validated_data)

@action(detail=True, methods=["get"])
def export(self, request, **_kwargs) -> HttpResponse:
"""
Export a taxonomy.
"""
taxonomy = self.get_object()
perm = "oel_tagging.export_taxonomy"
if not request.user.has_perm(perm, taxonomy):
raise PermissionDenied("You do not have permission to export this taxonomy.")
query_params = TaxonomyExportQueryParamsSerializer(
data=request.query_params.dict()
)
query_params.is_valid(raise_exception=True)
output_format = query_params.data.get("output_format")
assert output_format is not None
if output_format.lower() == "json":
parser_format = ParserFormat.JSON
content_type = "application/json"
else:
parser_format = ParserFormat.CSV
if query_params.data.get("download"):
content_type = "text/csv"
else:
content_type = "text"

tags = export_tags(taxonomy, parser_format)
if query_params.data.get("download"):
response = HttpResponse(tags.encode('utf-8'), content_type=content_type)
response["Content-Disposition"] = f'attachment; filename="{taxonomy.name}{parser_format.value}"'
return response

return HttpResponse(tags, content_type=content_type)


@view_auth_classes
class ObjectTagView(
Expand Down
1 change: 1 addition & 0 deletions openedx_tagging/core/tagging/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def can_change_object_tag(
rules.add_perm("oel_tagging.change_taxonomy", can_change_taxonomy)
rules.add_perm("oel_tagging.delete_taxonomy", can_change_taxonomy)
rules.add_perm("oel_tagging.view_taxonomy", can_view_taxonomy)
rules.add_perm("oel_tagging.export_taxonomy", can_view_taxonomy)

# Tag
rules.add_perm("oel_tagging.add_tag", can_change_tag)
Expand Down
102 changes: 102 additions & 0 deletions tests/openedx_tagging/core/tagging/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from rest_framework import status
from rest_framework.test import APITestCase

from openedx_tagging.core.tagging.import_export import api as import_export_api
from openedx_tagging.core.tagging.import_export.parsers import ParserFormat
from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy
from openedx_tagging.core.tagging.models.system_defined import SystemDefinedTaxonomy
from openedx_tagging.core.tagging.rest_api.paginators import TagsPagination
Expand All @@ -20,6 +22,7 @@

TAXONOMY_LIST_URL = "/tagging/rest_api/v1/taxonomies/"
TAXONOMY_DETAIL_URL = "/tagging/rest_api/v1/taxonomies/{pk}/"
TAXONOMY_EXPORT_URL = "/tagging/rest_api/v1/taxonomies/{pk}/export/"
TAXONOMY_TAGS_URL = "/tagging/rest_api/v1/taxonomies/{pk}/tags/"


Expand Down Expand Up @@ -395,6 +398,105 @@ def test_delete_taxonomy_404(self):
response = self.client.delete(url)
assert response.status_code == status.HTTP_404_NOT_FOUND

@ddt.data(
("csv", "text"),
("json", "application/json")
)
@ddt.unpack
def test_export_taxonomy(self, output_format, content_type):
"""
Tests if a user can export a taxonomy
"""
taxonomy = Taxonomy.objects.create(name="T1", enabled=True)
taxonomy.save()
for i in range(20):
# Valid ObjectTags
Tag.objects.create(taxonomy=taxonomy, value=f"Tag {i}").save()

url = TAXONOMY_EXPORT_URL.format(pk=taxonomy.pk)

self.client.force_authenticate(user=self.staff)
response = self.client.get(url, {"output_format": output_format})
assert response.status_code == status.HTTP_200_OK
if output_format == "json":
expected_data = import_export_api.export_tags(taxonomy, ParserFormat.JSON)
else:
expected_data = import_export_api.export_tags(taxonomy, ParserFormat.CSV)

assert response.headers['Content-Type'] == content_type
assert response.content == expected_data.encode("utf-8")

@ddt.data(
("csv", "text/csv"),
("json", "application/json")
)
@ddt.unpack
def test_export_taxonomy_download(self, output_format, content_type):
"""
Tests if a user can export a taxonomy with download option
"""
taxonomy = Taxonomy.objects.create(name="T1", enabled=True)
taxonomy.save()
for i in range(20):
# Valid ObjectTags
Tag.objects.create(taxonomy=taxonomy, value=f"Tag {i}").save()

url = TAXONOMY_EXPORT_URL.format(pk=taxonomy.pk)

self.client.force_authenticate(user=self.staff)
response = self.client.get(url, {"output_format": output_format, "download": True})
assert response.status_code == status.HTTP_200_OK
if output_format == "json":
expected_data = import_export_api.export_tags(taxonomy, ParserFormat.JSON)
else:
expected_data = import_export_api.export_tags(taxonomy, ParserFormat.CSV)

assert response.headers['Content-Type'] == content_type
assert response.headers['Content-Disposition'] == f'attachment; filename="{taxonomy.name}.{output_format}"'
assert response.content == expected_data.encode("utf-8")

def test_export_taxonomy_invalid_param_output_format(self):
"""
Tests if a user can export a taxonomy using an invalid output_format param
"""
taxonomy = Taxonomy.objects.create(name="T1", enabled=True)
taxonomy.save()

url = TAXONOMY_EXPORT_URL.format(pk=taxonomy.pk)

self.client.force_authenticate(user=self.staff)
response = self.client.get(url, {"output_format": "html", "download": True})
assert response.status_code == status.HTTP_400_BAD_REQUEST

def test_export_taxonomy_invalid_param_download(self):
"""
Tests if a user can export a taxonomy using an invalid output_format param
"""
taxonomy = Taxonomy.objects.create(name="T1", enabled=True)
taxonomy.save()

url = TAXONOMY_EXPORT_URL.format(pk=taxonomy.pk)

self.client.force_authenticate(user=self.staff)
response = self.client.get(url, {"output_format": "json", "download": "invalid"})
assert response.status_code == status.HTTP_400_BAD_REQUEST

def test_export_taxonomy_unauthorized(self):
"""
Tests if a user can export a taxonomy that he doesn't have authorization
"""
# Only staff can view a disabled taxonomy
taxonomy = Taxonomy.objects.create(name="T1", enabled=False)
taxonomy.save()

url = TAXONOMY_EXPORT_URL.format(pk=taxonomy.pk)

self.client.force_authenticate(user=self.user)
response = self.client.get(url, {"output_format": "json"})

# Return 404, because the user doesn't have permission to view the taxonomy
assert response.status_code == status.HTTP_404_NOT_FOUND


@ddt.ddt
class TestObjectTagViewSet(APITestCase):
Expand Down

0 comments on commit 1304904

Please sign in to comment.