Merge ~cjwatson/launchpad:distribution-traversal into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 6f7cc0336b53708625d61934a86cf46f919f4856
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:distribution-traversal
Merge into: launchpad:master
Diff against target: 428 lines (+215/-12)
10 files modified
lib/lp/registry/browser/configure.zcml (+1/-2)
lib/lp/registry/browser/distribution.py (+23/-5)
lib/lp/registry/browser/distroseries.py (+36/-2)
lib/lp/registry/browser/tests/distribution-views.txt (+2/-1)
lib/lp/registry/browser/tests/test_distribution.py (+84/-0)
lib/lp/registry/configure.zcml (+2/-0)
lib/lp/registry/enums.py (+18/-1)
lib/lp/registry/interfaces/distribution.py (+18/-1)
lib/lp/registry/model/distribution.py (+5/-0)
lib/lp/registry/tests/test_distribution.py (+26/-0)
Reviewer Review Type Date Requested Status
Thiago F. Pappacena (community) Approve
Review via email: mp+393731@code.launchpad.net

Commit message

Add configurable distribution default traversal rules

Description of the change

We can use redirect_default_traversal to configure whether the canonical URL for a distroseries is (e.g.) /ubuntu/focal or /ubuntu/+series/focal. In the future we'll also be able to use default_traversal_policy to configure the default traversal for a distribution to mean something else: for example it might well make sense to use it for distribution source packages rather than for series.

DB MP: https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/393730

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

LGTM

review: Approve
Revision history for this message
Otto Co-Pilot (otto-copilot) wrote :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
index a718640..777652f 100644
--- a/lib/lp/registry/browser/configure.zcml
+++ b/lib/lp/registry/browser/configure.zcml
@@ -99,8 +99,7 @@
99 />99 />
100 <browser:url100 <browser:url
101 for="lp.registry.interfaces.distroseries.IDistroSeries"101 for="lp.registry.interfaces.distroseries.IDistroSeries"
102 path_expression="name"102 urldata="lp.registry.browser.distroseries.DistroSeriesURL"
103 attribute_to_parent="distribution"
104 />103 />
105 <browser:defaultView104 <browser:defaultView
106 name="+index"105 name="+index"
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index 8d566e0..04d6dc1 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -95,6 +95,7 @@ from lp.registry.browser.pillar import (
95 PillarNavigationMixin,95 PillarNavigationMixin,
96 PillarViewMixin,96 PillarViewMixin,
97 )97 )
98from lp.registry.enums import DistributionDefaultTraversalPolicy
98from lp.registry.interfaces.distribution import (99from lp.registry.interfaces.distribution import (
99 IDistribution,100 IDistribution,
100 IDistributionMirrorMenuMarker,101 IDistributionMirrorMenuMarker,
@@ -189,13 +190,28 @@ class DistributionNavigation(
189190
190 @stepthrough('+series')191 @stepthrough('+series')
191 def traverse_series(self, name):192 def traverse_series(self, name):
192 series, _ = self._resolveSeries(name)193 series, redirect = self._resolveSeries(name)
193 return self.redirectSubTree(194 if not redirect:
194 canonical_url(series, request=self.request), status=303)195 policy = self.context.default_traversal_policy
196 if (policy == DistributionDefaultTraversalPolicy.SERIES and
197 not self.context.redirect_default_traversal):
198 redirect = True
199 if redirect:
200 return self.redirectSubTree(
201 canonical_url(series, request=self.request), status=303)
202 else:
203 return series
195204
196 def traverse(self, name):205 def traverse(self, name):
197 series, is_alias = self._resolveSeries(name)206 series, redirect = self._resolveSeries(name)
198 if is_alias:207 if series is None:
208 return None
209 if not redirect:
210 policy = self.context.default_traversal_policy
211 if (policy == DistributionDefaultTraversalPolicy.SERIES and
212 self.context.redirect_default_traversal):
213 redirect = True
214 if redirect:
199 return self.redirectSubTree(215 return self.redirectSubTree(
200 canonical_url(series, request=self.request), status=303)216 canonical_url(series, request=self.request), status=303)
201 else:217 else:
@@ -965,6 +981,8 @@ class DistributionEditView(RegistryEditFormView,
965 'translations_usage',981 'translations_usage',
966 'answers_usage',982 'answers_usage',
967 'translation_focus',983 'translation_focus',
984 'default_traversal_policy',
985 'redirect_default_traversal',
968 ]986 ]
969987
970 custom_widget_icon = CustomWidgetFactory(988 custom_widget_icon = CustomWidgetFactory(
diff --git a/lib/lp/registry/browser/distroseries.py b/lib/lp/registry/browser/distroseries.py
index 89114b9..75c0468 100644
--- a/lib/lp/registry/browser/distroseries.py
+++ b/lib/lp/registry/browser/distroseries.py
@@ -1,4 +1,4 @@
1# Copyright 2009-2018 Canonical Ltd. This software is licensed under the1# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""View classes related to `IDistroSeries`."""4"""View classes related to `IDistroSeries`."""
@@ -17,6 +17,7 @@ __all__ = [
17 'DistroSeriesPackageSearchView',17 'DistroSeriesPackageSearchView',
18 'DistroSeriesPackagesView',18 'DistroSeriesPackagesView',
19 'DistroSeriesUniquePackagesView',19 'DistroSeriesUniquePackagesView',
20 'DistroSeriesURL',
20 'DistroSeriesView',21 'DistroSeriesView',
21 ]22 ]
2223
@@ -27,7 +28,10 @@ from zope.component import getUtility
27from zope.event import notify28from zope.event import notify
28from zope.formlib import form29from zope.formlib import form
29from zope.formlib.widget import CustomWidgetFactory30from zope.formlib.widget import CustomWidgetFactory
30from zope.interface import Interface31from zope.interface import (
32 implementer,
33 Interface,
34 )
31from zope.lifecycleevent import ObjectCreatedEvent35from zope.lifecycleevent import ObjectCreatedEvent
32from zope.schema import (36from zope.schema import (
33 Choice,37 Choice,
@@ -67,6 +71,7 @@ from lp.registry.browser import (
67 MilestoneOverlayMixin,71 MilestoneOverlayMixin,
68 )72 )
69from lp.registry.enums import (73from lp.registry.enums import (
74 DistributionDefaultTraversalPolicy,
70 DistroSeriesDifferenceStatus,75 DistroSeriesDifferenceStatus,
71 DistroSeriesDifferenceType,76 DistroSeriesDifferenceType,
72 )77 )
@@ -86,6 +91,7 @@ from lp.services.webapp.authorization import check_permission
86from lp.services.webapp.batching import BatchNavigator91from lp.services.webapp.batching import BatchNavigator
87from lp.services.webapp.breadcrumb import Breadcrumb92from lp.services.webapp.breadcrumb import Breadcrumb
88from lp.services.webapp.escaping import structured93from lp.services.webapp.escaping import structured
94from lp.services.webapp.interfaces import ICanonicalUrlData
89from lp.services.webapp.menu import (95from lp.services.webapp.menu import (
90 ApplicationMenu,96 ApplicationMenu,
91 enabled_with_permission,97 enabled_with_permission,
@@ -129,6 +135,34 @@ def get_dsd_source():
129 return getUtility(IDistroSeriesDifferenceSource)135 return getUtility(IDistroSeriesDifferenceSource)
130136
131137
138@implementer(ICanonicalUrlData)
139class DistroSeriesURL:
140 """Distro series URL creation rules.
141
142 The canonical URL for a distro series depends on the values of
143 `default_traversal_policy` and `redirect_default_traversal` on the
144 context distribution.
145 """
146
147 rootsite = None
148
149 def __init__(self, context):
150 self.context = context
151
152 @property
153 def inside(self):
154 return self.context.distribution
155
156 @property
157 def path(self):
158 policy = self.context.distribution.default_traversal_policy
159 if (policy == DistributionDefaultTraversalPolicy.SERIES and
160 not self.context.distribution.redirect_default_traversal):
161 return self.context.name
162 else:
163 return u"+series/%s" % self.context.name
164
165
132class DistroSeriesNavigation(GetitemNavigation, BugTargetTraversalMixin,166class DistroSeriesNavigation(GetitemNavigation, BugTargetTraversalMixin,
133 StructuralSubscriptionTargetTraversalMixin):167 StructuralSubscriptionTargetTraversalMixin):
134168
diff --git a/lib/lp/registry/browser/tests/distribution-views.txt b/lib/lp/registry/browser/tests/distribution-views.txt
index 810f118..533d559 100644
--- a/lib/lp/registry/browser/tests/distribution-views.txt
+++ b/lib/lp/registry/browser/tests/distribution-views.txt
@@ -132,7 +132,8 @@ The view accepts most of the distribution fields.
132 'package_derivatives_email', 'icon',132 'package_derivatives_email', 'icon',
133 'logo', 'mugshot', 'official_malone', 'enable_bug_expiration',133 'logo', 'mugshot', 'official_malone', 'enable_bug_expiration',
134 'blueprints_usage', 'translations_usage', 'answers_usage',134 'blueprints_usage', 'translations_usage', 'answers_usage',
135 'translation_focus']135 'translation_focus',
136 'default_traversal_policy', 'redirect_default_traversal']
136137
137 >>> del form['field.name']138 >>> del form['field.name']
138 >>> del form['field.actions.save']139 >>> del form['field.actions.save']
diff --git a/lib/lp/registry/browser/tests/test_distribution.py b/lib/lp/registry/browser/tests/test_distribution.py
index d47eb99..b92d7b2 100644
--- a/lib/lp/registry/browser/tests/test_distribution.py
+++ b/lib/lp/registry/browser/tests/test_distribution.py
@@ -72,6 +72,42 @@ class TestDistributionNavigation(TestCaseWithFactory):
72 "http://launchpad.test/%s/%s" % (72 "http://launchpad.test/%s/%s" % (
73 distroseries.distribution.name, distroseries.name))73 distroseries.distribution.name, distroseries.name))
7474
75 def test_classic_series_url_redirects(self):
76 distroseries = self.factory.makeDistroSeries()
77 distroseries.distribution.redirect_default_traversal = True
78 self.assertRedirects(
79 "http://launchpad.test/%s/%s" % (
80 distroseries.distribution.name, distroseries.name),
81 "http://launchpad.test/%s/+series/%s" % (
82 distroseries.distribution.name, distroseries.name))
83
84 def test_classic_series_url_with_alias_redirects(self):
85 distroseries = self.factory.makeDistroSeries()
86 distroseries.distribution.redirect_default_traversal = True
87 distroseries.distribution.development_series_alias = "devel"
88 self.assertRedirects(
89 "http://launchpad.test/%s/devel" % distroseries.distribution.name,
90 "http://launchpad.test/%s/+series/%s" % (
91 distroseries.distribution.name, distroseries.name))
92
93 def test_new_series_url(self):
94 distroseries = self.factory.makeDistroSeries()
95 distroseries.distribution.redirect_default_traversal = True
96 obj, _, _ = test_traverse(
97 "http://launchpad.test/%s/+series/%s" % (
98 distroseries.distribution.name, distroseries.name))
99 self.assertEqual(distroseries, obj)
100
101 def test_new_series_url_with_alias(self):
102 distroseries = self.factory.makeDistroSeries()
103 distroseries.distribution.redirect_default_traversal = True
104 distroseries.distribution.development_series_alias = "devel"
105 self.assertRedirects(
106 "http://launchpad.test/%s/+series/devel" % (
107 distroseries.distribution.name),
108 "http://launchpad.test/%s/+series/%s" % (
109 distroseries.distribution.name, distroseries.name))
110
75 def test_new_series_url_redirects(self):111 def test_new_series_url_redirects(self):
76 distroseries = self.factory.makeDistroSeries()112 distroseries = self.factory.makeDistroSeries()
77 self.assertRedirects(113 self.assertRedirects(
@@ -97,6 +133,54 @@ class TestDistributionNavigation(TestCaseWithFactory):
97 self.assertIsInstance(marshaller.dereference_url(url), RedirectionView)133 self.assertIsInstance(marshaller.dereference_url(url), RedirectionView)
98 self.assertEqual(expected_obj, marshaller.marshall_from_json_data(url))134 self.assertEqual(expected_obj, marshaller.marshall_from_json_data(url))
99135
136 def test_classic_series_url_supports_object_lookup(self):
137 # Classic series URLs (without +series) are compatible with
138 # webservice object lookup, even if the distribution is configured
139 # to redirect the default traversal.
140 distroseries = self.factory.makeDistroSeries()
141 distroseries.distribution.redirect_default_traversal = True
142 distroseries_url = "/%s/%s" % (
143 distroseries.distribution.name, distroseries.name)
144 self.assertDereferences(distroseries_url, distroseries)
145
146 # Objects subordinate to the redirected series work too.
147 distroarchseries = self.factory.makeDistroArchSeries(
148 distroseries=distroseries)
149 distroarchseries_url = "/%s/%s/%s" % (
150 distroarchseries.distroseries.distribution.name,
151 distroarchseries.distroseries.name,
152 distroarchseries.architecturetag)
153 self.assertDereferences(distroarchseries_url, distroarchseries)
154
155 def test_classic_series_url_supports_object_lookup_https(self):
156 # Classic series URLs (without +series) are compatible with
157 # webservice object lookup, even if the distribution is configured
158 # to redirect the default traversal and the vhost is configured to
159 # use HTTPS. "SERVER_URL": None exposes a bug in lazr.restful <
160 # 0.22.2.
161 self.addCleanup(allvhosts.reload)
162 self.pushConfig("vhosts", use_https=True)
163 allvhosts.reload()
164
165 distroseries = self.factory.makeDistroSeries()
166 distroseries.distribution.redirect_default_traversal = True
167 distroseries_url = "/%s/%s" % (
168 distroseries.distribution.name, distroseries.name)
169 self.assertDereferences(
170 distroseries_url, distroseries,
171 environ={"HTTPS": "on", "SERVER_URL": None})
172
173 # Objects subordinate to the redirected series work too.
174 distroarchseries = self.factory.makeDistroArchSeries(
175 distroseries=distroseries)
176 distroarchseries_url = "/%s/%s/%s" % (
177 distroarchseries.distroseries.distribution.name,
178 distroarchseries.distroseries.name,
179 distroarchseries.architecturetag)
180 self.assertDereferences(
181 distroarchseries_url, distroarchseries,
182 environ={"HTTPS": "on", "SERVER_URL": None})
183
100 def test_new_series_url_supports_object_lookup(self):184 def test_new_series_url_supports_object_lookup(self):
101 # New-style +series URLs are compatible with webservice object185 # New-style +series URLs are compatible with webservice object
102 # lookup.186 # lookup.
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index 1c73f4b..659b8d9 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -1820,6 +1820,7 @@
1820 set_attributes="1820 set_attributes="
1821 bug_reported_acknowledgement1821 bug_reported_acknowledgement
1822 bug_reporting_guidelines1822 bug_reporting_guidelines
1823 default_traversal_policy
1823 description1824 description
1824 development_series_alias1825 development_series_alias
1825 display_name1826 display_name
@@ -1837,6 +1838,7 @@
1837 official_malone1838 official_malone
1838 owner1839 owner
1839 package_derivatives_email1840 package_derivatives_email
1841 redirect_default_traversal
1840 redirect_release_uploads1842 redirect_release_uploads
1841 summary1843 summary
1842 vcs1844 vcs
diff --git a/lib/lp/registry/enums.py b/lib/lp/registry/enums.py
index 631ba6f..30f4a79 100644
--- a/lib/lp/registry/enums.py
+++ b/lib/lp/registry/enums.py
@@ -1,4 +1,4 @@
1# Copyright 2010-2015 Canonical Ltd. This software is licensed under the1# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Enums for the Registry app."""4"""Enums for the Registry app."""
@@ -7,6 +7,7 @@ __metaclass__ = type
7__all__ = [7__all__ = [
8 'BranchSharingPolicy',8 'BranchSharingPolicy',
9 'BugSharingPolicy',9 'BugSharingPolicy',
10 'DistributionDefaultTraversalPolicy',
10 'DistroSeriesDifferenceStatus',11 'DistroSeriesDifferenceStatus',
11 'DistroSeriesDifferenceType',12 'DistroSeriesDifferenceType',
12 'EXCLUSIVE_TEAM_POLICY',13 'EXCLUSIVE_TEAM_POLICY',
@@ -425,3 +426,19 @@ class VCSType(DBEnumeratedType):
425426
426 The Git DVCS is used as the default project or distribution VCS.427 The Git DVCS is used as the default project or distribution VCS.
427 """)428 """)
429
430
431class DistributionDefaultTraversalPolicy(DBEnumeratedType):
432 """Policy for the default traversal from a distribution.
433
434 This determines what the "name" segment in a URL such as
435 "/{distro}/{name}" (with no intervening segment such as "+source")
436 means.
437 """
438
439 SERIES = DBItem(0, """
440 Series
441
442 The default traversal from a distribution is used for series of that
443 distribution.
444 """)
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index ecd6c49..8c23b90 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -73,7 +73,10 @@ from lp.bugs.interfaces.bugtarget import (
73from lp.bugs.interfaces.structuralsubscription import (73from lp.bugs.interfaces.structuralsubscription import (
74 IStructuralSubscriptionTarget,74 IStructuralSubscriptionTarget,
75 )75 )
76from lp.registry.enums import VCSType76from lp.registry.enums import (
77 DistributionDefaultTraversalPolicy,
78 VCSType,
79 )
77from lp.registry.interfaces.announcement import IMakesAnnouncements80from lp.registry.interfaces.announcement import IMakesAnnouncements
78from lp.registry.interfaces.distributionmirror import IDistributionMirror81from lp.registry.interfaces.distributionmirror import IDistributionMirror
79from lp.registry.interfaces.distroseries import DistroSeriesNameField82from lp.registry.interfaces.distroseries import DistroSeriesNameField
@@ -390,6 +393,20 @@ class IDistributionPublic(
390 description=_(393 description=_(
391 "Version control system for this distribution's code.")))394 "Version control system for this distribution's code.")))
392395
396 default_traversal_policy = exported(Choice(
397 title=_("Default traversal policy"),
398 description=_(
399 "The type of object that /{distro}/{name} URLs for this "
400 "distribution resolve to."),
401 vocabulary=DistributionDefaultTraversalPolicy,
402 readonly=False, required=False))
403 redirect_default_traversal = exported(Bool(
404 title=_("Redirect the default traversal"),
405 description=_(
406 "If true, the default traversal is for migration and redirects "
407 "to a different canonical URL."),
408 readonly=False, required=False))
409
393 def getArchiveIDList(archive=None):410 def getArchiveIDList(archive=None):
394 """Return a list of archive IDs suitable for sqlvalues() or quote().411 """Return a list of archive IDs suitable for sqlvalues() or quote().
395412
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index b9baba7..ba9b432 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -88,6 +88,7 @@ from lp.code.interfaces.seriessourcepackagebranch import (
88from lp.registry.enums import (88from lp.registry.enums import (
89 BranchSharingPolicy,89 BranchSharingPolicy,
90 BugSharingPolicy,90 BugSharingPolicy,
91 DistributionDefaultTraversalPolicy,
91 SpecificationSharingPolicy,92 SpecificationSharingPolicy,
92 VCSType,93 VCSType,
93 )94 )
@@ -262,6 +263,10 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
262 redirect_release_uploads = BoolCol(notNull=True, default=False)263 redirect_release_uploads = BoolCol(notNull=True, default=False)
263 development_series_alias = StringCol(notNull=False, default=None)264 development_series_alias = StringCol(notNull=False, default=None)
264 vcs = EnumCol(enum=VCSType, notNull=False)265 vcs = EnumCol(enum=VCSType, notNull=False)
266 default_traversal_policy = EnumCol(
267 enum=DistributionDefaultTraversalPolicy, notNull=False,
268 default=DistributionDefaultTraversalPolicy.SERIES)
269 redirect_default_traversal = BoolCol(notNull=False, default=False)
265270
266 def __repr__(self):271 def __repr__(self):
267 display_name = self.display_name.encode('ASCII', 'backslashreplace')272 display_name = self.display_name.encode('ASCII', 'backslashreplace')
diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
index 9eb671d..a053738 100644
--- a/lib/lp/registry/tests/test_distribution.py
+++ b/lib/lp/registry/tests/test_distribution.py
@@ -31,6 +31,7 @@ from lp.app.interfaces.launchpad import ILaunchpadCelebrities
31from lp.registry.enums import (31from lp.registry.enums import (
32 BranchSharingPolicy,32 BranchSharingPolicy,
33 BugSharingPolicy,33 BugSharingPolicy,
34 DistributionDefaultTraversalPolicy,
34 EXCLUSIVE_TEAM_POLICY,35 EXCLUSIVE_TEAM_POLICY,
35 INCLUSIVE_TEAM_POLICY,36 INCLUSIVE_TEAM_POLICY,
36 )37 )
@@ -345,6 +346,31 @@ class TestDistribution(TestCaseWithFactory):
345 self.assertEqual(1, result.count())346 self.assertEqual(1, result.count())
346 self.assertEqual(first_project, result[0])347 self.assertEqual(first_project, result[0])
347348
349 def test_default_traversal(self):
350 # By default, a distribution's default traversal refers to its
351 # series.
352 distro = self.factory.makeDistribution()
353 self.assertEqual(
354 DistributionDefaultTraversalPolicy.SERIES,
355 distro.default_traversal_policy)
356 self.assertFalse(distro.redirect_default_traversal)
357
358 def test_default_traversal_permissions(self):
359 # Only distribution owners can change the default traversal
360 # behaviour.
361 distro = self.factory.makeDistribution()
362 with person_logged_in(self.factory.makePerson()):
363 self.assertRaises(
364 Unauthorized, setattr, distro, 'default_traversal_policy',
365 DistributionDefaultTraversalPolicy.SERIES)
366 self.assertRaises(
367 Unauthorized, setattr, distro, 'redirect_default_traversal',
368 True)
369 with person_logged_in(distro.owner):
370 distro.default_traversal_policy = (
371 DistributionDefaultTraversalPolicy.SERIES)
372 distro.redirect_default_traversal = True
373
348374
349class TestDistributionCurrentSourceReleases(375class TestDistributionCurrentSourceReleases(
350 CurrentSourceReleasesMixin, TestCase):376 CurrentSourceReleasesMixin, TestCase):

Subscribers

People subscribed via source and target branches

to status/vote changes: