From 1304904cb10a8235145b70fd60ea6bc33dd820da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 13 Oct 2023 13:49:22 -0300 Subject: [PATCH] feat: add export taxonomy rest api (#97) --- .../core/tagging/rest_api/v1/serializers.py | 8 ++ .../core/tagging/rest_api/v1/views.py | 58 +++++++++- openedx_tagging/core/tagging/rules.py | 1 + .../core/tagging/test_views.py | 102 ++++++++++++++++++ 4 files changed, 167 insertions(+), 2 deletions(-) diff --git a/openedx_tagging/core/tagging/rest_api/v1/serializers.py b/openedx_tagging/core/tagging/rest_api/v1/serializers.py index 388ee6cc..a4eb89ff 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/serializers.py +++ b/openedx_tagging/core/tagging/rest_api/v1/serializers.py @@ -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. diff --git a/openedx_tagging/core/tagging/rest_api/v1/views.py b/openedx_tagging/core/tagging/rest_api/v1/views.py index c00f78dc..b4774baf 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/views.py +++ b/openedx_tagging/core/tagging/rest_api/v1/views.py @@ -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 @@ -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 @@ -35,6 +38,7 @@ TagsForSearchSerializer, TagsSerializer, TagsWithSubTagsSerializer, + TaxonomyExportQueryParamsSerializer, TaxonomyListQueryParamsSerializer, TaxonomySerializer, ) @@ -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, @@ -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 @@ -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( diff --git a/openedx_tagging/core/tagging/rules.py b/openedx_tagging/core/tagging/rules.py index 00ec8811..878b1c90 100644 --- a/openedx_tagging/core/tagging/rules.py +++ b/openedx_tagging/core/tagging/rules.py @@ -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) diff --git a/tests/openedx_tagging/core/tagging/test_views.py b/tests/openedx_tagging/core/tagging/test_views.py index 5092c414..98f2332b 100644 --- a/tests/openedx_tagging/core/tagging/test_views.py +++ b/tests/openedx_tagging/core/tagging/test_views.py @@ -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 @@ -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/" @@ -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):