Merge lp:~wgrant/launchpad/webhook-browser into lp:launchpad
- webhook-browser
- Merge into devel
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 17679 | ||||
Proposed branch: | lp:~wgrant/launchpad/webhook-browser | ||||
Merge into: | lp:launchpad | ||||
Diff against target: |
774 lines (+582/-21) 12 files modified
lib/lp/app/browser/launchpad.py (+15/-11) lib/lp/app/doc/hierarchical-menu.txt (+4/-4) lib/lp/code/browser/gitrepository.py (+9/-1) lib/lp/services/webapp/tests/test_breadcrumbs.py (+1/-1) lib/lp/services/webhooks/browser.py (+125/-0) lib/lp/services/webhooks/configure.zcml (+33/-0) lib/lp/services/webhooks/interfaces.py (+7/-3) lib/lp/services/webhooks/model.py (+2/-1) lib/lp/services/webhooks/templates/webhook-delete.pt (+29/-0) lib/lp/services/webhooks/templates/webhook-index.pt (+23/-0) lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt (+52/-0) lib/lp/services/webhooks/tests/test_browser.py (+282/-0) |
||||
To merge this branch: | bzr merge lp:~wgrant/launchpad/webhook-browser | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+266870@code.launchpad.net |
Commit message
Basic web UI for managing webhooks.
Description of the change
Basic webhook web UI.
Git repositories gain a "Manage webhooks" side menu link, appearing only when the feature flag is set. That takes you to a list of webhooks and an add link. Webhook:+index is its config form, as a webhook is pretty much entirely config anyway, and it links to +delete. Webhook:+index will later grow a lower half for delivery management, but that'll require some JavaScript and fits best in another branch.
I had to slightly extend the breadcrumb infrastructure to get consistent breadcrumbs and titles. In particular, IGitRepository:
We need a better way to unit test breadcrumbs, but this works for now.
Colin Watson (cjwatson) : | # |
Preview Diff
1 | === modified file 'lib/lp/app/browser/launchpad.py' |
2 | --- lib/lp/app/browser/launchpad.py 2015-07-23 16:41:12 +0000 |
3 | +++ lib/lp/app/browser/launchpad.py 2015-08-06 00:47:33 +0000 |
4 | @@ -322,9 +322,7 @@ |
5 | remaining_crumb.rootsite_override = facet.rootsite |
6 | break |
7 | if len(breadcrumbs) > 0: |
8 | - page_crumb = self.makeBreadcrumbForRequestedPage() |
9 | - if page_crumb: |
10 | - breadcrumbs.append(page_crumb) |
11 | + breadcrumbs.extend(self.makeBreadcrumbsForRequestedPage()) |
12 | return breadcrumbs |
13 | |
14 | @property |
15 | @@ -356,15 +354,15 @@ |
16 | else: |
17 | return None |
18 | |
19 | - def makeBreadcrumbForRequestedPage(self): |
20 | - """Return an `IBreadcrumb` for the requested page. |
21 | + def makeBreadcrumbsForRequestedPage(self): |
22 | + """Return a sequence of `IBreadcrumb`s for the requested page. |
23 | |
24 | The `IBreadcrumb` for the requested page is created using the current |
25 | URL and the page's name (i.e. the last path segment of the URL). |
26 | |
27 | If the view is the default one for the object or the current |
28 | - facet, return None -- we'll have injected a facet Breadcrumb |
29 | - earlier in the hierarchy which links here. |
30 | + facet, no breadcrumbs are returned -- we'll have injected a |
31 | + facet Breadcrumb earlier in the hierarchy which links here. |
32 | """ |
33 | url = self.request.getURL() |
34 | obj = self.request.traversed_objects[-2] |
35 | @@ -374,16 +372,22 @@ |
36 | facet = queryUtility(IFacet, name=get_facet(view)) |
37 | if facet is not None: |
38 | default_views.append(facet.default_view) |
39 | + crumbs = [] |
40 | + |
41 | + # Views may provide an additional breadcrumb to precede them. |
42 | + # This is useful to have an add view link back to its |
43 | + # collection despite its parent being the context of the collection. |
44 | + if hasattr(view, 'inside_breadcrumb'): |
45 | + crumbs.append(view.inside_breadcrumb) |
46 | + |
47 | if hasattr(view, '__name__') and view.__name__ not in default_views: |
48 | title = getattr(view, 'page_title', None) |
49 | if title is None: |
50 | title = getattr(view, 'label', None) |
51 | if isinstance(title, Message): |
52 | title = i18n.translate(title, context=self.request) |
53 | - breadcrumb = Breadcrumb(None, url=url, text=title) |
54 | - return breadcrumb |
55 | - else: |
56 | - return None |
57 | + crumbs.append(Breadcrumb(None, url=url, text=title)) |
58 | + return crumbs |
59 | |
60 | @property |
61 | def display_breadcrumbs(self): |
62 | |
63 | === modified file 'lib/lp/app/doc/hierarchical-menu.txt' |
64 | --- lib/lp/app/doc/hierarchical-menu.txt 2015-07-08 16:05:11 +0000 |
65 | +++ lib/lp/app/doc/hierarchical-menu.txt 2015-08-06 00:47:33 +0000 |
66 | @@ -91,12 +91,12 @@ |
67 | >>> from lp.app.browser.launchpad import Hierarchy |
68 | >>> from lp.services.webapp.breadcrumb import Breadcrumb |
69 | |
70 | - # Monkey patch Hierarchy.makeBreadcrumbForRequestedPage so that we don't |
71 | + # Monkey patch Hierarchy.makeBreadcrumbsForRequestedPage so that we don't |
72 | # have to create fake views and other stuff to test breadcrumbs here. The |
73 | # functionality provided by that method is tested in |
74 | # webapp/tests/test_breadcrumbs.py. |
75 | - >>> make_breadcrumb_func = Hierarchy.makeBreadcrumbForRequestedPage |
76 | - >>> Hierarchy.makeBreadcrumbForRequestedPage = lambda self: None |
77 | + >>> make_breadcrumb_func = Hierarchy.makeBreadcrumbsForRequestedPage |
78 | + >>> Hierarchy.makeBreadcrumbsForRequestedPage = lambda self: [] |
79 | |
80 | # Note that the Hierarchy assigns the breadcrumb's URL, but we need to |
81 | # give it a valid .text attribute. |
82 | @@ -294,4 +294,4 @@ |
83 | |
84 | Put the monkey patched method back. |
85 | |
86 | - >>> Hierarchy.makeBreadcrumbForRequestedPage = make_breadcrumb_func |
87 | + >>> Hierarchy.makeBreadcrumbsForRequestedPage = make_breadcrumb_func |
88 | |
89 | === modified file 'lib/lp/code/browser/gitrepository.py' |
90 | --- lib/lp/code/browser/gitrepository.py 2015-07-12 23:48:01 +0000 |
91 | +++ lib/lp/code/browser/gitrepository.py 2015-08-06 00:47:33 +0000 |
92 | @@ -68,6 +68,7 @@ |
93 | from lp.registry.vocabularies import UserTeamsParticipationPlusSelfVocabulary |
94 | from lp.services.config import config |
95 | from lp.services.database.constants import UTC_NOW |
96 | +from lp.services.features import getFeatureFlag |
97 | from lp.services.propertycache import cachedproperty |
98 | from lp.services.webapp import ( |
99 | canonical_url, |
100 | @@ -147,7 +148,7 @@ |
101 | usedfor = IGitRepository |
102 | facet = "branches" |
103 | title = "Edit Git repository" |
104 | - links = ["edit", "reviewer", "delete"] |
105 | + links = ["edit", "reviewer", "webhooks", "delete"] |
106 | |
107 | @enabled_with_permission("launchpad.Edit") |
108 | def edit(self): |
109 | @@ -160,6 +161,13 @@ |
110 | return Link("+reviewer", text, icon="edit") |
111 | |
112 | @enabled_with_permission("launchpad.Edit") |
113 | + def webhooks(self): |
114 | + text = "Manage webhooks" |
115 | + return Link( |
116 | + "+webhooks", text, icon="edit", |
117 | + enabled=bool(getFeatureFlag('webhooks.new.enabled'))) |
118 | + |
119 | + @enabled_with_permission("launchpad.Edit") |
120 | def delete(self): |
121 | text = "Delete repository" |
122 | return Link("+delete", text, icon="trash-icon") |
123 | |
124 | === modified file 'lib/lp/services/webapp/tests/test_breadcrumbs.py' |
125 | --- lib/lp/services/webapp/tests/test_breadcrumbs.py 2015-07-08 16:05:11 +0000 |
126 | +++ lib/lp/services/webapp/tests/test_breadcrumbs.py 2015-08-06 00:47:33 +0000 |
127 | @@ -123,7 +123,7 @@ |
128 | request = LaunchpadTestRequest() |
129 | request.traversed_objects = [self.product, test_view] |
130 | hierarchy_view = Hierarchy(test_view, request) |
131 | - breadcrumb = hierarchy_view.makeBreadcrumbForRequestedPage() |
132 | + [breadcrumb] = hierarchy_view.makeBreadcrumbsForRequestedPage() |
133 | self.assertEquals(breadcrumb.text, 'breadcrumb test') |
134 | |
135 | |
136 | |
137 | === modified file 'lib/lp/services/webhooks/browser.py' |
138 | --- lib/lp/services/webhooks/browser.py 2015-07-03 07:29:10 +0000 |
139 | +++ lib/lp/services/webhooks/browser.py 2015-08-06 00:47:33 +0000 |
140 | @@ -10,12 +10,24 @@ |
141 | 'WebhookTargetNavigationMixin', |
142 | ] |
143 | |
144 | +from lazr.restful.interface import use_template |
145 | from zope.component import getUtility |
146 | +from zope.interface import Interface |
147 | |
148 | +from lp.app.browser.launchpadform import ( |
149 | + action, |
150 | + LaunchpadEditFormView, |
151 | + LaunchpadFormView, |
152 | + ) |
153 | +from lp.services.propertycache import cachedproperty |
154 | from lp.services.webapp import ( |
155 | + canonical_url, |
156 | + LaunchpadView, |
157 | Navigation, |
158 | stepthrough, |
159 | ) |
160 | +from lp.services.webapp.batching import BatchNavigator |
161 | +from lp.services.webapp.breadcrumb import Breadcrumb |
162 | from lp.services.webhooks.interfaces import ( |
163 | IWebhook, |
164 | IWebhookSource, |
165 | @@ -47,3 +59,116 @@ |
166 | if webhook is None or webhook.target != self.context: |
167 | return None |
168 | return webhook |
169 | + |
170 | + |
171 | +class WebhooksView(LaunchpadView): |
172 | + |
173 | + @property |
174 | + def page_title(self): |
175 | + return "Webhooks" |
176 | + |
177 | + @property |
178 | + def label(self): |
179 | + return "Webhooks for %s" % self.context.display_name |
180 | + |
181 | + @cachedproperty |
182 | + def batchnav(self): |
183 | + return BatchNavigator( |
184 | + getUtility(IWebhookSource).findByTarget(self.context), |
185 | + self.request) |
186 | + |
187 | + |
188 | +class WebhooksBreadcrumb(Breadcrumb): |
189 | + |
190 | + text = "Webhooks" |
191 | + |
192 | + @property |
193 | + def url(self): |
194 | + return canonical_url(self.context, view_name="+webhooks") |
195 | + |
196 | + @property |
197 | + def inside(self): |
198 | + return self.context |
199 | + |
200 | + |
201 | +class WebhookBreadcrumb(Breadcrumb): |
202 | + |
203 | + @property |
204 | + def text(self): |
205 | + return self.context.delivery_url |
206 | + |
207 | + @property |
208 | + def inside(self): |
209 | + return WebhooksBreadcrumb(self.context.target) |
210 | + |
211 | + |
212 | +class WebhookEditSchema(Interface): |
213 | + # XXX wgrant 2015-08-04: Need custom widgets for secret and |
214 | + # event_types. |
215 | + use_template(IWebhook, include=['delivery_url', 'event_types', 'active']) |
216 | + |
217 | + |
218 | +class WebhookAddView(LaunchpadFormView): |
219 | + |
220 | + page_title = label = "Add webhook" |
221 | + |
222 | + schema = WebhookEditSchema |
223 | + |
224 | + @property |
225 | + def inside_breadcrumb(self): |
226 | + return WebhooksBreadcrumb(self.context) |
227 | + |
228 | + @property |
229 | + def initial_values(self): |
230 | + return {'active': True} |
231 | + |
232 | + @property |
233 | + def cancel_url(self): |
234 | + return canonical_url(self.context, view_name="+webhooks") |
235 | + |
236 | + @action("Add webhook", name="new") |
237 | + def new_action(self, action, data): |
238 | + webhook = self.context.newWebhook( |
239 | + registrant=self.user, delivery_url=data['delivery_url'], |
240 | + event_types=data['event_types'], active=data['active']) |
241 | + self.next_url = canonical_url(webhook) |
242 | + |
243 | + |
244 | +class WebhookView(LaunchpadEditFormView): |
245 | + |
246 | + schema = WebhookEditSchema |
247 | + |
248 | + label = "Manage webhook" |
249 | + |
250 | + @property |
251 | + def next_url(self): |
252 | + # The edit form is the default view, so the URL doesn't need the |
253 | + # normal view name suffix. |
254 | + return canonical_url(self.context) |
255 | + |
256 | + @property |
257 | + def adapters(self): |
258 | + return {self.schema: self.context} |
259 | + |
260 | + @action("Save webhook", name="save") |
261 | + def save_action(self, action, data): |
262 | + self.updateContextFromData(data) |
263 | + |
264 | + |
265 | +class WebhookDeleteView(LaunchpadFormView): |
266 | + |
267 | + schema = Interface |
268 | + |
269 | + page_title = label = "Delete webhook" |
270 | + |
271 | + @property |
272 | + def cancel_url(self): |
273 | + return canonical_url(self.context) |
274 | + |
275 | + @action("Delete webhook", name="delete") |
276 | + def delete_action(self, action, data): |
277 | + target = self.context.target |
278 | + self.context.destroySelf() |
279 | + self.request.response.addNotification( |
280 | + "Webhook for %s deleted." % self.context.delivery_url) |
281 | + self.next_url = canonical_url(target, view_name="+webhooks") |
282 | |
283 | === modified file 'lib/lp/services/webhooks/configure.zcml' |
284 | --- lib/lp/services/webhooks/configure.zcml 2015-07-17 00:55:13 +0000 |
285 | +++ lib/lp/services/webhooks/configure.zcml 2015-08-06 00:47:33 +0000 |
286 | @@ -59,5 +59,38 @@ |
287 | |
288 | <webservice:register module="lp.services.webhooks.webservice" /> |
289 | |
290 | + <browser:page |
291 | + for="lp.services.webhooks.interfaces.IWebhookTarget" |
292 | + name="+webhooks" |
293 | + permission="launchpad.Edit" |
294 | + class="lp.services.webhooks.browser.WebhooksView" |
295 | + template="templates/webhooktarget-webhooks.pt" /> |
296 | + <browser:page |
297 | + for="lp.services.webhooks.interfaces.IWebhookTarget" |
298 | + name="+new-webhook" |
299 | + permission="launchpad.Edit" |
300 | + class="lp.services.webhooks.browser.WebhookAddView" |
301 | + template="../../app/templates/generic-edit.pt" /> |
302 | + |
303 | + <adapter |
304 | + provides="lp.services.webapp.interfaces.IBreadcrumb" |
305 | + for="lp.services.webhooks.interfaces.IWebhook" |
306 | + factory="lp.services.webhooks.browser.WebhookBreadcrumb" |
307 | + permission="zope.Public"/> |
308 | + <browser:page |
309 | + for="lp.services.webhooks.interfaces.IWebhook" |
310 | + name="+index" |
311 | + permission="launchpad.View" |
312 | + class="lp.services.webhooks.browser.WebhookView" |
313 | + template="templates/webhook-index.pt" /> |
314 | + <browser:defaultView |
315 | + for="lp.services.webhooks.interfaces.IWebhook" |
316 | + name="+index" /> |
317 | + <browser:page |
318 | + for="lp.services.webhooks.interfaces.IWebhook" |
319 | + name="+delete" |
320 | + permission="launchpad.View" |
321 | + class="lp.services.webhooks.browser.WebhookDeleteView" |
322 | + template="templates/webhook-delete.pt" /> |
323 | |
324 | </configure> |
325 | |
326 | === modified file 'lib/lp/services/webhooks/interfaces.py' |
327 | --- lib/lp/services/webhooks/interfaces.py 2015-08-04 13:37:56 +0000 |
328 | +++ lib/lp/services/webhooks/interfaces.py 2015-08-06 00:47:33 +0000 |
329 | @@ -54,6 +54,7 @@ |
330 | |
331 | from lp import _ |
332 | from lp.registry.interfaces.person import IPerson |
333 | +from lp.services.fields import URIField |
334 | from lp.services.job.interfaces.job import ( |
335 | IJob, |
336 | IJobSource, |
337 | @@ -109,10 +110,13 @@ |
338 | date_last_modified = exported(Datetime( |
339 | title=_("Date last modified"), required=True, readonly=True)) |
340 | |
341 | - delivery_url = exported(TextLine( |
342 | - title=_("URL"), required=True, readonly=False)) |
343 | + delivery_url = exported(URIField( |
344 | + title=_("Delivery URL"), allowed_schemes=['http', 'https'], |
345 | + required=True, readonly=False)) |
346 | active = exported(Bool( |
347 | - title=_("Active"), required=True, readonly=False)) |
348 | + title=_("Active"), |
349 | + description=_("Deliver details of subscribed events."), |
350 | + required=True, readonly=False)) |
351 | |
352 | # Do not export this. |
353 | secret = TextLine( |
354 | |
355 | === modified file 'lib/lp/services/webhooks/model.py' |
356 | --- lib/lp/services/webhooks/model.py 2015-08-04 06:02:20 +0000 |
357 | +++ lib/lp/services/webhooks/model.py 2015-08-06 00:47:33 +0000 |
358 | @@ -186,7 +186,8 @@ |
359 | target_filter = Webhook.git_repository == target |
360 | else: |
361 | raise AssertionError("Unsupported target: %r" % (target,)) |
362 | - return IStore(Webhook).find(Webhook, target_filter) |
363 | + return IStore(Webhook).find(Webhook, target_filter).order_by( |
364 | + Webhook.id) |
365 | |
366 | |
367 | class WebhookTargetMixin: |
368 | |
369 | === added directory 'lib/lp/services/webhooks/templates' |
370 | === added file 'lib/lp/services/webhooks/templates/webhook-delete.pt' |
371 | --- lib/lp/services/webhooks/templates/webhook-delete.pt 1970-01-01 00:00:00 +0000 |
372 | +++ lib/lp/services/webhooks/templates/webhook-delete.pt 2015-08-06 00:47:33 +0000 |
373 | @@ -0,0 +1,29 @@ |
374 | +<html |
375 | + xmlns="http://www.w3.org/1999/xhtml" |
376 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
377 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
378 | + xmlns:i18n="http://xml.zope.org/namespaces/i18n" |
379 | + metal:use-macro="view/macro:page/main_only" |
380 | + i18n:domain="launchpad"> |
381 | +<body> |
382 | + |
383 | + <div metal:fill-slot="main"> |
384 | + <div metal:use-macro="context/@@launchpad_form/form"> |
385 | + <div metal:fill-slot="extra_info"> |
386 | + <p> |
387 | + Deleting this webhook will prevent future events from being |
388 | + sent to |
389 | + <tt tal:content="context/delivery_url">http://example.com/ep</tt>, |
390 | + and any pending deliveries or logs of past deliveries will be |
391 | + permanently lost. |
392 | + </p> |
393 | + <p> |
394 | + If you just want to temporarily suspend deliveries, deactivate |
395 | + the webhook instead. |
396 | + </p> |
397 | + </div> |
398 | + </div> |
399 | + </div> |
400 | + |
401 | +</body> |
402 | +</html> |
403 | |
404 | === added file 'lib/lp/services/webhooks/templates/webhook-index.pt' |
405 | --- lib/lp/services/webhooks/templates/webhook-index.pt 1970-01-01 00:00:00 +0000 |
406 | +++ lib/lp/services/webhooks/templates/webhook-index.pt 2015-08-06 00:47:33 +0000 |
407 | @@ -0,0 +1,23 @@ |
408 | +<html |
409 | + xmlns="http://www.w3.org/1999/xhtml" |
410 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
411 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
412 | + xmlns:i18n="http://xml.zope.org/namespaces/i18n" |
413 | + metal:use-macro="view/macro:page/main_only" |
414 | + i18n:domain="launchpad"> |
415 | +<body> |
416 | + <div metal:fill-slot="main"> |
417 | + <div metal:use-macro="context/@@launchpad_form/form"> |
418 | + <div class="actions" id="launchpad-form-actions" |
419 | + metal:fill-slot="buttons"> |
420 | + <tal:actions repeat="action view/actions"> |
421 | + <input tal:replace="structure action/render" |
422 | + tal:condition="action/available"/> |
423 | + </tal:actions> |
424 | + <a tal:attributes="href context/fmt:url/+delete">Delete webhook</a> |
425 | + </div> |
426 | + </div> |
427 | + </div> |
428 | +</body> |
429 | +</html> |
430 | + |
431 | |
432 | === added file 'lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt' |
433 | --- lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt 1970-01-01 00:00:00 +0000 |
434 | +++ lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt 2015-08-06 00:47:33 +0000 |
435 | @@ -0,0 +1,52 @@ |
436 | +<html |
437 | + xmlns="http://www.w3.org/1999/xhtml" |
438 | + xmlns:tal="http://xml.zope.org/namespaces/tal" |
439 | + xmlns:metal="http://xml.zope.org/namespaces/metal" |
440 | + xmlns:i18n="http://xml.zope.org/namespaces/i18n" |
441 | + metal:use-macro="view/macro:page/main_side" |
442 | + i18n:domain="launchpad"> |
443 | +<body> |
444 | + |
445 | + <div metal:fill-slot="main"> |
446 | + <div class="top-portlet"> |
447 | + <p> |
448 | + Webhooks let you configure Launchpad to notify external services |
449 | + when certain events occur. When an event happens, Launchpad will |
450 | + send a POST request to any matching webhook URLs that you've |
451 | + specified. |
452 | + </p> |
453 | + <div> |
454 | + <div class="beta" style="display: inline"> |
455 | + <img class="beta" alt="[BETA]" src="/@@/beta" /></div> |
456 | + The only currently supported events are Git pushes. We'll be |
457 | + rolling out webhooks for more soon. |
458 | + </div> |
459 | + <ul class="horizontal"> |
460 | + <li> |
461 | + <a class="sprite add" |
462 | + tal:attributes="href context/fmt:url/+new-webhook">Add webhook</a> |
463 | + </li> |
464 | + </ul> |
465 | + </div> |
466 | + <div class="portlet" tal:condition="view/batchnav/currentBatch"> |
467 | + <tal:navigation |
468 | + condition="view/batchnav/has_multiple_pages" |
469 | + replace="structure view/batchnav/@@+navigation-links-upper" /> |
470 | + <table class="listing"> |
471 | + <tbody> |
472 | + <tr tal:repeat="webhook view/batchnav/currentBatch"> |
473 | + <td> |
474 | + <a tal:content="webhook/delivery_url" |
475 | + tal:attributes="href webhook/fmt:url">http://example.com/ep</a> |
476 | + </td> |
477 | + </tr> |
478 | + </tbody> |
479 | + </table> |
480 | + <tal:navigation |
481 | + condition="view/batchnav/has_multiple_pages" |
482 | + replace="structure view/batchnav/@@+navigation-links-lower" /> |
483 | + </div> |
484 | + </div> |
485 | + |
486 | +</body> |
487 | +</html> |
488 | |
489 | === added file 'lib/lp/services/webhooks/tests/test_browser.py' |
490 | --- lib/lp/services/webhooks/tests/test_browser.py 1970-01-01 00:00:00 +0000 |
491 | +++ lib/lp/services/webhooks/tests/test_browser.py 2015-08-06 00:47:33 +0000 |
492 | @@ -0,0 +1,282 @@ |
493 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
494 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
495 | + |
496 | +"""Unit tests for Webhook views.""" |
497 | + |
498 | +__metaclass__ = type |
499 | + |
500 | +import re |
501 | + |
502 | +import soupmatchers |
503 | +from testtools.matchers import ( |
504 | + Equals, |
505 | + MatchesAll, |
506 | + MatchesStructure, |
507 | + Not, |
508 | + ) |
509 | +import transaction |
510 | + |
511 | +from lp.services.features.testing import FeatureFixture |
512 | +from lp.services.webapp.publisher import canonical_url |
513 | +from lp.testing import ( |
514 | + login_person, |
515 | + record_two_runs, |
516 | + TestCaseWithFactory, |
517 | + ) |
518 | +from lp.testing.layers import DatabaseFunctionalLayer |
519 | +from lp.testing.matchers import HasQueryCount |
520 | +from lp.testing.views import create_view |
521 | + |
522 | +breadcrumbs_tag = soupmatchers.Tag( |
523 | + 'breadcrumbs', 'ol', attrs={'class': 'breadcrumbs'}) |
524 | +webhooks_page_crumb_tag = soupmatchers.Tag( |
525 | + 'webhooks page breadcrumb', 'li', text=re.compile('Webhooks')) |
526 | +webhooks_collection_crumb_tag = soupmatchers.Tag( |
527 | + 'webhooks page breadcrumb', 'a', text=re.compile('Webhooks'), |
528 | + attrs={'href': re.compile(r'/\+webhooks$')}) |
529 | +add_webhook_tag = soupmatchers.Tag( |
530 | + 'add webhook', 'a', text='Add webhook', |
531 | + attrs={'href': re.compile(r'/\+new-webhook$')}) |
532 | +webhook_listing_constants = soupmatchers.HTMLContains( |
533 | + soupmatchers.Within(breadcrumbs_tag, webhooks_page_crumb_tag), |
534 | + add_webhook_tag) |
535 | + |
536 | +webhook_listing_tag = soupmatchers.Tag( |
537 | + 'webhook listing', 'table', attrs={'class': 'listing'}) |
538 | +batch_nav_tag = soupmatchers.Tag( |
539 | + 'batch nav links', 'td', attrs={'class': 'batch-navigation-links'}) |
540 | + |
541 | + |
542 | +class WebhookTargetViewTestHelpers: |
543 | + |
544 | + def setUp(self): |
545 | + super(WebhookTargetViewTestHelpers, self).setUp() |
546 | + self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'})) |
547 | + self.target = self.factory.makeGitRepository() |
548 | + self.owner = self.target.owner |
549 | + login_person(self.owner) |
550 | + |
551 | + def makeView(self, name, **kwargs): |
552 | + view = create_view(self.target, name, principal=self.owner, **kwargs) |
553 | + # To test the breadcrumbs we need a correct traversal stack. |
554 | + view.request.traversed_objects = [ |
555 | + self.target.target, self.target, view] |
556 | + view.initialize() |
557 | + return view |
558 | + |
559 | + |
560 | +class TestWebhooksView(WebhookTargetViewTestHelpers, TestCaseWithFactory): |
561 | + |
562 | + layer = DatabaseFunctionalLayer |
563 | + |
564 | + def makeHooksAndMatchers(self, count): |
565 | + hooks = [ |
566 | + self.factory.makeWebhook( |
567 | + target=self.target, delivery_url=u'http://example.com/%d' % i) |
568 | + for i in range(count)] |
569 | + # There is a link to each webhook. |
570 | + link_matchers = [ |
571 | + soupmatchers.Tag( |
572 | + "webhook link", "a", text=hook.delivery_url, |
573 | + attrs={ |
574 | + "href": canonical_url(hook, path_only_if_possible=True)}) |
575 | + for hook in hooks] |
576 | + return link_matchers |
577 | + |
578 | + def test_empty(self): |
579 | + # The table isn't shown if there are no webhooks yet. |
580 | + self.assertThat( |
581 | + self.makeView("+webhooks")(), |
582 | + MatchesAll( |
583 | + webhook_listing_constants, |
584 | + Not(soupmatchers.HTMLContains(webhook_listing_tag)))) |
585 | + |
586 | + def test_few_hooks(self): |
587 | + # The table is just a simple table if there is only one batch. |
588 | + link_matchers = self.makeHooksAndMatchers(3) |
589 | + self.assertThat( |
590 | + self.makeView("+webhooks")(), |
591 | + MatchesAll( |
592 | + webhook_listing_constants, |
593 | + soupmatchers.HTMLContains(webhook_listing_tag, *link_matchers), |
594 | + Not(soupmatchers.HTMLContains(batch_nav_tag)))) |
595 | + |
596 | + def test_many_hooks(self): |
597 | + # Batch navigation controls are shown once there are enough. |
598 | + link_matchers = self.makeHooksAndMatchers(10) |
599 | + self.assertThat( |
600 | + self.makeView("+webhooks")(), |
601 | + MatchesAll( |
602 | + webhook_listing_constants, |
603 | + soupmatchers.HTMLContains( |
604 | + webhook_listing_tag, batch_nav_tag, *link_matchers[:5]), |
605 | + Not(soupmatchers.HTMLContains(*link_matchers[5:])))) |
606 | + |
607 | + def test_query_count(self): |
608 | + # The query count is constant with number of webhooks. |
609 | + def create_webhook(): |
610 | + self.factory.makeWebhook(target=self.target) |
611 | + |
612 | + # Run once to get things stable, then check that adding more |
613 | + # webhooks doesn't inflate the count. |
614 | + self.makeView("+webhooks")() |
615 | + recorder1, recorder2 = record_two_runs( |
616 | + lambda: self.makeView("+webhooks")(), create_webhook, 10) |
617 | + self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count))) |
618 | + |
619 | + |
620 | +class TestWebhookAddView(WebhookTargetViewTestHelpers, TestCaseWithFactory): |
621 | + |
622 | + layer = DatabaseFunctionalLayer |
623 | + |
624 | + def test_rendering(self): |
625 | + self.assertThat( |
626 | + self.makeView("+new-webhook")(), |
627 | + soupmatchers.HTMLContains( |
628 | + soupmatchers.Within( |
629 | + breadcrumbs_tag, webhooks_collection_crumb_tag), |
630 | + soupmatchers.Within( |
631 | + breadcrumbs_tag, |
632 | + soupmatchers.Tag( |
633 | + 'add webhook breadcrumb', 'li', |
634 | + text=re.compile('Add webhook'))), |
635 | + soupmatchers.Tag( |
636 | + 'cancel link', 'a', text='Cancel', |
637 | + attrs={'href': re.compile(r'/\+webhooks$')}))) |
638 | + |
639 | + def test_creates(self): |
640 | + view = self.makeView( |
641 | + "+new-webhook", method="POST", |
642 | + form={ |
643 | + "field.delivery_url": "http://example.com/test", |
644 | + "field.active": "on", "field.event_types.count": "0", |
645 | + "field.actions.new": "Add webhook"}) |
646 | + self.assertEqual([], view.errors) |
647 | + hook = self.target.webhooks.one() |
648 | + self.assertThat( |
649 | + hook, |
650 | + MatchesStructure.byEquality( |
651 | + target=self.target, |
652 | + registrant=self.owner, |
653 | + delivery_url="http://example.com/test", |
654 | + active=True, |
655 | + event_types=[])) |
656 | + |
657 | + def test_rejects_bad_scheme(self): |
658 | + transaction.commit() |
659 | + view = self.makeView( |
660 | + "+new-webhook", method="POST", |
661 | + form={ |
662 | + "field.delivery_url": "ftp://example.com/test", |
663 | + "field.active": "on", "field.event_types.count": "0", |
664 | + "field.actions.new": "Add webhook"}) |
665 | + self.assertEqual( |
666 | + ['delivery_url'], [error.field_name for error in view.errors]) |
667 | + self.assertIs(None, self.target.webhooks.one()) |
668 | + |
669 | + |
670 | +class WebhookViewTestHelpers: |
671 | + |
672 | + def setUp(self): |
673 | + super(WebhookViewTestHelpers, self).setUp() |
674 | + self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'})) |
675 | + self.target = self.factory.makeGitRepository() |
676 | + self.owner = self.target.owner |
677 | + self.webhook = self.factory.makeWebhook( |
678 | + target=self.target, delivery_url=u'http://example.com/original') |
679 | + login_person(self.owner) |
680 | + |
681 | + def makeView(self, name, **kwargs): |
682 | + view = create_view(self.webhook, name, principal=self.owner, **kwargs) |
683 | + # To test the breadcrumbs we need a correct traversal stack. |
684 | + view.request.traversed_objects = [ |
685 | + self.target.target, self.target, self.webhook, view] |
686 | + view.initialize() |
687 | + return view |
688 | + |
689 | + |
690 | +class TestWebhookView(WebhookViewTestHelpers, TestCaseWithFactory): |
691 | + |
692 | + layer = DatabaseFunctionalLayer |
693 | + |
694 | + def test_rendering(self): |
695 | + self.assertThat( |
696 | + self.makeView("+index")(), |
697 | + soupmatchers.HTMLContains( |
698 | + soupmatchers.Within( |
699 | + breadcrumbs_tag, webhooks_collection_crumb_tag), |
700 | + soupmatchers.Within( |
701 | + breadcrumbs_tag, |
702 | + soupmatchers.Tag( |
703 | + 'webhook breadcrumb', 'li', |
704 | + text=re.compile(re.escape( |
705 | + self.webhook.delivery_url)))), |
706 | + soupmatchers.Tag( |
707 | + 'delete link', 'a', text='Delete webhook', |
708 | + attrs={'href': re.compile(r'/\+delete$')}))) |
709 | + |
710 | + def test_saves(self): |
711 | + view = self.makeView( |
712 | + "+index", method="POST", |
713 | + form={ |
714 | + "field.delivery_url": "http://example.com/edited", |
715 | + "field.active": "off", "field.event_types.count": "0", |
716 | + "field.actions.save": "Save webhook"}) |
717 | + self.assertEqual([], view.errors) |
718 | + self.assertThat( |
719 | + self.webhook, |
720 | + MatchesStructure.byEquality( |
721 | + delivery_url="http://example.com/edited", |
722 | + active=False, |
723 | + event_types=[])) |
724 | + |
725 | + def test_rejects_bad_scheme(self): |
726 | + transaction.commit() |
727 | + view = self.makeView( |
728 | + "+index", method="POST", |
729 | + form={ |
730 | + "field.delivery_url": "ftp://example.com/edited", |
731 | + "field.active": "off", "field.event_types.count": "0", |
732 | + "field.actions.save": "Save webhook"}) |
733 | + self.assertEqual( |
734 | + ['delivery_url'], [error.field_name for error in view.errors]) |
735 | + self.assertThat( |
736 | + self.webhook, |
737 | + MatchesStructure.byEquality( |
738 | + delivery_url="http://example.com/original", |
739 | + active=True, |
740 | + event_types=[])) |
741 | + |
742 | + |
743 | +class TestWebhookDeleteView(WebhookViewTestHelpers, TestCaseWithFactory): |
744 | + |
745 | + layer = DatabaseFunctionalLayer |
746 | + |
747 | + def test_rendering(self): |
748 | + self.assertThat( |
749 | + self.makeView("+delete")(), |
750 | + soupmatchers.HTMLContains( |
751 | + soupmatchers.Within( |
752 | + breadcrumbs_tag, webhooks_collection_crumb_tag), |
753 | + soupmatchers.Within( |
754 | + breadcrumbs_tag, |
755 | + soupmatchers.Tag( |
756 | + 'webhook breadcrumb', 'a', |
757 | + text=re.compile(re.escape( |
758 | + self.webhook.delivery_url)), |
759 | + attrs={'href': canonical_url(self.webhook)})), |
760 | + soupmatchers.Within( |
761 | + breadcrumbs_tag, |
762 | + soupmatchers.Tag( |
763 | + 'delete breadcrumb', 'li', |
764 | + text=re.compile('Delete webhook'))), |
765 | + soupmatchers.Tag( |
766 | + 'cancel link', 'a', text='Cancel', |
767 | + attrs={'href': canonical_url(self.webhook)}))) |
768 | + |
769 | + def test_deletes(self): |
770 | + view = self.makeView( |
771 | + "+delete", method="POST", |
772 | + form={"field.actions.delete": "Delete webhook"}) |
773 | + self.assertEqual([], view.errors) |
774 | + self.assertIs(None, self.target.webhooks.one()) |