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

Proposed by Colin Watson
Status: Merged
Merged at revision: 18073
Proposed branch: lp:~cjwatson/launchpad/snap-store-add-edit-views
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/snap-authorize-view
Diff against target: 865 lines (+428/-56)
12 files modified
lib/lp/app/browser/configure.zcml (+6/-0)
lib/lp/app/browser/tales.py (+9/-0)
lib/lp/snappy/browser/snap.py (+123/-19)
lib/lp/snappy/browser/tests/test_snap.py (+184/-34)
lib/lp/snappy/interfaces/snap.py (+9/-1)
lib/lp/snappy/interfaces/snappyseries.py (+3/-0)
lib/lp/snappy/model/snap.py (+13/-0)
lib/lp/snappy/model/snappyseries.py (+4/-0)
lib/lp/snappy/templates/snap-edit.pt (+19/-1)
lib/lp/snappy/templates/snap-index.pt (+22/-0)
lib/lp/snappy/templates/snap-new.pt (+19/-1)
lib/lp/snappy/tests/test_snappyseries.py (+17/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/snap-store-add-edit-views
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+294524@code.launchpad.net

Commit message

Allow configuring automatic store upload in SnapAddView and SnapEditView.

Description of the change

Allow configuring automatic store upload in SnapAddView and SnapEditView.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/app/browser/configure.zcml'
2--- lib/lp/app/browser/configure.zcml 2015-08-07 10:12:38 +0000
3+++ lib/lp/app/browser/configure.zcml 2016-05-27 10:39:59 +0000
4@@ -853,6 +853,12 @@
5 name="fmt"
6 />
7 <adapter
8+ for="lp.snappy.interfaces.snappyseries.ISnappySeries"
9+ provides="zope.traversing.interfaces.IPathAdapter"
10+ factory="lp.app.browser.tales.SnappySeriesFormatterAPI"
11+ name="fmt"
12+ />
13+ <adapter
14 for="lp.blueprints.interfaces.specification.ISpecification"
15 provides="zope.traversing.interfaces.IPathAdapter"
16 factory="lp.app.browser.tales.SpecificationFormatterAPI"
17
18=== modified file 'lib/lp/app/browser/tales.py'
19--- lib/lp/app/browser/tales.py 2016-02-04 04:39:30 +0000
20+++ lib/lp/app/browser/tales.py 2016-05-27 10:39:59 +0000
21@@ -1872,6 +1872,15 @@
22 'owner': self._context.owner.displayname}
23
24
25+class SnappySeriesFormatterAPI(CustomizableFormatter):
26+ """Adapter providing fmt support for ISnappySeries objects."""
27+
28+ _link_summary_template = _('%(title)s')
29+
30+ def _link_summary_values(self):
31+ return {'title': self._context.title}
32+
33+
34 class SpecificationFormatterAPI(CustomizableFormatter):
35 """Adapter providing fmt support for Specification objects"""
36
37
38=== modified file 'lib/lp/snappy/browser/snap.py'
39--- lib/lp/snappy/browser/snap.py 2016-05-24 04:45:38 +0000
40+++ lib/lp/snappy/browser/snap.py 2016-05-27 10:39:59 +0000
41@@ -26,6 +26,7 @@
42 )
43 from pymacaroons import Macaroon
44 from zope.component import getUtility
45+from zope.error.interfaces import IErrorReportingUtility
46 from zope.interface import Interface
47 from zope.schema import (
48 Choice,
49@@ -87,7 +88,11 @@
50 SnapPrivateFeatureDisabled,
51 )
52 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
53-from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
54+from lp.snappy.interfaces.snappyseries import ISnappyDistroSeriesSet
55+from lp.snappy.interfaces.snapstoreclient import (
56+ BadRequestPackageUploadResponse,
57+ ISnapStoreClient,
58+ )
59 from lp.soyuz.browser.archive import EnableProcessorsMixin
60 from lp.soyuz.browser.build import get_build_by_id_str
61 from lp.soyuz.interfaces.archive import IArchive
62@@ -296,9 +301,11 @@
63 'name',
64 'private',
65 'require_virtualized',
66+ 'store_upload',
67 ])
68- distro_series = Choice(
69- vocabulary='BuildableDistroSeries', title=u'Distribution series')
70+ store_distro_series = Choice(
71+ vocabulary='BuildableSnappyDistroSeries', required=True,
72+ title=u'Series')
73 vcs = Choice(vocabulary=VCSType, required=True, title=u'VCS')
74
75 # Each of these is only required if vcs has an appropriate value. Later
76@@ -306,15 +313,44 @@
77 branch = copy_field(ISnap['branch'], required=True)
78 git_ref = copy_field(ISnap['git_ref'], required=True)
79
80-
81-class SnapAddView(LaunchpadFormView):
82+ # These are only required if store_upload is True. Later validation
83+ # takes care of adjusting the required attribute.
84+ store_name = copy_field(ISnap['store_name'], required=True)
85+
86+
87+def log_oops(error, request):
88+ """Log an oops report without raising an error."""
89+ info = (error.__class__, error, None)
90+ getUtility(IErrorReportingUtility).raising(info, request)
91+
92+
93+class SnapAuthorizeMixin:
94+
95+ def requestAuthorization(self, snap):
96+ try:
97+ self.next_url = SnapAuthorizeView.requestAuthorization(
98+ snap, self.request)
99+ except BadRequestPackageUploadResponse as e:
100+ self.setFieldError(
101+ 'store_upload',
102+ 'Cannot get permission from the store to upload this package.')
103+ log_oops(e, self.request)
104+
105+
106+class SnapAddView(LaunchpadFormView, SnapAuthorizeMixin):
107 """View for creating snap packages."""
108
109 page_title = label = 'Create a new snap package'
110
111 schema = ISnapEditSchema
112- field_names = ['owner', 'name', 'distro_series']
113- custom_widget('distro_series', LaunchpadRadioWidget)
114+ field_names = [
115+ 'owner',
116+ 'name',
117+ 'store_distro_series',
118+ 'store_upload',
119+ 'store_name',
120+ ]
121+ custom_widget('store_distro_series', LaunchpadRadioWidget)
122
123 def initialize(self):
124 """See `LaunchpadView`."""
125@@ -340,13 +376,28 @@
126 # accidentally selecting ubuntu-rtm/14.09 or similar.
127 # ubuntu.currentseries will always be in BuildableDistroSeries.
128 series = getUtility(ILaunchpadCelebrities).ubuntu.currentseries
129+ sds_set = getUtility(ISnappyDistroSeriesSet)
130 return {
131 'owner': self.user,
132- 'distro_series': series,
133+ 'store_distro_series': sds_set.getByDistroSeries(series).first(),
134 }
135
136+ @property
137+ def has_snappy_distro_series(self):
138+ return not getUtility(ISnappyDistroSeriesSet).getAll().is_empty()
139+
140+ def validate_widgets(self, data, names=None):
141+ """See `LaunchpadFormView`."""
142+ if self.widgets.get('store_upload') is not None:
143+ # Set widgets as required or optional depending on the
144+ # store_upload field.
145+ super(SnapAddView, self).validate_widgets(data, ['store_upload'])
146+ store_upload = data.get('store_upload', False)
147+ self.widgets['store_name'].context.required = store_upload
148+ super(SnapAddView, self).validate_widgets(data, names=names)
149+
150 @action('Create snap package', name='create')
151- def request_action(self, action, data):
152+ def create_action(self, action, data):
153 if IGitRef.providedBy(self.context):
154 kwargs = {'git_ref': self.context}
155 else:
156@@ -354,9 +405,15 @@
157 private = not getUtility(
158 ISnapSet).isValidPrivacy(False, data['owner'], **kwargs)
159 snap = getUtility(ISnapSet).new(
160- self.user, data['owner'], data['distro_series'], data['name'],
161- private=private, **kwargs)
162- self.next_url = canonical_url(snap)
163+ self.user, data['owner'],
164+ data['store_distro_series'].distro_series, data['name'],
165+ private=private, store_upload=data['store_upload'],
166+ store_series=data['store_distro_series'].snappy_series,
167+ store_name=data['store_name'], **kwargs)
168+ if data['store_upload']:
169+ self.requestAuthorization(snap)
170+ else:
171+ self.next_url = canonical_url(snap)
172
173 def validate(self, data):
174 super(SnapAddView, self).validate(data)
175@@ -370,7 +427,7 @@
176 'name.' % owner.displayname)
177
178
179-class BaseSnapEditView(LaunchpadEditFormView):
180+class BaseSnapEditView(LaunchpadEditFormView, SnapAuthorizeMixin):
181
182 schema = ISnapEditSchema
183
184@@ -388,6 +445,10 @@
185 render_radio_widget_part(widget, value, current_value)
186 for value in (VCSType.BZR, VCSType.GIT)]
187
188+ @property
189+ def has_snappy_distro_series(self):
190+ return not getUtility(ISnappyDistroSeriesSet).getAll().is_empty()
191+
192 def validate_widgets(self, data, names=None):
193 """See `LaunchpadFormView`."""
194 if self.widgets.get('vcs') is not None:
195@@ -403,8 +464,29 @@
196 self.widgets['git_ref'].context.required = True
197 else:
198 raise AssertionError("Unknown branch type %s" % vcs)
199+ if self.widgets.get('store_upload') is not None:
200+ # Set widgets as required or optional depending on the
201+ # store_upload field.
202+ super(BaseSnapEditView, self).validate_widgets(
203+ data, ['store_upload'])
204+ store_upload = data.get('store_upload', False)
205+ self.widgets['store_name'].context.required = store_upload
206 super(BaseSnapEditView, self).validate_widgets(data, names=names)
207
208+ def _needStoreReauth(self, data):
209+ """Does this change require reauthorizing to the store?"""
210+ store_upload = data.get('store_upload', False)
211+ store_distro_series = data.get('store_distro_series')
212+ store_name = data.get('store_name')
213+ if (not store_upload or
214+ store_distro_series is None or store_name is None):
215+ return False
216+ if store_distro_series.snappy_series != self.context.store_series:
217+ return True
218+ if store_name != self.context.store_name:
219+ return True
220+ return False
221+
222 @action('Update snap package', name='update')
223 def request_action(self, action, data):
224 vcs = data.pop('vcs', None)
225@@ -418,8 +500,15 @@
226 self.context.setProcessors(
227 new_processors, check_permissions=True, user=self.user)
228 del data['processors']
229+ store_upload = data.get('store_upload', False)
230+ if not store_upload:
231+ data['store_name'] = None
232+ need_store_reauth = self._needStoreReauth(data)
233 self.updateContextFromData(data)
234- self.next_url = canonical_url(self.context)
235+ if need_store_reauth:
236+ self.requestAuthorization(self.context)
237+ else:
238+ self.next_url = canonical_url(self.context)
239
240 @property
241 def adapters(self):
242@@ -462,8 +551,16 @@
243 page_title = 'Edit'
244
245 field_names = [
246- 'owner', 'name', 'distro_series', 'vcs', 'branch', 'git_ref']
247- custom_widget('distro_series', LaunchpadRadioWidget)
248+ 'owner',
249+ 'name',
250+ 'store_distro_series',
251+ 'store_upload',
252+ 'store_name',
253+ 'vcs',
254+ 'branch',
255+ 'git_ref',
256+ ]
257+ custom_widget('store_distro_series', LaunchpadRadioWidget)
258 custom_widget('vcs', LaunchpadRadioWidget)
259 custom_widget('git_ref', GitRefWidget)
260
261@@ -478,11 +575,18 @@
262
263 @property
264 def initial_values(self):
265+ initial_values = {}
266+ if self.context.store_series is None:
267+ # XXX cjwatson 2016-04-26: Remove this case once all existing
268+ # Snaps have had a store_series backfilled.
269+ sds_set = getUtility(ISnappyDistroSeriesSet)
270+ initial_values['store_distro_series'] = sds_set.getByDistroSeries(
271+ self.context.distro_series).first()
272 if self.context.git_ref is not None:
273- vcs = VCSType.GIT
274+ initial_values['vcs'] = VCSType.GIT
275 else:
276- vcs = VCSType.BZR
277- return {'vcs': vcs}
278+ initial_values['vcs'] = VCSType.BZR
279+ return initial_values
280
281 def validate(self, data):
282 super(SnapEditView, self).validate(data)
283
284=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
285--- lib/lp/snappy/browser/tests/test_snap.py 2016-05-24 04:45:38 +0000
286+++ lib/lp/snappy/browser/tests/test_snap.py 2016-05-27 10:39:59 +0000
287@@ -53,6 +53,7 @@
288 )
289 from lp.snappy.interfaces.snap import (
290 CannotModifySnapProcessor,
291+ ISnapSet,
292 SNAP_FEATURE_FLAG,
293 SNAP_TESTING_FLAGS,
294 SnapFeatureDisabled,
295@@ -139,7 +140,7 @@
296
297 class TestSnapAddView(BrowserTestCase):
298
299- layer = DatabaseFunctionalLayer
300+ layer = LaunchpadFunctionalLayer
301
302 def setUp(self):
303 super(TestSnapAddView, self).setUp()
304@@ -147,24 +148,35 @@
305 self.useFixture(FakeLogger())
306 self.person = self.factory.makePerson(
307 name="test-person", displayname="Test Person")
308+ self.distroseries = self.factory.makeUbuntuDistroSeries(
309+ version="13.10")
310+ with admin_logged_in():
311+ self.snappyseries = self.factory.makeSnappySeries(
312+ usable_distro_series=[self.distroseries])
313
314 def test_initial_distroseries(self):
315 # The initial distroseries is the newest that is current or in
316 # development.
317- archive = self.factory.makeArchive(owner=self.person)
318- self.factory.makeDistroSeries(
319- distribution=archive.distribution, version="14.04",
320- status=SeriesStatus.DEVELOPMENT)
321- development = self.factory.makeDistroSeries(
322- distribution=archive.distribution, version="14.10",
323- status=SeriesStatus.DEVELOPMENT)
324- self.factory.makeDistroSeries(
325- distribution=archive.distribution, version="15.04",
326- status=SeriesStatus.EXPERIMENTAL)
327+ old = self.factory.makeUbuntuDistroSeries(
328+ version="14.04", status=SeriesStatus.DEVELOPMENT)
329+ development = self.factory.makeUbuntuDistroSeries(
330+ version="14.10", status=SeriesStatus.DEVELOPMENT)
331+ experimental = self.factory.makeUbuntuDistroSeries(
332+ version="15.04", status=SeriesStatus.EXPERIMENTAL)
333+ with admin_logged_in():
334+ self.factory.makeSnappySeries(
335+ usable_distro_series=[old, development, experimental])
336+ newest = self.factory.makeSnappySeries(
337+ usable_distro_series=[development, experimental])
338+ self.factory.makeSnappySeries(
339+ usable_distro_series=[old, experimental])
340 branch = self.factory.makeAnyBranch()
341 with person_logged_in(self.person):
342 view = create_initialized_view(branch, "+new-snap")
343- self.assertEqual(development, view.initial_values["distro_series"])
344+ self.assertThat(
345+ view.initial_values["store_distro_series"],
346+ MatchesStructure.byEquality(
347+ snappy_series=newest, distro_series=development))
348
349 def test_create_new_snap_not_logged_in(self):
350 branch = self.factory.makeAnyBranch()
351@@ -173,14 +185,11 @@
352 no_login=True)
353
354 def test_create_new_snap_bzr(self):
355- archive = self.factory.makeArchive()
356- distroseries = self.factory.makeDistroSeries(
357- distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
358 branch = self.factory.makeAnyBranch()
359 source_display = branch.display_name
360 browser = self.getViewBrowser(
361 branch, view_name="+new-snap", user=self.person)
362- browser.getControl("Name").value = "snap-name"
363+ browser.getControl(name="field.name").value = "snap-name"
364 browser.getControl("Create snap package").click()
365
366 content = find_main_content(browser.contents)
367@@ -189,21 +198,22 @@
368 "Test Person", MatchesPickerText(content, "edit-owner"))
369 self.assertThat(
370 "Distribution series:\n%s\nEdit snap package" %
371- distroseries.fullseriesname,
372+ self.distroseries.fullseriesname,
373 MatchesTagText(content, "distro_series"))
374 self.assertThat(
375 "Source:\n%s\nEdit snap package" % source_display,
376 MatchesTagText(content, "source"))
377+ self.assertThat(
378+ "Builds of this snap package are not automatically uploaded to "
379+ "the store.\nEdit snap package",
380+ MatchesTagText(content, "store_upload"))
381
382 def test_create_new_snap_git(self):
383- archive = self.factory.makeArchive()
384- distroseries = self.factory.makeDistroSeries(
385- distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
386 [git_ref] = self.factory.makeGitRefs()
387 source_display = git_ref.display_name
388 browser = self.getViewBrowser(
389 git_ref, view_name="+new-snap", user=self.person)
390- browser.getControl("Name").value = "snap-name"
391+ browser.getControl(name="field.name").value = "snap-name"
392 browser.getControl("Create snap package").click()
393
394 content = find_main_content(browser.contents)
395@@ -212,11 +222,15 @@
396 "Test Person", MatchesPickerText(content, "edit-owner"))
397 self.assertThat(
398 "Distribution series:\n%s\nEdit snap package" %
399- distroseries.fullseriesname,
400+ self.distroseries.fullseriesname,
401 MatchesTagText(content, "distro_series"))
402 self.assertThat(
403 "Source:\n%s\nEdit snap package" % source_display,
404 MatchesTagText(content, "source"))
405+ self.assertThat(
406+ "Builds of this snap package are not automatically uploaded to "
407+ "the store.\nEdit snap package",
408+ MatchesTagText(content, "store_upload"))
409
410 def test_create_new_snap_users_teams_as_owner_options(self):
411 # Teams that the user is in are options for the snap package owner.
412@@ -231,12 +245,12 @@
413 sorted(str(option) for option in options))
414
415 def test_create_new_snap_public(self):
416- # Public owner implies in public snap.
417+ # Public owner implies public snap.
418 branch = self.factory.makeAnyBranch()
419
420 browser = self.getViewBrowser(
421 branch, view_name="+new-snap", user=self.person)
422- browser.getControl("Name").value = "public-snap"
423+ browser.getControl(name="field.name").value = "public-snap"
424 browser.getControl("Create snap package").click()
425
426 content = find_main_content(browser.contents)
427@@ -272,7 +286,7 @@
428
429 browser = self.getViewBrowser(
430 branch, view_name="+new-snap", user=self.person)
431- browser.getControl("Name").value = "private-snap"
432+ browser.getControl(name="field.name").value = "private-snap"
433 browser.getControl("Owner").value = ['super-private']
434 browser.getControl("Create snap package").click()
435
436@@ -283,6 +297,60 @@
437 extract_text(find_tag_by_id(browser.contents, "privacy"))
438 )
439
440+ def test_create_new_snap_store_upload(self):
441+ # Creating a new snap and asking for it to be automatically uploaded
442+ # to the store sets all the appropriate fields and redirects to SSO
443+ # for authorization.
444+ branch = self.factory.makeAnyBranch()
445+ view_url = canonical_url(branch, view_name="+new-snap")
446+ browser = self.getNonRedirectingBrowser(url=view_url, user=self.person)
447+ browser.getControl(name="field.name").value = "snap-name"
448+ browser.getControl("Automatically upload to store").selected = True
449+ browser.getControl("Registered store package name").value = (
450+ "store-name")
451+ root_macaroon = Macaroon()
452+ root_macaroon.add_third_party_caveat(
453+ urlsplit(config.launchpad.openid_provider_root).netloc, "",
454+ "dummy")
455+ root_macaroon_raw = root_macaroon.serialize()
456+
457+ @all_requests
458+ def handler(url, request):
459+ self.request = request
460+ return {
461+ "status_code": 200,
462+ "content": {"macaroon": root_macaroon_raw},
463+ }
464+
465+ self.pushConfig("snappy", store_url="http://sca.example/")
466+ with HTTMock(handler):
467+ redirection = self.assertRaises(
468+ HTTPError, browser.getControl("Create snap package").click)
469+ login_person(self.person)
470+ snap = getUtility(ISnapSet).getByName(self.person, u"snap-name")
471+ self.assertThat(snap, MatchesStructure.byEquality(
472+ owner=self.person, distro_series=self.distroseries,
473+ name=u"snap-name", source=branch, store_upload=True,
474+ store_series=self.snappyseries, store_name=u"store-name",
475+ store_secrets={"root": root_macaroon_raw}))
476+ self.assertThat(self.request, MatchesStructure.byEquality(
477+ url="http://sca.example/dev/api/acl/", method="POST"))
478+ expected_body = {
479+ "packages": [{
480+ "name": "store-name",
481+ "series": self.snappyseries.name,
482+ }],
483+ "permissions": ["package_upload"],
484+ }
485+ self.assertEqual(expected_body, json.loads(self.request.body))
486+ self.assertEqual(303, redirection.code)
487+ self.assertEqual(
488+ canonical_url(snap, rootsite="code") +
489+ "/+authorize/+login?field.callback=on&"
490+ "macaroon_caveat_id=dummy&"
491+ "discharge_macaroon_field=field.discharge_macaroon",
492+ redirection.hdrs["Location"])
493+
494
495 class TestSnapAdminView(BrowserTestCase):
496
497@@ -372,27 +440,53 @@
498 self.useFixture(FakeLogger())
499 self.person = self.factory.makePerson(
500 name="test-person", displayname="Test Person")
501+ self.distroseries = self.factory.makeUbuntuDistroSeries(
502+ version="13.10")
503+ with admin_logged_in():
504+ self.snappyseries = self.factory.makeSnappySeries(
505+ usable_distro_series=[self.distroseries])
506+
507+ def test_initial_store_series(self):
508+ # The initial store_series is the newest that is usable for the
509+ # selected distroseries.
510+ development = self.factory.makeUbuntuDistroSeries(
511+ version="14.10", status=SeriesStatus.DEVELOPMENT)
512+ experimental = self.factory.makeUbuntuDistroSeries(
513+ version="15.04", status=SeriesStatus.EXPERIMENTAL)
514+ with admin_logged_in():
515+ self.factory.makeSnappySeries(
516+ usable_distro_series=[development, experimental])
517+ newest = self.factory.makeSnappySeries(
518+ usable_distro_series=[development])
519+ self.factory.makeSnappySeries(usable_distro_series=[experimental])
520+ snap = self.factory.makeSnap(distroseries=development)
521+ with person_logged_in(self.person):
522+ view = create_initialized_view(snap, "+edit")
523+ self.assertThat(
524+ view.initial_values["store_distro_series"],
525+ MatchesStructure.byEquality(
526+ snappy_series=newest, distro_series=development))
527
528 def test_edit_snap(self):
529- archive = self.factory.makeArchive()
530- old_series = self.factory.makeDistroSeries(
531- distribution=archive.distribution, status=SeriesStatus.CURRENT)
532+ old_series = self.factory.makeUbuntuDistroSeries()
533 old_branch = self.factory.makeAnyBranch()
534 snap = self.factory.makeSnap(
535 registrant=self.person, owner=self.person, distroseries=old_series,
536 branch=old_branch)
537 self.factory.makeTeam(
538 name="new-team", displayname="New Team", members=[self.person])
539- new_series = self.factory.makeDistroSeries(
540- distribution=archive.distribution, status=SeriesStatus.DEVELOPMENT)
541+ new_series = self.factory.makeUbuntuDistroSeries()
542+ with admin_logged_in():
543+ new_snappy_series = self.factory.makeSnappySeries(
544+ usable_distro_series=[new_series])
545 [new_git_ref] = self.factory.makeGitRefs()
546
547 browser = self.getViewBrowser(snap, user=self.person)
548 browser.getLink("Edit snap package").click()
549 browser.getControl("Owner").value = ["new-team"]
550- browser.getControl("Name").value = "new-name"
551- browser.getControl(name="field.distro_series").value = [
552- str(new_series.id)]
553+ browser.getControl(name="field.name").value = "new-name"
554+ browser.getControl(name="field.store_distro_series").value = [
555+ "ubuntu/%s/%s" % (new_series.name, new_snappy_series.name)]
556 browser.getControl("Git", index=0).click()
557 browser.getControl("Git repository").value = (
558 new_git_ref.repository.identity)
559@@ -409,6 +503,10 @@
560 self.assertThat(
561 "Source:\n%s\nEdit snap package" % new_git_ref.display_name,
562 MatchesTagText(content, "source"))
563+ self.assertThat(
564+ "Builds of this snap package are not automatically uploaded to "
565+ "the store.\nEdit snap package",
566+ MatchesTagText(content, "store_upload"))
567
568 def test_edit_snap_sets_date_last_modified(self):
569 # Editing a snap package sets the date_last_modified property.
570@@ -432,7 +530,7 @@
571 registrant=self.person, owner=self.person, name=u"two")
572 browser = self.getViewBrowser(snap, user=self.person)
573 browser.getLink("Edit snap package").click()
574- browser.getControl("Name").value = "two"
575+ browser.getControl(name="field.name").value = "two"
576 browser.getControl("Update snap package").click()
577 self.assertEqual(
578 "There is already a snap package owned by Test Person with this "
579@@ -448,6 +546,8 @@
580 self.factory.makeDistroArchSeries(
581 distroseries=distroseries, architecturetag=name,
582 processor=processor)
583+ with admin_logged_in():
584+ self.factory.makeSnappySeries(usable_distro_series=[distroseries])
585 return distroseries
586
587 def assertSnapProcessors(self, snap, names):
588@@ -571,6 +671,52 @@
589 login_person(self.person)
590 self.assertSnapProcessors(snap, ["386", "armhf"])
591
592+ def test_edit_store_upload(self):
593+ # Changing store upload settings on a snap sets all the appropriate
594+ # fields and redirects to SSO for reauthorization.
595+ snap = self.factory.makeSnap(
596+ registrant=self.person, owner=self.person,
597+ distroseries=self.distroseries, store_upload=True,
598+ store_series=self.snappyseries, store_name=u"one")
599+ view_url = canonical_url(snap, view_name="+edit")
600+ browser = self.getNonRedirectingBrowser(url=view_url, user=self.person)
601+ browser.getControl("Registered store package name").value = "two"
602+ root_macaroon = Macaroon()
603+ root_macaroon.add_third_party_caveat(
604+ urlsplit(config.launchpad.openid_provider_root).netloc, "",
605+ "dummy")
606+ root_macaroon_raw = root_macaroon.serialize()
607+
608+ @all_requests
609+ def handler(url, request):
610+ self.request = request
611+ return {
612+ "status_code": 200,
613+ "content": {"macaroon": root_macaroon_raw},
614+ }
615+
616+ self.pushConfig("snappy", store_url="http://sca.example/")
617+ with HTTMock(handler):
618+ redirection = self.assertRaises(
619+ HTTPError, browser.getControl("Update snap package").click)
620+ login_person(self.person)
621+ self.assertThat(snap, MatchesStructure.byEquality(
622+ store_name=u"two", store_secrets={"root": root_macaroon_raw}))
623+ self.assertThat(self.request, MatchesStructure.byEquality(
624+ url="http://sca.example/dev/api/acl/", method="POST"))
625+ expected_body = {
626+ "packages": [{"name": "two", "series": self.snappyseries.name}],
627+ "permissions": ["package_upload"],
628+ }
629+ self.assertEqual(expected_body, json.loads(self.request.body))
630+ self.assertEqual(303, redirection.code)
631+ self.assertEqual(
632+ canonical_url(snap) +
633+ "/+authorize/+login?field.callback=on&"
634+ "macaroon_caveat_id=dummy&"
635+ "discharge_macaroon_field=field.discharge_macaroon",
636+ redirection.hdrs["Location"])
637+
638
639 class TestSnapAuthorizeView(BrowserTestCase):
640
641@@ -839,6 +985,8 @@
642 Owner: Test Person
643 Distribution series: Ubuntu Shiny
644 Source: lp://dev/~test-person/\\+junk/snap-branch
645+ Builds of this snap package are not automatically uploaded to
646+ the store.
647 Latest builds
648 Status When complete Architecture Archive
649 Successfully built 30 minutes ago i386
650@@ -860,6 +1008,8 @@
651 Owner: Test Person
652 Distribution series: Ubuntu Shiny
653 Source: ~test-person/\\+git/snap-repository:master
654+ Builds of this snap package are not automatically uploaded to
655+ the store.
656 Latest builds
657 Status When complete Architecture Archive
658 Successfully built 30 minutes ago i386
659
660=== modified file 'lib/lp/snappy/interfaces/snap.py'
661--- lib/lp/snappy/interfaces/snap.py 2016-05-13 15:32:22 +0000
662+++ lib/lp/snappy/interfaces/snap.py 2016-05-27 10:39:59 +0000
663@@ -86,7 +86,10 @@
664 PublicPersonChoice,
665 )
666 from lp.services.webhooks.interfaces import IWebhookTarget
667-from lp.snappy.interfaces.snappyseries import ISnappySeries
668+from lp.snappy.interfaces.snappyseries import (
669+ ISnappyDistroSeries,
670+ ISnappySeries,
671+ )
672 from lp.soyuz.interfaces.archive import IArchive
673 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
674
675@@ -389,6 +392,11 @@
676 "The series in which this snap package should be published in the "
677 "store."))
678
679+ store_distro_series = ReferenceChoice(
680+ title=_("Store and distro series"),
681+ schema=ISnappyDistroSeries, vocabulary="SnappyDistroSeries",
682+ required=False, readonly=False)
683+
684 store_name = TextLine(
685 title=_("Registered store package name"),
686 required=False, readonly=False,
687
688=== modified file 'lib/lp/snappy/interfaces/snappyseries.py'
689--- lib/lp/snappy/interfaces/snappyseries.py 2016-05-06 11:55:53 +0000
690+++ lib/lp/snappy/interfaces/snappyseries.py 2016-05-27 10:39:59 +0000
691@@ -190,3 +190,6 @@
692
693 def getByBothSeries(snappy_series, distro_series):
694 """Return a `SnappyDistroSeries` for this pair of series, or None."""
695+
696+ def getAll():
697+ """Return all `SnappyDistroSeries`."""
698
699=== modified file 'lib/lp/snappy/model/snap.py'
700--- lib/lp/snappy/model/snap.py 2016-05-17 12:47:34 +0000
701+++ lib/lp/snappy/model/snap.py 2016-05-27 10:39:59 +0000
702@@ -102,6 +102,7 @@
703 SnapPrivateFeatureDisabled,
704 )
705 from lp.snappy.interfaces.snapbuild import ISnapBuildSet
706+from lp.snappy.interfaces.snappyseries import ISnappyDistroSeriesSet
707 from lp.snappy.model.snapbuild import SnapBuild
708 from lp.soyuz.interfaces.archive import ArchiveDisabled
709 from lp.soyuz.model.archive import (
710@@ -291,6 +292,18 @@
711 or not self.require_virtualized))]
712
713 @property
714+ def store_distro_series(self):
715+ if self.store_series is None:
716+ return None
717+ return getUtility(ISnappyDistroSeriesSet).getByBothSeries(
718+ self.store_series, self.distro_series)
719+
720+ @store_distro_series.setter
721+ def store_distro_series(self, value):
722+ self.distro_series = value.distro_series
723+ self.store_series = value.snappy_series
724+
725+ @property
726 def can_upload_to_store(self):
727 return (
728 config.snappy.store_upload_url is not None and
729
730=== modified file 'lib/lp/snappy/model/snappyseries.py'
731--- lib/lp/snappy/model/snappyseries.py 2016-05-09 13:24:10 +0000
732+++ lib/lp/snappy/model/snappyseries.py 2016-05-27 10:39:59 +0000
733@@ -182,3 +182,7 @@
734 SnappyDistroSeries,
735 SnappyDistroSeries.snappy_series == snappy_series,
736 SnappyDistroSeries.distro_series == distro_series).one()
737+
738+ def getAll(self):
739+ """See `ISnappyDistroSeriesSet`."""
740+ return IStore(SnappyDistroSeries).find(SnappyDistroSeries)
741
742=== modified file 'lib/lp/snappy/templates/snap-edit.pt'
743--- lib/lp/snappy/templates/snap-edit.pt 2016-02-04 00:45:12 +0000
744+++ lib/lp/snappy/templates/snap-edit.pt 2016-05-27 10:39:59 +0000
745@@ -25,10 +25,28 @@
746 <tal:widget define="widget nocall:view/widgets/name">
747 <metal:block use-macro="context/@@launchpad_form/widget_row" />
748 </tal:widget>
749- <tal:widget define="widget nocall:view/widgets/distro_series">
750+ <tal:widget define="widget nocall:view/widgets/store_distro_series">
751 <metal:block use-macro="context/@@launchpad_form/widget_row" />
752 </tal:widget>
753
754+ <tr tal:condition="view/has_snappy_distro_series">
755+ <td>
756+ <tal:widget define="widget nocall:view/widgets/store_upload">
757+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
758+ </tal:widget>
759+ <table class="subordinate">
760+ <tal:widget define="widget nocall:view/widgets/store_name">
761+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
762+ </tal:widget>
763+ </table>
764+ <p class="formHelp">
765+ If you change any settings related to automatically uploading
766+ builds of this snap to the store, then the login service will
767+ prompt you to authorize this request.
768+ </p>
769+ </td>
770+ </tr>
771+
772 <tr>
773 <td>
774 <div>
775
776=== modified file 'lib/lp/snappy/templates/snap-index.pt'
777--- lib/lp/snappy/templates/snap-index.pt 2016-05-14 00:25:07 +0000
778+++ lib/lp/snappy/templates/snap-index.pt 2016-05-27 10:39:59 +0000
779@@ -63,6 +63,28 @@
780 </dl>
781 </div>
782
783+ <div id="store_upload" class="two-column-list"
784+ tal:condition="context/store_upload">
785+ <dl id="store_series">
786+ <dt>Store series:</dt>
787+ <dd>
788+ <a tal:replace="structure context/store_series/fmt:link"/>
789+ <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
790+ </dd>
791+ </dl>
792+ <dl id="store_name">
793+ <dt>Registered store package name:</dt>
794+ <dd>
795+ <span tal:content="context/store_name"/>
796+ <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
797+ </dd>
798+ </dl>
799+ </div>
800+ <p id="store_upload" tal:condition="not: context/store_upload">
801+ Builds of this snap package are not automatically uploaded to the store.
802+ <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
803+ </p>
804+
805 <h2>Latest builds</h2>
806 <table id="latest-builds-listing" class="listing"
807 style="margin-bottom: 1em;">
808
809=== modified file 'lib/lp/snappy/templates/snap-new.pt'
810--- lib/lp/snappy/templates/snap-new.pt 2016-02-04 00:45:12 +0000
811+++ lib/lp/snappy/templates/snap-new.pt 2016-05-27 10:39:59 +0000
812@@ -28,9 +28,27 @@
813 <tal:widget define="widget nocall:view/widgets/owner">
814 <metal:block use-macro="context/@@launchpad_form/widget_row" />
815 </tal:widget>
816- <tal:widget define="widget nocall:view/widgets/distro_series">
817+ <tal:widget define="widget nocall:view/widgets/store_distro_series">
818 <metal:block use-macro="context/@@launchpad_form/widget_row" />
819 </tal:widget>
820+
821+ <tr tal:condition="view/has_snappy_distro_series">
822+ <td>
823+ <tal:widget define="widget nocall:view/widgets/store_upload">
824+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
825+ </tal:widget>
826+ <table class="subordinate">
827+ <tal:widget define="widget nocall:view/widgets/store_name">
828+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
829+ </tal:widget>
830+ </table>
831+ <p class="formHelp">
832+ If you ask Launchpad to automatically upload builds of this
833+ snap to the store on your behalf, then the login service
834+ will prompt you to authorize this request.
835+ </p>
836+ </td>
837+ </tr>
838 </table>
839 </metal:formbody>
840 </div>
841
842=== modified file 'lib/lp/snappy/tests/test_snappyseries.py'
843--- lib/lp/snappy/tests/test_snappyseries.py 2016-05-06 12:08:29 +0000
844+++ lib/lp/snappy/tests/test_snappyseries.py 2016-05-27 10:39:59 +0000
845@@ -277,3 +277,20 @@
846 self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[0], dses[1]))
847 self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[1], dses[0]))
848 self.assertIsNone(sds_set.getByBothSeries(snappy_serieses[1], dses[1]))
849+
850+ def test_getAll(self):
851+ dses = [self.factory.makeDistroSeries() for _ in range(2)]
852+ snappy_serieses = [self.factory.makeSnappySeries() for _ in range(2)]
853+ snappy_serieses[0].usable_distro_series = dses
854+ snappy_serieses[1].usable_distro_series = [dses[0]]
855+ sds_set = getUtility(ISnappyDistroSeriesSet)
856+ self.assertThat(
857+ sds_set.getAll(),
858+ MatchesSetwise(
859+ MatchesStructure.byEquality(
860+ snappy_series=snappy_serieses[0], distro_series=dses[0]),
861+ MatchesStructure.byEquality(
862+ snappy_series=snappy_serieses[0], distro_series=dses[1]),
863+ MatchesStructure.byEquality(
864+ snappy_series=snappy_serieses[1], distro_series=dses[0]),
865+ ))