diff --git a/component_catalog/management/commands/makepackageset.py b/component_catalog/management/commands/makepackageset.py new file mode 100644 index 00000000..21635227 --- /dev/null +++ b/component_catalog/management/commands/makepackageset.py @@ -0,0 +1,40 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# DejaCode is a trademark of nexB Inc. +# SPDX-License-Identifier: AGPL-3.0-only +# See https://github.com/aboutcode-org/dejacode for support or download. +# See https://aboutcode.org for more information about AboutCode FOSS projects. +# + + +from collections import Counter + +from component_catalog.models import Package +from dje.management.commands import DataspacedCommand + + +class Command(DataspacedCommand): + help = "Create PackageSet relationships from existing packages." + + def add_arguments(self, parser): + super().add_arguments(parser) + # parser.add_argument("username", help="Your username, for History entries.") + parser.add_argument( + "--last_modified_date", + help=( + "Limit the packages batch to objects created/modified after that date. " + 'Format: "YYYY-MM-DD"' + ), + ) + + def handle(self, *args, **options): + super().handle(*args, **options) + + qs = Package.objects.scope(self.dataspace).has_package_url() + plain_purl_list = ( + qs.annotate_plain_purl().values_list("plain_purl", flat=True).order_by("plain_purl") + ) + duplicates = [ + purl for purl, count in Counter(plain_purl_list).items() if count > 1 and purl + ] + print(duplicates) diff --git a/component_catalog/migrations/0012_package_package_content.py b/component_catalog/migrations/0012_package_package_content.py new file mode 100644 index 00000000..eb72c82d --- /dev/null +++ b/component_catalog/migrations/0012_package_package_content.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-29 14:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('component_catalog', '0011_alter_component_owner'), + ] + + operations = [ + migrations.AddField( + model_name='package', + name='package_content', + 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), + ), + ] diff --git a/component_catalog/models.py b/component_catalog/models.py index 157c3661..ea572659 100644 --- a/component_catalog/models.py +++ b/component_catalog/models.py @@ -18,10 +18,14 @@ from django.core.exceptions import ValidationError from django.core.validators import EMPTY_VALUES from django.db import models +from django.db.models import Case from django.db.models import CharField from django.db.models import Count from django.db.models import Exists +from django.db.models import F from django.db.models import OuterRef +from django.db.models import Value +from django.db.models import When from django.db.models.functions import Concat from django.dispatch import receiver from django.template.defaultfilters import filesizeformat @@ -1648,6 +1652,86 @@ def __str__(self): return self.label +class PackageContentFieldMixin(models.Model): + # Keep in sync with purldb/packagedb.models.PackageContentType + class PackageTypes(models.IntegerChoices): + CURATION = 1, "curation" + PATCH = 2, "patch" + SOURCE_REPO = 3, "source_repo" + SOURCE_ARCHIVE = 4, "source_archive" + BINARY = 5, "binary" + TEST = 6, "test" + DOC = 7, "doc" + + package_content = models.IntegerField( + null=True, + choices=PackageTypes.choices, + help_text=_("Content of this package as one of: {}".format(", ".join(PackageTypes.labels))), + ) + + class Meta: + abstract = True + + +def get_plain_package_url_expression(): + """ + Return a Django expression to compute the "PLAIN" Package URL (purl). + Return an empty string if the required `type` or `name` values are missing. + """ + plain_package_url = Concat( + Value("pkg:"), + F("type"), + Case( + When(namespace="", then=Value("")), + default=Concat(Value("/"), F("namespace")), + output_field=CharField(), + ), + Value("/"), + F("name"), + Case( + When(version="", then=Value("")), + default=Concat(Value("@"), F("version")), + output_field=CharField(), + ), + output_field=CharField(), + ) + + return Case( + When(type="", then=Value("")), + When(name="", then=Value("")), + default=plain_package_url, + output_field=CharField(), + ) + + +def get_package_url_expression(): + """ + Return a Django expression to compute the "FULL" Package URL (purl). + Return an empty string if the required `type` or `name` values are missing. + """ + package_url = Concat( + get_plain_package_url_expression(), + Case( + When(qualifiers="", then=Value("")), + default=Concat(Value("?"), F("qualifiers")), + output_field=CharField(), + ), + Case( + When(subpath="", then=Value("")), + default=Concat(Value("#"), F("subpath")), + output_field=CharField(), + ), + output_field=CharField(), + ) + + return Case( + When(type="", then=Value("")), + When(name="", then=Value("")), + default=package_url, + output_field=CharField(), + ) + + PACKAGE_URL_FIELDS = ["type", "namespace", "name", "version", "qualifiers", "subpath"] @@ -1666,6 +1750,13 @@ def annotate_sortable_identifier(self): sortable_identifier=Concat(*PACKAGE_URL_FIELDS, "filename", output_field=CharField()) ) + def annotate_plain_purl(self): + """ + Annotate the QuerySet with a database computed "PLAIN" PURL value that combines + the base Package URL fields: `type, `namespace`, `name`, `version`. + """ + return self.annotate(plain_purl=get_plain_package_url_expression()) + def only_rendering_fields(self): """Minimum requirements to render a Package element in the UI.""" return self.only( @@ -1708,6 +1799,7 @@ class Package( URLFieldsMixin, HashFieldsMixin, PackageURLMixin, + PackageContentFieldMixin, DataspacedModel, ): filename = models.CharField( @@ -1851,6 +1943,11 @@ class Package( related_name="affected_%(class)ss", help_text=_("Vulnerabilities affecting this object."), ) + # related_packages = models.ManyToManyField( + # to="component_catalog.PackageSet", + # # related_name="packages", + # help_text=_("A set representing the Package sets this Package is a member of."), + # ) objects = DataspacedManager.from_queryset(PackageQuerySet)() diff --git a/component_catalog/templates/component_catalog/tabs/tab_package_set.html b/component_catalog/templates/component_catalog/tabs/tab_package_set.html new file mode 100644 index 00000000..8cfe96d8 --- /dev/null +++ b/component_catalog/templates/component_catalog/tabs/tab_package_set.html @@ -0,0 +1,32 @@ +{% load i18n %} +{% spaceless %} + + + + + + + + + + + {% for package in values %} + + + + + + + {% endfor %} + +
{% trans 'Package URL' %}{% trans 'Filename' %}{% trans 'Download URL' %}{% trans 'Concluded license' %}
+ {% if package.uuid == object.uuid %} + {{ package.package_url }} +
+ Current package +
+ {% else %} + {{ package.package_url }} + {% endif %} +
{{ package.filename|default_if_none:"" }}{{ package.download_url|default_if_none:"" }}{{ package.license_expression|default_if_none:"" }}
+{% endspaceless %} \ No newline at end of file diff --git a/component_catalog/views.py b/component_catalog/views.py index f3a8c146..e44e9b2f 100644 --- a/component_catalog/views.py +++ b/component_catalog/views.py @@ -67,6 +67,7 @@ from component_catalog.license_expression_dje import get_unique_license_keys from component_catalog.models import Component from component_catalog.models import Package +from component_catalog.models import PACKAGE_URL_FIELDS from component_catalog.models import PackageAlreadyExistsWarning from component_catalog.models import Subcomponent from dejacode_toolkit.download import DataCollectionException @@ -1143,6 +1144,7 @@ class PackageDetailsView( "components", ], }, + "package_set": {}, "product_usage": {}, "activity": {}, "external_references": { @@ -1297,6 +1299,31 @@ def tab_others(self): return {"fields": fields} + def tab_package_set(self): + plain_url = self.object.plain_package_url + related_packages = ( + self.model.objects.scope(self.object.dataspace) + .for_package_url(plain_url) + .only( + "uuid", + *PACKAGE_URL_FIELDS, + "filename", + "download_url", + "license_expression", + "dataspace__name", + ) + .order_by( + *PACKAGE_URL_FIELDS, + "filename", + "download_url", + ) + .distinct() + ) + + template = "component_catalog/tabs/tab_package_set.html" + if len(related_packages) > 1: + return {"fields": [(None, related_packages, None, template)]} + def tab_product_usage(self): user = self.request.user # Product data in Package views are not available to AnonymousUser for security reason diff --git a/product_portfolio/migrations/0013_alter_productdependency_for_package_and_more.py b/product_portfolio/migrations/0013_alter_productdependency_for_package_and_more.py new file mode 100644 index 00000000..f94cbed9 --- /dev/null +++ b/product_portfolio/migrations/0013_alter_productdependency_for_package_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.8 on 2025-04-29 14:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('component_catalog', '0011_alter_component_owner'), + ('product_portfolio', '0012_alter_scancodeproject_status_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='productdependency', + name='for_package', + 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'), + ), + migrations.AlterField( + model_name='productdependency', + name='resolved_to_package', + 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'), + ), + ]