Skip to content

Prototype implementation of Package set #276 #301

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions component_catalog/management/commands/makepackageset.py
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 18 additions & 0 deletions component_catalog/migrations/0012_package_package_content.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
97 changes: 97 additions & 0 deletions component_catalog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]


Expand All @@ -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(
Expand Down Expand Up @@ -1708,6 +1799,7 @@ class Package(
URLFieldsMixin,
HashFieldsMixin,
PackageURLMixin,
PackageContentFieldMixin,
DataspacedModel,
):
filename = models.CharField(
Expand Down Expand Up @@ -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)()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{% load i18n %}
{% spaceless %}
<table class="table table-bordered table-hover table-md text-break">
<thead>
<tr>
<th>{% trans 'Package URL' %}</th>
<th>{% trans 'Filename' %}</th>
<th>{% trans 'Download URL' %}</th>
<th>{% trans 'Concluded license' %}</th>
</tr>
</thead>
<tbody>
{% for package in values %}
<tr class="{% cycle 'odd' '' %}">
<td class="fw-bold">
{% if package.uuid == object.uuid %}
{{ package.package_url }}
<div>
<span class="badge bg-secondary">Current package</span>
</div>
{% else %}
<a href="{{ package.get_absolute_url }}" target="_blank">{{ package.package_url }}</a>
{% endif %}
</td>
<td>{{ package.filename|default_if_none:"" }}</td>
<td>{{ package.download_url|default_if_none:"" }}</td>
<td>{{ package.license_expression|default_if_none:"" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endspaceless %}
27 changes: 27 additions & 0 deletions component_catalog/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1143,6 +1144,7 @@ class PackageDetailsView(
"components",
],
},
"package_set": {},
"product_usage": {},
"activity": {},
"external_references": {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Loading