Merge lp:~wgrant/launchpad/webhook-browser into lp:launchpad

Proposed by William Grant
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
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:+new-webhook makes use of the new inside_breadcrumb property to show a link to +webhooks in the hierarchy despite not being a child of that page. But every page added here has a "Webhooks" breadcrumb, making navigation and context a bit less sucky.

We need a better way to unit test breadcrumbs, but this works for now.

To post a comment you must log in.
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/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())