← Back to team overview

launchpad-reviewers team mailing list archive

[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,