Skip to content

Commit d13a1af

Browse files
authored
Add PURL fragment search in ProductDependencyAdmin #286 (#288)
Signed-off-by: tdruez <[email protected]>
1 parent 3abc0c7 commit d13a1af

File tree

15 files changed

+194
-40
lines changed

15 files changed

+194
-40
lines changed

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ Release notes
120120
simplicity, and readability.
121121
https://github.com/aboutcode-org/dejacode/issues/241
122122

123+
- Refine the way the PURL fragments are handled in searches.
124+
https://github.com/aboutcode-org/dejacode/issues/286
125+
123126
### Version 5.2.1
124127

125128
- Fix the models documentation navigation.

component_catalog/admin.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
from dje.templatetags.dje_tags import urlize_target_blank
7272
from dje.utils import CHANGELIST_LINK_TEMPLATE
7373
from dje.utils import get_instance_from_referer
74+
from dje.utils import is_purl_fragment
7475
from license_library.models import License
7576
from reporting.filters import ReportingQueryListFilter
7677

@@ -953,9 +954,9 @@ def get_search_results(self, request, queryset, search_term):
953954
"""Add searching on provided PackageURL identifier."""
954955
use_distinct = False
955956

956-
is_purl = "/" in search_term
957-
if is_purl:
958-
return queryset.for_package_url(search_term), use_distinct
957+
if is_purl_fragment(search_term):
958+
if results := queryset.for_package_url(search_term):
959+
return results, use_distinct
959960

960961
return super().get_search_results(request, queryset, search_term)
961962

component_catalog/filters.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from dje.filters import HasRelationFilter
2323
from dje.filters import MatchOrderedSearchFilter
2424
from dje.filters import RelatedLookupListFilter
25+
from dje.utils import is_purl_fragment
2526
from dje.widgets import BootstrapSelectMultipleWidget
2627
from dje.widgets import DropDownRightWidget
2728
from dje.widgets import SortDropDownWidget
@@ -183,9 +184,9 @@ def filter(self, qs, value):
183184
if not value:
184185
return qs
185186

186-
is_purl = "/" in value
187-
if is_purl:
188-
return qs.for_package_url(value)
187+
if is_purl_fragment(value):
188+
if results := qs.for_package_url(value):
189+
return results
189190

190191
return super().filter(qs, value)
191192

component_catalog/tests/test_admin.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from component_catalog.models import Package
3535
from component_catalog.models import PackageAssignedLicense
3636
from component_catalog.models import Subcomponent
37+
from component_catalog.tests import make_package
3738
from dje.copier import copy_object
3839
from dje.filters import DataspaceFilter
3940
from dje.models import Dataspace
@@ -1650,6 +1651,8 @@ def test_package_changelist_advanced_search_on_protocol(self):
16501651
p2 = Package.objects.create(
16511652
filename="p2", download_url="https://url.com/p2.zip", dataspace=self.dataspace1
16521653
)
1654+
package_url = "pkg:pypi/[email protected]"
1655+
package3 = make_package(self.dataspace1, package_url)
16531656

16541657
self.client.login(username="test", password="secret")
16551658
changelist_url = reverse("admin:component_catalog_package_changelist")
@@ -1663,6 +1666,16 @@ def test_package_changelist_advanced_search_on_protocol(self):
16631666
self.assertEqual(1, response.context_data["cl"].result_count)
16641667
self.assertIn(p2, response.context_data["cl"].result_list)
16651668

1669+
response = self.client.get(changelist_url + f"?q={package_url}")
1670+
self.assertEqual(200, response.status_code)
1671+
self.assertEqual(1, response.context_data["cl"].result_count)
1672+
self.assertIn(package3, response.context_data["cl"].result_list)
1673+
1674+
response = self.client.get(changelist_url + "?q=pypi/django")
1675+
self.assertEqual(200, response.status_code)
1676+
self.assertEqual(1, response.context_data["cl"].result_count)
1677+
self.assertIn(package3, response.context_data["cl"].result_list)
1678+
16661679
def test_package_changelist_set_policy_action_proper(self):
16671680
self.client.login(username=self.user.username, password="secret")
16681681
p1 = Package.objects.create(filename="p1.zip", dataspace=self.dataspace1)

component_catalog/tests/test_views.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -642,41 +642,42 @@ def test_component_catalog_list_view_sort_keep_active_filters(self):
642642
# Sort filter
643643
self.assertContains(
644644
response,
645-
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort" aria-label="Sort">',
645+
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort ms-1" aria-label="Sort">',
646646
)
647647
# Sort in the headers
648648
self.assertContains(
649649
response,
650-
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort" aria-label="Sort">',
650+
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort ms-1" aria-label="Sort">',
651651
)
652652
self.assertContains(
653653
response,
654-
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort" '
654+
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort ms-1" '
655655
'aria-label="Sort">',
656656
)
657657

658658
data["sort"] = "name"
659659
response = self.client.get(url, data=data)
660660
self.assertContains(
661661
response,
662-
'<a href="?q=a&amp;licenses=license1&sort=-name" class="sort active" '
662+
'<a href="?q=a&amp;licenses=license1&sort=-name" class="sort active ms-1" '
663663
'aria-label="Sort">',
664664
)
665665
self.assertContains(
666666
response,
667-
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort" '
667+
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort ms-1" '
668668
'aria-label="Sort">',
669669
)
670670

671671
data["sort"] = "-name"
672672
response = self.client.get(url, data=data)
673673
self.assertContains(
674674
response,
675-
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort active" aria-label="Sort">',
675+
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort active ms-1" '
676+
'aria-label="Sort">',
676677
)
677678
self.assertContains(
678679
response,
679-
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort" '
680+
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort ms-1" '
680681
'aria-label="Sort">',
681682
)
682683

dje/admin.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,11 @@ def set_reference_link(self, request):
429429
params = f"?{DataspaceFilter.parameter_name}={reference_dataspace.id}"
430430
self.reference_params = params
431431

432+
def get_search_fields_for_hint_display(self):
433+
if not self.search_fields:
434+
return []
435+
return tuple(set(field.split("__")[0] for field in self.search_fields))
436+
432437

433438
class DataspacedAdmin(
434439
DataspacedFKMixin,

dje/templates/admin/change_list_extended.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ <h1>Browse {{ cl.opts.verbose_name_plural|capfirst }}</h1>
9696
{% if cl.search_fields %}
9797
$("#grp-changelist-search-form")
9898
.attr("style", "width: 90%;")
99-
.attr("data-hint", "Search fields: {{ cl.search_fields|join:', ' }}")
100-
.addClass("hint--bottom");
99+
.attr("data-hint", "Search fields: {{ cl.get_search_fields_for_hint_display|join:', ' }}")
100+
.addClass("hint--bottom hint--large");
101101
{% endif %}
102102

103103
// Opens actions listed in `target_blank_actions` in a new tab.

dje/tests/test_utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from dje.utils import get_zipfile
3636
from dje.utils import group_by_name_version
3737
from dje.utils import is_available
38+
from dje.utils import is_purl_fragment
3839
from dje.utils import is_purl_str
3940
from dje.utils import localized_datetime
4041
from dje.utils import merge_relations
@@ -502,6 +503,28 @@ def test_utils_is_purl_str(self):
502503
self.assertTrue(is_purl_str("pkg:npm/[email protected]"))
503504
self.assertTrue(is_purl_str("pkg:npm/[email protected]", validate=True))
504505

506+
def test_utils_is_purl_fragment(self):
507+
valid_fragments = [
508+
"pkg:npm/[email protected]", # Valid full PURL
509+
"npm/[email protected]", # PURL without pkg: prefix
510+
"npm/type", # Fragment with type and namespace
511+
"name@version", # Fragment with name and version
512+
"namespace/name", # Fragment with namespace and name
513+
"npm/package", # Type and package name
514+
"[email protected]", # Name and version
515+
]
516+
517+
invalid_fragments = [
518+
"package", # Just the package name
519+
"package 1.0.0", # No connector
520+
]
521+
522+
for fragment in valid_fragments:
523+
self.assertTrue(is_purl_fragment(fragment), msg=fragment)
524+
525+
for fragment in invalid_fragments:
526+
self.assertFalse(is_purl_fragment(fragment), msg=fragment)
527+
505528
def test_utils_localized_datetime(self):
506529
self.assertIsNone(localized_datetime(None))
507530

dje/utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,18 @@ def is_purl_str(url, validate=False):
644644
return True
645645

646646

647+
def is_purl_fragment(string):
648+
"""
649+
Check if the given string could be a valid Package URL (PURL) or a recognizable
650+
fragment of it.
651+
652+
A valid PURL typically follows the format:
653+
`pkg://type/namespace/name@version?qualifiers#subpath`
654+
"""
655+
purl_connectors = ["pkg:", "/", "@", "?", "#"]
656+
return any(connector in string for connector in purl_connectors)
657+
658+
647659
def remove_empty_values(input_dict):
648660
"""
649661
Return a new dict not including empty value entries from `input_dict`.

product_portfolio/admin.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
from guardian.admin import GuardedModelAdminMixin
2424
from guardian.shortcuts import get_perms as guardian_get_perms
25+
from packageurl.contrib.django.utils import purl_to_lookups
2526

2627
from component_catalog.admin import AwesompleteAdminMixin
2728
from component_catalog.admin import BaseStatusAdmin
@@ -44,6 +45,7 @@
4445
from dje.list_display import AsURL
4546
from dje.permissions import assign_all_object_permissions
4647
from dje.permissions import get_limited_perms_for_model
48+
from dje.utils import is_purl_fragment
4749
from product_portfolio.filters import ComponentCompletenessListFilter
4850
from product_portfolio.forms import ProductAdminForm
4951
from product_portfolio.forms import ProductComponentAdminForm
@@ -842,7 +844,18 @@ class ProductDependencyAdmin(ProductRelatedAdminMixin):
842844
"resolved_to_package",
843845
]
844846
autocomplete_lookup_fields = {"fk": raw_id_fields}
845-
search_fields = ("path",)
847+
search_fields = (
848+
"dependency_uid",
849+
"for_package__type",
850+
"for_package__namespace",
851+
"for_package__name",
852+
"for_package__version",
853+
"resolved_to_package__type",
854+
"resolved_to_package__namespace",
855+
"resolved_to_package__name",
856+
"resolved_to_package__version",
857+
"declared_dependency",
858+
)
846859
list_filter = (
847860
("product", RelatedLookupListFilter),
848861
"is_runtime",
@@ -865,3 +878,24 @@ def get_queryset(self, request):
865878
"resolved_to_package__dataspace",
866879
)
867880
)
881+
882+
def get_search_results(self, request, queryset, search_term):
883+
"""Add searching on provided PackageURL identifier."""
884+
use_distinct = False
885+
886+
if is_purl_fragment(search_term):
887+
if lookups := purl_to_lookups(search_term, encode=True):
888+
purl_fields = ["for_package", "resolved_to_package"]
889+
890+
query = models.Q()
891+
for field in purl_fields:
892+
field_purl_lookup = models.Q()
893+
for key, value in lookups.items():
894+
field_purl_lookup &= models.Q(**{f"{field}__{key}": value})
895+
# Combine the AND conditions for each field with OR
896+
query |= field_purl_lookup
897+
898+
if results := queryset.filter(query):
899+
return results, use_distinct
900+
901+
return super().get_search_results(request, queryset, search_term)

0 commit comments

Comments
 (0)