launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #32774
[Merge] ~enriqueesanchz/launchpad:add-external-package-url into launchpad:master
Enrique Sánchez has proposed merging ~enriqueesanchz/launchpad:add-external-package-url into launchpad:master with ~enriqueesanchz/launchpad:add-external-package as a prerequisite.
Commit message:
Add ExternalPackageURL and BugTaskURL classes
We use `+external` for ExternalPackages and `+bugtask` to identify BugTasks inside an ExternalPackage's bug. SourcePackages and DistributionSourcePackages remain as before.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~enriqueesanchz/launchpad/+git/launchpad/+merge/489379
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~enriqueesanchz/launchpad:add-external-package-url into launchpad:master.
diff --git a/lib/lp/bugs/browser/buglisting.py b/lib/lp/bugs/browser/buglisting.py
index 641e350..764c473 100644
--- a/lib/lp/bugs/browser/buglisting.py
+++ b/lib/lp/bugs/browser/buglisting.py
@@ -93,6 +93,7 @@ from lp.registry.interfaces.distributionsourcepackage import (
IDistributionSourcePackage,
)
from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.externalpackage import IExternalPackage
from lp.registry.interfaces.ociproject import IOCIProject
from lp.registry.interfaces.person import IPerson
from lp.registry.interfaces.product import IProduct
@@ -1149,6 +1150,7 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
distribution_context = self._distributionContext()
distroseries_context = self._distroSeriesContext()
distrosourcepackage_context = self._distroSourcePackageContext()
+ externalpackage_context = self._externalPackageContext()
sourcepackage_context = self._sourcePackageContext()
ociproject_context = self._ociprojectContext()
@@ -1157,6 +1159,7 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
or productseries_context
or distrosourcepackage_context
or sourcepackage_context
+ or externalpackage_context
):
return ["id", "summary", "importance", "status", "heat"]
elif distribution_context or distroseries_context:
@@ -1764,6 +1767,13 @@ class BugTaskSearchListingView(LaunchpadFormView, FeedsMixin, BugsInfoMixin):
"""
return IDistributionSourcePackage(self.context, None)
+ def _externalPackageContext(self):
+ """Is this page being viewed in an external package context?
+
+ Return the IExternalPackage if yes, otherwise return None.
+ """
+ return IExternalPackage(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 9ec5f1a..68d90f1 100644
--- a/lib/lp/bugs/browser/bugtask.py
+++ b/lib/lp/bugs/browser/bugtask.py
@@ -19,6 +19,7 @@ __all__ = [
"BugTaskTableRowView",
"BugTaskTextView",
"BugTaskView",
+ "BugTaskURL",
"can_add_package_task_to_bug",
"can_add_project_task_to_bug",
"get_comments_for_bugtask",
@@ -153,7 +154,7 @@ from lp.services.webapp.authorization import (
)
from lp.services.webapp.breadcrumb import Breadcrumb
from lp.services.webapp.escaping import html_escape, structured
-from lp.services.webapp.interfaces import ILaunchBag
+from lp.services.webapp.interfaces import ICanonicalUrlData, ILaunchBag
vocabulary_registry = getVocabularyRegistry()
@@ -287,6 +288,27 @@ def get_visible_comments(comments, user=None):
return visible_comments
+@implementer(ICanonicalUrlData)
+class BugTaskURL:
+ """External package URL creation rules."""
+
+ rootsite = "bugs"
+
+ def __init__(self, context):
+ self.context = context
+
+ @property
+ def inside(self):
+ return self.context.target
+
+ @property
+ def path(self):
+ if IExternalPackage.providedBy(self.context.target):
+ return f"+bug/{self.context.bug.id}/+bugtask/{self.context.id}"
+
+ return "+bug/%s" % self.context.bug.id
+
+
class BugTargetTraversalMixin:
"""Mix-in in class that provides .../+bug/NNN traversal."""
@@ -320,29 +342,24 @@ 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.
-
- externalpackage_bugtask = None
+ is_external_package = IExternalPackage.providedBy(context)
for bugtask in bug.bugtasks:
- if bugtask.target == context:
- # Security proxy this object on the way out.
- return getUtility(IBugTaskSet).get(bugtask.id)
-
+ target = bugtask.target
if (
- externalpackage_bugtask is None
- and IExternalPackage.providedBy(bugtask.target)
+ target == context
+ or is_external_package
+ and IExternalPackage.providedBy(target)
+ and target.sourcepackagename == context.sourcepackagename
):
- # enriqueesanchz 2025-07-15 TODO: set +external urls for
- # ExternalPackages. Currently we select the first
- # ExternalPackage that appears if we don't have other match
- externalpackage_bugtask = getUtility(IBugTaskSet).get(
- bugtask.id
- )
+ # for ExternalPackage we select the first package whose name is
+ # equal to the one from the context and later we will target
+ # other bugtask if required
- # If we've come this far, there's no task for the requested context.
- if externalpackage_bugtask:
- return externalpackage_bugtask
+ # Security proxy this 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.
# If we are attempting to navigate past the non-existent bugtask,
# we raise NotFound error. eg +delete or +edit etc.
# Otherwise we are simply navigating to a non-existent task and so we
@@ -365,6 +382,18 @@ class BugTaskNavigation(Navigation):
usedfor = IBugTask
+ @stepthrough("+bugtask")
+ def traverse_bugtask(self, id):
+ bugtask = getUtility(IBugTaskSet).get(int(id))
+ # Jumping to another bug is not allowed
+ if bugtask.bug.id != self.context.bug.id:
+ return
+ # Jumping to another sourcepackagename is not allowed
+ if bugtask.sourcepackagename != self.context.sourcepackagename:
+ return
+
+ return bugtask
+
@stepthrough("attachments")
def traverse_attachments(self, name):
"""traverse to an attachment by id."""
diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml
index 3b1c7d8..c585354 100644
--- a/lib/lp/bugs/browser/configure.zcml
+++ b/lib/lp/bugs/browser/configure.zcml
@@ -458,9 +458,7 @@
name="+index"/>
<lp:url
for="lp.bugs.interfaces.bugtask.IBugTask"
- path_expression="string:+bug/${bug/id}"
- attribute_to_parent="target"
- rootsite="bugs"/>
+ urldata="lp.bugs.browser.bugtask.BugTaskURL"/>
<lp:navigation
module="lp.bugs.browser.bugtask"
classes="BugTaskNavigation"/>
diff --git a/lib/lp/bugs/browser/tests/test_buglisting.py b/lib/lp/bugs/browser/tests/test_buglisting.py
index f11f586..8f0d5fb 100644
--- a/lib/lp/bugs/browser/tests/test_buglisting.py
+++ b/lib/lp/bugs/browser/tests/test_buglisting.py
@@ -33,6 +33,10 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
distro = self.factory.makeDistribution("test-distro")
return self.factory.makeDistributionSourcePackage("test-dsp", distro)
+ def _makeExternalPackage(self):
+ distro = self.factory.makeDistribution("ep-distro")
+ return self.factory.makeExternalPackage("ep", distribution=distro)
+
def test_distributionsourcepackage_unknown_bugtracker_message(self):
# A DistributionSourcePackage whose Distro does not use
# Launchpad for bug tracking should explain that.
@@ -50,6 +54,23 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
extract_text(top_portlet[0]),
)
+ def test_externalpackage_unknown_bugtracker_message(self):
+ ep = self._makeExternalPackage()
+ url = canonical_url(ep, 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"
+ )
+ # An external package from url will use unknown type
+ self.assertTextMatchesExpressionIgnoreWhitespace(
+ """
+ ep - Unknown in Ep-distro
+ does not use Launchpad for bug tracking.
+ Getting started with bug tracking in Launchpad.""",
+ extract_text(top_portlet[0]),
+ )
+
def test_distributionsourcepackage_unknown_bugtracker_no_button(self):
# A DistributionSourcePackage whose Distro does not use
# Launchpad for bug tracking should not show the "Report a bug"
@@ -64,6 +85,17 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
"not be shown",
)
+ def test_externalpackage_unknown_bugtracker_no_button(self):
+ ep = self._makeExternalPackage()
+ url = canonical_url(ep, 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_distributionsourcepackage_unknown_bugtracker_no_filters(self):
# A DistributionSourcePackage whose Distro does not use
# Launchpad for bug tracking should not show links to "New
@@ -77,6 +109,16 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
"portlet-bugfilters should not be shown.",
)
+ def test_externalpackage_unknown_bugtracker_no_filters(self):
+ dsp = self._makeExternalPackage()
+ url = canonical_url(dsp, 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_distributionsourcepackage_unknown_bugtracker_no_tags(self):
# A DistributionSourcePackage whose Distro does not use
# Launchpad for bug tracking should not show links to search by
@@ -90,6 +132,19 @@ class TestBugTaskSearchListingPage(BrowserTestCase):
"portlet-tags should not be shown.",
)
+ def test_externalpackage_unknown_bugtracker_no_tags(self):
+ # A DistributionSourcePackage whose Distro does not use
+ # Launchpad for bug tracking should not show links to search by
+ # bug tags.
+ ep = self._makeExternalPackage()
+ url = canonical_url(ep, rootsite="bugs")
+ browser = self.getUserBrowser(url)
+ self.assertIs(
+ None,
+ find_tag_by_id(browser.contents, "portlet-tags"),
+ "portlet-tags should not be shown.",
+ )
+
def _makeSourcePackage(self):
distro = self.factory.makeDistribution("test-distro")
self.factory.makeDistroSeries(distribution=distro, name="test-series")
diff --git a/lib/lp/bugs/browser/tests/test_bugtask.py b/lib/lp/bugs/browser/tests/test_bugtask.py
index 8d665f0..6a3ed4b 100644
--- a/lib/lp/bugs/browser/tests/test_bugtask.py
+++ b/lib/lp/bugs/browser/tests/test_bugtask.py
@@ -44,6 +44,7 @@ from lp.registry.enums import (
BugSharingPolicy,
DistributionDefaultTraversalPolicy,
)
+from lp.registry.interfaces.externalpackage import ExternalPackageType
from lp.registry.interfaces.person import IPersonSet, PersonVisibility
from lp.services.beautifulsoup import BeautifulSoup
from lp.services.config import config
@@ -848,6 +849,7 @@ class TestBugTasksTableView(TestCaseWithFactory):
# Distro tasks follow, sorted by package, distribution, then
# series (by version in the case of distribution series).
# OCI projects comes after their pillars.
+ # ExternalPackages comes last, ordered by packagetype and channel
foo = self.factory.makeProduct(displayname="Foo")
self.factory.makeProductSeries(product=foo, name="2.0")
self.factory.makeProductSeries(product=foo, name="1.0")
@@ -873,6 +875,25 @@ class TestBugTasksTableView(TestCaseWithFactory):
foo_ociproject = self.factory.makeOCIProject(pillar=foo)
barix_ociproject = self.factory.makeOCIProject(pillar=barix)
+ foo_ep_snap = self.factory.makeExternalPackage(foo_spn)
+ foo_ep_charm = self.factory.makeExternalPackage(
+ foo_spn, packagetype=ExternalPackageType.CHARM
+ )
+ foo_ep_charm_candidate = self.factory.makeExternalPackage(
+ foo_spn,
+ packagetype=ExternalPackageType.CHARM,
+ channel=("12.1", "candidate"),
+ )
+ bar_ep_snap = self.factory.makeExternalPackage(bar_spn)
+ bar_ep_rock = self.factory.makeExternalPackage(
+ bar_spn, packagetype=ExternalPackageType.ROCK
+ )
+ bar_ep_rock_candidate = self.factory.makeExternalPackage(
+ bar_spn,
+ packagetype=ExternalPackageType.ROCK,
+ channel=("12.1", "candidate"),
+ )
+
expected_targets = [
bar,
bar.getSeries("0.0"),
@@ -884,10 +905,16 @@ class TestBugTasksTableView(TestCaseWithFactory):
barix.getSourcePackage(bar_spn),
barix.getSeries("beta").getSourcePackage(bar_spn),
barix.getSeries("aaa-release").getSourcePackage(bar_spn),
+ bar_ep_snap,
+ bar_ep_rock,
+ bar_ep_rock_candidate,
fooix.getSourcePackage(bar_spn),
fooix.getSeries("beta").getSourcePackage(bar_spn),
barix.getSourcePackage(foo_spn),
barix.getSeries("alpha").getSourcePackage(foo_spn),
+ foo_ep_snap,
+ foo_ep_charm,
+ foo_ep_charm_candidate,
]
bug = self.factory.makeBug(target=expected_targets[0])
diff --git a/lib/lp/bugs/browser/tests/test_bugtask_navigation.py b/lib/lp/bugs/browser/tests/test_bugtask_navigation.py
index 356f05d..7eb1f6b 100644
--- a/lib/lp/bugs/browser/tests/test_bugtask_navigation.py
+++ b/lib/lp/bugs/browser/tests/test_bugtask_navigation.py
@@ -58,3 +58,87 @@ class TestBugTaskTraversal(TestCaseWithFactory):
"http://api.launchpad.test/1.0/%s/+bug/%d"
% (bug.default_bugtask.target.name, bug.default_bugtask.bug.id),
)
+
+ def test_traversal_to_external_package_bugtask(self):
+ # Test that traversal using +bugtask/id works
+ bug = self.factory.makeBug()
+ ep = self.factory.makeExternalPackage()
+ bugtask = self.factory.makeBugTask(bug=bug, target=ep)
+ bugtask_url = canonical_url(bugtask)
+ ep_2 = self.factory.makeExternalPackage()
+ bugtask_2 = self.factory.makeBugTask(bug=bug, target=ep_2)
+ bugtask_url_2 = canonical_url(bugtask_2)
+ self.assertEqual(
+ bugtask_url,
+ "http://bugs.launchpad.test/%s/+external/%s/+bug/%d/+bugtask/%s"
+ % (
+ removeSecurityProxy(bugtask).distribution.name,
+ removeSecurityProxy(bugtask).target.name,
+ removeSecurityProxy(bugtask).bug.id,
+ removeSecurityProxy(bugtask).id,
+ ),
+ )
+ self.assertEqual(
+ bugtask_url_2,
+ "http://bugs.launchpad.test/%s/+external/%s/+bug/%d/+bugtask/%s"
+ % (
+ removeSecurityProxy(bugtask_2).distribution.name,
+ removeSecurityProxy(bugtask_2).target.name,
+ removeSecurityProxy(bugtask_2).bug.id,
+ removeSecurityProxy(bugtask_2).id,
+ ),
+ )
+ obj, _, _ = test_traverse(bugtask_url)
+ obj_2, _, _ = test_traverse(bugtask_url_2)
+ self.assertEqual(bugtask, obj)
+ self.assertEqual(bugtask_2, obj_2)
+ self.assertEqual(ep, obj.target)
+ self.assertEqual(ep_2, obj_2.target)
+
+ 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_url = canonical_url(bug, rootsite="bugs")
+ 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/+external/%s/+bug/%d/+bugtask/%s"
+ % (
+ bug.default_bugtask.distribution.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)
+ obj, view, request = test_traverse(
+ "http://api.launchpad.test/1.0/%s/+bug/%d"
+ % (
+ removeSecurityProxy(ep).distribution.name,
+ bug.default_bugtask.bug.id,
+ )
+ )
+ self.assertEqual(
+ removeSecurityProxy(view).target,
+ "http://api.launchpad.test/1.0/%s/+external/%s/+bug/%d/+bugtask/%s"
+ % (
+ bug.default_bugtask.distribution.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 83253c6..6feb0a7 100644
--- a/lib/lp/bugs/interfaces/bugtasksearch.py
+++ b/lib/lp/bugs/interfaces/bugtasksearch.py
@@ -38,7 +38,7 @@ from lp.bugs.interfaces.bugtask import (
IBugTask,
)
from lp.services.fields import SearchTag
-from lp.services.searchbuilder import NULL, all, any
+from lp.services.searchbuilder import NULL, all, any, not_equals
from lp.soyuz.interfaces.component import IComponent
@@ -155,6 +155,7 @@ class BugTaskSearchParams:
milestone_tag=None,
assignee=None,
sourcepackagename=None,
+ packagetype=None,
owner=None,
attachmenttype=None,
orderby=None,
@@ -200,6 +201,7 @@ class BugTaskSearchParams:
self.milestone_tag = milestone_tag
self.assignee = assignee
self.sourcepackagename = sourcepackagename
+ self.packagetype = packagetype
self.owner = owner
self.attachmenttype = attachmenttype
self.user = user
@@ -307,8 +309,16 @@ class BugTaskSearchParams:
else:
# This is a sourcepackage in a distribution.
self.distribution = sourcepackage.distribution
+ self.packagetype = NULL
self.sourcepackagename = sourcepackage.sourcepackagename
+ def setExternalPackage(self, externalpackage):
+ """Set the externalpackage context on which to filter the search."""
+ self.distribution = externalpackage.distribution
+ # Currently we are only filtering by having any packagetype
+ self.packagetype = not_equals(None)
+ self.sourcepackagename = externalpackage.sourcepackagename
+
def setOCIProject(self, ociproject):
"""Set the distribution context on which to filter the search."""
self.ociproject = ociproject
@@ -331,6 +341,7 @@ class BugTaskSearchParams:
IDistributionSourcePackage,
)
from lp.registry.interfaces.distroseries import IDistroSeries
+ from lp.registry.interfaces.externalpackage import IExternalPackage
from lp.registry.interfaces.milestone import IMilestone
from lp.registry.interfaces.ociproject import IOCIProject
from lp.registry.interfaces.product import IProduct
@@ -359,6 +370,8 @@ class BugTaskSearchParams:
self.setSourcePackage(target)
elif IDistributionSourcePackage.providedBy(instance):
self.setSourcePackage(target)
+ elif IExternalPackage.providedBy(instance):
+ self.setExternalPackage(target)
elif IProjectGroup.providedBy(instance):
self.setProjectGroup(target)
elif IOCIProject.providedBy(instance):
diff --git a/lib/lp/bugs/model/bugtaskflat.py b/lib/lp/bugs/model/bugtaskflat.py
index 833fe01..ea2304b 100644
--- a/lib/lp/bugs/model/bugtaskflat.py
+++ b/lib/lp/bugs/model/bugtaskflat.py
@@ -40,6 +40,7 @@ class BugTaskFlat(StormBase):
distroseries = Reference(distroseries_id, "DistroSeries.id")
sourcepackagename_id = Int(name="sourcepackagename")
sourcepackagename = Reference(sourcepackagename_id, "SourcePackageName.id")
+ packagetype = Int(name="packagetype")
ociproject_id = Int(name="ociproject")
ociproject = Reference(ociproject_id, "OCIProject.id")
status = DBEnum(enum=(BugTaskStatus, BugTaskStatusSearch))
diff --git a/lib/lp/bugs/model/bugtasksearch.py b/lib/lp/bugs/model/bugtasksearch.py
index e2a8600..2af1ee7 100644
--- a/lib/lp/bugs/model/bugtasksearch.py
+++ b/lib/lp/bugs/model/bugtasksearch.py
@@ -323,6 +323,7 @@ def _build_query(params):
BugTaskFlat.productseries: params.productseries,
BugTaskFlat.assignee: params.assignee,
BugTaskFlat.sourcepackagename: params.sourcepackagename,
+ BugTaskFlat.packagetype: params.packagetype,
BugTaskFlat.owner: params.owner,
BugTaskFlat.date_closed: params.date_closed,
}
diff --git a/lib/lp/bugs/tests/test_bugtaskflat_triggers.py b/lib/lp/bugs/tests/test_bugtaskflat_triggers.py
index 7ecbfca..bb54d56 100644
--- a/lib/lp/bugs/tests/test_bugtaskflat_triggers.py
+++ b/lib/lp/bugs/tests/test_bugtaskflat_triggers.py
@@ -40,6 +40,7 @@ class BugTaskFlat(NamedTuple):
distribution: Any
distroseries: Any
sourcepackagename: Any
+ packagetype: Any
status: Any
importance: Any
assignee: Any
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index f60b871..ad96af8 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -554,10 +554,6 @@
for="lp.registry.interfaces.distributionsourcepackage.IDistributionSourcePackage"
urldata="lp.registry.browser.distributionsourcepackage.DistributionSourcePackageURL"
/>
- <lp:url
- for="lp.registry.interfaces.externalpackage.IExternalPackage"
- urldata="lp.registry.browser.distributionsourcepackage.DistributionSourcePackageURL"
- />
<lp:navigation
module="lp.registry.browser.distributionsourcepackage"
classes="
@@ -607,6 +603,18 @@
provides="zope.traversing.interfaces.IPathAdapter"
for="lp.registry.interfaces.distributionsourcepackage.IDistributionSourcePackage"
/>
+ <lp:url
+ for="lp.registry.interfaces.externalpackage.IExternalPackage"
+ urldata="lp.registry.browser.externalpackage.ExternalPackageURL"
+ />
+ <lp:navigation
+ module="lp.registry.browser.externalpackage"
+ classes="ExternalPackageNavigation"
+ />
+ <lp:menus
+ module="lp.registry.browser.externalpackage"
+ classes="ExternalPackageFacets"
+ />
<adapter
name="fmt"
factory="lp.registry.browser.sourcepackage.SourcePackageFormatterAPI"
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index aac1c14..d8d40c2 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -114,6 +114,7 @@ from lp.registry.interfaces.distributionmirror import (
MirrorContent,
MirrorSpeed,
)
+from lp.registry.interfaces.externalpackage import ExternalPackageType
from lp.registry.interfaces.ociproject import (
OCI_PROJECT_ALLOW_CREATE,
IOCIProjectSet,
@@ -183,6 +184,12 @@ class DistributionNavigation(
else:
return dsp
+ @stepthrough("+external")
+ def traverse_external(self, name):
+ return self.context.getExternalPackage(
+ name, ExternalPackageType.UNKNOWN, None
+ )
+
@stepthrough("+oci")
def traverse_oci(self, name):
oci_project = self.context.getOCIProject(name)
diff --git a/lib/lp/registry/browser/externalpackage.py b/lib/lp/registry/browser/externalpackage.py
new file mode 100644
index 0000000..36842ef
--- /dev/null
+++ b/lib/lp/registry/browser/externalpackage.py
@@ -0,0 +1,92 @@
+# Copyright 2009-2025 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+ "ExternalPackageBreadcrumb",
+ "ExternalPackageNavigation",
+ "ExternalPackageURL",
+ "ExternalPackageFacets",
+]
+
+
+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.code.browser.vcslisting import TargetDefaultVCSNavigationMixin
+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
+from lp.services.webhooks.browser import WebhookTargetNavigationMixin
+from lp.translations.browser.customlanguagecode import (
+ HasCustomLanguageCodesTraversalMixin,
+)
+
+
+@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
+
+
+@implementer(IHeadingBreadcrumb, IMultiFacetedBreadcrumb)
+class ExternalPackageBreadcrumb(Breadcrumb):
+ """Builds a breadcrumb for an `IExternalPackage`."""
+
+ rootsite = "bugs"
+
+ @property
+ def text(self):
+ return "%s external package" % self.context.sourcepackagename.name
+
+
+class ExternalPackageFacets(StandardLaunchpadFacets):
+ usedfor = IExternalPackage
+ enable_only = [
+ "bugs",
+ ]
+
+
+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,
+ HasCustomLanguageCodesTraversalMixin,
+ TargetDefaultVCSNavigationMixin,
+ StructuralSubscriptionTargetTraversalMixin,
+ WebhookTargetNavigationMixin,
+):
+ usedfor = IExternalPackage
+
+ @redirection("+editbugcontact")
+ def redirect_editbugcontact(self):
+ return "+subscribe"
+
+ def traverse(self, name):
+ return None
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 22e60a9..32147fc 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -622,6 +622,20 @@
enable_bugfiling_duplicate_search
"/>
</class>
+ <adapter
+ for="lp.registry.interfaces.externalpackage.IExternalPackage"
+ provides="lp.services.webapp.interfaces.ILaunchpadContainer"
+ factory="lp.services.webapp.publisher.LaunchpadContainer"/>
+ <adapter
+ provides="lp.app.interfaces.launchpad.IServiceUsage"
+ for="lp.registry.interfaces.externalpackage.IExternalPackage"
+ factory="lp.registry.adapters.sourcepackage_to_distribution"
+ permission="zope.Public"/>
+ <adapter
+ for="lp.registry.interfaces.externalpackage.IExternalPackage"
+ provides="lp.services.webapp.interfaces.IBreadcrumb"
+ factory="lp.registry.browser.externalpackage.ExternalPackageBreadcrumb"/>
+
<!-- CommercialSubscription -->
diff --git a/lib/lp/registry/model/externalpackage.py b/lib/lp/registry/model/externalpackage.py
index d97390d..f7bfaa8 100644
--- a/lib/lp/registry/model/externalpackage.py
+++ b/lib/lp/registry/model/externalpackage.py
@@ -147,6 +147,23 @@ class ExternalPackage(
"""See `IBugTarget`."""
return self.distribution
+ @property
+ def bug_reporting_guidelines(self):
+ return
+
+ @property
+ def content_templates(self):
+ return
+
+ @property
+ def bug_reported_acknowledgement(self):
+ """See `IBugTarget`."""
+ return self.distribution.bug_reported_acknowledgement
+
def _getOfficialTagClause(self):
"""See `IBugTarget`."""
return self.distribution._getOfficialTagClause()
+
+ def _customizeSearchParams(self, search_params):
+ """Customize `search_params` for this distribution source package."""
+ search_params.setExternalPackage(self)
Follow ups