launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #32799
[Merge] ~enriqueesanchz/launchpad:add-external-package-series into launchpad:master
Enrique Sánchez has proposed merging ~enriqueesanchz/launchpad:add-external-package-series into launchpad:master.
Commit message:
Add ExternalPackageSeries model
It represents an ExternalPackage in a distroseries. Set up `+external` url for distributions and distroseries.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~enriqueesanchz/launchpad/+git/launchpad/+merge/489721
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~enriqueesanchz/launchpad:add-external-package-series into launchpad:master.
diff --git a/lib/lp/bugs/browser/buglisting.py b/lib/lp/bugs/browser/buglisting.py
index 764c473..5960e4f 100644
--- a/lib/lp/bugs/browser/buglisting.py
+++ b/lib/lp/bugs/browser/buglisting.py
@@ -94,6 +94,7 @@ from lp.registry.interfaces.distributionsourcepackage import (
)
from lp.registry.interfaces.distroseries import IDistroSeries
from lp.registry.interfaces.externalpackage import IExternalPackage
+from lp.registry.interfaces.externalpackageseries import IExternalPackageSeries
from lp.registry.interfaces.ociproject import IOCIProject
from lp.registry.interfaces.person import IPerson
from lp.registry.interfaces.product import IProduct
@@ -1151,6 +1152,7 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
distroseries_context = self._distroSeriesContext()
distrosourcepackage_context = self._distroSourcePackageContext()
externalpackage_context = self._externalPackageContext()
+ externalpackageseries_context = self._externalPackageSeriesContext()
sourcepackage_context = self._sourcePackageContext()
ociproject_context = self._ociprojectContext()
@@ -1160,6 +1162,7 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
or distrosourcepackage_context
or sourcepackage_context
or externalpackage_context
+ or externalpackageseries_context
):
return ["id", "summary", "importance", "status", "heat"]
elif distribution_context or distroseries_context:
@@ -1774,6 +1777,13 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
"""
return IExternalPackage(self.context, None)
+ def _externalPackageSeriesContext(self):
+ """Is this page being viewed in an external package series context?
+
+ Return the IExternalPackageSeries if yes, otherwise return None.
+ """
+ return IExternalPackageSeries(self.context, None)
+
def _ociprojectContext(self):
"""Is this page being viewed in an OCI project context?
diff --git a/lib/lp/bugs/browser/bugtask.py b/lib/lp/bugs/browser/bugtask.py
index 081825d..91e3314 100644
--- a/lib/lp/bugs/browser/bugtask.py
+++ b/lib/lp/bugs/browser/bugtask.py
@@ -127,7 +127,11 @@ from lp.registry.interfaces.distributionsourcepackage import (
IDistributionSourcePackage,
)
from lp.registry.interfaces.distroseries import IDistroSeries, IDistroSeriesSet
-from lp.registry.interfaces.externalpackage import IExternalPackage
+from lp.registry.interfaces.externalpackage import (
+ IExternalPackage,
+ IExternalURL,
+)
+from lp.registry.interfaces.externalpackageseries import IExternalPackageSeries
from lp.registry.interfaces.ociproject import IOCIProject
from lp.registry.interfaces.person import IPersonSet
from lp.registry.interfaces.product import IProduct
@@ -303,8 +307,7 @@ class BugTaskURL:
@property
def path(self):
- # Only ExternalPackage can use +bugtask
- if IExternalPackage.providedBy(self.context.target):
+ if IExternalURL.providedBy(self.context.target):
return f"+bug/{self.context.bug.id}/+bugtask/{self.context.id}"
return "+bug/%s" % self.context.bug.id
@@ -343,23 +346,18 @@ class BugTargetTraversalMixin:
# anonymous user is presented with a login screen at the correct URL,
# rather than making it look as though this task was "not found",
# because it was filtered out by privacy-aware code.
- is_external_package = IExternalPackage.providedBy(context)
+
+ # Check if it uses +external url
+ is_external = IExternalURL.providedBy(context)
+
for bugtask in bug.bugtasks:
target = bugtask.target
- if is_external_package:
- # +external url lacks necessary data, so we only match
- # distribution and sourcepackagename, then using +bugktask we
- # can jump to the right one
- if (
- IExternalPackage.providedBy(target)
- and target.sourcepackagename == context.sourcepackagename
- and target.distribution == context.distribution
- ):
+ if is_external:
+ if context.isMatching(target):
+ # Security proxy the object on the way out
return getUtility(IBugTaskSet).get(bugtask.id)
-
elif target == context:
- # Security proxy the object on the way out
return getUtility(IBugTaskSet).get(bugtask.id)
# If we've come this far, there's no task for the requested context.
@@ -1883,22 +1881,45 @@ def bugtask_sort_key(bugtask):
None,
None,
)
+ # Version should only be compared to items with same object type
elif ISourcePackage.providedBy(bugtask.target):
key = (
bugtask.target.sourcepackagename.name,
bugtask.target.distribution.displayname,
+ None,
+ None,
+ Version(bugtask.target.distroseries.version),
+ None,
+ )
+ elif IExternalPackageSeries.providedBy(bugtask.target):
+ key = (
+ bugtask.target.sourcepackagename.name,
+ bugtask.target.distribution.displayname,
+ bugtask.target.packagetype,
+ bugtask.target.channel,
Version(bugtask.target.distroseries.version),
None,
None,
None,
)
elif IProduct.providedBy(bugtask.target):
- key = (None, None, None, bugtask.target.displayname, None, None)
+ key = (
+ None,
+ None,
+ None,
+ None,
+ None,
+ bugtask.target.displayname,
+ None,
+ None,
+ )
elif IProductSeries.providedBy(bugtask.target):
key = (
None,
None,
None,
+ None,
+ None,
bugtask.target.product.displayname,
bugtask.target.name,
None,
@@ -1906,11 +1927,20 @@ def bugtask_sort_key(bugtask):
elif IOCIProject.providedBy(bugtask.target):
ociproject = bugtask.target
pillar = ociproject.pillar
- key = [None, None, None, None, None, ociproject.displayname]
+ key = [
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ ociproject.displayname,
+ ]
if IDistribution.providedBy(pillar):
- key[1] = pillar.displayname
- elif IProduct.providedBy(pillar):
key[3] = pillar.displayname
+ elif IProduct.providedBy(pillar):
+ key[5] = pillar.displayname
key = tuple(key)
else:
raise AssertionError("No sort key for %r" % bugtask.target)
diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml
index c585354..e710a4f 100644
--- a/lib/lp/bugs/browser/configure.zcml
+++ b/lib/lp/bugs/browser/configure.zcml
@@ -1182,6 +1182,10 @@
layer="lp.bugs.publisher.BugsLayer"
name="+bugs"/>
<browser:defaultView
+ for="lp.registry.interfaces.externalpackageseries.IExternalPackageSeries"
+ layer="lp.bugs.publisher.BugsLayer"
+ name="+bugs"/>
+ <browser:defaultView
for="lp.registry.interfaces.person.IPerson"
layer="lp.bugs.publisher.BugsLayer"
name="+bugs"/>
diff --git a/lib/lp/bugs/browser/tests/test_buglisting.py b/lib/lp/bugs/browser/tests/test_buglisting.py
index a18b4d0..18e4a3c 100644
--- a/lib/lp/bugs/browser/tests/test_buglisting.py
+++ b/lib/lp/bugs/browser/tests/test_buglisting.py
@@ -158,6 +158,13 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
self.factory.makeDistroSeries(distribution=distro, name="test-series")
return self.factory.makeSourcePackage("test-sp", distro.currentseries)
+ def _makeExternalPackageSeries(self):
+ distro = self.factory.makeDistribution("test-distro")
+ self.factory.makeDistroSeries(distribution=distro, name="test-series")
+ return self.factory.makeExternalPackageSeries(
+ sourcepackagename="test-sp", distroseries=distro.currentseries
+ )
+
def test_sourcepackage_unknown_bugtracker_message(self):
# A SourcePackage whose Distro does not use
# Launchpad for bug tracking should explain that.
@@ -176,6 +183,24 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
extract_text(top_portlet[0]),
)
+ def test_externalpackageseries_unknown_bugtracker_message(self):
+ # An ExternalPackageSeries whose Distro does not use
+ # Launchpad for bug tracking should explain that.
+ sp = self._makeExternalPackageSeries()
+ url = canonical_url(sp, rootsite="bugs")
+ browser = self.getUserBrowser(url)
+ top_portlet = find_tags_by_class(browser.contents, "top-portlet")
+ self.assertTrue(
+ len(top_portlet) > 0, "Tag with class=top-portlet not found"
+ )
+ self.assertTextMatchesExpressionIgnoreWhitespace(
+ """
+ test-sp in Test-distro Test-series does not
+ use Launchpad for bug tracking.
+ Getting started with bug tracking in Launchpad.""",
+ extract_text(top_portlet[0]),
+ )
+
def test_sourcepackage_unknown_bugtracker_no_button(self):
# A SourcePackage whose Distro does not use Launchpad for bug
# tracking should not show the "Report a bug" button.
@@ -189,6 +214,19 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
"not be shown",
)
+ def test_externalpackageseries_unknown_bugtracker_no_button(self):
+ # An ExternalPackageSeries whose Distro does not use Launchpad for bug
+ # tracking should not show the "Report a bug" button.
+ sp = self._makeExternalPackageSeries()
+ url = canonical_url(sp, rootsite="bugs")
+ browser = self.getUserBrowser(url)
+ self.assertIs(
+ None,
+ find_tag_by_id(browser.contents, "involvement"),
+ "Involvement portlet with Report-a-bug button should "
+ "not be shown",
+ )
+
def test_sourcepackage_unknown_bugtracker_no_filters(self):
# A SourcePackage whose Distro does not use Launchpad for bug
# tracking should not show links to "New bugs", "Open bugs",
@@ -202,6 +240,19 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
"portlet-bugfilters should not be shown.",
)
+ def test_externalpackageseries_unknown_bugtracker_no_filters(self):
+ # An ExternalPackageSeries whose Distro does not use Launchpad for bug
+ # tracking should not show links to "New bugs", "Open bugs",
+ # etc.
+ sp = self._makeExternalPackageSeries()
+ url = canonical_url(sp, rootsite="bugs")
+ browser = self.getUserBrowser(url)
+ self.assertIs(
+ None,
+ find_tag_by_id(browser.contents, "portlet-bugfilters"),
+ "portlet-bugfilters should not be shown.",
+ )
+
def test_sourcepackage_unknown_bugtracker_no_tags(self):
# A SourcePackage whose Distro does not use Launchpad for bug
# tracking should not show links to search by bug tags.
@@ -214,6 +265,18 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
"portlet-tags should not be shown.",
)
+ def test_externalpackageseries_unknown_bugtracker_no_tags(self):
+ # An ExternalPackageSeries whose Distro does not use Launchpad for bug
+ # tracking should not show links to search by bug tags.
+ sp = self._makeExternalPackageSeries()
+ url = canonical_url(sp, rootsite="bugs")
+ browser = self.getUserBrowser(url)
+ self.assertIs(
+ None,
+ find_tag_by_id(browser.contents, "portlet-tags"),
+ "portlet-tags should not be shown.",
+ )
+
def test_search_components_error(self):
# Searching for using components for bug targets that are not a distro
# or distroseries will report an error, but not OOPS. See bug
diff --git a/lib/lp/bugs/browser/tests/test_bugtask.py b/lib/lp/bugs/browser/tests/test_bugtask.py
index 0c440a6..d4f94ad 100644
--- a/lib/lp/bugs/browser/tests/test_bugtask.py
+++ b/lib/lp/bugs/browser/tests/test_bugtask.py
@@ -693,6 +693,20 @@ class TestBugTasksTableView(TestCaseWithFactory):
self.view.initialize()
self.assertIs(None, self.view.getTargetLinkTitle(bug_task.target))
+ def test_getTargetLinkTitle_externalpackage(self):
+ # The target link title is always none for external packages.
+ target = self.factory.makeExternalPackage()
+ bug_task = self.factory.makeBugTask(bug=self.bug, target=target)
+ self.view.initialize()
+ self.assertIs(None, self.view.getTargetLinkTitle(bug_task.target))
+
+ def test_getTargetLinkTitle_externalpackageseries(self):
+ # The target link title is always none for external packages.
+ target = self.factory.makeExternalPackageSeries()
+ bug_task = self.factory.makeBugTask(bug=self.bug, target=target)
+ self.view.initialize()
+ self.assertIs(None, self.view.getTargetLinkTitle(bug_task.target))
+
def test_getTargetLinkTitle_unpublished_distributionsourcepackage(self):
# The target link title states that the package is not published
# in the current release.
@@ -886,9 +900,17 @@ class TestBugTasksTableView(TestCaseWithFactory):
packagetype=ExternalPackageType.CHARM,
channel=("12.1", "candidate"),
)
+
bar_ep_snap = self.factory.makeExternalPackage(
- sourcepackagename=bar_spn
+ sourcepackagename=bar_spn, distribution=barix
)
+ bar_ep_snap_series_1 = self.factory.makeExternalPackageSeries(
+ sourcepackagename=bar_spn, distroseries=barix.getSeries("alpha")
+ )
+ bar_ep_snap_series_2 = self.factory.makeExternalPackageSeries(
+ sourcepackagename=bar_spn, distroseries=barix.getSeries("beta")
+ )
+
bar_ep_rock = self.factory.makeExternalPackage(
sourcepackagename=bar_spn, packagetype=ExternalPackageType.ROCK
)
@@ -910,6 +932,8 @@ class TestBugTasksTableView(TestCaseWithFactory):
barix.getSeries("beta").getSourcePackage(bar_spn),
barix.getSeries("aaa-release").getSourcePackage(bar_spn),
bar_ep_snap,
+ bar_ep_snap_series_1,
+ bar_ep_snap_series_2,
bar_ep_rock,
bar_ep_rock_candidate,
fooix.getSourcePackage(bar_spn),
diff --git a/lib/lp/bugs/browser/tests/test_bugtask_navigation.py b/lib/lp/bugs/browser/tests/test_bugtask_navigation.py
index e30f0ce..bb41781 100644
--- a/lib/lp/bugs/browser/tests/test_bugtask_navigation.py
+++ b/lib/lp/bugs/browser/tests/test_bugtask_navigation.py
@@ -16,6 +16,18 @@ from lp.testing.publication import test_traverse
class TestBugTaskTraversal(TestCaseWithFactory):
layer = DatabaseFunctionalLayer
+ def setUp(self):
+ super().setUp()
+ distribution = self.factory.makeDistribution()
+ distroseries = self.factory.makeDistroSeries()
+ self.ep = self.factory.makeExternalPackage(distribution=distribution)
+ self.eps = self.factory.makeExternalPackageSeries(
+ distroseries=distroseries,
+ sourcepackagename=self.ep.sourcepackagename,
+ packagetype=self.ep.packagetype,
+ channel=removeSecurityProxy(self.ep).channel,
+ )
+
def test_traversal_to_nonexistent_bugtask(self):
# Test that a traversing to a non-existent bugtask redirects to the
# bug's default bugtask.
@@ -60,88 +72,101 @@ class TestBugTaskTraversal(TestCaseWithFactory):
% (bug.default_bugtask.target.name, bug.default_bugtask.bug.id),
)
- def test_traversal_to_external_package_bugtask(self):
+ def test_traversal_to_external_bugtask(self):
# Test that traversal using +bugtask/id works
# Test that we can differ between bugtasks with same packagename and
- # distribution, but different packagetype or channel
+ # distribution/distroseries, but different packagetype or channel
bug = self.factory.makeBug()
distribution = self.factory.makeDistribution()
+ distroseries_1 = self.factory.makeDistroSeries(
+ distribution=distribution
+ )
+ distroseries_2 = self.factory.makeDistroSeries(
+ distribution=distribution
+ )
spn = self.factory.makeSourcePackageName(name="mypackage")
- ep = self.factory.makeExternalPackage(
- distribution=distribution,
- sourcepackagename=spn,
- packagetype=ExternalPackageType.SNAP,
- channel=("11", "stable"),
- )
- ep_2 = self.factory.makeExternalPackage(
- distribution=distribution,
- sourcepackagename=spn,
- packagetype=ExternalPackageType.SNAP,
- channel=("11", "edge"),
- )
- ep_3 = self.factory.makeExternalPackage(
- distribution=distribution,
- sourcepackagename=spn,
- packagetype=ExternalPackageType.CHARM,
- channel=("11", "stable"),
- )
-
- bugtask = self.factory.makeBugTask(bug=bug, target=ep)
- bugtask_url = canonical_url(bugtask)
- bugtask_2 = self.factory.makeBugTask(bug=bug, target=ep_2)
- bugtask_url_2 = canonical_url(bugtask_2)
- bugtask_3 = self.factory.makeBugTask(bug=bug, target=ep_3)
- bugtask_url_3 = canonical_url(bugtask_3)
- # makeBug creates the first and default bugtask
- self.assertEqual(4, len(bug.bugtasks))
-
- self.assertEqual(
- bugtask_url,
- "http://bugs.launchpad.test/%s/+external/%s/+bug/%d/+bugtask/%s"
- % (
- bugtask.distribution.name,
- bugtask.target.name,
- bugtask.bug.id,
- bugtask.id,
+ targets = (
+ self.factory.makeExternalPackage(
+ distribution=distribution,
+ sourcepackagename=spn,
+ packagetype=ExternalPackageType.SNAP,
+ channel=("11", "stable"),
),
- )
- self.assertEqual(
- bugtask_url_2,
- "http://bugs.launchpad.test/%s/+external/%s/+bug/%d/+bugtask/%s"
- % (
- bugtask_2.distribution.name,
- bugtask_2.target.name,
- bugtask_2.bug.id,
- bugtask_2.id,
+ self.factory.makeExternalPackage(
+ distribution=distribution,
+ sourcepackagename=spn,
+ packagetype=ExternalPackageType.SNAP,
+ channel=("11", "edge"),
),
- )
- self.assertEqual(
- bugtask_url_3,
- "http://bugs.launchpad.test/%s/+external/%s/+bug/%d/+bugtask/%s"
- % (
- bugtask_3.distribution.name,
- bugtask_3.target.name,
- bugtask_3.bug.id,
- bugtask_3.id,
+ self.factory.makeExternalPackage(
+ distribution=distribution,
+ sourcepackagename=spn,
+ packagetype=ExternalPackageType.CHARM,
+ channel=("11", "stable"),
+ ),
+ self.factory.makeExternalPackageSeries(
+ distroseries=distroseries_1,
+ sourcepackagename=spn,
+ packagetype=ExternalPackageType.CHARM,
+ channel=("11", "stable"),
+ ),
+ self.factory.makeExternalPackageSeries(
+ distroseries=distroseries_2,
+ sourcepackagename=spn,
+ packagetype=ExternalPackageType.CHARM,
+ channel=("11", "stable"),
),
)
- obj, _, _ = test_traverse(bugtask_url)
- self.assertEqual(bugtask, obj)
- self.assertEqual(ep, obj.target)
- obj_2, _, _ = test_traverse(bugtask_url_2)
- self.assertEqual(bugtask_2, obj_2)
- self.assertEqual(ep_2, obj_2.target)
- obj_3, _, _ = test_traverse(bugtask_url_3)
- self.assertEqual(bugtask_3, obj_3)
- self.assertEqual(ep_3, obj_3.target)
+ bugtasks = []
+ for target in targets:
+ bugtasks.append(
+ self.factory.makeBugTask(bug=bug, target=target),
+ )
+
+ # makeBug creates the first and default bugtask
+ self.assertEqual(6, len(bug.bugtasks))
+ default, _, _ = test_traverse(canonical_url(bug.default_bugtask))
+ self.assertEqual(bug.default_bugtask, default)
+
+ # Check externalpackage urls
+ for bugtask in bugtasks[:3]:
+ self.assertEqual(
+ canonical_url(bugtask),
+ "http://bugs.launchpad.test/%s/+external/%s/+bug/%d/"
+ "+bugtask/%s"
+ % (
+ bugtask.distribution.name,
+ bugtask.target.name,
+ bugtask.bug.id,
+ bugtask.id,
+ ),
+ )
+ obj, _, _ = test_traverse(canonical_url(bugtask))
+ self.assertEqual(bugtask, obj)
+
+ # Check externalpackageseries urls
+ for bugtask in bugtasks[3:]:
+ self.assertEqual(
+ canonical_url(bugtask),
+ "http://bugs.launchpad.test/%s/%s/+external/%s/+bug/%d/"
+ "+bugtask/%s"
+ % (
+ bugtask.target.distribution.name,
+ bugtask.distroseries.name,
+ bugtask.target.name,
+ bugtask.bug.id,
+ bugtask.id,
+ ),
+ )
+ obj, _, _ = test_traverse(canonical_url(bugtask))
+ self.assertEqual(bugtask, obj)
def test_traversal_to_default_external_package_bugtask(self):
# Test that a traversing to a bug with an external package as default
# bugtask redirects to the bug's default bugtask using +bugtask/id.
- ep = self.factory.makeExternalPackage()
- bug = self.factory.makeBug(target=ep)
+ bug = self.factory.makeBug(target=self.ep)
bug_url = canonical_url(bug, rootsite="bugs")
obj, view, request = test_traverse(bug_url)
view()
@@ -162,15 +187,50 @@ class TestBugTaskTraversal(TestCaseWithFactory):
),
)
+ def test_traversal_to_default_external_package_series_bugtask(self):
+ # Test that a traversing to a bug with an external package series
+ # as default bugtask redirects to the bug's default bugtask using
+ # +bugtask/id.
+ bug = self.factory.makeBug(target=self.ep)
+ bug_url = canonical_url(bug, rootsite="bugs")
+
+ # We need to create a bugtask in the distribution before creating it in
+ # the distroseries
+ eps_bugtask = self.factory.makeBugTask(bug=bug, target=self.eps)
+
+ # Deleting the distribution bugtask to change the default one
+ login_person(bug.owner)
+ bug.default_bugtask.delete()
+ self.assertEqual(eps_bugtask, bug.default_bugtask)
+
+ obj, view, request = test_traverse(bug_url)
+ view()
+ naked_view = removeSecurityProxy(view)
+ self.assertEqual(303, request.response.getStatus())
+ self.assertEqual(
+ naked_view.target,
+ canonical_url(bug.default_bugtask, rootsite="bugs"),
+ )
+ self.assertEqual(
+ removeSecurityProxy(view).target,
+ "http://bugs.launchpad.test/%s/%s/+external/%s/+bug/%d/+bugtask/%s"
+ % (
+ bug.default_bugtask.target.distribution.name,
+ bug.default_bugtask.distroseries.name,
+ bug.default_bugtask.target.name,
+ bug.default_bugtask.bug.id,
+ bug.default_bugtask.id,
+ ),
+ )
+
def test_traversal_to_default_external_package_bugtask_on_api(self):
# Traversing to a bug with an external package as default task
# redirects to the +bugtask/id also in the API.
- ep = self.factory.makeExternalPackage()
- bug = self.factory.makeBug(target=ep)
+ bug = self.factory.makeBug(target=self.ep)
obj, view, request = test_traverse(
"http://api.launchpad.test/1.0/%s/+bug/%d"
% (
- removeSecurityProxy(ep).distribution.name,
+ removeSecurityProxy(self.ep).distribution.name,
bug.default_bugtask.bug.id,
)
)
@@ -184,3 +244,36 @@ class TestBugTaskTraversal(TestCaseWithFactory):
bug.default_bugtask.id,
),
)
+
+ def test_traversal_to_default_external_package_series_bugtask_on_api(self):
+ # Traversing to a bug with an external package series as default task
+ # redirects to the +bugtask/id also in the API.
+ bug = self.factory.makeBug(target=self.ep)
+ # We need to create a bugtask in the distribution before creating it in
+ # the distroseries
+ eps_bugtask = self.factory.makeBugTask(bug=bug, target=self.eps)
+
+ # Deleting the distribution bugtask to change the default one
+ login_person(bug.owner)
+ bug.default_bugtask.delete()
+ self.assertEqual(eps_bugtask, bug.default_bugtask)
+
+ obj, view, request = test_traverse(
+ "http://api.launchpad.test/1.0/%s/+bug/%d"
+ % (
+ removeSecurityProxy(self.ep).distribution.name,
+ bug.default_bugtask.bug.id,
+ )
+ )
+ self.assertEqual(
+ removeSecurityProxy(view).target,
+ "http://api.launchpad.test/1.0/%s/%s/+external/%s/+bug/%d/"
+ "+bugtask/%s"
+ % (
+ bug.default_bugtask.target.distribution.name,
+ bug.default_bugtask.distroseries.name,
+ bug.default_bugtask.target.name,
+ bug.default_bugtask.bug.id,
+ bug.default_bugtask.id,
+ ),
+ )
diff --git a/lib/lp/bugs/interfaces/bugtasksearch.py b/lib/lp/bugs/interfaces/bugtasksearch.py
index 6feb0a7..045049d 100644
--- a/lib/lp/bugs/interfaces/bugtasksearch.py
+++ b/lib/lp/bugs/interfaces/bugtasksearch.py
@@ -319,6 +319,13 @@ class BugTaskSearchParams:
self.packagetype = not_equals(None)
self.sourcepackagename = externalpackage.sourcepackagename
+ def setExternalPackageSeries(self, externalpackageseries):
+ """Set the externalpackage context on which to filter the search."""
+ self.distroseries = externalpackageseries.distroseries
+ # Currently we are only filtering by having any packagetype
+ self.packagetype = not_equals(None)
+ self.sourcepackagename = externalpackageseries.sourcepackagename
+
def setOCIProject(self, ociproject):
"""Set the distribution context on which to filter the search."""
self.ociproject = ociproject
diff --git a/lib/lp/bugs/model/bugnomination.py b/lib/lp/bugs/model/bugnomination.py
index 53e6d99..50d1b0a 100644
--- a/lib/lp/bugs/model/bugnomination.py
+++ b/lib/lp/bugs/model/bugnomination.py
@@ -119,7 +119,15 @@ class BugNomination(StormBase):
for task in self.bug.bugtasks:
if not task.distribution == distribution:
continue
- if task.sourcepackagename is not None:
+ if task.packagetype is not None:
+ targets.append(
+ distroseries.getExternalPackageSeries(
+ task.sourcepackagename,
+ task.packagetype,
+ task.channel,
+ )
+ )
+ elif task.sourcepackagename is not None:
targets.append(
distroseries.getSourcePackage(task.sourcepackagename)
)
@@ -234,6 +242,7 @@ class BugNominationSet:
filter_args = dict(distroseries_id=target.series.id)
else:
return None
+ # IExternalPackageSeries does not support bug nominations
store = IStore(BugNomination)
return store.find(BugNomination, bug=bug, **filter_args).one()
diff --git a/lib/lp/bugs/model/bugtask.py b/lib/lp/bugs/model/bugtask.py
index 1f78384..998b572 100644
--- a/lib/lp/bugs/model/bugtask.py
+++ b/lib/lp/bugs/model/bugtask.py
@@ -83,6 +83,7 @@ from lp.registry.interfaces.externalpackage import (
ExternalPackageType,
IExternalPackage,
)
+from lp.registry.interfaces.externalpackageseries import IExternalPackageSeries
from lp.registry.interfaces.milestone import IMilestoneSet
from lp.registry.interfaces.milestonetag import IProjectGroupMilestoneTag
from lp.registry.interfaces.ociproject import IOCIProject
@@ -198,7 +199,11 @@ def bug_target_from_key(
else:
return distribution
elif distroseries:
- if sourcepackagename:
+ if sourcepackagename and packagetype:
+ return distroseries.getExternalPackageSeries(
+ sourcepackagename, packagetype, removeSecurityProxy(channel)
+ )
+ elif sourcepackagename:
return distroseries.getSourcePackage(sourcepackagename)
else:
return distroseries
@@ -237,6 +242,11 @@ def bug_target_to_key(target):
values["sourcepackagename"] = target.sourcepackagename
values["packagetype"] = target.packagetype
values["channel"] = removeSecurityProxy(target).channel
+ elif IExternalPackageSeries.providedBy(target):
+ values["distroseries"] = target.distroseries
+ values["sourcepackagename"] = target.sourcepackagename
+ values["packagetype"] = target.packagetype
+ values["channel"] = removeSecurityProxy(target).channel
elif IOCIProject.providedBy(target):
# De-normalize the ociproject, including also the ociproject's
# pillar (distribution or product).
diff --git a/lib/lp/bugs/model/structuralsubscription.py b/lib/lp/bugs/model/structuralsubscription.py
index 9b76421..63677b0 100644
--- a/lib/lp/bugs/model/structuralsubscription.py
+++ b/lib/lp/bugs/model/structuralsubscription.py
@@ -59,6 +59,7 @@ from lp.registry.interfaces.distributionsourcepackage import (
)
from lp.registry.interfaces.distroseries import IDistroSeries
from lp.registry.interfaces.externalpackage import IExternalPackage
+from lp.registry.interfaces.externalpackageseries import IExternalPackageSeries
from lp.registry.interfaces.milestone import IMilestone
from lp.registry.interfaces.ociproject import IOCIProject
from lp.registry.interfaces.person import (
@@ -612,7 +613,9 @@ def get_structural_subscriptions_for_bug(bug, person=None):
# enriqueesanchz 2025-07-15 TODO: support bug subscriptions for
# ExternalPackages
for bugtask in bug.bugtasks:
- if not IExternalPackage.providedBy(bugtask.target):
+ if not IExternalPackage.providedBy(
+ bugtask.target
+ ) and not IExternalPackageSeries.providedBy(bugtask.target):
bugtasks.append(bugtask)
if not bugtasks:
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index ad96af8..81f8048 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -605,16 +605,30 @@
/>
<lp:url
for="lp.registry.interfaces.externalpackage.IExternalPackage"
- urldata="lp.registry.browser.externalpackage.ExternalPackageURL"
+ path_expression="string:+external/${name}"
+ attribute_to_parent="distribution"
+ />
+ <lp:url
+ for="lp.registry.interfaces.externalpackageseries.IExternalPackageSeries"
+ path_expression="string:+external/${name}"
+ attribute_to_parent="distroseries"
/>
<lp:navigation
module="lp.registry.browser.externalpackage"
classes="ExternalPackageNavigation"
/>
+ <lp:navigation
+ module="lp.registry.browser.externalpackageseries"
+ classes="ExternalPackageSeriesNavigation"
+ />
<lp:menus
module="lp.registry.browser.externalpackage"
classes="ExternalPackageFacets"
/>
+ <lp:menus
+ module="lp.registry.browser.externalpackageseries"
+ classes="ExternalPackageSeriesFacets"
+ />
<adapter
name="fmt"
factory="lp.registry.browser.sourcepackage.SourcePackageFormatterAPI"
diff --git a/lib/lp/registry/browser/distroseries.py b/lib/lp/registry/browser/distroseries.py
index 2729684..c72511d 100644
--- a/lib/lp/registry/browser/distroseries.py
+++ b/lib/lp/registry/browser/distroseries.py
@@ -64,6 +64,7 @@ from lp.registry.interfaces.distroseries import IDistroSeries
from lp.registry.interfaces.distroseriesdifference import (
IDistroSeriesDifferenceSource,
)
+from lp.registry.interfaces.externalpackage import ExternalPackageType
from lp.registry.interfaces.person import IPersonSet
from lp.registry.interfaces.pocket import PackagePublishingPocket
from lp.registry.interfaces.series import SeriesStatus
@@ -181,6 +182,12 @@ class DistroSeriesNavigation(
return distroserieslang
+ @stepthrough("+external")
+ def external(self, name):
+ return self.context.getExternalPackageSeries(
+ name, ExternalPackageType.UNKNOWN, None
+ )
+
@stepthrough("+source")
def source(self, name):
return self.context.getSourcePackage(name)
diff --git a/lib/lp/registry/browser/externalpackage.py b/lib/lp/registry/browser/externalpackage.py
index 7dc03d3..b9ccdcd 100644
--- a/lib/lp/registry/browser/externalpackage.py
+++ b/lib/lp/registry/browser/externalpackage.py
@@ -4,7 +4,6 @@
__all__ = [
"ExternalPackageBreadcrumb",
"ExternalPackageNavigation",
- "ExternalPackageURL",
"ExternalPackageFacets",
]
@@ -19,29 +18,7 @@ from lp.bugs.browser.structuralsubscription import (
from lp.registry.interfaces.externalpackage import IExternalPackage
from lp.services.webapp import Navigation, StandardLaunchpadFacets, redirection
from lp.services.webapp.breadcrumb import Breadcrumb
-from lp.services.webapp.interfaces import (
- ICanonicalUrlData,
- IMultiFacetedBreadcrumb,
-)
-from lp.services.webapp.menu import Link
-
-
-@implementer(ICanonicalUrlData)
-class ExternalPackageURL:
- """External package URL creation rules."""
-
- rootsite = None
-
- def __init__(self, context):
- self.context = context
-
- @property
- def inside(self):
- return self.context.distribution
-
- @property
- def path(self):
- return "+external/%s" % self.context.name
+from lp.services.webapp.interfaces import IMultiFacetedBreadcrumb
@implementer(IHeadingBreadcrumb, IMultiFacetedBreadcrumb)
@@ -62,13 +39,6 @@ class ExternalPackageFacets(StandardLaunchpadFacets):
]
-class ExternalPackageLinksMixin:
- def new_bugs(self):
- base_path = "+bugs"
- get_data = "?field.status:list=NEW"
- return Link(base_path + get_data, "New bugs", site="bugs")
-
-
class ExternalPackageNavigation(
Navigation,
BugTargetTraversalMixin,
diff --git a/lib/lp/registry/browser/externalpackageseries.py b/lib/lp/registry/browser/externalpackageseries.py
new file mode 100644
index 0000000..9e8f1a6
--- /dev/null
+++ b/lib/lp/registry/browser/externalpackageseries.py
@@ -0,0 +1,70 @@
+# Copyright 2009-2025 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+ "ExternalPackageSeriesBreadcrumb",
+ "ExternalPackageSeriesNavigation",
+ "ExternalPackageSeriesFacets",
+]
+
+
+from zope.interface import implementer
+
+from lp.app.interfaces.headings import IHeadingBreadcrumb
+from lp.bugs.browser.bugtask import BugTargetTraversalMixin
+from lp.bugs.browser.structuralsubscription import (
+ StructuralSubscriptionTargetTraversalMixin,
+)
+from lp.registry.interfaces.externalpackageseries import IExternalPackageSeries
+from lp.services.webapp import (
+ Navigation,
+ StandardLaunchpadFacets,
+ canonical_url,
+ redirection,
+ stepto,
+)
+from lp.services.webapp.breadcrumb import Breadcrumb
+from lp.services.webapp.interfaces import IMultiFacetedBreadcrumb
+
+
+@implementer(IHeadingBreadcrumb, IMultiFacetedBreadcrumb)
+class ExternalPackageSeriesBreadcrumb(Breadcrumb):
+ """Builds a breadcrumb for an `IExternalPackageSeries`."""
+
+ rootsite = "bugs"
+
+ @property
+ def text(self):
+ return "%s external package in %s" % (
+ self.context.sourcepackagename.name,
+ self.context.distroseries.named_version,
+ )
+
+
+class ExternalPackageSeriesFacets(StandardLaunchpadFacets):
+ usedfor = IExternalPackageSeries
+ enable_only = [
+ "bugs",
+ ]
+
+
+class ExternalPackageSeriesNavigation(
+ Navigation,
+ BugTargetTraversalMixin,
+ StructuralSubscriptionTargetTraversalMixin,
+):
+ usedfor = IExternalPackageSeries
+
+ @redirection("+editbugcontact")
+ def redirect_editbugcontact(self):
+ return "+subscribe"
+
+ @stepto("+filebug")
+ def filebug(self):
+ """Redirect to the IExternalPackage +filebug page."""
+ external_package = self.context.distribution_sourcepackage
+
+ redirection_url = canonical_url(external_package, view_name="+filebug")
+ if self.request.form.get("no-redirect") is not None:
+ redirection_url += "?no-redirect"
+ return self.redirectSubTree(redirection_url, status=303)
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 32147fc..f0355fd 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -636,6 +636,23 @@
provides="lp.services.webapp.interfaces.IBreadcrumb"
factory="lp.registry.browser.externalpackage.ExternalPackageBreadcrumb"/>
+ <!-- ExternalPackageSeries -->
+ <class
+ class="lp.registry.model.externalpackageseries.ExternalPackageSeries">
+ <allow
+ interface="lp.registry.interfaces.externalpackageseries.IExternalPackageSeriesView"/>
+ <allow
+ interface="lp.bugs.interfaces.bugtarget.ISeriesBugTarget"/>
+ </class>
+ <adapter
+ provides="lp.app.interfaces.launchpad.IServiceUsage"
+ for="lp.registry.interfaces.externalpackageseries.IExternalPackageSeries"
+ factory="lp.registry.adapters.sourcepackage_to_distribution"
+ permission="zope.Public"/>
+ <adapter
+ for="lp.registry.interfaces.externalpackageseries.IExternalPackageSeries"
+ provides="lp.services.webapp.interfaces.IBreadcrumb"
+ factory="lp.registry.browser.externalpackageseries.ExternalPackageSeriesBreadcrumb"/>
<!-- CommercialSubscription -->
diff --git a/lib/lp/registry/interfaces/distroseries.py b/lib/lp/registry/interfaces/distroseries.py
index 171b27e..9389bc9 100644
--- a/lib/lp/registry/interfaces/distroseries.py
+++ b/lib/lp/registry/interfaces/distroseries.py
@@ -708,6 +708,13 @@ class IDistroSeriesPublic(
object. The source package may not be published in the distro series.
"""
+ def getExternalPackageSeries(name, packagetype, channel):
+ """Return an external package in this distro series by name.
+
+ The name given may be a string or an ISourcePackageName-providing
+ object.
+ """
+
def getTranslatableSourcePackages():
"""Return a list of Source packages in this distribution series
that can be translated.
diff --git a/lib/lp/registry/interfaces/externalpackage.py b/lib/lp/registry/interfaces/externalpackage.py
index 2789943..8a8ef57 100644
--- a/lib/lp/registry/interfaces/externalpackage.py
+++ b/lib/lp/registry/interfaces/externalpackage.py
@@ -4,6 +4,7 @@
"""External package interfaces."""
__all__ = [
+ "IExternalURL",
"IExternalPackage",
"ExternalPackageType",
]
@@ -11,7 +12,7 @@ __all__ = [
from lazr.enum import DBEnumeratedType, DBItem
from lazr.restful.declarations import exported, exported_as_webservice_entry
from lazr.restful.fields import Reference
-from zope.interface import Attribute
+from zope.interface import Attribute, Interface
from zope.schema import TextLine
from lp import _
@@ -21,12 +22,23 @@ from lp.registry.interfaces.distribution import IDistribution
from lp.registry.interfaces.role import IHasDrivers
+class IExternalURL(Interface):
+ """Uses +external url"""
+
+ def isMatching(other):
+ """Returns if it matches the other object.
+ +external url lacks necessary data, so we only match the necessary
+ attributes.
+ """
+
+
@exported_as_webservice_entry(as_of="beta")
class IExternalPackageView(
IHeadingContext,
IBugTarget,
IHasOfficialBugTags,
IHasDrivers,
+ IExternalURL,
):
"""`IExternalPackage` attributes that require launchpad.View."""
@@ -54,6 +66,9 @@ class IExternalPackageView(
drivers = Attribute("The drivers for the distribution.")
+ def isMatching(other):
+ """See `IExternalURL`."""
+
def __eq__(other):
"""IExternalPackage comparison method.
diff --git a/lib/lp/registry/interfaces/externalpackageseries.py b/lib/lp/registry/interfaces/externalpackageseries.py
new file mode 100644
index 0000000..8aef040
--- /dev/null
+++ b/lib/lp/registry/interfaces/externalpackageseries.py
@@ -0,0 +1,90 @@
+# Copyright 2009, 2025 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""External Package Series interface."""
+
+__all__ = [
+ "IExternalPackageSeries",
+]
+
+from lazr.restful.declarations import exported, exported_as_webservice_entry
+from lazr.restful.fields import Reference
+from zope.interface import Attribute
+from zope.schema import TextLine
+
+from lp import _
+from lp.app.interfaces.launchpad import IHeadingContext
+from lp.bugs.interfaces.bugtarget import IBugTarget, IHasOfficialBugTags
+from lp.registry.interfaces.distribution import IDistribution
+from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.externalpackage import IExternalURL
+from lp.registry.interfaces.role import IHasDrivers
+
+
+@exported_as_webservice_entry(as_of="beta")
+class IExternalPackageSeriesView(
+ IHeadingContext,
+ IBugTarget,
+ IHasOfficialBugTags,
+ IHasDrivers,
+ IExternalURL,
+):
+ """`IExternalPackageSeries` attributes that require launchpad.View."""
+
+ packagetype = Attribute("The package type")
+
+ channel = Attribute("The package channel")
+
+ display_channel = TextLine(title=_("Display channel name"), readonly=True)
+
+ distribution = exported(
+ Reference(IDistribution, title=_("The distribution."))
+ )
+ distroseries = exported(
+ Reference(IDistroSeries, title=_("The distroseries."))
+ )
+ sourcepackagename = Attribute("The source package name.")
+
+ distribution_sourcepackage = Attribute(
+ "The IExternalPackage for this external package series."
+ )
+
+ name = exported(
+ TextLine(title=_("The source package name as text"), readonly=True)
+ )
+ display_name = exported(
+ TextLine(title=_("Display name for this package."), readonly=True)
+ )
+ displayname = Attribute("Display name (deprecated)")
+ title = exported(
+ TextLine(title=_("Title for this package."), readonly=True)
+ )
+
+ drivers = Attribute("The drivers for the distroseries.")
+
+ def isMatching(other):
+ """See `IExternalURL`."""
+
+ def __eq__(other):
+ """IExternalPackageSeries comparison method.
+
+ ExternalPackageSeries compare equal only if their fields compare equal.
+ """
+
+ def __ne__(other):
+ """IExternalPackageSeries comparison method.
+
+ External packages compare not equal if either of their
+ fields compare not equal.
+ """
+
+
+@exported_as_webservice_entry(as_of="beta")
+class IExternalPackageSeries(
+ IExternalPackageSeriesView,
+):
+ """Represents an ExternalPackage in a distroseries.
+
+ Create IExternalPackageSeries by invoking
+ `IDistroSeries.getExternalPackageSeries()`.
+ """
diff --git a/lib/lp/registry/model/distroseries.py b/lib/lp/registry/model/distroseries.py
index 59748f7..d67dc65 100644
--- a/lib/lp/registry/model/distroseries.py
+++ b/lib/lp/registry/model/distroseries.py
@@ -69,6 +69,7 @@ from lp.registry.interfaces.sourcepackagename import (
ISourcePackageName,
ISourcePackageNameSet,
)
+from lp.registry.model.externalpackageseries import ExternalPackageSeries
from lp.registry.model.milestone import HasMilestonesMixin, Milestone
from lp.registry.model.packaging import Packaging
from lp.registry.model.person import Person
@@ -1091,6 +1092,20 @@ class DistroSeries(
sourcepackagename=name, distroseries=self
)
+ def getExternalPackageSeries(self, name, packagetype, channel):
+ """See `IDistroSeries`."""
+ if ISourcePackageName.providedBy(name):
+ sourcepackagename = name
+ else:
+ sourcepackagename = getUtility(ISourcePackageNameSet).queryByName(
+ name
+ )
+ if sourcepackagename is None:
+ return None
+ return ExternalPackageSeries(
+ self, sourcepackagename, packagetype, channel
+ )
+
def getBinaryPackage(self, name):
"""See `IDistroSeries`."""
if not IBinaryPackageName.providedBy(name):
diff --git a/lib/lp/registry/model/externalpackage.py b/lib/lp/registry/model/externalpackage.py
index 488856d..81ab6c5 100644
--- a/lib/lp/registry/model/externalpackage.py
+++ b/lib/lp/registry/model/externalpackage.py
@@ -111,6 +111,14 @@ class ExternalPackage(
"""See `IExternalPackage`."""
return self.display_name
+ def isMatching(self, other) -> bool:
+ """See `IExternalURL`."""
+ return (
+ IExternalPackage.providedBy(other)
+ and self.sourcepackagename.id == other.sourcepackagename.id
+ and self.distribution.id == other.distribution.id
+ )
+
def __eq__(self, other: "ExternalPackage") -> str:
"""See `IExternalPackage`."""
return (
diff --git a/lib/lp/registry/model/externalpackageseries.py b/lib/lp/registry/model/externalpackageseries.py
new file mode 100644
index 0000000..1315746
--- /dev/null
+++ b/lib/lp/registry/model/externalpackageseries.py
@@ -0,0 +1,201 @@
+# Copyright 2009-2025 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Classes to represent external packages in a distroseries."""
+
+__all__ = [
+ "ExternalPackageSeries",
+]
+
+from zope.interface import implementer
+
+from lp.bugs.interfaces.bugtarget import ISeriesBugTarget
+from lp.bugs.model.bugtarget import BugTargetBase
+from lp.bugs.model.structuralsubscription import (
+ StructuralSubscriptionTargetMixin,
+)
+from lp.registry.interfaces.distribution import IDistribution
+from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.externalpackage import ExternalPackageType
+from lp.registry.interfaces.externalpackageseries import IExternalPackageSeries
+from lp.registry.interfaces.sourcepackagename import ISourcePackageName
+from lp.registry.model.hasdrivers import HasDriversMixin
+from lp.services.channels import channel_list_to_string, channel_string_to_list
+from lp.services.propertycache import cachedproperty
+
+
+@implementer(IExternalPackageSeries, ISeriesBugTarget)
+class ExternalPackageSeries(
+ BugTargetBase,
+ HasDriversMixin,
+ StructuralSubscriptionTargetMixin,
+):
+ """This is a "Magic External Package". It is not a Storm model, but instead
+ it represents a package with a particular name, type and channel in a
+ particular distroseries.
+ """
+
+ def __init__(
+ self,
+ distroseries: IDistroSeries,
+ sourcepackagename: ISourcePackageName,
+ packagetype: ExternalPackageType,
+ channel: (str, tuple, list),
+ ) -> "ExternalPackageSeries":
+ self.distroseries = distroseries
+ self.sourcepackagename = sourcepackagename
+ self.packagetype = packagetype
+ self.channel = self.validate_channel(channel)
+
+ def __repr__(self) -> str:
+ return f"<{self.__class__.__name__} '{self.display_name}'>"
+
+ def validate_channel(self, channel: (str, tuple, list)) -> tuple:
+ if channel is None:
+ return None
+
+ if not isinstance(channel, (str, tuple, list)):
+ raise ValueError("Channel must be a str, tuple or list")
+
+ return channel_string_to_list(channel)
+
+ @property
+ def name(self) -> str:
+ """See `IExternalPackageSeries`."""
+ return self.sourcepackagename.name
+
+ @property
+ def distribution(self) -> str:
+ """See `IExternalPackageSeries`."""
+ return self.distroseries.distribution
+
+ @property
+ def display_channel(self) -> str:
+ """See `IExternalPackageSeries`."""
+ if not self.channel:
+ return None
+
+ return channel_list_to_string(*self.channel)
+
+ @cachedproperty
+ def display_name(self) -> str:
+ """See `IExternalPackageSeries`."""
+ if self.channel:
+ return "%s - %s @%s in %s" % (
+ self.sourcepackagename.name,
+ self.packagetype,
+ self.display_channel,
+ self.distroseries.display_name,
+ )
+
+ return "%s - %s in %s" % (
+ self.sourcepackagename.name,
+ self.packagetype,
+ self.distroseries.display_name,
+ )
+
+ # There are different places of launchpad codebase where they use different
+ # display names
+ @property
+ def displayname(self) -> str:
+ """See `IExternalPackageSeries`."""
+ return self.display_name
+
+ @property
+ def bugtargetdisplayname(self) -> str:
+ """See `IExternalPackageSeries`."""
+ return self.display_name
+
+ @property
+ def bugtargetname(self) -> str:
+ """See `IExternalPackageSeries`."""
+ return self.display_name
+
+ @property
+ def bugtarget_parent(self):
+ """See `ISeriesBugTarget`."""
+ return self.distribution_sourcepackage
+
+ @property
+ def distribution_sourcepackage(self):
+ """See `IExternalPackageSeries`."""
+ return self.distribution.getExternalPackage(
+ self.sourcepackagename, self.packagetype, self.channel
+ )
+
+ @property
+ def series(self):
+ """See `ISeriesBugTarget`."""
+ return self.distroseries
+
+ @property
+ def title(self) -> str:
+ """See `IExternalPackageSeries`."""
+ return self.display_name
+
+ def isMatching(self, other) -> bool:
+ """See `IExternalURL`."""
+ return (
+ IExternalPackageSeries.providedBy(other)
+ and self.sourcepackagename.id == other.sourcepackagename.id
+ and self.distroseries.id == other.distroseries.id
+ )
+
+ def __eq__(self, other: "ExternalPackageSeries") -> str:
+ """See `IExternalPackageSeries`."""
+ return (
+ (IExternalPackageSeries.providedBy(other))
+ and (self.distroseries.id == other.distroseries.id)
+ and (self.sourcepackagename.id == other.sourcepackagename.id)
+ and (self.packagetype == other.packagetype)
+ and (self.channel == other.channel)
+ )
+
+ def __hash__(self) -> int:
+ """Return the combined attributes hash."""
+ return hash(
+ (
+ self.distroseries,
+ self.sourcepackagename,
+ self.packagetype,
+ self.display_channel,
+ )
+ )
+
+ @property
+ def drivers(self) -> list:
+ """See `IHasDrivers`."""
+ return self.distroseries.drivers
+
+ @property
+ def official_bug_tags(self) -> list:
+ """See `IHasBugs`."""
+ return self.distroseries.official_bug_tags
+
+ @property
+ def pillar(self) -> IDistribution:
+ """See `IBugTarget`."""
+ return self.distroseries.pillar
+
+ @property
+ def bug_reporting_guidelines(self):
+ """See `IBugTarget`."""
+ return self.distribution.bug_reporting_guidelines
+
+ @property
+ def content_templates(self):
+ """See `IBugTarget`."""
+ return self.distribution.content_templates
+
+ @property
+ def bug_reported_acknowledgement(self):
+ """See `IBugTarget`."""
+ return self.distribution.bug_reported_acknowledgement
+
+ def _getOfficialTagClause(self):
+ """See `IBugTarget`."""
+ return self.distroseries._getOfficialTagClause()
+
+ def _customizeSearchParams(self, search_params):
+ """Customize `search_params` for this external package series."""
+ search_params.setExternalPackageSeries(self)
diff --git a/lib/lp/registry/tests/test_distroseries.py b/lib/lp/registry/tests/test_distroseries.py
index eafd665..ad78f5f 100644
--- a/lib/lp/registry/tests/test_distroseries.py
+++ b/lib/lp/registry/tests/test_distroseries.py
@@ -17,6 +17,7 @@ from zope.security.proxy import removeSecurityProxy
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.registry.errors import NoSuchDistroSeries
from lp.registry.interfaces.distroseries import IDistroSeriesSet
+from lp.registry.interfaces.externalpackage import ExternalPackageType
from lp.registry.interfaces.pocket import PackagePublishingPocket
from lp.services.database.interfaces import IStore
from lp.services.webapp.interfaces import OAuthPermission
@@ -459,6 +460,39 @@ class TestDistroSeries(TestCaseWithFactory):
naked_distroseries.publishing_options["publish_i18n_index"]
)
+ def test_getExternalPackageSeries(self):
+ # Test that we get the ExternalPackageSeries that belongs to the
+ # distribution with the proper attributes
+ distroseries = self.factory.makeDistroSeries()
+ sourcepackagename = self.factory.getOrMakeSourcePackageName(
+ "my-package"
+ )
+ channel = ("22.04", "candidate", "staging")
+ externalpackageseries = distroseries.getExternalPackageSeries(
+ name=sourcepackagename,
+ packagetype=ExternalPackageType.ROCK,
+ channel=channel,
+ )
+ self.assertEqual(externalpackageseries.distroseries, distroseries)
+ self.assertEqual(externalpackageseries.name, "my-package")
+ self.assertEqual(
+ externalpackageseries.packagetype, ExternalPackageType.ROCK
+ )
+ self.assertEqual(externalpackageseries.channel, channel)
+
+ # We can have external package series without channel
+ externalpackageseries = distroseries.getExternalPackageSeries(
+ name=sourcepackagename,
+ packagetype=ExternalPackageType.SNAP,
+ channel=None,
+ )
+ self.assertEqual(externalpackageseries.distroseries, distroseries)
+ self.assertEqual(externalpackageseries.name, "my-package")
+ self.assertEqual(
+ externalpackageseries.packagetype, ExternalPackageType.SNAP
+ )
+ self.assertEqual(externalpackageseries.channel, None)
+
class TestDistroSeriesPackaging(TestCaseWithFactory):
layer = DatabaseFunctionalLayer
diff --git a/lib/lp/registry/tests/test_externalpackage.py b/lib/lp/registry/tests/test_externalpackage.py
index 032d605..cb3c57a 100644
--- a/lib/lp/registry/tests/test_externalpackage.py
+++ b/lib/lp/registry/tests/test_externalpackage.py
@@ -134,6 +134,27 @@ class TestExternalPackage(TestCaseWithFactory):
self.externalpackage.display_name,
)
+ def test_matches(self):
+ """Test if two externalpackages matches in sourcepackagename and
+ distribution.
+ """
+ self.assertTrue(
+ self.externalpackage.isMatching(self.externalpackage_maven)
+ )
+
+ other_spn = self.factory.makeSourcePackageName()
+ other_ep_1 = self.factory.makeExternalPackage(
+ sourcepackagename=other_spn,
+ distribution=self.distribution,
+ )
+ self.assertFalse(self.externalpackage.isMatching(other_ep_1))
+
+ other_distro = self.factory.makeDistribution()
+ other_ep_2 = self.factory.makeExternalPackage(
+ sourcepackagename=self.sourcepackagename, distribution=other_distro
+ )
+ self.assertFalse(self.externalpackage.isMatching(other_ep_2))
+
def test_compare(self):
"""Test __eq__ and __neq__"""
self.assertEqual(self.externalpackage, self.externalpackage_copy)
diff --git a/lib/lp/registry/tests/test_externalpackageseries.py b/lib/lp/registry/tests/test_externalpackageseries.py
new file mode 100644
index 0000000..8b241ab
--- /dev/null
+++ b/lib/lp/registry/tests/test_externalpackageseries.py
@@ -0,0 +1,275 @@
+# Copyright 2009-2025 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for ExternalPackageSeries."""
+
+from zope.security.proxy import removeSecurityProxy
+
+from lp.registry.interfaces.externalpackage import ExternalPackageType
+from lp.registry.model.externalpackage import ExternalPackage
+from lp.registry.model.externalpackageseries import ExternalPackageSeries
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestExternalPackageSeries(TestCaseWithFactory):
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super().setUp()
+
+ self.sourcepackagename = self.factory.getOrMakeSourcePackageName(
+ "mypackage"
+ )
+ self.channel = "14.90-test/edge/myfix"
+ self.distribution = self.factory.makeDistribution(name="mydistro")
+ self.distroseries = self.factory.makeDistroSeries(
+ distribution=self.distribution, name="mydistroseries"
+ )
+
+ self.externalpackageseries = (
+ self.distroseries.getExternalPackageSeries(
+ name=self.sourcepackagename,
+ packagetype=ExternalPackageType.SNAP,
+ channel=self.channel,
+ )
+ )
+ self.externalpackageseries_maven = (
+ self.distroseries.getExternalPackageSeries(
+ name=self.sourcepackagename,
+ packagetype=ExternalPackageType.MAVEN,
+ channel=None,
+ )
+ )
+ self.externalpackageseries_copy = ExternalPackageSeries(
+ self.distroseries,
+ sourcepackagename=self.sourcepackagename,
+ packagetype=ExternalPackageType.SNAP,
+ channel=self.channel,
+ )
+
+ def test_repr(self):
+ """Test __repr__ function"""
+ self.assertEqual(
+ "<ExternalPackageSeries 'mypackage - Snap @14.90-test/edge/myfix "
+ "in Mydistroseries'>",
+ self.externalpackageseries.__repr__(),
+ )
+ self.assertEqual(
+ "<ExternalPackageSeries 'mypackage - Maven in Mydistroseries'>",
+ self.externalpackageseries_maven.__repr__(),
+ )
+
+ def test_name(self):
+ """Test name property"""
+ self.assertEqual("mypackage", self.externalpackageseries.name)
+ self.assertEqual("mypackage", self.externalpackageseries_maven.name)
+
+ def test_distribution(self):
+ """Test distribution property"""
+ self.assertEqual(
+ self.distribution, self.externalpackageseries.distribution
+ )
+ self.assertEqual(
+ self.distribution, self.externalpackageseries_maven.distribution
+ )
+
+ def test_series(self):
+ """Test series property"""
+ self.assertEqual(self.distroseries, self.externalpackageseries.series)
+ self.assertEqual(
+ self.distroseries, self.externalpackageseries_maven.series
+ )
+
+ def test_display_channel(self):
+ """Test display_channel property"""
+ self.assertEqual(
+ self.externalpackageseries.display_channel, "14.90-test/edge/myfix"
+ )
+ self.assertEqual(
+ self.externalpackageseries_maven.display_channel, None
+ )
+
+ removeSecurityProxy(self.externalpackageseries).channel = (
+ "12.81",
+ "candidate",
+ None,
+ )
+ self.assertEqual(
+ "12.81/candidate", self.externalpackageseries.display_channel
+ )
+
+ def test_channel_fields(self):
+ """Test channel fields when creating an ExternalPackageSeries"""
+ # Valid channel is str, tuple or list
+ self.assertRaises(
+ ValueError,
+ ExternalPackageSeries,
+ self.distribution,
+ self.sourcepackagename,
+ ExternalPackageType.SNAP,
+ {},
+ )
+ self.assertRaises(
+ ValueError,
+ ExternalPackageSeries,
+ self.distribution,
+ self.sourcepackagename,
+ ExternalPackageType.CHARM,
+ 16,
+ )
+ # Channel risk is missing
+ self.assertRaises(
+ ValueError,
+ ExternalPackageSeries,
+ self.distribution,
+ self.sourcepackagename,
+ ExternalPackageType.ROCK,
+ "16",
+ )
+ # Branch name is also risk name
+ self.assertRaises(
+ ValueError,
+ ExternalPackageSeries,
+ self.distribution,
+ self.sourcepackagename,
+ ExternalPackageType.ROCK,
+ "16/stable/stable",
+ )
+ # Invalid risk name
+ self.assertRaises(
+ ValueError,
+ ExternalPackageSeries,
+ self.distribution,
+ self.sourcepackagename,
+ ExternalPackageType.ROCK,
+ "16/foo/bar",
+ )
+
+ def test_display_name(self):
+ """Test display_name property without channel"""
+ self.assertEqual(
+ "mypackage - Maven in Mydistroseries",
+ self.externalpackageseries_maven.display_name,
+ )
+
+ def test_display_name_with_channel(self):
+ """Test display_name property with channel"""
+ self.assertEqual(
+ "mypackage - Snap @14.90-test/edge/myfix in Mydistroseries",
+ self.externalpackageseries.display_name,
+ )
+
+ def test_bugtarget_parent(self):
+ """The bugtarget parent is an ExternalPackage with the same
+ sourcepackagename, packagetype and channel."""
+ expected = ExternalPackage(
+ distribution=self.externalpackageseries.distribution,
+ sourcepackagename=self.externalpackageseries.sourcepackagename,
+ packagetype=self.externalpackageseries.packagetype,
+ channel=removeSecurityProxy(self.externalpackageseries.channel),
+ )
+ self.assertEqual(expected, self.externalpackageseries.bugtarget_parent)
+
+ def test_matches(self):
+ """Test if two externalpackageseries matches in sourcepackagename and
+ distroseries.
+ """
+ self.assertTrue(
+ self.externalpackageseries.isMatching(
+ self.externalpackageseries_maven
+ )
+ )
+
+ other_spn = self.factory.makeSourcePackageName()
+ other_eps_1 = self.factory.makeExternalPackageSeries(
+ sourcepackagename=other_spn,
+ distroseries=self.distroseries,
+ )
+ self.assertFalse(self.externalpackageseries.isMatching(other_eps_1))
+
+ other_distroseries = self.factory.makeDistroSeries()
+ other_eps_2 = self.factory.makeExternalPackageSeries(
+ sourcepackagename=self.sourcepackagename,
+ distroseries=other_distroseries,
+ )
+ self.assertFalse(self.externalpackageseries.isMatching(other_eps_2))
+
+ def test_compare(self):
+ """Test __eq__ and __neq__"""
+ self.assertEqual(
+ self.externalpackageseries, self.externalpackageseries_copy
+ )
+ self.assertNotEqual(
+ self.externalpackageseries, self.externalpackageseries_maven
+ )
+
+ def test_hash(self):
+ """Test __hash__"""
+ self.assertEqual(
+ removeSecurityProxy(self.externalpackageseries).__hash__(),
+ removeSecurityProxy(self.externalpackageseries_copy).__hash__(),
+ )
+ self.assertNotEqual(
+ removeSecurityProxy(self.externalpackageseries).__hash__(),
+ removeSecurityProxy(self.externalpackageseries_maven).__hash__(),
+ )
+
+ def test_pillar(self):
+ """Test pillar property"""
+ self.assertEqual(
+ self.externalpackageseries.pillar, self.distroseries.pillar
+ )
+
+ def test_official_bug_tags(self):
+ """Test official_bug_tags property"""
+ self.assertEqual(
+ self.externalpackageseries.official_bug_tags,
+ self.distroseries.official_bug_tags,
+ )
+
+ @property
+ def test_bug_reporting_guidelines(self):
+ """Test bug_reporting_guidelines property"""
+ self.assertEqual(
+ self.distribution.bug_reporting_guidelines,
+ self.externalpackageseries.bug_reporting_guidelines,
+ )
+
+ @property
+ def test_content_templates(self):
+ """Test content_templates property"""
+ self.assertEqual(
+ self.distribution.content_templates,
+ self.externalpackageseries.content_templates,
+ )
+
+ @property
+ def test_bug_reported_acknowledgement(self):
+ """Test bug_reported_acknowledgement property"""
+ self.assertEqual(
+ self.distribution.bug_reported_acknowledgement,
+ self.externalpackageseries.bug_reported_acknowledgement,
+ )
+
+ def test__getOfficialTagClause(self):
+ """Test _getOfficialTagClause"""
+ self.assertEqual(
+ self.distroseries._getOfficialTagClause(),
+ self.externalpackageseries._getOfficialTagClause(),
+ )
+
+ def test_drivers_are_distributions(self):
+ """Drivers property returns the drivers for the distribution."""
+ self.assertNotEqual([], self.distroseries.drivers)
+ self.assertEqual(
+ self.externalpackageseries.drivers, self.distroseries.drivers
+ )
+
+ def test_personHasDriverRights(self):
+ """A distribution driver has driver permissions on an
+ externalpackageseries."""
+ driver = self.distroseries.drivers[0]
+ self.assertTrue(
+ self.externalpackageseries.personHasDriverRights(driver)
+ )
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 74ee801..52d93e5 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -5608,6 +5608,28 @@ class LaunchpadObjectFactory(ObjectFactory):
sourcepackagename, packagetype, channel
)
+ def makeExternalPackageSeries(
+ self,
+ sourcepackagename=None,
+ packagetype=None,
+ channel=None,
+ distroseries=None,
+ ):
+ if sourcepackagename is None or isinstance(sourcepackagename, str):
+ sourcepackagename = self.getOrMakeSourcePackageName(
+ sourcepackagename
+ )
+ if distroseries is None:
+ distroseries = self.makeDistroSeries()
+ if packagetype is None:
+ packagetype = ExternalPackageType.SNAP
+ if channel is None:
+ channel = ("12.1", "stable", None)
+
+ return distroseries.getExternalPackageSeries(
+ sourcepackagename, packagetype, channel
+ )
+
def makeEmailMessage(
self,
body=None,