Skip to content

Commit 31a41c3

Browse files
authored
Add a new "Package Set" tab to the Package details view #276 (#305)
Signed-off-by: tdruez <tdruez@nexb.com>
1 parent e06fb5c commit 31a41c3

File tree

9 files changed

+253
-7
lines changed

9 files changed

+253
-7
lines changed

CHANGELOG.rst

+4
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ Release notes
149149
merged and kept for the data update.
150150
https://github.com/aboutcode-org/dejacode/issues/303
151151

152+
- Add a new "Package Set" tab to the Package details view.
153+
This tab displays related packages grouped by their normalized ("plain") Package URL.
154+
https://github.com/aboutcode-org/dejacode/issues/276
155+
152156
### Version 5.2.1
153157

154158
- Fix the models documentation navigation.

component_catalog/models.py

+109-5
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@
1818
from django.core.exceptions import ValidationError
1919
from django.core.validators import EMPTY_VALUES
2020
from django.db import models
21+
from django.db.models import Case
2122
from django.db.models import CharField
2223
from django.db.models import Count
2324
from django.db.models import Exists
25+
from django.db.models import F
2426
from django.db.models import OuterRef
27+
from django.db.models import Value
28+
from django.db.models import When
2529
from django.db.models.functions import Concat
2630
from django.dispatch import receiver
2731
from django.template.defaultfilters import filesizeformat
@@ -1651,6 +1655,65 @@ def __str__(self):
16511655
PACKAGE_URL_FIELDS = ["type", "namespace", "name", "version", "qualifiers", "subpath"]
16521656

16531657

1658+
def get_plain_package_url_expression():
1659+
"""
1660+
Return a Django expression to compute the "PLAIN" Package URL (PURL).
1661+
Return an empty string if the required `type` or `name` values are missing.
1662+
"""
1663+
plain_package_url = Concat(
1664+
Value("pkg:"),
1665+
F("type"),
1666+
Case(
1667+
When(namespace="", then=Value("")),
1668+
default=Concat(Value("/"), F("namespace")),
1669+
output_field=CharField(),
1670+
),
1671+
Value("/"),
1672+
F("name"),
1673+
Case(
1674+
When(version="", then=Value("")),
1675+
default=Concat(Value("@"), F("version")),
1676+
output_field=CharField(),
1677+
),
1678+
output_field=CharField(),
1679+
)
1680+
1681+
return Case(
1682+
When(type="", then=Value("")),
1683+
When(name="", then=Value("")),
1684+
default=plain_package_url,
1685+
output_field=CharField(),
1686+
)
1687+
1688+
1689+
def get_package_url_expression():
1690+
"""
1691+
Return a Django expression to compute the "FULL" Package URL (PURL).
1692+
Return an empty string if the required `type` or `name` values are missing.
1693+
"""
1694+
package_url = Concat(
1695+
get_plain_package_url_expression(),
1696+
Case(
1697+
When(qualifiers="", then=Value("")),
1698+
default=Concat(Value("?"), F("qualifiers")),
1699+
output_field=CharField(),
1700+
),
1701+
Case(
1702+
When(subpath="", then=Value("")),
1703+
default=Concat(Value("#"), F("subpath")),
1704+
output_field=CharField(),
1705+
),
1706+
output_field=CharField(),
1707+
)
1708+
1709+
return Case(
1710+
When(type="", then=Value("")),
1711+
When(name="", then=Value("")),
1712+
default=package_url,
1713+
output_field=CharField(),
1714+
)
1715+
1716+
16541717
class PackageQuerySet(PackageURLQuerySetMixin, VulnerabilityQuerySetMixin, DataspacedQuerySet):
16551718
def has_package_url(self):
16561719
"""Return objects with Package URL defined."""
@@ -1666,6 +1729,26 @@ def annotate_sortable_identifier(self):
16661729
sortable_identifier=Concat(*PACKAGE_URL_FIELDS, "filename", output_field=CharField())
16671730
)
16681731

1732+
def annotate_plain_package_url(self):
1733+
"""
1734+
Annotate the QuerySet with a computed 'plain' Package URL (PURL).
1735+
1736+
This plain PURL is a simplified version that includes only the core fields:
1737+
`type`, `namespace`, `name`, and `version`. It omits any qualifiers or
1738+
subpath components, providing a normalized and minimal representation
1739+
of the Package URL.
1740+
"""
1741+
return self.annotate(plain_purl=get_plain_package_url_expression())
1742+
1743+
def annotate_package_url(self):
1744+
"""
1745+
Annotate the QuerySet with a fully-computed Package URL (PURL).
1746+
1747+
This includes the core PURL fields (`type`, `namespace`, `name`, `version`)
1748+
as well as any qualifiers and subpath components.
1749+
"""
1750+
return self.annotate(purl=get_package_url_expression())
1751+
16691752
def only_rendering_fields(self):
16701753
"""Minimum requirements to render a Package element in the UI."""
16711754
return self.only(
@@ -2533,17 +2616,12 @@ def update_from_purldb(self, user):
25332616
for field_name in [*hash_field_names, *identifier_fields]:
25342617
package_data.pop(field_name, None)
25352618

2536-
# try:
25372619
updated_fields = self.update_from_data(
25382620
user,
25392621
package_data,
25402622
override=False,
25412623
override_unknown=True,
25422624
)
2543-
# except IntegrityError as e:
2544-
# logger.error(f"[update_from_purldb] Skipping {self} due to IntegrityError: {e}")
2545-
# return []
2546-
25472625
return updated_fields
25482626

25492627
def update_from_scan(self, user):
@@ -2560,6 +2638,32 @@ def update_from_scan(self, user):
25602638
updated_fields = scancodeio.update_from_scan(package=self, user=user)
25612639
return updated_fields
25622640

2641+
def get_related_packages_qs(self):
2642+
"""
2643+
Return a QuerySet of packages that are considered part of the same
2644+
"Package Set".
2645+
2646+
A "Package Set" consists of all packages that share the same "plain"
2647+
Package URL (PURL), meaning they have identical values for the following PURL
2648+
components:
2649+
`type`, `namespace`, `name`, and `version`.
2650+
The `qualifiers` and `subpath` components are ignored for this comparison.
2651+
"""
2652+
plain_package_url = self.plain_package_url
2653+
if not plain_package_url:
2654+
return None
2655+
2656+
return (
2657+
self.__class__.objects.scope(self.dataspace)
2658+
.for_package_url(plain_package_url, exact_match=True)
2659+
.order_by(
2660+
*PACKAGE_URL_FIELDS,
2661+
"filename",
2662+
"download_url",
2663+
)
2664+
.distinct()
2665+
)
2666+
25632667

25642668
class PackageAssignedLicense(DataspacedModel):
25652669
package = models.ForeignKey(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{% load i18n %}
2+
{% spaceless %}
3+
<table class="table table-bordered table-hover table-md text-break">
4+
<thead>
5+
<tr>
6+
<th>{% trans 'Package URL' %}</th>
7+
<th>{% trans 'Filename' %}</th>
8+
<th>{% trans 'Download URL' %}</th>
9+
<th>{% trans 'Concluded license' %}</th>
10+
</tr>
11+
</thead>
12+
<tbody>
13+
{% for package in values %}
14+
<tr data-uuid="{{ package.uuid }}">
15+
<td class="fw-bold">
16+
{% if package.uuid == object.uuid %}
17+
{{ package.package_url }}
18+
<div>
19+
<span class="badge bg-secondary">Current package</span>
20+
</div>
21+
{% else %}
22+
<a href="{{ package.get_absolute_url }}" target="_blank">
23+
{{ package.package_url }}
24+
</a>
25+
{% endif %}
26+
</td>
27+
<td>{{ package.filename|default_if_none:"" }}</td>
28+
<td>
29+
{% if package.download_url %}
30+
<i class="fa-solid fa-download me-1"></i>
31+
{{ package.download_url|urlize }}
32+
{% endif %}
33+
</td>
34+
<td>
35+
{{ package.license_expression_linked|default_if_none:"" }}
36+
</td>
37+
</tr>
38+
{% endfor %}
39+
</tbody>
40+
</table>
41+
{% endspaceless %}

component_catalog/tests/test_models.py

+43
Original file line numberDiff line numberDiff line change
@@ -2693,6 +2693,20 @@ def test_package_model_update_from_purldb_duplicate_exception(self, mock_get_pur
26932693
package_no_download_url.refresh_from_db()
26942694
self.assertEqual(purldb_entry["description"], package_no_download_url.description)
26952695

2696+
def test_package_model_get_related_packages_qs(self):
2697+
package_url = "pkg:pypi/django@5.0"
2698+
package1 = make_package(self.dataspace, package_url=package_url)
2699+
related_packages_qs = package1.get_related_packages_qs()
2700+
self.assertQuerySetEqual(related_packages_qs, [package1])
2701+
2702+
package2 = make_package(
2703+
self.dataspace,
2704+
package_url=package_url,
2705+
filename="Django-5.0.tar.gz",
2706+
)
2707+
related_packages_qs = package1.get_related_packages_qs()
2708+
self.assertQuerySetEqual(related_packages_qs, [package1, package2])
2709+
26962710
def test_package_model_vulnerability_queryset_mixin(self):
26972711
package1 = make_package(self.dataspace, is_vulnerable=True)
26982712
package2 = make_package(self.dataspace)
@@ -2709,3 +2723,32 @@ def test_vulnerability_mixin_is_vulnerable_property(self):
27092723
package2 = make_package(self.dataspace)
27102724
self.assertTrue(package1.is_vulnerable)
27112725
self.assertFalse(package2.is_vulnerable)
2726+
2727+
def test_package_queryset_has_package_url(self):
2728+
package1 = make_package(self.dataspace, package_url="pkg:pypi/django@5.0")
2729+
make_package(self.dataspace)
2730+
qs = Package.objects.has_package_url()
2731+
self.assertQuerySetEqual(qs, [package1])
2732+
2733+
def test_package_queryset_annotate_sortable_identifier(self):
2734+
package1 = make_package(self.dataspace, package_url="pkg:pypi/django@5.0")
2735+
package2 = make_package(self.dataspace)
2736+
qs = Package.objects.annotate_sortable_identifier()
2737+
self.assertEqual("pypidjango5.0", qs.get(pk=package1.pk).sortable_identifier)
2738+
self.assertEqual(package2.filename, qs.get(pk=package2.pk).sortable_identifier)
2739+
2740+
def test_package_queryset_annotate_package_url(self):
2741+
package_url = "pkg:pypi/django@5.0?qualifier=true#path"
2742+
package1 = make_package(self.dataspace, package_url=package_url)
2743+
package2 = make_package(self.dataspace)
2744+
qs = Package.objects.annotate_package_url()
2745+
self.assertEqual(package_url, qs.get(pk=package1.pk).purl)
2746+
self.assertEqual("", qs.get(pk=package2.pk).purl)
2747+
2748+
def test_package_queryset_annotate_plain_package_url(self):
2749+
package_url = "pkg:pypi/django@5.0?qualifier=true#path"
2750+
package1 = make_package(self.dataspace, package_url=package_url)
2751+
package2 = make_package(self.dataspace)
2752+
qs = Package.objects.annotate_plain_package_url()
2753+
self.assertEqual("pkg:pypi/django@5.0", qs.get(pk=package1.pk).plain_purl)
2754+
self.assertEqual("", qs.get(pk=package2.pk).plain_purl)

component_catalog/tests/test_views.py

+34-1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from component_catalog.models import ComponentType
4040
from component_catalog.models import Package
4141
from component_catalog.models import Subcomponent
42+
from component_catalog.tests import make_package
4243
from component_catalog.views import ComponentAddView
4344
from component_catalog.views import ComponentListView
4445
from component_catalog.views import PackageTabScanView
@@ -1239,8 +1240,21 @@ def test_package_list_multi_send_about_files_view(self):
12391240
)
12401241

12411242
def test_package_details_view_num_queries(self):
1243+
# Create a Package Set
1244+
package_url = "pkg:pypi/django@5.0"
1245+
self.package1.set_package_url(package_url)
1246+
self.package1.save()
1247+
license_expression = "{} AND {}".format(self.license1.key, self.license2.key)
1248+
make_package(self.dataspace, package_url=package_url, license_expression=license_expression)
1249+
make_package(
1250+
self.dataspace,
1251+
package_url=package_url,
1252+
license_expression=license_expression,
1253+
filename="Django-5.0.tar.gz",
1254+
)
1255+
12421256
self.client.login(username=self.super_user.username, password="secret")
1243-
with self.assertNumQueries(28):
1257+
with self.assertNumQueries(30):
12441258
self.client.get(self.package1.get_absolute_url())
12451259

12461260
def test_package_details_view_content(self):
@@ -1332,6 +1346,25 @@ def test_package_details_view_aboutcode_tab(self):
13321346
self.assertContains(response, "This tab renders a preview of the AboutCode files")
13331347
self.assertContains(response, "about_resource: package1")
13341348

1349+
def test_package_details_view_tab_package_set(self):
1350+
self.client.login(username=self.super_user.username, password="secret")
1351+
1352+
package_url = "pkg:pypi/django@5.0"
1353+
package1 = make_package(self.dataspace, package_url=package_url)
1354+
details_url = package1.get_absolute_url()
1355+
1356+
expected = 'id="tab_package-set-tab"'
1357+
response = self.client.get(details_url)
1358+
self.assertNotContains(response, expected)
1359+
1360+
make_package(
1361+
self.dataspace,
1362+
package_url=package_url,
1363+
filename="Django-5.0.tar.gz",
1364+
)
1365+
response = self.client.get(details_url)
1366+
self.assertContains(response, expected)
1367+
13351368
def test_package_list_view_add_to_product(self):
13361369
user = create_user("user", self.dataspace)
13371370
self.client.login(username=user.username, password="secret")

component_catalog/views.py

+20
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
from component_catalog.license_expression_dje import get_dataspace_licensing
6666
from component_catalog.license_expression_dje import get_formatted_expression
6767
from component_catalog.license_expression_dje import get_unique_license_keys
68+
from component_catalog.models import PACKAGE_URL_FIELDS
6869
from component_catalog.models import Component
6970
from component_catalog.models import Package
7071
from component_catalog.models import PackageAlreadyExistsWarning
@@ -1144,6 +1145,7 @@ class PackageDetailsView(
11441145
],
11451146
},
11461147
"product_usage": {},
1148+
"package_set": {},
11471149
"activity": {},
11481150
"external_references": {
11491151
"fields": [
@@ -1297,6 +1299,24 @@ def tab_others(self):
12971299

12981300
return {"fields": fields}
12991301

1302+
def tab_package_set(self):
1303+
related_packages_qs = self.object.get_related_packages_qs()
1304+
if related_packages_qs is None:
1305+
return
1306+
1307+
related_packages = related_packages_qs.only(
1308+
"uuid",
1309+
*PACKAGE_URL_FIELDS,
1310+
"filename",
1311+
"download_url",
1312+
"license_expression",
1313+
"dataspace__name",
1314+
).prefetch_related("licenses")
1315+
1316+
template = "component_catalog/tabs/tab_package_set.html"
1317+
if len(related_packages) > 1:
1318+
return {"fields": [(None, related_packages, None, template)]}
1319+
13001320
def tab_product_usage(self):
13011321
user = self.request.user
13021322
# Product data in Package views are not available to AnonymousUser for security reason

dje/tests/test_permissions.py

+1
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def test_permissions_get_all_tabsets(self):
110110
"others",
111111
"components",
112112
"product_usage",
113+
"package_set",
113114
"activity",
114115
"external_references",
115116
"usage_policy",

setup.cfg

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ install_requires =
5151
wheel==0.45.1
5252
pip==25.0.1
5353
# Django
54-
Django==5.1.8
54+
Django==5.1.9
5555
asgiref==3.8.1
5656
typing_extensions==4.12.2
5757
sqlparse==0.5.3

0 commit comments

Comments
 (0)