Skip to content

Commit e5e45ea

Browse files
committed
Added the ability to export the current filtered QuerySet of a 'FilterView' into the JSON format, Solves issue #1319
Signed-off-by: Aayush Kumar <[email protected]>
1 parent 61bb26d commit e5e45ea

File tree

4 files changed

+168
-1
lines changed

4 files changed

+168
-1
lines changed

scanpipe/pipes/output.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,16 @@ def get_relations(self, project):
280280
)
281281

282282

283+
JSON_EXCLUDE_FIELDS = [
284+
"extra_data",
285+
"package_data",
286+
"license_detections",
287+
"other_license_detections",
288+
"license_clues",
289+
"affected_by_vulnerabilities",
290+
]
291+
292+
283293
def to_json(project):
284294
"""
285295
Generate output for the provided `project` in JSON format.

scanpipe/templates/scanpipe/dropdowns/list_actions_dropdown.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
<div class="dropdown-menu" id="dropdown-menu-action" role="menu">
1111
<div class="dropdown-content">
1212
<a href="?{{ export_xlsx_url_query }}" class="dropdown-item">
13-
<i class="fa-solid fa-download mr-2"></i>Export results as XLSX
13+
<i class="fa-solid fa-file-excel mr-2"></i>Export results as XLSX
14+
</a>
15+
<a href="?{{ export_json_url_query }}" class="dropdown-item">
16+
<i class="fa-solid fa-file-pdf mr-2"></i>Export results as JSON
1417
</a>
1518
</div>
1619
</div>

scanpipe/tests/test_views.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
from django.apps import apps
3131
from django.core.exceptions import SuspiciousFileOperation
32+
from django.http import FileResponse
3233
from django.http.response import Http404
3334
from django.test import TestCase
3435
from django.test import override_settings
@@ -1325,3 +1326,88 @@ def test_scanpipe_policies_broken_policies_project_details(self):
13251326
response = self.client.get(url)
13261327
self.assertEqual(200, response.status_code)
13271328
self.assertContains(response, "Policies file format error")
1329+
1330+
def test_scanpipe_views_export_json_returns_valid_response(self):
1331+
url = reverse("project_resources", args=[self.project1.slug])
1332+
response = self.client.get(url + "?export_json=True")
1333+
1334+
self.assertIsInstance(response, FileResponse)
1335+
self.assertEqual(response.get("Content-Type"), "application/json")
1336+
self.assertTrue(response.get("Content-Disposition").startswith("attachment"))
1337+
1338+
def test_scanpipe_views_export_json_correct_filename(self):
1339+
url = reverse("project_resources", args=[self.project1.slug])
1340+
response = self.client.get(url + "?export_json=True")
1341+
1342+
actual_filename = response.get("Content-Disposition")
1343+
expected_filename = (
1344+
f'attachment; filename="{self.project1.name}_codebaseresource.json"'
1345+
)
1346+
self.assertEqual(actual_filename, expected_filename)
1347+
1348+
def test_scanpipe_views_export_json_contains_expected_fields(self):
1349+
make_resource_file(self.project1, "file1.txt")
1350+
url = reverse("project_resources", args=[self.project1.slug])
1351+
response = self.client.get(url + "?export_json=True")
1352+
1353+
file_content = b"".join(response.streaming_content).decode("utf-8")
1354+
json_data = json.loads(file_content)
1355+
1356+
expected_fields = [
1357+
"path",
1358+
"type",
1359+
"name",
1360+
"status",
1361+
"for_packages",
1362+
"tag",
1363+
"extension",
1364+
"size",
1365+
"md5",
1366+
"sha1",
1367+
"sha256",
1368+
"sha512",
1369+
"mime_type",
1370+
"file_type",
1371+
"programming_language",
1372+
"is_binary",
1373+
"is_text",
1374+
"is_archive",
1375+
"is_media",
1376+
"is_legal",
1377+
"is_manifest",
1378+
"is_readme",
1379+
"is_top_level",
1380+
"is_key_file",
1381+
"detected_license_expression",
1382+
"detected_license_expression_spdx",
1383+
"percentage_of_license_text",
1384+
"compliance_alert",
1385+
"copyrights",
1386+
"holders",
1387+
"authors",
1388+
"emails",
1389+
"urls",
1390+
]
1391+
1392+
for field in expected_fields:
1393+
self.assertIn(field, json_data)
1394+
1395+
def test_scanpipe_views_export_json_excludes_fields(self):
1396+
make_resource_file(self.project1, "file1.txt")
1397+
url = reverse("project_resources", args=[self.project1.slug])
1398+
response = self.client.get(url + "?export_json=True")
1399+
1400+
file_content = b"".join(response.streaming_content).decode("utf-8")
1401+
json_data = json.loads(file_content)
1402+
1403+
excluded_fields = [
1404+
"extra_data",
1405+
"package_data",
1406+
"license_detections",
1407+
"other_license_detections",
1408+
"license_clues",
1409+
"affected_by_vulnerabilities",
1410+
]
1411+
1412+
for field in excluded_fields:
1413+
self.assertNotIn(field, json_data)

scanpipe/views.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,69 @@ def get(self, request, *args, **kwargs):
498498
return response
499499

500500

501+
class ExportJSONMixin:
502+
"""
503+
Add the ability to export the current filtered QuerySet of a `FilterView`
504+
into JSON format.
505+
"""
506+
507+
export_json_query_param = "export_json"
508+
509+
def get_export_json_queryset(self):
510+
return self.filterset.qs
511+
512+
def get_export_json_filename(self):
513+
return f"{self.project.name}_{self.model._meta.model_name}.json"
514+
515+
def get_filtered_files(self, queryset):
516+
from scanpipe.api.serializers import CodebaseResourceSerializer
517+
518+
yield from self.encode_queryset(queryset, CodebaseResourceSerializer)
519+
520+
def export_json_file_response(self):
521+
queryset = self.get_export_json_queryset()
522+
523+
output_file = io.BytesIO()
524+
525+
for chunk in self.get_filtered_files(queryset):
526+
output_file.write(chunk.encode("utf-8"))
527+
output_file.seek(0)
528+
529+
return FileResponse(
530+
output_file,
531+
as_attachment=True,
532+
filename=self.get_export_json_filename(),
533+
content_type="application/json",
534+
)
535+
536+
def encode_queryset(self, queryset, serializer_class):
537+
for obj in queryset.iterator(chunk_size=2000):
538+
serialized_obj = serializer_class(obj)
539+
data = serialized_obj.data
540+
541+
for field in output.JSON_EXCLUDE_FIELDS:
542+
data.pop(field, None)
543+
544+
yield json.dumps(data, indent=2)
545+
546+
def get_context_data(self, **kwargs):
547+
context = super().get_context_data(**kwargs)
548+
549+
query_dict = self.request.GET.copy()
550+
query_dict[self.export_json_query_param] = True
551+
context["export_json_url_query"] = query_dict.urlencode()
552+
553+
return context
554+
555+
def get(self, request, *args, **kwargs):
556+
response = super().get(request, *args, **kwargs)
557+
558+
if request.GET.get(self.export_json_query_param):
559+
return self.export_json_file_response()
560+
561+
return response
562+
563+
501564
class FormAjaxMixin:
502565
def is_xhr(self):
503566
return self.request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest"
@@ -1542,6 +1605,7 @@ class CodebaseResourceListView(
15421605
ProjectRelatedViewMixin,
15431606
TableColumnsMixin,
15441607
ExportXLSXMixin,
1608+
ExportJSONMixin,
15451609
PaginatedFilterView,
15461610
):
15471611
model = CodebaseResource
@@ -1615,6 +1679,7 @@ class DiscoveredPackageListView(
16151679
ProjectRelatedViewMixin,
16161680
TableColumnsMixin,
16171681
ExportXLSXMixin,
1682+
ExportJSONMixin,
16181683
PaginatedFilterView,
16191684
):
16201685
model = DiscoveredPackage
@@ -1671,6 +1736,7 @@ class DiscoveredDependencyListView(
16711736
ProjectRelatedViewMixin,
16721737
TableColumnsMixin,
16731738
ExportXLSXMixin,
1739+
ExportJSONMixin,
16741740
PaginatedFilterView,
16751741
):
16761742
model = DiscoveredDependency
@@ -1740,6 +1806,7 @@ class ProjectMessageListView(
17401806
ProjectRelatedViewMixin,
17411807
TableColumnsMixin,
17421808
ExportXLSXMixin,
1809+
ExportJSONMixin,
17431810
FilterView,
17441811
):
17451812
model = ProjectMessage
@@ -1764,6 +1831,7 @@ class CodebaseRelationListView(
17641831
PrefetchRelatedViewMixin,
17651832
TableColumnsMixin,
17661833
ExportXLSXMixin,
1834+
ExportJSONMixin,
17671835
PaginatedFilterView,
17681836
):
17691837
model = CodebaseRelation

0 commit comments

Comments
 (0)