diff --git a/dje/tests/test_views.py b/dje/tests/test_views.py
index 3af503d4..84b8952a 100644
--- a/dje/tests/test_views.py
+++ b/dje/tests/test_views.py
@@ -64,7 +64,7 @@ def test_home_view(self):
self.assertFalse(self.super_user.request_as_assignee.exists())
self.assertFalse(self.super_user.request_as_requester.exists())
self.assertContains(response, "Welcome to DejaCode!")
- self.assertContains(response, "Documentation:")
+ self.assertContains(response, "Documentation")
self.dataspace1.home_page_announcements = "Custom announcements"
self.dataspace1.save()
@@ -89,8 +89,8 @@ def test_home_view(self):
self.assertTrue(self.super_user.request_as_requester.exists())
response = self.client.get(home_url)
- self.assertContains(response, "Requests assigned to me")
- self.assertContains(response, "Requests I am following")
+ self.assertContains(response, "Assigned to me")
+ self.assertContains(response, "Following")
request_list_url = reverse("workflow:request_list")
expected = f'
View all 1 requests'
self.assertContains(response, expected, html=True)
@@ -114,7 +114,7 @@ def test_home_view_card_layout(self):
response = self.client.get(home_url)
self.assertContains(
- response, '', html=True
+ response, ' ', html=True
)
self.assertContains(
response,
@@ -122,8 +122,7 @@ def test_home_view_card_layout(self):
html=True,
)
changelist_link = (
- f'
'
+ f''
f" View all the objects in changelist"
f""
)
diff --git a/dje/views.py b/dje/views.py
index 09b5e463..8aec73ea 100644
--- a/dje/views.py
+++ b/dje/views.py
@@ -701,10 +701,9 @@ def home_view(request):
rtd_url = "https://dejacode.readthedocs.io/en/latest"
documentation_urls = {
- "Documentation": "https://dejacode.readthedocs.io/en/latest/",
"Tutorials": f"{rtd_url}/tutorial-1.html",
+ "How-to": f"{rtd_url}/howto-1.html",
"API documentation": reverse("api-docs:docs-index"),
- "How-To videos": "https://www.youtube.com/playlist?list=PLCq_LXeUqhkQj0u7M26fSHt1ebFhNCpCv",
}
support_urls = {
@@ -1886,6 +1885,7 @@ def get_context_data(self, **kwargs):
)
include_purldb_conditions = [
+ user.is_authenticated,
user_dataspace.enable_purldb_access,
PurlDB(user_dataspace).is_available(),
]
diff --git a/license_library/tests/test_views.py b/license_library/tests/test_views.py
index 66eb7c83..cb053ef3 100644
--- a/license_library/tests/test_views.py
+++ b/license_library/tests/test_views.py
@@ -10,6 +10,7 @@
from django.contrib.contenttypes.models import ContentType
from django.shortcuts import resolve_url
from django.test import TestCase
+from django.test.utils import override_settings
from django.urls import reverse
from dje.copier import copy_object
@@ -136,6 +137,7 @@ def setUp(self):
dataspace=self.nexb_dataspace,
)
+ @override_settings(ANONYMOUS_USERS_DATASPACE=None)
def test_license_library_list_view_access(self):
license_list_url = resolve_url("license_library:license_list")
diff --git a/product_portfolio/models.py b/product_portfolio/models.py
index 0205e7c5..2b476974 100644
--- a/product_portfolio/models.py
+++ b/product_portfolio/models.py
@@ -15,6 +15,7 @@
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Case
+from django.db.models import CharField
from django.db.models import Count
from django.db.models import DecimalField
from django.db.models import F
@@ -146,6 +147,18 @@ def with_risk_threshold(self):
),
)
+ def with_max_risk_level(self):
+ return self.annotate(
+ 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),
+ ),
+ )
+
def with_vulnerability_counts(self):
threshold_filter = Q(
productpackages__package__affected_by_vulnerabilities__risk_score__gte=F(
@@ -193,6 +206,31 @@ def with_license_compliance_counts(self):
),
)
+ def with_compliance_data(self):
+ """Apply all compliance annotations and severity-based ordering."""
+ return (
+ self.with_risk_threshold()
+ .with_vulnerability_counts()
+ .with_license_compliance_counts()
+ .annotate(package_count=Count("productpackages", distinct=True))
+ .order_by(
+ F("max_risk_score").desc(nulls_last=True),
+ "-license_error_count",
+ "-license_warning_count",
+ "name",
+ "-version",
+ )
+ )
+
+ def with_compliance_issues(self):
+ """Filter to products that have license or critical/high vulnerability issues."""
+ return self.filter(
+ Q(license_error_count__gt=0)
+ | Q(license_warning_count__gt=0)
+ | Q(critical_count__gt=0)
+ | Q(high_count__gt=0)
+ )
+
class ProductSecuredManager(DataspacedManager):
"""
diff --git a/product_portfolio/templates/product_portfolio/compliance/compliance_watchlist_card.html b/product_portfolio/templates/product_portfolio/compliance/compliance_watchlist_card.html
new file mode 100644
index 00000000..55e5c7a7
--- /dev/null
+++ b/product_portfolio/templates/product_portfolio/compliance/compliance_watchlist_card.html
@@ -0,0 +1,67 @@
+
+
+
+ {% for product in compliance_qs %}
+
+
{{ product }}
+
+
License
+
+ {% if product.license_error_count %}
+ {{ product.license_error_count }} error{{ product.license_error_count|pluralize }}
+ {% endif %}
+ {% if product.license_warning_count %}
+ {{ product.license_warning_count }} warning{{ product.license_warning_count|pluralize }}
+ {% endif %}
+ {% if not product.license_error_count and not product.license_warning_count %}
+ OK
+ {% endif %}
+
+
+
+
Vulnerabilities
+
+ {% if product.critical_count %}
+ {{ product.critical_count }} critical
+ {% endif %}
+ {% if product.high_count %}
+ {{ product.high_count }} high
+ {% endif %}
+ {% if product.medium_count %}
+ {{ product.medium_count }} medium
+ {% endif %}
+ {% if product.low_count %}
+ {{ product.low_count }} low
+ {% endif %}
+ {% if not product.critical_count and not product.high_count and not product.medium_count and not product.low_count %}
+ None
+ {% endif %}
+
+
+
+ {% if not forloop.last %}
{% endif %}
+ {% empty %}
+
+
+
All products are compliant
+
+ {% endfor %}
+
+
+
\ No newline at end of file
diff --git a/product_portfolio/urls.py b/product_portfolio/urls.py
index f4115bdd..e1b398f3 100644
--- a/product_portfolio/urls.py
+++ b/product_portfolio/urls.py
@@ -10,6 +10,7 @@
from product_portfolio.views import AttributionView
from product_portfolio.views import ComplianceDashboardView
+from product_portfolio.views import ComplianceWatchlistCardView
from product_portfolio.views import ImportManifestsView
from product_portfolio.views import LoadSBOMsView
from product_portfolio.views import ManageComponentGridView
@@ -73,6 +74,11 @@ def product_path(path_segment, view):
ComplianceDashboardView.as_view(),
name="compliance_dashboard",
),
+ path(
+ "compliance_watchlist/",
+ ComplianceWatchlistCardView.as_view(),
+ name="compliance_watchlist_card",
+ ),
path(
"import_packages_from_scancodeio/
/",
import_packages_from_scancodeio_view,
diff --git a/product_portfolio/views.py b/product_portfolio/views.py
index cc7d0ece..7e58dbf9 100644
--- a/product_portfolio/views.py
+++ b/product_portfolio/views.py
@@ -25,8 +25,6 @@
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
@@ -35,8 +33,6 @@
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
@@ -2949,6 +2945,16 @@ def export_yaml(self):
return response
+def get_viewable_products(user):
+ """Return active, unlocked products the user can view."""
+ return Product.objects.get_queryset(
+ user=user,
+ perms="view_product",
+ include_inactive=False,
+ exclude_locked=True,
+ )
+
+
class ComplianceDashboardView(LoginRequiredMixin, ExportComplianceMixin, DataspacedFilterView):
"""Compliance control center: overview of all products."""
@@ -2973,36 +2979,7 @@ class ComplianceDashboardView(LoginRequiredMixin, ExportComplianceMixin, Dataspa
}
def get_queryset(self):
- base_qs = Product.objects.get_queryset(
- user=self.request.user,
- perms="view_product",
- include_inactive=False,
- exclude_locked=True,
- )
-
- return (
- base_qs.with_risk_threshold()
- .with_vulnerability_counts()
- .with_license_compliance_counts()
- .annotate(
- package_count=Count("productpackages", 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",
- )
- )
+ return get_viewable_products(self.request.user).with_compliance_data().with_max_risk_level()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -3010,12 +2987,7 @@ def get_context_data(self, **kwargs):
products = self.object_list
total_products = products.count()
- 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_issues = products.with_compliance_issues().count()
products_with_license_issues = products.filter(
Q(license_error_count__gt=0) | Q(license_warning_count__gt=0)
@@ -3050,6 +3022,28 @@ def get_context_data(self, **kwargs):
return context
+class ComplianceWatchlistCardView(
+ LoginRequiredMixin,
+ BaseProductViewMixin,
+ TemplateView,
+):
+ """HTMX partial: top products with compliance issues for the home dashboard."""
+
+ template_name = "product_portfolio/compliance/compliance_watchlist_card.html"
+ limit = 5
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ products_with_issues = (
+ get_viewable_products(self.request.user)
+ .with_compliance_data()
+ .with_compliance_issues()
+ )
+ context["compliance_qs"] = products_with_issues[: self.limit]
+ context["total_products_with_issues"] = products_with_issues.count()
+ return context
+
+
class ProductLicenseComplianceExportView(
LoginRequiredMixin,
ExportComplianceMixin,
diff --git a/workflow/templates/workflow/includes/request_home_dashboard.html b/workflow/templates/workflow/includes/request_home_dashboard.html
index 8592ded9..9b797855 100644
--- a/workflow/templates/workflow/includes/request_home_dashboard.html
+++ b/workflow/templates/workflow/includes/request_home_dashboard.html
@@ -1,9 +1,11 @@
{% load humanize %}
-
-
-