← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:oci-recipe-build-ui into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:oci-recipe-build-ui into launchpad:master with ~cjwatson/launchpad:oci-recipe-processors as a prerequisite.

Commit message:
Add OCIRecipe:+request-builds view

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1847444 in Launchpad itself: "Support OCI image building"
  https://bugs.launchpad.net/launchpad/+bug/1847444

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/381910
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-recipe-build-ui into launchpad:master.
diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
index e3fcc08..548a725 100644
--- a/lib/lp/oci/browser/configure.zcml
+++ b/lib/lp/oci/browser/configure.zcml
@@ -35,6 +35,12 @@
             template="../templates/ocirecipe-new.pt" />
         <browser:page
             for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+            class="lp.oci.browser.ocirecipe.OCIRecipeRequestBuildsView"
+            permission="launchpad.Edit"
+            name="+request-builds"
+            template="../templates/ocirecipe-request-builds.pt" />
+        <browser:page
+            for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
             class="lp.oci.browser.ocirecipe.OCIRecipeAdminView"
             permission="launchpad.Admin"
             name="+admin"
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 1c5d696..9eafed3 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -13,6 +13,7 @@ __all__ = [
     'OCIRecipeEditView',
     'OCIRecipeNavigation',
     'OCIRecipeNavigationMenu',
+    'OCIRecipeRequestBuildsView',
     'OCIRecipeView',
     ]
 
@@ -22,6 +23,10 @@ from lazr.restful.interface import (
     )
 from zope.component import getUtility
 from zope.interface import Interface
+from zope.schema import (
+    Choice,
+    List,
+    )
 
 from lp.app.browser.launchpadform import (
     action,
@@ -30,6 +35,7 @@ from lp.app.browser.launchpadform import (
     )
 from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
 from lp.app.browser.tales import format_link
+from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
 from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.code.browser.widgets.gitref import GitRefWidget
 from lp.oci.interfaces.ocirecipe import (
@@ -38,10 +44,12 @@ from lp.oci.interfaces.ocirecipe import (
     NoSuchOCIRecipe,
     OCI_RECIPE_ALLOW_CREATE,
     OCI_RECIPE_WEBHOOKS_FEATURE_FLAG,
+    OCIRecipeBuildAlreadyPending,
     OCIRecipeFeatureDisabled,
     )
 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
 from lp.services.features import getFeatureFlag
+from lp.services.helpers import english_list
 from lp.services.propertycache import cachedproperty
 from lp.services.webapp import (
     canonical_url,
@@ -51,6 +59,7 @@ from lp.services.webapp import (
     Navigation,
     NavigationMenu,
     stepthrough,
+    structured,
     )
 from lp.services.webapp.breadcrumb import NameBreadcrumb
 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
@@ -148,6 +157,88 @@ def builds_for_recipe(recipe):
     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 OCIRecipeRequestBuildsView(LaunchpadFormView):
+    """A view for requesting builds of an OCI recipe."""
+
+    @property
+    def label(self):
+        return 'Request builds for %s' % self.context.name
+
+    page_title = 'Request builds'
+
+    class schema(Interface):
+        """Schema for requesting a build."""
+
+        distro_arch_series = List(
+            Choice(vocabulary='OCIRecipeDistroArchSeries'),
+            title='Architectures', required=True)
+
+    custom_widget_distro_arch_series = LabeledMultiCheckBoxWidget
+
+    @property
+    def cancel_url(self):
+        return canonical_url(self.context)
+
+    @property
+    def initial_values(self):
+        """See `LaunchpadFormView`."""
+        return {'distro_arch_series': self.context.getAllowedArchitectures()}
+
+    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 requestBuilds(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, arch)
+                builds.append(build)
+            except OCIRecipeBuildAlreadyPending:
+                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.requestBuilds(data)
+        already_pending = informational.get('already_pending')
+        notification_text = new_builds_notification_text(
+            builds, already_pending)
+        self.request.response.addNotification(notification_text)
+        self.next_url = self.cancel_url
+
+
 class IOCIRecipeEditSchema(Interface):
     """Schema for adding or editing an OCI recipe."""
 
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index b840d8c..a956071 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -698,3 +698,90 @@ class TestOCIRecipeView(BaseTestOCIRecipeView):
             self.setStatus(build, BuildStatus.FULLYBUILT)
         del get_property_cache(view).builds
         self.assertEqual(list(reversed(builds[1:])), view.builds)
+
+
+class TestOCIRecipeRequestBuildsView(BaseTestOCIRecipeView):
+
+    def setUp(self):
+        super(TestOCIRecipeRequestBuildsView, self).setUp()
+        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.useFixture(FeatureFixture({
+            OCI_RECIPE_ALLOW_CREATE: "on",
+            "oci.build_series.%s" % self.distroseries.distribution.name:
+                self.distroseries.name,
+            }))
+        oci_project = self.factory.makeOCIProject(
+            pillar=self.distroseries.distribution,
+            ociprojectname="oci-project-name")
+        self.recipe = self.factory.makeOCIRecipe(
+            name="recipe-name", registrant=self.person, owner=self.person,
+            oci_project=oci_project)
+
+    def test_request_builds_page(self):
+        # The +request-builds page is sane.
+        self.assertTextMatchesExpressionIgnoreWhitespace("""
+            Request builds for recipe-name
+            oci-project-name OCI project
+            recipe-name
+            Request builds
+            Architectures:
+            amd64
+            i386
+            or
+            Cancel
+            """,
+            self.getMainText(self.recipe, "+request-builds", user=self.person))
+
+    def test_request_builds_not_owner(self):
+        # A user without launchpad.Edit cannot request builds.
+        self.assertRaises(
+            Unauthorized, self.getViewBrowser, self.recipe, "+request-builds")
+
+    def test_request_builds_action(self):
+        # Requesting a build creates pending builds.
+        browser = self.getViewBrowser(
+            self.recipe, "+request-builds", user=self.person)
+        self.assertTrue(browser.getControl("amd64").selected)
+        self.assertTrue(browser.getControl("i386").selected)
+        browser.getControl("Request builds").click()
+
+        login_person(self.person)
+        builds = self.recipe.pending_builds
+        self.assertContentEqual(
+            ["amd64", "i386"],
+            [build.distro_arch_series.architecturetag for build in builds])
+        self.assertContentEqual(
+            [2510], set(build.buildqueue_record.lastscore for build in builds))
+
+    def test_request_builds_rejects_duplicate(self):
+        # A duplicate build request causes a notification.
+        self.recipe.requestBuild(self.person, self.distroseries["amd64"])
+        browser = self.getViewBrowser(
+            self.recipe, "+request-builds", user=self.person)
+        self.assertTrue(browser.getControl("amd64").selected)
+        self.assertTrue(browser.getControl("i386").selected)
+        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)
+
+    def test_request_builds_no_architectures(self):
+        # Selecting no architectures causes a validation failure.
+        browser = self.getViewBrowser(
+            self.recipe, "+request-builds", user=self.person)
+        browser.getControl("amd64").selected = False
+        browser.getControl("i386").selected = False
+        browser.getControl("Request builds").click()
+        self.assertIn(
+            "You need to select at least one architecture.",
+            extract_text(find_main_content(browser.contents)))
diff --git a/lib/lp/oci/configure.zcml b/lib/lp/oci/configure.zcml
index 43ea399..565412d 100644
--- a/lib/lp/oci/configure.zcml
+++ b/lib/lp/oci/configure.zcml
@@ -9,6 +9,7 @@
     i18n_domain="launchpad">
 
     <include package=".browser" />
+    <include file="vocabularies.zcml" />
 
     <webservice:register module="lp.oci.interfaces.webservice" />
 
diff --git a/lib/lp/oci/templates/ocirecipe-request-builds.pt b/lib/lp/oci/templates/ocirecipe-request-builds.pt
new file mode 100644
index 0000000..cba9e8e
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-request-builds.pt
@@ -0,0 +1,23 @@
+<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/distro_arch_series">
+          <metal:block use-macro="context/@@launchpad_form/widget_row" />
+        </tal:widget>
+      </table>
+    </metal:formbody>
+  </div>
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py
new file mode 100644
index 0000000..2e44624
--- /dev/null
+++ b/lib/lp/oci/vocabularies.py
@@ -0,0 +1,30 @@
+# Copyright 2020 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""OCI vocabularies."""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+__all__ = []
+
+from zope.schema.vocabulary import SimpleTerm
+
+from lp.services.webapp.vocabulary import StormVocabularyBase
+from lp.soyuz.model.distroarchseries import DistroArchSeries
+
+
+class OCIRecipeDistroArchSeriesVocabulary(StormVocabularyBase):
+    """All architectures of an OCI recipe's distribution series."""
+
+    _table = DistroArchSeries
+
+    def toTerm(self, das):
+        return SimpleTerm(das, das.id, das.architecturetag)
+
+    def __iter__(self):
+        for obj in self.context.getAllowedArchitectures():
+            yield self.toTerm(obj)
+
+    def __len__(self):
+        return len(self.context.getAllowedArchitectures())
diff --git a/lib/lp/oci/vocabularies.zcml b/lib/lp/oci/vocabularies.zcml
new file mode 100644
index 0000000..fae4a6d
--- /dev/null
+++ b/lib/lp/oci/vocabularies.zcml
@@ -0,0 +1,18 @@
+<!-- Copyright 2020 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="OCIRecipeDistroArchSeries"
+        component="lp.oci.vocabularies.OCIRecipeDistroArchSeriesVocabulary"
+        provides="zope.schema.interfaces.IVocabularyFactory">
+        <allow interface="zope.schema.interfaces.IVocabularyFactory" />
+    </securedutility>
+
+    <class class="lp.oci.vocabularies.OCIRecipeDistroArchSeriesVocabulary">
+        <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
+    </class>
+
+</configure>