Merge lp:~matiasb/launchpad/upload-snap-to-track-based-channels into lp:launchpad
- upload-snap-to-track-based-channels
- Merge into devel
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 |
Related bugs: |
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.
Description of the change
To post a comment you must log in.
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
1 | === modified file 'lib/lp/snappy/browser/snap.py' | |||
2 | --- lib/lp/snappy/browser/snap.py 2017-03-08 22:45:20 +0000 | |||
3 | +++ lib/lp/snappy/browser/snap.py 2017-03-30 14:10:04 +0000 | |||
4 | @@ -32,7 +32,6 @@ | |||
5 | 32 | List, | 32 | List, |
6 | 33 | TextLine, | 33 | TextLine, |
7 | 34 | ) | 34 | ) |
8 | 35 | from zope.schema.interfaces import IVocabularyFactory | ||
9 | 36 | 35 | ||
10 | 37 | from lp import _ | 36 | from lp import _ |
11 | 38 | from lp.app.browser.launchpadform import ( | 37 | from lp.app.browser.launchpadform import ( |
12 | @@ -62,7 +61,6 @@ | |||
13 | 62 | from lp.registry.interfaces.pocket import PackagePublishingPocket | 61 | from lp.registry.interfaces.pocket import PackagePublishingPocket |
14 | 63 | from lp.services.features import getFeatureFlag | 62 | from lp.services.features import getFeatureFlag |
15 | 64 | from lp.services.helpers import english_list | 63 | from lp.services.helpers import english_list |
16 | 65 | from lp.services.propertycache import cachedproperty | ||
17 | 66 | from lp.services.scripts import log | 64 | from lp.services.scripts import log |
18 | 67 | from lp.services.webapp import ( | 65 | from lp.services.webapp import ( |
19 | 68 | canonical_url, | 66 | canonical_url, |
20 | @@ -82,6 +80,7 @@ | |||
21 | 82 | from lp.services.webapp.url import urlappend | 80 | from lp.services.webapp.url import urlappend |
22 | 83 | from lp.services.webhooks.browser import WebhookTargetNavigationMixin | 81 | from lp.services.webhooks.browser import WebhookTargetNavigationMixin |
23 | 84 | from lp.snappy.browser.widgets.snaparchive import SnapArchiveWidget | 82 | from lp.snappy.browser.widgets.snaparchive import SnapArchiveWidget |
24 | 83 | from lp.snappy.browser.widgets.storechannels import StoreChannelsWidget | ||
25 | 85 | from lp.snappy.interfaces.snap import ( | 84 | from lp.snappy.interfaces.snap import ( |
26 | 86 | CannotAuthorizeStoreUploads, | 85 | CannotAuthorizeStoreUploads, |
27 | 87 | ISnap, | 86 | ISnap, |
28 | @@ -190,14 +189,9 @@ | |||
29 | 190 | else: | 189 | else: |
30 | 191 | return 'Built on request' | 190 | return 'Built on request' |
31 | 192 | 191 | ||
33 | 193 | @cachedproperty | 192 | @property |
34 | 194 | def store_channels(self): | 193 | def store_channels(self): |
41 | 195 | vocabulary = getUtility( | 194 | return ', '.join(self.context.store_channels) |
36 | 196 | IVocabularyFactory, name='SnapStoreChannel')(self.context) | ||
37 | 197 | channel_titles = [] | ||
38 | 198 | for channel in self.context.store_channels: | ||
39 | 199 | channel_titles.append(vocabulary.getTermByToken(channel).title) | ||
40 | 200 | return ', '.join(channel_titles) | ||
42 | 201 | 195 | ||
43 | 202 | 196 | ||
44 | 203 | def builds_for_snap(snap): | 197 | def builds_for_snap(snap): |
45 | @@ -388,7 +382,7 @@ | |||
46 | 388 | ] | 382 | ] |
47 | 389 | custom_widget('store_distro_series', LaunchpadRadioWidget) | 383 | custom_widget('store_distro_series', LaunchpadRadioWidget) |
48 | 390 | custom_widget('auto_build_archive', SnapArchiveWidget) | 384 | custom_widget('auto_build_archive', SnapArchiveWidget) |
50 | 391 | custom_widget('store_channels', LabeledMultiCheckBoxWidget) | 385 | custom_widget('store_channels', StoreChannelsWidget) |
51 | 392 | custom_widget('auto_build_pocket', LaunchpadDropdownWidget) | 386 | custom_widget('auto_build_pocket', LaunchpadDropdownWidget) |
52 | 393 | 387 | ||
53 | 394 | help_links = { | 388 | help_links = { |
54 | @@ -694,7 +688,7 @@ | |||
55 | 694 | 'store_channels', | 688 | 'store_channels', |
56 | 695 | ] | 689 | ] |
57 | 696 | custom_widget('store_distro_series', LaunchpadRadioWidget) | 690 | custom_widget('store_distro_series', LaunchpadRadioWidget) |
59 | 697 | custom_widget('store_channels', LabeledMultiCheckBoxWidget) | 691 | custom_widget('store_channels', StoreChannelsWidget) |
60 | 698 | custom_widget('vcs', LaunchpadRadioWidget) | 692 | custom_widget('vcs', LaunchpadRadioWidget) |
61 | 699 | custom_widget('git_ref', GitRefWidget, allow_external=True) | 693 | custom_widget('git_ref', GitRefWidget, allow_external=True) |
62 | 700 | custom_widget('auto_build_archive', SnapArchiveWidget) | 694 | custom_widget('auto_build_archive', SnapArchiveWidget) |
63 | 701 | 695 | ||
64 | === modified file 'lib/lp/snappy/browser/tests/test_snap.py' | |||
65 | --- lib/lp/snappy/browser/tests/test_snap.py 2017-02-03 11:30:05 +0000 | |||
66 | +++ lib/lp/snappy/browser/tests/test_snap.py 2017-03-30 14:10:04 +0000 | |||
67 | @@ -393,6 +393,7 @@ | |||
68 | 393 | browser.getControl("Registered store package name").value = ( | 393 | browser.getControl("Registered store package name").value = ( |
69 | 394 | "store-name") | 394 | "store-name") |
70 | 395 | self.assertFalse(browser.getControl("Stable").selected) | 395 | self.assertFalse(browser.getControl("Stable").selected) |
71 | 396 | browser.getControl(name="field.store_channels.track").value = "track" | ||
72 | 396 | browser.getControl("Edge").selected = True | 397 | browser.getControl("Edge").selected = True |
73 | 397 | root_macaroon = Macaroon() | 398 | root_macaroon = Macaroon() |
74 | 398 | root_macaroon.add_third_party_caveat( | 399 | root_macaroon.add_third_party_caveat( |
75 | @@ -419,7 +420,7 @@ | |||
76 | 419 | name=u"snap-name", source=branch, store_upload=True, | 420 | name=u"snap-name", source=branch, store_upload=True, |
77 | 420 | store_series=self.snappyseries, store_name=u"store-name", | 421 | store_series=self.snappyseries, store_name=u"store-name", |
78 | 421 | store_secrets={"root": root_macaroon_raw}, | 422 | store_secrets={"root": root_macaroon_raw}, |
80 | 422 | store_channels=["edge"])) | 423 | store_channels=["track/edge"])) |
81 | 423 | self.assertThat(self.request, MatchesStructure.byEquality( | 424 | self.assertThat(self.request, MatchesStructure.byEquality( |
82 | 424 | url="http://sca.example/dev/api/acl/", method="POST")) | 425 | url="http://sca.example/dev/api/acl/", method="POST")) |
83 | 425 | expected_body = { | 426 | expected_body = { |
84 | @@ -946,12 +947,14 @@ | |||
85 | 946 | registrant=self.person, owner=self.person, | 947 | registrant=self.person, owner=self.person, |
86 | 947 | distroseries=self.distroseries, store_upload=True, | 948 | distroseries=self.distroseries, store_upload=True, |
87 | 948 | store_series=self.snappyseries, store_name=u"one", | 949 | store_series=self.snappyseries, store_name=u"one", |
89 | 949 | store_channels=["edge"]) | 950 | store_channels=["track/edge"]) |
90 | 950 | view_url = canonical_url(snap, view_name="+edit") | 951 | view_url = canonical_url(snap, view_name="+edit") |
91 | 951 | browser = self.getNonRedirectingBrowser(url=view_url, user=self.person) | 952 | browser = self.getNonRedirectingBrowser(url=view_url, user=self.person) |
92 | 952 | browser.getControl("Registered store package name").value = "two" | 953 | browser.getControl("Registered store package name").value = "two" |
93 | 954 | self.assertEqual("track", browser.getControl("Track").value) | ||
94 | 955 | self.assertTrue(browser.getControl("Edge").selected) | ||
95 | 956 | browser.getControl("Track").value = "" | ||
96 | 953 | browser.getControl("Stable").selected = True | 957 | browser.getControl("Stable").selected = True |
97 | 954 | self.assertTrue(browser.getControl("Edge").selected) | ||
98 | 955 | root_macaroon = Macaroon() | 958 | root_macaroon = Macaroon() |
99 | 956 | root_macaroon.add_third_party_caveat( | 959 | root_macaroon.add_third_party_caveat( |
100 | 957 | urlsplit(config.launchpad.openid_provider_root).netloc, "", | 960 | urlsplit(config.launchpad.openid_provider_root).netloc, "", |
101 | @@ -1373,24 +1376,11 @@ | |||
102 | 1373 | view = create_initialized_view(snap, "+index") | 1376 | view = create_initialized_view(snap, "+index") |
103 | 1374 | self.assertEqual("", view.store_channels) | 1377 | self.assertEqual("", view.store_channels) |
104 | 1375 | 1378 | ||
123 | 1376 | def test_store_channels_uses_titles(self): | 1379 | def test_store_channels_display(self): |
124 | 1377 | snap_store_client = FakeMethod() | 1380 | snap = self.factory.makeSnap( |
125 | 1378 | snap_store_client.listChannels = FakeMethod(result=[ | 1381 | store_channels=["track/stable", "track/edge"]) |
126 | 1379 | {"name": "stable", "display_name": "Stable"}, | 1382 | view = create_initialized_view(snap, "+index") |
127 | 1380 | {"name": "edge", "display_name": "Edge"}, | 1383 | self.assertEqual("track/stable, track/edge", view.store_channels) |
110 | 1381 | {"name": "old", "display_name": "Old channel"}, | ||
111 | 1382 | ]) | ||
112 | 1383 | self.useFixture( | ||
113 | 1384 | ZopeUtilityFixture(snap_store_client, ISnapStoreClient)) | ||
114 | 1385 | snap = self.factory.makeSnap(store_channels=["stable", "old"]) | ||
115 | 1386 | view = create_initialized_view(snap, "+index") | ||
116 | 1387 | self.assertEqual("Stable, Old channel", view.store_channels) | ||
117 | 1388 | snap_store_client.listChannels.result = [ | ||
118 | 1389 | {"name": "stable", "display_name": "Stable"}, | ||
119 | 1390 | {"name": "edge", "display_name": "Edge"}, | ||
120 | 1391 | ] | ||
121 | 1392 | view = create_initialized_view(snap, "+index") | ||
122 | 1393 | self.assertEqual("Stable, old", view.store_channels) | ||
128 | 1394 | 1384 | ||
129 | 1395 | 1385 | ||
130 | 1396 | class TestSnapRequestBuildsView(BaseTestSnapView): | 1386 | class TestSnapRequestBuildsView(BaseTestSnapView): |
131 | 1397 | 1387 | ||
132 | === added file 'lib/lp/snappy/browser/widgets/storechannels.py' | |||
133 | --- lib/lp/snappy/browser/widgets/storechannels.py 1970-01-01 00:00:00 +0000 | |||
134 | +++ lib/lp/snappy/browser/widgets/storechannels.py 2017-03-30 14:10:04 +0000 | |||
135 | @@ -0,0 +1,152 @@ | |||
136 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
137 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
138 | 3 | |||
139 | 4 | __metaclass__ = type | ||
140 | 5 | |||
141 | 6 | __all__ = [ | ||
142 | 7 | 'StoreChannelsWidget', | ||
143 | 8 | ] | ||
144 | 9 | |||
145 | 10 | from z3c.ptcompat import ViewPageTemplateFile | ||
146 | 11 | from zope.formlib.interfaces import IInputWidget, WidgetInputError | ||
147 | 12 | from zope.formlib.utility import setUpWidget | ||
148 | 13 | from zope.formlib.widget import ( | ||
149 | 14 | BrowserWidget, | ||
150 | 15 | CustomWidgetFactory, | ||
151 | 16 | InputErrors, | ||
152 | 17 | InputWidget, | ||
153 | 18 | ) | ||
154 | 19 | from zope.interface import implementer | ||
155 | 20 | from zope.schema import ( | ||
156 | 21 | Choice, | ||
157 | 22 | List, | ||
158 | 23 | TextLine, | ||
159 | 24 | ) | ||
160 | 25 | |||
161 | 26 | from lp import _ | ||
162 | 27 | from lp.app.errors import UnexpectedFormData | ||
163 | 28 | from lp.app.validators import LaunchpadValidationError | ||
164 | 29 | from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget | ||
165 | 30 | from lp.services.webapp.interfaces import ( | ||
166 | 31 | IAlwaysSubmittedWidget, | ||
167 | 32 | ISingleLineWidgetLayout, | ||
168 | 33 | ) | ||
169 | 34 | from lp.snappy.validators.channels import ( | ||
170 | 35 | channel_components_delimiter, | ||
171 | 36 | split_channel_name, | ||
172 | 37 | ) | ||
173 | 38 | |||
174 | 39 | |||
175 | 40 | @implementer(ISingleLineWidgetLayout, IAlwaysSubmittedWidget, IInputWidget) | ||
176 | 41 | class StoreChannelsWidget(BrowserWidget, InputWidget): | ||
177 | 42 | |||
178 | 43 | template = ViewPageTemplateFile("templates/storechannels.pt") | ||
179 | 44 | display_label = False | ||
180 | 45 | _separator = channel_components_delimiter | ||
181 | 46 | _default_track = 'latest' | ||
182 | 47 | _widgets_set_up = False | ||
183 | 48 | |||
184 | 49 | def __init__(self, field, value_type, request): | ||
185 | 50 | # We don't use value_type. | ||
186 | 51 | super(StoreChannelsWidget, self).__init__(field, request) | ||
187 | 52 | |||
188 | 53 | def setUpSubWidgets(self): | ||
189 | 54 | if self._widgets_set_up: | ||
190 | 55 | return | ||
191 | 56 | fields = [ | ||
192 | 57 | TextLine(__name__="track", title=u"Track", required=False, | ||
193 | 58 | description=_( | ||
194 | 59 | "Track defines a series for your software. " | ||
195 | 60 | "If not specified, the default track ('latest') is " | ||
196 | 61 | "assumed.") | ||
197 | 62 | ), | ||
198 | 63 | List(__name__="risks", title=u"Risk", required=False, | ||
199 | 64 | value_type=Choice(vocabulary="SnapStoreChannel"), | ||
200 | 65 | description=_( | ||
201 | 66 | "Risks denote the stability of your software.")), | ||
202 | 67 | ] | ||
203 | 68 | |||
204 | 69 | self.risks_widget = CustomWidgetFactory(LabeledMultiCheckBoxWidget) | ||
205 | 70 | for field in fields: | ||
206 | 71 | setUpWidget( | ||
207 | 72 | self, field.__name__, field, IInputWidget, prefix=self.name) | ||
208 | 73 | self.risks_widget.orientation = 'horizontal' | ||
209 | 74 | self._widgets_set_up = True | ||
210 | 75 | |||
211 | 76 | @property | ||
212 | 77 | def has_risks_vocabulary(self): | ||
213 | 78 | risks_widget = getattr(self, 'risks_widget', None) | ||
214 | 79 | return risks_widget and bool(risks_widget.vocabulary) | ||
215 | 80 | |||
216 | 81 | def buildChannelName(self, track, risk): | ||
217 | 82 | """Return channel name composed from given track and risk.""" | ||
218 | 83 | channel = risk | ||
219 | 84 | if track and track != self._default_track: | ||
220 | 85 | channel = track + self._separator + risk | ||
221 | 86 | return channel | ||
222 | 87 | |||
223 | 88 | def splitChannelName(self, channel): | ||
224 | 89 | """Return extracted track and risk from given channel name.""" | ||
225 | 90 | try: | ||
226 | 91 | track, risk = split_channel_name(channel) | ||
227 | 92 | except ValueError: | ||
228 | 93 | raise AssertionError("Not a valid value: %r" % channel) | ||
229 | 94 | return track, risk | ||
230 | 95 | |||
231 | 96 | def setRenderedValue(self, value): | ||
232 | 97 | """See `IWidget`.""" | ||
233 | 98 | self.setUpSubWidgets() | ||
234 | 99 | if value: | ||
235 | 100 | # NOTE: atm target channels must belong to the same track | ||
236 | 101 | tracks = set() | ||
237 | 102 | risks = [] | ||
238 | 103 | for channel in value: | ||
239 | 104 | track, risk = self.splitChannelName(channel) | ||
240 | 105 | tracks.add(track) | ||
241 | 106 | risks.append(risk) | ||
242 | 107 | if len(tracks) != 1: | ||
243 | 108 | raise AssertionError("Not a valid value: %r" % value) | ||
244 | 109 | track = tracks.pop() | ||
245 | 110 | self.track_widget.setRenderedValue(track) | ||
246 | 111 | self.risks_widget.setRenderedValue(risks) | ||
247 | 112 | else: | ||
248 | 113 | self.track_widget.setRenderedValue(None) | ||
249 | 114 | self.risks_widget.setRenderedValue(None) | ||
250 | 115 | |||
251 | 116 | def hasInput(self): | ||
252 | 117 | """See `IInputWidget`.""" | ||
253 | 118 | return ("%s.risks" % self.name) in self.request.form | ||
254 | 119 | |||
255 | 120 | def hasValidInput(self): | ||
256 | 121 | """See `IInputWidget`.""" | ||
257 | 122 | try: | ||
258 | 123 | self.getInputValue() | ||
259 | 124 | return True | ||
260 | 125 | except (InputErrors, UnexpectedFormData): | ||
261 | 126 | return False | ||
262 | 127 | |||
263 | 128 | def getInputValue(self): | ||
264 | 129 | """See `IInputWidget`.""" | ||
265 | 130 | self.setUpSubWidgets() | ||
266 | 131 | risks = self.risks_widget.getInputValue() | ||
267 | 132 | track = self.track_widget.getInputValue() | ||
268 | 133 | if track and self._separator in track: | ||
269 | 134 | error_msg = "Track name cannot include '%s'." % self._separator | ||
270 | 135 | raise WidgetInputError( | ||
271 | 136 | self.name, self.label, LaunchpadValidationError(error_msg)) | ||
272 | 137 | channels = [self.buildChannelName(track, risk) for risk in risks] | ||
273 | 138 | return channels | ||
274 | 139 | |||
275 | 140 | def error(self): | ||
276 | 141 | """See `IBrowserWidget`.""" | ||
277 | 142 | try: | ||
278 | 143 | if self.hasInput(): | ||
279 | 144 | self.getInputValue() | ||
280 | 145 | except InputErrors as error: | ||
281 | 146 | self._error = error | ||
282 | 147 | return super(StoreChannelsWidget, self).error() | ||
283 | 148 | |||
284 | 149 | def __call__(self): | ||
285 | 150 | """See `IBrowserWidget`.""" | ||
286 | 151 | self.setUpSubWidgets() | ||
287 | 152 | return self.template() | ||
288 | 0 | 153 | ||
289 | === added file 'lib/lp/snappy/browser/widgets/templates/storechannels.pt' | |||
290 | --- lib/lp/snappy/browser/widgets/templates/storechannels.pt 1970-01-01 00:00:00 +0000 | |||
291 | +++ lib/lp/snappy/browser/widgets/templates/storechannels.pt 2017-03-30 14:10:04 +0000 | |||
292 | @@ -0,0 +1,22 @@ | |||
293 | 1 | <table> | ||
294 | 2 | <tr> | ||
295 | 3 | <td> | ||
296 | 4 | <tal:widget define="widget nocall:view/track_widget"> | ||
297 | 5 | <metal:block | ||
298 | 6 | use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" /> | ||
299 | 7 | </tal:widget> | ||
300 | 8 | <p class="formHelp"> | ||
301 | 9 | To open a new track, <a href="https://snapcraft.io/community">ask the store admins</a>. | ||
302 | 10 | </p> | ||
303 | 11 | </td> | ||
304 | 12 | </tr> | ||
305 | 13 | <tr> | ||
306 | 14 | <td> | ||
307 | 15 | <tal:widget define="widget nocall:view/risks_widget" | ||
308 | 16 | condition="widget/context/value_type/vocabulary"> | ||
309 | 17 | <metal:block | ||
310 | 18 | use-macro="context/@@launchpad_widget_macros/launchpad_widget_row" /> | ||
311 | 19 | </tal:widget> | ||
312 | 20 | </td> | ||
313 | 21 | </tr> | ||
314 | 22 | </table> | ||
315 | 0 | 23 | ||
316 | === added file 'lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py' | |||
317 | --- lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py 1970-01-01 00:00:00 +0000 | |||
318 | +++ lib/lp/snappy/browser/widgets/tests/test_storechannelswidget.py 2017-03-30 14:10:04 +0000 | |||
319 | @@ -0,0 +1,194 @@ | |||
320 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
321 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
322 | 3 | |||
323 | 4 | __metaclass__ = type | ||
324 | 5 | |||
325 | 6 | import re | ||
326 | 7 | |||
327 | 8 | from BeautifulSoup import BeautifulSoup | ||
328 | 9 | from zope.formlib.interfaces import ( | ||
329 | 10 | IBrowserWidget, | ||
330 | 11 | IInputWidget, | ||
331 | 12 | WidgetInputError, | ||
332 | 13 | ) | ||
333 | 14 | from zope.schema import List | ||
334 | 15 | |||
335 | 16 | from lp.app.validators import LaunchpadValidationError | ||
336 | 17 | from lp.services.webapp.escaping import html_escape | ||
337 | 18 | from lp.services.webapp.servers import LaunchpadTestRequest | ||
338 | 19 | from lp.snappy.browser.widgets.storechannels import StoreChannelsWidget | ||
339 | 20 | from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient | ||
340 | 21 | from lp.snappy.vocabularies import SnapStoreChannelVocabulary | ||
341 | 22 | from lp.testing import ( | ||
342 | 23 | TestCaseWithFactory, | ||
343 | 24 | verifyObject, | ||
344 | 25 | ) | ||
345 | 26 | from lp.testing.fakemethod import FakeMethod | ||
346 | 27 | from lp.testing.fixture import ZopeUtilityFixture | ||
347 | 28 | from lp.testing.layers import DatabaseFunctionalLayer | ||
348 | 29 | |||
349 | 30 | |||
350 | 31 | class TestStoreChannelsWidget(TestCaseWithFactory): | ||
351 | 32 | |||
352 | 33 | layer = DatabaseFunctionalLayer | ||
353 | 34 | |||
354 | 35 | def setUp(self): | ||
355 | 36 | super(TestStoreChannelsWidget, self).setUp() | ||
356 | 37 | field = List(__name__="channels", title=u"Store channels") | ||
357 | 38 | self.context = self.factory.makeSnap() | ||
358 | 39 | field = field.bind(self.context) | ||
359 | 40 | request = LaunchpadTestRequest() | ||
360 | 41 | self.widget = StoreChannelsWidget(field, None, request) | ||
361 | 42 | |||
362 | 43 | # setup fake store client response for available channels/risks | ||
363 | 44 | self.risks = [ | ||
364 | 45 | {"name": "stable", "display_name": "Stable"}, | ||
365 | 46 | {"name": "candidate", "display_name": "Candidate"}, | ||
366 | 47 | {"name": "beta", "display_name": "Beta"}, | ||
367 | 48 | {"name": "edge", "display_name": "Edge"}, | ||
368 | 49 | ] | ||
369 | 50 | snap_store_client = FakeMethod() | ||
370 | 51 | snap_store_client.listChannels = FakeMethod(result=self.risks) | ||
371 | 52 | self.useFixture( | ||
372 | 53 | ZopeUtilityFixture(snap_store_client, ISnapStoreClient)) | ||
373 | 54 | |||
374 | 55 | def test_implements(self): | ||
375 | 56 | self.assertTrue(verifyObject(IBrowserWidget, self.widget)) | ||
376 | 57 | self.assertTrue(verifyObject(IInputWidget, self.widget)) | ||
377 | 58 | |||
378 | 59 | def test_template(self): | ||
379 | 60 | self.assertTrue( | ||
380 | 61 | self.widget.template.filename.endswith("storechannels.pt"), | ||
381 | 62 | "Template was not set up.") | ||
382 | 63 | |||
383 | 64 | def test_setUpSubWidgets_first_call(self): | ||
384 | 65 | # The subwidgets are set up and a flag is set. | ||
385 | 66 | self.widget.setUpSubWidgets() | ||
386 | 67 | self.assertTrue(self.widget._widgets_set_up) | ||
387 | 68 | self.assertIsNotNone(getattr(self.widget, "track_widget", None)) | ||
388 | 69 | self.assertIsInstance( | ||
389 | 70 | self.widget.risks_widget.vocabulary, SnapStoreChannelVocabulary) | ||
390 | 71 | self.assertTrue(self.widget.has_risks_vocabulary) | ||
391 | 72 | |||
392 | 73 | def test_setUpSubWidgets_second_call(self): | ||
393 | 74 | # The setUpSubWidgets method exits early if a flag is set to | ||
394 | 75 | # indicate that the widgets were set up. | ||
395 | 76 | self.widget._widgets_set_up = True | ||
396 | 77 | self.widget.setUpSubWidgets() | ||
397 | 78 | self.assertIsNone(getattr(self.widget, "track_widget", None)) | ||
398 | 79 | self.assertIsNone(getattr(self.widget, "risks_widget", None)) | ||
399 | 80 | self.assertIsNone(self.widget.has_risks_vocabulary) | ||
400 | 81 | |||
401 | 82 | def test_buildChannelName_no_track(self): | ||
402 | 83 | self.assertEqual("edge", self.widget.buildChannelName(None, "edge")) | ||
403 | 84 | |||
404 | 85 | def test_buildChannelName_with_track(self): | ||
405 | 86 | self.assertEqual( | ||
406 | 87 | "track/edge", self.widget.buildChannelName("track", "edge")) | ||
407 | 88 | |||
408 | 89 | def test_splitChannelName_no_track(self): | ||
409 | 90 | self.assertEqual((None, "edge"), self.widget.splitChannelName("edge")) | ||
410 | 91 | |||
411 | 92 | def test_splitChannelName_with_track(self): | ||
412 | 93 | self.assertEqual( | ||
413 | 94 | ("track", "edge"), self.widget.splitChannelName("track/edge")) | ||
414 | 95 | |||
415 | 96 | def test_splitChannelName_invalid(self): | ||
416 | 97 | self.assertRaises( | ||
417 | 98 | AssertionError, self.widget.splitChannelName, "track/edge/invalid") | ||
418 | 99 | |||
419 | 100 | def test_setRenderedValue_empty(self): | ||
420 | 101 | self.widget.setRenderedValue([]) | ||
421 | 102 | self.assertIsNone(self.widget.track_widget._getCurrentValue()) | ||
422 | 103 | self.assertIsNone(self.widget.risks_widget._getCurrentValue()) | ||
423 | 104 | |||
424 | 105 | def test_setRenderedValue_no_track(self): | ||
425 | 106 | # Channels do not include a track | ||
426 | 107 | risks = ['candidate', 'edge'] | ||
427 | 108 | self.widget.setRenderedValue(risks) | ||
428 | 109 | self.assertIsNone(self.widget.track_widget._getCurrentValue()) | ||
429 | 110 | self.assertEqual(risks, self.widget.risks_widget._getCurrentValue()) | ||
430 | 111 | |||
431 | 112 | def test_setRenderedValue_with_track(self): | ||
432 | 113 | # Channels including a track | ||
433 | 114 | channels = ['2.2/candidate', '2.2/edge'] | ||
434 | 115 | self.widget.setRenderedValue(channels) | ||
435 | 116 | self.assertEqual('2.2', self.widget.track_widget._getCurrentValue()) | ||
436 | 117 | self.assertEqual( | ||
437 | 118 | ['candidate', 'edge'], self.widget.risks_widget._getCurrentValue()) | ||
438 | 119 | |||
439 | 120 | def test_setRenderedValue_invalid_value(self): | ||
440 | 121 | # Multiple channels, different tracks, unsupported | ||
441 | 122 | channels = ['2.2/candidate', '2.1/edge'] | ||
442 | 123 | self.assertRaises( | ||
443 | 124 | AssertionError, self.widget.setRenderedValue, channels) | ||
444 | 125 | |||
445 | 126 | def test_hasInput_false(self): | ||
446 | 127 | # hasInput is false when there is no risk set in the form data. | ||
447 | 128 | self.widget.request = LaunchpadTestRequest( | ||
448 | 129 | form={"field.channels.track": "track"}) | ||
449 | 130 | self.assertFalse(self.widget.hasInput()) | ||
450 | 131 | |||
451 | 132 | def test_hasInput_true(self): | ||
452 | 133 | # hasInput is true if there are risks set in the form data. | ||
453 | 134 | self.widget.request = LaunchpadTestRequest( | ||
454 | 135 | form={"field.channels.risks": ["beta"]}) | ||
455 | 136 | self.assertTrue(self.widget.hasInput()) | ||
456 | 137 | |||
457 | 138 | def test_hasValidInput_false(self): | ||
458 | 139 | # The field input is invalid if any of the submitted parts are | ||
459 | 140 | # invalid. | ||
460 | 141 | form = { | ||
461 | 142 | "field.channels.track": "", | ||
462 | 143 | "field.channels.risks": ["invalid"], | ||
463 | 144 | } | ||
464 | 145 | self.widget.request = LaunchpadTestRequest(form=form) | ||
465 | 146 | self.assertFalse(self.widget.hasValidInput()) | ||
466 | 147 | |||
467 | 148 | def test_hasValidInput_true(self): | ||
468 | 149 | # The field input is valid when all submitted parts are valid. | ||
469 | 150 | form = { | ||
470 | 151 | "field.channels.track": "track", | ||
471 | 152 | "field.channels.risks": ["stable", "beta"], | ||
472 | 153 | } | ||
473 | 154 | self.widget.request = LaunchpadTestRequest(form=form) | ||
474 | 155 | self.assertTrue(self.widget.hasValidInput()) | ||
475 | 156 | |||
476 | 157 | def assertGetInputValueError(self, form, message): | ||
477 | 158 | self.widget.request = LaunchpadTestRequest(form=form) | ||
478 | 159 | e = self.assertRaises(WidgetInputError, self.widget.getInputValue) | ||
479 | 160 | self.assertEqual(LaunchpadValidationError(message), e.errors) | ||
480 | 161 | self.assertEqual(html_escape(message), self.widget.error()) | ||
481 | 162 | |||
482 | 163 | def test_getInputValue_invalid_track(self): | ||
483 | 164 | # An error is raised when the track includes a '/'. | ||
484 | 165 | form = {"field.channels.track": "tra/ck", | ||
485 | 166 | "field.channels.risks": ["beta"]} | ||
486 | 167 | self.assertGetInputValueError(form, "Track name cannot include '/'.") | ||
487 | 168 | |||
488 | 169 | def test_getInputValue_no_track(self): | ||
489 | 170 | self.widget.request = LaunchpadTestRequest( | ||
490 | 171 | form={"field.channels.track": "", | ||
491 | 172 | "field.channels.risks": ["beta", "edge"]}) | ||
492 | 173 | expected = ["beta", "edge"] | ||
493 | 174 | self.assertEqual(expected, self.widget.getInputValue()) | ||
494 | 175 | |||
495 | 176 | def test_getInputValue_with_track(self): | ||
496 | 177 | self.widget.request = LaunchpadTestRequest( | ||
497 | 178 | form={"field.channels.track": "track", | ||
498 | 179 | "field.channels.risks": ["beta", "edge"]}) | ||
499 | 180 | expected = ["track/beta", "track/edge"] | ||
500 | 181 | self.assertEqual(expected, self.widget.getInputValue()) | ||
501 | 182 | |||
502 | 183 | def test_call(self): | ||
503 | 184 | # The __call__ method sets up the widgets. | ||
504 | 185 | markup = self.widget() | ||
505 | 186 | self.assertIsNotNone(self.widget.track_widget) | ||
506 | 187 | self.assertIsNotNone(self.widget.risks_widget) | ||
507 | 188 | soup = BeautifulSoup(markup) | ||
508 | 189 | fields = soup.findAll(["input"], {"id": re.compile(".*")}) | ||
509 | 190 | expected_ids = [ | ||
510 | 191 | "field.channels.risks.%d" % i for i in range(len(self.risks))] | ||
511 | 192 | expected_ids.append("field.channels.track") | ||
512 | 193 | ids = [field["id"] for field in fields] | ||
513 | 194 | self.assertContentEqual(expected_ids, ids) | ||
514 | 0 | 195 | ||
515 | === modified file 'lib/lp/snappy/interfaces/snap.py' | |||
516 | --- lib/lp/snappy/interfaces/snap.py 2017-02-06 14:34:35 +0000 | |||
517 | +++ lib/lp/snappy/interfaces/snap.py 2017-03-30 14:10:04 +0000 | |||
518 | @@ -94,6 +94,7 @@ | |||
519 | 94 | ISnappyDistroSeries, | 94 | ISnappyDistroSeries, |
520 | 95 | ISnappySeries, | 95 | ISnappySeries, |
521 | 96 | ) | 96 | ) |
522 | 97 | from lp.snappy.validators.channels import channels_validator | ||
523 | 97 | from lp.soyuz.interfaces.archive import IArchive | 98 | from lp.soyuz.interfaces.archive import IArchive |
524 | 98 | from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries | 99 | from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries |
525 | 99 | 100 | ||
526 | @@ -537,11 +538,12 @@ | |||
527 | 537 | "authorize uploads of this snap package.")) | 538 | "authorize uploads of this snap package.")) |
528 | 538 | 539 | ||
529 | 539 | store_channels = exported(List( | 540 | store_channels = exported(List( |
532 | 540 | value_type=Choice(vocabulary="SnapStoreChannel"), | 541 | title=_("Store channels"), |
533 | 541 | title=_("Store channels"), required=False, readonly=False, | 542 | required=False, readonly=False, constraint=channels_validator, |
534 | 542 | description=_( | 543 | description=_( |
535 | 543 | "Channels to release this snap package to after uploading it to " | 544 | "Channels to release this snap package to after uploading it to " |
537 | 544 | "the store."))) | 545 | "the store. A channel is defined by a combination of an optional " |
538 | 546 | " track and a risk, e.g. '2.1/stable', or 'stable'."))) | ||
539 | 545 | 547 | ||
540 | 546 | 548 | ||
541 | 547 | class ISnapAdminAttributes(Interface): | 549 | class ISnapAdminAttributes(Interface): |
542 | 548 | 550 | ||
543 | === modified file 'lib/lp/snappy/templates/snap-edit.pt' | |||
544 | --- lib/lp/snappy/templates/snap-edit.pt 2016-07-28 12:42:21 +0000 | |||
545 | +++ lib/lp/snappy/templates/snap-edit.pt 2017-03-30 14:10:04 +0000 | |||
546 | @@ -87,7 +87,7 @@ | |||
547 | 87 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> | 87 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> |
548 | 88 | </tal:widget> | 88 | </tal:widget> |
549 | 89 | <tal:widget define="widget nocall:view/widgets/store_channels" | 89 | <tal:widget define="widget nocall:view/widgets/store_channels" |
551 | 90 | condition="widget/context/value_type/vocabulary"> | 90 | condition="widget/has_risks_vocabulary"> |
552 | 91 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> | 91 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> |
553 | 92 | </tal:widget> | 92 | </tal:widget> |
554 | 93 | </table> | 93 | </table> |
555 | 94 | 94 | ||
556 | === modified file 'lib/lp/snappy/templates/snap-new.pt' | |||
557 | --- lib/lp/snappy/templates/snap-new.pt 2017-02-01 06:31:30 +0000 | |||
558 | +++ lib/lp/snappy/templates/snap-new.pt 2017-03-30 14:10:04 +0000 | |||
559 | @@ -63,7 +63,7 @@ | |||
560 | 63 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> | 63 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> |
561 | 64 | </tal:widget> | 64 | </tal:widget> |
562 | 65 | <tal:widget define="widget nocall:view/widgets/store_channels" | 65 | <tal:widget define="widget nocall:view/widgets/store_channels" |
564 | 66 | condition="widget/context/value_type/vocabulary"> | 66 | condition="widget/has_risks_vocabulary"> |
565 | 67 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> | 67 | <metal:block use-macro="context/@@launchpad_form/widget_row" /> |
566 | 68 | </tal:widget> | 68 | </tal:widget> |
567 | 69 | </table> | 69 | </table> |
568 | 70 | 70 | ||
569 | === added directory 'lib/lp/snappy/validators' | |||
570 | === added file 'lib/lp/snappy/validators/__init__.py' | |||
571 | === added file 'lib/lp/snappy/validators/channels.py' | |||
572 | --- lib/lp/snappy/validators/channels.py 1970-01-01 00:00:00 +0000 | |||
573 | +++ lib/lp/snappy/validators/channels.py 2017-03-30 14:10:04 +0000 | |||
574 | @@ -0,0 +1,53 @@ | |||
575 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
576 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
577 | 3 | |||
578 | 4 | """Validators for the .store_channels attribute.""" | ||
579 | 5 | |||
580 | 6 | __metaclass__ = type | ||
581 | 7 | |||
582 | 8 | from lp import _ | ||
583 | 9 | from lp.app.validators import LaunchpadValidationError | ||
584 | 10 | from lp.services.webapp.escaping import ( | ||
585 | 11 | html_escape, | ||
586 | 12 | structured, | ||
587 | 13 | ) | ||
588 | 14 | |||
589 | 15 | |||
590 | 16 | # delimiter separating channel components | ||
591 | 17 | channel_components_delimiter = '/' | ||
592 | 18 | |||
593 | 19 | |||
594 | 20 | def split_channel_name(channel): | ||
595 | 21 | """Return extracted track and risk from given channel name.""" | ||
596 | 22 | components = channel.split(channel_components_delimiter) | ||
597 | 23 | if len(components) == 2: | ||
598 | 24 | track, risk = components | ||
599 | 25 | elif len(components) == 1: | ||
600 | 26 | track = None | ||
601 | 27 | risk = components[0] | ||
602 | 28 | else: | ||
603 | 29 | raise ValueError("Invalid channel name: %r" % channel) | ||
604 | 30 | return track, risk | ||
605 | 31 | |||
606 | 32 | |||
607 | 33 | def channels_validator(channels): | ||
608 | 34 | """Return True if the channels in a list are valid, or raise a | ||
609 | 35 | LaunchpadValidationError. | ||
610 | 36 | """ | ||
611 | 37 | tracks = set() | ||
612 | 38 | for name in channels: | ||
613 | 39 | try: | ||
614 | 40 | track, risk = split_channel_name(name) | ||
615 | 41 | except ValueError: | ||
616 | 42 | message = _( | ||
617 | 43 | "Invalid channel name '${name}'. Channel names must be of the " | ||
618 | 44 | "form 'track/risk' or 'risk'.", | ||
619 | 45 | mapping={'name': html_escape(name)}) | ||
620 | 46 | raise LaunchpadValidationError(structured(message)) | ||
621 | 47 | tracks.add(track) | ||
622 | 48 | |||
623 | 49 | if len(tracks) != 1: | ||
624 | 50 | message = _("Channels must belong to the same track.") | ||
625 | 51 | raise LaunchpadValidationError(structured(message)) | ||
626 | 52 | |||
627 | 53 | return True | ||
628 | 0 | 54 | ||
629 | === added directory 'lib/lp/snappy/validators/tests' | |||
630 | === added file 'lib/lp/snappy/validators/tests/__init__.py' | |||
631 | === added file 'lib/lp/snappy/validators/tests/test_channels.py' | |||
632 | --- lib/lp/snappy/validators/tests/test_channels.py 1970-01-01 00:00:00 +0000 | |||
633 | +++ lib/lp/snappy/validators/tests/test_channels.py 2017-03-30 14:10:04 +0000 | |||
634 | @@ -0,0 +1,40 @@ | |||
635 | 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the | ||
636 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
637 | 3 | |||
638 | 4 | __metaclass__ = type | ||
639 | 5 | |||
640 | 6 | from lp.app.validators import LaunchpadValidationError | ||
641 | 7 | from lp.snappy.validators.channels import ( | ||
642 | 8 | channels_validator, | ||
643 | 9 | split_channel_name, | ||
644 | 10 | ) | ||
645 | 11 | from lp.testing import TestCaseWithFactory | ||
646 | 12 | from lp.testing.layers import LaunchpadFunctionalLayer | ||
647 | 13 | |||
648 | 14 | |||
649 | 15 | class TestChannelsValidator(TestCaseWithFactory): | ||
650 | 16 | |||
651 | 17 | layer = LaunchpadFunctionalLayer | ||
652 | 18 | |||
653 | 19 | def test_split_channel_name_no_track(self): | ||
654 | 20 | self.assertEqual((None, "edge"), split_channel_name("edge")) | ||
655 | 21 | |||
656 | 22 | def test_split_channel_name_with_track(self): | ||
657 | 23 | self.assertEqual(("track", "edge"), split_channel_name("track/edge")) | ||
658 | 24 | |||
659 | 25 | def test_split_channel_name_invalid(self): | ||
660 | 26 | self.assertRaises(ValueError, split_channel_name, "track/edge/invalid") | ||
661 | 27 | |||
662 | 28 | def test_channels_validator_valid(self): | ||
663 | 29 | self.assertTrue(channels_validator(['1.1/beta', '1.1/edge'])) | ||
664 | 30 | self.assertTrue(channels_validator(['beta', 'edge'])) | ||
665 | 31 | |||
666 | 32 | def test_channels_validator_multiple_tracks(self): | ||
667 | 33 | self.assertRaises( | ||
668 | 34 | LaunchpadValidationError, channels_validator, | ||
669 | 35 | ['1.1/stable', '2.1/edge']) | ||
670 | 36 | |||
671 | 37 | def test_channels_validator_invalid_channel(self): | ||
672 | 38 | self.assertRaises( | ||
673 | 39 | LaunchpadValidationError, channels_validator, | ||
674 | 40 | ['1.1/stable/invalid']) |
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.