diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6db4e23f3..3574e8a7d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -27,6 +27,10 @@ v34.9.4 (unreleased) - Add a download action on project list to enable bulk download of Project output files. https://github.com/aboutcode-org/scancode.io/issues/1518 +- Add labels to Project level search. + The labels are now always presented in alphabetical order for consistency. + https://github.com/aboutcode-org/scancode.io/issues/1520 + v34.9.3 (2024-12-31) -------------------- diff --git a/scanpipe/filters.py b/scanpipe/filters.py index 2f6cf7195..97972e3eb 100644 --- a/scanpipe/filters.py +++ b/scanpipe/filters.py @@ -245,15 +245,19 @@ def filter_queryset(self, queryset): `empty_value` to any filters. """ for name, value in self.form.cleaned_data.items(): - field_name = self.filters[name].field_name - if value == self.empty_value: + filter_field = self.filters[name] + field_name = filter_field.field_name + + if isinstance(filter_field, QuerySearchFilter): + queryset = filter_field.filter(queryset, value) + elif value == self.empty_value: queryset = queryset.filter(**{f"{field_name}__in": EMPTY_VALUES}) elif value == self.any_value: queryset = queryset.filter(~Q(**{f"{field_name}__in": EMPTY_VALUES})) elif value == self.other_value and hasattr(queryset, "less_common"): return queryset.less_common(name) else: - queryset = self.filters[name].filter(queryset, value) + queryset = filter_field.filter(queryset, value) return queryset @@ -266,7 +270,7 @@ def filter_for_lookup(cls, field, lookup_type): return super().filter_for_lookup(field, lookup_type) -def parse_query_string_to_lookups(query_string, default_lookup_expr, default_field): +def parse_query_string_to_lookups(query_string, default_lookup_expr, search_fields): """Parse a query string and convert it into queryset lookups using Q objects.""" lookups = Q() terms = shlex.split(query_string) @@ -295,11 +299,14 @@ def parse_query_string_to_lookups(query_string, default_lookup_expr, default_fie field_name = field_name[1:] negated = True + lookups &= Q( + **{f"{field_name}__{lookup_expr}": search_value}, _negated=negated + ) + else: search_value = term - field_name = default_field - - lookups &= Q(**{f"{field_name}__{lookup_expr}": search_value}, _negated=negated) + for field_name in search_fields: + lookups |= Q(**{f"{field_name}__{lookup_expr}": search_value}) return lookups @@ -323,6 +330,10 @@ class QuerySearchFilter(django_filters.CharFilter): field_class = QuerySearchField + def __init__(self, search_fields=None, lookup_expr="icontains", *args, **kwargs): + super().__init__(lookup_expr=lookup_expr, *args, **kwargs) + self.search_fields = search_fields or [] + def filter(self, qs, value): if not value: return qs @@ -330,11 +341,11 @@ def filter(self, qs, value): lookups = parse_query_string_to_lookups( query_string=value, default_lookup_expr=self.lookup_expr, - default_field=self.field_name, + search_fields=self.search_fields, ) try: - return qs.filter(lookups) + return qs.filter(lookups).distinct() except FieldError: return qs.none() @@ -347,7 +358,7 @@ class ProjectFilterSet(FilterSetUtilsMixin, django_filters.FilterSet): ] search = QuerySearchFilter( - label="Search", field_name="name", lookup_expr="icontains" + label="Search", search_fields=["name", "labels__name"], lookup_expr="icontains" ) sort = django_filters.OrderingFilter( label="Sort", @@ -508,7 +519,7 @@ class ResourceFilterSet(FilterSetUtilsMixin, django_filters.FilterSet): search = QuerySearchFilter( label="Search", - field_name="path", + search_fields=["path"], lookup_expr="icontains", ) sort = django_filters.OrderingFilter( @@ -615,15 +626,7 @@ def filter(self, qs, value): if value.startswith("pkg:"): return qs.for_package_url(value) - if ":" in value: - return super().filter(qs, value) - - search_fields = ["type", "namespace", "name", "version"] - lookups = Q() - for field_names in search_fields: - lookups |= Q(**{f"{field_names}__{self.lookup_expr}": value}) - - return qs.filter(lookups) + return super().filter(qs, value) class GroupOrderingFilter(django_filters.OrderingFilter): @@ -662,7 +665,9 @@ class PackageFilterSet(FilterSetUtilsMixin, django_filters.FilterSet): ] search = DiscoveredPackageSearchFilter( - label="Search", field_name="name", lookup_expr="icontains" + label="Search", + search_fields=["type", "namespace", "name", "version"], + lookup_expr="icontains", ) sort = GroupOrderingFilter( label="Sort", @@ -746,7 +751,7 @@ class DependencyFilterSet(FilterSetUtilsMixin, django_filters.FilterSet): ] search = QuerySearchFilter( - label="Search", field_name="name", lookup_expr="icontains" + label="Search", search_fields=["name"], lookup_expr="icontains" ) sort = GroupOrderingFilter( label="Sort", @@ -803,7 +808,7 @@ class Meta: class ProjectMessageFilterSet(FilterSetUtilsMixin, django_filters.FilterSet): search = QuerySearchFilter( - label="Search", field_name="description", lookup_expr="icontains" + label="Search", search_fields=["description"], lookup_expr="icontains" ) sort = django_filters.OrderingFilter( label="Sort", @@ -855,7 +860,7 @@ class RelationFilterSet(FilterSetUtilsMixin, django_filters.FilterSet): search = QuerySearchFilter( label="Search", - field_name="to_resource__path", + search_fields=["to_resource__path"], lookup_expr="icontains", ) sort = django_filters.OrderingFilter( diff --git a/scanpipe/models.py b/scanpipe/models.py index 0afce189a..78df705ab 100644 --- a/scanpipe/models.py +++ b/scanpipe/models.py @@ -561,7 +561,7 @@ class Project(UUIDPKModel, ExtraDataFieldMixin, UpdateMixin, models.Model): ) notes = models.TextField(blank=True) settings = models.JSONField(default=dict, blank=True) - labels = TaggableManager(through=UUIDTaggedItem) + labels = TaggableManager(through=UUIDTaggedItem, ordering=["name"]) purl = models.CharField( max_length=2048, blank=True, diff --git a/scanpipe/tests/test_api.py b/scanpipe/tests/test_api.py index 9ae4b905f..d91087946 100644 --- a/scanpipe/tests/test_api.py +++ b/scanpipe/tests/test_api.py @@ -489,13 +489,13 @@ def test_scanpipe_api_project_create_pipeline_old_name_compatibility(self): def test_scanpipe_api_project_create_labels(self): data = { "name": "Project1", - "labels": ["label1", "label2"], + "labels": ["label2", "label1"], } response = self.csrf_client.post(self.project_list_url, data) self.assertEqual(status.HTTP_201_CREATED, response.status_code) - self.assertEqual(data["labels"], sorted(response.data["labels"])) + self.assertEqual(sorted(data["labels"]), response.data["labels"]) project = Project.objects.get(name=data["name"]) - self.assertEqual(data["labels"], sorted(project.labels.names())) + self.assertEqual(sorted(data["labels"]), list(project.labels.names())) def test_scanpipe_api_project_create_pipeline_groups(self): data = { diff --git a/scanpipe/tests/test_filters.py b/scanpipe/tests/test_filters.py index 30a882def..7a8a098be 100644 --- a/scanpipe/tests/test_filters.py +++ b/scanpipe/tests/test_filters.py @@ -95,6 +95,11 @@ def test_scanpipe_filters_project_filterset_labels(self): filterset = ProjectFilterSet(data={"label": "label2"}) self.assertEqual(0, len(filterset.qs)) + filterset = ProjectFilterSet(data={"search": "label1"}) + self.assertEqual([self.project1], list(filterset.qs)) + filterset = ProjectFilterSet(data={"search": "lab"}) + self.assertEqual([self.project1], list(filterset.qs)) + def test_scanpipe_filters_filter_queryset_empty_values(self): resource1 = CodebaseResource.objects.create( project=self.project1, @@ -276,7 +281,7 @@ def test_scanpipe_filters_parse_query_string_to_lookups(self): inputs = { "LICENSE": "(AND: ('name__icontains', 'LICENSE'))", "two words": ( - "(AND: ('name__icontains', 'two'), ('name__icontains', 'words'))" + "(OR: ('name__icontains', 'two'), ('name__icontains', 'words'))" ), "'two words'": "(AND: ('name__icontains', 'two words'))", "na me:LICENSE": ( @@ -306,7 +311,7 @@ def test_scanpipe_filters_parse_query_string_to_lookups(self): } for query_string, expected in inputs.items(): - lookups = parse_query_string_to_lookups(query_string, "icontains", "name") + lookups = parse_query_string_to_lookups(query_string, "icontains", ["name"]) self.assertEqual(expected, str(lookups)) def test_scanpipe_filters_filter_advanced_search_query_string(self): diff --git a/scanpipe/tests/test_models.py b/scanpipe/tests/test_models.py index 9c365fde9..71064fcdf 100644 --- a/scanpipe/tests/test_models.py +++ b/scanpipe/tests/test_models.py @@ -816,13 +816,13 @@ def test_scanpipe_project_get_ignored_vulnerabilities_set(self): self.assertEqual(expected, self.project1.get_ignored_vulnerabilities_set()) def test_scanpipe_project_model_labels(self): - self.project1.labels.add("label1", "label2") + self.project1.labels.add("label2", "label1") self.assertEqual(2, UUIDTaggedItem.objects.count()) - self.assertEqual(["label1", "label2"], sorted(self.project1.labels.names())) + self.assertEqual(["label1", "label2"], list(self.project1.labels.names())) self.project1.labels.remove("label1") self.assertEqual(1, UUIDTaggedItem.objects.count()) - self.assertEqual(["label2"], sorted(self.project1.labels.names())) + self.assertEqual(["label2"], list(self.project1.labels.names())) self.project1.labels.clear() self.assertEqual(0, UUIDTaggedItem.objects.count()) diff --git a/scanpipe/tests/test_views.py b/scanpipe/tests/test_views.py index 8d5f507b1..6b3eb14f6 100644 --- a/scanpipe/tests/test_views.py +++ b/scanpipe/tests/test_views.py @@ -358,7 +358,7 @@ def test_scanpipe_views_project_details_add_labels(self): data["add-labels-submit"] = "" response = self.client.post(url, data, follow=True) self.assertContains(response, "Label(s) added.") - self.assertEqual(["label1", "label2"], sorted(self.project1.labels.names())) + self.assertEqual(["label1", "label2"], list(self.project1.labels.names())) def test_scanpipe_views_project_delete_label(self): self.project1.labels.add("label1")