From 07117ecb132f1d92f835baad5deb344c6bb182ca Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 10 Jul 2025 17:00:50 +0400 Subject: [PATCH 1/4] Enhance the dependency tree view in a more dynamic rendering Signed-off-by: tdruez --- CHANGELOG.rst | 3 + .../iamkate-tree-views/expand-collapse.svg | 7 + scancodeio/static/iamkate-tree-views/tree.css | 75 +++++++ .../static/iamkate-tree-views/tree.css.ABOUT | 10 + scanpipe/models.py | 7 + .../scanpipe/includes/project_list_table.html | 5 + .../includes/project_summary_level.html | 5 + .../scanpipe/project_dependency_tree.html | 189 +++++++++++++----- .../scanpipe/tree/dependency_children.html | 19 ++ .../scanpipe/tree/dependency_node.html | 23 +++ scanpipe/tests/test_models.py | 35 ++-- scanpipe/views.py | 33 ++- 12 files changed, 340 insertions(+), 71 deletions(-) create mode 100644 scancodeio/static/iamkate-tree-views/expand-collapse.svg create mode 100644 scancodeio/static/iamkate-tree-views/tree.css create mode 100644 scancodeio/static/iamkate-tree-views/tree.css.ABOUT create mode 100644 scanpipe/templates/scanpipe/tree/dependency_children.html create mode 100644 scanpipe/templates/scanpipe/tree/dependency_node.html diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 50ea051c03..2aa8ad16e4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,9 @@ v35.2.0 (unreleased) ``policies.yml`` file. https://github.com/aboutcode-org/scancode.io/issues/1348 +- Enhance the dependency tree view in a more dynamic rendering. + Vulnerabilities and compliance alert are displayed along the dependency entries. + v35.1.0 (2025-07-02) -------------------- diff --git a/scancodeio/static/iamkate-tree-views/expand-collapse.svg b/scancodeio/static/iamkate-tree-views/expand-collapse.svg new file mode 100644 index 0000000000..a52ccd22f7 --- /dev/null +++ b/scancodeio/static/iamkate-tree-views/expand-collapse.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/scancodeio/static/iamkate-tree-views/tree.css b/scancodeio/static/iamkate-tree-views/tree.css new file mode 100644 index 0000000000..b140236154 --- /dev/null +++ b/scancodeio/static/iamkate-tree-views/tree.css @@ -0,0 +1,75 @@ +.tree{ + --spacing : 1.5rem; + --radius : 10px; +} + +.tree li{ + display : block; + position : relative; + padding-left : calc(2 * var(--spacing) - var(--radius) - 2px); +} + +.tree ul{ + margin-left : calc(var(--radius) - var(--spacing)); + padding-left : 0; +} + +.tree ul li{ + border-left : 2px solid #ddd; +} + +.tree ul li:last-child{ + border-color : transparent; +} + +.tree ul li::before{ + content : ''; + display : block; + position : absolute; + top : calc(var(--spacing) / -2); + left : -2px; + width : calc(var(--spacing) + 2px); + height : calc(var(--spacing) + 1px); + border : solid #ddd; + border-width : 0 0 2px 2px; +} + +.tree summary{ + display : block; + cursor : pointer; +} + +.tree summary::marker, +.tree summary::-webkit-details-marker{ + display : none; +} + +.tree summary:focus{ + outline : none; +} + +.tree summary:focus-visible{ + outline : 1px dotted #000; +} + +.tree li::after, +.tree summary::before{ + content : ''; + display : block; + position : absolute; + top : calc(var(--spacing) / 2 - var(--radius)); + left : calc(var(--spacing) - var(--radius) - 1px); + width : calc(2 * var(--radius)); + height : calc(2 * var(--radius)); + border-radius : 50%; + background : #ddd; +} + +.tree summary::before{ + z-index : 1; + background : #696 url('expand-collapse.svg') 0 0; +} + +.tree details[open] > summary::before{ + background-position : calc(-2 * var(--radius)) 0; +} diff --git a/scancodeio/static/iamkate-tree-views/tree.css.ABOUT b/scancodeio/static/iamkate-tree-views/tree.css.ABOUT new file mode 100644 index 0000000000..13316e5265 --- /dev/null +++ b/scancodeio/static/iamkate-tree-views/tree.css.ABOUT @@ -0,0 +1,10 @@ +about_resource: tree.css +name: css-tree-views +homepage_url: https://iamkate.com/code/tree-views/ +description: A tree view (collapsible list) can be created using only html and css, without + the need for JavaScript. Accessibility software will see the tree view as lists nested inside + disclosure widgets, and the standard keyboard interaction is supported automatically. +license_expression: cc0-1.0 +licenses: + - key: cc0-1.0 + name: cc0-1.0 diff --git a/scanpipe/models.py b/scanpipe/models.py index 82c73e4f07..7bc6d12072 100644 --- a/scanpipe/models.py +++ b/scanpipe/models.py @@ -2568,6 +2568,13 @@ def from_db(cls, db, field_names, values): return new + @property + def has_compliance_issue(self): + """Return True if the compliance status is not OK or not set.""" + if not self.compliance_alert or self.compliance_alert == self.Compliance.OK: + return False + return True + @property def license_policy_index(self): return self.project.license_policy_index diff --git a/scanpipe/templates/scanpipe/includes/project_list_table.html b/scanpipe/templates/scanpipe/includes/project_list_table.html index c02e0a4668..1b4d0171a7 100644 --- a/scanpipe/templates/scanpipe/includes/project_list_table.html +++ b/scanpipe/templates/scanpipe/includes/project_list_table.html @@ -39,6 +39,11 @@ {{ project.discovereddependencies_count|intcomma }} + + + + + {% else %} 0 {% endif %} diff --git a/scanpipe/templates/scanpipe/includes/project_summary_level.html b/scanpipe/templates/scanpipe/includes/project_summary_level.html index bc75d3d543..e2f0e25451 100644 --- a/scanpipe/templates/scanpipe/includes/project_summary_level.html +++ b/scanpipe/templates/scanpipe/includes/project_summary_level.html @@ -44,6 +44,11 @@ {{ project.vulnerable_dependency_count|intcomma }} {% endif %} + + + + + {% else %} 0 {% endif %} diff --git a/scanpipe/templates/scanpipe/project_dependency_tree.html b/scanpipe/templates/scanpipe/project_dependency_tree.html index ff368c05e6..c81318e7c7 100644 --- a/scanpipe/templates/scanpipe/project_dependency_tree.html +++ b/scanpipe/templates/scanpipe/project_dependency_tree.html @@ -1,7 +1,25 @@ {% extends "scanpipe/base.html" %} +{% load static %} {% block title %}ScanCode.io: {{ project.name }} - Dependency tree{% endblock %} +{% block extrahead %} + + +{% endblock %} + {% block content %}
{% include 'scanpipe/includes/navbar_header.html' %} @@ -13,64 +31,127 @@
- {% if recursion_error %} -
-
- The dependency tree cannot be rendered as it contains circular references. - {{ message|linebreaksbr }} -
-
- {% endif %} -
+
+ {% if recursion_error %} +
+
+ The dependency tree cannot be rendered as it contains circular references. + {{ message|linebreaksbr }} +
+
+ {% endif %} + +
+ + + + +
+ +
    +
  • +
    + + {{ dependency_tree.name }} + + {% include 'scanpipe/tree/dependency_children.html' with children=dependency_tree.children %} +
    +
  • +
+
{% endblock %} {% block scripts %} - - - {{ dependency_tree|json_script:"dependency_tree" }} - {{ row_count|json_script:"row_count" }} - {{ max_depth|json_script:"max_depth" }} - + showVulnerableOnlyButton.addEventListener('click', handleVulnerableItems); + showComplianceAlertOnlyButton.addEventListener('click', handleComplianceAlertItems); + }); + {% endblock %} \ No newline at end of file diff --git a/scanpipe/templates/scanpipe/tree/dependency_children.html b/scanpipe/templates/scanpipe/tree/dependency_children.html new file mode 100644 index 0000000000..f7401a9a53 --- /dev/null +++ b/scanpipe/templates/scanpipe/tree/dependency_children.html @@ -0,0 +1,19 @@ + \ No newline at end of file diff --git a/scanpipe/templates/scanpipe/tree/dependency_node.html b/scanpipe/templates/scanpipe/tree/dependency_node.html new file mode 100644 index 0000000000..d13e9014e1 --- /dev/null +++ b/scanpipe/templates/scanpipe/tree/dependency_node.html @@ -0,0 +1,23 @@ +{% if node.name %} + {{ node.name }} +{% else %} + Missing data +{% endif %} + + + {% if node.url %} + + + + {% endif %} + {% if node.has_compliance_issue %} + + + + {% endif %} + {% if node.is_vulnerable %} + + + + {% endif %} + \ No newline at end of file diff --git a/scanpipe/tests/test_models.py b/scanpipe/tests/test_models.py index c589cccf53..67601d6014 100644 --- a/scanpipe/tests/test_models.py +++ b/scanpipe/tests/test_models.py @@ -2703,19 +2703,13 @@ def test_scanpipe_codebase_resource_queryset_elfs(self): def test_scanpipe_model_codebase_resource_compliance_alert_queryset_mixin(self): severities = CodebaseResource.Compliance - make_resource_file(self.project1, path="none") - make_resource_file(self.project1, path="ok", compliance_alert=severities.OK) - warning = make_resource_file( - self.project1, path="warning", compliance_alert=severities.WARNING - ) - error = make_resource_file( - self.project1, path="error", compliance_alert=severities.ERROR - ) - missing = make_resource_file( - self.project1, path="missing", compliance_alert=severities.MISSING - ) + make_resource_file(self.project1) + make_resource_file(self.project1, compliance_alert=severities.OK) + warning = make_resource_file(self.project1, compliance_alert=severities.WARNING) + error = make_resource_file(self.project1, compliance_alert=severities.ERROR) + missing = make_resource_file(self.project1, compliance_alert=severities.MISSING) - qs = CodebaseResource.objects.order_by("path") + qs = CodebaseResource.objects.order_by("compliance_alert") self.assertQuerySetEqual(qs.compliance_issues(severities.ERROR), [error]) self.assertQuerySetEqual( qs.compliance_issues(severities.WARNING), [error, warning] @@ -2724,6 +2718,23 @@ def test_scanpipe_model_codebase_resource_compliance_alert_queryset_mixin(self): qs.compliance_issues(severities.MISSING), [error, missing, warning] ) + def test_scanpipe_model_codebase_resource_has_compliance_issue(self): + severities = CodebaseResource.Compliance + none = make_resource_file(self.project1) + self.assertFalse(none.has_compliance_issue) + + ok = make_resource_file(self.project1, compliance_alert=severities.OK) + self.assertFalse(ok.has_compliance_issue) + + warning = make_resource_file(self.project1, compliance_alert=severities.WARNING) + self.assertTrue(warning.has_compliance_issue) + + error = make_resource_file(self.project1, compliance_alert=severities.ERROR) + self.assertTrue(error.has_compliance_issue) + + missing = make_resource_file(self.project1, compliance_alert=severities.MISSING) + self.assertTrue(missing.has_compliance_issue) + class ScanPipeModelsTransactionTest(TransactionTestCase): """ diff --git a/scanpipe/views.py b/scanpipe/views.py index d630628fbe..3fd07c6363 100644 --- a/scanpipe/views.py +++ b/scanpipe/views.py @@ -2175,6 +2175,7 @@ class DiscoveredPackageDetailsView( "field_name": "other_license_expression_spdx", "label": "Other license expression (SPDX)", }, + "compliance_alert", "extracted_license_statement", "copyright", "holder", @@ -2537,20 +2538,22 @@ class ProjectDependencyTreeView(ConditionalLoginRequired, generic.DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - try: - dependency_tree = self.get_dependency_tree(project=self.object) + context["dependency_tree"] = self.get_dependency_tree(project=self.object) except RecursionError: context["recursion_error"] = True - return context - context["dependency_tree"] = dependency_tree return context def get_dependency_tree(self, project): root_packages = project.discoveredpackages.root_packages().order_by("name") project_children = [self.get_node(package) for package in root_packages] + # Dependencies with no assigned `for_packages`. + project_dependencies = project.discovereddependencies.project_dependencies() + for dependency in project_dependencies: + project_children.append({"name": dependency.package_url}) + project_tree = { "name": project.name, "children": project_children, @@ -2559,11 +2562,31 @@ def get_dependency_tree(self, project): return project_tree def get_node(self, package): - node = {"name": str(package)} + node = { + "name": str(package), + "url": package.get_absolute_url(), + "compliance_alert": package.compliance_alert, + "has_compliance_issue": package.has_compliance_issue, + "is_vulnerable": package.is_vulnerable, + } + + # Resolved dependencies children = [ self.get_node(child_package) for child_package in package.children_packages.all() ] + + # Un-resolved dependencies + unresolved_dependencies = package.declared_dependencies.unresolved() + for dependency in unresolved_dependencies: + children.append( + { + "name": dependency.package_url, + "is_vulnerable": dependency.is_vulnerable, + } + ) + if children: node["children"] = children + return node From 43592e9562a8af0d464bf6f4fb8da19abf7445c5 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 10 Jul 2025 17:11:56 +0400 Subject: [PATCH 2/4] Fix and improve unit test #1742 Signed-off-by: tdruez --- CHANGELOG.rst | 1 + scanpipe/tests/test_views.py | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2aa8ad16e4..133158b4ca 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,7 @@ v35.2.0 (unreleased) - Enhance the dependency tree view in a more dynamic rendering. Vulnerabilities and compliance alert are displayed along the dependency entries. + https://github.com/aboutcode-org/scancode.io/pull/1742 v35.1.0 (2025-07-02) -------------------- diff --git a/scanpipe/tests/test_views.py b/scanpipe/tests/test_views.py index 4c06995b16..8b55e7c383 100644 --- a/scanpipe/tests/test_views.py +++ b/scanpipe/tests/test_views.py @@ -1297,14 +1297,16 @@ def test_scanpipe_views_license_details_view(self): response = self.client.get(xss_url) self.assertEqual(response.status_code, 404) - def test_scanpipe_views_project_dependency_tree(self): + @mock.patch("scanpipe.models.DiscoveredPackage.get_absolute_url") + def test_scanpipe_views_project_dependency_tree(self, mock_get_url): + mock_get_url.return_value = "mocked-url" url = reverse("project_dependency_tree", args=[self.project1.slug]) response = self.client.get(url) expected_tree = {"name": "Analysis", "children": []} self.assertEqual(expected_tree, response.context["dependency_tree"]) project = Project.objects.create(name="project") - a = make_package(project, "pkg:type/a") + a = make_package(project, "pkg:type/a", compliance_alert="error") b = make_package(project, "pkg:type/b") c = make_package(project, "pkg:type/c") make_package(project, "pkg:type/z") @@ -1319,15 +1321,39 @@ def test_scanpipe_views_project_dependency_tree(self): "children": [ { "name": "pkg:type/a", + "url": "mocked-url", + "compliance_alert": "error", + "has_compliance_issue": True, + "is_vulnerable": False, "children": [ - {"name": "pkg:type/b", "children": [{"name": "pkg:type/c"}]} + { + "name": "pkg:type/b", + "url": "mocked-url", + "compliance_alert": "", + "has_compliance_issue": False, + "is_vulnerable": False, + "children": [ + { + "name": "pkg:type/c", + "url": "mocked-url", + "compliance_alert": "", + "has_compliance_issue": False, + "is_vulnerable": False, + } + ], + } ], }, - {"name": "pkg:type/z"}, + { + "name": "pkg:type/z", + "url": "mocked-url", + "compliance_alert": "", + "has_compliance_issue": False, + "is_vulnerable": False, + }, ], } self.assertEqual(expected_tree, response.context["dependency_tree"]) - self.assertContains(response, '