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
1diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
2index a718640..777652f 100644
3--- a/lib/lp/registry/browser/configure.zcml
4+++ b/lib/lp/registry/browser/configure.zcml
5@@ -99,8 +99,7 @@
6 />
7 <browser:url
8 for="lp.registry.interfaces.distroseries.IDistroSeries"
9- path_expression="name"
10- attribute_to_parent="distribution"
11+ urldata="lp.registry.browser.distroseries.DistroSeriesURL"
12 />
13 <browser:defaultView
14 name="+index"
15diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
16index 8d566e0..04d6dc1 100644
17--- a/lib/lp/registry/browser/distribution.py
18+++ b/lib/lp/registry/browser/distribution.py
19@@ -95,6 +95,7 @@ from lp.registry.browser.pillar import (
20 PillarNavigationMixin,
21 PillarViewMixin,
22 )
23+from lp.registry.enums import DistributionDefaultTraversalPolicy
24 from lp.registry.interfaces.distribution import (
25 IDistribution,
26 IDistributionMirrorMenuMarker,
27@@ -189,13 +190,28 @@ class DistributionNavigation(
28
29 @stepthrough('+series')
30 def traverse_series(self, name):
31- series, _ = self._resolveSeries(name)
32- return self.redirectSubTree(
33- canonical_url(series, request=self.request), status=303)
34+ series, redirect = self._resolveSeries(name)
35+ if not redirect:
36+ policy = self.context.default_traversal_policy
37+ if (policy == DistributionDefaultTraversalPolicy.SERIES and
38+ not self.context.redirect_default_traversal):
39+ redirect = True
40+ if redirect:
41+ return self.redirectSubTree(
42+ canonical_url(series, request=self.request), status=303)
43+ else:
44+ return series
45
46 def traverse(self, name):
47- series, is_alias = self._resolveSeries(name)
48- if is_alias:
49+ series, redirect = self._resolveSeries(name)
50+ if series is None:
51+ return None
52+ if not redirect:
53+ policy = self.context.default_traversal_policy
54+ if (policy == DistributionDefaultTraversalPolicy.SERIES and
55+ self.context.redirect_default_traversal):
56+ redirect = True
57+ if redirect:
58 return self.redirectSubTree(
59 canonical_url(series, request=self.request), status=303)
60 else:
61@@ -965,6 +981,8 @@ class DistributionEditView(RegistryEditFormView,
62 'translations_usage',
63 'answers_usage',
64 'translation_focus',
65+ 'default_traversal_policy',
66+ 'redirect_default_traversal',
67 ]
68
69 custom_widget_icon = CustomWidgetFactory(
70diff --git a/lib/lp/registry/browser/distroseries.py b/lib/lp/registry/browser/distroseries.py
71index 89114b9..75c0468 100644
72--- a/lib/lp/registry/browser/distroseries.py
73+++ b/lib/lp/registry/browser/distroseries.py
74@@ -1,4 +1,4 @@
75-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
76+# Copyright 2009-2020 Canonical Ltd. This software is licensed under the
77 # GNU Affero General Public License version 3 (see the file LICENSE).
78
79 """View classes related to `IDistroSeries`."""
80@@ -17,6 +17,7 @@ __all__ = [
81 'DistroSeriesPackageSearchView',
82 'DistroSeriesPackagesView',
83 'DistroSeriesUniquePackagesView',
84+ 'DistroSeriesURL',
85 'DistroSeriesView',
86 ]
87
88@@ -27,7 +28,10 @@ from zope.component import getUtility
89 from zope.event import notify
90 from zope.formlib import form
91 from zope.formlib.widget import CustomWidgetFactory
92-from zope.interface import Interface
93+from zope.interface import (
94+ implementer,
95+ Interface,
96+ )
97 from zope.lifecycleevent import ObjectCreatedEvent
98 from zope.schema import (
99 Choice,
100@@ -67,6 +71,7 @@ from lp.registry.browser import (
101 MilestoneOverlayMixin,
102 )
103 from lp.registry.enums import (
104+ DistributionDefaultTraversalPolicy,
105 DistroSeriesDifferenceStatus,
106 DistroSeriesDifferenceType,
107 )
108@@ -86,6 +91,7 @@ from lp.services.webapp.authorization import check_permission
109 from lp.services.webapp.batching import BatchNavigator
110 from lp.services.webapp.breadcrumb import Breadcrumb
111 from lp.services.webapp.escaping import structured
112+from lp.services.webapp.interfaces import ICanonicalUrlData
113 from lp.services.webapp.menu import (
114 ApplicationMenu,
115 enabled_with_permission,
116@@ -129,6 +135,34 @@ def get_dsd_source():
117 return getUtility(IDistroSeriesDifferenceSource)
118
119
120+@implementer(ICanonicalUrlData)
121+class DistroSeriesURL:
122+ """Distro series URL creation rules.
123+
124+ The canonical URL for a distro series depends on the values of
125+ `default_traversal_policy` and `redirect_default_traversal` on the
126+ context distribution.
127+ """
128+
129+ rootsite = None
130+
131+ def __init__(self, context):
132+ self.context = context
133+
134+ @property
135+ def inside(self):
136+ return self.context.distribution
137+
138+ @property
139+ def path(self):
140+ policy = self.context.distribution.default_traversal_policy
141+ if (policy == DistributionDefaultTraversalPolicy.SERIES and
142+ not self.context.distribution.redirect_default_traversal):
143+ return self.context.name
144+ else:
145+ return u"+series/%s" % self.context.name
146+
147+
148 class DistroSeriesNavigation(GetitemNavigation, BugTargetTraversalMixin,
149 StructuralSubscriptionTargetTraversalMixin):
150
151diff --git a/lib/lp/registry/browser/tests/distribution-views.txt b/lib/lp/registry/browser/tests/distribution-views.txt
152index 810f118..533d559 100644
153--- a/lib/lp/registry/browser/tests/distribution-views.txt
154+++ b/lib/lp/registry/browser/tests/distribution-views.txt
155@@ -132,7 +132,8 @@ The view accepts most of the distribution fields.
156 'package_derivatives_email', 'icon',
157 'logo', 'mugshot', 'official_malone', 'enable_bug_expiration',
158 'blueprints_usage', 'translations_usage', 'answers_usage',
159- 'translation_focus']
160+ 'translation_focus',
161+ 'default_traversal_policy', 'redirect_default_traversal']
162
163 >>> del form['field.name']
164 >>> del form['field.actions.save']
165diff --git a/lib/lp/registry/browser/tests/test_distribution.py b/lib/lp/registry/browser/tests/test_distribution.py
166index d47eb99..b92d7b2 100644
167--- a/lib/lp/registry/browser/tests/test_distribution.py
168+++ b/lib/lp/registry/browser/tests/test_distribution.py
169@@ -72,6 +72,42 @@ class TestDistributionNavigation(TestCaseWithFactory):
170 "http://launchpad.test/%s/%s" % (
171 distroseries.distribution.name, distroseries.name))
172
173+ def test_classic_series_url_redirects(self):
174+ distroseries = self.factory.makeDistroSeries()
175+ distroseries.distribution.redirect_default_traversal = True
176+ self.assertRedirects(
177+ "http://launchpad.test/%s/%s" % (
178+ distroseries.distribution.name, distroseries.name),
179+ "http://launchpad.test/%s/+series/%s" % (
180+ distroseries.distribution.name, distroseries.name))
181+
182+ def test_classic_series_url_with_alias_redirects(self):
183+ distroseries = self.factory.makeDistroSeries()
184+ distroseries.distribution.redirect_default_traversal = True
185+ distroseries.distribution.development_series_alias = "devel"
186+ self.assertRedirects(
187+ "http://launchpad.test/%s/devel" % distroseries.distribution.name,
188+ "http://launchpad.test/%s/+series/%s" % (
189+ distroseries.distribution.name, distroseries.name))
190+
191+ def test_new_series_url(self):
192+ distroseries = self.factory.makeDistroSeries()
193+ distroseries.distribution.redirect_default_traversal = True
194+ obj, _, _ = test_traverse(
195+ "http://launchpad.test/%s/+series/%s" % (
196+ distroseries.distribution.name, distroseries.name))
197+ self.assertEqual(distroseries, obj)
198+
199+ def test_new_series_url_with_alias(self):
200+ distroseries = self.factory.makeDistroSeries()
201+ distroseries.distribution.redirect_default_traversal = True
202+ distroseries.distribution.development_series_alias = "devel"
203+ self.assertRedirects(
204+ "http://launchpad.test/%s/+series/devel" % (
205+ distroseries.distribution.name),
206+ "http://launchpad.test/%s/+series/%s" % (
207+ distroseries.distribution.name, distroseries.name))
208+
209 def test_new_series_url_redirects(self):
210 distroseries = self.factory.makeDistroSeries()
211 self.assertRedirects(
212@@ -97,6 +133,54 @@ class TestDistributionNavigation(TestCaseWithFactory):
213 self.assertIsInstance(marshaller.dereference_url(url), RedirectionView)
214 self.assertEqual(expected_obj, marshaller.marshall_from_json_data(url))
215
216+ def test_classic_series_url_supports_object_lookup(self):
217+ # Classic series URLs (without +series) are compatible with
218+ # webservice object lookup, even if the distribution is configured
219+ # to redirect the default traversal.
220+ distroseries = self.factory.makeDistroSeries()
221+ distroseries.distribution.redirect_default_traversal = True
222+ distroseries_url = "/%s/%s" % (
223+ distroseries.distribution.name, distroseries.name)
224+ self.assertDereferences(distroseries_url, distroseries)
225+
226+ # Objects subordinate to the redirected series work too.
227+ distroarchseries = self.factory.makeDistroArchSeries(
228+ distroseries=distroseries)
229+ distroarchseries_url = "/%s/%s/%s" % (
230+ distroarchseries.distroseries.distribution.name,
231+ distroarchseries.distroseries.name,
232+ distroarchseries.architecturetag)
233+ self.assertDereferences(distroarchseries_url, distroarchseries)
234+
235+ def test_classic_series_url_supports_object_lookup_https(self):
236+ # Classic series URLs (without +series) are compatible with
237+ # webservice object lookup, even if the distribution is configured
238+ # to redirect the default traversal and the vhost is configured to
239+ # use HTTPS. "SERVER_URL": None exposes a bug in lazr.restful <
240+ # 0.22.2.
241+ self.addCleanup(allvhosts.reload)
242+ self.pushConfig("vhosts", use_https=True)
243+ allvhosts.reload()
244+
245+ distroseries = self.factory.makeDistroSeries()
246+ distroseries.distribution.redirect_default_traversal = True
247+ distroseries_url = "/%s/%s" % (
248+ distroseries.distribution.name, distroseries.name)
249+ self.assertDereferences(
250+ distroseries_url, distroseries,
251+ environ={"HTTPS": "on", "SERVER_URL": None})
252+
253+ # Objects subordinate to the redirected series work too.
254+ distroarchseries = self.factory.makeDistroArchSeries(
255+ distroseries=distroseries)
256+ distroarchseries_url = "/%s/%s/%s" % (
257+ distroarchseries.distroseries.distribution.name,
258+ distroarchseries.distroseries.name,
259+ distroarchseries.architecturetag)
260+ self.assertDereferences(
261+ distroarchseries_url, distroarchseries,
262+ environ={"HTTPS": "on", "SERVER_URL": None})
263+
264 def test_new_series_url_supports_object_lookup(self):
265 # New-style +series URLs are compatible with webservice object
266 # lookup.
267diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
268index 1c73f4b..659b8d9 100644
269--- a/lib/lp/registry/configure.zcml
270+++ b/lib/lp/registry/configure.zcml
271@@ -1820,6 +1820,7 @@
272 set_attributes="
273 bug_reported_acknowledgement
274 bug_reporting_guidelines
275+ default_traversal_policy
276 description
277 development_series_alias
278 display_name
279@@ -1837,6 +1838,7 @@
280 official_malone
281 owner
282 package_derivatives_email
283+ redirect_default_traversal
284 redirect_release_uploads
285 summary
286 vcs
287diff --git a/lib/lp/registry/enums.py b/lib/lp/registry/enums.py
288index 631ba6f..30f4a79 100644
289--- a/lib/lp/registry/enums.py
290+++ b/lib/lp/registry/enums.py
291@@ -1,4 +1,4 @@
292-# Copyright 2010-2015 Canonical Ltd. This software is licensed under the
293+# Copyright 2010-2020 Canonical Ltd. This software is licensed under the
294 # GNU Affero General Public License version 3 (see the file LICENSE).
295
296 """Enums for the Registry app."""
297@@ -7,6 +7,7 @@ __metaclass__ = type
298 __all__ = [
299 'BranchSharingPolicy',
300 'BugSharingPolicy',
301+ 'DistributionDefaultTraversalPolicy',
302 'DistroSeriesDifferenceStatus',
303 'DistroSeriesDifferenceType',
304 'EXCLUSIVE_TEAM_POLICY',
305@@ -425,3 +426,19 @@ class VCSType(DBEnumeratedType):
306
307 The Git DVCS is used as the default project or distribution VCS.
308 """)
309+
310+
311+class DistributionDefaultTraversalPolicy(DBEnumeratedType):
312+ """Policy for the default traversal from a distribution.
313+
314+ This determines what the "name" segment in a URL such as
315+ "/{distro}/{name}" (with no intervening segment such as "+source")
316+ means.
317+ """
318+
319+ SERIES = DBItem(0, """
320+ Series
321+
322+ The default traversal from a distribution is used for series of that
323+ distribution.
324+ """)
325diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
326index ecd6c49..8c23b90 100644
327--- a/lib/lp/registry/interfaces/distribution.py
328+++ b/lib/lp/registry/interfaces/distribution.py
329@@ -73,7 +73,10 @@ from lp.bugs.interfaces.bugtarget import (
330 from lp.bugs.interfaces.structuralsubscription import (
331 IStructuralSubscriptionTarget,
332 )
333-from lp.registry.enums import VCSType
334+from lp.registry.enums import (
335+ DistributionDefaultTraversalPolicy,
336+ VCSType,
337+ )
338 from lp.registry.interfaces.announcement import IMakesAnnouncements
339 from lp.registry.interfaces.distributionmirror import IDistributionMirror
340 from lp.registry.interfaces.distroseries import DistroSeriesNameField
341@@ -390,6 +393,20 @@ class IDistributionPublic(
342 description=_(
343 "Version control system for this distribution's code.")))
344
345+ default_traversal_policy = exported(Choice(
346+ title=_("Default traversal policy"),
347+ description=_(
348+ "The type of object that /{distro}/{name} URLs for this "
349+ "distribution resolve to."),
350+ vocabulary=DistributionDefaultTraversalPolicy,
351+ readonly=False, required=False))
352+ redirect_default_traversal = exported(Bool(
353+ title=_("Redirect the default traversal"),
354+ description=_(
355+ "If true, the default traversal is for migration and redirects "
356+ "to a different canonical URL."),
357+ readonly=False, required=False))
358+
359 def getArchiveIDList(archive=None):
360 """Return a list of archive IDs suitable for sqlvalues() or quote().
361
362diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
363index b9baba7..ba9b432 100644
364--- a/lib/lp/registry/model/distribution.py
365+++ b/lib/lp/registry/model/distribution.py
366@@ -88,6 +88,7 @@ from lp.code.interfaces.seriessourcepackagebranch import (
367 from lp.registry.enums import (
368 BranchSharingPolicy,
369 BugSharingPolicy,
370+ DistributionDefaultTraversalPolicy,
371 SpecificationSharingPolicy,
372 VCSType,
373 )
374@@ -262,6 +263,10 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
375 redirect_release_uploads = BoolCol(notNull=True, default=False)
376 development_series_alias = StringCol(notNull=False, default=None)
377 vcs = EnumCol(enum=VCSType, notNull=False)
378+ default_traversal_policy = EnumCol(
379+ enum=DistributionDefaultTraversalPolicy, notNull=False,
380+ default=DistributionDefaultTraversalPolicy.SERIES)
381+ redirect_default_traversal = BoolCol(notNull=False, default=False)
382
383 def __repr__(self):
384 display_name = self.display_name.encode('ASCII', 'backslashreplace')
385diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
386index 9eb671d..a053738 100644
387--- a/lib/lp/registry/tests/test_distribution.py
388+++ b/lib/lp/registry/tests/test_distribution.py
389@@ -31,6 +31,7 @@ from lp.app.interfaces.launchpad import ILaunchpadCelebrities
390 from lp.registry.enums import (
391 BranchSharingPolicy,
392 BugSharingPolicy,
393+ DistributionDefaultTraversalPolicy,
394 EXCLUSIVE_TEAM_POLICY,
395 INCLUSIVE_TEAM_POLICY,
396 )
397@@ -345,6 +346,31 @@ class TestDistribution(TestCaseWithFactory):
398 self.assertEqual(1, result.count())
399 self.assertEqual(first_project, result[0])
400
401+ def test_default_traversal(self):
402+ # By default, a distribution's default traversal refers to its
403+ # series.
404+ distro = self.factory.makeDistribution()
405+ self.assertEqual(
406+ DistributionDefaultTraversalPolicy.SERIES,
407+ distro.default_traversal_policy)
408+ self.assertFalse(distro.redirect_default_traversal)
409+
410+ def test_default_traversal_permissions(self):
411+ # Only distribution owners can change the default traversal
412+ # behaviour.
413+ distro = self.factory.makeDistribution()
414+ with person_logged_in(self.factory.makePerson()):
415+ self.assertRaises(
416+ Unauthorized, setattr, distro, 'default_traversal_policy',
417+ DistributionDefaultTraversalPolicy.SERIES)
418+ self.assertRaises(
419+ Unauthorized, setattr, distro, 'redirect_default_traversal',
420+ True)
421+ with person_logged_in(distro.owner):
422+ distro.default_traversal_policy = (
423+ DistributionDefaultTraversalPolicy.SERIES)
424+ distro.redirect_default_traversal = True
425+
426
427 class TestDistributionCurrentSourceReleases(
428 CurrentSourceReleasesMixin, TestCase):

Subscribers

People subscribed via source and target branches

to status/vote changes: