Skip to content

Commit 1a8b4d1

Browse files
committed
Prototype implementation of Package set #276
Signed-off-by: tdruez <tdruez@nexb.com>
1 parent e3f3fbb commit 1a8b4d1

File tree

6 files changed

+252
-0
lines changed

6 files changed

+252
-0
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# DejaCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: AGPL-3.0-only
5+
# See https://github.com/aboutcode-org/dejacode for support or download.
6+
# See https://aboutcode.org for more information about AboutCode FOSS projects.
7+
#
8+
9+
10+
from collections import Counter
11+
12+
from component_catalog.models import Package
13+
from dje.management.commands import DataspacedCommand
14+
15+
16+
class Command(DataspacedCommand):
17+
help = "Create PackageSet relationships from existing packages."
18+
19+
def add_arguments(self, parser):
20+
super().add_arguments(parser)
21+
# parser.add_argument("username", help="Your username, for History entries.")
22+
parser.add_argument(
23+
"--last_modified_date",
24+
help=(
25+
"Limit the packages batch to objects created/modified after that date. "
26+
'Format: "YYYY-MM-DD"'
27+
),
28+
)
29+
30+
def handle(self, *args, **options):
31+
super().handle(*args, **options)
32+
33+
qs = Package.objects.scope(self.dataspace).has_package_url()
34+
plain_purl_list = (
35+
qs.annotate_plain_purl().values_list("plain_purl", flat=True).order_by("plain_purl")
36+
)
37+
duplicates = [
38+
purl for purl, count in Counter(plain_purl_list).items() if count > 1 and purl
39+
]
40+
print(duplicates)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.1.8 on 2025-04-29 14:49
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('component_catalog', '0011_alter_component_owner'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='package',
15+
name='package_content',
16+
field=models.IntegerField(choices=[(1, 'curation'), (2, 'patch'), (3, 'source_repo'), (4, 'source_archive'), (5, 'binary'), (6, 'test'), (7, 'doc')], help_text='Content of this package as one of: curation, patch, source_repo, source_archive, binary, test, doc', null=True),
17+
),
18+
]

component_catalog/models.py

Lines changed: 130 additions & 0 deletions
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
@@ -1647,6 +1651,86 @@ def __str__(self):
16471651
return self.label
16481652

16491653

1654+
class PackageContentFieldMixin(models.Model):
1655+
# Keep in sync with purldb/packagedb.models.PackageContentType
1656+
class PackageTypes(models.IntegerChoices):
1657+
CURATION = 1, "curation"
1658+
PATCH = 2, "patch"
1659+
SOURCE_REPO = 3, "source_repo"
1660+
SOURCE_ARCHIVE = 4, "source_archive"
1661+
BINARY = 5, "binary"
1662+
TEST = 6, "test"
1663+
DOC = 7, "doc"
1664+
1665+
package_content = models.IntegerField(
1666+
null=True,
1667+
choices=PackageTypes.choices,
1668+
help_text=_("Content of this package as one of: {}".format(", ".join(PackageTypes.labels))),
1669+
)
1670+
1671+
class Meta:
1672+
abstract = True
1673+
1674+
1675+
def get_plain_package_url_expression():
1676+
"""
1677+
Return a Django expression to compute the "PLAIN" Package URL (purl).
1678+
Return an empty string if the required `type` or `name` values are missing.
1679+
"""
1680+
plain_package_url = Concat(
1681+
Value("pkg:"),
1682+
F("type"),
1683+
Case(
1684+
When(namespace="", then=Value("")),
1685+
default=Concat(Value("/"), F("namespace")),
1686+
output_field=CharField(),
1687+
),
1688+
Value("/"),
1689+
F("name"),
1690+
Case(
1691+
When(version="", then=Value("")),
1692+
default=Concat(Value("@"), F("version")),
1693+
output_field=CharField(),
1694+
),
1695+
output_field=CharField(),
1696+
)
1697+
1698+
return Case(
1699+
When(type="", then=Value("")),
1700+
When(name="", then=Value("")),
1701+
default=plain_package_url,
1702+
output_field=CharField(),
1703+
)
1704+
1705+
1706+
def get_package_url_expression():
1707+
"""
1708+
Return a Django expression to compute the "FULL" Package URL (purl).
1709+
Return an empty string if the required `type` or `name` values are missing.
1710+
"""
1711+
package_url = Concat(
1712+
get_plain_package_url_expression(),
1713+
Case(
1714+
When(qualifiers="", then=Value("")),
1715+
default=Concat(Value("?"), F("qualifiers")),
1716+
output_field=CharField(),
1717+
),
1718+
Case(
1719+
When(subpath="", then=Value("")),
1720+
default=Concat(Value("#"), F("subpath")),
1721+
output_field=CharField(),
1722+
),
1723+
output_field=CharField(),
1724+
)
1725+
1726+
return Case(
1727+
When(type="", then=Value("")),
1728+
When(name="", then=Value("")),
1729+
default=package_url,
1730+
output_field=CharField(),
1731+
)
1732+
1733+
16501734
PACKAGE_URL_FIELDS = ["type", "namespace", "name", "version", "qualifiers", "subpath"]
16511735

16521736

@@ -1665,6 +1749,13 @@ def annotate_sortable_identifier(self):
16651749
sortable_identifier=Concat(*PACKAGE_URL_FIELDS, "filename", output_field=CharField())
16661750
)
16671751

1752+
def annotate_plain_purl(self):
1753+
"""
1754+
Annotate the QuerySet with a database computed "PLAIN" PURL value that combines
1755+
the base Package URL fields: `type, `namespace`, `name`, `version`.
1756+
"""
1757+
return self.annotate(plain_purl=get_plain_package_url_expression())
1758+
16681759
def only_rendering_fields(self):
16691760
"""Minimum requirements to render a Package element in the UI."""
16701761
return self.only(
@@ -1707,6 +1798,7 @@ class Package(
17071798
URLFieldsMixin,
17081799
HashFieldsMixin,
17091800
PackageURLMixin,
1801+
PackageContentFieldMixin,
17101802
DataspacedModel,
17111803
):
17121804
filename = models.CharField(
@@ -1850,6 +1942,11 @@ class Package(
18501942
related_name="affected_%(class)ss",
18511943
help_text=_("Vulnerabilities affecting this object."),
18521944
)
1945+
# related_packages = models.ManyToManyField(
1946+
# to="component_catalog.PackageSet",
1947+
# # related_name="packages",
1948+
# help_text=_("A set representing the Package sets this Package is a member of."),
1949+
# )
18531950

18541951
objects = DataspacedManager.from_queryset(PackageQuerySet)()
18551952

@@ -2538,6 +2635,39 @@ class Meta:
25382635
unique_together = (("package", "vulnerability"), ("dataspace", "uuid"))
25392636

25402637

2638+
# class PackageSet(DataspacedModel):
2639+
# """A group of related Packages by their plain PURL."""
2640+
#
2641+
# type = models.CharField(
2642+
# max_length=16,
2643+
# blank=True,
2644+
# )
2645+
# namespace = models.CharField(
2646+
# max_length=255,
2647+
# blank=True,
2648+
# )
2649+
# name = models.CharField(
2650+
# max_length=100,
2651+
# blank=True,
2652+
# )
2653+
# version = models.CharField(
2654+
# max_length=100,
2655+
# blank=True,
2656+
# )
2657+
#
2658+
# def add_to_package_set(self, package):
2659+
# self.packages.add(package)
2660+
#
2661+
# def get_package_set_members(self):
2662+
# """Return related Packages"""
2663+
# return self.packages.order_by(
2664+
# "package_content",
2665+
# )
2666+
#
2667+
# class Meta:
2668+
# unique_together = [("dataspace", "uuid")]
2669+
2670+
25412671
class ComponentAffectedByVulnerability(AffectedByVulnerabilityRelationship):
25422672
component = models.ForeignKey(
25432673
to="component_catalog.Component",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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 'Download URL' %}</th>
8+
<th>{% trans 'Filename' %}</th>
9+
</tr>
10+
</thead>
11+
<tbody>
12+
{% for package in values %}
13+
<tr class="{% cycle 'odd' '' %}">
14+
<td class="fw-bold">
15+
{% if package.uuid == object.uuid %}
16+
{{ package.package_url }}
17+
<div>
18+
<span class="badge bg-secondary">Current package</span>
19+
</div>
20+
{% else %}
21+
<a href="{{ package.get_absolute_url }}" target="_blank">{{ package.package_url }}</a>
22+
{% endif %}
23+
</td>
24+
<td>{{ package.download_url|default_if_none:"" }}</td>
25+
<td>{{ package.filename|default_if_none:"" }}</td>
26+
</tr>
27+
{% endfor %}
28+
</tbody>
29+
</table>
30+
{% endspaceless %}

component_catalog/views.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,7 @@ class PackageDetailsView(
11431143
"components",
11441144
],
11451145
},
1146+
"package_set": {},
11461147
"product_usage": {},
11471148
"activity": {},
11481149
"external_references": {
@@ -1297,6 +1298,14 @@ def tab_others(self):
12971298

12981299
return {"fields": fields}
12991300

1301+
def tab_package_set(self):
1302+
plain_url = self.object.plain_package_url
1303+
related_packages = self.get_queryset().for_package_url(plain_url)
1304+
1305+
template = "component_catalog/tabs/tab_package_set.html"
1306+
if len(related_packages) > 1:
1307+
return {"fields": [(None, related_packages, None, template)]}
1308+
13001309
def tab_product_usage(self):
13011310
user = self.request.user
13021311
# Product data in Package views are not available to AnonymousUser for security reason
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 5.1.8 on 2025-04-29 14:49
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('component_catalog', '0011_alter_component_owner'),
11+
('product_portfolio', '0012_alter_scancodeproject_status_and_more'),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name='productdependency',
17+
name='for_package',
18+
field=models.ForeignKey(blank=True, help_text='The package that declares this dependency.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='declared_dependencies', to='component_catalog.package'),
19+
),
20+
migrations.AlterField(
21+
model_name='productdependency',
22+
name='resolved_to_package',
23+
field=models.ForeignKey(blank=True, help_text='The resolved package for this dependency. If empty, it indicates the dependency is unresolved.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_from_dependencies', to='component_catalog.package'),
24+
),
25+
]

0 commit comments

Comments
 (0)