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()