Merge lp:~edwin-grubbs/launchpad/bug-99395-linking-sourcepackages-to-projects into lp:launchpad

Proposed by Edwin Grubbs
Status: Merged
Approved by: Edwin Grubbs
Approved revision: not available
Merged at revision: not available
Proposed branch: lp:~edwin-grubbs/launchpad/bug-99395-linking-sourcepackages-to-projects
Merge into: lp:launchpad
Diff against target: 473 lines (+242/-78)
7 files modified
lib/canonical/launchpad/webapp/launchpadform.py (+6/-0)
lib/lp/bugs/stories/bug-also-affects/xx-also-affects-upstream-default-values.txt (+3/-2)
lib/lp/registry/browser/sourcepackage.py (+134/-46)
lib/lp/registry/browser/tests/sourcepackage-views.txt (+68/-22)
lib/lp/registry/stories/distribution/xx-distribution-packages.txt (+11/-1)
lib/lp/registry/stories/packaging/xx-sourcepackage-packaging.txt (+3/-2)
lib/lp/registry/templates/sourcepackage-edit-packaging.pt (+17/-5)
To merge this branch: bzr merge lp:~edwin-grubbs/launchpad/bug-99395-linking-sourcepackages-to-projects
Reviewer Review Type Date Requested Status
Brad Crittenden (community) code Approve
Review via email: mp+19429@code.launchpad.net

Commit message

Made $sourcepackage/+edit-packaging a two step form since choosing a product series by entering $product/$series was confusing.

To post a comment you must log in.
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

Summary
-------

Make $sourcepackage/+edit-packaging a two step form since users are
confused by having to enter $project/$series.

Implementation details
----------------------

The launchpadform was displaying "(Optional)" next to readonly widgets,
which seems silly.
    lib/canonical/launchpad/webapp/launchpadform.py

Converted SourcePackageChangeUpstreamView to MultiStepView.
    lib/lp/registry/browser/sourcepackage.py
    lib/lp/registry/stories/distribution/xx-distribution-packages.txt

Added multistep info and info on creating a new series if needed.
    lib/lp/registry/templates/sourcepackage-edit-packaging.pt

Tests
-----

./bin/test -vv -t xx-distribution-packages.txt

Demo and Q/A
------------

* Open https://launchpad.dev/ubuntu/warty/+source/iceweasel/+edit-packaging
    * Enter a project name.
    * Click "Continue".
    * Select a series.
    * Click "Change".
    * Verify that the series has changed on the sourcepackage page.

Revision history for this message
Brad Crittenden (bac) wrote :

Edwin this branch looks great. I expected the multistep stuff to be much harder. Thanks for a nice branch and thorough explanations.

review: Approve (code)
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :
Download full text (8.7 KiB)

> Edwin this branch looks great. I expected the multistep stuff to be much
> harder. Thanks for a nice branch and thorough explanations.

Hi Brad,

Here are some tests that were broken by +edit-packaging being two steps
now. I also have changes to browser/sourcepackage.py, since I had
erroneously edit the field's default without copying the field, so the
default value was propagated to other views and was not only a bad value
but also a stale storm object. I'm setting the default manually, since
passing in the render_context is more complicated for the multistep views.

-Edwin

Incremental diff:

=== modified file 'lib/lp/bugs/stories/bug-also-affects/xx-also-affects-upstream-default-values.txt'
--- lib/lp/bugs/stories/bug-also-affects/xx-also-affects-upstream-default-values.txt 2009-06-12 16:36:02 +0000
+++ lib/lp/bugs/stories/bug-also-affects/xx-also-affects-upstream-default-values.txt 2010-02-17 03:16:46 +0000
@@ -37,8 +37,9 @@
 Let's follow the link and specify the packaging information.

     >>> user_browser.getLink('updating the packaging information').click()
- >>> user_browser.getControl(name='field.productseries').value = (
- ... 'thunderbird/trunk')
+ >>> user_browser.getControl(name='field.product').value = 'thunderbird'
+ >>> user_browser.getControl('Continue').click()
+ >>> user_browser.getControl(name='field.productseries').value = ['trunk']
     >>> user_browser.getControl('Change').click()

 Now the upstream product will be chosen automatically also for pmount.

=== modified file 'lib/lp/registry/browser/sourcepackage.py'
--- lib/lp/registry/browser/sourcepackage.py 2010-02-16 18:56:38 +0000
+++ lib/lp/registry/browser/sourcepackage.py 2010-02-18 19:27:48 +0000
@@ -28,6 +28,8 @@
 from zope.schema.vocabulary import (
     getVocabularyRegistry, SimpleVocabulary, SimpleTerm)

+from lazr.restful.interface import copy_field
+
 from canonical.widgets import LaunchpadRadioWidget

 from canonical.launchpad import helpers
@@ -143,8 +145,8 @@

 class SourcePackageChangeUpstreamStepOne(StepView):
     """A view to set the `IProductSeries` of a sourcepackage."""
- schema = IProductSeries
- _field_names = ['product']
+ schema = Interface
+ _field_names = []

     step_name = 'sourcepackage_change_upstream_step1'
     template = ViewPageTemplateFile(
@@ -158,7 +160,12 @@
         super(SourcePackageChangeUpstreamStepOne, self).setUpFields()
         series = self.context.productseries
         if series is not None:
- self.form_fields['product'].field.default = series.product
+ default = series.product
+ else:
+ default = None
+ product_field = copy_field(
+ IProductSeries['product'], default=default)
+ self.form_fields += Fields(product_field)

     @property
     def cancel_url(self):

=== modified file 'lib/lp/registry/browser/tests/sourcepackage-views.txt'
--- lib/lp/registry/browser/tests/sourcepackage-views.txt 2010-02-12 14:04:16 +0000
+++ lib/lp/registry/browser/tests/sourcepackage-views.txt 2010-02-18 20:37:41 +0000
@@ -22,35 +22,67 @@
     >>> print view.page_title
     Link to an upstream project

- >>> print view.cancel...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/webapp/launchpadform.py'
2--- lib/canonical/launchpad/webapp/launchpadform.py 2009-08-04 00:41:49 +0000
3+++ lib/canonical/launchpad/webapp/launchpadform.py 2010-02-19 01:53:17 +0000
4@@ -362,6 +362,12 @@
5 # widgets.
6 if not IInputWidget.providedBy(widget):
7 return False
8+
9+ # Do not show for readonly fields.
10+ context = getattr(widget, 'context', None)
11+ if getattr(context, 'readonly', None):
12+ return False
13+
14 # Do not show the marker for required widgets or always submitted
15 # widgets. Everything else gets the marker.
16 return not (widget.required or
17
18=== modified file 'lib/lp/bugs/stories/bug-also-affects/xx-also-affects-upstream-default-values.txt'
19--- lib/lp/bugs/stories/bug-also-affects/xx-also-affects-upstream-default-values.txt 2009-06-12 16:36:02 +0000
20+++ lib/lp/bugs/stories/bug-also-affects/xx-also-affects-upstream-default-values.txt 2010-02-19 01:53:17 +0000
21@@ -37,8 +37,9 @@
22 Let's follow the link and specify the packaging information.
23
24 >>> user_browser.getLink('updating the packaging information').click()
25- >>> user_browser.getControl(name='field.productseries').value = (
26- ... 'thunderbird/trunk')
27+ >>> user_browser.getControl(name='field.product').value = 'thunderbird'
28+ >>> user_browser.getControl('Continue').click()
29+ >>> user_browser.getControl(name='field.productseries').value = ['trunk']
30 >>> user_browser.getControl('Change').click()
31
32 Now the upstream product will be chosen automatically also for pmount.
33
34=== modified file 'lib/lp/registry/browser/sourcepackage.py'
35--- lib/lp/registry/browser/sourcepackage.py 2010-02-12 11:45:37 +0000
36+++ lib/lp/registry/browser/sourcepackage.py 2010-02-19 01:53:17 +0000
37@@ -18,11 +18,13 @@
38
39 from apt_pkg import ParseSrcDepends
40 from cgi import escape
41+from z3c.ptcompat import ViewPageTemplateFile
42+from zope.app.form.browser import DropdownWidget
43+from zope.app.form.interfaces import IInputWidget
44 from zope.component import getUtility, getMultiAdapter
45-from zope.app.form.interfaces import IInputWidget
46-from zope.formlib.form import Fields, FormFields
47+from zope.formlib.form import Fields
48 from zope.interface import Interface
49-from zope.schema import Choice
50+from zope.schema import Choice, TextLine
51 from zope.schema.vocabulary import (
52 getVocabularyRegistry, SimpleVocabulary, SimpleTerm)
53
54@@ -31,6 +33,7 @@
55 from canonical.widgets import LaunchpadRadioWidget
56
57 from canonical.launchpad import helpers
58+from canonical.launchpad.browser.multistep import MultiStepView, StepView
59 from lp.bugs.browser.bugtask import BugTargetTraversalMixin
60 from canonical.launchpad.browser.packagerelationship import (
61 relationship_builder)
62@@ -39,13 +42,15 @@
63 from lp.services.worlddata.interfaces.country import ICountry
64 from lp.registry.interfaces.packaging import IPackaging
65 from lp.registry.interfaces.pocket import PackagePublishingPocket
66+from lp.registry.interfaces.product import IProductSet
67+from lp.registry.interfaces.productseries import IProductSeries
68+from lp.registry.interfaces.series import SeriesStatus
69 from lp.registry.interfaces.sourcepackage import ISourcePackage
70 from lp.translations.interfaces.potemplate import IPOTemplateSet
71 from canonical.launchpad import _
72 from canonical.launchpad.webapp import (
73 action, ApplicationMenu, custom_widget, GetitemNavigation,
74- LaunchpadEditFormView, LaunchpadFormView, Link, redirection,
75- StandardLaunchpadFacets, stepto)
76+ LaunchpadFormView, Link, redirection, StandardLaunchpadFacets, stepto)
77 from canonical.launchpad.webapp import canonical_url
78 from canonical.launchpad.webapp.authorization import check_permission
79 from canonical.launchpad.webapp.breadcrumb import Breadcrumb
80@@ -138,53 +143,136 @@
81 return Link('+gethelp', 'Help and support options', icon='info')
82
83
84-class SourcePackageChangeUpstreamView(LaunchpadEditFormView):
85- """A view to set the `IProductSeries` of a sourcepackage."""
86- schema = ISourcePackage
87- field_names = ['productseries']
88-
89- label = 'Link to an upstream project'
90- page_title = label
91-
92- @property
93- def cancel_url(self):
94- return canonical_url(self.context)
95-
96- def setUpFields(self):
97- """ See `LaunchpadFormView`.
98-
99- The productseries field is required by the view.
100- """
101- super(SourcePackageChangeUpstreamView, self).setUpFields()
102- field = copy_field(ISourcePackage['productseries'], required=True)
103- self.form_fields = self.form_fields.omit('productseries')
104- self.form_fields = self.form_fields + FormFields(field)
105-
106- def setUpWidgets(self):
107- """See `LaunchpadFormView`.
108-
109- Set the current `IProductSeries` as the default value.
110- """
111- super(SourcePackageChangeUpstreamView, self).setUpWidgets()
112- if self.context.productseries is not None:
113- widget = self.widgets.get('productseries')
114- widget.setRenderedValue(self.context.productseries)
115-
116- def validate(self, data):
117- productseries = data.get('productseries', None)
118- if productseries is None:
119- message = "You must choose a project series."
120- self.setFieldError('productseries', message)
121-
122- @action(_("Change"), name="change")
123- def change(self, action, data):
124+class SourcePackageChangeUpstreamStepOne(StepView):
125+ """A view to set the `IProductSeries` of a sourcepackage."""
126+ schema = Interface
127+ _field_names = []
128+
129+ step_name = 'sourcepackage_change_upstream_step1'
130+ template = ViewPageTemplateFile(
131+ '../templates/sourcepackage-edit-packaging.pt')
132+ label = 'Link to an upstream project'
133+ page_title = label
134+ step_description = 'Choose project'
135+ product = None
136+
137+ def setUpFields(self):
138+ super(SourcePackageChangeUpstreamStepOne, self).setUpFields()
139+ series = self.context.productseries
140+ if series is not None:
141+ default = series.product
142+ else:
143+ default = None
144+ product_field = copy_field(
145+ IProductSeries['product'], default=default)
146+ self.form_fields += Fields(product_field)
147+
148+ @property
149+ def cancel_url(self):
150+ return canonical_url(self.context)
151+
152+ def main_action(self, data):
153+ """See `MultiStepView`."""
154+ self.next_step = SourcePackageChangeUpstreamStepTwo
155+ self.request.form['product'] = data['product']
156+
157+
158+class SourcePackageChangeUpstreamStepTwo(StepView):
159+ """A view to set the `IProductSeries` of a sourcepackage."""
160+ schema = IProductSeries
161+ _field_names = ['product']
162+
163+ step_name = 'sourcepackage_change_upstream_step2'
164+ template = ViewPageTemplateFile(
165+ '../templates/sourcepackage-edit-packaging.pt')
166+ label = 'Link to an upstream project'
167+ page_title = label
168+ step_description = 'Choose project series'
169+ product = None
170+
171+ # The DropdownWidget is used, since the VocabularyPickerWidget
172+ # does not support visible=False to turn it into a hidden input
173+ # to continue passing the variable in the form.
174+ custom_widget('product', DropdownWidget, visible=False)
175+ custom_widget('productseries', LaunchpadRadioWidget)
176+
177+ @property
178+ def cancel_url(self):
179+ return canonical_url(self.context)
180+
181+ def setUpFields(self):
182+ super(SourcePackageChangeUpstreamStepTwo, self).setUpFields()
183+
184+ # The vocabulary for the product series is overridden to just
185+ # include active series from the product selected in the
186+ # previous step.
187+ product_name = self.request.form['field.product']
188+ self.product = getUtility(IProductSet)[product_name]
189+ series_list = [
190+ series for series in self.product.series
191+ if series.status != SeriesStatus.OBSOLETE
192+ ]
193+ dev_focus = self.product.development_focus
194+ if dev_focus in series_list:
195+ series_list.remove(dev_focus)
196+ vocab_terms = [
197+ SimpleTerm(series, series.name, series.name)
198+ for series in series_list
199+ ]
200+ dev_focus_term = SimpleTerm(
201+ dev_focus, dev_focus.name, "%s (Recommended)" % dev_focus.name)
202+ vocab_terms.insert(0, dev_focus_term)
203+
204+ # If the product is not being changed, then the current
205+ # productseries can be the default choice. Otherwise,
206+ # it will not exist in the vocabulary.
207+ if (self.context.productseries is not None
208+ and self.context.productseries.product == self.product):
209+ series_default = self.context.productseries
210+ else:
211+ series_default = None
212+
213+ productseries_choice = Choice(
214+ __name__='productseries',
215+ title=_("Series"),
216+ description=_("The series in this project."),
217+ vocabulary=SimpleVocabulary(vocab_terms),
218+ default=series_default,
219+ required=True)
220+
221+ # The product selected in the previous step should be displayed,
222+ # but a widget can't be readonly and pass its value with the
223+ # form, so the real product field passes the value, and this fake
224+ # product field displays it.
225+ display_product_field = TextLine(
226+ __name__='fake_product',
227+ title=_("Project"),
228+ default=self.product.displayname,
229+ readonly=True)
230+
231+ self.form_fields = (
232+ Fields(display_product_field, productseries_choice)
233+ + self.form_fields)
234+
235+ main_action_label = u'Change'
236+ def main_action(self, data):
237 productseries = data['productseries']
238+ # Because it is part of a multistep view, the next_url can't
239+ # be set until the action is called, or it will skip the step.
240+ self.next_url = canonical_url(self.context)
241 if self.context.productseries == productseries:
242 # There is nothing to do.
243 return
244 self.context.setPackaging(productseries, self.user)
245 self.request.response.addNotification('Upstream link updated.')
246- self.next_url = canonical_url(self.context)
247+
248+
249+class SourcePackageChangeUpstreamView(MultiStepView):
250+ """A view to set the `IProductSeries` of a sourcepackage."""
251+ page_title = SourcePackageChangeUpstreamStepOne.page_title
252+ label = SourcePackageChangeUpstreamStepOne.label
253+ total_steps = 2
254+ first_step = SourcePackageChangeUpstreamStepOne
255
256
257 class SourcePackageView:
258
259=== modified file 'lib/lp/registry/browser/tests/sourcepackage-views.txt'
260--- lib/lp/registry/browser/tests/sourcepackage-views.txt 2010-02-12 14:04:16 +0000
261+++ lib/lp/registry/browser/tests/sourcepackage-views.txt 2010-02-19 01:53:17 +0000
262@@ -22,35 +22,67 @@
263 >>> print view.page_title
264 Link to an upstream project
265
266- >>> print view.cancel_url
267+ >>> print view.view.cancel_url
268 http://launchpad.dev/youbuntu/busy/+source/bonkers
269
270
271-The view allows the logged in user to change product series field. The value
272-of the product series field is None by default because it is not required to
273-create a source package.
274-
275- >>> view.field_names
276- ['productseries']
277-
278- >>> print view.widgets.get('productseries')._getFormValue()
279+The view allows the logged in user to change product series field. The
280+value of the product field is None by default because it is not required
281+to create a source package.
282+
283+ # The product field is added in setUpFields().
284+ >>> view.view.field_names
285+ ['__visited_steps__']
286+ >>> [form_field.__name__ for form_field in view.view.form_fields]
287+ ['__visited_steps__', 'product']
288+
289+ >>> print view.view.widgets.get('product')._getFormValue()
290 <BLANKLINE>
291
292 >>> print package.productseries
293 None
294
295+This is a multistep view. In the first step, the product is specified.
296+
297+ >>> print view.view.__class__.__name__
298+ SourcePackageChangeUpstreamStepOne
299+ >>> print view.view.request.form
300+ {'field.__visited_steps__': 'sourcepackage_change_upstream_step1'}
301+
302 >>> login_person(product.owner)
303 >>> form = {
304- ... 'field.productseries': 'bonkers/crazy',
305- ... 'field.actions.change': 'Change',
306+ ... 'field.product': 'bonkers',
307+ ... 'field.actions.continue': 'Continue',
308 ... }
309+ >>> form.update(view.view.request.form)
310 >>> view = create_initialized_view(
311 ... package, name='+edit-packaging', form=form,
312 ... principal=product.owner)
313- >>> view.errors
314+ >>> view.view.errors
315 []
316
317- >>> print view.next_url
318+In the second step, one of the series of the previously selected
319+product can be chosen from a list of options.
320+
321+ >>> print view.view.__class__.__name__
322+ SourcePackageChangeUpstreamStepTwo
323+ >>> print view.view.request.form['field.__visited_steps__']
324+ sourcepackage_change_upstream_step1|sourcepackage_change_upstream_step2
325+ >>> [term.token for term in view.view.widgets['productseries'].vocabulary]
326+ ['trunk', 'crazy']
327+
328+ >>> form = {
329+ ... 'field.__visited_steps__': 'sourcepackage_change_upstream_step2',
330+ ... 'field.product': 'bonkers',
331+ ... 'field.productseries': 'crazy',
332+ ... 'field.actions.continue': 'continue',
333+ ... }
334+ >>> view = create_initialized_view(
335+ ... package, name='+edit-packaging', form=form,
336+ ... principal=product.owner)
337+
338+ >>> ignored = view.view.render()
339+ >>> print view.view.next_url
340 http://launchpad.dev/youbuntu/busy/+source/bonkers
341
342 >>> for notification in view.request.response.notifications:
343@@ -62,26 +94,40 @@
344
345 >>> transaction.commit()
346
347-The form shows the current product series if it is set.
348+The form shows the current product if it is set.
349
350 >>> view = create_initialized_view(package, name='+edit-packaging')
351- >>> print view.widgets.get('productseries')._getFormValue().name
352+
353+ >>> print view.view.widgets.get('product')._getFormValue().name
354+ bonkers
355+
356+If the same product as the current product series is selected,
357+then the current product series will be the selected option.
358+
359+ >>> form = {
360+ ... 'field.product': 'bonkers',
361+ ... 'field.actions.continue': 'Continue',
362+ ... }
363+ >>> form.update(view.view.request.form)
364+ >>> view = create_initialized_view(
365+ ... package, name='+edit-packaging', form=form,
366+ ... principal=product.owner)
367+ >>> print view.view.widgets.get('productseries')._getFormValue().name
368 crazy
369
370-The form requires a product series. An error is raised if the field is left
371+The form requires a product. An error is raised if the field is left
372 empty.
373
374 >>> form = {
375- ... 'field.productseries': '',
376- ... 'field.actions.change': 'Change',
377+ ... 'field.product': '',
378+ ... 'field.actions.continue': 'Continue',
379 ... }
380 >>> view = create_initialized_view(
381 ... package, name='+edit-packaging', form=form,
382 ... principal=product.owner)
383- >>> for error in view.errors:
384+ >>> for error in view.view.errors:
385 ... print error
386- ('productseries', u'Project series', RequiredMissing())
387- You must choose a project series.
388+ ('product', u'Project', RequiredMissing())
389
390 Submitting the same product series as the current packaging is not an error,
391 but there is no notification message that the upstream link was updated.
392@@ -93,7 +139,7 @@
393 >>> view = create_initialized_view(
394 ... package, name='+edit-packaging', form=form,
395 ... principal=product.owner)
396- >>> view.errors
397+ >>> view.view.errors
398 []
399
400 >>> print view.request.response.notifications
401
402=== modified file 'lib/lp/registry/stories/distribution/xx-distribution-packages.txt'
403--- lib/lp/registry/stories/distribution/xx-distribution-packages.txt 2010-02-09 15:38:13 +0000
404+++ lib/lp/registry/stories/distribution/xx-distribution-packages.txt 2010-02-19 01:53:17 +0000
405@@ -284,8 +284,18 @@
406 >>> print user_browser.url
407 http://launchpad.dev/ubuntu/warty/+source/iceweasel/+edit-packaging
408
409+In step one the project is specified.
410+
411 >>> user_browser.getControl(
412- ... name='field.productseries').value = "firefox/trunk"
413+ ... name='field.product').value = "firefox"
414+ >>> user_browser.getControl('Continue').click()
415+
416+In step two, one of the series for that project can be selected.
417+
418+ >>> series_control = user_browser.getControl(name='field.productseries')
419+ >>> print series_control.options
420+ ['trunk', '1.0']
421+ >>> series_control.value = ['trunk']
422 >>> user_browser.getControl('Change').click()
423
424 Go back to the source page, and now the upstream's description is shown and
425
426=== modified file 'lib/lp/registry/stories/packaging/xx-sourcepackage-packaging.txt'
427--- lib/lp/registry/stories/packaging/xx-sourcepackage-packaging.txt 2010-02-17 13:16:21 +0000
428+++ lib/lp/registry/stories/packaging/xx-sourcepackage-packaging.txt 2010-02-19 01:53:17 +0000
429@@ -23,8 +23,9 @@
430 project. He sets the upstream packaging link and sees that it is set.
431
432 >>> user_browser.getLink('Set upstream link').click()
433- >>> user_browser.getControl(
434- ... name="field.productseries").value = 'thunderbird/trunk'
435+ >>> user_browser.getControl(name='field.product').value = 'thunderbird'
436+ >>> user_browser.getControl('Continue').click()
437+ >>> user_browser.getControl(name='field.productseries').value = ['trunk']
438 >>> user_browser.getControl("Change").click()
439 >>> print extract_text(find_tag_by_id(
440 ... user_browser.contents, 'upstreams'))
441
442=== modified file 'lib/lp/registry/templates/sourcepackage-edit-packaging.pt'
443--- lib/lp/registry/templates/sourcepackage-edit-packaging.pt 2009-08-16 19:42:08 +0000
444+++ lib/lp/registry/templates/sourcepackage-edit-packaging.pt 2010-02-19 01:53:17 +0000
445@@ -10,11 +10,23 @@
446 <div metal:fill-slot="main">
447
448 <div metal:use-macro="context/@@launchpad_form/form">
449- <p metal:fill-slot="extra_info">
450- Links from distribution packages to upstream project series let
451- distribution and upstream maintainers share bugs, patches, and
452- translations efficiently.
453- </p>
454+ <div metal:fill-slot="extra_info">
455+ <h2 class="legend" id="step-title">Step
456+ <tal:step_number tal:replace="view/step_number"/>
457+ (of <tal:total_steps tal:replace="view/total_steps"/>):
458+ <tal:step_description tal:replace="view/step_description"/>
459+ </h2>
460+ <p>
461+ Links from distribution packages to upstream project series let
462+ distribution and upstream maintainers share bugs, patches, and
463+ translations efficiently.
464+ </p>
465+ </div>
466+
467+ <div metal:fill-slot="extra_bottom" tal:condition="view/product">
468+ If you need a new series created, contact the owner of
469+ <a tal:content="structure view/product/fmt:link"/>.
470+ </div>
471 </div>
472
473 </div>