From 6657996169c1f793a08b95e9736de2590c6b7a31 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 8 Apr 2026 15:27:32 +0400 Subject: [PATCH 01/14] Add compliance control center dashboard Signed-off-by: tdruez --- dejacode/static/css/dejacode_bootstrap.css | 5 +- dje/templates/includes/navbar_header.html | 1 + .../includes/navbar_header_tools_menu.html | 6 + .../compliance_dashboard.html | 149 ++++++++++++++++++ product_portfolio/urls.py | 6 + product_portfolio/views.py | 110 +++++++++++++ 6 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 product_portfolio/templates/product_portfolio/compliance_dashboard.html diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index 91ba8de9..1c76c4ab 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -94,11 +94,14 @@ table.text-break thead { } .bg-warning-orange { background-color: var(--bs-orange); - color: #000; + color: #fff; } .text-warning-orange { color: var(--bs-orange) !important; } +.bg-warning-orange-subtle { + background-color: rgba(253, 126, 20, 0.15); +} .spinner-border-md { --bs-spinner-width: 1.5rem; --bs-spinner-height: 1.5rem; diff --git a/dje/templates/includes/navbar_header.html b/dje/templates/includes/navbar_header.html index af0dc424..405aeedd 100644 --- a/dje/templates/includes/navbar_header.html +++ b/dje/templates/includes/navbar_header.html @@ -6,6 +6,7 @@ {% url 'license_library:license_list' as license_list_url %} {% url 'organization:owner_list' as owner_list_url %} {% url 'global_search' as global_search_url %} +{% url 'product_portfolio:compliance_dashboard' as compliance_dashboard_url %} {% url 'reporting:report_list' as report_list_url %} {% url 'workflow:request_list' as request_list_url %} {% url 'component_catalog:scan_list' as scan_list_url %} diff --git a/dje/templates/includes/navbar_header_tools_menu.html b/dje/templates/includes/navbar_header_tools_menu.html index 6bd6440a..ced30d5c 100644 --- a/dje/templates/includes/navbar_header_tools_menu.html +++ b/dje/templates/includes/navbar_header_tools_menu.html @@ -5,6 +5,12 @@ Tools
-
+
-
{% trans "Products" %}
-
- {{ products_ok }} / {{ total_products }} +
{% trans "Products with issues" %}
+
+ {{ products_with_issues }}
- {% trans "active products with no issues" %} + {% if products_with_issues %} + {% trans "of" %} {{ total_products }} {% trans "active products" %} + {% else %} + {% trans "All" %} {{ total_products }} {% trans "products are compliant" %} + {% endif %}
@@ -38,30 +42,30 @@

{% trans "Compliance Control Center" %}

-
{% trans "Security compliance" %}
-
- {{ security_compliance_pct }}% +
{% trans "Security issues" %}
+
+ {{ products_with_critical_or_high }}
- {% if products_security_ok == total_products %} - {% trans "No critical or high vulnerabilities" %} + {% if products_with_critical_or_high %} + {% trans "products with critical/high vulnerabilities" %} {% else %} - {{ products_security_ok }} {% trans "of" %} {{ total_products }} {% trans "products without critical/high" %} + {% trans "No critical or high vulnerabilities" %} {% endif %}
-
{% trans "Vulnerabilities" %}
-
- {{ products_with_vulnerabilities }} +
{% trans "Total vulnerabilities" %}
+
+ {{ total_vulnerabilities|intcomma }}
- {% if products_with_critical %} - {{ products_with_critical }} {% trans "with critical vulnerabilities" %} - {% elif products_with_vulnerabilities %} - {% trans "products with vulnerabilities" %} + {% if total_critical %} + {{ total_critical }} {% trans "critical" %}{% if total_high %}, {{ total_high }} {% trans "high" %}{% endif %} + {% elif total_vulnerabilities %} + {% trans "across all products" %} {% else %} {% trans "No known vulnerabilities" %} {% endif %} @@ -70,7 +74,7 @@

{% trans "Compliance Control Center" %}

-
+
@@ -131,7 +135,7 @@

{% trans "Compliance Control Center" %}

{{ product.high_count }} {% trans "high" %} {% endif %} {% if not product.critical_count and not product.high_count %} - {{ product.vulnerability_count }} + {{ product.vulnerability_count }} {% endif %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index e01efc50..a42cf032 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2884,42 +2884,43 @@ def get_queryset(self): ) def get_context_data(self, **kwargs): + from django.db.models import Sum + context = super().get_context_data(**kwargs) products = self.object_list total_products = products.count() - products_with_license_issues = products.filter( - Q(license_error_count__gt=0) | Q(license_warning_count__gt=0) + products_with_issues = products.filter( + Q(license_error_count__gt=0) + | Q(license_warning_count__gt=0) + | Q(critical_count__gt=0) + | Q(high_count__gt=0) ).count() - products_with_vulnerabilities = products.filter(vulnerability_count__gt=0).count() - - products_with_critical = products.filter(critical_count__gt=0).count() - - products_ok = products.filter( - license_error_count=0, - license_warning_count=0, - vulnerability_count=0, + products_with_license_issues = products.filter( + Q(license_error_count__gt=0) | Q(license_warning_count__gt=0) ).count() - products_security_ok = products.filter( - Q(vulnerability_count=0) | Q(critical_count=0, high_count=0) + products_with_critical_or_high = products.filter( + Q(critical_count__gt=0) | Q(high_count__gt=0) ).count() - security_compliance_pct = ( - round((products_security_ok / total_products) * 100) if total_products else 100 + totals = products.aggregate( + total_vulnerabilities=Sum("vulnerability_count"), + total_critical=Sum("critical_count"), + total_high=Sum("high_count"), ) context.update( { "total_products": total_products, + "products_with_issues": products_with_issues, "products_with_license_issues": products_with_license_issues, - "products_with_vulnerabilities": products_with_vulnerabilities, - "products_with_critical": products_with_critical, - "products_ok": products_ok, - "products_security_ok": products_security_ok, - "security_compliance_pct": security_compliance_pct, + "products_with_critical_or_high": products_with_critical_or_high, + "total_vulnerabilities": totals["total_vulnerabilities"] or 0, + "total_critical": totals["total_critical"] or 0, + "total_high": totals["total_high"] or 0, } ) From 5602234ada660170f2db1b78abc988202880ec39 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 8 Apr 2026 17:51:05 +0400 Subject: [PATCH 03/14] Refine UI and CSS Signed-off-by: tdruez --- dejacode/static/css/dejacode_bootstrap.css | 3 +-- .../product_portfolio/compliance_dashboard.html | 14 +++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index 1c76c4ab..f49cb19d 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -801,8 +801,7 @@ pre.log { .nav-pills .show>.nav-link { background-color: var(--bs-djc-blue-bg); } -.card, -.table { +.card { box-shadow: rgba(0, 0, 0, 0.05) 0 0.0625rem 0.125rem; } .table-md th, diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index ffee61a8..c34841fa 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -78,22 +78,22 @@

{% trans "Compliance Control Center" %}

- - - - - + + + + + {% for product in object_list %} {% with product_url=product.get_absolute_url %} - + {% for product in object_list %} + {% with product_url=product.get_absolute_url %} + + + + + + + + {% endwith %} + {% empty %} + + + + {% endfor %} + +
{% trans "Product" %}{% trans "Packages" %}{% trans "License compliance" %}{% trans "Security compliance" %}{% trans "Vulnerabilities" %}{% trans "Product" %}{% trans "Packages" %}{% trans "License compliance" %}{% trans "Security compliance" %}{% trans "Vulnerabilities" %}
+ {{ product }} - + {{ product.package_count|intcomma }} From 7ab452e232aba6dd12de8e83669c138feb29d4a2 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 8 Apr 2026 18:09:44 +0400 Subject: [PATCH 04/14] Improve card values Signed-off-by: tdruez --- .../compliance_dashboard.html | 22 +++++++----- product_portfolio/views.py | 36 ++++++++++++++----- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index c34841fa..98c156dd 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -63,7 +63,7 @@

{% trans "Compliance Control Center" %}

{% if total_critical %} - {{ total_critical }} {% trans "critical" %}{% if total_high %}, {{ total_high }} {% trans "high" %}{% endif %} + {{ total_critical }} {% trans "critical" %}{% if total_high %}, {{ total_high }} {% trans "high" %}{% endif %}{% if total_medium %}, {{ total_medium }} {% trans "medium" %}{% endif %}{% if total_low %}, {{ total_low }} {% trans "low" %}{% endif %} {% elif total_vulnerabilities %} {% trans "across all products" %} {% else %} @@ -78,11 +78,11 @@

{% trans "Compliance Control Center" %}

- - - - - + + + + + @@ -134,8 +134,14 @@

{% trans "Compliance Control Center" %}

{% if product.high_count %} {{ product.high_count }} {% trans "high" %} {% endif %} - {% if not product.critical_count and not product.high_count %} - {{ product.vulnerability_count }} + {% if product.medium_count %} + {{ product.medium_count }} {% trans "medium" %} + {% endif %} + {% if product.low_count %} + {{ product.low_count }} {% trans "low" %} + {% endif %} + {% if not product.vulnerability_count %} + {% trans "None" %} {% endif %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index a42cf032..2c637069 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2845,6 +2845,14 @@ def get_queryset(self): distinct=True, ), max_risk_score=Max("productpackages__package__affected_by_vulnerabilities__risk_score"), + max_risk_level=Case( + When(max_risk_score__gte=8.0, then=Value("critical")), + When(max_risk_score__gte=6.0, then=Value("high")), + When(max_risk_score__gte=3.0, then=Value("medium")), + When(max_risk_score__gte=0.1, then=Value("low")), + default=Value(""), + output_field=CharField(max_length=8), + ), critical_count=Count( "productpackages__package__affected_by_vulnerabilities", filter=Q( @@ -2857,6 +2865,20 @@ def get_queryset(self): filter=Q(productpackages__package__affected_by_vulnerabilities__risk_level="high"), distinct=True, ), + medium_count=Count( + "productpackages__package__affected_by_vulnerabilities", + filter=Q( + productpackages__package__affected_by_vulnerabilities__risk_level="medium" + ), + distinct=True, + ), + low_count=Count( + "productpackages__package__affected_by_vulnerabilities", + filter=Q( + productpackages__package__affected_by_vulnerabilities__risk_level="low" + ), + distinct=True, + ), license_warning_count=Count( "productpackages__licenses", filter=Q(productpackages__licenses__usage_policy__compliance_alert="warning"), @@ -2867,20 +2889,12 @@ def get_queryset(self): filter=Q(productpackages__licenses__usage_policy__compliance_alert="error"), distinct=True, ), - max_risk_level=Case( - When(max_risk_score__gte=8.0, then=Value("critical")), - When(max_risk_score__gte=6.0, then=Value("high")), - When(max_risk_score__gte=3.0, then=Value("medium")), - When(max_risk_score__gte=0.1, then=Value("low")), - default=Value(""), - output_field=CharField(max_length=8), - ), ).order_by( F("max_risk_score").desc(nulls_last=True), "-license_error_count", "-license_warning_count", "name", - "version", + "-version", ) def get_context_data(self, **kwargs): @@ -2910,6 +2924,8 @@ def get_context_data(self, **kwargs): total_vulnerabilities=Sum("vulnerability_count"), total_critical=Sum("critical_count"), total_high=Sum("high_count"), + total_medium=Sum("medium_count"), + total_low=Sum("low_count"), ) context.update( @@ -2921,6 +2937,8 @@ def get_context_data(self, **kwargs): "total_vulnerabilities": totals["total_vulnerabilities"] or 0, "total_critical": totals["total_critical"] or 0, "total_high": totals["total_high"] or 0, + "total_medium": totals["total_medium"] or 0, + "total_low": totals["total_low"] or 0, } ) From 00295f59c8139ef4f4f82e10e8d07c3f5ebfb53b Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 8 Apr 2026 18:23:15 +0400 Subject: [PATCH 05/14] Fix code format Signed-off-by: tdruez --- product_portfolio/views.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 2c637069..215417d9 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2874,9 +2874,7 @@ def get_queryset(self): ), low_count=Count( "productpackages__package__affected_by_vulnerabilities", - filter=Q( - productpackages__package__affected_by_vulnerabilities__risk_level="low" - ), + filter=Q(productpackages__package__affected_by_vulnerabilities__risk_level="low"), distinct=True, ), license_warning_count=Count( From 646d9c06cb2a2f2be3a631db15b17b837c8ecf28 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Apr 2026 15:39:16 +0400 Subject: [PATCH 06/14] refine imports Signed-off-by: tdruez --- product_portfolio/views.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 215417d9..f17bcdf8 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -24,13 +24,19 @@ from django.core.exceptions import ValidationError from django.core.paginator import Paginator from django.db import transaction +from django.db.models import Case +from django.db.models import CharField from django.db.models import Count from django.db.models import Exists from django.db.models import F +from django.db.models import Max from django.db.models import OuterRef from django.db.models import Prefetch from django.db.models import Q from django.db.models import Subquery +from django.db.models import Sum +from django.db.models import Value +from django.db.models import When from django.db.models.functions import Lower from django.forms import modelformset_factory from django.http import FileResponse @@ -2825,12 +2831,6 @@ class ComplianceDashboardView(LoginRequiredMixin, DataspacedFilterView): paginate_by = 50 def get_queryset(self): - from django.db.models import Case - from django.db.models import CharField - from django.db.models import Max - from django.db.models import Value - from django.db.models import When - base_qs = Product.objects.get_queryset( user=self.request.user, perms="view_product", @@ -2896,8 +2896,6 @@ def get_queryset(self): ) def get_context_data(self, **kwargs): - from django.db.models import Sum - context = super().get_context_data(**kwargs) products = self.object_list From c44c3bbb95a5f6d1740cb6ec0e68c91634b9c847 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Apr 2026 15:50:41 +0400 Subject: [PATCH 07/14] add pagination Signed-off-by: tdruez --- .../compliance_dashboard.html | 284 +++++++++--------- 1 file changed, 145 insertions(+), 139 deletions(-) diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index 98c156dd..4c5a680a 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -4,156 +4,162 @@ {% block page_title %}{% trans "Compliance Control Center" %}{% endblock %} {% 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 "Products with issues" %}
-
- {{ products_with_issues }} -
-
- {% if products_with_issues %} - {% trans "of" %} {{ total_products }} {% trans "active products" %} - {% else %} - {% trans "All" %} {{ total_products }} {% trans "products are compliant" %} - {% endif %} +
+
+
+
{% trans "Products with issues" %}
+
+ {{ products_with_issues }} +
+
+ {% if products_with_issues %} + {% trans "of" %} {{ total_products }} {% trans "active products" %} + {% else %} + {% trans "All" %} {{ total_products }} {% trans "products are compliant" %} + {% endif %} +
-
-
-
-
{% trans "License issues" %}
-
- {{ products_with_license_issues }} -
-
- {% if products_with_license_issues %} - {% trans "products with policy violations" %} - {% else %} - {% trans "All products within policy" %} - {% endif %} +
+
+
{% trans "License issues" %}
+
+ {{ products_with_license_issues }} +
+
+ {% if products_with_license_issues %} + {% trans "products with policy violations" %} + {% else %} + {% trans "All products within policy" %} + {% endif %} +
-
-
-
-
{% trans "Security issues" %}
-
- {{ products_with_critical_or_high }} -
-
- {% if products_with_critical_or_high %} - {% trans "products with critical/high vulnerabilities" %} - {% else %} - {% trans "No critical or high vulnerabilities" %} - {% endif %} +
+
+
{% trans "Security issues" %}
+
+ {{ products_with_critical_or_high }} +
+
+ {% if products_with_critical_or_high %} + {% trans "products with critical/high vulnerabilities" %} + {% else %} + {% trans "No critical or high vulnerabilities" %} + {% endif %} +
-
-
-
-
{% trans "Total vulnerabilities" %}
-
- {{ total_vulnerabilities|intcomma }} -
-
- {% if total_critical %} - {{ total_critical }} {% trans "critical" %}{% if total_high %}, {{ total_high }} {% trans "high" %}{% endif %}{% if total_medium %}, {{ total_medium }} {% trans "medium" %}{% endif %}{% if total_low %}, {{ total_low }} {% trans "low" %}{% endif %} - {% elif total_vulnerabilities %} - {% trans "across all products" %} - {% else %} - {% trans "No known vulnerabilities" %} - {% endif %} +
+
+
{% trans "Total vulnerabilities" %}
+
+ {{ total_vulnerabilities|intcomma }} +
+
+ {% if total_critical %} + {{ total_critical }} {% trans "critical" %}{% if total_high %}, {{ total_high }} {% trans "high" %}{% endif %}{% if total_medium %}, {{ total_medium }} {% trans "medium" %}{% endif %}{% if total_low %}, {{ total_low }} {% trans "low" %}{% endif %} + {% elif total_vulnerabilities %} + {% trans "across all products" %} + {% else %} + {% trans "No known vulnerabilities" %} + {% endif %} +
-
-
-
{% trans "Product" %}{% trans "Packages" %}{% trans "License compliance" %}{% trans "Security compliance" %}{% trans "Vulnerabilities" %}{% trans "Product" %}{% trans "Packages" %}{% trans "License compliance" %}{% trans "Security compliance" %}{% trans "Vulnerabilities" %}
- - - - - - - - - - - {% for product in object_list %} - {% with product_url=product.get_absolute_url %} - - - - - - - - {% endwith %} - {% empty %} +
+
{% trans "Product" %}{% trans "Packages" %}{% trans "License compliance" %}{% trans "Security compliance" %}{% trans "Vulnerabilities" %}
- - {{ product }} - - - - {{ product.package_count|intcomma }} - - - {% if product.license_error_count %} - - {{ product.license_error_count }} {% trans "error" %}{{ product.license_error_count|pluralize }} - - {% endif %} - {% if product.license_warning_count %} - - {{ product.license_warning_count }} {% trans "warning" %}{{ product.license_warning_count|pluralize }} - - {% endif %} - {% if not product.license_error_count and not product.license_warning_count %} - {% trans "OK" %} - {% endif %} - - {% if product.max_risk_level == "critical" %} - {% trans "Critical" %} - {% elif product.max_risk_level == "high" %} - {% trans "High" %} - {% elif product.max_risk_level == "medium" %} - {% trans "Medium" %} - {% elif product.max_risk_level == "low" %} - {% trans "Low" %} - {% else %} - {% trans "OK" %} - {% endif %} - - {% if product.critical_count %} - {{ product.critical_count }} {% trans "critical" %} - {% endif %} - {% if product.high_count %} - {{ product.high_count }} {% trans "high" %} - {% endif %} - {% if product.medium_count %} - {{ product.medium_count }} {% trans "medium" %} - {% endif %} - {% if product.low_count %} - {{ product.low_count }} {% trans "low" %} - {% endif %} - {% if not product.vulnerability_count %} - {% trans "None" %} - {% endif %} -
+ - + + + + + - {% endfor %} - -
- {% trans "No active products" %} - {% trans "Product" %}{% trans "Packages" %}{% trans "License compliance" %}{% trans "Security compliance" %}{% trans "Vulnerabilities" %}
-
+ +
+ + {{ product }} + + + + {{ product.package_count|intcomma }} + + + {% if product.license_error_count %} + + {{ product.license_error_count }} {% trans "error" %}{{ product.license_error_count|pluralize }} + + {% endif %} + {% if product.license_warning_count %} + + {{ product.license_warning_count }} {% trans "warning" %}{{ product.license_warning_count|pluralize }} + + {% endif %} + {% if not product.license_error_count and not product.license_warning_count %} + {% trans "OK" %} + {% endif %} + + {% if product.max_risk_level == "critical" %} + {% trans "Critical" %} + {% elif product.max_risk_level == "high" %} + {% trans "High" %} + {% elif product.max_risk_level == "medium" %} + {% trans "Medium" %} + {% elif product.max_risk_level == "low" %} + {% trans "Low" %} + {% else %} + {% trans "OK" %} + {% endif %} + + {% if product.critical_count %} + {{ product.critical_count }} {% trans "critical" %} + {% endif %} + {% if product.high_count %} + {{ product.high_count }} {% trans "high" %} + {% endif %} + {% if product.medium_count %} + {{ product.medium_count }} {% trans "medium" %} + {% endif %} + {% if product.low_count %} + {{ product.low_count }} {% trans "low" %} + {% endif %} + {% if not product.vulnerability_count %} + {% trans "None" %} + {% endif %} +
+ {% trans "No active products" %} +
+
+ + {% if is_paginated %} +
+ {% include 'pagination/object_list_pagination.html' %} +
+ {% endif %} {% endblock %} \ No newline at end of file From 2e698bd90739aad371a90dcd0f8e4f6e4f14293b Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Apr 2026 15:57:12 +0400 Subject: [PATCH 08/14] add unit tests Signed-off-by: tdruez --- product_portfolio/tests/test_views.py | 142 ++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 878775a7..8e67767c 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -3565,3 +3565,145 @@ def test_product_portfolio_tab_compliance_view_security_with_risk_threshold(self self.assertEqual(1, response.context["above_threshold_count"]) self.assertEqual("high", response.context["risk_threshold"]) self.assertEqual(6.0, response.context["risk_threshold_number"]) + + def test_product_portfolio_compliance_dashboard_view_access(self): + url = reverse("product_portfolio:compliance_dashboard") + response = self.client.get(url) + self.assertRedirects(response, f"/login/?next={url}") + + self.client.login(username=self.basic_user.username, password="secret") + response = self.client.get(url) + self.assertEqual(200, response.status_code) + self.assertContains(response, "Compliance Control Center") + + def test_product_portfolio_compliance_dashboard_view_empty(self): + self.client.login(username=self.basic_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + response = self.client.get(url) + self.assertEqual(0, response.context["total_products"]) + self.assertEqual(0, response.context["products_with_issues"]) + self.assertEqual(0, response.context["total_vulnerabilities"]) + self.assertContains(response, "No active products") + + def test_product_portfolio_compliance_dashboard_view_product_visibility(self): + self.client.login(username=self.basic_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + response = self.client.get(url) + self.assertEqual(0, response.context["total_products"]) + + assign_perm("view_product", self.basic_user, self.product1) + response = self.client.get(url) + self.assertEqual(1, response.context["total_products"]) + + def test_product_portfolio_compliance_dashboard_view_excludes_inactive(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + response = self.client.get(url) + self.assertIn(self.product1, response.context["object_list"]) + + self.product1.is_active = False + self.product1.save() + response = self.client.get(url) + self.assertNotIn(self.product1, response.context["object_list"]) + + def test_product_portfolio_compliance_dashboard_view_excludes_locked(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + response = self.client.get(url) + self.assertIn(self.product1, response.context["object_list"]) + + locked_status = make_product_status(self.dataspace, is_locked=True) + self.product1.update(configuration_status=locked_status) + response = self.client.get(url) + self.assertNotIn(self.product1, response.context["object_list"]) + + def test_product_portfolio_compliance_dashboard_view_license_issues(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + owner1 = Owner.objects.create(name="Owner1", dataspace=self.dataspace) + license_policy = UsagePolicy.objects.create( + label="LicensePolicy", + icon="icon", + content_type=ContentType.objects.get_for_model(License), + compliance_alert=UsagePolicy.Compliance.ERROR, + dataspace=self.dataspace, + ) + license1 = License.objects.create( + key="l1", + name="L1", + short_name="L1", + owner=owner1, + usage_policy=license_policy, + dataspace=self.dataspace, + ) + package1 = make_package(self.dataspace) + ProductPackage.objects.create( + product=self.product1, + package=package1, + dataspace=self.dataspace, + license_expression=license1.key, + ) + + response = self.client.get(url) + self.assertEqual(1, response.context["products_with_license_issues"]) + self.assertEqual(1, response.context["products_with_issues"]) + + def test_product_portfolio_compliance_dashboard_view_vulnerability_counts(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=6.5) + + product1 = make_product(self.dataspace, inventory=[p1, p2]) + + response = self.client.get(url) + self.assertEqual(1, response.context["products_with_critical_or_high"]) + self.assertEqual(2, response.context["total_vulnerabilities"]) + self.assertEqual(1, response.context["total_critical"]) + self.assertEqual(1, response.context["total_high"]) + + def test_product_portfolio_compliance_dashboard_view_ordering(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=3.0) + + product_critical = make_product(self.dataspace, inventory=[p1]) + product_medium = make_product(self.dataspace, inventory=[p2]) + + response = self.client.get(url) + products = list(response.context["object_list"]) + critical_index = products.index(product_critical) + medium_index = products.index(product_medium) + self.assertLess(critical_index, medium_index) + + def test_product_portfolio_compliance_dashboard_view_pagination(self): + self.client.login(username=self.super_user.username, password="secret") + url = reverse("product_portfolio:compliance_dashboard") + + response = self.client.get(url) + self.assertFalse(response.context["is_paginated"]) + + for index in range(55): + Product.objects.create( + name=f"PaginationProduct{index}", + version="1.0", + dataspace=self.dataspace, + ) + + response = self.client.get(url) + self.assertTrue(response.context["is_paginated"]) + self.assertEqual(50, len(response.context["object_list"])) + + response = self.client.get(url + "?page=2") + self.assertEqual(5, len(response.context["object_list"])) From 49261a8913f3939793051f72be1fafd1f4b4b62f Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Apr 2026 16:22:16 +0400 Subject: [PATCH 09/14] refactor annotation to a new ProductQuerySet Signed-off-by: tdruez --- product_portfolio/models.py | 65 ++++++++++++++++++++++- product_portfolio/tests/test_views.py | 2 +- product_portfolio/views.py | 76 ++++++++------------------- 3 files changed, 87 insertions(+), 56 deletions(-) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 9cb6bdca..d540c6c6 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -15,11 +15,14 @@ from django.core.validators import MinValueValidator from django.db import models from django.db.models import Case +from django.db.models import Count +from django.db.models import DecimalField from django.db.models import F from django.db.models import FloatField +from django.db.models import OuterRef +from django.db.models import Q from django.db.models import Value from django.db.models import When -from django.db.models.expressions import OuterRef from django.db.models.functions import Coalesce from django.utils.functional import cached_property from django.utils.html import format_html @@ -41,6 +44,7 @@ from dje.fields import LastModifiedByField from dje.models import DataspacedManager from dje.models import DataspacedModel +from dje.models import DataspacedQuerySet from dje.models import History from dje.models import HistoryFieldsMixin from dje.models import ProductSecuredQuerySet @@ -131,6 +135,63 @@ class Meta(BaseStatusMixin.Meta): verbose_name_plural = _("product status") +class ProductQuerySet(DataspacedQuerySet): + def with_risk_threshold(self): + return self.annotate( + effective_threshold=Coalesce( + "vulnerabilities_risk_threshold", + "dataspace__configuration__vulnerabilities_risk_threshold", + output_field=DecimalField(), + ), + ) + + def with_vulnerability_counts(self): + return self.annotate( + vulnerability_count=Count( + "productpackages__package__affected_by_vulnerabilities", + distinct=True, + ), + critical_count=Count( + "productpackages__package__affected_by_vulnerabilities", + filter=Q( + productpackages__package__affected_by_vulnerabilities__risk_level="critical" + ), + distinct=True, + ), + high_count=Count( + "productpackages__package__affected_by_vulnerabilities", + filter=Q(productpackages__package__affected_by_vulnerabilities__risk_level="high"), + distinct=True, + ), + medium_count=Count( + "productpackages__package__affected_by_vulnerabilities", + filter=Q( + productpackages__package__affected_by_vulnerabilities__risk_level="medium" + ), + distinct=True, + ), + low_count=Count( + "productpackages__package__affected_by_vulnerabilities", + filter=Q(productpackages__package__affected_by_vulnerabilities__risk_level="low"), + distinct=True, + ), + ) + + def with_license_compliance_counts(self): + return self.annotate( + license_warning_count=Count( + "productpackages__licenses", + filter=Q(productpackages__licenses__usage_policy__compliance_alert="warning"), + distinct=True, + ), + license_error_count=Count( + "productpackages__licenses", + filter=Q(productpackages__licenses__usage_policy__compliance_alert="error"), + distinct=True, + ), + ) + + class ProductSecuredManager(DataspacedManager): """ WARNING: The security is always enabled on this manager. @@ -293,7 +354,7 @@ class Product( help_text=_("Vulnerabilities directly affecting this product."), ) - objects = ProductSecuredManager() + objects = ProductSecuredManager.from_queryset(ProductQuerySet)() # WARNING: Bypass the security system implemented in ProductSecuredManager. # This is to be used only in a few cases where the User scoping is not appropriated. diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index 8e67767c..a12be50c 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -3661,7 +3661,7 @@ def test_product_portfolio_compliance_dashboard_view_vulnerability_counts(self): make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) make_vulnerability(self.dataspace, affecting=[p2], risk_score=6.5) - product1 = make_product(self.dataspace, inventory=[p1, p2]) + make_product(self.dataspace, inventory=[p1, p2]) response = self.client.get(url) self.assertEqual(1, response.context["products_with_critical_or_high"]) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index f17bcdf8..06f1a56d 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2838,61 +2838,31 @@ def get_queryset(self): exclude_locked=True, ) - return base_qs.annotate( - package_count=Count("productpackages", distinct=True), - vulnerability_count=Count( - "productpackages__package__affected_by_vulnerabilities", - distinct=True, - ), - max_risk_score=Max("productpackages__package__affected_by_vulnerabilities__risk_score"), - max_risk_level=Case( - When(max_risk_score__gte=8.0, then=Value("critical")), - When(max_risk_score__gte=6.0, then=Value("high")), - When(max_risk_score__gte=3.0, then=Value("medium")), - When(max_risk_score__gte=0.1, then=Value("low")), - default=Value(""), - output_field=CharField(max_length=8), - ), - critical_count=Count( - "productpackages__package__affected_by_vulnerabilities", - filter=Q( - productpackages__package__affected_by_vulnerabilities__risk_level="critical" + return ( + base_qs.with_risk_threshold() + .with_vulnerability_counts() + .with_license_compliance_counts() + .annotate( + package_count=Count("productpackages", distinct=True), + max_risk_score=Max( + "productpackages__package__affected_by_vulnerabilities__risk_score" ), - distinct=True, - ), - high_count=Count( - "productpackages__package__affected_by_vulnerabilities", - filter=Q(productpackages__package__affected_by_vulnerabilities__risk_level="high"), - distinct=True, - ), - medium_count=Count( - "productpackages__package__affected_by_vulnerabilities", - filter=Q( - productpackages__package__affected_by_vulnerabilities__risk_level="medium" + max_risk_level=Case( + When(max_risk_score__gte=8.0, then=Value("critical")), + When(max_risk_score__gte=6.0, then=Value("high")), + When(max_risk_score__gte=3.0, then=Value("medium")), + When(max_risk_score__gte=0.1, then=Value("low")), + default=Value(""), + output_field=CharField(max_length=8), ), - distinct=True, - ), - low_count=Count( - "productpackages__package__affected_by_vulnerabilities", - filter=Q(productpackages__package__affected_by_vulnerabilities__risk_level="low"), - distinct=True, - ), - license_warning_count=Count( - "productpackages__licenses", - filter=Q(productpackages__licenses__usage_policy__compliance_alert="warning"), - distinct=True, - ), - license_error_count=Count( - "productpackages__licenses", - filter=Q(productpackages__licenses__usage_policy__compliance_alert="error"), - distinct=True, - ), - ).order_by( - F("max_risk_score").desc(nulls_last=True), - "-license_error_count", - "-license_warning_count", - "name", - "-version", + ) + .order_by( + F("max_risk_score").desc(nulls_last=True), + "-license_error_count", + "-license_warning_count", + "name", + "-version", + ) ) def get_context_data(self, **kwargs): From dae54b013e888a36ed6f015a71d1304e9885fa84 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Apr 2026 16:23:56 +0400 Subject: [PATCH 10/14] move max_risk_score to QS Signed-off-by: tdruez --- product_portfolio/models.py | 2 ++ product_portfolio/views.py | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index d540c6c6..43d92a5c 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -19,6 +19,7 @@ from django.db.models import DecimalField from django.db.models import F from django.db.models import FloatField +from django.db.models import Max from django.db.models import OuterRef from django.db.models import Q from django.db.models import Value @@ -175,6 +176,7 @@ def with_vulnerability_counts(self): filter=Q(productpackages__package__affected_by_vulnerabilities__risk_level="low"), distinct=True, ), + max_risk_score=Max("productpackages__package__affected_by_vulnerabilities__risk_score"), ) def with_license_compliance_counts(self): diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 06f1a56d..c8a64b74 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -29,7 +29,6 @@ from django.db.models import Count from django.db.models import Exists from django.db.models import F -from django.db.models import Max from django.db.models import OuterRef from django.db.models import Prefetch from django.db.models import Q @@ -2844,9 +2843,6 @@ def get_queryset(self): .with_license_compliance_counts() .annotate( package_count=Count("productpackages", distinct=True), - max_risk_score=Max( - "productpackages__package__affected_by_vulnerabilities__risk_score" - ), max_risk_level=Case( When(max_risk_score__gte=8.0, then=Value("critical")), When(max_risk_score__gte=6.0, then=Value("high")), From 93c69ff380b5132ac74da5fb68cbbc59518c17b6 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Apr 2026 16:29:13 +0400 Subject: [PATCH 11/14] display risk_threshold in table Signed-off-by: tdruez --- product_portfolio/models.py | 2 +- .../templates/product_portfolio/compliance_dashboard.html | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index 43d92a5c..ad71f875 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -139,7 +139,7 @@ class Meta(BaseStatusMixin.Meta): class ProductQuerySet(DataspacedQuerySet): def with_risk_threshold(self): return self.annotate( - effective_threshold=Coalesce( + risk_threshold=Coalesce( "vulnerabilities_risk_threshold", "dataspace__configuration__vulnerabilities_risk_threshold", output_field=DecimalField(), diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index 4c5a680a..6eb1eb4d 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -115,6 +115,11 @@

{% trans "Compliance Control Center" %}

{% endif %} + {% if product.risk_threshold %} + + ≥ {{ product.risk_threshold }} + + {% endif %} {% if product.max_risk_level == "critical" %} {% trans "Critical" %} {% elif product.max_risk_level == "high" %} From f6dd479e1cd1060fe8aa5886e34e34c373e30fca Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Apr 2026 16:40:46 +0400 Subject: [PATCH 12/14] rework the with_vulnerability_counts to include threshold Signed-off-by: tdruez --- product_portfolio/models.py | 54 +++++++++---------- .../compliance_dashboard.html | 10 ++-- product_portfolio/views.py | 2 +- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/product_portfolio/models.py b/product_portfolio/models.py index ad71f875..00f4506a 100644 --- a/product_portfolio/models.py +++ b/product_portfolio/models.py @@ -147,36 +147,36 @@ def with_risk_threshold(self): ) def with_vulnerability_counts(self): - return self.annotate( - vulnerability_count=Count( - "productpackages__package__affected_by_vulnerabilities", - distinct=True, - ), - critical_count=Count( - "productpackages__package__affected_by_vulnerabilities", - filter=Q( - productpackages__package__affected_by_vulnerabilities__risk_level="critical" - ), - distinct=True, - ), - high_count=Count( - "productpackages__package__affected_by_vulnerabilities", - filter=Q(productpackages__package__affected_by_vulnerabilities__risk_level="high"), - distinct=True, - ), - medium_count=Count( - "productpackages__package__affected_by_vulnerabilities", - filter=Q( - productpackages__package__affected_by_vulnerabilities__risk_level="medium" - ), - distinct=True, - ), - low_count=Count( + threshold_filter = Q( + productpackages__package__affected_by_vulnerabilities__risk_score__gte=F( + "risk_threshold" + ) + ) + no_threshold = Q(risk_threshold__isnull=True) + + def count_vulns(extra_filter=None): + combined = no_threshold | threshold_filter + if extra_filter: + combined = combined & extra_filter + return Count( "productpackages__package__affected_by_vulnerabilities", - filter=Q(productpackages__package__affected_by_vulnerabilities__risk_level="low"), + filter=combined, distinct=True, + ) + + def risk_level_filter(level): + return Q(productpackages__package__affected_by_vulnerabilities__risk_level=level) + + return self.annotate( + vulnerability_count=count_vulns(), + max_risk_score=Max( + "productpackages__package__affected_by_vulnerabilities__risk_score", + filter=no_threshold | threshold_filter, ), - max_risk_score=Max("productpackages__package__affected_by_vulnerabilities__risk_score"), + critical_count=count_vulns(risk_level_filter("critical")), + high_count=count_vulns(risk_level_filter("high")), + medium_count=count_vulns(risk_level_filter("medium")), + low_count=count_vulns(risk_level_filter("low")), ) def with_license_compliance_counts(self): diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index 6eb1eb4d..6f52b1dc 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -115,11 +115,6 @@

{% trans "Compliance Control Center" %}

{% endif %} - {% if product.risk_threshold %} - - ≥ {{ product.risk_threshold }} - - {% endif %} {% if product.max_risk_level == "critical" %} {% trans "Critical" %} {% elif product.max_risk_level == "high" %} @@ -133,6 +128,11 @@

{% trans "Compliance Control Center" %}

{% endif %} + {% if product.risk_threshold %} + + ≥ {{ product.risk_threshold }} + + {% endif %} {% if product.critical_count %} {{ product.critical_count }} {% trans "critical" %} {% endif %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index c8a64b74..48676ae8 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2827,7 +2827,7 @@ class ComplianceDashboardView(LoginRequiredMixin, DataspacedFilterView): template_name = "product_portfolio/compliance_dashboard.html" model = Product filterset_class = ProductFilterSet - paginate_by = 50 + paginate_by = settings.DEJACODE_PAGINATE_BY.get("compliance", 50) def get_queryset(self): base_qs = Product.objects.get_queryset( From 7f56572f88dd19e8caa38f910cb05cea734da4f4 Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Apr 2026 16:57:43 +0400 Subject: [PATCH 13/14] add unit tests Signed-off-by: tdruez --- product_portfolio/tests/test_views.py | 148 +++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index a12be50c..a5db81a2 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -3668,6 +3668,8 @@ def test_product_portfolio_compliance_dashboard_view_vulnerability_counts(self): self.assertEqual(2, response.context["total_vulnerabilities"]) self.assertEqual(1, response.context["total_critical"]) self.assertEqual(1, response.context["total_high"]) + self.assertEqual(0, response.context["total_medium"]) + self.assertEqual(0, response.context["total_low"]) def test_product_portfolio_compliance_dashboard_view_ordering(self): self.client.login(username=self.super_user.username, password="secret") @@ -3706,4 +3708,148 @@ def test_product_portfolio_compliance_dashboard_view_pagination(self): self.assertEqual(50, len(response.context["object_list"])) response = self.client.get(url + "?page=2") - self.assertEqual(5, len(response.context["object_list"])) + self.assertEqual(7, len(response.context["object_list"])) + + def test_product_portfolio_compliance_dashboard_view_risk_threshold_product(self): + """Vulnerability counts respect per-product risk threshold.""" + 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) + p3 = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) + make_vulnerability(self.dataspace, affecting=[p2], risk_score=6.5) + make_vulnerability(self.dataspace, affecting=[p3], risk_score=2.0) + + product1 = make_product(self.dataspace, inventory=[p1, p2, p3]) + + # No threshold: all 3 vulns count + response = self.client.get(url) + product_in_list = [p for p in response.context["object_list"] if p.pk == product1.pk][0] + self.assertEqual(3, product_in_list.vulnerability_count) + self.assertEqual(1, product_in_list.critical_count) + self.assertEqual(1, product_in_list.high_count) + self.assertEqual(0, product_in_list.medium_count) + self.assertEqual(1, product_in_list.low_count) + + # Set threshold to 6.0: only critical and high count + product1.update(vulnerabilities_risk_threshold=6.0) + response = self.client.get(url) + product_in_list = [p for p in response.context["object_list"] if p.pk == product1.pk][0] + self.assertEqual(2, product_in_list.vulnerability_count) + self.assertEqual(1, product_in_list.critical_count) + self.assertEqual(1, product_in_list.high_count) + self.assertEqual(0, product_in_list.medium_count) + self.assertEqual(0, product_in_list.low_count) + + # Set threshold to 8.0: only critical counts + product1.update(vulnerabilities_risk_threshold=8.0) + response = self.client.get(url) + product_in_list = [p for p in response.context["object_list"] if p.pk == product1.pk][0] + self.assertEqual(1, product_in_list.vulnerability_count) + self.assertEqual(1, product_in_list.critical_count) + self.assertEqual(0, product_in_list.high_count) + + def test_product_portfolio_compliance_dashboard_view_risk_threshold_dataspace(self): + """Vulnerability counts fall back to dataspace threshold.""" + from dje.models import DataspaceConfiguration + + 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) + + make_product(self.dataspace, inventory=[p1, p2]) + + # No threshold anywhere: all vulns count + response = self.client.get(url) + self.assertEqual(2, response.context["total_vulnerabilities"]) + + # Set dataspace threshold to 6.0: only critical counts + DataspaceConfiguration.objects.create( + dataspace=self.dataspace, + vulnerabilities_risk_threshold=6.0, + ) + response = self.client.get(url) + self.assertEqual(1, response.context["total_vulnerabilities"]) + self.assertEqual(1, response.context["total_critical"]) + self.assertEqual(0, response.context["total_low"]) + + def test_product_portfolio_compliance_dashboard_view_risk_threshold_product_overrides_dataspace( + self, + ): + """Product threshold takes precedence over dataspace threshold.""" + from dje.models import DataspaceConfiguration + + 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) + p3 = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) + make_vulnerability(self.dataspace, affecting=[p2], risk_score=5.0) + make_vulnerability(self.dataspace, affecting=[p3], risk_score=2.0) + + product1 = make_product(self.dataspace, inventory=[p1, p2, p3]) + + # Dataspace threshold at 6.0 + DataspaceConfiguration.objects.create( + dataspace=self.dataspace, + vulnerabilities_risk_threshold=6.0, + ) + response = self.client.get(url) + product_in_list = [p for p in response.context["object_list"] if p.pk == product1.pk][0] + self.assertEqual(1, product_in_list.vulnerability_count) + + # Product threshold at 3.0 overrides dataspace + product1.update(vulnerabilities_risk_threshold=3.0) + response = self.client.get(url) + product_in_list = [p for p in response.context["object_list"] if p.pk == product1.pk][0] + self.assertEqual(2, product_in_list.vulnerability_count) + + def test_product_portfolio_compliance_dashboard_view_risk_threshold_totals(self): + """Summary totals respect per-product thresholds.""" + 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) + p3 = make_package(self.dataspace) + p4 = make_package(self.dataspace) + make_vulnerability(self.dataspace, affecting=[p1], risk_score=9.0) + make_vulnerability(self.dataspace, affecting=[p2], risk_score=2.0) + make_vulnerability(self.dataspace, affecting=[p3], risk_score=8.5) + make_vulnerability(self.dataspace, affecting=[p4], risk_score=1.0) + + # Product A: threshold 6.0, only p1 (9.0) counts + product_a = make_product(self.dataspace, inventory=[p1, p2]) + product_a.update(vulnerabilities_risk_threshold=6.0) + + # Product B: no threshold, both p3 (8.5) and p4 (1.0) count + make_product(self.dataspace, inventory=[p3, p4]) + + response = self.client.get(url) + # Product A: 1 (critical), Product B: 2 (critical + low) + self.assertEqual(3, response.context["total_vulnerabilities"]) + self.assertEqual(2, response.context["total_critical"]) + self.assertEqual(0, response.context["total_high"]) + self.assertEqual(1, response.context["total_low"]) + + def test_product_portfolio_compliance_dashboard_view_risk_threshold_display(self): + """Risk threshold value is displayed in the template.""" + 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]) + product1.update(vulnerabilities_risk_threshold=7.0) + + response = self.client.get(url) + self.assertContains(response, "7.0") + self.assertContains(response, "Risk threshold") From a2f86e47861c12abd5dec6e6e251c48b60b5a39a Mon Sep 17 00:00:00 2001 From: tdruez Date: Wed, 15 Apr 2026 17:04:43 +0400 Subject: [PATCH 14/14] do not display threshold when vulnerability_count is none Signed-off-by: tdruez --- .../templates/product_portfolio/compliance_dashboard.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/product_portfolio/templates/product_portfolio/compliance_dashboard.html b/product_portfolio/templates/product_portfolio/compliance_dashboard.html index 6f52b1dc..497f9975 100644 --- a/product_portfolio/templates/product_portfolio/compliance_dashboard.html +++ b/product_portfolio/templates/product_portfolio/compliance_dashboard.html @@ -128,7 +128,7 @@

{% trans "Compliance Control Center" %}

{% endif %} - {% if product.risk_threshold %} + {% if product.risk_threshold and product.vulnerability_count %} ≥ {{ product.risk_threshold }}