← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-store-add-edit-views into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-store-add-edit-views into lp:launchpad with lp:~cjwatson/launchpad/snap-authorize-view as a prerequisite.

Commit message:
Allow configuring automatic store upload in SnapAddView and SnapEditView.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1572605 in Launchpad itself: "Automatically upload snap builds to store"
  https://bugs.launchpad.net/launchpad/+bug/1572605

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-store-add-edit-views/+merge/294524

Allow configuring automatic store upload in SnapAddView and SnapEditView.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-store-add-edit-views into lp:launchpad.
=== modified file 'lib/lp/app/browser/configure.zcml'
--- lib/lp/app/browser/configure.zcml	2015-08-07 10:12:38 +0000
+++ lib/lp/app/browser/configure.zcml	2016-05-12 15:04:04 +0000
@@ -853,6 +853,12 @@
       name="fmt"
       />
   <adapter
+      for="lp.snappy.interfaces.snappyseries.ISnappySeries"
+      provides="zope.traversing.interfaces.IPathAdapter"
+      factory="lp.app.browser.tales.SnappySeriesFormatterAPI"
+      name="fmt"
+      />
+  <adapter
       for="lp.blueprints.interfaces.specification.ISpecification"
       provides="zope.traversing.interfaces.IPathAdapter"
       factory="lp.app.browser.tales.SpecificationFormatterAPI"

=== modified file 'lib/lp/app/browser/tales.py'
--- lib/lp/app/browser/tales.py	2016-02-04 04:39:30 +0000
+++ lib/lp/app/browser/tales.py	2016-05-12 15:04:04 +0000
@@ -1872,6 +1872,15 @@
                 'owner': self._context.owner.displayname}
 
 
+class SnappySeriesFormatterAPI(CustomizableFormatter):
+    """Adapter providing fmt support for ISnappySeries objects."""
+
+    _link_summary_template = _('%(title)s')
+
+    def _link_summary_values(self):
+        return {'title': self._context.title}
+
+
 class SpecificationFormatterAPI(CustomizableFormatter):
     """Adapter providing fmt support for Specification objects"""
 

=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2016-05-12 15:04:04 +0000
+++ lib/lp/snappy/browser/snap.py	2016-05-12 15:04:04 +0000
@@ -89,7 +89,14 @@
     SnapPrivateFeatureDisabled,
     )
 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
-from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
+from lp.snappy.interfaces.snappyseries import (
+    ISnappyDistroSeriesSet,
+    ISnappySeriesSet,
+    )
+from lp.snappy.interfaces.snapstoreclient import (
+    BadRequestPackageUploadResponse,
+    ISnapStoreClient,
+    )
 from lp.soyuz.browser.archive import EnableProcessorsMixin
 from lp.soyuz.browser.build import get_build_by_id_str
 from lp.soyuz.interfaces.archive import IArchive
@@ -298,9 +305,11 @@
         'name',
         'private',
         'require_virtualized',
+        'store_upload',
         ])
-    distro_series = Choice(
-        vocabulary='BuildableDistroSeries', title=u'Distribution series')
+    store_distro_series = Choice(
+        vocabulary='BuildableSnappyDistroSeries', required=True,
+        title=u'Series')
     vcs = Choice(vocabulary=VCSType, required=True, title=u'VCS')
 
     # Each of these is only required if vcs has an appropriate value.  Later
@@ -308,6 +317,10 @@
     branch = copy_field(ISnap['branch'], required=True)
     git_ref = copy_field(ISnap['git_ref'], required=True)
 
+    # These are only required if store_upload is True.  Later validation
+    # takes care of adjusting the required attribute.
+    store_name = copy_field(ISnap['store_name'], required=True)
+
 
 class SnapAddView(LaunchpadFormView):
     """View for creating snap packages."""
@@ -315,8 +328,14 @@
     page_title = label = 'Create a new snap package'
 
     schema = ISnapEditSchema
-    field_names = ['owner', 'name', 'distro_series']
-    custom_widget('distro_series', LaunchpadRadioWidget)
+    field_names = [
+        'owner',
+        'name',
+        'store_distro_series',
+        'store_upload',
+        'store_name',
+        ]
+    custom_widget('store_distro_series', LaunchpadRadioWidget)
 
     def initialize(self):
         """See `LaunchpadView`."""
@@ -342,13 +361,28 @@
         # accidentally selecting ubuntu-rtm/14.09 or similar.
         # ubuntu.currentseries will always be in BuildableDistroSeries.
         series = getUtility(ILaunchpadCelebrities).ubuntu.currentseries
+        sds_set = getUtility(ISnappyDistroSeriesSet)
         return {
             'owner': self.user,
-            'distro_series': series,
+            'store_distro_series': sds_set.getByDistroSeries(series).first(),
             }
 
+    @property
+    def has_snappy_series(self):
+        return not getUtility(ISnappySeriesSet).getAll().is_empty()
+
+    def validate_widgets(self, data, names=None):
+        """See `LaunchpadFormView`."""
+        if self.widgets.get('store_upload') is not None:
+            # Set widgets as required or optional depending on the
+            # store_upload field.
+            super(SnapAddView, self).validate_widgets(data, ['store_upload'])
+            store_upload = data.get('store_upload', False)
+            self.widgets['store_name'].context.required = store_upload
+        super(SnapAddView, self).validate_widgets(data, names=names)
+
     @action('Create snap package', name='create')
-    def request_action(self, action, data):
+    def create_action(self, action, data):
         if IGitRef.providedBy(self.context):
             kwargs = {'git_ref': self.context}
         else:
@@ -356,9 +390,20 @@
         private = not getUtility(
             ISnapSet).isValidPrivacy(False, data['owner'], **kwargs)
         snap = getUtility(ISnapSet).new(
-            self.user, data['owner'], data['distro_series'], data['name'],
-            private=private, **kwargs)
-        self.next_url = canonical_url(snap)
+            self.user, data['owner'],
+            data['store_distro_series'].distro_series, data['name'],
+            private=private, store_upload=data['store_upload'],
+            store_series=data['store_distro_series'].snappy_series,
+            store_name=data['store_name'], **kwargs)
+        if data['store_upload']:
+            try:
+                self.next_url = SnapAuthorizeView.requestAuthorization(
+                    snap, self.request)
+            except BadRequestPackageUploadResponse as e:
+                self.setFieldError(
+                    'store_upload', 'Cannot upload this package: %s' % e)
+        else:
+            self.next_url = canonical_url(snap)
 
     def validate(self, data):
         super(SnapAddView, self).validate(data)
@@ -390,6 +435,10 @@
                 render_radio_widget_part(widget, value, current_value)
                 for value in (VCSType.BZR, VCSType.GIT)]
 
+    @property
+    def has_snappy_series(self):
+        return not getUtility(ISnappySeriesSet).getAll().is_empty()
+
     def validate_widgets(self, data, names=None):
         """See `LaunchpadFormView`."""
         if self.widgets.get('vcs') is not None:
@@ -405,8 +454,29 @@
                 self.widgets['git_ref'].context.required = True
             else:
                 raise AssertionError("Unknown branch type %s" % vcs)
+        if self.widgets.get('store_upload') is not None:
+            # Set widgets as required or optional depending on the
+            # store_upload field.
+            super(BaseSnapEditView, self).validate_widgets(
+                data, ['store_upload'])
+            store_upload = data.get('store_upload', False)
+            self.widgets['store_name'].context.required = store_upload
         super(BaseSnapEditView, self).validate_widgets(data, names=names)
 
+    def _needStoreReauth(self, data):
+        """Does this change require reauthorizing to the store?"""
+        store_upload = data.get('store_upload', False)
+        store_distro_series = data.get('store_distro_series')
+        store_name = data.get('store_name')
+        if (not store_upload or
+                store_distro_series is None or store_name is None):
+            return False
+        if store_distro_series.snappy_series != self.context.store_series:
+            return True
+        if store_name != self.context.store_name:
+            return True
+        return False
+
     @action('Update snap package', name='update')
     def request_action(self, action, data):
         vcs = data.pop('vcs', None)
@@ -420,8 +490,20 @@
                 self.context.setProcessors(
                     new_processors, check_permissions=True, user=self.user)
             del data['processors']
+        store_upload = data.get('store_upload', False)
+        if not store_upload:
+            data['store_name'] = None
+        need_store_reauth = self._needStoreReauth(data)
         self.updateContextFromData(data)
-        self.next_url = canonical_url(self.context)
+        if need_store_reauth:
+            try:
+                self.next_url = SnapAuthorizeView.requestAuthorization(
+                    self.context, self.request)
+            except BadRequestPackageUploadResponse as e:
+                self.setFieldError(
+                    'store_upload', 'Cannot upload this package: %s' % e)
+        else:
+            self.next_url = canonical_url(self.context)
 
     @property
     def adapters(self):
@@ -464,8 +546,16 @@
     page_title = 'Edit'
 
     field_names = [
-        'owner', 'name', 'distro_series', 'vcs', 'branch', 'git_ref']
-    custom_widget('distro_series', LaunchpadRadioWidget)
+        'owner',
+        'name',
+        'store_distro_series',
+        'store_upload',
+        'store_name',
+        'vcs',
+        'branch',
+        'git_ref',
+        ]
+    custom_widget('store_distro_series', LaunchpadRadioWidget)
     custom_widget('vcs', LaunchpadRadioWidget)
     custom_widget('git_ref', GitRefWidget)
 
@@ -480,11 +570,18 @@
 
     @property
     def initial_values(self):
+        initial_values = {}
+        if self.context.store_series is None:
+            # XXX cjwatson 2016-04-26: Remove this case once all existing
+            # Snaps have had a store_series backfilled.
+            sds_set = getUtility(ISnappyDistroSeriesSet)
+            initial_values['store_distro_series'] = sds_set.getByDistroSeries(
+                self.context.distro_series).first()
         if self.context.git_ref is not None:
-            vcs = VCSType.GIT
+            initial_values['vcs'] = VCSType.GIT
         else:
-            vcs = VCSType.BZR
-        return {'vcs': vcs}
+            initial_values['vcs'] = VCSType.BZR
+        return initial_values
 
     def validate(self, data):
         super(SnapEditView, self).validate(data)

=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	2016-05-12 15:04:04 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2016-05-12 15:04:04 +0000
@@ -12,6 +12,7 @@
 import json
 import re
 from textwrap import dedent
+from urllib2 import HTTPError
 from urlparse import urlsplit
 
 from fixtures import FakeLogger
@@ -53,6 +54,7 @@
     )
 from lp.snappy.interfaces.snap import (
     CannotModifySnapProcessor,
+    ISnapSet,
     SNAP_FEATURE_FLAG,
     SNAP_TESTING_FLAGS,
     SnapFeatureDisabled,
@@ -138,7 +140,7 @@
 
 class TestSnapAddView(BrowserTestCase):
 
-    layer = DatabaseFunctionalLayer
+    layer = LaunchpadFunctionalLayer
 
     def setUp(self):
         super(TestSnapAddView, self).setUp()
@@ -146,24 +148,35 @@
         self.useFixture(FakeLogger())
         self.person = self.factory.makePerson(
             name="test-person", displayname="Test Person")
+        self.distroseries = self.factory.makeUbuntuDistroSeries(
+            version="13.10")
+        with admin_logged_in():
+            self.snappyseries = self.factory.makeSnappySeries(
+                usable_distro_series=[self.distroseries])
 
     def test_initial_distroseries(self):
         # The initial distroseries is the newest that is current or in
         # development.
-        archive = self.factory.makeArchive(owner=self.person)
-        self.factory.makeDistroSeries(
-            distribution=archive.distribution, version="14.04",
-            status=SeriesStatus.DEVELOPMENT)
-        development = self.factory.makeDistroSeries(
-            distribution=archive.distribution, version="14.10",
-            status=SeriesStatus.DEVELOPMENT)
-        self.factory.makeDistroSeries(
-            distribution=archive.distribution, version="15.04",
-            status=SeriesStatus.EXPERIMENTAL)
+        old = self.factory.makeUbuntuDistroSeries(
+            version="14.04", status=SeriesStatus.DEVELOPMENT)
+        development = self.factory.makeUbuntuDistroSeries(
+            version="14.10", status=SeriesStatus.DEVELOPMENT)
+        experimental = self.factory.makeUbuntuDistroSeries(
+            version="15.04", status=SeriesStatus.EXPERIMENTAL)
+        with admin_logged_in():
+            self.factory.makeSnappySeries(
+                usable_distro_series=[old, development, experimental])
+            newest = self.factory.makeSnappySeries(
+                usable_distro_series=[development, experimental])
+            self.factory.makeSnappySeries(
+                usable_distro_series=[old, experimental])
         branch = self.factory.makeAnyBranch()
         with person_logged_in(self.person):
             view = create_initialized_view(branch, "+new-snap")
-        self.assertEqual(development, view.initial_values["distro_series"])
+        self.assertThat(
+            view.initial_values["store_distro_series"],
+            MatchesStructure.byEquality(
+                snappy_series=newest, distro_series=development))
 
     def test_create_new_snap_not_logged_in(self):
         branch = self.factory.makeAnyBranch()
@@ -172,14 +185,11 @@
             no_login=True)
 
     def test_create_new_snap_bzr(self):
-        archive = self.factory.makeArchive()
-        distroseries = self.factory.makeDistroSeries(
-            distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
         branch = self.factory.makeAnyBranch()
         source_display = branch.display_name
         browser = self.getViewBrowser(
             branch, view_name="+new-snap", user=self.person)
-        browser.getControl("Name").value = "snap-name"
+        browser.getControl(name="field.name").value = "snap-name"
         browser.getControl("Create snap package").click()
 
         content = find_main_content(browser.contents)
@@ -188,21 +198,22 @@
             "Test Person", MatchesPickerText(content, "edit-owner"))
         self.assertThat(
             "Distribution series:\n%s\nEdit snap package" %
-            distroseries.fullseriesname,
+            self.distroseries.fullseriesname,
             MatchesTagText(content, "distro_series"))
         self.assertThat(
             "Source:\n%s\nEdit snap package" % source_display,
             MatchesTagText(content, "source"))
+        self.assertThat(
+            "Builds of this snap package are not automatically uploaded to "
+            "the store.\nEdit snap package",
+            MatchesTagText(content, "store_upload"))
 
     def test_create_new_snap_git(self):
-        archive = self.factory.makeArchive()
-        distroseries = self.factory.makeDistroSeries(
-            distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
         [git_ref] = self.factory.makeGitRefs()
         source_display = git_ref.display_name
         browser = self.getViewBrowser(
             git_ref, view_name="+new-snap", user=self.person)
-        browser.getControl("Name").value = "snap-name"
+        browser.getControl(name="field.name").value = "snap-name"
         browser.getControl("Create snap package").click()
 
         content = find_main_content(browser.contents)
@@ -211,11 +222,15 @@
             "Test Person", MatchesPickerText(content, "edit-owner"))
         self.assertThat(
             "Distribution series:\n%s\nEdit snap package" %
-            distroseries.fullseriesname,
+            self.distroseries.fullseriesname,
             MatchesTagText(content, "distro_series"))
         self.assertThat(
             "Source:\n%s\nEdit snap package" % source_display,
             MatchesTagText(content, "source"))
+        self.assertThat(
+            "Builds of this snap package are not automatically uploaded to "
+            "the store.\nEdit snap package",
+            MatchesTagText(content, "store_upload"))
 
     def test_create_new_snap_users_teams_as_owner_options(self):
         # Teams that the user is in are options for the snap package owner.
@@ -230,12 +245,12 @@
             sorted(str(option) for option in options))
 
     def test_create_new_snap_public(self):
-        # Public owner implies in public snap.
+        # Public owner implies public snap.
         branch = self.factory.makeAnyBranch()
 
         browser = self.getViewBrowser(
             branch, view_name="+new-snap", user=self.person)
-        browser.getControl("Name").value = "public-snap"
+        browser.getControl(name="field.name").value = "public-snap"
         browser.getControl("Create snap package").click()
 
         content = find_main_content(browser.contents)
@@ -271,7 +286,7 @@
 
         browser = self.getViewBrowser(
             branch, view_name="+new-snap", user=self.person)
-        browser.getControl("Name").value = "private-snap"
+        browser.getControl(name="field.name").value = "private-snap"
         browser.getControl("Owner").value = ['super-private']
         browser.getControl("Create snap package").click()
 
@@ -282,6 +297,56 @@
             extract_text(find_tag_by_id(browser.contents, "privacy"))
         )
 
+    def test_create_new_snap_store_upload(self):
+        # Creating a new snap and asking for it to be automatically uploaded
+        # to the store sets all the appropriate fields and redirects to SSO
+        # for authorization.
+        branch = self.factory.makeAnyBranch()
+        view_url = canonical_url(branch, view_name="+new-snap")
+        browser = self.getNonRedirectingBrowser(url=view_url, user=self.person)
+        browser.getControl(name="field.name").value = "snap-name"
+        browser.getControl("Automatically upload to store").selected = True
+        browser.getControl("Registered store package name").value = (
+            "store-name")
+        root_macaroon = Macaroon()
+        root_macaroon.add_third_party_caveat(
+            urlsplit(config.launchpad.openid_provider_root).netloc, "",
+            "dummy")
+        root_macaroon_raw = root_macaroon.serialize()
+
+        @all_requests
+        def handler(url, request):
+            self.request = request
+            return {
+                "status_code": 200,
+                "content": {"macaroon": root_macaroon_raw},
+                }
+
+        self.pushConfig("snappy", store_url="http://sca.example/";)
+        with HTTMock(handler):
+            redirection = self.assertRaises(
+                HTTPError, browser.getControl("Create snap package").click)
+        login_person(self.person)
+        snap = getUtility(ISnapSet).getByName(self.person, u"snap-name")
+        self.assertThat(snap, MatchesStructure.byEquality(
+            owner=self.person, distro_series=self.distroseries,
+            name=u"snap-name", source=branch, store_upload=True,
+            store_series=self.snappyseries, store_name=u"store-name",
+            store_secrets={"root": root_macaroon_raw}))
+        self.assertThat(self.request, MatchesStructure.byEquality(
+            url="http://sca.example/api/2.0/acl/package_upload/";,
+            method="POST"))
+        self.assertEqual(
+            {"name": "store-name", "series": self.snappyseries.name},
+            json.loads(self.request.body))
+        self.assertEqual(303, redirection.code)
+        self.assertEqual(
+            canonical_url(snap, rootsite="code") +
+            "/+authorize/+login?field.callback=on&"
+            "macaroon_caveat_id=dummy&"
+            "discharge_macaroon_field=field.discharge_macaroon",
+            redirection.hdrs["Location"])
+
 
 class TestSnapAdminView(BrowserTestCase):
 
@@ -371,27 +436,53 @@
         self.useFixture(FakeLogger())
         self.person = self.factory.makePerson(
             name="test-person", displayname="Test Person")
+        self.distroseries = self.factory.makeUbuntuDistroSeries(
+            version="13.10")
+        with admin_logged_in():
+            self.snappyseries = self.factory.makeSnappySeries(
+                usable_distro_series=[self.distroseries])
+
+    def test_initial_store_series(self):
+        # The initial store_series is the newest that is usable for the
+        # selected distroseries.
+        development = self.factory.makeUbuntuDistroSeries(
+            version="14.10", status=SeriesStatus.DEVELOPMENT)
+        experimental = self.factory.makeUbuntuDistroSeries(
+            version="15.04", status=SeriesStatus.EXPERIMENTAL)
+        with admin_logged_in():
+            self.factory.makeSnappySeries(
+                usable_distro_series=[development, experimental])
+            newest = self.factory.makeSnappySeries(
+                usable_distro_series=[development])
+            self.factory.makeSnappySeries(usable_distro_series=[experimental])
+        snap = self.factory.makeSnap(distroseries=development)
+        with person_logged_in(self.person):
+            view = create_initialized_view(snap, "+edit")
+        self.assertThat(
+            view.initial_values["store_distro_series"],
+            MatchesStructure.byEquality(
+                snappy_series=newest, distro_series=development))
 
     def test_edit_snap(self):
-        archive = self.factory.makeArchive()
-        old_series = self.factory.makeDistroSeries(
-            distribution=archive.distribution, status=SeriesStatus.CURRENT)
+        old_series = self.factory.makeUbuntuDistroSeries()
         old_branch = self.factory.makeAnyBranch()
         snap = self.factory.makeSnap(
             registrant=self.person, owner=self.person, distroseries=old_series,
             branch=old_branch)
         self.factory.makeTeam(
             name="new-team", displayname="New Team", members=[self.person])
-        new_series = self.factory.makeDistroSeries(
-            distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
+        new_series = self.factory.makeUbuntuDistroSeries()
+        with admin_logged_in():
+            new_snappy_series = self.factory.makeSnappySeries(
+                usable_distro_series=[new_series])
         [new_git_ref] = self.factory.makeGitRefs()
 
         browser = self.getViewBrowser(snap, user=self.person)
         browser.getLink("Edit snap package").click()
         browser.getControl("Owner").value = ["new-team"]
-        browser.getControl("Name").value = "new-name"
-        browser.getControl(name="field.distro_series").value = [
-            str(new_series.id)]
+        browser.getControl(name="field.name").value = "new-name"
+        browser.getControl(name="field.store_distro_series").value = [
+            "ubuntu/%s/%s" % (new_series.name, new_snappy_series.name)]
         browser.getControl("Git", index=0).click()
         browser.getControl("Git repository").value = (
             new_git_ref.repository.identity)
@@ -408,6 +499,10 @@
         self.assertThat(
             "Source:\n%s\nEdit snap package" % new_git_ref.display_name,
             MatchesTagText(content, "source"))
+        self.assertThat(
+            "Builds of this snap package are not automatically uploaded to "
+            "the store.\nEdit snap package",
+            MatchesTagText(content, "store_upload"))
 
     def test_edit_snap_sets_date_last_modified(self):
         # Editing a snap package sets the date_last_modified property.
@@ -431,7 +526,7 @@
             registrant=self.person, owner=self.person, name=u"two")
         browser = self.getViewBrowser(snap, user=self.person)
         browser.getLink("Edit snap package").click()
-        browser.getControl("Name").value = "two"
+        browser.getControl(name="field.name").value = "two"
         browser.getControl("Update snap package").click()
         self.assertEqual(
             "There is already a snap package owned by Test Person with this "
@@ -447,6 +542,8 @@
             self.factory.makeDistroArchSeries(
                 distroseries=distroseries, architecturetag=name,
                 processor=processor)
+        with admin_logged_in():
+            self.factory.makeSnappySeries(usable_distro_series=[distroseries])
         return distroseries
 
     def assertSnapProcessors(self, snap, names):
@@ -570,6 +667,51 @@
         login_person(self.person)
         self.assertSnapProcessors(snap, ["386", "armhf"])
 
+    def test_edit_store_upload(self):
+        # Changing store upload settings on a snap sets all the appropriate
+        # fields and redirects to SSO for reauthorization.
+        snap = self.factory.makeSnap(
+            registrant=self.person, owner=self.person,
+            distroseries=self.distroseries, store_upload=True,
+            store_series=self.snappyseries, store_name=u"one")
+        view_url = canonical_url(snap, view_name="+edit")
+        browser = self.getNonRedirectingBrowser(url=view_url, user=self.person)
+        browser.getControl("Registered store package name").value = "two"
+        root_macaroon = Macaroon()
+        root_macaroon.add_third_party_caveat(
+            urlsplit(config.launchpad.openid_provider_root).netloc, "",
+            "dummy")
+        root_macaroon_raw = root_macaroon.serialize()
+
+        @all_requests
+        def handler(url, request):
+            self.request = request
+            return {
+                "status_code": 200,
+                "content": {"macaroon": root_macaroon_raw},
+                }
+
+        self.pushConfig("snappy", store_url="http://sca.example/";)
+        with HTTMock(handler):
+            redirection = self.assertRaises(
+                HTTPError, browser.getControl("Update snap package").click)
+        login_person(self.person)
+        self.assertThat(snap, MatchesStructure.byEquality(
+            store_name=u"two", store_secrets={"root": root_macaroon_raw}))
+        self.assertThat(self.request, MatchesStructure.byEquality(
+            url="http://sca.example/api/2.0/acl/package_upload/";,
+            method="POST"))
+        self.assertEqual(
+            {"name": "two", "series": self.snappyseries.name},
+            json.loads(self.request.body))
+        self.assertEqual(303, redirection.code)
+        self.assertEqual(
+            canonical_url(snap) +
+            "/+authorize/+login?field.callback=on&"
+            "macaroon_caveat_id=dummy&"
+            "discharge_macaroon_field=field.discharge_macaroon",
+            redirection.hdrs["Location"])
+
 
 class TestSnapAuthorizeView(BrowserTestCase):
 
@@ -846,6 +988,8 @@
             Owner: Test Person
             Distribution series: Ubuntu Shiny
             Source: lp://dev/~test-person/\\+junk/snap-branch
+            Builds of this snap package are not automatically uploaded to
+            the store.
             Latest builds
             Status When complete Architecture Archive
             Successfully built 30 minutes ago i386
@@ -867,6 +1011,8 @@
             Owner: Test Person
             Distribution series: Ubuntu Shiny
             Source: ~test-person/\\+git/snap-repository:master
+            Builds of this snap package are not automatically uploaded to
+            the store.
             Latest builds
             Status When complete Architecture Archive
             Successfully built 30 minutes ago i386

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2016-05-11 00:00:47 +0000
+++ lib/lp/snappy/interfaces/snap.py	2016-05-12 15:04:04 +0000
@@ -86,7 +86,10 @@
     PublicPersonChoice,
     )
 from lp.services.webhooks.interfaces import IWebhookTarget
-from lp.snappy.interfaces.snappyseries import ISnappySeries
+from lp.snappy.interfaces.snappyseries import (
+    ISnappyDistroSeries,
+    ISnappySeries,
+    )
 from lp.soyuz.interfaces.archive import IArchive
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
 
@@ -374,6 +377,11 @@
             "The series in which this snap package should be published in the "
             "store."))
 
+    store_distro_series = ReferenceChoice(
+        title=_("Store and distro series"),
+        schema=ISnappyDistroSeries, vocabulary="SnappyDistroSeries",
+        required=False, readonly=False)
+
     store_name = TextLine(
         title=_("Registered store package name"),
         required=False, readonly=False,

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2016-05-11 00:00:47 +0000
+++ lib/lp/snappy/model/snap.py	2016-05-12 15:04:04 +0000
@@ -94,6 +94,7 @@
     SnapPrivateFeatureDisabled,
     )
 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
+from lp.snappy.interfaces.snappyseries import ISnappyDistroSeriesSet
 from lp.snappy.model.snapbuild import SnapBuild
 from lp.soyuz.interfaces.archive import ArchiveDisabled
 from lp.soyuz.model.archive import (
@@ -283,6 +284,18 @@
                     or not self.require_virtualized))]
 
     @property
+    def store_distro_series(self):
+        if self.store_series is None:
+            return None
+        return getUtility(ISnappyDistroSeriesSet).getByBothSeries(
+            self.store_series, self.distro_series)
+
+    @store_distro_series.setter
+    def store_distro_series(self, value):
+        self.distro_series = value.distro_series
+        self.store_series = value.snappy_series
+
+    @property
     def can_upload_to_store(self):
         return (
             config.snappy.store_upload_url is not None and

=== modified file 'lib/lp/snappy/templates/snap-edit.pt'
--- lib/lp/snappy/templates/snap-edit.pt	2016-02-04 00:45:12 +0000
+++ lib/lp/snappy/templates/snap-edit.pt	2016-05-12 15:04:04 +0000
@@ -25,10 +25,28 @@
         <tal:widget define="widget nocall:view/widgets/name">
           <metal:block use-macro="context/@@launchpad_form/widget_row" />
         </tal:widget>
-        <tal:widget define="widget nocall:view/widgets/distro_series">
+        <tal:widget define="widget nocall:view/widgets/store_distro_series">
           <metal:block use-macro="context/@@launchpad_form/widget_row" />
         </tal:widget>
 
+        <tr tal:condition="view/has_snappy_series">
+          <td>
+            <tal:widget define="widget nocall:view/widgets/store_upload">
+              <metal:block use-macro="context/@@launchpad_form/widget_row" />
+            </tal:widget>
+            <table class="subordinate">
+              <tal:widget define="widget nocall:view/widgets/store_name">
+                <metal:block use-macro="context/@@launchpad_form/widget_row" />
+              </tal:widget>
+            </table>
+            <p class="formHelp">
+              If you change any settings related to automatically uploading
+              builds of this snap to the store, then the login service will
+              prompt you to authorize this request.
+            </p>
+          </td>
+        </tr>
+
         <tr>
           <td>
             <div>

=== modified file 'lib/lp/snappy/templates/snap-index.pt'
--- lib/lp/snappy/templates/snap-index.pt	2016-01-27 12:43:00 +0000
+++ lib/lp/snappy/templates/snap-index.pt	2016-05-12 15:04:04 +0000
@@ -51,6 +51,28 @@
       </dl>
     </div>
 
+    <div id="store_upload" class="two-column-list"
+         tal:condition="context/store_upload">
+      <dl id="store_series">
+        <dt>Store series:</dt>
+        <dd>
+          <a tal:replace="structure context/store_series/fmt:link"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+        </dd>
+      </dl>
+      <dl id="store_name">
+        <dt>Registered store package name:</dt>
+        <dd>
+          <span tal:content="context/store_name"/>
+          <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+        </dd>
+      </dl>
+    </div>
+    <p id="store_upload" tal:condition="not: context/store_upload">
+      Builds of this snap package are not automatically uploaded to the store.
+      <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
+    </p>
+
     <h2>Latest builds</h2>
     <table id="latest-builds-listing" class="listing"
            style="margin-bottom: 1em;">

=== modified file 'lib/lp/snappy/templates/snap-new.pt'
--- lib/lp/snappy/templates/snap-new.pt	2016-02-04 00:45:12 +0000
+++ lib/lp/snappy/templates/snap-new.pt	2016-05-12 15:04:04 +0000
@@ -28,9 +28,27 @@
         <tal:widget define="widget nocall:view/widgets/owner">
           <metal:block use-macro="context/@@launchpad_form/widget_row" />
         </tal:widget>
-        <tal:widget define="widget nocall:view/widgets/distro_series">
+        <tal:widget define="widget nocall:view/widgets/store_distro_series">
           <metal:block use-macro="context/@@launchpad_form/widget_row" />
         </tal:widget>
+
+        <tr tal:condition="view/has_snappy_series">
+          <td>
+            <tal:widget define="widget nocall:view/widgets/store_upload">
+              <metal:block use-macro="context/@@launchpad_form/widget_row" />
+            </tal:widget>
+            <table class="subordinate">
+              <tal:widget define="widget nocall:view/widgets/store_name">
+                <metal:block use-macro="context/@@launchpad_form/widget_row" />
+              </tal:widget>
+            </table>
+            <p class="formHelp">
+              If you ask Launchpad to automatically upload builds of this
+              snap to the store on your behalf, then the login service
+              will prompt you to authorize this request.
+            </p>
+          </td>
+        </tr>
       </table>
     </metal:formbody>
   </div>


Follow ups