diff --git a/dje/api.py b/dje/api.py index ff83ff2e..7a4bc0bf 100644 --- a/dje/api.py +++ b/dje/api.py @@ -660,6 +660,6 @@ def cyclonedx_sbom(self, request, uuid): return outputs.get_attachment_response( file_content=cyclonedx_bom_json, - filename=outputs.get_filename(instance, extension="cdx"), + filename=outputs.get_filename(instance, extension="cdx.json"), content_type="application/json", ) diff --git a/dje/outputs.py b/dje/outputs.py index 348f3eb7..d5b93624 100644 --- a/dje/outputs.py +++ b/dje/outputs.py @@ -13,6 +13,7 @@ from django.http import FileResponse from django.http import Http404 +from django.utils import timezone import msgspec from cyclonedx import output as cyclonedx_output @@ -33,6 +34,27 @@ def safe_filename(filename): return re.sub("[^A-Za-z0-9.-]+", "_", filename).lower() +def get_filename(instance, extension): + filename = f"dejacode_{instance.dataspace.name}_{instance}.{extension}" + return safe_filename(filename) + + +def get_export_filename(dataspace, report_type, extension, instance=None): + """Return a safe filename for exports.""" + timestamp = timezone.now().strftime("%Y-%m-%d_%H%M%S") + if instance: + filename = f"dejacode_{dataspace.name}_{instance}_{report_type}_{timestamp}.{extension}" + else: + filename = f"dejacode_{dataspace.name}_{report_type}_{timestamp}.{extension}" + return safe_filename(filename) + + +def get_spdx_filename(spdx_document): + document_name = spdx_document.as_dict()["name"] + filename = f"{document_name}.spdx.json" + return safe_filename(filename) + + def get_attachment_response(file_content, filename, content_type): if not file_content or not filename: raise Http404 @@ -97,12 +119,6 @@ def get_spdx_document(instance, user): return document -def get_spdx_filename(spdx_document): - document_name = spdx_document.as_dict()["name"] - filename = f"{document_name}.spdx.json" - return safe_filename(filename) - - def get_cyclonedx_bom(instance, user, include_components=True, include_vex=False): """ https://cyclonedx.org/use-cases/#dependency-graph @@ -195,12 +211,6 @@ def sort_bom_with_schema_ordering(bom_as_dict, schema_version): return json.dumps(ordered_dict, indent=2) -def get_filename(instance, extension): - base_filename = f"dejacode_{instance.dataspace.name}_{instance._meta.model_name}" - filename = f"{base_filename}_{instance}.{extension}.json" - return safe_filename(filename) - - CDX_STATE_TO_CSAF_STATUS = { "resolved": "fixed", "resolved_with_pedigree": "fixed", diff --git a/dje/tests/test_outputs.py b/dje/tests/test_outputs.py index fefdd19e..b71f9282 100644 --- a/dje/tests/test_outputs.py +++ b/dje/tests/test_outputs.py @@ -191,10 +191,36 @@ def test_outputs_get_cyclonedx_bom_json(self): def test_outputs_get_filename(self): self.assertEqual( - "dejacode_nexb_product_product1_with_space_1.0.cdx.json", - outputs.get_filename(instance=self.product1, extension="cdx"), + "dejacode_nexb_product1_with_space_1.0.cdx.json", + outputs.get_filename(instance=self.product1, extension="cdx.json"), ) + def test_outputs_get_export_filename(self): + mock_now = datetime(2024, 10, 10, 12, 0, 0, tzinfo=UTC) + with mock.patch("dje.outputs.timezone") as mock_timezone: + mock_timezone.now.return_value = mock_now + + filename_without_instance = outputs.get_export_filename( + dataspace=self.dataspace, + report_type="vulnerabilities", + extension="csv", + ) + self.assertEqual( + "dejacode_nexb_vulnerabilities_2024-10-10_120000.csv", + filename_without_instance, + ) + + filename_with_instance = outputs.get_export_filename( + dataspace=self.dataspace, + report_type="vulnerabilities", + extension="csv", + instance=self.product1, + ) + self.assertEqual( + "dejacode_nexb_product1_with_space_1.0_vulnerabilities_2024-10-10_120000.csv", + filename_with_instance, + ) + def test_outputs_get_csaf_security_advisory(self): mock_now = datetime(2024, 12, 19, 12, 0, 0, tzinfo=UTC) with mock.patch("dje.outputs.datetime") as mock_datetime: diff --git a/dje/views.py b/dje/views.py index 6c093f77..09b5e463 100644 --- a/dje/views.py +++ b/dje/views.py @@ -2407,11 +2407,11 @@ def get(self, request, *args, **kwargs): spec_version = self.request.GET.get("spec_version") content = self.request.GET.get("content", "sbom") - extension = "cdx" + extension = "cdx.json" include_components = True include_vex = False if content == "vex": - extension = "vex" + extension = "vex.json" include_components = False include_vex = True elif content == "combined": @@ -2446,7 +2446,7 @@ def get(self, request, *args, **kwargs): product = self.get_object() security_advisory = outputs.get_csaf_security_advisory(product) security_advisory_json = security_advisory.model_dump_json(indent=2, exclude_none=True) - filename = outputs.get_filename(product, extension="csaf.vex") + filename = outputs.get_filename(product, extension="csaf.vex.json") return outputs.get_attachment_response( file_content=security_advisory_json, @@ -2464,7 +2464,7 @@ class ExportOpenVEXView( def get(self, request, *args, **kwargs): product = self.get_object() openvex_document_json = outputs.get_openvex_document_json(product) - filename = outputs.get_filename(product, extension="openvex") + filename = outputs.get_filename(product, extension="openvex.json") return outputs.get_attachment_response( file_content=openvex_document_json, diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 00f4506a..0205e7c5 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -417,6 +417,12 @@ def get_export_csaf_url(self): def get_export_openvex_url(self): return self.get_url("export_openvex") + def get_export_license_compliance_url(self): + return self.get_url("export_license_compliance") + + def get_export_security_compliance_url(self): + return self.get_url("export_security_compliance") + @property def cyclonedx_bom_ref(self): return str(self.uuid) diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html index 7ae4f2e9..7f163654 100644 --- a/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html +++ b/product_portfolio/templates/product_portfolio/compliance/compliance_panels.html @@ -5,19 +5,49 @@

{% trans "License compliance" %}

- {% if license_issues_count == 0 %} - - {% trans "OK" %} - - {% elif license_error_count > 0 %} - - {% trans "Error" %} - - {% else %} - - {% trans "Warning" %} - - {% endif %} +
+ + {% if license_issues_count == 0 %} + {% trans "OK" %} + {% elif license_error_count > 0 %} + {% trans "Error" %} + {% else %} + {% trans "Warning" %} + {% endif %} +

@@ -73,17 +103,53 @@

{% trans "License compliance" %}

{# Header #}

{% trans "Security compliance" %}

- {% if vulnerability_count == 0 or above_threshold_count == 0 %} - {% trans "OK" %} - {% elif max_vulnerability_severity == "critical" %} - {% trans "Critical" %} - {% elif max_vulnerability_severity == "high" %} - {% trans "High" %} - {% elif max_vulnerability_severity == "medium" %} - {% trans "Medium" %} - {% else %} - {% trans "Low" %} - {% endif %} +
+ + {% if vulnerability_count == 0 or above_threshold_count == 0 %} + {% trans "OK" %} + {% elif max_vulnerability_severity == "critical" %} + {% trans "Critical" %} + {% elif max_vulnerability_severity == "high" %} + {% trans "High" %} + {% elif max_vulnerability_severity == "medium" %} + {% trans "Medium" %} + {% else %} + {% trans "Low" %} + {% endif %} +
{# Summary #} diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py index 508849ff..88b50bcd 100644 --- a/product_portfolio/tests/test_api.py +++ b/product_portfolio/tests/test_api.py @@ -573,7 +573,7 @@ def test_api_product_endpoint_cyclonedx_sbom_action(self): response = self.client.get(url) self.assertEqual(status.HTTP_200_OK, response.status_code) - expected = 'attachment; filename="dejacode_nexb_product_p1.cdx.json"' + expected = 'attachment; filename="dejacode_nexb_p1.cdx.json"' self.assertEqual(expected, response["Content-Disposition"]) self.assertEqual("application/json", response["Content-Type"]) self.assertIn('"specVersion": "1.6"', str(response.getvalue())) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 7ba928b7..f47da018 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -38,8 +38,10 @@ from dje.tests import create_superuser from dje.tests import create_user from license_library.models import License +from license_library.tests import make_license from organization.models import Owner from policy.models import UsagePolicy +from policy.tests import make_usage_policy from product_portfolio.forms import ProductForm from product_portfolio.forms import ProductGridConfigurationForm from product_portfolio.forms import ProductPackageForm @@ -3060,9 +3062,7 @@ def test_product_portfolio_product_export_cyclonedx_view(self): self.client.login(username=self.super_user.username, password="secret") export_cyclonedx_url = self.product1.get_export_cyclonedx_url() response = self.client.get(export_cyclonedx_url) - self.assertEqual( - "dejacode_nexb_product_product1_with_space_1.0.cdx.json", response.filename - ) + self.assertEqual("dejacode_nexb_product1_with_space_1.0.cdx.json", response.filename) self.assertEqual("application/json", response.headers["Content-Type"]) content = io.BytesIO(b"".join(response.streaming_content)) @@ -3145,9 +3145,7 @@ def test_product_portfolio_product_export_openvex_view(self): self.client.login(username=self.super_user.username, password="secret") export_openvex_url = self.product1.get_export_openvex_url() response = self.client.get(export_openvex_url) - self.assertEqual( - "dejacode_nexb_product_product1_with_space_1.0.openvex.json", response.filename - ) + self.assertEqual("dejacode_nexb_product1_with_space_1.0.openvex.json", response.filename) self.assertEqual("application/json", response.headers["Content-Type"]) content = io.BytesIO(b"".join(response.streaming_content)) @@ -3997,3 +3995,257 @@ def test_product_portfolio_compliance_dashboard_view_export_risk_threshold(self) self.assertEqual(1, product_data["vulnerability_count"]) self.assertEqual(1, product_data["critical_count"]) self.assertEqual(0, product_data["low_count"]) + + def test_product_portfolio_product_license_compliance_export_view_csv(self): + self.client.login(username=self.super_user.username, password="secret") + url = self.product1.get_export_license_compliance_url() + + usage_policy = make_usage_policy( + self.dataspace, + model=License, + compliance_alert="error", + ) + license1 = make_license( + self.dataspace, + key="mit", + short_name="MIT", + spdx_license_key="MIT", + usage_policy=usage_policy, + ) + make_product_package(self.product1, license_expression=license1.key) + + response = self.client.get(url + "?export=csv") + self.assertEqual(200, response.status_code) + self.assertEqual("text/csv", response["Content-Type"]) + self.assertIn("license_compliance_", response["Content-Disposition"]) + self.assertIn(".csv", response["Content-Disposition"]) + # Filename includes the product since the view carries a detail object + self.assertIn("product1_with_space", response["Content-Disposition"]) + + content = response.content.decode() + self.assertIn("SPDX license key,Short name,Key,Packages,Compliance alert", content) + self.assertIn("MIT,MIT,mit,1,error", content) + + def test_product_portfolio_product_license_compliance_export_view_json(self): + self.client.login(username=self.super_user.username, password="secret") + url = self.product1.get_export_license_compliance_url() + + license1 = make_license(self.dataspace, key="mit", short_name="MIT") + license2 = make_license(self.dataspace, key="apache-2.0", short_name="Apache 2.0") + make_product_package(self.product1, license_expression=license1.key) + make_product_package(self.product1, license_expression=f"{license1.key} AND {license2.key}") + + response = self.client.get(url + "?export=json") + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response["Content-Type"]) + self.assertIn("license_compliance_", response["Content-Disposition"]) + + data = json.loads(response.content) + self.assertEqual(2, len(data)) + # Ordered by package_count desc: MIT (2 packages) before Apache (1 package) + self.assertEqual("mit", data[0]["key"]) + self.assertEqual(2, data[0]["package_count"]) + self.assertEqual("apache-2.0", data[1]["key"]) + self.assertEqual(1, data[1]["package_count"]) + + def test_product_portfolio_product_license_compliance_export_view_xlsx(self): + self.client.login(username=self.super_user.username, password="secret") + url = self.product1.get_export_license_compliance_url() + + license1 = make_license(self.dataspace, key="mit", short_name="MIT") + make_product_package(self.product1, license_expression=license1.key) + 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("license_compliance_", response["Content-Disposition"]) + self.assertIn(".xlsx", response["Content-Disposition"]) + + def test_product_portfolio_product_license_compliance_export_view_ods(self): + self.client.login(username=self.super_user.username, password="secret") + url = self.product1.get_export_license_compliance_url() + + license1 = make_license(self.dataspace, key="mit", short_name="MIT") + make_product_package(self.product1, license_expression=license1.key) + + 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("license_compliance_", response["Content-Disposition"]) + self.assertIn(".ods", response["Content-Disposition"]) + + def test_product_portfolio_product_license_compliance_export_view_yaml(self): + self.client.login(username=self.super_user.username, password="secret") + url = self.product1.get_export_license_compliance_url() + + license1 = make_license(self.dataspace, key="mit", short_name="MIT") + make_product_package(self.product1, license_expression=license1.key) + + response = self.client.get(url + "?export=yaml") + self.assertEqual(200, response.status_code) + self.assertEqual("application/x-yaml", response["Content-Type"]) + self.assertIn("license_compliance_", response["Content-Disposition"]) + self.assertIn(".yaml", response["Content-Disposition"]) + + content = response.content.decode() + self.assertIn("mit", content) + self.assertIn("package_count", content) + + def test_product_portfolio_product_license_compliance_export_view_respects_permissions(self): + self.client.login(username=self.basic_user.username, password="secret") + url = self.product1.get_export_license_compliance_url() + + license1 = make_license(self.dataspace, key="mit", short_name="MIT") + make_product_package(self.product1, license_expression=license1.key) + + # Without permission, the detail lookup should 404 + response = self.client.get(url + "?export=json") + self.assertEqual(404, response.status_code) + + # With permission, the export is returned + assign_perm("view_product", self.basic_user, self.product1) + response = self.client.get(url + "?export=json") + self.assertEqual(200, response.status_code) + data = json.loads(response.content) + self.assertEqual("mit", data[0]["key"]) + + def test_product_portfolio_product_security_compliance_export_view_csv(self): + self.client.login(username=self.super_user.username, password="secret") + url = self.product1.get_export_security_compliance_url() + + package = make_package(self.dataspace, filename="isolated") + vulnerability = make_vulnerability( + self.dataspace, + affecting=package, + aliases=["CVE-2024-42005", "GHSA-pv4p-cwwg-4rph", "PYSEC-2024-70"], + ) + make_product_package(self.product1, package=package) + + response = self.client.get(url + "?export=csv") + self.assertEqual(200, response.status_code) + self.assertEqual("text/csv", response["Content-Type"]) + self.assertIn("security_compliance_", response["Content-Disposition"]) + self.assertIn(".csv", response["Content-Disposition"]) + self.assertIn("product1_with_space", response["Content-Disposition"]) + + content = response.content.decode() + expected_header = ( + "Vulnerability ID,Aliases,Summary,Risk level,Risk score," + "Exploitability,Weighted severity,Affected packages,Fixed packages," + "Reference URL" + ) + self.assertIn(expected_header, content) + self.assertIn(vulnerability.vulnerability_id, content) + # Aliases must be flattened to a comma-joined string, not a Python list repr. + self.assertIn('"CVE-2024-42005, GHSA-pv4p-cwwg-4rph, PYSEC-2024-70"', content) + self.assertNotIn("['CVE-2024-42005'", content) + + def test_product_portfolio_product_security_compliance_export_view_json(self): + self.client.login(username=self.super_user.username, password="secret") + url = self.product1.get_export_security_compliance_url() + + package = make_package(self.dataspace, filename="isolated") + vulnerability = make_vulnerability( + self.dataspace, + affecting=package, + aliases=["CVE-2024-42005", "GHSA-pv4p-cwwg-4rph", "PYSEC-2024-70"], + ) + make_product_package(self.product1, package=package) + + response = self.client.get(url + "?export=json") + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response["Content-Type"]) + self.assertIn("security_compliance_", response["Content-Disposition"]) + + data = json.loads(response.content) + self.assertEqual(1, len(data)) + self.assertEqual(vulnerability.vulnerability_id, data[0]["vulnerability_id"]) + # Aliases stay a real list in JSON, not a comma-joined string. + self.assertEqual( + ["CVE-2024-42005", "GHSA-pv4p-cwwg-4rph", "PYSEC-2024-70"], + data[0]["aliases"], + ) + self.assertEqual(1, data[0]["affected_package_count"]) + + def test_product_portfolio_product_security_compliance_export_view_xlsx(self): + self.client.login(username=self.super_user.username, password="secret") + url = self.product1.get_export_security_compliance_url() + + make_vulnerability( + self.dataspace, + affecting=self.package1, + aliases=["CVE-2024-42005", "GHSA-pv4p-cwwg-4rph", "PYSEC-2024-70"], + ) + make_product_package(self.product1, package=self.package1) + + 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("security_compliance_", response["Content-Disposition"]) + self.assertIn(".xlsx", response["Content-Disposition"]) + + def test_product_portfolio_product_security_compliance_export_view_ods(self): + self.client.login(username=self.super_user.username, password="secret") + url = self.product1.get_export_security_compliance_url() + + make_vulnerability( + self.dataspace, + affecting=self.package1, + aliases=["CVE-2024-42005", "GHSA-pv4p-cwwg-4rph", "PYSEC-2024-70"], + ) + make_product_package(self.product1, package=self.package1) + + 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("security_compliance_", response["Content-Disposition"]) + self.assertIn(".ods", response["Content-Disposition"]) + + def test_product_portfolio_product_security_compliance_export_view_yaml(self): + self.client.login(username=self.super_user.username, password="secret") + url = self.product1.get_export_security_compliance_url() + + vulnerability = make_vulnerability( + self.dataspace, + affecting=self.package1, + aliases=["CVE-2024-42005", "GHSA-pv4p-cwwg-4rph", "PYSEC-2024-70"], + ) + make_product_package(self.product1, package=self.package1) + + response = self.client.get(url + "?export=yaml") + self.assertEqual(200, response.status_code) + self.assertEqual("application/x-yaml", response["Content-Type"]) + self.assertIn("security_compliance_", response["Content-Disposition"]) + self.assertIn(".yaml", response["Content-Disposition"]) + + content = response.content.decode() + self.assertIn(vulnerability.vulnerability_id, content) + self.assertIn("vulnerability_id", content) + + def test_product_portfolio_product_security_compliance_export_view_respects_permissions(self): + self.client.login(username=self.basic_user.username, password="secret") + url = self.product1.get_export_security_compliance_url() + + package = make_package(self.dataspace, filename="isolated") + vulnerability = make_vulnerability( + self.dataspace, + affecting=package, + aliases=["CVE-2024-42005", "GHSA-pv4p-cwwg-4rph", "PYSEC-2024-70"], + ) + make_product_package(self.product1, package=package) + + # Without permission, the detail lookup should 404 + response = self.client.get(url + "?export=json") + self.assertEqual(404, response.status_code) + + # With permission, the export is returned + assign_perm("view_product", self.basic_user, self.product1) + response = self.client.get(url + "?export=json") + self.assertEqual(200, response.status_code) + data = json.loads(response.content) + vulnerability_ids = [entry["vulnerability_id"] for entry in data] + self.assertIn(vulnerability.vulnerability_id, vulnerability_ids) diff --git a/product_portfolio/urls.py b/product_portfolio/urls.py index c90603b5..f4115bdd 100644 --- a/product_portfolio/urls.py +++ b/product_portfolio/urls.py @@ -21,7 +21,9 @@ from product_portfolio.views import ProductExportCycloneDXBOMView from product_portfolio.views import ProductExportOpenVEXView from product_portfolio.views import ProductExportSPDXDocumentView +from product_portfolio.views import ProductLicenseComplianceExportView from product_portfolio.views import ProductListView +from product_portfolio.views import ProductSecurityComplianceExportView from product_portfolio.views import ProductSendAboutFilesView from product_portfolio.views import ProductTabCodebaseView from product_portfolio.views import ProductTabComplianceView @@ -115,6 +117,8 @@ def product_path(path_segment, view): *product_path("export_cyclonedx", ProductExportCycloneDXBOMView.as_view()), *product_path("export_csaf", ProductExportCSAFDocumentView.as_view()), *product_path("export_openvex", ProductExportOpenVEXView.as_view()), + *product_path("export_license_compliance", ProductLicenseComplianceExportView.as_view()), + *product_path("export_security_compliance", ProductSecurityComplianceExportView.as_view()), *product_path("attribution", AttributionView.as_view()), *product_path("change", ProductUpdateView.as_view()), *product_path("delete", ProductDeleteView.as_view()), diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 8a1dcf50..cc7d0ece 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -49,7 +49,6 @@ 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 @@ -60,6 +59,7 @@ from django.views.generic import DetailView from django.views.generic import FormView from django.views.generic import TemplateView +from django.views.generic.detail import BaseDetailView import odfdo import saneyaml @@ -80,6 +80,7 @@ from dejacode_toolkit.scancodeio import get_scan_results_as_file_url from dejacode_toolkit.utils import sha1 from dejacode_toolkit.vulnerablecode import VulnerableCode +from dje import outputs from dje.client_data import add_client_data from dje.filters import BooleanChoiceFilter from dje.filters import HasCountFilter @@ -2836,12 +2837,22 @@ 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 build_export_filename(self, extension): + instance = getattr(self, "object", None) + if instance: + dataspace = instance.dataspace + else: + dataspace = self.dataspace + + return outputs.get_export_filename( + dataspace=dataspace, + report_type=self.export_filename, + extension=extension, + instance=instance, + ) def get_content_disposition(self, extension): - return f'attachment; filename="{self.get_export_filename(extension)}"' + return f'attachment; filename="{self.build_export_filename(extension)}"' def export(self, export_format): if export_format == "csv": @@ -2854,12 +2865,21 @@ def export(self, export_format): return self.export_yaml() return self.export_json() + @staticmethod + def normalize_cell_value(value): + """Convert list values to comma-joined strings for spreadsheet cells.""" + if isinstance(value, list): + return ", ".join(str(item) for item in value) + return value + 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()) + writer.writerows( + [self.normalize_cell_value(value) for value in row] for row in self.get_export_rows() + ) return response def export_xlsx(self): @@ -2871,7 +2891,7 @@ def export_xlsx(self): worksheet.append(headers) for row in self.get_export_rows(): - worksheet.append(row) + worksheet.append([self.normalize_cell_value(value) for value in row]) style_xlsx_worksheet(worksheet, headers) @@ -2898,7 +2918,9 @@ def export_ods(self): 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")) + normalized = self.normalize_cell_value(value) + cell_value = str(normalized) if normalized is not None else "" + row.append(odfdo.Cell(cell_value, cell_type="string")) table.append(row) document = odfdo.Document("spreadsheet") @@ -3026,3 +3048,76 @@ def get_context_data(self, **kwargs): ) return context + + +class ProductLicenseComplianceExportView( + LoginRequiredMixin, + ExportComplianceMixin, + BaseProductViewMixin, + DataspaceScopeMixin, + GetDataspacedObjectMixin, + BaseDetailView, +): + """Export license compliance data for a single product.""" + + export_filename = "license_compliance" + export_fields = { + "spdx_license_key": "SPDX license key", + "short_name": "Short name", + "key": "Key", + "package_count": "Packages", + "compliance_alert": "Compliance alert", + } + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def get_export_queryset(self): + productpackages = self.object.productpackages.all() + licenses = License.objects.filter(productpackage__in=productpackages) + return licenses.annotate( + package_count=Count("productpackage"), + compliance_alert=F("usage_policy__compliance_alert"), + ).order_by("-package_count") + + +class ProductSecurityComplianceExportView( + LoginRequiredMixin, + ExportComplianceMixin, + BaseProductViewMixin, + DataspaceScopeMixin, + GetDataspacedObjectMixin, + BaseDetailView, +): + """Export security compliance data for a single product.""" + + export_filename = "security_compliance" + export_fields = { + "vulnerability_id": "Vulnerability ID", + "aliases": "Aliases", + "summary": "Summary", + "risk_level": "Risk level", + "risk_score": "Risk score", + "exploitability": "Exploitability", + "weighted_severity": "Weighted severity", + "affected_package_count": "Affected packages", + "fixed_packages_count": "Fixed packages", + "resource_url": "Reference URL", + } + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return super().get(request, *args, **kwargs) + + def get_export_queryset(self): + product = self.object + vulnerabilities = product.get_vulnerability_qs(risk_threshold=None) + package_ids = product.productpackages.values_list("package_id", flat=True) + return vulnerabilities.annotate( + affected_package_count=Count( + "affected_packages", + filter=Q(affected_packages__in=package_ids), + distinct=True, + ), + ).order_by_risk()