From 7c432301885986c09c89a16460d4224cfa7acde3 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Apr 2026 18:56:17 +0400 Subject: [PATCH 01/10] Add CSV export for the compliance dashboard Signed-off-by: tdruez --- .../compliance_dashboard.html | 11 ++++- product_portfolio/views.py | 46 ++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index 497f9975..5fe8223b 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -5,8 +5,15 @@ {% block content %}
-

{% trans "Compliance Control Center" %}

- {{ total_products }} {% trans "active product" %}{{ total_products|pluralize }} +

+ {% trans "Compliance Control Center" %} +

+ + {{ total_products }} {% trans "active product" %}{{ total_products|pluralize }} + + + {% trans "Export CSV" %} +
diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 48676ae8..4d022bf2 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -58,7 +58,8 @@ from django.views.generic import DetailView from django.views.generic import FormView from django.views.generic import TemplateView - +import csv +from django.http import HttpResponse from crispy_forms.utils import render_crispy_form from guardian.shortcuts import get_perms as guardian_get_perms from openpyxl import Workbook @@ -2829,6 +2830,11 @@ class ComplianceDashboardView(LoginRequiredMixin, DataspacedFilterView): filterset_class = ProductFilterSet paginate_by = settings.DEJACODE_PAGINATE_BY.get("compliance", 50) + def get(self, request, *args, **kwargs): + if request.GET.get("export") == "csv": + return self.export_csv() + return super().get(request, *args, **kwargs) + def get_queryset(self): base_qs = Product.objects.get_queryset( user=self.request.user, @@ -2905,3 +2911,41 @@ def get_context_data(self, **kwargs): ) return context + + def export_csv(self): + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="compliance_dashboard.csv"' + + products = self.get_queryset() + writer = csv.writer(response) + writer.writerow([ + "Product", + "Version", + "Packages", + "License errors", + "License warnings", + "Max risk level", + "Risk threshold", + "Critical", + "High", + "Medium", + "Low", + "Total vulnerabilities", + ]) + rows = products.values_list( + "name", + "version", + "package_count", + "license_error_count", + "license_warning_count", + "max_risk_level", + "risk_threshold", + "critical_count", + "high_count", + "medium_count", + "low_count", + "vulnerability_count", + ) + writer.writerows(rows) + + return response From b4c10c49efad721c233d5e8fbf46d8285a9564a7 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Apr 2026 19:27:55 +0400 Subject: [PATCH 02/10] Add XLSX export for the compliance dashboard Signed-off-by: tdruez --- .../compliance_dashboard.html | 20 ++++- product_portfolio/views.py | 74 +++++++++++++++---- 2 files changed, 75 insertions(+), 19 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index 5fe8223b..d00eafdd 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -11,9 +11,23 @@

{{ total_products }} {% trans "active product" %}{{ total_products|pluralize }} - - {% trans "Export CSV" %} - +

diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 4d022bf2..9d3e4660 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -58,8 +58,7 @@ from django.views.generic import DetailView from django.views.generic import FormView from django.views.generic import TemplateView -import csv -from django.http import HttpResponse + from crispy_forms.utils import render_crispy_form from guardian.shortcuts import get_perms as guardian_get_perms from openpyxl import Workbook @@ -68,6 +67,7 @@ from openpyxl.styles import Font from openpyxl.styles import NamedStyle from openpyxl.styles import Side +from openpyxl.utils import get_column_letter from component_catalog.forms import ComponentAjaxForm from component_catalog.license_expression_dje import build_licensing @@ -2830,11 +2830,6 @@ class ComplianceDashboardView(LoginRequiredMixin, DataspacedFilterView): filterset_class = ProductFilterSet paginate_by = settings.DEJACODE_PAGINATE_BY.get("compliance", 50) - def get(self, request, *args, **kwargs): - if request.GET.get("export") == "csv": - return self.export_csv() - return super().get(request, *args, **kwargs) - def get_queryset(self): base_qs = Product.objects.get_queryset( user=self.request.user, @@ -2912,13 +2907,14 @@ def get_context_data(self, **kwargs): return context - def export_csv(self): - response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = 'attachment; filename="compliance_dashboard.csv"' + def get(self, request, *args, **kwargs): + export_format = request.GET.get("export") + if export_format in ("csv", "xlsx"): + return self.export(export_format) + return super().get(request, *args, **kwargs) - products = self.get_queryset() - writer = csv.writer(response) - writer.writerow([ + def get_export_headers(self): + return [ "Product", "Version", "Packages", @@ -2931,8 +2927,10 @@ def export_csv(self): "Medium", "Low", "Total vulnerabilities", - ]) - rows = products.values_list( + ] + + def get_export_rows(self): + return self.get_queryset().values_list( "name", "version", "package_count", @@ -2946,6 +2944,50 @@ def export_csv(self): "low_count", "vulnerability_count", ) - writer.writerows(rows) + def export(self, export_format): + if export_format == "csv": + return self.export_csv() + return self.export_xlsx() + + def export_csv(self): + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="compliance_dashboard.csv"' + + writer = csv.writer(response) + writer.writerow(self.get_export_headers()) + writer.writerows(self.get_export_rows()) + return response + + def export_xlsx(self): + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "Compliance Dashboard" + + headers = self.get_export_headers() + worksheet.append(headers) + + # Header styling + header_style = NamedStyle(name="header") + header_style.font = Font(bold=True) + header_style.border = Border(bottom=Side(border_style="thin")) + header_style.alignment = Alignment(horizontal="center", vertical="center") + for cell in worksheet[1]: + cell.style = header_style + + worksheet.freeze_panes = "A2" + + for row in self.get_export_rows(): + worksheet.append(row) + + # Auto-width columns + for col_index, header in enumerate(headers, 1): + column_letter = get_column_letter(col_index) + worksheet.column_dimensions[column_letter].width = max(len(header) + 4, 12) + + response = HttpResponse( + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + response["Content-Disposition"] = 'attachment; filename="compliance_dashboard.xlsx"' + workbook.save(response) return response From 6650ab97d2688632ee94eda51aa3d19b31d029fa Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Apr 2026 19:37:28 +0400 Subject: [PATCH 03/10] Add JSON export for the compliance dashboard Signed-off-by: tdruez --- .../compliance_dashboard.html | 5 ++ product_portfolio/views.py | 70 ++++++++++--------- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index d00eafdd..408549d5 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -26,6 +26,11 @@

{% trans "XLSX" %} +
  • + + {% trans "JSON" %} + +
  • diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 9d3e4660..afe85fab 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2829,6 +2829,20 @@ class ComplianceDashboardView(LoginRequiredMixin, DataspacedFilterView): model = Product filterset_class = ProductFilterSet paginate_by = settings.DEJACODE_PAGINATE_BY.get("compliance", 50) + export_fields = { + "name": "Product", + "version": "Version", + "package_count": "Packages", + "license_error_count": "License errors", + "license_warning_count": "License warnings", + "max_risk_level": "Max risk level", + "risk_threshold": "Risk threshold", + "critical_count": "Critical", + "high_count": "High", + "medium_count": "Medium", + "low_count": "Low", + "vulnerability_count": "Total vulnerabilities", + } def get_queryset(self): base_qs = Product.objects.get_queryset( @@ -2909,46 +2923,25 @@ def get_context_data(self, **kwargs): def get(self, request, *args, **kwargs): export_format = request.GET.get("export") - if export_format in ("csv", "xlsx"): + if export_format in ("csv", "xlsx", "json"): return self.export(export_format) return super().get(request, *args, **kwargs) - def get_export_headers(self): - return [ - "Product", - "Version", - "Packages", - "License errors", - "License warnings", - "Max risk level", - "Risk threshold", - "Critical", - "High", - "Medium", - "Low", - "Total vulnerabilities", - ] - - def get_export_rows(self): - return self.get_queryset().values_list( - "name", - "version", - "package_count", - "license_error_count", - "license_warning_count", - "max_risk_level", - "risk_threshold", - "critical_count", - "high_count", - "medium_count", - "low_count", - "vulnerability_count", - ) - def export(self, export_format): if export_format == "csv": return self.export_csv() - return self.export_xlsx() + if export_format == "xlsx": + return self.export_xlsx() + return self.export_json() + + def get_export_headers(self): + return list(self.export_fields.values()) + + def get_export_fields(self): + return list(self.export_fields.keys()) + + def get_export_rows(self): + return self.get_queryset().values_list(*self.get_export_fields()) def export_csv(self): response = HttpResponse(content_type="text/csv") @@ -2991,3 +2984,12 @@ def export_xlsx(self): response["Content-Disposition"] = 'attachment; filename="compliance_dashboard.xlsx"' workbook.save(response) return response + + def export_json(self): + data = list(self.get_queryset().values(*self.get_export_fields())) + response = HttpResponse( + json.dumps(data, indent=2, default=str), + content_type="application/json", + ) + response["Content-Disposition"] = 'attachment; filename="compliance_dashboard.json"' + return response From 14ca47a7ea096de2724ece312ee4ec7e9726f456 Mon Sep 17 00:00:00 2001 From: tdruez Date: Fri, 17 Apr 2026 19:39:19 +0400 Subject: [PATCH 04/10] refactor the compliance_dashboard filename as a var Signed-off-by: tdruez --- product_portfolio/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index afe85fab..ace3b8de 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2829,6 +2829,7 @@ class ComplianceDashboardView(LoginRequiredMixin, DataspacedFilterView): model = Product filterset_class = ProductFilterSet paginate_by = settings.DEJACODE_PAGINATE_BY.get("compliance", 50) + export_filename = "compliance_dashboard" export_fields = { "name": "Product", "version": "Version", @@ -2945,7 +2946,7 @@ def get_export_rows(self): def export_csv(self): response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = 'attachment; filename="compliance_dashboard.csv"' + response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.csv"' writer = csv.writer(response) writer.writerow(self.get_export_headers()) @@ -2981,7 +2982,7 @@ def export_xlsx(self): response = HttpResponse( content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) - response["Content-Disposition"] = 'attachment; filename="compliance_dashboard.xlsx"' + response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.xlsx"' workbook.save(response) return response @@ -2991,5 +2992,5 @@ def export_json(self): json.dumps(data, indent=2, default=str), content_type="application/json", ) - response["Content-Disposition"] = 'attachment; filename="compliance_dashboard.json"' + response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.json"' return response From 67a8940f83f50282b0fb65a0de52d59528d18941 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Apr 2026 13:13:45 +0400 Subject: [PATCH 05/10] extract the export methods into a Mixin Signed-off-by: tdruez --- .../compliance_dashboard.html | 6 +- product_portfolio/views.py | 154 +++++++++--------- 2 files changed, 83 insertions(+), 77 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index 408549d5..fae9abf2 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -18,17 +18,17 @@

    diff --git a/product_portfolio/views.py b/product_portfolio/views.py index ace3b8de..13073e81 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2822,7 +2822,86 @@ def get_security_compliance_context(product, display_limit=10): } -class ComplianceDashboardView(LoginRequiredMixin, DataspacedFilterView): +class ExportComplianceMixin: + """Mixin for views that support CSV, XLSX, and JSON export.""" + + export_filename = "export" + export_fields = {} + + def get(self, request, *args, **kwargs): + export_format = request.GET.get("export") + if export_format in ("csv", "xlsx", "json"): + return self.export(export_format) + return super().get(request, *args, **kwargs) + + def get_export_headers(self): + return list(self.export_fields.values()) + + def get_export_fields(self): + return list(self.export_fields.keys()) + + def get_export_queryset(self): + return self.get_queryset() + + def get_export_rows(self): + return self.get_export_queryset().values_list(*self.get_export_fields()) + + def export(self, export_format): + if export_format == "csv": + return self.export_csv() + if export_format == "xlsx": + return self.export_xlsx() + return self.export_json() + + def export_csv(self): + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.csv"' + writer = csv.writer(response) + writer.writerow(self.get_export_headers()) + writer.writerows(self.get_export_rows()) + return response + + def export_xlsx(self): + workbook = Workbook() + worksheet = workbook.active + worksheet.title = self.export_filename.replace("_", " ").title() + + headers = self.get_export_headers() + worksheet.append(headers) + + header_style = NamedStyle(name="header") + header_style.font = Font(bold=True) + header_style.border = Border(bottom=Side(border_style="thin")) + header_style.alignment = Alignment(horizontal="center", vertical="center") + for cell in worksheet[1]: + cell.style = header_style + worksheet.freeze_panes = "A2" + + for row in self.get_export_rows(): + worksheet.append(row) + + for col_index, header in enumerate(headers, 1): + column_letter = get_column_letter(col_index) + worksheet.column_dimensions[column_letter].width = max(len(header) + 4, 12) + + response = HttpResponse( + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.xlsx"' + workbook.save(response) + return response + + def export_json(self): + data = list(self.get_export_queryset().values(*self.get_export_fields())) + response = HttpResponse( + json.dumps(data, indent=2, default=str), + content_type="application/json", + ) + response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.json"' + return response + + +class ComplianceDashboardView(LoginRequiredMixin, ExportComplianceMixin, DataspacedFilterView): """Compliance control center: overview of all products.""" template_name = "product_portfolio/compliance_dashboard.html" @@ -2921,76 +3000,3 @@ def get_context_data(self, **kwargs): ) return context - - def get(self, request, *args, **kwargs): - export_format = request.GET.get("export") - if export_format in ("csv", "xlsx", "json"): - return self.export(export_format) - return super().get(request, *args, **kwargs) - - def export(self, export_format): - if export_format == "csv": - return self.export_csv() - if export_format == "xlsx": - return self.export_xlsx() - return self.export_json() - - def get_export_headers(self): - return list(self.export_fields.values()) - - def get_export_fields(self): - return list(self.export_fields.keys()) - - def get_export_rows(self): - return self.get_queryset().values_list(*self.get_export_fields()) - - def export_csv(self): - response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.csv"' - - writer = csv.writer(response) - writer.writerow(self.get_export_headers()) - writer.writerows(self.get_export_rows()) - return response - - def export_xlsx(self): - workbook = Workbook() - worksheet = workbook.active - worksheet.title = "Compliance Dashboard" - - headers = self.get_export_headers() - worksheet.append(headers) - - # Header styling - header_style = NamedStyle(name="header") - header_style.font = Font(bold=True) - header_style.border = Border(bottom=Side(border_style="thin")) - header_style.alignment = Alignment(horizontal="center", vertical="center") - for cell in worksheet[1]: - cell.style = header_style - - worksheet.freeze_panes = "A2" - - for row in self.get_export_rows(): - worksheet.append(row) - - # Auto-width columns - for col_index, header in enumerate(headers, 1): - column_letter = get_column_letter(col_index) - worksheet.column_dimensions[column_letter].width = max(len(header) + 4, 12) - - response = HttpResponse( - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ) - response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.xlsx"' - workbook.save(response) - return response - - def export_json(self): - data = list(self.get_queryset().values(*self.get_export_fields())) - response = HttpResponse( - json.dumps(data, indent=2, default=str), - content_type="application/json", - ) - response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.json"' - return response From b2c54bd4ca00652b446b4b47caf5ca287852bade Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Apr 2026 13:21:52 +0400 Subject: [PATCH 06/10] refactored the xlsx styling for reusability Signed-off-by: tdruez --- dje/utils.py | 24 ++++++++++++++++++++++++ product_portfolio/views.py | 30 +++--------------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dje/utils.py b/dje/utils.py index 2d210a8c..0d1d734b 100644 --- a/dje/utils.py +++ b/dje/utils.py @@ -34,6 +34,12 @@ from django.utils.http import urlencode import requests +from openpyxl.styles import Alignment +from openpyxl.styles import Border +from openpyxl.styles import Font +from openpyxl.styles import NamedStyle +from openpyxl.styles import Side +from openpyxl.utils import get_column_letter from packageurl import PackageURL @@ -747,3 +753,21 @@ def merge_common_non_empty_values(dicts): merged_result[key] = values[0] return merged_result + + +def style_xlsx_worksheet(worksheet, headers=None, auto_width=True): + """Apply standard styling to an XLSX worksheet.""" + header_style = NamedStyle(name="header") + header_style.font = Font(bold=True) + header_style.border = Border(bottom=Side(border_style="thin")) + header_style.alignment = Alignment(horizontal="center", vertical="center") + + for cell in worksheet[1]: + cell.style = header_style + + worksheet.freeze_panes = "A2" + + if auto_width and headers: + for col_index, header in enumerate(headers, 1): + column_letter = get_column_letter(col_index) + worksheet.column_dimensions[column_letter].width = max(len(str(header)) + 4, 12) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 13073e81..2c2c0e2e 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -62,12 +62,6 @@ from crispy_forms.utils import render_crispy_form from guardian.shortcuts import get_perms as guardian_get_perms from openpyxl import Workbook -from openpyxl.styles import Alignment -from openpyxl.styles import Border -from openpyxl.styles import Font -from openpyxl.styles import NamedStyle -from openpyxl.styles import Side -from openpyxl.utils import get_column_letter from component_catalog.forms import ComponentAjaxForm from component_catalog.license_expression_dje import build_licensing @@ -93,6 +87,7 @@ from dje.utils import get_object_compare_diff from dje.utils import group_by_simple from dje.utils import is_uuid4 +from dje.utils import style_xlsx_worksheet from dje.views import DataspacedCreateView from dje.views import DataspacedDeleteView from dje.views import DataspacedFilterView @@ -1697,16 +1692,7 @@ def get_relation_data(relation, diff, is_left): ws.append(row) # Styling - header = NamedStyle(name="header") - header.font = Font(bold=True) - header.border = Border(bottom=Side(border_style="thin")) - header.alignment = Alignment(horizontal="center", vertical="center") - header_row = ws[1] - for cell in header_row: - cell.style = header - - # Freeze first header row - ws.freeze_panes = "A2" + style_xlsx_worksheet(ws) # Columns width ws.column_dimensions["B"].width = 40 @@ -2869,20 +2855,10 @@ def export_xlsx(self): headers = self.get_export_headers() worksheet.append(headers) - header_style = NamedStyle(name="header") - header_style.font = Font(bold=True) - header_style.border = Border(bottom=Side(border_style="thin")) - header_style.alignment = Alignment(horizontal="center", vertical="center") - for cell in worksheet[1]: - cell.style = header_style - worksheet.freeze_panes = "A2" - for row in self.get_export_rows(): worksheet.append(row) - for col_index, header in enumerate(headers, 1): - column_letter = get_column_letter(col_index) - worksheet.column_dimensions[column_letter].width = max(len(header) + 4, 12) + style_xlsx_worksheet(worksheet, headers) response = HttpResponse( content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" From 2cfb2dff907daeca6429662c058835f390ac2c0d Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Apr 2026 13:47:06 +0400 Subject: [PATCH 07/10] add support for OpenDocument .ods format Signed-off-by: tdruez --- .../compliance_dashboard.html | 13 +++++--- product_portfolio/views.py | 32 +++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index fae9abf2..973b75ac 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -22,13 +22,18 @@

  • - - {% trans "Microsoft Excel (.xlsx)" %} + + {% trans "JSON (.json)" %}
  • - - {% trans "JSON (.json)" %} + + {% trans "OpenDocument (.ods)" %} + +
  • +
  • + + {% trans "Microsoft Excel (.xlsx)" %}
  • diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 2c2c0e2e..f646a1c5 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -7,6 +7,7 @@ # import csv +import io import json from collections import OrderedDict from collections import defaultdict @@ -59,6 +60,7 @@ from django.views.generic import FormView from django.views.generic import TemplateView +import odfdo from crispy_forms.utils import render_crispy_form from guardian.shortcuts import get_perms as guardian_get_perms from openpyxl import Workbook @@ -2811,12 +2813,12 @@ def get_security_compliance_context(product, display_limit=10): class ExportComplianceMixin: """Mixin for views that support CSV, XLSX, and JSON export.""" - export_filename = "export" + export_filename = "compliance_dashboard" export_fields = {} def get(self, request, *args, **kwargs): export_format = request.GET.get("export") - if export_format in ("csv", "xlsx", "json"): + if export_format in ("csv", "xlsx", "json", "ods"): return self.export(export_format) return super().get(request, *args, **kwargs) @@ -2837,6 +2839,8 @@ def export(self, export_format): return self.export_csv() if export_format == "xlsx": return self.export_xlsx() + if export_format == "ods": + return self.export_ods() return self.export_json() def export_csv(self): @@ -2876,6 +2880,30 @@ def export_json(self): response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.json"' return response + def export_ods(self): + title = self.export_filename.replace("_", " ").title() + table = odfdo.Table(title) + + for row_data in [self.get_export_headers()] + list(self.get_export_rows()): + row = odfdo.Row() + for value in row_data: + row.append(odfdo.Cell(str(value if value is not None else ""), cell_type="string")) + table.append(row) + + document = odfdo.Document("spreadsheet") + document.body.clear() + document.body.append(table) + + file_output = io.BytesIO() + document.save(file_output) + + response = HttpResponse( + file_output.getvalue(), + content_type="application/vnd.oasis.opendocument.spreadsheet", + ) + response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.ods"' + return response + class ComplianceDashboardView(LoginRequiredMixin, ExportComplianceMixin, DataspacedFilterView): """Compliance control center: overview of all products.""" From d8b6c4d2e844a2ab6ba926a7ed81edcf05ef6afd Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Apr 2026 13:56:33 +0400 Subject: [PATCH 08/10] add support for YAML .yaml format Signed-off-by: tdruez --- .../product_portfolio/compliance_dashboard.html | 5 +++++ product_portfolio/views.py | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index 973b75ac..4f393b08 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -36,6 +36,11 @@

    {% trans "Microsoft Excel (.xlsx)" %} +
  • + + {% trans "YAML (.yaml)" %} + +
  • diff --git a/product_portfolio/views.py b/product_portfolio/views.py index f646a1c5..26eebf76 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -61,6 +61,7 @@ from django.views.generic import TemplateView import odfdo +import saneyaml from crispy_forms.utils import render_crispy_form from guardian.shortcuts import get_perms as guardian_get_perms from openpyxl import Workbook @@ -2818,7 +2819,7 @@ class ExportComplianceMixin: def get(self, request, *args, **kwargs): export_format = request.GET.get("export") - if export_format in ("csv", "xlsx", "json", "ods"): + if export_format in ("csv", "xlsx", "json", "ods", "yaml"): return self.export(export_format) return super().get(request, *args, **kwargs) @@ -2841,6 +2842,8 @@ def export(self, export_format): return self.export_xlsx() if export_format == "ods": return self.export_ods() + if export_format == "yaml": + return self.export_yaml() return self.export_json() def export_csv(self): @@ -2904,6 +2907,17 @@ def export_ods(self): response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.ods"' return response + def export_yaml(self): + fields = self.get_export_fields() + # Round-trip through JSON to convert Decimal and other non-serializable types + data = json.loads(json.dumps(list(self.get_export_queryset().values(*fields)), default=str)) + response = HttpResponse( + saneyaml.dump(data), + content_type="application/x-yaml", + ) + response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.yaml"' + return response + class ComplianceDashboardView(LoginRequiredMixin, ExportComplianceMixin, DataspacedFilterView): """Compliance control center: overview of all products.""" From 81b0ae875845dbaaf06207d56d641a89b929c453 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Apr 2026 14:02:10 +0400 Subject: [PATCH 09/10] add timestamp in export filename Signed-off-by: tdruez --- product_portfolio/views.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 26eebf76..8a1dcf50 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -49,6 +49,7 @@ from django.template.context_processors import csrf from django.template.response import TemplateResponse from django.urls import reverse +from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.html import format_html from django.utils.html import mark_safe @@ -2814,7 +2815,7 @@ def get_security_compliance_context(product, display_limit=10): class ExportComplianceMixin: """Mixin for views that support CSV, XLSX, and JSON export.""" - export_filename = "compliance_dashboard" + export_filename = "export" export_fields = {} def get(self, request, *args, **kwargs): @@ -2835,6 +2836,13 @@ def get_export_queryset(self): def get_export_rows(self): return self.get_export_queryset().values_list(*self.get_export_fields()) + def get_export_filename(self, extension): + timestamp = timezone.now().strftime("%Y-%m-%d_%H%M%S") + return f"{self.export_filename}_{timestamp}.{extension}" + + def get_content_disposition(self, extension): + return f'attachment; filename="{self.get_export_filename(extension)}"' + def export(self, export_format): if export_format == "csv": return self.export_csv() @@ -2848,7 +2856,7 @@ def export(self, export_format): def export_csv(self): response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.csv"' + response["Content-Disposition"] = self.get_content_disposition("csv") writer = csv.writer(response) writer.writerow(self.get_export_headers()) writer.writerows(self.get_export_rows()) @@ -2870,7 +2878,7 @@ def export_xlsx(self): response = HttpResponse( content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) - response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.xlsx"' + response["Content-Disposition"] = self.get_content_disposition("xlsx") workbook.save(response) return response @@ -2880,7 +2888,7 @@ def export_json(self): json.dumps(data, indent=2, default=str), content_type="application/json", ) - response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.json"' + response["Content-Disposition"] = self.get_content_disposition("json") return response def export_ods(self): @@ -2904,7 +2912,7 @@ def export_ods(self): file_output.getvalue(), content_type="application/vnd.oasis.opendocument.spreadsheet", ) - response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.ods"' + response["Content-Disposition"] = self.get_content_disposition("ods") return response def export_yaml(self): @@ -2915,7 +2923,7 @@ def export_yaml(self): saneyaml.dump(data), content_type="application/x-yaml", ) - response["Content-Disposition"] = f'attachment; filename="{self.export_filename}.yaml"' + response["Content-Disposition"] = self.get_content_disposition("yaml") return response From 900f1f4269f3ee7b73f20d2053726cb8008aa37d Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 20 Apr 2026 17:18:56 +0400 Subject: [PATCH 10/10] add unit test for compliance export formats Signed-off-by: tdruez --- product_portfolio/tests/test_views.py | 144 ++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index a5db81a2..7ba928b7 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -3853,3 +3853,147 @@ def test_product_portfolio_compliance_dashboard_view_risk_threshold_display(self response = self.client.get(url) self.assertContains(response, "7.0") self.assertContains(response, "Risk threshold") + + def test_product_portfolio_compliance_dashboard_view_export_csv(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + p1 = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) + make_product(self.dataspace, inventory=[p1]) + + response = self.client.get(url + "?export=csv") + self.assertEqual(200, response.status_code) + self.assertEqual("text/csv", response["Content-Type"]) + self.assertIn("compliance_dashboard_", response["Content-Disposition"]) + self.assertIn(".csv", response["Content-Disposition"]) + + content = response.content.decode() + self.assertIn("Product,Version,Packages", content) + self.assertIn("critical", content) + + def test_product_portfolio_compliance_dashboard_view_export_xlsx(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + make_product_package(self.product1) + + response = self.client.get(url + "?export=xlsx") + self.assertEqual(200, response.status_code) + expected_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + self.assertEqual(expected_type, response["Content-Type"]) + self.assertIn("compliance_dashboard_", response["Content-Disposition"]) + self.assertIn(".xlsx", response["Content-Disposition"]) + + def test_product_portfolio_compliance_dashboard_view_export_json(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + p1 = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) + product1 = make_product(self.dataspace, inventory=[p1]) + + response = self.client.get(url + "?export=json") + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response["Content-Type"]) + self.assertIn("compliance_dashboard_", response["Content-Disposition"]) + self.assertIn(".json", response["Content-Disposition"]) + + data = json.loads(response.content) + self.assertTrue(len(data) > 0) + first = next(entry for entry in data if entry["name"] == product1.name) + self.assertEqual(1, first["critical_count"]) + + def test_product_portfolio_compliance_dashboard_view_export_ods(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + make_product_package(self.product1) + + response = self.client.get(url + "?export=ods") + self.assertEqual(200, response.status_code) + expected_type = "application/vnd.oasis.opendocument.spreadsheet" + self.assertEqual(expected_type, response["Content-Type"]) + self.assertIn("compliance_dashboard_", response["Content-Disposition"]) + self.assertIn(".ods", response["Content-Disposition"]) + + def test_product_portfolio_compliance_dashboard_view_export_yaml(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + p1 = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) + product1 = make_product(self.dataspace, inventory=[p1]) + + response = self.client.get(url + "?export=yaml") + self.assertEqual(200, response.status_code) + self.assertEqual("application/x-yaml", response["Content-Type"]) + self.assertIn("compliance_dashboard_", response["Content-Disposition"]) + self.assertIn(".yaml", response["Content-Disposition"]) + + content = response.content.decode() + self.assertIn(product1.name, content) + self.assertIn("critical_count", content) + + def test_product_portfolio_compliance_dashboard_view_export_respects_permissions(self): + self.client.login(username=self.basic_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + p1 = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) + product1 = make_product(self.dataspace, inventory=[p1]) + + # Without permission, export should return empty data + response = self.client.get(url + "?export=json") + self.assertEqual(200, response.status_code) + data = json.loads(response.content) + product_names = [entry["name"] for entry in data] + self.assertNotIn(product1.name, product_names) + + # With permission, product should appear + assign_perm("view_product", self.basic_user, product1) + response = self.client.get(url + "?export=json") + data = json.loads(response.content) + product_names = [entry["name"] for entry in data] + self.assertIn(product1.name, product_names) + + def test_product_portfolio_compliance_dashboard_view_export_invalid_format(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + response = self.client.get(url + "?export=pdf") + self.assertEqual(200, response.status_code) + # Invalid format falls through to normal HTML view + self.assertContains(response, "Compliance Control Center") + + def test_product_portfolio_compliance_dashboard_view_export_filename_has_timestamp(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + response = self.client.get(url + "?export=csv") + disposition = response["Content-Disposition"] + # Format: compliance_dashboard_YYYY-MM-DD_HHMMSS.csv + self.assertRegex( + disposition, + r"compliance_dashboard_\d{4}-\d{2}-\d{2}_\d{6}\.csv", + ) + + def test_product_portfolio_compliance_dashboard_view_export_risk_threshold(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + p1 = make_package(self.dataspace) + p2 = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) + make_vulnerability(self.dataspace, affecting=[p2], risk_score=2.0) + + product1 = make_product(self.dataspace, inventory=[p1, p2]) + product1.update(vulnerabilities_risk_threshold=6.0) + + response = self.client.get(url + "?export=json") + data = json.loads(response.content) + product_data = next(entry for entry in data if entry["name"] == product1.name) + # Only the critical vulnerability (9.0) should count, low (2.0) is below threshold + self.assertEqual(1, product_data["vulnerability_count"]) + self.assertEqual(1, product_data["critical_count"]) + self.assertEqual(0, product_data["low_count"])