Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ Release notes
simplicity, and readability.
https://github.com/aboutcode-org/dejacode/issues/241

- Refine the way the PURL fragments are handled in searches.
https://github.com/aboutcode-org/dejacode/issues/286

### Version 5.2.1

- Fix the models documentation navigation.
Expand Down
7 changes: 4 additions & 3 deletions component_catalog/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
from dje.templatetags.dje_tags import urlize_target_blank
from dje.utils import CHANGELIST_LINK_TEMPLATE
from dje.utils import get_instance_from_referer
from dje.utils import is_purl_fragment
from license_library.models import License
from reporting.filters import ReportingQueryListFilter

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

is_purl = "/" in search_term
if is_purl:
return queryset.for_package_url(search_term), use_distinct
if is_purl_fragment(search_term):
if results := queryset.for_package_url(search_term):
return results, use_distinct

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

Expand Down
7 changes: 4 additions & 3 deletions component_catalog/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from dje.filters import HasRelationFilter
from dje.filters import MatchOrderedSearchFilter
from dje.filters import RelatedLookupListFilter
from dje.utils import is_purl_fragment
from dje.widgets import BootstrapSelectMultipleWidget
from dje.widgets import DropDownRightWidget
from dje.widgets import SortDropDownWidget
Expand Down Expand Up @@ -183,9 +184,9 @@ def filter(self, qs, value):
if not value:
return qs

is_purl = "/" in value
if is_purl:
return qs.for_package_url(value)
if is_purl_fragment(value):
if results := qs.for_package_url(value):
return results

return super().filter(qs, value)

Expand Down
13 changes: 13 additions & 0 deletions component_catalog/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from component_catalog.models import Package
from component_catalog.models import PackageAssignedLicense
from component_catalog.models import Subcomponent
from component_catalog.tests import make_package
from dje.copier import copy_object
from dje.filters import DataspaceFilter
from dje.models import Dataspace
Expand Down Expand Up @@ -1650,6 +1651,8 @@ def test_package_changelist_advanced_search_on_protocol(self):
p2 = Package.objects.create(
filename="p2", download_url="https://url.com/p2.zip", dataspace=self.dataspace1
)
package_url = "pkg:pypi/django@5.0"
package3 = make_package(self.dataspace1, package_url)

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

response = self.client.get(changelist_url + f"?q={package_url}")
self.assertEqual(200, response.status_code)
self.assertEqual(1, response.context_data["cl"].result_count)
self.assertIn(package3, response.context_data["cl"].result_list)

response = self.client.get(changelist_url + "?q=pypi/django")
self.assertEqual(200, response.status_code)
self.assertEqual(1, response.context_data["cl"].result_count)
self.assertIn(package3, response.context_data["cl"].result_list)

def test_package_changelist_set_policy_action_proper(self):
self.client.login(username=self.user.username, password="secret")
p1 = Package.objects.create(filename="p1.zip", dataspace=self.dataspace1)
Expand Down
15 changes: 8 additions & 7 deletions component_catalog/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,41 +642,42 @@ def test_component_catalog_list_view_sort_keep_active_filters(self):
# Sort filter
self.assertContains(
response,
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort" aria-label="Sort">',
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort ms-1" aria-label="Sort">',
)
# Sort in the headers
self.assertContains(
response,
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort" aria-label="Sort">',
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort ms-1" aria-label="Sort">',
)
self.assertContains(
response,
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort" '
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort ms-1" '
'aria-label="Sort">',
)

data["sort"] = "name"
response = self.client.get(url, data=data)
self.assertContains(
response,
'<a href="?q=a&amp;licenses=license1&sort=-name" class="sort active" '
'<a href="?q=a&amp;licenses=license1&sort=-name" class="sort active ms-1" '
'aria-label="Sort">',
)
self.assertContains(
response,
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort" '
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort ms-1" '
'aria-label="Sort">',
)

data["sort"] = "-name"
response = self.client.get(url, data=data)
self.assertContains(
response,
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort active" aria-label="Sort">',
'<a href="?q=a&amp;licenses=license1&sort=name" class="sort active ms-1" '
'aria-label="Sort">',
)
self.assertContains(
response,
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort" '
'<a href="?q=a&amp;licenses=license1&sort=primary_language" class="sort ms-1" '
'aria-label="Sort">',
)

Expand Down
5 changes: 5 additions & 0 deletions dje/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,11 @@ def set_reference_link(self, request):
params = f"?{DataspaceFilter.parameter_name}={reference_dataspace.id}"
self.reference_params = params

def get_search_fields_for_hint_display(self):
if not self.search_fields:
return []
return tuple(set(field.split("__")[0] for field in self.search_fields))


class DataspacedAdmin(
DataspacedFKMixin,
Expand Down
4 changes: 2 additions & 2 deletions dje/templates/admin/change_list_extended.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ <h1>Browse {{ cl.opts.verbose_name_plural|capfirst }}</h1>
{% if cl.search_fields %}
$("#grp-changelist-search-form")
.attr("style", "width: 90%;")
.attr("data-hint", "Search fields: {{ cl.search_fields|join:', ' }}")
.addClass("hint--bottom");
.attr("data-hint", "Search fields: {{ cl.get_search_fields_for_hint_display|join:', ' }}")
.addClass("hint--bottom hint--large");
{% endif %}

// Opens actions listed in `target_blank_actions` in a new tab.
Expand Down
23 changes: 23 additions & 0 deletions dje/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from dje.utils import get_zipfile
from dje.utils import group_by_name_version
from dje.utils import is_available
from dje.utils import is_purl_fragment
from dje.utils import is_purl_str
from dje.utils import localized_datetime
from dje.utils import merge_relations
Expand Down Expand Up @@ -502,6 +503,28 @@ def test_utils_is_purl_str(self):
self.assertTrue(is_purl_str("pkg:npm/is-npm@1.0.0"))
self.assertTrue(is_purl_str("pkg:npm/is-npm@1.0.0", validate=True))

def test_utils_is_purl_fragment(self):
valid_fragments = [
"pkg:npm/package@1.0.0", # Valid full PURL
"npm/package@1.0.0", # PURL without pkg: prefix
"npm/type", # Fragment with type and namespace
"name@version", # Fragment with name and version
"namespace/name", # Fragment with namespace and name
"npm/package", # Type and package name
"package@1.0.0", # Name and version
]

invalid_fragments = [
"package", # Just the package name
"package 1.0.0", # No connector
]

for fragment in valid_fragments:
self.assertTrue(is_purl_fragment(fragment), msg=fragment)

for fragment in invalid_fragments:
self.assertFalse(is_purl_fragment(fragment), msg=fragment)

def test_utils_localized_datetime(self):
self.assertIsNone(localized_datetime(None))

Expand Down
12 changes: 12 additions & 0 deletions dje/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,18 @@ def is_purl_str(url, validate=False):
return True


def is_purl_fragment(string):
"""
Check if the given string could be a valid Package URL (PURL) or a recognizable
fragment of it.

A valid PURL typically follows the format:
`pkg://type/namespace/name@version?qualifiers#subpath`
"""
purl_connectors = ["pkg:", "/", "@", "?", "#"]
return any(connector in string for connector in purl_connectors)


def remove_empty_values(input_dict):
"""
Return a new dict not including empty value entries from `input_dict`.
Expand Down
36 changes: 35 additions & 1 deletion product_portfolio/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from guardian.admin import GuardedModelAdminMixin
from guardian.shortcuts import get_perms as guardian_get_perms
from packageurl.contrib.django.utils import purl_to_lookups

from component_catalog.admin import AwesompleteAdminMixin
from component_catalog.admin import BaseStatusAdmin
Expand All @@ -44,6 +45,7 @@
from dje.list_display import AsURL
from dje.permissions import assign_all_object_permissions
from dje.permissions import get_limited_perms_for_model
from dje.utils import is_purl_fragment
from product_portfolio.filters import ComponentCompletenessListFilter
from product_portfolio.forms import ProductAdminForm
from product_portfolio.forms import ProductComponentAdminForm
Expand Down Expand Up @@ -842,7 +844,18 @@ class ProductDependencyAdmin(ProductRelatedAdminMixin):
"resolved_to_package",
]
autocomplete_lookup_fields = {"fk": raw_id_fields}
search_fields = ("path",)
search_fields = (
"dependency_uid",
"for_package__type",
"for_package__namespace",
"for_package__name",
"for_package__version",
"resolved_to_package__type",
"resolved_to_package__namespace",
"resolved_to_package__name",
"resolved_to_package__version",
"declared_dependency",
)
list_filter = (
("product", RelatedLookupListFilter),
"is_runtime",
Expand All @@ -865,3 +878,24 @@ def get_queryset(self, request):
"resolved_to_package__dataspace",
)
)

def get_search_results(self, request, queryset, search_term):
"""Add searching on provided PackageURL identifier."""
use_distinct = False

if is_purl_fragment(search_term):
if lookups := purl_to_lookups(search_term, encode=True):
purl_fields = ["for_package", "resolved_to_package"]

query = models.Q()
for field in purl_fields:
field_purl_lookup = models.Q()
for key, value in lookups.items():
field_purl_lookup &= models.Q(**{f"{field}__{key}": value})
# Combine the AND conditions for each field with OR
query |= field_purl_lookup

if results := queryset.filter(query):
return results, use_distinct

return super().get_search_results(request, queryset, search_term)
14 changes: 14 additions & 0 deletions product_portfolio/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

import uuid

from component_catalog.models import Component
from component_catalog.models import Package
from component_catalog.tests import make_component
from component_catalog.tests import make_package
from dje.tests import make_string
from product_portfolio.models import Product
from product_portfolio.models import ProductComponent
from product_portfolio.models import ProductDependency
from product_portfolio.models import ProductItemPurpose
from product_portfolio.models import ProductPackage

Expand Down Expand Up @@ -74,3 +77,14 @@ def make_product_item_purpose(dataspace, **data):
dataspace=dataspace,
**data,
)


def make_product_dependency(product, **data):
if "dependency_uid" not in data:
data["dependency_uid"] = str(uuid.uuid4())

return ProductDependency.objects.create(
product=product,
dataspace=product.dataspace,
**data,
)
57 changes: 57 additions & 0 deletions product_portfolio/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from component_catalog.models import Component
from component_catalog.models import Package
from component_catalog.tests import make_package
from dje.filters import DataspaceFilter
from dje.models import Dataspace
from dje.tests import create_superuser
Expand All @@ -21,6 +22,7 @@
from product_portfolio.models import Product
from product_portfolio.models import ProductComponent
from product_portfolio.models import ProductPackage
from product_portfolio.tests import make_product_dependency


class ProductPortfolioAdminsTestCase(TestCase):
Expand Down Expand Up @@ -482,3 +484,58 @@ def test_codebaseresource_admin_changeform_lookup_autocomplete(self):
response = self.client.get(url)
self.assertEqual(200, response.status_code)
self.assertEqual(expected, response.content)

def test_product_dependencies_changelist_search(self):
self.client.login(username=self.user.username, password="secret")
changelist_url = reverse("admin:product_portfolio_productdependency_changelist")

package1 = make_package(self.dataspace, "pkg:pypi/django@5.0")
package2 = make_package(self.dataspace, "pkg:pypi/dep@1.0")

dependency1 = make_product_dependency(self.product1, for_package=package1)
dependency2 = make_product_dependency(
self.product1, for_package=package1, resolved_to_package=package2
)

response = self.client.get(changelist_url)
self.assertEqual(2, response.context_data["cl"].result_count)

response = self.client.get(changelist_url + "?q=django")
self.assertEqual(2, response.context_data["cl"].result_count)

response = self.client.get(changelist_url + "?q=pkg:pypi/django@5.0")
self.assertEqual(2, response.context_data["cl"].result_count)
self.assertIn(dependency1, response.context_data["cl"].result_list)
self.assertIn(dependency2, response.context_data["cl"].result_list)

response = self.client.get(changelist_url + "?q=dep")
self.assertEqual(1, response.context_data["cl"].result_count)
self.assertIn(dependency2, response.context_data["cl"].result_list)

response = self.client.get(changelist_url + "?q=pkg:pypi/dep@1.0")
self.assertEqual(1, response.context_data["cl"].result_count)
self.assertIn(dependency2, response.context_data["cl"].result_list)

response = self.client.get(changelist_url + "?q=pypi/dep")
self.assertEqual(1, response.context_data["cl"].result_count)
self.assertIn(dependency2, response.context_data["cl"].result_list)

def test_product_dependencies_add_view(self):
self.client.login(username=self.user.username, password="secret")
url = reverse("admin:product_portfolio_productdependency_add")
package2 = make_package(self.dataspace, "pkg:pypi/dep@1.0")
data = {
"product": self.product1.pk,
"dependency_uid": "a7e28b42-2212-456e-9215-9b3760db32c3",
"for_package": self.package1.pk,
"resolved_to_package": package2.pk,
"is_runtime": "on",
"is_pinned": "on",
"is_direct": "on",
}

response = self.client.post(url, data, follow=True)
self.assertContains(response, "was added successfully")
dependency = self.product1.dependencies.get()
self.assertEqual(self.package1, dependency.for_package)
self.assertEqual(package2, dependency.resolved_to_package)
Loading