← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/processors-for-everyone into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/processors-for-everyone into lp:launchpad.

Commit message:
Allow archive owners to enable/disable unrestricted processors.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/processors-for-everyone/+merge/272093

Allow archive owners to enable/disable unrestricted processors.

Some things to note:

 * Archive.setProcessors moves to launchpad.View and has manual permission checks.  This is necessary because commercial admins have launchpad.Admin on archives but not necessarily launchpad.Edit.
 * Setting the Archive.processors property involves no security checks.  This only affects Archive:+edit, which now uses Archive.setProcessors.
 * Processor selection moves from Archive:+admin to Archive:+edit.
 * Processors that are unavailable in any of an archive's distribution's series are omitted entirely, since showing users greyed-out lpia or whatever is unlikely to achieve much except confusion.
 * Restricted but available processors are shown, but greyed out so that you have an indication that you can ask for them.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/processors-for-everyone into lp:launchpad.
=== modified file 'lib/lp/app/widgets/itemswidgets.py'
--- lib/lp/app/widgets/itemswidgets.py	2013-04-10 08:09:05 +0000
+++ lib/lp/app/widgets/itemswidgets.py	2015-09-23 12:20:46 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Widgets dealing with a choice of options."""
@@ -38,7 +38,7 @@
 
 
 class PlainMultiCheckBoxWidget(MultiCheckBoxWidget):
-    """MultiCheckBoxWidget that copes with CustomWidgetFacotry."""
+    """MultiCheckBoxWidget that copes with CustomWidgetFactory."""
 
     _joinButtonToMessageTemplate = u'%s %s '
 
@@ -48,12 +48,26 @@
         if IChoice.providedBy(vocabulary):
             vocabulary = vocabulary.vocabulary
         MultiCheckBoxWidget.__init__(self, field, vocabulary, request)
+        self._disabled_items = []
+
+    @property
+    def disabled_items(self):
+        return self._disabled_items
+
+    @disabled_items.setter
+    def disabled_items(self, items):
+        if items is None:
+            items = []
+        self._disabled_items = [
+            self.vocabulary.getTerm(item).token for item in items]
 
     def _renderItem(self, index, text, value, name, cssClass, checked=False):
-        """Render a checkbox and text without without label."""
+        """Render a checkbox and text without a label."""
         kw = {}
         if checked:
             kw['checked'] = 'checked'
+        if value in self.disabled_items:
+            kw['disabled'] = 'disabled'
         value = html_escape(value)
         text = html_escape(text)
         id = '%s.%s' % (name, index)
@@ -76,6 +90,8 @@
         kw = {}
         if checked:
             kw['checked'] = 'checked'
+        if value in self.disabled_items:
+            kw['disabled'] = 'disabled'
         value = html_escape(value)
         text = html_escape(text)
         id = '%s.%s' % (name, index)

=== modified file 'lib/lp/app/widgets/tests/test_itemswidgets.py'
--- lib/lp/app/widgets/tests/test_itemswidgets.py	2012-11-26 08:40:20 +0000
+++ lib/lp/app/widgets/tests/test_itemswidgets.py	2015-09-23 12:20:46 +0000
@@ -92,6 +92,17 @@
         expected = '<input ... />&nbsp;&lt;unsafe&gt; &amp;nbsp; title '
         self.assertRenderItem(expected, self.UNSAFE_TERM, checked=False)
 
+    def test__renderItem_disabled(self):
+        # Render item in disabled state.
+        self.widget.disabled_items = ['object-1']
+        expected = (
+            '<input ... disabled="disabled" ... />&nbsp;Safe title')
+        self.assertRenderItem(expected, self.SAFE_TERM)
+        expected = (
+            '<input class="checkboxType" id="test_field.1" name="test_field" '
+            'type="checkbox" value="token-2" />&nbsp;&lt;unsafe&gt; ...')
+        self.assertRenderItem(expected, self.UNSAFE_TERM)
+
 
 class TestLabeledMultiCheckBoxWidget(ItemWidgetTestCase):
     """Test the LabeledMultiCheckBoxWidget class."""
@@ -118,6 +129,19 @@
         expected = '<label .../>&nbsp;&lt;unsafe&gt; &amp;nbsp; title</label>'
         self.assertRenderItem(expected, self.UNSAFE_TERM, checked=False)
 
+    def test__renderItem_disabled(self):
+        # Render item in disabled state.
+        self.widget.disabled_items = ['object-1']
+        expected = (
+            '<label ...><input ... disabled="disabled" ... />&nbsp;'
+            'Safe title</label>')
+        self.assertRenderItem(expected, self.SAFE_TERM)
+        expected = (
+            '<label for="field.test_field.1" style="font-weight: normal">'
+            '<input class="checkboxType" id="test_field.1" name="test_field" '
+            'type="checkbox" value="token-2" />&nbsp;&lt;unsafe&gt; ...')
+        self.assertRenderItem(expected, self.UNSAFE_TERM)
+
 
 class TestLaunchpadRadioWidget(ItemWidgetTestCase):
     """Test the LaunchpadRadioWidget class."""

=== modified file 'lib/lp/registry/browser/distribution.py'
--- lib/lp/registry/browser/distribution.py	2015-07-08 16:05:11 +0000
+++ lib/lp/registry/browser/distribution.py	2015-09-23 12:20:46 +0000
@@ -857,6 +857,7 @@
         LaunchpadFormView.setUpFields(self)
         self.form_fields += self.createRequireVirtualized()
         self.form_fields += self.createEnabledProcessors(
+            getUtility(IProcessorSet).getAll(),
             u"The architectures on which the distribution's main archive can "
             u"build.")
 
@@ -920,6 +921,7 @@
         RegistryEditFormView.setUpFields(self)
         self.form_fields += self.createRequireVirtualized()
         self.form_fields += self.createEnabledProcessors(
+            getUtility(IProcessorSet).getAll(),
             u"The architectures on which the distribution's main archive can "
             u"build.")
 

=== modified file 'lib/lp/soyuz/browser/archive.py'
--- lib/lp/soyuz/browser/archive.py	2015-09-21 19:21:57 +0000
+++ lib/lp/soyuz/browser/archive.py	2015-09-23 12:20:46 +0000
@@ -41,6 +41,7 @@
 from storm.expr import Desc
 from zope.component import getUtility
 from zope.formlib import form
+from zope.formlib.widget import CustomWidgetFactory
 from zope.formlib.widgets import TextAreaWidget
 from zope.interface import (
     implementer,
@@ -81,7 +82,6 @@
     )
 from lp.app.widgets.textwidgets import StrippedTextWidget
 from lp.buildmaster.enums import BuildStatus
-from lp.buildmaster.interfaces.processor import IProcessorSet
 from lp.code.interfaces.sourcepackagerecipebuild import (
     ISourcePackageRecipeBuildSource,
     )
@@ -1992,6 +1992,12 @@
         # IArchive.enabled is a read-only property that cannot be set
         # directly.
         del(data['enabled'])
+        new_processors = data.get('processors')
+        if new_processors is not None:
+            if set(self.context.processors) != set(new_processors):
+                self.context.setProcessors(
+                    new_processors, check_permissions=True, user=self.user)
+            del data['processors']
         self.updateContextFromData(data)
         self.next_url = canonical_url(self.context)
 
@@ -2010,37 +2016,26 @@
                 "enabled", "Deleted PPAs can't be enabled.")
 
 
-class ArchiveEditView(BaseArchiveEditView):
-
-    field_names = [
-        'displayname',
-        'description',
-        'enabled',
-        'publish',
-        'build_debug_symbols',
-        'publish_debug_symbols',
-        ]
-    custom_widget(
-        'description', TextAreaWidget, height=10, width=30)
-    page_title = 'Change details'
-
-    @property
-    def label(self):
-        return 'Edit %s' % self.context.displayname
-
-
 class EnableProcessorsMixin:
     """A mixin that provides processors field support"""
 
-    def createEnabledProcessors(self, description=None):
+    def createEnabledProcessors(self, available_processors, description=None):
         """Creates the 'processors' field."""
         terms = []
-        for processor in sorted(
-                getUtility(IProcessorSet).getAll(), key=attrgetter('name')):
+        disabled = []
+        if check_permission('launchpad.Admin', self.context):
+            can_modify = lambda proc: True
+        else:
+            can_modify = lambda proc: not proc.restricted
+        for processor in sorted(available_processors, key=attrgetter('name')):
             terms.append(SimpleTerm(
                 processor, token=processor.name,
                 title="%s (%s)" % (processor.title, processor.name)))
+            if not can_modify(processor):
+                disabled.append(processor)
         old_field = IArchive['processors']
+        widget = CustomWidgetFactory(
+            LabeledMultiCheckBoxWidget, disabled_items=disabled)
         return form.Fields(
             List(__name__=old_field.__name__,
                  title=old_field.title,
@@ -2048,7 +2043,54 @@
                  required=False,
                  description=old_field.description if description is None
                      else description),
-                 render_context=self.render_context)
+             render_context=self.render_context, custom_widget=widget)
+
+
+class ArchiveEditView(BaseArchiveEditView, EnableProcessorsMixin):
+
+    field_names = [
+        'displayname',
+        'description',
+        'enabled',
+        'publish',
+        'build_debug_symbols',
+        'publish_debug_symbols',
+        ]
+    custom_widget(
+        'description', TextAreaWidget, height=10, width=30)
+    page_title = 'Change details'
+
+    @property
+    def label(self):
+        return 'Edit %s' % self.context.displayname
+
+    @property
+    def initial_values(self):
+        return {
+            'processors': self.context.processors,
+            }
+
+    def setUpFields(self):
+        """Override `LaunchpadEditFormView`.
+
+        See `createEnabledProcessors` method.
+        """
+        super(ArchiveEditView, self).setUpFields()
+        self.form_fields += self.createEnabledProcessors(
+            self.context.available_processors,
+            u"The architectures on which the archive can build. Some "
+            u"architectures are restricted and may only be enabled or "
+            u"disabled by administrators.")
+
+    def validate(self, data):
+        if 'processors' in data:
+            available_processors = set(self.context.available_processors)
+            for processor in self.context.processors:
+                if (processor not in available_processors and
+                        processor not in data['processors']):
+                    # This processor is not currently available for
+                    # selection, but is enabled.  Leave it untouched.
+                    data['processors'].append(processor)
 
 
 class ArchiveAdminView(BaseArchiveEditView, EnableProcessorsMixin):
@@ -2064,7 +2106,6 @@
         'external_dependencies',
         ]
     custom_widget('external_dependencies', TextAreaWidget, height=3)
-    custom_widget('processors', LabeledMultiCheckBoxWidget)
     page_title = 'Administer'
 
     @property
@@ -2105,20 +2146,6 @@
         """
         return self.context.owner.visibility == PersonVisibility.PRIVATE
 
-    @property
-    def initial_values(self):
-        return {
-            'processors': self.context.processors,
-            }
-
-    def setUpFields(self):
-        """Override `LaunchpadEditFormView`.
-
-        See `createEnabledProcessors` method.
-        """
-        super(ArchiveAdminView, self).setUpFields()
-        self.form_fields += self.createEnabledProcessors()
-
 
 class ArchiveDeleteView(LaunchpadFormView):
     """View class for deleting `IArchive`s"""

=== modified file 'lib/lp/soyuz/browser/tests/test_archive.py'
--- lib/lp/soyuz/browser/tests/test_archive.py	2014-06-30 13:51:15 +0000
+++ lib/lp/soyuz/browser/tests/test_archive.py	2015-09-23 12:20:46 +0000
@@ -1,21 +1,142 @@
-# Copyright 2014 Canonical Ltd.  This software is licensed under the
+# Copyright 2014-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
 
-from testtools.matchers import Equals
+from fixtures import FakeLogger
+from testtools.matchers import (
+    Equals,
+    MatchesSetwise,
+    MatchesStructure,
+    )
+from zope.component import getUtility
 
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.services.webapp import canonical_url
+from lp.soyuz.interfaces.archive import CannotModifyArchiveProcessor
 from lp.testing import (
     admin_logged_in,
     login_person,
     record_two_runs,
     TestCaseWithFactory,
     )
-from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    LaunchpadFunctionalLayer,
+    )
 from lp.testing.matchers import HasQueryCount
+from lp.testing.pages import extract_text
 from lp.testing.views import create_initialized_view
 
 
+class TestArchiveEditView(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super(TestArchiveEditView, self).setUp()
+        # None of the Ubuntu series in sampledata have amd64.  Add it to
+        # breezy so that it shows up in the list of available processors.
+        self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        proc_amd64 = getUtility(IProcessorSet).getByName("amd64")
+        self.factory.makeDistroArchSeries(
+            distroseries=self.ubuntu.getSeries("breezy-autotest"),
+            architecturetag="amd64", processor=proc_amd64)
+
+    def test_display_processors(self):
+        ppa = self.factory.makeArchive()
+        owner = login_person(ppa.owner)
+        browser = self.getUserBrowser(
+            canonical_url(ppa) + "/+edit", user=owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(
+            ["Intel 386 (386)", "AMD 64bit (amd64)", "HPPA Processor (hppa)"],
+            [extract_text(option) for option in processors.displayOptions])
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.options)
+
+    def test_edit_processors(self):
+        ppa = self.factory.makeArchive()
+        owner = login_person(ppa.owner)
+        self.assertContentEqual(
+            ["386", "amd64", "hppa"],
+            [processor.name for processor in ppa.processors])
+        browser = self.getUserBrowser(
+            canonical_url(ppa) + "/+edit", user=owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.value)
+        processors.value = ["386", "amd64"]
+        browser.getControl("Save").click()
+        login_person(ppa.owner)
+        self.assertContentEqual(
+            ["386", "amd64"],
+            [processor.name for processor in ppa.processors])
+
+    def test_edit_with_invisible_processor(self):
+        # It's possible for existing archives to have an enabled processor
+        # that's no longer usable with any non-obsolete distroseries, which
+        # will mean it's hidden from the UI, but the non-admin
+        # Archive.setProcessors isn't allowed to disable it.  Editing the
+        # processor list of such an archive leaves the invisible processor
+        # intact.
+        proc_386 = getUtility(IProcessorSet).getByName("386")
+        proc_amd64 = getUtility(IProcessorSet).getByName("amd64")
+        proc_armel = self.factory.makeProcessor(
+            name="armel", restricted=True, build_by_default=False)
+        ppa = self.factory.makeArchive()
+        with admin_logged_in():
+            ppa.processors = [proc_386, proc_amd64, proc_armel]
+        owner = login_person(ppa.owner)
+        browser = self.getUserBrowser(
+            canonical_url(ppa) + "/+edit", user=owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(["386", "amd64"], processors.value)
+        processors.value = ["amd64"]
+        browser.getControl("Save").click()
+        login_person(ppa.owner)
+        self.assertContentEqual(
+            ["amd64", "armel"],
+            [processor.name for processor in ppa.processors])
+
+    def test_edit_processors_restricted(self):
+        # A restricted processor is shown disabled in the UI and cannot be
+        # enabled.
+        self.useFixture(FakeLogger())
+        proc_armhf = self.factory.makeProcessor(
+            name="armhf", restricted=True, build_by_default=False)
+        self.factory.makeDistroArchSeries(
+            distroseries=self.ubuntu.getSeries("breezy-autotest"),
+            architecturetag="armhf", processor=proc_armhf)
+        ppa = self.factory.makeArchive()
+        owner = login_person(ppa.owner)
+        self.assertContentEqual(
+            ["386", "amd64", "hppa"],
+            [processor.name for processor in ppa.processors])
+        browser = self.getUserBrowser(
+            canonical_url(ppa) + "/+edit", user=owner)
+        processors = browser.getControl(name="field.processors")
+        self.assertContentEqual(["386", "amd64", "hppa"], processors.value)
+        self.assertThat(
+            processors.controls, MatchesSetwise(
+                MatchesStructure.byEquality(
+                    optionValue="386", disabled=False),
+                MatchesStructure.byEquality(
+                    optionValue="amd64", disabled=False),
+                MatchesStructure.byEquality(
+                    optionValue="armhf", disabled=True),
+                MatchesStructure.byEquality(
+                    optionValue="hppa", disabled=False),
+                ))
+        # Even if the user works around the disabled checkbox and forcibly
+        # enables it, they can't enable the restricted processor.
+        for control in processors.controls:
+            if control.optionValue == "armhf":
+                control.mech_item.disabled = False
+        processors.value = ["386", "amd64", "armhf"]
+        self.assertRaises(
+            CannotModifyArchiveProcessor, browser.getControl("Save").click)
+
+
 class TestArchiveCopyPackagesView(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer

=== modified file 'lib/lp/soyuz/browser/tests/test_archive_admin_view.py'
--- lib/lp/soyuz/browser/tests/test_archive_admin_view.py	2015-06-19 05:47:43 +0000
+++ lib/lp/soyuz/browser/tests/test_archive_admin_view.py	2015-09-23 12:20:46 +0000
@@ -3,17 +3,14 @@
 
 __metaclass__ = type
 
-from lp.services.webapp import canonical_url
 from lp.services.webapp.servers import LaunchpadTestRequest
 from lp.soyuz.browser.archive import ArchiveAdminView
 from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
 from lp.testing import (
     login,
-    login_celebrity,
     TestCaseWithFactory,
     )
 from lp.testing.layers import LaunchpadFunctionalLayer
-from lp.testing.pages import extract_text
 
 
 class TestArchivePrivacySwitchingView(TestCaseWithFactory):
@@ -91,36 +88,3 @@
             'This archive already has published sources. '
             'It is not possible to switch the privacy.',
             view.errors[0])
-
-
-class TestArchiveAdminView(TestCaseWithFactory):
-
-    layer = LaunchpadFunctionalLayer
-
-    def test_display_processors(self):
-        ppa = self.factory.makeArchive()
-        admin = login_celebrity("admin")
-        browser = self.getUserBrowser(
-            canonical_url(ppa) + "/+admin", user=admin)
-        processors = browser.getControl(name="field.processors")
-        self.assertContentEqual(
-            ["Intel 386 (386)", "AMD 64bit (amd64)", "HPPA Processor (hppa)"],
-            [extract_text(option) for option in processors.displayOptions])
-        self.assertContentEqual(["386", "amd64", "hppa"], processors.options)
-
-    def test_edit_processors(self):
-        ppa = self.factory.makeArchive()
-        admin = login_celebrity("admin")
-        self.assertEqual(
-            ["386", "amd64", "hppa"],
-            [processor.name for processor in ppa.processors])
-        browser = self.getUserBrowser(
-            canonical_url(ppa) + "/+admin", user=admin)
-        processors = browser.getControl(name="field.processors")
-        self.assertContentEqual(["386", "amd64", "hppa"], processors.value)
-        processors.value = ["386", "amd64"]
-        browser.getControl("Save").click()
-        login_celebrity("admin")
-        self.assertEqual(
-            ["386", "amd64"],
-            [processor.name for processor in ppa.processors])

=== modified file 'lib/lp/soyuz/browser/tests/test_archive_webservice.py'
--- lib/lp/soyuz/browser/tests/test_archive_webservice.py	2015-09-16 06:53:39 +0000
+++ lib/lp/soyuz/browser/tests/test_archive_webservice.py	2015-09-23 12:20:46 +0000
@@ -1,4 +1,4 @@
-# Copyright 2010-2013 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2015 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -304,47 +304,83 @@
         self.assertEqual('New ARM Title', ws_proc.title)
         self.assertEqual('New ARM Description', ws_proc.description)
 
-    def test_setProcessors(self):
-        """A new processor can be added to the enabled restricted set."""
+    def setProcessors(self, user, archive_url, names):
+        ws = webservice_for_person(
+            user, permission=OAuthPermission.WRITE_PUBLIC)
+        return ws.named_post(
+            archive_url, 'setProcessors',
+            processors=['/+processors/%s' % name for name in names],
+            api_version='devel')
+
+    def assertProcessors(self, user, archive_url, names):
+        body = webservice_for_person(user).get(
+            archive_url + '/processors', api_version='devel').jsonBody()
+        self.assertContentEqual(
+            names, [entry['name'] for entry in body['entries']])
+
+    def test_setProcessors_admin(self):
+        """An admin can add a new processor to the enabled restricted set."""
         commercial = getUtility(ILaunchpadCelebrities).commercial_admin
         commercial_admin = self.factory.makePerson(member_of=[commercial])
         self.factory.makeProcessor(
             'arm', 'ARM', 'ARM', restricted=True, build_by_default=False)
         ppa_url = api_url(self.factory.makeArchive(purpose=ArchivePurpose.PPA))
-
-        body = webservice_for_person(commercial_admin).get(
-            ppa_url + '/processors', api_version='devel').jsonBody()
-        self.assertContentEqual(
-            ['386', 'hppa', 'amd64'],
-            [entry['name'] for entry in body['entries']])
-
-        response = webservice_for_person(
-                commercial_admin,
-                permission=OAuthPermission.WRITE_PUBLIC).named_post(
-            ppa_url, 'setProcessors',
-            processors=['/+processors/386', '/+processors/arm'],
-            api_version='devel')
+        self.assertProcessors(
+            commercial_admin, ppa_url, ['386', 'hppa', 'amd64'])
+
+        response = self.setProcessors(
+            commercial_admin, ppa_url, ['386', 'arm'])
         self.assertEqual(200, response.status)
-
-        body = webservice_for_person(commercial_admin).get(
-            ppa_url + '/processors', api_version='devel').jsonBody()
-        self.assertContentEqual(
-            ['386', 'arm'], [entry['name'] for entry in body['entries']])
-
-    def test_setProcessors_owner_forbidden(self):
-        """Only commercial admins can call setProcessors."""
+        self.assertProcessors(commercial_admin, ppa_url, ['386', 'arm'])
+
+    def test_setProcessors_non_owner_forbidden(self):
+        """Only commercial admins and archive owners can call setProcessors."""
         self.factory.makeProcessor(
-            'arm', 'ARM', 'ARM', restricted=True, build_by_default=False)
-        archive = self.factory.makeArchive(purpose=ArchivePurpose.PPA)
-        ppa_url = api_url(archive)
-        owner = archive.owner
+            'unrestricted', 'Unrestricted', 'Unrestricted', restricted=False,
+            build_by_default=False)
+        ppa_url = api_url(self.factory.makeArchive(purpose=ArchivePurpose.PPA))
 
-        response = webservice_for_person(owner).named_post(
-            ppa_url, 'setProcessors',
-            processors=['/+processors/386', '/+processors/arm'],
-            api_version='devel')
+        response = self.setProcessors(
+            self.factory.makePerson(), ppa_url, ['386', 'unrestricted'])
         self.assertEqual(401, response.status)
 
+    def test_setProcessors_owner(self):
+        """The archive owner can enable/disable unrestricted processors."""
+        archive = self.factory.makeArchive(purpose=ArchivePurpose.PPA)
+        ppa_url = api_url(archive)
+        owner = archive.owner
+        self.assertProcessors(owner, ppa_url, ['386', 'hppa', 'amd64'])
+
+        response = self.setProcessors(owner, ppa_url, ['386'])
+        self.assertEqual(200, response.status)
+        self.assertProcessors(owner, ppa_url, ['386'])
+
+        response = self.setProcessors(owner, ppa_url, ['386', 'amd64'])
+        self.assertEqual(200, response.status)
+        self.assertProcessors(owner, ppa_url, ['386', 'amd64'])
+
+    def test_setProcessors_owner_restricted_forbidden(self):
+        """The archive owner cannot enable/disable restricted processors."""
+        commercial = getUtility(ILaunchpadCelebrities).commercial_admin
+        commercial_admin = self.factory.makePerson(member_of=[commercial])
+        self.factory.makeProcessor(
+            'arm', 'ARM', 'ARM', restricted=True, build_by_default=False)
+        archive = self.factory.makeArchive(purpose=ArchivePurpose.PPA)
+        ppa_url = api_url(archive)
+        owner = archive.owner
+
+        response = self.setProcessors(owner, ppa_url, ['386', 'arm'])
+        self.assertEqual(403, response.status)
+
+        # If a commercial admin enables arm, the owner cannot disable it.
+        response = self.setProcessors(
+            commercial_admin, ppa_url, ['386', 'arm'])
+        self.assertEqual(200, response.status)
+        self.assertProcessors(owner, ppa_url, ['386', 'arm'])
+
+        response = self.setProcessors(owner, ppa_url, ['386'])
+        self.assertEqual(403, response.status)
+
     def test_enableRestrictedProcessor(self):
         """A new processor can be added to the enabled restricted set."""
         self.ws_version = 'devel'

=== modified file 'lib/lp/soyuz/configure.zcml'
--- lib/lp/soyuz/configure.zcml	2015-09-21 19:21:57 +0000
+++ lib/lp/soyuz/configure.zcml	2015-09-23 12:20:46 +0000
@@ -365,7 +365,7 @@
             permission="launchpad.Edit"
             interface="lp.soyuz.interfaces.archive.IArchiveEdit"
             set_attributes="build_debug_symbols description displayname
-                            publish publish_debug_symbols status
+                            processors publish publish_debug_symbols status
                             suppress_subscription_notifications"/>
         <!--
            NOTE: The 'private' permission controls who can turn a public
@@ -380,7 +380,7 @@
                             enabled_restricted_processors
                             external_dependencies name
                             permit_obsolete_series_uploads
-                            private processors require_virtualized"/>
+                            private require_virtualized"/>
         <require
             permission="launchpad.Moderate"
             set_schema="lp.soyuz.interfaces.archive.IArchiveRestricted"/>

=== modified file 'lib/lp/soyuz/interfaces/archive.py'
--- lib/lp/soyuz/interfaces/archive.py	2015-09-16 06:53:39 +0000
+++ lib/lp/soyuz/interfaces/archive.py	2015-09-23 12:20:46 +0000
@@ -14,6 +14,7 @@
     'CannotCopy',
     'CannotSwitchPrivacy',
     'ComponentNotFound',
+    'CannotModifyArchiveProcessor',
     'CannotUploadToArchive',
     'CannotUploadToPPA',
     'CannotUploadToPocket',
@@ -297,6 +298,19 @@
         self.errors = errors
 
 
+@error_status(httplib.FORBIDDEN)
+class CannotModifyArchiveProcessor(Exception):
+    """Tried to enable or disable a restricted processor on an archive."""
+
+    _fmt = (
+        '%(processor)s is restricted, and may only be enabled or disabled '
+        'by administrators.')
+
+    def __init__(self, processor):
+        super(CannotModifyArchiveProcessor, self).__init__(
+            self._fmt % {'processor': processor.name})
+
+
 class IArchivePublic(IPrivacy, IHasOwner):
     """An Archive interface for publicly available operations."""
     # Most of this stuff should really be on View, but it's needed for
@@ -650,6 +664,20 @@
             readonly=True),
         as_of='devel')
 
+    available_processors = Attribute(
+        "The architectures that are available to be enabled or disabled for "
+        "this archive.")
+
+    @call_with(check_permissions=True, user=REQUEST_USER)
+    @operation_parameters(
+        processors=List(
+            value_type=Reference(schema=IProcessor), required=True),
+    )
+    @export_write_operation()
+    @operation_for_version('devel')
+    def setProcessors(processors, check_permissions=False, user=None):
+        """Set the architectures on which the archive can build."""
+
     def getSourcesForDeletion(name=None, status=None, distroseries=None):
         """All `ISourcePackagePublishingHistory` available for deletion.
 
@@ -2050,15 +2078,6 @@
     """Archive interface for operations restricted by commercial."""
 
     @operation_parameters(
-        processors=List(
-            value_type=Reference(schema=IProcessor), required=True),
-    )
-    @export_write_operation()
-    @operation_for_version('devel')
-    def setProcessors(processors):
-        """Set the architectures on which the archive can build."""
-
-    @operation_parameters(
         processor=Reference(schema=IProcessor, required=True),
     )
     @export_write_operation()

=== modified file 'lib/lp/soyuz/model/archive.py'
--- lib/lp/soyuz/model/archive.py	2015-09-16 06:53:39 +0000
+++ lib/lp/soyuz/model/archive.py	2015-09-23 12:20:46 +0000
@@ -48,6 +48,7 @@
     alsoProvides,
     implementer,
     )
+from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.errors import NotFoundError
@@ -139,6 +140,7 @@
     ArchiveDisabled,
     ArchiveNotPrivate,
     CannotCopy,
+    CannotModifyArchiveProcessor,
     CannotSwitchPrivacy,
     CannotUploadToPocket,
     CannotUploadToPPA,
@@ -2129,23 +2131,60 @@
         """See `IArchive`."""
         self.processors = set(self.processors + [processor])
 
+    @property
+    def available_processors(self):
+        """See `IArchive`."""
+        # Circular imports.
+        from lp.registry.model.distroseries import DistroSeries
+        from lp.soyuz.model.distroarchseries import DistroArchSeries
+
+        clauses = [
+            Processor.id == DistroArchSeries.processor_id,
+            DistroArchSeries.distroseriesID == DistroSeries.id,
+            DistroSeries.distribution == self.distribution,
+            ]
+        if not self.permit_obsolete_series_uploads:
+            clauses.append(DistroSeries.status != SeriesStatus.OBSOLETE)
+        return Store.of(self).find(Processor, *clauses).config(
+            distinct=(Processor.id,))
+
     def _getProcessors(self):
         return list(Store.of(self).find(
             Processor,
             Processor.id == ArchiveArch.processor_id,
             ArchiveArch.archive == self))
 
-    def setProcessors(self, processors):
+    def setProcessors(self, processors, check_permissions=False, user=None):
         """See `IArchive`."""
+        if check_permissions:
+            can_modify = None
+            if user is not None:
+                roles = IPersonRoles(user)
+                authz = lambda perm: getAdapter(self, IAuthorization, perm)
+                if authz('launchpad.Admin').checkAuthenticated(roles):
+                    can_modify = lambda proc: True
+                elif authz('launchpad.Edit').checkAuthenticated(roles):
+                    can_modify = lambda proc: not proc.restricted
+            if can_modify is None:
+                raise Unauthorized(
+                    'Permission launchpad.Admin or launchpad.Edit required '
+                    'on %s.' % self)
+        else:
+            can_modify = lambda proc: True
+
         enablements = dict(Store.of(self).find(
             (Processor, ArchiveArch),
             Processor.id == ArchiveArch.processor_id,
             ArchiveArch.archive == self))
         for proc in enablements:
             if proc not in processors:
+                if not can_modify(proc):
+                    raise CannotModifyArchiveProcessor(proc)
                 Store.of(self).remove(enablements[proc])
         for proc in processors:
             if proc not in self.processors:
+                if not can_modify(proc):
+                    raise CannotModifyArchiveProcessor(proc)
                 archivearch = ArchiveArch()
                 archivearch.archive = self
                 archivearch.processor = proc

=== modified file 'lib/lp/soyuz/tests/test_archive.py'
--- lib/lp/soyuz/tests/test_archive.py	2015-09-21 19:21:57 +0000
+++ lib/lp/soyuz/tests/test_archive.py	2015-09-23 12:20:46 +0000
@@ -63,6 +63,7 @@
     ArchiveDependencyError,
     ArchiveDisabled,
     CannotCopy,
+    CannotModifyArchiveProcessor,
     CannotUploadToPocket,
     CannotUploadToPPA,
     CannotUploadToSeries,
@@ -1077,11 +1078,11 @@
     """Ensure that restricted architectures builds can be allowed and
     disallowed correctly."""
 
-    layer = LaunchpadZopelessLayer
+    layer = LaunchpadFunctionalLayer
 
     def setUp(self):
         """Setup an archive with relevant publications."""
-        super(TestProcessors, self).setUp()
+        super(TestProcessors, self).setUp(user='foo.bar@xxxxxxxxxxxxx')
         self.publisher = SoyuzTestPublisher()
         self.publisher.prepareBreezyAutotest()
         self.archive = self.factory.makeArchive()
@@ -1139,6 +1140,43 @@
         self.archive.processors = []
         self.assertContentEqual([], self.archive.processors)
 
+    def test_set_non_admin(self):
+        """Non-admins can only enable or disable unrestricted processors."""
+        self.archive.setProcessors(self.default_procs)
+        self.assertContentEqual(self.default_procs, self.archive.processors)
+        with person_logged_in(self.archive.owner) as owner:
+            # Adding arm is forbidden ...
+            self.assertRaises(
+                CannotModifyArchiveProcessor, self.archive.setProcessors,
+                [self.default_procs[0], self.arm],
+                check_permissions=True, user=owner)
+            # ... but removing amd64 is OK.
+            self.archive.setProcessors(
+                [self.default_procs[0]], check_permissions=True, user=owner)
+            self.assertContentEqual(
+                [self.default_procs[0]], self.archive.processors)
+        with admin_logged_in() as admin:
+            self.archive.setProcessors(
+                [self.default_procs[0], self.arm],
+                check_permissions=True, user=admin)
+            self.assertContentEqual(
+                [self.default_procs[0], self.arm], self.archive.processors)
+        with person_logged_in(self.archive.owner) as owner:
+            hppa = getUtility(IProcessorSet).getByName("hppa")
+            self.assertFalse(hppa.restricted)
+            # Adding hppa while removing arm is forbidden ...
+            self.assertRaises(
+                CannotModifyArchiveProcessor, self.archive.setProcessors,
+                [self.default_procs[0], hppa],
+                check_permissions=True, user=owner)
+            # ... but adding hppa while retaining arm is OK.
+            self.archive.setProcessors(
+                [self.default_procs[0], self.arm, hppa],
+                check_permissions=True, user=owner)
+            self.assertContentEqual(
+                [self.default_procs[0], self.arm, hppa],
+                self.archive.processors)
+
     def test_set_enabled_restricted_processors(self):
         """The deprecated enabled_restricted_processors property still works.
 


Follow ups