← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-requestBuild-ui into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-requestBuild-ui into lp:launchpad.

Commit message:
Add a basic request-builds view for snap packages.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1476405 in Launchpad itself: "Add support for building snaps"
  https://bugs.launchpad.net/launchpad/+bug/1476405

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-requestBuild-ui/+merge/271650

Add a basic request-builds view for snap packages.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-requestBuild-ui into lp:launchpad.
=== modified file 'lib/lp/snappy/browser/configure.zcml'
--- lib/lp/snappy/browser/configure.zcml	2015-09-18 10:15:54 +0000
+++ lib/lp/snappy/browser/configure.zcml	2015-09-18 13:41:23 +0000
@@ -24,7 +24,9 @@
             template="../templates/snap-index.pt" />
         <browser:menus
             module="lp.snappy.browser.snap"
-            classes="SnapNavigationMenu" />
+            classes="
+                SnapNavigationMenu
+                SnapContextMenu" />
         <browser:navigation
             module="lp.snappy.browser.snap"
             classes="SnapNavigation" />
@@ -42,6 +44,12 @@
             template="../templates/snap-new.pt" />
         <browser:page
             for="lp.snappy.interfaces.snap.ISnap"
+            class="lp.snappy.browser.snap.SnapRequestBuildsView"
+            permission="launchpad.Edit"
+            name="+request-builds"
+            template="../templates/snap-request-builds.pt" />
+        <browser:page
+            for="lp.snappy.interfaces.snap.ISnap"
             class="lp.snappy.browser.snap.SnapAdminView"
             permission="launchpad.Admin"
             name="+admin"

=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2015-09-18 11:05:47 +0000
+++ lib/lp/snappy/browser/snap.py	2015-09-18 13:41:23 +0000
@@ -6,20 +6,26 @@
 __metaclass__ = type
 __all__ = [
     'SnapAddView',
+    'SnapContextMenu',
     'SnapDeleteView',
     'SnapEditView',
     'SnapNavigation',
     'SnapNavigationMenu',
+    'SnapRequestBuildsView',
     'SnapView',
     ]
 
+from lazr.restful.fields import Reference
 from lazr.restful.interface import (
     copy_field,
     use_template,
     )
 from zope.component import getUtility
 from zope.interface import Interface
-from zope.schema import Choice
+from zope.schema import (
+    Choice,
+    List,
+    )
 
 from lp.app.browser.launchpadform import (
     action,
@@ -31,34 +37,44 @@
 from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
 from lp.app.browser.tales import format_link
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
-from lp.app.widgets.itemswidgets import LaunchpadRadioWidget
+from lp.app.widgets.itemswidgets import (
+    LabeledMultiCheckBoxWidget,
+    LaunchpadRadioWidget,
+    )
 from lp.code.browser.widgets.gitref import GitRefWidget
 from lp.code.interfaces.gitref import IGitRef
 from lp.registry.enums import VCSType
+from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.features import getFeatureFlag
+from lp.services.helpers import english_list
 from lp.services.webapp import (
     canonical_url,
+    ContextMenu,
     enabled_with_permission,
     LaunchpadView,
     Link,
     Navigation,
     NavigationMenu,
     stepthrough,
+    structured,
     )
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.breadcrumb import (
     Breadcrumb,
     NameBreadcrumb,
     )
+from lp.snappy.browser.widgets.snaparchive import SnapArchiveWidget
 from lp.snappy.interfaces.snap import (
     ISnap,
     ISnapSet,
+    NoSuchSnap,
     SNAP_FEATURE_FLAG,
+    SnapBuildAlreadyPending,
     SnapFeatureDisabled,
-    NoSuchSnap,
     )
 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
 from lp.soyuz.browser.build import get_build_by_id_str
+from lp.soyuz.interfaces.archive import IArchive
 
 
 class SnapNavigation(Navigation):
@@ -104,6 +120,20 @@
         return Link('+delete', 'Delete snap package', icon='trash-icon')
 
 
+class SnapContextMenu(ContextMenu):
+    """Context menu for snap packages."""
+
+    usedfor = ISnap
+
+    facet = 'overview'
+
+    links = ('request_builds',)
+
+    @enabled_with_permission('launchpad.Edit')
+    def request_builds(self):
+        return Link('+request-builds', 'Request build(s)', icon='add')
+
+
 class SnapView(LaunchpadView):
     """Default view of a Snap."""
 
@@ -145,6 +175,96 @@
     return builds
 
 
+def new_builds_notification_text(builds, already_pending=None):
+    nr_builds = len(builds)
+    if not nr_builds:
+        builds_text = "All requested builds are already queued."
+    elif nr_builds == 1:
+        builds_text = "1 new build has been queued."
+    else:
+        builds_text = "%d new builds have been queued." % nr_builds
+    if nr_builds and already_pending:
+        return structured("<p>%s</p><p>%s</p>", builds_text, already_pending)
+    else:
+        return builds_text
+
+
+class SnapRequestBuildsView(LaunchpadFormView):
+    """A view for requesting builds of a snap package."""
+
+    @property
+    def label(self):
+        return 'Request builds for %s' % self.context.name
+
+    page_title = 'Request builds'
+
+    class schema(Interface):
+        """Schema for requesting a build."""
+
+        archive = Reference(IArchive, title=u'Source archive', required=True)
+        distro_arch_series = List(
+            Choice(vocabulary='SnapDistroArchSeries'),
+            title=u'Architectures', required=True)
+        pocket = Choice(
+            title=u'Pocket', vocabulary=PackagePublishingPocket, required=True)
+
+    custom_widget('archive', SnapArchiveWidget)
+    custom_widget('distro_arch_series', LabeledMultiCheckBoxWidget)
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @property
+    def initial_values(self):
+        """See `LaunchpadFormView`."""
+        return {
+            'archive': self.context.distro_series.main_archive,
+            'pocket': PackagePublishingPocket.RELEASE,
+            }
+
+    def validate(self, data):
+        """See `LaunchpadFormView`."""
+        arches = data.get('distro_arch_series', [])
+        if not arches:
+            self.setFieldError(
+                'distro_arch_series',
+                "You need to select at least one architecture.")
+
+    def requestBuild(self, data):
+        """User action for requesting a number of builds.
+
+        We raise exceptions for most errors, but if there's already a
+        pending build for a particular architecture, we simply record that
+        so that other builds can be queued and a message displayed to the
+        caller.
+        """
+        informational = {}
+        builds = []
+        already_pending = []
+        for arch in data['distro_arch_series']:
+            try:
+                build = self.context.requestBuild(
+                    self.user, data['archive'], arch, data['pocket'])
+                builds.append(build)
+            except SnapBuildAlreadyPending:
+                already_pending.append(arch)
+        if already_pending:
+            informational['already_pending'] = (
+                "An identical build is already pending for %s." %
+                english_list(arch.architecturetag for arch in already_pending))
+        return builds, informational
+
+    @action('Request builds', name='request')
+    def request_action(self, action, data):
+        builds, informational = self.requestBuild(data)
+        self.next_url = self.cancel_url
+        already_pending = informational.get('already_pending')
+        notification_text = new_builds_notification_text(
+            builds, already_pending)
+        self.request.response.addNotification(notification_text)
+
+
 class ISnapEditSchema(Interface):
     """Schema for adding or editing a snap package."""
 

=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	2015-09-16 19:18:25 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2015-09-18 13:41:23 +0000
@@ -9,6 +9,7 @@
     datetime,
     timedelta,
     )
+from textwrap import dedent
 
 from fixtures import FakeLogger
 from mechanize import LinkNotFoundError
@@ -20,6 +21,7 @@
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import BuildStatus
 from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.database.constants import UTC_NOW
 from lp.services.features.testing import FeatureFixture
@@ -508,3 +510,118 @@
         for build in builds[:9]:
             self.setStatus(build, BuildStatus.FULLYBUILT)
         self.assertEqual(list(reversed(builds[1:])), view.builds)
+
+
+class TestSnapRequestBuildsView(BrowserTestCase):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapRequestBuildsView, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+        self.useFixture(FakeLogger())
+        self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        self.distroseries = self.factory.makeDistroSeries(
+            distribution=self.ubuntu, name="shiny", displayname="Shiny")
+        self.architectures = []
+        for processor, architecture in ("386", "i386"), ("amd64", "amd64"):
+            das = self.factory.makeDistroArchSeries(
+                distroseries=self.distroseries, architecturetag=architecture,
+                processor=getUtility(IProcessorSet).getByName(processor))
+            das.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
+            self.architectures.append(das)
+        self.person = self.factory.makePerson()
+        self.snap = self.factory.makeSnap(
+            registrant=self.person, owner=self.person,
+            distroseries=self.distroseries, name=u"snap-name")
+
+    def test_request_builds_page(self):
+        # The +request-builds page is sane.
+        pattern = dedent("""\
+            Request builds for snap-name
+            Snap packages
+            snap-name
+            Request builds
+            Source archive:
+            Primary Archive for Ubuntu Linux
+            PPA
+            (Find&hellip;)
+            Architectures:
+            amd64
+            i386
+            Pocket:
+            Release
+            Security
+            Updates
+            Proposed
+            Backports
+            or
+            Cancel""")
+        main_text = self.getMainText(
+            self.snap, "+request-builds", user=self.person)
+        self.assertEqual(pattern, main_text)
+
+    def test_request_builds_not_owner(self):
+        # A user without launchpad.Edit cannot request builds.
+        self.assertRaises(
+            Unauthorized, self.getViewBrowser, self.snap, "+request-builds")
+
+    def test_request_builds_action(self):
+        # Requesting a build creates pending builds.
+        browser = self.getViewBrowser(
+            self.snap, "+request-builds", user=self.person)
+        browser.getControl("amd64").click()
+        browser.getControl("i386").click()
+        browser.getControl("Request builds").click()
+
+        login_person(self.person)
+        builds = self.snap.pending_builds
+        self.assertContentEqual(
+            [self.ubuntu.main_archive], set(build.archive for build in builds))
+        self.assertContentEqual(
+            ["amd64", "i386"],
+            [build.distro_arch_series.architecturetag for build in builds])
+        self.assertContentEqual(
+            [PackagePublishingPocket.RELEASE],
+            set(build.pocket for build in builds))
+        self.assertContentEqual(
+            [2505], set(build.buildqueue_record.lastscore for build in builds))
+
+    def test_request_builds_ppa(self):
+        # Selecting a different archive creates builds in that archive.
+        ppa = self.factory.makeArchive(
+            distribution=self.ubuntu, owner=self.person, name="snap-ppa")
+        browser = self.getViewBrowser(
+            self.snap, "+request-builds", user=self.person)
+        browser.getControl("PPA").click()
+        browser.getControl(name="field.archive.ppa").value = ppa.reference
+        browser.getControl("amd64").click()
+        browser.getControl("Request builds").click()
+
+        login_person(self.person)
+        builds = self.snap.pending_builds
+        self.assertEqual([ppa], [build.archive for build in builds])
+
+    def test_request_builds_no_architectures(self):
+        # Selecting no architectures causes a validation failure.
+        browser = self.getViewBrowser(
+            self.snap, "+request-builds", user=self.person)
+        browser.getControl("Request builds").click()
+        self.assertIn(
+            "You need to select at least one architecture.",
+            extract_text(find_main_content(browser.contents)))
+
+    def test_request_builds_rejects_duplicate(self):
+        # A duplicate build request causes a notification.
+        self.snap.requestBuild(
+            self.person, self.ubuntu.main_archive, self.distroseries["amd64"],
+            PackagePublishingPocket.RELEASE)
+        browser = self.getViewBrowser(
+            self.snap, "+request-builds", user=self.person)
+        browser.getControl("amd64").click()
+        browser.getControl("i386").click()
+        browser.getControl("Request builds").click()
+        main_text = extract_text(find_main_content(browser.contents))
+        self.assertIn("1 new build has been queued.", main_text)
+        self.assertIn(
+            "An identical build is already pending for amd64.", main_text)

=== added directory 'lib/lp/snappy/browser/widgets'
=== added file 'lib/lp/snappy/browser/widgets/__init__.py'
=== added file 'lib/lp/snappy/browser/widgets/snaparchive.py'
--- lib/lp/snappy/browser/widgets/snaparchive.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/widgets/snaparchive.py	2015-09-18 13:41:23 +0000
@@ -0,0 +1,132 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+__all__ = [
+    'SnapArchiveWidget',
+    ]
+
+from z3c.ptcompat import ViewPageTemplateFile
+from zope.formlib.interfaces import (
+    ConversionError,
+    IInputWidget,
+    MissingInputError,
+    WidgetInputError,
+    )
+from zope.formlib.utility import setUpWidget
+from zope.formlib.widget import (
+    BrowserWidget,
+    InputErrors,
+    InputWidget,
+    renderElement,
+    )
+from zope.interface import implementer
+from zope.schema import Choice
+
+from lp.app.errors import UnexpectedFormData
+from lp.app.validators import LaunchpadValidationError
+from lp.services.webapp.interfaces import (
+    IAlwaysSubmittedWidget,
+    IMultiLineWidgetLayout,
+    )
+from lp.soyuz.interfaces.archive import IArchive
+
+
+@implementer(IMultiLineWidgetLayout, IAlwaysSubmittedWidget, IInputWidget)
+class SnapArchiveWidget(BrowserWidget, InputWidget):
+
+    template = ViewPageTemplateFile("templates/snaparchive.pt")
+    default_option = "primary"
+    _widgets_set_up = False
+
+    def setUpSubWidgets(self):
+        if self._widgets_set_up:
+            return
+        fields = [
+            Choice(
+                __name__="ppa", title=u"PPA", required=True, vocabulary="PPA"),
+            ]
+        for field in fields:
+            setUpWidget(
+                self, field.__name__, field, IInputWidget, prefix=self.name)
+        self._widgets_set_up = True
+
+    def setUpOptions(self):
+        """Set up options to be rendered."""
+        self.options = {}
+        for option in ["primary", "ppa"]:
+            attributes = dict(
+                type="radio", name=self.name, value=option,
+                id="%s.option.%s" % (self.name, option))
+            if self.request.form_ng.getOne(
+                    self.name, self.default_option) == option:
+                attributes["checked"] = "checked"
+            self.options[option] = renderElement("input", **attributes)
+
+    @property
+    def main_archive(self):
+        return self.context.context.distro_series.main_archive
+
+    def setRenderedValue(self, value):
+        """See `IWidget`."""
+        self.setUpSubWidgets()
+        if value is None or not IArchive.providedBy(value):
+            raise AssertionError("Not a valid value: %r" % value)
+        if value.is_primary:
+            self.default_option = "primary"
+        elif value.is_ppa:
+            self.default_option = "ppa"
+            self.ppa_widget.setRenderedValue(value)
+        else:
+            raise AssertionError("Not a primary archive or a PPA: %r" % value)
+
+    def hasInput(self):
+        """See `IInputWidget`."""
+        return self.name in self.request.form
+
+    def hasValidInput(self):
+        """See `IInputWidget`."""
+        try:
+            self.getInputValue()
+            return True
+        except (InputErrors, UnexpectedFormData):
+            return False
+
+    def getInputValue(self):
+        """See `IInputWidget`."""
+        self.setUpSubWidgets()
+        form_value = self.request.form_ng.getOne(self.name)
+        if form_value == "primary":
+            return self.main_archive
+        elif form_value == "ppa":
+            try:
+                ppa = self.ppa_widget.getInputValue()
+            except MissingInputError:
+                raise WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError("Please choose a PPA."))
+            except ConversionError:
+                entered_name = self.request.form_ng.getOne(
+                    "%s.ppa" % self.name)
+                raise WidgetInputError(
+                    self.name, self.label,
+                    LaunchpadValidationError(
+                        "There is no PPA named '%s' registered in Launchpad." %
+                        entered_name))
+            return ppa
+
+    def error(self):
+        """See `IBrowserWidget`."""
+        try:
+            if self.hasInput():
+                self.getInputValue()
+        except InputErrors as error:
+            self._error = error
+        return super(SnapArchiveWidget, self).error()
+
+    def __call__(self):
+        """See `IBrowserWidget`."""
+        self.setUpSubWidgets()
+        self.setUpOptions()
+        return self.template()

=== added directory 'lib/lp/snappy/browser/widgets/templates'
=== added file 'lib/lp/snappy/browser/widgets/templates/snaparchive.pt'
--- lib/lp/snappy/browser/widgets/templates/snaparchive.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/widgets/templates/snaparchive.pt	2015-09-18 13:41:23 +0000
@@ -0,0 +1,32 @@
+<tal:root
+    xmlns:tal="http://xml.zope.org/namespaces/tal";
+    omit-tag="">
+
+<table>
+  <tr>
+    <td colspan="2">
+      <label>
+        <input
+            type="radio" value="primary"
+            tal:replace="structure view/options/primary" />
+        <tal:primary-displayname replace="view/main_archive/displayname" />
+      </label>
+    </td>
+  </tr>
+
+  <tr>
+    <td>
+      <label>
+        <input
+            type="radio" value="ppa"
+            tal:replace="structure view/options/ppa" />
+        PPA
+      </label>
+    </td>
+    <td>
+      <tal:ppa tal:replace="structure view/ppa_widget" />
+    </td>
+  </tr>
+</table>
+
+</tal:root>

=== added directory 'lib/lp/snappy/browser/widgets/tests'
=== added file 'lib/lp/snappy/browser/widgets/tests/__init__.py'
=== added file 'lib/lp/snappy/browser/widgets/tests/test_snaparchivewidget.py'
--- lib/lp/snappy/browser/widgets/tests/test_snaparchivewidget.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/widgets/tests/test_snaparchivewidget.py	2015-09-18 13:41:23 +0000
@@ -0,0 +1,232 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__metaclass__ = type
+
+import re
+
+from BeautifulSoup import BeautifulSoup
+from lazr.restful.fields import Reference
+from zope.formlib.interfaces import (
+    IBrowserWidget,
+    IInputWidget,
+    WidgetInputError,
+    )
+
+from lp.app.validators import LaunchpadValidationError
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.escaping import html_escape
+from lp.services.webapp.servers import LaunchpadTestRequest
+from lp.snappy.browser.widgets.snaparchive import SnapArchiveWidget
+from lp.snappy.interfaces.snap import SNAP_FEATURE_FLAG
+from lp.soyuz.enums import ArchivePurpose
+from lp.soyuz.interfaces.archive import IArchive
+from lp.soyuz.vocabularies import PPAVocabulary
+from lp.testing import (
+    TestCaseWithFactory,
+    verifyObject,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+
+
+class TestSnapArchiveWidget(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestSnapArchiveWidget, self).setUp()
+        self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+        field = Reference(
+            __name__="archive", schema=IArchive, title=u"Archive")
+        self.context = self.factory.makeSnap()
+        field = field.bind(self.context)
+        request = LaunchpadTestRequest()
+        self.widget = SnapArchiveWidget(field, request)
+
+    def test_implements(self):
+        self.assertTrue(verifyObject(IBrowserWidget, self.widget))
+        self.assertTrue(verifyObject(IInputWidget, self.widget))
+
+    def test_template(self):
+        self.assertTrue(
+            self.widget.template.filename.endswith("snaparchive.pt"),
+            "Template was not set up.")
+
+    def test_default_option(self):
+        # The primary field is the default option.
+        self.assertEqual("primary", self.widget.default_option)
+
+    def test_setUpSubWidgets_first_call(self):
+        # The subwidgets are set up and a flag is set.
+        self.widget.setUpSubWidgets()
+        self.assertTrue(self.widget._widgets_set_up)
+        self.assertIsInstance(
+            self.widget.ppa_widget.context.vocabulary, PPAVocabulary)
+
+    def test_setUpSubWidgets_second_call(self):
+        # The setUpSubWidgets method exits early if a flag is set to
+        # indicate that the widgets were set up.
+        self.widget._widgets_set_up = True
+        self.widget.setUpSubWidgets()
+        self.assertIsNone(getattr(self.widget, "ppa_widget", None))
+
+    def test_setUpOptions_default_primary_checked(self):
+        # The radio button options are composed of the setup widgets with
+        # the primary widget set as the default.
+        self.widget.setUpSubWidgets()
+        self.widget.setUpOptions()
+        self.assertEqual(
+            '<input class="radioType" checked="checked" ' +
+            'id="field.archive.option.primary" name="field.archive" '
+            'type="radio" value="primary" />',
+            self.widget.options["primary"])
+        self.assertEqual(
+            '<input class="radioType" ' +
+            'id="field.archive.option.ppa" name="field.archive" '
+            'type="radio" value="ppa" />',
+            self.widget.options["ppa"])
+
+    def test_setUpOptions_primary_checked(self):
+        # The primary radio button is selected when the form is submitted
+        # when the archive field's value is 'primary'.
+        form = {
+            "field.archive": "primary",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.widget.setUpSubWidgets()
+        self.widget.setUpOptions()
+        self.assertEqual(
+            '<input class="radioType" checked="checked" ' +
+            'id="field.archive.option.primary" name="field.archive" '
+            'type="radio" value="primary" />',
+            self.widget.options["primary"])
+        self.assertEqual(
+            '<input class="radioType" ' +
+            'id="field.archive.option.ppa" name="field.archive" '
+            'type="radio" value="ppa" />',
+            self.widget.options["ppa"])
+
+    def test_setUpOptions_ppa_checked(self):
+        # The ppa radio button is selected when the form is submitted when
+        # the archive field's value is 'ppa'.
+        form = {
+            "field.archive": "ppa",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.widget.setUpSubWidgets()
+        self.widget.setUpOptions()
+        self.assertEqual(
+            '<input class="radioType" ' +
+            'id="field.archive.option.primary" name="field.archive" '
+            'type="radio" value="primary" />',
+            self.widget.options["primary"])
+        self.assertEqual(
+            '<input class="radioType" checked="checked" ' +
+            'id="field.archive.option.ppa" name="field.archive" '
+            'type="radio" value="ppa" />',
+            self.widget.options["ppa"])
+
+    def test_setRenderedValue_primary(self):
+        # Passing a primary archive will set the widget's render state to
+        # 'primary'.
+        self.widget.setUpSubWidgets()
+        self.widget.setRenderedValue(self.context.distro_series.main_archive)
+        self.assertEqual("primary", self.widget.default_option)
+
+    def test_setRenderedValue_personal(self):
+        # Passing a person will set the widget's render state to 'personal'.
+        self.widget.setUpSubWidgets()
+        archive = self.factory.makeArchive(
+            distribution=self.context.distro_series.distribution,
+            purpose=ArchivePurpose.PPA)
+        self.widget.setRenderedValue(archive)
+        self.assertEqual("ppa", self.widget.default_option)
+        self.assertEqual(archive, self.widget.ppa_widget._getCurrentValue())
+
+    def test_hasInput_false(self):
+        # hasInput is false when the widget's name is not in the form data.
+        self.widget.request = LaunchpadTestRequest(form={})
+        self.assertFalse(self.widget.hasInput())
+
+    def test_hasInput_true(self):
+        # hasInput is false when the widget's name is in the form data.
+        self.widget.request = LaunchpadTestRequest(
+            form={"field.archive": "primary"})
+        self.assertTrue(self.widget.hasInput())
+
+    def test_hasValidInput_false(self):
+        # The field input is invalid if any of the submitted parts are
+        # invalid.
+        form = {
+            "field.archive": "ppa",
+            "field.archive.ppa": "non-existent",
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertFalse(self.widget.hasValidInput())
+
+    def test_hasValidInput_true(self):
+        # The field input is valid when all submitted parts are valid.
+        archive = self.factory.makeArchive()
+        form = {
+            "field.archive": "ppa",
+            "field.archive.ppa": archive.reference,
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertTrue(self.widget.hasValidInput())
+
+    def assertGetInputValueError(self, form, message):
+        self.widget.request = LaunchpadTestRequest(form=form)
+        e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
+        self.assertEqual(LaunchpadValidationError(message), e.errors)
+        self.assertEqual(html_escape(message), self.widget.error())
+
+    def test_getInputValue_primary(self):
+        # The field value is the context's primary archive when the primary
+        # radio button is selected.
+        self.widget.request = LaunchpadTestRequest(
+            form={"field.archive": "primary"})
+        self.assertEqual(
+            self.context.distro_series.main_archive,
+            self.widget.getInputValue())
+
+    def test_getInputValue_ppa_missing(self):
+        # An error is raised when the ppa field is missing.
+        form = {"field.archive": "ppa"}
+        self.assertGetInputValueError(form, "Please choose a PPA.")
+
+    def test_getInputValue_ppa_invalid(self):
+        # An error is raised when the PPA does not exist.
+        form = {
+            "field.archive": "ppa",
+            "field.archive.ppa": "non-existent",
+            }
+        self.assertGetInputValueError(
+            form,
+            "There is no PPA named 'non-existent' registered in Launchpad.")
+
+    def test_getInputValue_ppa(self):
+        # The field value is the PPA when the ppa radio button is selected
+        # and the ppa field is valid.
+        archive = self.factory.makeArchive()
+        form = {
+            "field.archive": "ppa",
+            "field.archive.ppa": archive.reference,
+            }
+        self.widget.request = LaunchpadTestRequest(form=form)
+        self.assertEqual(archive, self.widget.getInputValue())
+
+    def test_call(self):
+        # The __call__ method sets up the widgets and the options.
+        markup = self.widget()
+        self.assertIsNotNone(self.widget.ppa_widget)
+        self.assertIn("primary", self.widget.options)
+        self.assertIn("ppa", self.widget.options)
+        soup = BeautifulSoup(markup)
+        fields = soup.findAll(["input", "select"], {"id": re.compile(".*")})
+        expected_ids = [
+            "field.archive.option.primary",
+            "field.archive.option.ppa",
+            "field.archive.ppa",
+            ]
+        ids = [field["id"] for field in fields]
+        self.assertContentEqual(expected_ids, ids)

=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml	2015-08-03 14:50:22 +0000
+++ lib/lp/snappy/configure.zcml	2015-09-18 13:41:23 +0000
@@ -12,6 +12,7 @@
     i18n_domain="launchpad">
 
     <include package=".browser" />
+    <include file="vocabularies.zcml" />
 
     <!-- Snap -->
     <class class="lp.snappy.model.snap.Snap">

=== modified file 'lib/lp/snappy/templates/snap-index.pt'
--- lib/lp/snappy/templates/snap-index.pt	2015-09-16 13:30:33 +0000
+++ lib/lp/snappy/templates/snap-index.pt	2015-09-18 13:41:23 +0000
@@ -99,6 +99,10 @@
     <p tal:condition="not: view/builds">
       This snap package has not been built yet.
     </p>
+    <div tal:define="link context/menu:context/request_builds"
+         tal:condition="link/enabled">
+      <tal:request-builds replace="structure link/fmt:link"/>
+    </div>
   </div>
 
 </body>

=== added file 'lib/lp/snappy/templates/snap-request-builds.pt'
--- lib/lp/snappy/templates/snap-request-builds.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/templates/snap-request-builds.pt	2015-09-18 13:41:23 +0000
@@ -0,0 +1,29 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <metal:formbody fill-slot="widgets">
+      <table class="form">
+        <tal:widget define="widget nocall:view/widgets/archive">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/distro_arch_series">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+        <tal:widget define="widget nocall:view/widgets/pocket">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+      </table>
+    </metal:formbody>
+  </div>
+</div>
+
+</body>
+</html>

=== added file 'lib/lp/snappy/vocabularies.py'
--- lib/lp/snappy/vocabularies.py	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/vocabularies.py	2015-09-18 13:41:23 +0000
@@ -0,0 +1,28 @@
+# Copyright 2015 Canonical Ltd.  This software is licensed under the GNU
+# Affero General Public License version 3 (see the file LICENSE).
+
+"""Snappy vocabularies."""
+
+__metaclass__ = type
+
+__all__ = [
+    'SnapDistroArchSeriesVocabulary',
+    ]
+
+from zope.schema.vocabulary import SimpleTerm
+
+from lp.services.webapp.vocabulary import StormVocabularyBase
+from lp.soyuz.model.distroarchseries import DistroArchSeries
+
+
+class SnapDistroArchSeriesVocabulary(StormVocabularyBase):
+    """All architectures of a Snap's distribution series."""
+
+    _table = DistroArchSeries
+
+    def toTerm(self, das):
+        return SimpleTerm(das, das.id, das.architecturetag)
+
+    @property
+    def _entries(self):
+        return self.context.distro_series.buildable_architectures

=== added file 'lib/lp/snappy/vocabularies.zcml'
--- lib/lp/snappy/vocabularies.zcml	1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/vocabularies.zcml	2015-09-18 13:41:23 +0000
@@ -0,0 +1,18 @@
+<!-- Copyright 2015 Canonical Ltd.  This software is licensed under the
+     GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<configure xmlns="http://namespaces.zope.org/zope";>
+
+    <securedutility
+        name="SnapDistroArchSeries"
+        component="lp.snappy.vocabularies.SnapDistroArchSeriesVocabulary"
+        provides="zope.schema.interfaces.IVocabularyFactory">
+        <allow interface="zope.schema.interfaces.IVocabularyFactory" />
+    </securedutility>
+
+    <class class="lp.snappy.vocabularies.SnapDistroArchSeriesVocabulary">
+        <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
+    </class>
+
+</configure>


Follow ups