Merge lp:~matiasb/launchpad/upload-snap-to-track-based-channels into lp:launchpad

Proposed by Matias Bordese
Status: Merged
Merged at revision: 18338
Proposed branch: lp:~matiasb/launchpad/upload-snap-to-track-based-channels
Merge into: lp:launchpad
Diff against target: 674 lines (+484/-37)
10 files modified
lib/lp/snappy/browser/snap.py (+5/-11)
lib/lp/snappy/browser/tests/test_snap.py (+11/-21)
lib/lp/snappy/browser/widgets/storechannels.py (+152/-0)
lib/lp/snappy/browser/widgets/templates/storechannels.pt (+22/-0)
lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py (+194/-0)
lib/lp/snappy/interfaces/snap.py (+5/-3)
lib/lp/snappy/templates/snap-edit.pt (+1/-1)
lib/lp/snappy/templates/snap-new.pt (+1/-1)
lib/lp/snappy/validators/channels.py (+53/-0)
lib/lp/snappy/validators/tests/test_channels.py (+40/-0)
To merge this branch: bzr merge lp:~matiasb/launchpad/upload-snap-to-track-based-channels
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+320855@code.launchpad.net

Commit message

Added initial channel track support when uploading snaps to the store.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

Overall very good, thanks! I think this needs a few tweaks especially around making sure that invalid values can't be set over the API, but it's pretty close.

review: Needs Fixing
Revision history for this message
Matias Bordese (matiasb) wrote :

Replied comments, pushing updates in a minute.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py 2017-03-08 22:45:20 +0000
+++ lib/lp/snappy/browser/snap.py 2017-03-30 14:10:04 +0000
@@ -32,7 +32,6 @@
32 List,32 List,
33 TextLine,33 TextLine,
34 )34 )
35from zope.schema.interfaces import IVocabularyFactory
3635
37from lp import _36from lp import _
38from lp.app.browser.launchpadform import (37from lp.app.browser.launchpadform import (
@@ -62,7 +61,6 @@
62from lp.registry.interfaces.pocket import PackagePublishingPocket61from lp.registry.interfaces.pocket import PackagePublishingPocket
63from lp.services.features import getFeatureFlag62from lp.services.features import getFeatureFlag
64from lp.services.helpers import english_list63from lp.services.helpers import english_list
65from lp.services.propertycache import cachedproperty
66from lp.services.scripts import log64from lp.services.scripts import log
67from lp.services.webapp import (65from lp.services.webapp import (
68 canonical_url,66 canonical_url,
@@ -82,6 +80,7 @@
82from lp.services.webapp.url import urlappend80from lp.services.webapp.url import urlappend
83from lp.services.webhooks.browser import WebhookTargetNavigationMixin81from lp.services.webhooks.browser import WebhookTargetNavigationMixin
84from lp.snappy.browser.widgets.snaparchive import SnapArchiveWidget82from lp.snappy.browser.widgets.snaparchive import SnapArchiveWidget
83from lp.snappy.browser.widgets.storechannels import StoreChannelsWidget
85from lp.snappy.interfaces.snap import (84from lp.snappy.interfaces.snap import (
86 CannotAuthorizeStoreUploads,85 CannotAuthorizeStoreUploads,
87 ISnap,86 ISnap,
@@ -190,14 +189,9 @@
190 else:189 else:
191 return 'Built on request'190 return 'Built on request'
192191
193 @cachedproperty192 @property
194 def store_channels(self):193 def store_channels(self):
195 vocabulary = getUtility(194 return ', '.join(self.context.store_channels)
196 IVocabularyFactory, name='SnapStoreChannel')(self.context)
197 channel_titles = []
198 for channel in self.context.store_channels:
199 channel_titles.append(vocabulary.getTermByToken(channel).title)
200 return ', '.join(channel_titles)
201195
202196
203def builds_for_snap(snap):197def builds_for_snap(snap):
@@ -388,7 +382,7 @@
388 ]382 ]
389 custom_widget('store_distro_series', LaunchpadRadioWidget)383 custom_widget('store_distro_series', LaunchpadRadioWidget)
390 custom_widget('auto_build_archive', SnapArchiveWidget)384 custom_widget('auto_build_archive', SnapArchiveWidget)
391 custom_widget('store_channels', LabeledMultiCheckBoxWidget)385 custom_widget('store_channels', StoreChannelsWidget)
392 custom_widget('auto_build_pocket', LaunchpadDropdownWidget)386 custom_widget('auto_build_pocket', LaunchpadDropdownWidget)
393387
394 help_links = {388 help_links = {
@@ -694,7 +688,7 @@
694 'store_channels',688 'store_channels',
695 ]689 ]
696 custom_widget('store_distro_series', LaunchpadRadioWidget)690 custom_widget('store_distro_series', LaunchpadRadioWidget)
697 custom_widget('store_channels', LabeledMultiCheckBoxWidget)691 custom_widget('store_channels', StoreChannelsWidget)
698 custom_widget('vcs', LaunchpadRadioWidget)692 custom_widget('vcs', LaunchpadRadioWidget)
699 custom_widget('git_ref', GitRefWidget, allow_external=True)693 custom_widget('git_ref', GitRefWidget, allow_external=True)
700 custom_widget('auto_build_archive', SnapArchiveWidget)694 custom_widget('auto_build_archive', SnapArchiveWidget)
701695
=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py 2017-02-03 11:30:05 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py 2017-03-30 14:10:04 +0000
@@ -393,6 +393,7 @@
393 browser.getControl("Registered store package name").value = (393 browser.getControl("Registered store package name").value = (
394 "store-name")394 "store-name")
395 self.assertFalse(browser.getControl("Stable").selected)395 self.assertFalse(browser.getControl("Stable").selected)
396 browser.getControl(name="field.store_channels.track").value = "track"
396 browser.getControl("Edge").selected = True397 browser.getControl("Edge").selected = True
397 root_macaroon = Macaroon()398 root_macaroon = Macaroon()
398 root_macaroon.add_third_party_caveat(399 root_macaroon.add_third_party_caveat(
@@ -419,7 +420,7 @@
419 name=u"snap-name", source=branch, store_upload=True,420 name=u"snap-name", source=branch, store_upload=True,
420 store_series=self.snappyseries, store_name=u"store-name",421 store_series=self.snappyseries, store_name=u"store-name",
421 store_secrets={"root": root_macaroon_raw},422 store_secrets={"root": root_macaroon_raw},
422 store_channels=["edge"]))423 store_channels=["track/edge"]))
423 self.assertThat(self.request, MatchesStructure.byEquality(424 self.assertThat(self.request, MatchesStructure.byEquality(
424 url="http://sca.example/dev/api/acl/", method="POST"))425 url="http://sca.example/dev/api/acl/", method="POST"))
425 expected_body = {426 expected_body = {
@@ -946,12 +947,14 @@
946 registrant=self.person, owner=self.person,947 registrant=self.person, owner=self.person,
947 distroseries=self.distroseries, store_upload=True,948 distroseries=self.distroseries, store_upload=True,
948 store_series=self.snappyseries, store_name=u"one",949 store_series=self.snappyseries, store_name=u"one",
949 store_channels=["edge"])950 store_channels=["track/edge"])
950 view_url = canonical_url(snap, view_name="+edit")951 view_url = canonical_url(snap, view_name="+edit")
951 browser = self.getNonRedirectingBrowser(url=view_url, user=self.person)952 browser = self.getNonRedirectingBrowser(url=view_url, user=self.person)
952 browser.getControl("Registered store package name").value = "two"953 browser.getControl("Registered store package name").value = "two"
954 self.assertEqual("track", browser.getControl("Track").value)
955 self.assertTrue(browser.getControl("Edge").selected)
956 browser.getControl("Track").value = ""
953 browser.getControl("Stable").selected = True957 browser.getControl("Stable").selected = True
954 self.assertTrue(browser.getControl("Edge").selected)
955 root_macaroon = Macaroon()958 root_macaroon = Macaroon()
956 root_macaroon.add_third_party_caveat(959 root_macaroon.add_third_party_caveat(
957 urlsplit(config.launchpad.openid_provider_root).netloc, "",960 urlsplit(config.launchpad.openid_provider_root).netloc, "",
@@ -1373,24 +1376,11 @@
1373 view = create_initialized_view(snap, "+index")1376 view = create_initialized_view(snap, "+index")
1374 self.assertEqual("", view.store_channels)1377 self.assertEqual("", view.store_channels)
13751378
1376 def test_store_channels_uses_titles(self):1379 def test_store_channels_display(self):
1377 snap_store_client = FakeMethod()1380 snap = self.factory.makeSnap(
1378 snap_store_client.listChannels = FakeMethod(result=[1381 store_channels=["track/stable", "track/edge"])
1379 {"name": "stable", "display_name": "Stable"},1382 view = create_initialized_view(snap, "+index")
1380 {"name": "edge", "display_name": "Edge"},1383 self.assertEqual("track/stable, track/edge", view.store_channels)
1381 {"name": "old", "display_name": "Old channel"},
1382 ])
1383 self.useFixture(
1384 ZopeUtilityFixture(snap_store_client, ISnapStoreClient))
1385 snap = self.factory.makeSnap(store_channels=["stable", "old"])
1386 view = create_initialized_view(snap, "+index")
1387 self.assertEqual("Stable, Old channel", view.store_channels)
1388 snap_store_client.listChannels.result = [
1389 {"name": "stable", "display_name": "Stable"},
1390 {"name": "edge", "display_name": "Edge"},
1391 ]
1392 view = create_initialized_view(snap, "+index")
1393 self.assertEqual("Stable, old", view.store_channels)
13941384
13951385
1396class TestSnapRequestBuildsView(BaseTestSnapView):1386class TestSnapRequestBuildsView(BaseTestSnapView):
13971387
=== added file 'lib/lp/snappy/browser/widgets/storechannels.py'
--- lib/lp/snappy/browser/widgets/storechannels.py 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/widgets/storechannels.py 2017-03-30 14:10:04 +0000
@@ -0,0 +1,152 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__metaclass__ = type
5
6__all__ = [
7 'StoreChannelsWidget',
8 ]
9
10from z3c.ptcompat import ViewPageTemplateFile
11from zope.formlib.interfaces import IInputWidget, WidgetInputError
12from zope.formlib.utility import setUpWidget
13from zope.formlib.widget import (
14 BrowserWidget,
15 CustomWidgetFactory,
16 InputErrors,
17 InputWidget,
18 )
19from zope.interface import implementer
20from zope.schema import (
21 Choice,
22 List,
23 TextLine,
24 )
25
26from lp import _
27from lp.app.errors import UnexpectedFormData
28from lp.app.validators import LaunchpadValidationError
29from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
30from lp.services.webapp.interfaces import (
31 IAlwaysSubmittedWidget,
32 ISingleLineWidgetLayout,
33 )
34from lp.snappy.validators.channels import (
35 channel_components_delimiter,
36 split_channel_name,
37 )
38
39
40@implementer(ISingleLineWidgetLayout, IAlwaysSubmittedWidget, IInputWidget)
41class StoreChannelsWidget(BrowserWidget, InputWidget):
42
43 template = ViewPageTemplateFile("templates/storechannels.pt")
44 display_label = False
45 _separator = channel_components_delimiter
46 _default_track = 'latest'
47 _widgets_set_up = False
48
49 def __init__(self, field, value_type, request):
50 # We don't use value_type.
51 super(StoreChannelsWidget, self).__init__(field, request)
52
53 def setUpSubWidgets(self):
54 if self._widgets_set_up:
55 return
56 fields = [
57 TextLine(__name__="track", title=u"Track", required=False,
58 description=_(
59 "Track defines a series for your software. "
60 "If not specified, the default track ('latest') is "
61 "assumed.")
62 ),
63 List(__name__="risks", title=u"Risk", required=False,
64 value_type=Choice(vocabulary="SnapStoreChannel"),
65 description=_(
66 "Risks denote the stability of your software.")),
67 ]
68
69 self.risks_widget = CustomWidgetFactory(LabeledMultiCheckBoxWidget)
70 for field in fields:
71 setUpWidget(
72 self, field.__name__, field, IInputWidget, prefix=self.name)
73 self.risks_widget.orientation = 'horizontal'
74 self._widgets_set_up = True
75
76 @property
77 def has_risks_vocabulary(self):
78 risks_widget = getattr(self, 'risks_widget', None)
79 return risks_widget and bool(risks_widget.vocabulary)
80
81 def buildChannelName(self, track, risk):
82 """Return channel name composed from given track and risk."""
83 channel = risk
84 if track and track != self._default_track:
85 channel = track + self._separator + risk
86 return channel
87
88 def splitChannelName(self, channel):
89 """Return extracted track and risk from given channel name."""
90 try:
91 track, risk = split_channel_name(channel)
92 except ValueError:
93 raise AssertionError("Not a valid value: %r" % channel)
94 return track, risk
95
96 def setRenderedValue(self, value):
97 """See `IWidget`."""
98 self.setUpSubWidgets()
99 if value:
100 # NOTE: atm target channels must belong to the same track
101 tracks = set()
102 risks = []
103 for channel in value:
104 track, risk = self.splitChannelName(channel)
105 tracks.add(track)
106 risks.append(risk)
107 if len(tracks) != 1:
108 raise AssertionError("Not a valid value: %r" % value)
109 track = tracks.pop()
110 self.track_widget.setRenderedValue(track)
111 self.risks_widget.setRenderedValue(risks)
112 else:
113 self.track_widget.setRenderedValue(None)
114 self.risks_widget.setRenderedValue(None)
115
116 def hasInput(self):
117 """See `IInputWidget`."""
118 return ("%s.risks" % self.name) in self.request.form
119
120 def hasValidInput(self):
121 """See `IInputWidget`."""
122 try:
123 self.getInputValue()
124 return True
125 except (InputErrors, UnexpectedFormData):
126 return False
127
128 def getInputValue(self):
129 """See `IInputWidget`."""
130 self.setUpSubWidgets()
131 risks = self.risks_widget.getInputValue()
132 track = self.track_widget.getInputValue()
133 if track and self._separator in track:
134 error_msg = "Track name cannot include '%s'." % self._separator
135 raise WidgetInputError(
136 self.name, self.label, LaunchpadValidationError(error_msg))
137 channels = [self.buildChannelName(track, risk) for risk in risks]
138 return channels
139
140 def error(self):
141 """See `IBrowserWidget`."""
142 try:
143 if self.hasInput():
144 self.getInputValue()
145 except InputErrors as error:
146 self._error = error
147 return super(StoreChannelsWidget, self).error()
148
149 def __call__(self):
150 """See `IBrowserWidget`."""
151 self.setUpSubWidgets()
152 return self.template()
0153
=== added file 'lib/lp/snappy/browser/widgets/templates/storechannels.pt'
--- lib/lp/snappy/browser/widgets/templates/storechannels.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/widgets/templates/storechannels.pt 2017-03-30 14:10:04 +0000
@@ -0,0 +1,22 @@
1<table>
2 <tr>
3 <td>
4 <tal:widget define="widget nocall:view/track_widget">
5 <metal:block
6 use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" />
7 </tal:widget>
8 <p class="formHelp">
9 To open a new track, <a href="https://snapcraft.io/community">ask the store admins</a>.
10 </p>
11 </td>
12 </tr>
13 <tr>
14 <td>
15 <tal:widget define="widget nocall:view/risks_widget"
16 condition="widget/context/value_type/vocabulary">
17 <metal:block
18 use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" />
19 </tal:widget>
20 </td>
21 </tr>
22</table>
023
=== added file 'lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py'
--- lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py 2017-03-30 14:10:04 +0000
@@ -0,0 +1,194 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__metaclass__ = type
5
6import re
7
8from BeautifulSoup import BeautifulSoup
9from zope.formlib.interfaces import (
10 IBrowserWidget,
11 IInputWidget,
12 WidgetInputError,
13 )
14from zope.schema import List
15
16from lp.app.validators import LaunchpadValidationError
17from lp.services.webapp.escaping import html_escape
18from lp.services.webapp.servers import LaunchpadTestRequest
19from lp.snappy.browser.widgets.storechannels import StoreChannelsWidget
20from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
21from lp.snappy.vocabularies import SnapStoreChannelVocabulary
22from lp.testing import (
23 TestCaseWithFactory,
24 verifyObject,
25 )
26from lp.testing.fakemethod import FakeMethod
27from lp.testing.fixture import ZopeUtilityFixture
28from lp.testing.layers import DatabaseFunctionalLayer
29
30
31class TestStoreChannelsWidget(TestCaseWithFactory):
32
33 layer = DatabaseFunctionalLayer
34
35 def setUp(self):
36 super(TestStoreChannelsWidget, self).setUp()
37 field = List(__name__="channels", title=u"Store channels")
38 self.context = self.factory.makeSnap()
39 field = field.bind(self.context)
40 request = LaunchpadTestRequest()
41 self.widget = StoreChannelsWidget(field, None, request)
42
43 # setup fake store client response for available channels/risks
44 self.risks = [
45 {"name": "stable", "display_name": "Stable"},
46 {"name": "candidate", "display_name": "Candidate"},
47 {"name": "beta", "display_name": "Beta"},
48 {"name": "edge", "display_name": "Edge"},
49 ]
50 snap_store_client = FakeMethod()
51 snap_store_client.listChannels = FakeMethod(result=self.risks)
52 self.useFixture(
53 ZopeUtilityFixture(snap_store_client, ISnapStoreClient))
54
55 def test_implements(self):
56 self.assertTrue(verifyObject(IBrowserWidget, self.widget))
57 self.assertTrue(verifyObject(IInputWidget, self.widget))
58
59 def test_template(self):
60 self.assertTrue(
61 self.widget.template.filename.endswith("storechannels.pt"),
62 "Template was not set up.")
63
64 def test_setUpSubWidgets_first_call(self):
65 # The subwidgets are set up and a flag is set.
66 self.widget.setUpSubWidgets()
67 self.assertTrue(self.widget._widgets_set_up)
68 self.assertIsNotNone(getattr(self.widget, "track_widget", None))
69 self.assertIsInstance(
70 self.widget.risks_widget.vocabulary, SnapStoreChannelVocabulary)
71 self.assertTrue(self.widget.has_risks_vocabulary)
72
73 def test_setUpSubWidgets_second_call(self):
74 # The setUpSubWidgets method exits early if a flag is set to
75 # indicate that the widgets were set up.
76 self.widget._widgets_set_up = True
77 self.widget.setUpSubWidgets()
78 self.assertIsNone(getattr(self.widget, "track_widget", None))
79 self.assertIsNone(getattr(self.widget, "risks_widget", None))
80 self.assertIsNone(self.widget.has_risks_vocabulary)
81
82 def test_buildChannelName_no_track(self):
83 self.assertEqual("edge", self.widget.buildChannelName(None, "edge"))
84
85 def test_buildChannelName_with_track(self):
86 self.assertEqual(
87 "track/edge", self.widget.buildChannelName("track", "edge"))
88
89 def test_splitChannelName_no_track(self):
90 self.assertEqual((None, "edge"), self.widget.splitChannelName("edge"))
91
92 def test_splitChannelName_with_track(self):
93 self.assertEqual(
94 ("track", "edge"), self.widget.splitChannelName("track/edge"))
95
96 def test_splitChannelName_invalid(self):
97 self.assertRaises(
98 AssertionError, self.widget.splitChannelName, "track/edge/invalid")
99
100 def test_setRenderedValue_empty(self):
101 self.widget.setRenderedValue([])
102 self.assertIsNone(self.widget.track_widget._getCurrentValue())
103 self.assertIsNone(self.widget.risks_widget._getCurrentValue())
104
105 def test_setRenderedValue_no_track(self):
106 # Channels do not include a track
107 risks = ['candidate', 'edge']
108 self.widget.setRenderedValue(risks)
109 self.assertIsNone(self.widget.track_widget._getCurrentValue())
110 self.assertEqual(risks, self.widget.risks_widget._getCurrentValue())
111
112 def test_setRenderedValue_with_track(self):
113 # Channels including a track
114 channels = ['2.2/candidate', '2.2/edge']
115 self.widget.setRenderedValue(channels)
116 self.assertEqual('2.2', self.widget.track_widget._getCurrentValue())
117 self.assertEqual(
118 ['candidate', 'edge'], self.widget.risks_widget._getCurrentValue())
119
120 def test_setRenderedValue_invalid_value(self):
121 # Multiple channels, different tracks, unsupported
122 channels = ['2.2/candidate', '2.1/edge']
123 self.assertRaises(
124 AssertionError, self.widget.setRenderedValue, channels)
125
126 def test_hasInput_false(self):
127 # hasInput is false when there is no risk set in the form data.
128 self.widget.request = LaunchpadTestRequest(
129 form={"field.channels.track": "track"})
130 self.assertFalse(self.widget.hasInput())
131
132 def test_hasInput_true(self):
133 # hasInput is true if there are risks set in the form data.
134 self.widget.request = LaunchpadTestRequest(
135 form={"field.channels.risks": ["beta"]})
136 self.assertTrue(self.widget.hasInput())
137
138 def test_hasValidInput_false(self):
139 # The field input is invalid if any of the submitted parts are
140 # invalid.
141 form = {
142 "field.channels.track": "",
143 "field.channels.risks": ["invalid"],
144 }
145 self.widget.request = LaunchpadTestRequest(form=form)
146 self.assertFalse(self.widget.hasValidInput())
147
148 def test_hasValidInput_true(self):
149 # The field input is valid when all submitted parts are valid.
150 form = {
151 "field.channels.track": "track",
152 "field.channels.risks": ["stable", "beta"],
153 }
154 self.widget.request = LaunchpadTestRequest(form=form)
155 self.assertTrue(self.widget.hasValidInput())
156
157 def assertGetInputValueError(self, form, message):
158 self.widget.request = LaunchpadTestRequest(form=form)
159 e = self.assertRaises(WidgetInputError, self.widget.getInputValue)
160 self.assertEqual(LaunchpadValidationError(message), e.errors)
161 self.assertEqual(html_escape(message), self.widget.error())
162
163 def test_getInputValue_invalid_track(self):
164 # An error is raised when the track includes a '/'.
165 form = {"field.channels.track": "tra/ck",
166 "field.channels.risks": ["beta"]}
167 self.assertGetInputValueError(form, "Track name cannot include '/'.")
168
169 def test_getInputValue_no_track(self):
170 self.widget.request = LaunchpadTestRequest(
171 form={"field.channels.track": "",
172 "field.channels.risks": ["beta", "edge"]})
173 expected = ["beta", "edge"]
174 self.assertEqual(expected, self.widget.getInputValue())
175
176 def test_getInputValue_with_track(self):
177 self.widget.request = LaunchpadTestRequest(
178 form={"field.channels.track": "track",
179 "field.channels.risks": ["beta", "edge"]})
180 expected = ["track/beta", "track/edge"]
181 self.assertEqual(expected, self.widget.getInputValue())
182
183 def test_call(self):
184 # The __call__ method sets up the widgets.
185 markup = self.widget()
186 self.assertIsNotNone(self.widget.track_widget)
187 self.assertIsNotNone(self.widget.risks_widget)
188 soup = BeautifulSoup(markup)
189 fields = soup.findAll(["input"], {"id": re.compile(".*")})
190 expected_ids = [
191 "field.channels.risks.%d" % i for i in range(len(self.risks))]
192 expected_ids.append("field.channels.track")
193 ids = [field["id"] for field in fields]
194 self.assertContentEqual(expected_ids, ids)
0195
=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py 2017-02-06 14:34:35 +0000
+++ lib/lp/snappy/interfaces/snap.py 2017-03-30 14:10:04 +0000
@@ -94,6 +94,7 @@
94 ISnappyDistroSeries,94 ISnappyDistroSeries,
95 ISnappySeries,95 ISnappySeries,
96 )96 )
97from lp.snappy.validators.channels import channels_validator
97from lp.soyuz.interfaces.archive import IArchive98from lp.soyuz.interfaces.archive import IArchive
98from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries99from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
99100
@@ -537,11 +538,12 @@
537 "authorize uploads of this snap package."))538 "authorize uploads of this snap package."))
538539
539 store_channels = exported(List(540 store_channels = exported(List(
540 value_type=Choice(vocabulary="SnapStoreChannel"),541 title=_("Store channels"),
541 title=_("Store channels"), required=False, readonly=False,542 required=False, readonly=False, constraint=channels_validator,
542 description=_(543 description=_(
543 "Channels to release this snap package to after uploading it to "544 "Channels to release this snap package to after uploading it to "
544 "the store.")))545 "the store. A channel is defined by a combination of an optional "
546 " track and a risk, e.g. '2.1/stable', or 'stable'.")))
545547
546548
547class ISnapAdminAttributes(Interface):549class ISnapAdminAttributes(Interface):
548550
=== modified file 'lib/lp/snappy/templates/snap-edit.pt'
--- lib/lp/snappy/templates/snap-edit.pt 2016-07-28 12:42:21 +0000
+++ lib/lp/snappy/templates/snap-edit.pt 2017-03-30 14:10:04 +0000
@@ -87,7 +87,7 @@
87 <metal:block use-macro="context/@@launchpad_form/widget_row" />87 <metal:block use-macro="context/@@launchpad_form/widget_row" />
88 </tal:widget>88 </tal:widget>
89 <tal:widget define="widget nocall:view/widgets/store_channels"89 <tal:widget define="widget nocall:view/widgets/store_channels"
90 condition="widget/context/value_type/vocabulary">90 condition="widget/has_risks_vocabulary">
91 <metal:block use-macro="context/@@launchpad_form/widget_row" />91 <metal:block use-macro="context/@@launchpad_form/widget_row" />
92 </tal:widget>92 </tal:widget>
93 </table>93 </table>
9494
=== modified file 'lib/lp/snappy/templates/snap-new.pt'
--- lib/lp/snappy/templates/snap-new.pt 2017-02-01 06:31:30 +0000
+++ lib/lp/snappy/templates/snap-new.pt 2017-03-30 14:10:04 +0000
@@ -63,7 +63,7 @@
63 <metal:block use-macro="context/@@launchpad_form/widget_row" />63 <metal:block use-macro="context/@@launchpad_form/widget_row" />
64 </tal:widget>64 </tal:widget>
65 <tal:widget define="widget nocall:view/widgets/store_channels"65 <tal:widget define="widget nocall:view/widgets/store_channels"
66 condition="widget/context/value_type/vocabulary">66 condition="widget/has_risks_vocabulary">
67 <metal:block use-macro="context/@@launchpad_form/widget_row" />67 <metal:block use-macro="context/@@launchpad_form/widget_row" />
68 </tal:widget>68 </tal:widget>
69 </table>69 </table>
7070
=== added directory 'lib/lp/snappy/validators'
=== added file 'lib/lp/snappy/validators/__init__.py'
=== added file 'lib/lp/snappy/validators/channels.py'
--- lib/lp/snappy/validators/channels.py 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/validators/channels.py 2017-03-30 14:10:04 +0000
@@ -0,0 +1,53 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Validators for the .store_channels attribute."""
5
6__metaclass__ = type
7
8from lp import _
9from lp.app.validators import LaunchpadValidationError
10from lp.services.webapp.escaping import (
11 html_escape,
12 structured,
13 )
14
15
16# delimiter separating channel components
17channel_components_delimiter = '/'
18
19
20def split_channel_name(channel):
21 """Return extracted track and risk from given channel name."""
22 components = channel.split(channel_components_delimiter)
23 if len(components) == 2:
24 track, risk = components
25 elif len(components) == 1:
26 track = None
27 risk = components[0]
28 else:
29 raise ValueError("Invalid channel name: %r" % channel)
30 return track, risk
31
32
33def channels_validator(channels):
34 """Return True if the channels in a list are valid, or raise a
35 LaunchpadValidationError.
36 """
37 tracks = set()
38 for name in channels:
39 try:
40 track, risk = split_channel_name(name)
41 except ValueError:
42 message = _(
43 "Invalid channel name '${name}'. Channel names must be of the "
44 "form 'track/risk' or 'risk'.",
45 mapping={'name': html_escape(name)})
46 raise LaunchpadValidationError(structured(message))
47 tracks.add(track)
48
49 if len(tracks) != 1:
50 message = _("Channels must belong to the same track.")
51 raise LaunchpadValidationError(structured(message))
52
53 return True
054
=== added directory 'lib/lp/snappy/validators/tests'
=== added file 'lib/lp/snappy/validators/tests/__init__.py'
=== added file 'lib/lp/snappy/validators/tests/test_channels.py'
--- lib/lp/snappy/validators/tests/test_channels.py 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/validators/tests/test_channels.py 2017-03-30 14:10:04 +0000
@@ -0,0 +1,40 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__metaclass__ = type
5
6from lp.app.validators import LaunchpadValidationError
7from lp.snappy.validators.channels import (
8 channels_validator,
9 split_channel_name,
10 )
11from lp.testing import TestCaseWithFactory
12from lp.testing.layers import LaunchpadFunctionalLayer
13
14
15class TestChannelsValidator(TestCaseWithFactory):
16
17 layer = LaunchpadFunctionalLayer
18
19 def test_split_channel_name_no_track(self):
20 self.assertEqual((None, "edge"), split_channel_name("edge"))
21
22 def test_split_channel_name_with_track(self):
23 self.assertEqual(("track", "edge"), split_channel_name("track/edge"))
24
25 def test_split_channel_name_invalid(self):
26 self.assertRaises(ValueError, split_channel_name, "track/edge/invalid")
27
28 def test_channels_validator_valid(self):
29 self.assertTrue(channels_validator(['1.1/beta', '1.1/edge']))
30 self.assertTrue(channels_validator(['beta', 'edge']))
31
32 def test_channels_validator_multiple_tracks(self):
33 self.assertRaises(
34 LaunchpadValidationError, channels_validator,
35 ['1.1/stable', '2.1/edge'])
36
37 def test_channels_validator_invalid_channel(self):
38 self.assertRaises(
39 LaunchpadValidationError, channels_validator,
40 ['1.1/stable/invalid'])