Skip to content

Commit dfa74d7

Browse files
committed
Add a new "Package Set" tab to the Package details view #276
Signed-off-by: tdruez <tdruez@nexb.com>
1 parent 4c5576b commit dfa74d7

File tree

7 files changed

+140
-6
lines changed

7 files changed

+140
-6
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

+26-5
Original file line numberDiff line numberDiff line change
@@ -2616,17 +2616,12 @@ def update_from_purldb(self, user):
26162616
for field_name in [*hash_field_names, *identifier_fields]:
26172617
package_data.pop(field_name, None)
26182618

2619-
# try:
26202619
updated_fields = self.update_from_data(
26212620
user,
26222621
package_data,
26232622
override=False,
26242623
override_unknown=True,
26252624
)
2626-
# except IntegrityError as e:
2627-
# logger.error(f"[update_from_purldb] Skipping {self} due to IntegrityError: {e}")
2628-
# return []
2629-
26302625
return updated_fields
26312626

26322627
def update_from_scan(self, user):
@@ -2643,6 +2638,32 @@ def update_from_scan(self, user):
26432638
updated_fields = scancodeio.update_from_scan(package=self, user=user)
26442639
return updated_fields
26452640

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+
26462667

26472668
class PackageAssignedLicense(DataspacedModel):
26482669
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

+14
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)

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",

0 commit comments

Comments
 (0)