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 %} +
{% 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:"" }} | +