\ No newline at end of file
diff --git a/scanpipe/templates/scanpipe/includes/filter_sort.html b/scanpipe/templates/scanpipe/includes/filter_sort.html
index e8ea66bf42..7e91251a03 100644
--- a/scanpipe/templates/scanpipe/includes/filter_sort.html
+++ b/scanpipe/templates/scanpipe/includes/filter_sort.html
@@ -1,6 +1,19 @@
-
+ {% endif %}
+
\ No newline at end of file
diff --git a/scanpipe/templates/scanpipe/includes/list_view_thead.html b/scanpipe/templates/scanpipe/includes/list_view_thead.html
index f0a442e170..1737a06fd0 100644
--- a/scanpipe/templates/scanpipe/includes/list_view_thead.html
+++ b/scanpipe/templates/scanpipe/includes/list_view_thead.html
@@ -10,14 +10,14 @@
{% if column.sort_query %}
- {% include 'scanpipe/includes/filter_sort.html' with column=column only %}
+ {% include 'scanpipe/includes/filter_sort.html' with column=column is_htmx=False path=path only %}
{% else %}
{{ column.label }}
{% endif %}
{% if column.filter %}
- {% include 'scanpipe/dropdowns/filter_dropdown_choices_field.html' with filter=column.filter is_right=column.filter_is_right only %}
+ {% include 'scanpipe/dropdowns/filter_dropdown_choices_field.html' with filter=column.filter is_right=column.filter_is_right is_htmx=False path=path only %}
{% endif %}
diff --git a/scanpipe/templates/scanpipe/panels/codebase_tree_panel.html b/scanpipe/templates/scanpipe/panels/codebase_tree_panel.html
new file mode 100644
index 0000000000..e97ad96ed7
--- /dev/null
+++ b/scanpipe/templates/scanpipe/panels/codebase_tree_panel.html
@@ -0,0 +1,29 @@
+
diff --git a/scanpipe/templates/scanpipe/panels/project_codebase.html b/scanpipe/templates/scanpipe/panels/project_codebase.html
index b79c1c777e..c05a77aaa1 100644
--- a/scanpipe/templates/scanpipe/panels/project_codebase.html
+++ b/scanpipe/templates/scanpipe/panels/project_codebase.html
@@ -1,6 +1,9 @@
-
- Codebase
+
+ Codebase
+
+ Tree view
+
{% if current_dir and current_dir != "." %}
{% for dir_name, full_path in codebase_breadcrumbs.items %}
diff --git a/scanpipe/templates/scanpipe/panels/resource_table_panel.html b/scanpipe/templates/scanpipe/panels/resource_table_panel.html
new file mode 100644
index 0000000000..1f97ab5abc
--- /dev/null
+++ b/scanpipe/templates/scanpipe/panels/resource_table_panel.html
@@ -0,0 +1,123 @@
+{% load humanize %}
+
+
+
+
+
+ {% if select_all %}
+
+
+
+ {% endif %}
+ {% for column in columns_data %}
+
+
+
+ {% if column.sort_query %}
+ {% include 'scanpipe/includes/filter_sort.html' with column=column is_htmx=True project=project path=path only %}
+ {% else %}
+ {{ column.label }}
+ {% endif %}
+
+ {% if column.filter %}
+
+ {% include 'scanpipe/dropdowns/filter_dropdown_choices_field.html' with filter=column.filter is_right=True is_htmx=True project=project path=path only %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+ {% if resources %}
+ {% for resource in resources %}
+
+
+ {% if resource.type == "directory" %}
+ {{ resource.path }}
+ {% else %}
+ {{ resource.path }}
+ {% endif %}
+
+
+ {{ resource.status }}
+
+
+ {{ resource.type }}
+
+
+ {% if resource.type != "directory" %}
+ {{ resource.size|filesizeformat|default_if_none:"" }}
+ {% endif %}
+
+
+ {{ resource.name }}
+
+
+ {{ resource.extension }}
+
+
+ {{ resource.programming_language }}
+
+
+ {{ resource.mime_type }}
+
+
+ {{ resource.tag }}
+
+
+ {{ resource.detected_license_expression }}
+
+
+ {{ resource.compliance_alert }}
+
+
+ {% if resource.discovered_packages.all %}
+ {% for package in resource.discovered_packages.all|slice:"0:3" %}
+ {{ package }} {% if not forloop.last %}, {% endif %}
+ {% endfor %}
+ {% if resource.discovered_packages.all|length > 3 %}
+ +{{ resource.discovered_packages.all|length|add:"-3" }} more
+ {% endif %}
+ {% endif %}
+
+
+ {% endfor %}
+ {% else %}
+
+
+
+
+
+
+ {% if path %}
+ No resources found in this directory.
+ {% else %}
+ Select a file or folder from the tree to view its contents.
+ {% endif %}
+
+
+
+ {% endif %}
+
+
+
+ {% if is_paginated %}
+
+ {% endif %}
+
\ No newline at end of file
diff --git a/scanpipe/templates/scanpipe/resource_tree.html b/scanpipe/templates/scanpipe/resource_tree.html
new file mode 100644
index 0000000000..ed7429a2e6
--- /dev/null
+++ b/scanpipe/templates/scanpipe/resource_tree.html
@@ -0,0 +1,180 @@
+{% extends "scanpipe/base.html" %}
+{% load static humanize %}
+{% block title %}ScanCode.io: {{ project.name }} - Resource Tree{% endblock %}
+
+{% block extrahead %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {% include "scanpipe/panels/codebase_tree_panel.html" with children=children path=path %}
+
+
+
+
+
+ {% include "scanpipe/panels/resource_table_panel.html" %}
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/scanpipe/tests/test_models.py b/scanpipe/tests/test_models.py
index 59977b100b..affc8c7588 100644
--- a/scanpipe/tests/test_models.py
+++ b/scanpipe/tests/test_models.py
@@ -3214,6 +3214,24 @@ def test_scanpipe_discovered_package_model_unique_package_uid_in_project(self):
self.assertTrue(package3.package_uid)
self.assertNotEqual(package.package_uid, package3.package_uid)
+ def test_scanpipe_codebase_resource_queryset_with_has_children(self):
+ project1 = make_project("Analysis")
+
+ make_resource_directory(project1, "parent")
+ make_resource_file(project1, "parent/child.txt")
+ make_resource_directory(project1, "empty")
+
+ qs = CodebaseResource.objects.filter(project=project1).with_has_children()
+
+ resource1 = qs.get(path="parent")
+ self.assertTrue(resource1.has_children)
+
+ resource2 = qs.get(path="parent/child.txt")
+ self.assertFalse(resource2.has_children)
+
+ resource3 = qs.get(path="empty")
+ self.assertFalse(resource3.has_children)
+
@skipIf(connection.vendor == "sqlite", "No max_length constraints on SQLite.")
def test_scanpipe_codebase_resource_create_and_add_package_warnings(self):
project1 = make_project("Analysis")
diff --git a/scanpipe/tests/test_views.py b/scanpipe/tests/test_views.py
index e7f33091fa..19829e4ad1 100644
--- a/scanpipe/tests/test_views.py
+++ b/scanpipe/tests/test_views.py
@@ -54,6 +54,7 @@
from scanpipe.tests import make_dependency
from scanpipe.tests import make_package
from scanpipe.tests import make_project
+from scanpipe.tests import make_resource_directory
from scanpipe.tests import make_resource_file
from scanpipe.tests import package_data1
from scanpipe.tests import package_data2
@@ -1630,3 +1631,132 @@ def test_project_codebase_resources_export_json(self):
for field in expected_fields:
self.assertIn(field, json_data[0])
+
+ def test_scanpipe_views_resource_tree_root_path(self):
+ make_resource_file(self.project1, path="child1.txt")
+ make_resource_file(self.project1, path="dir1")
+
+ url = reverse("codebase_resource_tree", kwargs={"slug": self.project1.slug})
+ response = self.client.get(url)
+ children = response.context["children"]
+ child1 = children[0]
+ dir1 = children[1]
+
+ self.assertEqual(child1.path, "child1.txt")
+ self.assertEqual(dir1.path, "dir1")
+
+ def test_scanpipe_views_resource_tree_children_path(self):
+ make_resource_file(self.project1, path="parent/child1.txt")
+ make_resource_file(self.project1, path="parent/dir1")
+ make_resource_file(self.project1, path="parent/dir1/child2.txt")
+
+ url = reverse("codebase_resource_tree", kwargs={"slug": self.project1.slug})
+ response = self.client.get(url + "?path=parent&tree_panel=true")
+ children = response.context["children"]
+
+ child1 = children[0]
+ dir1 = children[1]
+
+ self.assertEqual(child1.path, "parent/child1.txt")
+ self.assertEqual(dir1.path, "parent/dir1")
+
+ self.assertFalse(child1.has_children)
+ self.assertTrue(dir1.has_children)
+
+ def test_scanpipe_views_codebase_resource_table_view_with_path_directory(self):
+ make_resource_directory(self.project1, path="parent")
+ make_resource_file(self.project1, path="parent/child1.txt")
+ make_resource_file(self.project1, path="parent/child2.py")
+
+ url = reverse("codebase_resource_table", kwargs={"slug": self.project1.slug})
+ response = self.client.get(url + "?path=parent")
+
+ self.assertEqual(200, response.status_code)
+ self.assertEqual("parent", response.context["path"])
+ resources = list(response.context["resources"])
+ self.assertEqual(2, len(resources))
+
+ resource_paths = [r.path for r in resources]
+ self.assertEqual(["parent/child1.txt", "parent/child2.py"], resource_paths)
+
+ def test_scanpipe_views_codebase_resource_table_view_with_path_file(self):
+ make_resource_file(self.project1, path="specific_file.txt")
+
+ url = reverse("codebase_resource_table", kwargs={"slug": self.project1.slug})
+ response = self.client.get(url + "?path=specific_file.txt")
+
+ self.assertEqual(200, response.status_code)
+ self.assertEqual("specific_file.txt", response.context["path"])
+ resources = list(response.context["resources"])
+ self.assertEqual(1, len(resources))
+ self.assertEqual("specific_file.txt", resources[0].path)
+
+ def test_scanpipe_views_codebase_resource_table_view_empty_directory(self):
+ make_resource_directory(self.project1, path="empty_dir")
+
+ url = reverse("codebase_resource_table", kwargs={"slug": self.project1.slug})
+ response = self.client.get(url + "?path=empty_dir")
+
+ self.assertEqual(200, response.status_code)
+ self.assertEqual("empty_dir", response.context["path"])
+ resources = list(response.context["resources"])
+ self.assertEqual(0, len(resources))
+
+ def test_scanpipe_views_codebase_resource_table_view_with_packages(self):
+ resource1 = make_resource_file(self.project1, path="file_with_package.txt")
+ package1 = DiscoveredPackage.create_from_data(self.project1, package_data1)
+ package1.add_resources([resource1])
+
+ url = reverse("codebase_resource_table", kwargs={"slug": self.project1.slug})
+ response = self.client.get(url + "?path=file_with_package.txt")
+
+ self.assertEqual(200, response.status_code)
+ resources = list(response.context["resources"])
+ self.assertEqual(1, len(resources))
+
+ resource = resources[0]
+ self.assertTrue(resource.discovered_packages.exists())
+
+ @mock.patch("scanpipe.views.CodebaseResourceTableView.paginate_by", 2)
+ def test_scanpipe_views_codebase_resource_table_view_pagination(self):
+ make_resource_directory(self.project1, path="parent")
+ make_resource_file(self.project1, path="parent/file1.txt", parent_path="parent")
+ make_resource_file(self.project1, path="parent/file2.txt", parent_path="parent")
+ make_resource_file(self.project1, path="parent/file3.txt", parent_path="parent")
+
+ url = reverse("codebase_resource_table", kwargs={"slug": self.project1.slug})
+
+ response = self.client.get(url + "?path=parent")
+ self.assertEqual(200, response.status_code)
+ self.assertTrue(response.context["is_paginated"])
+ self.assertEqual(1, response.context["page_obj"].number)
+ self.assertTrue(response.context["page_obj"].has_next())
+ self.assertFalse(response.context["page_obj"].has_previous())
+
+ response = self.client.get(url + "?path=parent&page=2")
+ self.assertEqual(200, response.status_code)
+ self.assertEqual(2, response.context["page_obj"].number)
+ self.assertFalse(response.context["page_obj"].has_next())
+ self.assertTrue(response.context["page_obj"].has_previous())
+
+ def test_scanpipe_views_codebase_resource_table_view_field_selection(self):
+ resource = make_resource_file(
+ self.project1,
+ path="test_file.py",
+ programming_language="Python",
+ mime_type="text/x-python",
+ detected_license_expression="MIT",
+ )
+
+ url = reverse("codebase_resource_table", kwargs={"slug": self.project1.slug})
+ response = self.client.get(url + "?path=test_file.py")
+
+ self.assertEqual(200, response.status_code)
+ resources = list(response.context["resources"])
+ self.assertEqual(1, len(resources))
+
+ resource = resources[0]
+ self.assertEqual("test_file.py", resource.path)
+ self.assertEqual("Python", resource.programming_language)
+ self.assertEqual("text/x-python", resource.mime_type)
+ self.assertEqual("MIT", resource.detected_license_expression)
diff --git a/scanpipe/urls.py b/scanpipe/urls.py
index 5efe6fdd76..67a457f94d 100644
--- a/scanpipe/urls.py
+++ b/scanpipe/urls.py
@@ -131,6 +131,16 @@
views.ProjectCodebaseView.as_view(),
name="project_codebase",
),
+ path(
+ "project//codebase_tree/",
+ views.CodebaseResourceTreeView.as_view(),
+ name="codebase_resource_tree",
+ ),
+ path(
+ "project//resource_table/",
+ views.CodebaseResourceTableView.as_view(),
+ name="codebase_resource_table",
+ ),
path(
"run//",
views.run_detail_view,
diff --git a/scanpipe/views.py b/scanpipe/views.py
index d52eafcaa2..b62eafe5f0 100644
--- a/scanpipe/views.py
+++ b/scanpipe/views.py
@@ -418,7 +418,7 @@ def get_columns_data(self):
sortable_fields = []
active_sort = ""
filterset = getattr(self, "filterset", None)
- if filterset and "sort" in filterset.filters:
+ if filterset and hasattr(filterset, "filters") and "sort" in filterset.filters:
sortable_fields = list(filterset.filters["sort"].param_map.keys())
active_sort = filterset.data.get("sort", "")
@@ -441,18 +441,29 @@ def get_columns_data(self):
sort_name = column_data.get("sort_name") or field_name
if sort_name in sortable_fields:
is_sorted = sort_name == active_sort.lstrip("-")
-
- sort_direction = ""
- if is_sorted and not active_sort.startswith("-"):
+ if is_sorted and active_sort.startswith("-"):
sort_direction = "-"
+ else:
+ sort_direction = ""
column_data["is_sorted"] = is_sorted
column_data["sort_direction"] = sort_direction
+
query_dict = self.request.GET.copy()
- query_dict["sort"] = f"{sort_direction}{sort_name}"
+ if is_sorted and sort_direction == "":
+ query_dict["sort"] = f"-{sort_name}"
+ else:
+ query_dict["sort"] = sort_name
column_data["sort_query"] = query_dict.urlencode()
- if filter_fieldname := column_data.get("filter_fieldname"):
+ filter_fieldname = column_data.get("filter_fieldname")
+ if (
+ filter_fieldname
+ and filterset
+ and hasattr(filterset, "form")
+ and filterset.form is not None
+ and filter_fieldname in filterset.form.fields
+ ):
column_data["filter"] = filterset.form[filter_fieldname]
columns_data.append(column_data)
@@ -683,6 +694,9 @@ def get_context_data(self, **kwargs):
context["reset_form"] = ProjectResetForm()
context["outputs_download_form"] = ProjectOutputDownloadForm()
context["report_form"] = ProjectReportForm()
+ context["request"] = (
+ self.request
+ ) # Ensure request is available in template context
return context
def get_queryset(self):
@@ -2756,3 +2770,147 @@ def get_node(self, package):
"children": children,
}
return node
+
+
+class CodebaseResourceTreeView(ConditionalLoginRequired, generic.DetailView):
+ template_name = "scanpipe/resource_tree.html"
+
+ def get(self, request, *args, **kwargs):
+ slug = self.kwargs.get("slug")
+ project = get_object_or_404(Project, slug=slug)
+ path = request.GET.get("path", "")
+
+ children = (
+ project.codebaseresources.filter(parent_path=path)
+ .with_has_children()
+ .only("id", "project_id", "path", "name", "type")
+ .order_by("path")
+ )
+
+ context = {
+ "project": project,
+ "path": path,
+ "children": children,
+ }
+
+ if request.GET.get("tree_panel") == "true":
+ return render(request, "scanpipe/panels/codebase_tree_panel.html", context)
+ return render(request, self.template_name, context)
+
+
+class CodebaseResourceTableView(
+ ConditionalLoginRequired,
+ PrefetchRelatedViewMixin,
+ ProjectRelatedViewMixin,
+ TableColumnsMixin,
+ PaginatedFilterView,
+):
+ prefetch_related = [
+ Prefetch(
+ "discovered_packages",
+ queryset=DiscoveredPackage.objects.only_package_url_fields(),
+ )
+ ]
+ def get_filterset_kwargs(self, filterset_class):
+ """Remove 'path' from filterset data when showing children of a directory."""
+ kwargs = super().get_filterset_kwargs(filterset_class)
+ path = self.request.GET.get("path")
+ if path:
+ base_qs = super().get_queryset()
+ if base_qs.filter(path=path, type="directory").exists():
+ data = kwargs.get("data")
+ if data:
+ data = data.copy()
+ data.pop("path", None)
+ kwargs["data"] = data
+ return kwargs
+
+ model = CodebaseResource
+ filterset_class = ResourceFilterSet
+ template_name = "scanpipe/panels/resource_table_panel.html"
+ paginate_by = settings.SCANCODEIO_PAGINATE_BY.get("resource", 100)
+ table_columns = [
+ "path",
+ {
+ "field_name": "status",
+ "filter_fieldname": "status",
+ },
+ {
+ "field_name": "type",
+ "filter_fieldname": "type",
+ },
+ "size",
+ "name",
+ "extension",
+ "programming_language",
+ "mime_type",
+ {
+ "field_name": "tag",
+ "filter_fieldname": "tag",
+ },
+ {
+ "field_name": "detected_license_expression",
+ "filter_fieldname": "detected_license_expression",
+ },
+ {
+ "field_name": "compliance_alert",
+ "filter_fieldname": "compliance_alert",
+ "filter_is_right": True,
+ },
+ {
+ "field_name": "packages",
+ "filter_fieldname": "in_package",
+ "filter_is_right": True,
+ },
+ ]
+
+ def get_queryset(self):
+ path = self.request.GET.get("path")
+ base_qs = super().get_queryset()
+ queryset = base_qs
+ if path:
+ dir_qs = base_qs.filter(path=path, type="directory")
+ if dir_qs.exists():
+ queryset = base_qs.filter(parent_path=path)
+ else:
+ file_qs = base_qs.filter(path=path).exclude(type="directory")
+ if file_qs.exists():
+ queryset = file_qs
+ return (
+ queryset.only(
+ "path",
+ "status",
+ "type",
+ "size",
+ "name",
+ "extension",
+ "programming_language",
+ "mime_type",
+ "tag",
+ "detected_license_expression",
+ "compliance_alert",
+ "package_data",
+ )
+ .order_by("type", "path")
+ )
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["path"] = self.request.GET.get("path", "")
+ context["columns_data"] = self.get_columns_data()
+ page_obj = context.get("page_obj")
+ if page_obj is not None:
+ context["resources"] = page_obj.object_list
+ else:
+ context["resources"] = context.get("object_list") or context.get("queryset")
+
+ return context
+
+ def render_to_response(self, context, **response_kwargs):
+ if self.request.headers.get("HX-Request") == "true":
+ from django.http import HttpResponse
+ from django.template.loader import render_to_string
+
+ html = render_to_string(self.template_name, context, request=self.request)
+ return HttpResponse(html)
+ return super().render_to_response(context, **response_kwargs)