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(