launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #19392
[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…)
+ 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