1818from django .core .exceptions import ValidationError
1919from django .core .validators import EMPTY_VALUES
2020from django .db import models
21+ from django .db .models import Case
2122from django .db .models import CharField
2223from django .db .models import Count
2324from django .db .models import Exists
25+ from django .db .models import F
2426from django .db .models import OuterRef
27+ from django .db .models import Value
28+ from django .db .models import When
2529from django .db .models .functions import Concat
2630from django .dispatch import receiver
2731from django .template .defaultfilters import filesizeformat
@@ -1651,6 +1655,65 @@ def __str__(self):
16511655PACKAGE_URL_FIELDS = ["type" , "namespace" , "name" , "version" , "qualifiers" , "subpath" ]
16521656
16531657
1658+ def get_plain_package_url_expression ():
1659+ """
1660+ Return a Django expression to compute the "PLAIN" Package URL (PURL).
1661+ Return an empty string if the required `type` or `name` values are missing.
1662+ """
1663+ plain_package_url = Concat (
1664+ Value ("pkg:" ),
1665+ F ("type" ),
1666+ Case (
1667+ When (namespace = "" , then = Value ("" )),
1668+ default = Concat (Value ("/" ), F ("namespace" )),
1669+ output_field = CharField (),
1670+ ),
1671+ Value ("/" ),
1672+ F ("name" ),
1673+ Case (
1674+ When (version = "" , then = Value ("" )),
1675+ default = Concat (Value ("@" ), F ("version" )),
1676+ output_field = CharField (),
1677+ ),
1678+ output_field = CharField (),
1679+ )
1680+
1681+ return Case (
1682+ When (type = "" , then = Value ("" )),
1683+ When (name = "" , then = Value ("" )),
1684+ default = plain_package_url ,
1685+ output_field = CharField (),
1686+ )
1687+
1688+
1689+ def get_package_url_expression ():
1690+ """
1691+ Return a Django expression to compute the "FULL" Package URL (PURL).
1692+ Return an empty string if the required `type` or `name` values are missing.
1693+ """
1694+ package_url = Concat (
1695+ get_plain_package_url_expression (),
1696+ Case (
1697+ When (qualifiers = "" , then = Value ("" )),
1698+ default = Concat (Value ("?" ), F ("qualifiers" )),
1699+ output_field = CharField (),
1700+ ),
1701+ Case (
1702+ When (subpath = "" , then = Value ("" )),
1703+ default = Concat (Value ("#" ), F ("subpath" )),
1704+ output_field = CharField (),
1705+ ),
1706+ output_field = CharField (),
1707+ )
1708+
1709+ return Case (
1710+ When (type = "" , then = Value ("" )),
1711+ When (name = "" , then = Value ("" )),
1712+ default = package_url ,
1713+ output_field = CharField (),
1714+ )
1715+
1716+
16541717class PackageQuerySet (PackageURLQuerySetMixin , VulnerabilityQuerySetMixin , DataspacedQuerySet ):
16551718 def has_package_url (self ):
16561719 """Return objects with Package URL defined."""
@@ -1666,6 +1729,26 @@ def annotate_sortable_identifier(self):
16661729 sortable_identifier = Concat (* PACKAGE_URL_FIELDS , "filename" , output_field = CharField ())
16671730 )
16681731
1732+ def annotate_plain_package_url (self ):
1733+ """
1734+ Annotate the QuerySet with a computed 'plain' Package URL (PURL).
1735+
1736+ This plain PURL is a simplified version that includes only the core fields:
1737+ `type`, `namespace`, `name`, and `version`. It omits any qualifiers or
1738+ subpath components, providing a normalized and minimal representation
1739+ of the Package URL.
1740+ """
1741+ return self .annotate (plain_purl = get_plain_package_url_expression ())
1742+
1743+ def annotate_package_url (self ):
1744+ """
1745+ Annotate the QuerySet with a fully-computed Package URL (PURL).
1746+
1747+ This includes the core PURL fields (`type`, `namespace`, `name`, `version`)
1748+ as well as any qualifiers and subpath components.
1749+ """
1750+ return self .annotate (purl = get_package_url_expression ())
1751+
16691752 def only_rendering_fields (self ):
16701753 """Minimum requirements to render a Package element in the UI."""
16711754 return self .only (
@@ -2533,17 +2616,12 @@ def update_from_purldb(self, user):
25332616 for field_name in [* hash_field_names , * identifier_fields ]:
25342617 package_data .pop (field_name , None )
25352618
2536- # try:
25372619 updated_fields = self .update_from_data (
25382620 user ,
25392621 package_data ,
25402622 override = False ,
25412623 override_unknown = True ,
25422624 )
2543- # except IntegrityError as e:
2544- # logger.error(f"[update_from_purldb] Skipping {self} due to IntegrityError: {e}")
2545- # return []
2546-
25472625 return updated_fields
25482626
25492627 def update_from_scan (self , user ):
@@ -2560,6 +2638,32 @@ def update_from_scan(self, user):
25602638 updated_fields = scancodeio .update_from_scan (package = self , user = user )
25612639 return updated_fields
25622640
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+
25632667
25642668class PackageAssignedLicense (DataspacedModel ):
25652669 package = models .ForeignKey (
0 commit comments