Skip to content

Commit 3a40cc1

Browse files
authored
Add a report action on project list to export XLSX of packages #1437 (#1517)
Signed-off-by: tdruez <tdruez@nexb.com>
1 parent ef68257 commit 3a40cc1

File tree

8 files changed

+171
-9
lines changed

8 files changed

+171
-9
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ v34.9.4 (unreleased)
2020
and let the data loading process handle the data issues.
2121
https://github.com/aboutcode-org/scancode.io/issues/1515
2222

23+
- Add a report action on project list to export XLSX containing packages from selected
24+
projects.
25+
https://github.com/aboutcode-org/scancode.io/issues/1437
26+
27+
- Add a download action on project list to enable bulk download of Project output files.
28+
https://github.com/aboutcode-org/scancode.io/issues/1518
29+
2330
v34.9.3 (2024-12-31)
2431
--------------------
2532

scanpipe/forms.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,22 @@ class ArchiveProjectForm(forms.Form):
256256
)
257257

258258

259+
class ProjectOutputDownloadForm(forms.Form):
260+
output_format = forms.ChoiceField(
261+
label="Choose the output format to include in the ZIP file",
262+
choices=[
263+
("json", "JSON"),
264+
("xlsx", "XLSX"),
265+
("spdx", "SPDX"),
266+
("cyclonedx", "CycloneDX"),
267+
("attribution", "Attribution"),
268+
],
269+
required=True,
270+
initial="json",
271+
widget=forms.RadioSelect,
272+
)
273+
274+
259275
class ListTextarea(forms.CharField):
260276
"""
261277
A Django form field that displays as a textarea and converts each line of input

scanpipe/pipes/output.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,9 @@ def to_json(project):
294294
}
295295

296296

297-
def queryset_to_xlsx_worksheet(queryset, workbook, exclude_fields=()):
297+
def queryset_to_xlsx_worksheet(
298+
queryset, workbook, exclude_fields=None, extra_fields=None
299+
):
298300
"""
299301
Add a new worksheet to the ``workbook`` ``xlsxwriter.Workbook`` using the
300302
``queryset``. The ``queryset`` "model_name" is used as a name for the
@@ -312,7 +314,10 @@ def queryset_to_xlsx_worksheet(queryset, workbook, exclude_fields=()):
312314

313315
fields = get_serializer_fields(model_class)
314316
exclude_fields = exclude_fields or []
317+
extra_fields = extra_fields or []
315318
fields = [field for field in fields if field not in exclude_fields]
319+
if extra_fields:
320+
fields.extend(extra_fields)
316321

317322
return _add_xlsx_worksheet(
318323
workbook=workbook,
@@ -893,3 +898,12 @@ def to_attribution(project):
893898

894899
output_file.write_text(rendered_template)
895900
return output_file
901+
902+
903+
FORMAT_TO_FUNCTION_MAPPING = {
904+
"json": to_json,
905+
"xlsx": to_xlsx,
906+
"spdx": to_spdx,
907+
"cyclonedx": to_cyclonedx,
908+
"attribution": to_attribution,
909+
}

scanpipe/templates/scanpipe/dropdowns/project_actions_dropdown.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@
1313
<div class="dropdown-menu" role="menu">
1414
<div class="dropdown-content">
1515
<ul>
16+
<li>
17+
<a href="#" class="dropdown-item modal-button" data-target="modal-projects-download" aria-haspopup="true">
18+
<i class="fa-solid fa-download mr-2"></i>Download
19+
</a>
20+
</li>
21+
<li>
22+
<a href="#" class="dropdown-item modal-button" data-target="modal-projects-report" aria-haspopup="true">
23+
<i class="fa-solid fa-table-cells mr-2"></i>Report
24+
</a>
25+
</li>
26+
<li>
27+
<hr class="dropdown-divider">
28+
</li>
1629
<li>
1730
<a href="#" class="dropdown-item modal-button" data-target="modal-projects-archive" aria-haspopup="true">
1831
<i class="fa-solid fa-dice-d6 mr-2"></i>Archive
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<div class="modal" id="modal-projects-download">
2+
<div class="modal-background"></div>
3+
<div class="modal-card">
4+
<header class="modal-card-head">
5+
<p class="modal-card-title">Download outputs for selected projects as ZIP file</p>
6+
<button class="delete" aria-label="close"></button>
7+
</header>
8+
<form action="{% url 'project_action' %}" method="post" id="download-projects-form">{% csrf_token %}
9+
<section class="modal-card-body">
10+
<ul class="mb-3">
11+
{{ outputs_download_form.as_ul }}
12+
</ul>
13+
</section>
14+
<input type="hidden" name="action" value="download">
15+
<footer class="modal-card-foot is-flex is-justify-content-space-between">
16+
<button class="button has-text-weight-semibold" type="reset">Cancel</button>
17+
<button class="button is-success" type="button" data-action-trigger="download-projects">
18+
Download<span class="icon ml-1"><i class="fa-solid fa-download"></i></span>
19+
</button>
20+
</footer>
21+
</form>
22+
</div>
23+
</div>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<div class="modal" id="modal-projects-report">
2+
<div class="modal-background"></div>
3+
<div class="modal-card">
4+
<header class="modal-card-head">
5+
<p class="modal-card-title">Report of selected projects</p>
6+
<button class="delete" aria-label="close"></button>
7+
</header>
8+
<form action="{% url 'project_action' %}" method="post" id="report-projects-form">{% csrf_token %}
9+
<section class="modal-card-body">
10+
<div class="notification is-info has-text-weight-semibold">
11+
All the packages for the selected projects will be included in an XLSX report.
12+
</div>
13+
</section>
14+
<input type="hidden" name="action" value="report">
15+
<footer class="modal-card-foot is-flex is-justify-content-space-between">
16+
<button class="button has-text-weight-semibold" type="reset">Cancel</button>
17+
<button class="button is-success" type="button" data-action-trigger="report-projects">
18+
Download XLSX<span class="icon ml-1"><i class="fa-solid fa-download"></i></span>
19+
</button>
20+
</footer>
21+
</form>
22+
</div>
23+
</div>

scanpipe/templates/scanpipe/project_list.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@
7070
</div>
7171

7272
{% include 'scanpipe/modals/run_modal.html' %}
73+
{% include "scanpipe/modals/projects_download_modal.html" %}
74+
{% include "scanpipe/modals/projects_report_modal.html" %}
7375
{% include "scanpipe/modals/projects_archive_modal.html" %}
7476
{% include "scanpipe/modals/projects_delete_modal.html" %}
7577
{% include "scanpipe/modals/projects_reset_modal.html" %}

scanpipe/views.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import io
2525
import json
2626
import operator
27+
import zipfile
2728
from collections import Counter
2829
from contextlib import suppress
2930
from pathlib import Path
@@ -79,6 +80,7 @@
7980
from scanpipe.forms import PipelineRunStepSelectionForm
8081
from scanpipe.forms import ProjectCloneForm
8182
from scanpipe.forms import ProjectForm
83+
from scanpipe.forms import ProjectOutputDownloadForm
8284
from scanpipe.forms import ProjectSettingsForm
8385
from scanpipe.models import CodebaseRelation
8486
from scanpipe.models import CodebaseResource
@@ -442,15 +444,30 @@ class ExportXLSXMixin:
442444

443445
export_xlsx_query_param = "export_xlsx"
444446

447+
def get_export_xlsx_queryset(self):
448+
return self.filterset.qs
449+
450+
def get_export_xlsx_filename(self):
451+
return f"{self.project.name}_{self.model._meta.model_name}.xlsx"
452+
453+
def get_export_xlsx_extra_fields(self):
454+
return []
455+
445456
def export_xlsx_file_response(self):
446-
filtered_qs = self.filterset.qs
447457
output_file = io.BytesIO()
458+
queryset = self.get_export_xlsx_queryset()
459+
extra_fields = self.get_export_xlsx_extra_fields()
448460
with xlsxwriter.Workbook(output_file) as workbook:
449-
output.queryset_to_xlsx_worksheet(filtered_qs, workbook)
461+
output.queryset_to_xlsx_worksheet(
462+
queryset, workbook, extra_fields=extra_fields
463+
)
450464

451-
filename = f"{self.project.name}_{self.model._meta.model_name}.xlsx"
452465
output_file.seek(0)
453-
return FileResponse(output_file, as_attachment=True, filename=filename)
466+
return FileResponse(
467+
output_file,
468+
as_attachment=True,
469+
filename=self.get_export_xlsx_filename(),
470+
)
454471

455472
def get_context_data(self, **kwargs):
456473
context = super().get_context_data(**kwargs)
@@ -574,6 +591,7 @@ class ProjectListView(
574591
def get_context_data(self, **kwargs):
575592
context = super().get_context_data(**kwargs)
576593
context["archive_form"] = ArchiveProjectForm()
594+
context["outputs_download_form"] = ProjectOutputDownloadForm()
577595
return context
578596

579597
def get_queryset(self):
@@ -1137,29 +1155,35 @@ def form_valid(self, form):
11371155

11381156

11391157
@method_decorator(require_POST, name="dispatch")
1140-
class ProjectActionView(ConditionalLoginRequired, generic.ListView):
1158+
class ProjectActionView(ConditionalLoginRequired, ExportXLSXMixin, generic.ListView):
11411159
"""Call a method for each instance of the selection."""
11421160

11431161
model = Project
1144-
allowed_actions = ["archive", "delete", "reset"]
1162+
allowed_actions = ["archive", "delete", "reset", "report", "download"]
11451163
success_url = reverse_lazy("project_list")
11461164

11471165
def post(self, request, *args, **kwargs):
11481166
action = request.POST.get("action")
11491167
if action not in self.allowed_actions:
11501168
raise Http404
11511169

1152-
selected_ids = request.POST.get("selected_ids", "").split(",")
1170+
self.selected_project_ids = request.POST.get("selected_ids", "").split(",")
11531171
count = 0
11541172

11551173
action_kwargs = {}
1174+
if action == "report":
1175+
return self.export_xlsx_file_response()
1176+
1177+
if action == "download":
1178+
return self.download_outputs_zip_response()
1179+
11561180
if action == "archive":
11571181
archive_form = ArchiveProjectForm(request.POST)
11581182
if not archive_form.is_valid():
11591183
raise Http404
11601184
action_kwargs = archive_form.cleaned_data
11611185

1162-
for project_uuid in selected_ids:
1186+
for project_uuid in self.selected_project_ids:
11631187
if self.perform_action(action, project_uuid, action_kwargs):
11641188
count += 1
11651189

@@ -1186,6 +1210,46 @@ def perform_action(self, action, project_uuid, action_kwargs=None):
11861210
def get_success_message(self, action, count):
11871211
return f"{count} projects have been {action}."
11881212

1213+
def get_projects_queryset(self):
1214+
return Project.objects.filter(pk__in=self.selected_project_ids)
1215+
1216+
def get_export_xlsx_queryset(self):
1217+
projects = self.get_projects_queryset()
1218+
packages = DiscoveredPackage.objects.filter(project__in=projects)
1219+
packages = packages.select_related("project")
1220+
return packages
1221+
1222+
def get_export_xlsx_extra_fields(self):
1223+
return ["project"]
1224+
1225+
def get_export_xlsx_filename(self):
1226+
return "report.xlsx"
1227+
1228+
def download_outputs_zip_response(self):
1229+
outputs_download_form = ProjectOutputDownloadForm(self.request.POST)
1230+
if not outputs_download_form.is_valid():
1231+
return HttpResponseRedirect(self.success_url)
1232+
1233+
output_format = outputs_download_form.cleaned_data["output_format"]
1234+
output_function = output.FORMAT_TO_FUNCTION_MAPPING.get(output_format)
1235+
projects = self.get_projects_queryset()
1236+
1237+
# In-memory file storage for the zip archive
1238+
zip_buffer = io.BytesIO()
1239+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
1240+
for project in projects:
1241+
output_file = output_function(project)
1242+
filename = output.safe_filename(f"{project.name}_{output_file.name}")
1243+
with open(output_file, "rb") as f:
1244+
zip_file.writestr(filename, f.read())
1245+
1246+
zip_buffer.seek(0)
1247+
return FileResponse(
1248+
zip_buffer,
1249+
as_attachment=True,
1250+
filename="scancodeio_output_files.zip",
1251+
)
1252+
11891253

11901254
class ProjectResetView(ConditionalLoginRequired, generic.DeleteView):
11911255
model = Project

0 commit comments

Comments
 (0)