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/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html
index 497f9975..4f393b08 100644
--- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html
+++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html
@@ -5,8 +5,44 @@
{% 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 }}
+
+
+
+
+
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"])
diff --git a/product_portfolio/views.py b/product_portfolio/views.py
index 48676ae8..8a1dcf50 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
@@ -48,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
@@ -59,14 +61,11 @@
from django.views.generic import FormView
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
-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 component_catalog.forms import ComponentAjaxForm
from component_catalog.license_expression_dje import build_licensing
@@ -92,6 +91,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
@@ -1696,16 +1696,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
@@ -2821,13 +2812,143 @@ 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", "ods", "yaml"):
+ 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 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()
+ if export_format == "xlsx":
+ 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):
+ response = HttpResponse(content_type="text/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())
+ 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)
+
+ for row in self.get_export_rows():
+ worksheet.append(row)
+
+ style_xlsx_worksheet(worksheet, headers)
+
+ response = HttpResponse(
+ content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ )
+ response["Content-Disposition"] = self.get_content_disposition("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"] = self.get_content_disposition("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"] = self.get_content_disposition("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"] = self.get_content_disposition("yaml")
+ return response
+
+
+class ComplianceDashboardView(LoginRequiredMixin, ExportComplianceMixin, DataspacedFilterView):
"""Compliance control center: overview of all products."""
template_name = "product_portfolio/compliance_dashboard.html"
model = Product
filterset_class = ProductFilterSet
paginate_by = settings.DEJACODE_PAGINATE_BY.get("compliance", 50)
+ export_filename = "compliance_dashboard"
+ 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(