Skip to content

Commit 1485a94

Browse files
committed
Add ability to download all output results formats #1880
Signed-off-by: tdruez <[email protected]>
1 parent 2da4d37 commit 1485a94

File tree

6 files changed

+90
-24
lines changed

6 files changed

+90
-24
lines changed

CHANGELOG.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
Changelog
22
=========
33

4+
v35.4.1 (unreleased)
5+
--------------------
6+
7+
- Add ability to download all output results formats as a zipfile for a given project.
8+
https://github.com/aboutcode-org/scancode.io/issues/1880
9+
410
v35.4.0 (2025-09-30)
511
--------------------
612

scanpipe/pipes/output.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@
2626
import json
2727
import re
2828
import uuid
29+
import zipfile
2930
from operator import attrgetter
3031
from pathlib import Path
3132

3233
from django.apps import apps
34+
from django.core.files.base import ContentFile
3335
from django.core.serializers.json import DjangoJSONEncoder
3436
from django.forms.models import model_to_dict
3537
from django.template import Context
@@ -1138,3 +1140,43 @@ def to_ort_package_list_yml(project):
11381140
"attribution": to_attribution,
11391141
"ort-package-list": to_ort_package_list_yml,
11401142
}
1143+
1144+
1145+
def make_zip_from_files(files):
1146+
"""Return an in-memory zipfile given a list of (filename, file_path) pairs."""
1147+
zip_buffer = io.BytesIO()
1148+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
1149+
for filename, file_path in files:
1150+
with open(file_path, "rb") as f:
1151+
zip_file.writestr(filename, f.read())
1152+
zip_buffer.seek(0)
1153+
return zip_buffer
1154+
1155+
1156+
# def to_all_formats(project):
1157+
# """Generate all output formats for a project and return a zipfile."""
1158+
# files = []
1159+
# for output_function in FORMAT_TO_FUNCTION_MAPPING.values():
1160+
# output_file = output_function(project)
1161+
# filename = safe_filename(f"{project.name}_{output_file.name}")
1162+
# files.append((filename, output_file))
1163+
# zip_buffer = make_zip_from_files(files)
1164+
# zip_buffer.name = "scancodeio_output_files.zip"
1165+
# return zip_buffer
1166+
1167+
1168+
def to_all_formats(project):
1169+
"""Generate all output formats for a project and return a Django File-like zip."""
1170+
files = []
1171+
for output_function in FORMAT_TO_FUNCTION_MAPPING.values():
1172+
output_file = output_function(project)
1173+
filename = safe_filename(f"{project.name}_{output_file.name}")
1174+
files.append((filename, output_file))
1175+
1176+
zip_buffer = make_zip_from_files(files)
1177+
1178+
# Wrap it into a Django File-like object
1179+
zip_file = ContentFile(zip_buffer.getvalue())
1180+
zip_file.name = safe_filename(f"{project.name}_outputs.zip")
1181+
1182+
return zip_file

scanpipe/templates/scanpipe/dropdowns/project_download_dropdown.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
<a href="{% url 'project_results' project.slug 'ort-package-list' %}" class="dropdown-item">
4141
<strong>ORT (package-list)</strong>
4242
</a>
43+
<hr class="dropdown-divider" />
44+
<a href="{% url 'project_results' project.slug 'all' %}" class="dropdown-item">
45+
<strong>All formats</strong>
46+
</a>
4347
</div>
4448
</div>
4549
</div>

scanpipe/templates/scanpipe/includes/project_downloads.html

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
<article class="message is-success">
2-
<div class="message-body">
3-
Download results as:
2+
<div class="message-body p-3">
3+
<span class="icon"><i class="fa-solid fa-download"></i></span>
4+
Download results:
45
<a class="tag is-success is-medium ml-2" href="{% url 'project_results' project.slug 'json' %}">
5-
<span class="icon mr-1"><i class="fa-solid fa-download"></i></span>JSON
6+
JSON
67
</a>
78
<a class="tag is-success is-medium" href="{% url 'project_results' project.slug 'xlsx' %}">
8-
<span class="icon mr-1"><i class="fa-solid fa-download"></i></span>XLSX
9+
XLSX
910
</a>
1011
<div class="dropdown is-hoverable">
1112
<div class="dropdown-trigger">
1213
<button class="button tag is-success is-medium has-text-weight-normal" aria-haspopup="true" aria-controls="dropdown-menu-spdx">
13-
<span class="icon">
14-
<i class="fa-solid fa-download" aria-hidden="true"></i>
15-
</span>
1614
<span>SPDX</span>
1715
<span class="icon is-small">
1816
<i class="fa-solid fa-angle-down" aria-hidden="true"></i>
@@ -33,9 +31,6 @@
3331
<div class="dropdown is-hoverable">
3432
<div class="dropdown-trigger">
3533
<button class="button tag is-success is-medium has-text-weight-normal" aria-haspopup="true" aria-controls="dropdown-menu-cyclonedx">
36-
<span class="icon">
37-
<i class="fa-solid fa-download" aria-hidden="true"></i>
38-
</span>
3934
<span>CycloneDX</span>
4035
<span class="icon is-small">
4136
<i class="fa-solid fa-angle-down" aria-hidden="true"></i>
@@ -57,14 +52,11 @@
5752
</div>
5853
</div>
5954
<a class="tag is-success is-medium" href="{% url 'project_results' project.slug 'attribution' %}">
60-
<span class="icon mr-1"><i class="fa-solid fa-download"></i></span>Attribution
55+
Attribution
6156
</a>
6257
<div class="dropdown is-hoverable">
6358
<div class="dropdown-trigger">
6459
<button class="button tag is-success is-medium has-text-weight-normal" aria-haspopup="true" aria-controls="dropdown-menu-ort">
65-
<span class="icon">
66-
<i class="fa-solid fa-download" aria-hidden="true"></i>
67-
</span>
6860
<span>Tools formats</span>
6961
<span class="icon is-small">
7062
<i class="fa-solid fa-angle-down" aria-hidden="true"></i>
@@ -79,5 +71,9 @@
7971
</div>
8072
</div>
8173
</div>
74+
<span class="p-1">|</span>
75+
<a class="tag is-success is-medium" href="{% url 'project_results' project.slug 'all' %}">
76+
All formats
77+
</a>
8278
</div>
8379
</article>

scanpipe/tests/pipes/test_output.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import shutil
2727
import tempfile
2828
import uuid
29+
import zipfile
2930
from dataclasses import dataclass
3031
from pathlib import Path
3132
from unittest import mock
@@ -633,6 +634,24 @@ def test_scanpipe_pipes_outputs_to_to_ort_package_list_yml(self):
633634
expected_file = self.data / "asgiref" / "asgiref-3.3.0.package-list.yml"
634635
self.assertResultsEqual(expected_file, output_file.read_text())
635636

637+
def test_scanpipe_pipes_outputs_to_all_formats(self):
638+
fixtures = self.data / "asgiref" / "asgiref-3.3.0_fixtures.json"
639+
call_command("loaddata", fixtures, **{"verbosity": 0})
640+
project = Project.objects.get(name="asgiref")
641+
642+
with self.assertNumQueries(35):
643+
output_file = output.to_all_formats(project=project)
644+
645+
self.assertEqual("asgiref_outputs.zip", output_file.name)
646+
647+
output_file.seek(0) # Important for reading from start
648+
with zipfile.ZipFile(output_file, "r") as zip_ref:
649+
zip_contents = zip_ref.namelist()
650+
file_count = len(zip_contents)
651+
652+
expected_file_count = len(output.FORMAT_TO_FUNCTION_MAPPING)
653+
self.assertEqual(file_count, expected_file_count)
654+
636655
def test_scanpipe_pipes_outputs_make_unknown_license_object(self):
637656
licensing = get_licensing()
638657
parsed_expression = licensing.parse("some-unknown-license")

scanpipe/views.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import io
2525
import json
2626
import operator
27-
import zipfile
2827
from collections import Counter
2928
from contextlib import suppress
3029
from pathlib import Path
@@ -1444,19 +1443,17 @@ def get_project_queryset(selected_project_ids=None, action_form=None):
14441443

14451444
@staticmethod
14461445
def download_outputs_zip_response(project_qs, action_form):
1446+
"""Generate and return a zip file response for selected projects."""
14471447
output_format = action_form.cleaned_data["output_format"]
14481448
output_function = output.FORMAT_TO_FUNCTION_MAPPING.get(output_format)
14491449

1450-
# In-memory file storage for the zip archive
1451-
zip_buffer = io.BytesIO()
1452-
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
1453-
for project in project_qs:
1454-
output_file = output_function(project)
1455-
filename = output.safe_filename(f"{project.name}_{output_file.name}")
1456-
with open(output_file, "rb") as f:
1457-
zip_file.writestr(filename, f.read())
1450+
files = []
1451+
for project in project_qs:
1452+
output_file = output_function(project)
1453+
filename = output.safe_filename(f"{project.name}_{output_file.name}")
1454+
files.append((filename, output_file))
14581455

1459-
zip_buffer.seek(0)
1456+
zip_buffer = output.make_zip_from_files(files)
14601457
return FileResponse(
14611458
zip_buffer,
14621459
as_attachment=True,
@@ -1633,6 +1630,8 @@ def get(self, request, *args, **kwargs):
16331630
output_file = output.to_attribution(project)
16341631
elif format == "ort-package-list":
16351632
output_file = output.to_ort_package_list_yml(project)
1633+
elif format == "all":
1634+
output_file = output.to_all_formats(project)
16361635
else:
16371636
raise Http404("Format not supported.")
16381637

0 commit comments

Comments
 (0)