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,
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp