launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #19163
[Merge] lp:~wgrant/launchpad/webhook-browser into lp:launchpad
William Grant has proposed merging lp:~wgrant/launchpad/webhook-browser into lp:launchpad.
Commit message:
Basic web UI for managing webhooks.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #342729 in Launchpad itself: "Should support post-commit webhooks"
https://bugs.launchpad.net/launchpad/+bug/342729
For more details, see:
https://code.launchpad.net/~wgrant/launchpad/webhook-browser/+merge/266870
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.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~wgrant/launchpad/webhook-browser into lp:launchpad.
=== modified file 'lib/lp/app/browser/launchpad.py'
--- lib/lp/app/browser/launchpad.py 2015-07-23 16:41:12 +0000
+++ lib/lp/app/browser/launchpad.py 2015-08-04 13:25:23 +0000
@@ -322,9 +322,7 @@
remaining_crumb.rootsite_override = facet.rootsite
break
if len(breadcrumbs) > 0:
- page_crumb = self.makeBreadcrumbForRequestedPage()
- if page_crumb:
- breadcrumbs.append(page_crumb)
+ breadcrumbs.extend(self.makeBreadcrumbsForRequestedPage())
return breadcrumbs
@property
@@ -356,14 +354,14 @@
else:
return None
- def makeBreadcrumbForRequestedPage(self):
- """Return an `IBreadcrumb` for the requested page.
+ def makeBreadcrumbsForRequestedPage(self):
+ """Return a sequence of `IBreadcrumb`s for the requested page.
The `IBreadcrumb` for the requested page is created using the current
URL and the page's name (i.e. the last path segment of the URL).
If the view is the default one for the object or the current
- facet, return None -- we'll have injected a facet Breadcrumb
+ facet, return none -- we'll have injected a facet Breadcrumb
earlier in the hierarchy which links here.
"""
url = self.request.getURL()
@@ -374,16 +372,22 @@
facet = queryUtility(IFacet, name=get_facet(view))
if facet is not None:
default_views.append(facet.default_view)
+ crumbs = []
+
+ # Views may provide an additional breadcrumb to precede them.
+ # This is useful to have an add view link back to its
+ # collection despite its parent being the context of the collection.
+ if hasattr(view, 'inside_breadcrumb'):
+ crumbs.append(view.inside_breadcrumb)
+
if hasattr(view, '__name__') and view.__name__ not in default_views:
title = getattr(view, 'page_title', None)
if title is None:
title = getattr(view, 'label', None)
if isinstance(title, Message):
title = i18n.translate(title, context=self.request)
- breadcrumb = Breadcrumb(None, url=url, text=title)
- return breadcrumb
- else:
- return None
+ crumbs.append(Breadcrumb(None, url=url, text=title))
+ return crumbs
@property
def display_breadcrumbs(self):
=== modified file 'lib/lp/app/doc/hierarchical-menu.txt'
--- lib/lp/app/doc/hierarchical-menu.txt 2015-07-08 16:05:11 +0000
+++ lib/lp/app/doc/hierarchical-menu.txt 2015-08-04 13:25:23 +0000
@@ -91,12 +91,12 @@
>>> from lp.app.browser.launchpad import Hierarchy
>>> from lp.services.webapp.breadcrumb import Breadcrumb
- # Monkey patch Hierarchy.makeBreadcrumbForRequestedPage so that we don't
+ # Monkey patch Hierarchy.makeBreadcrumbsForRequestedPage so that we don't
# have to create fake views and other stuff to test breadcrumbs here. The
# functionality provided by that method is tested in
# webapp/tests/test_breadcrumbs.py.
- >>> make_breadcrumb_func = Hierarchy.makeBreadcrumbForRequestedPage
- >>> Hierarchy.makeBreadcrumbForRequestedPage = lambda self: None
+ >>> make_breadcrumb_func = Hierarchy.makeBreadcrumbsForRequestedPage
+ >>> Hierarchy.makeBreadcrumbsForRequestedPage = lambda self: []
# Note that the Hierarchy assigns the breadcrumb's URL, but we need to
# give it a valid .text attribute.
@@ -294,4 +294,4 @@
Put the monkey patched method back.
- >>> Hierarchy.makeBreadcrumbForRequestedPage = make_breadcrumb_func
+ >>> Hierarchy.makeBreadcrumbsForRequestedPage = make_breadcrumb_func
=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py 2015-07-12 23:48:01 +0000
+++ lib/lp/code/browser/gitrepository.py 2015-08-04 13:25:23 +0000
@@ -68,6 +68,7 @@
from lp.registry.vocabularies import UserTeamsParticipationPlusSelfVocabulary
from lp.services.config import config
from lp.services.database.constants import UTC_NOW
+from lp.services.features import getFeatureFlag
from lp.services.propertycache import cachedproperty
from lp.services.webapp import (
canonical_url,
@@ -147,7 +148,7 @@
usedfor = IGitRepository
facet = "branches"
title = "Edit Git repository"
- links = ["edit", "reviewer", "delete"]
+ links = ["edit", "reviewer", "webhooks", "delete"]
@enabled_with_permission("launchpad.Edit")
def edit(self):
@@ -160,6 +161,13 @@
return Link("+reviewer", text, icon="edit")
@enabled_with_permission("launchpad.Edit")
+ def webhooks(self):
+ text = "Manage webhooks"
+ return Link(
+ "+webhooks", text, icon="edit",
+ enabled=bool(getFeatureFlag('webhooks.new.enabled')))
+
+ @enabled_with_permission("launchpad.Edit")
def delete(self):
text = "Delete repository"
return Link("+delete", text, icon="trash-icon")
=== modified file 'lib/lp/services/webapp/tests/test_breadcrumbs.py'
--- lib/lp/services/webapp/tests/test_breadcrumbs.py 2015-07-08 16:05:11 +0000
+++ lib/lp/services/webapp/tests/test_breadcrumbs.py 2015-08-04 13:25:23 +0000
@@ -123,7 +123,7 @@
request = LaunchpadTestRequest()
request.traversed_objects = [self.product, test_view]
hierarchy_view = Hierarchy(test_view, request)
- breadcrumb = hierarchy_view.makeBreadcrumbForRequestedPage()
+ [breadcrumb] = hierarchy_view.makeBreadcrumbsForRequestedPage()
self.assertEquals(breadcrumb.text, 'breadcrumb test')
=== modified file 'lib/lp/services/webhooks/browser.py'
--- lib/lp/services/webhooks/browser.py 2015-07-03 07:29:10 +0000
+++ lib/lp/services/webhooks/browser.py 2015-08-04 13:25:23 +0000
@@ -10,12 +10,24 @@
'WebhookTargetNavigationMixin',
]
+from lazr.restful.interface import use_template
from zope.component import getUtility
+from zope.interface import Interface
+from lp.app.browser.launchpadform import (
+ action,
+ LaunchpadEditFormView,
+ LaunchpadFormView,
+ )
+from lp.services.propertycache import cachedproperty
from lp.services.webapp import (
+ canonical_url,
+ LaunchpadView,
Navigation,
stepthrough,
)
+from lp.services.webapp.batching import BatchNavigator
+from lp.services.webapp.breadcrumb import Breadcrumb
from lp.services.webhooks.interfaces import (
IWebhook,
IWebhookSource,
@@ -47,3 +59,116 @@
if webhook is None or webhook.target != self.context:
return None
return webhook
+
+
+class WebhooksView(LaunchpadView):
+
+ @property
+ def page_title(self):
+ return "Webhooks"
+
+ @property
+ def label(self):
+ return "Webhooks for %s" % self.context.display_name
+
+ @cachedproperty
+ def batchnav(self):
+ return BatchNavigator(
+ getUtility(IWebhookSource).findByTarget(self.context),
+ self.request)
+
+
+class WebhooksBreadcrumb(Breadcrumb):
+
+ text = "Webhooks"
+
+ @property
+ def url(self):
+ return canonical_url(self.context, view_name="+webhooks")
+
+ @property
+ def inside(self):
+ return self.context
+
+
+class WebhookBreadcrumb(Breadcrumb):
+
+ @property
+ def text(self):
+ return self.context.delivery_url
+
+ @property
+ def inside(self):
+ return WebhooksBreadcrumb(self.context.target)
+
+
+class WebhookEditSchema(Interface):
+ # XXX wgrant 2015-08-04: Need custom widgets for secret and
+ # event_types.
+ use_template(IWebhook, include=['delivery_url', 'event_types', 'active'])
+
+
+class WebhookAddView(LaunchpadFormView):
+
+ page_title = label = "Add webhook"
+
+ schema = WebhookEditSchema
+
+ @property
+ def inside_breadcrumb(self):
+ return WebhooksBreadcrumb(self.context)
+
+ @property
+ def initial_values(self):
+ return {'active': True}
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context, view_name="+webhooks")
+
+ @action("Add webhook", name="new")
+ def new_action(self, action, data):
+ webhook = self.context.newWebhook(
+ registrant=self.user, delivery_url=data['delivery_url'],
+ event_types=data['event_types'], active=data['active'])
+ self.next_url = canonical_url(webhook)
+
+
+class WebhookView(LaunchpadEditFormView):
+
+ schema = WebhookEditSchema
+
+ label = "Manage webhook"
+
+ @property
+ def next_url(self):
+ # The edit form is the default view, so the URL doesn't need the
+ # normal view name suffix.
+ return canonical_url(self.context)
+
+ @property
+ def adapters(self):
+ return {self.schema: self.context}
+
+ @action("Save webhook", name="save")
+ def save_action(self, action, data):
+ self.updateContextFromData(data)
+
+
+class WebhookDeleteView(LaunchpadFormView):
+
+ schema = Interface
+
+ page_title = label = "Delete webhook"
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context)
+
+ @action("Delete webhook", name="delete")
+ def delete_action(self, action, data):
+ target = self.context.target
+ self.context.destroySelf()
+ self.request.response.addNotification(
+ "Webhook for %s deleted." % self.context.delivery_url)
+ self.next_url = canonical_url(target, view_name="+webhooks")
=== modified file 'lib/lp/services/webhooks/configure.zcml'
--- lib/lp/services/webhooks/configure.zcml 2015-07-17 00:55:13 +0000
+++ lib/lp/services/webhooks/configure.zcml 2015-08-04 13:25:23 +0000
@@ -59,5 +59,38 @@
<webservice:register module="lp.services.webhooks.webservice" />
+ <browser:page
+ for="lp.services.webhooks.interfaces.IWebhookTarget"
+ name="+webhooks"
+ permission="launchpad.Edit"
+ class="lp.services.webhooks.browser.WebhooksView"
+ template="templates/webhooktarget-webhooks.pt" />
+ <browser:page
+ for="lp.services.webhooks.interfaces.IWebhookTarget"
+ name="+new-webhook"
+ permission="launchpad.Edit"
+ class="lp.services.webhooks.browser.WebhookAddView"
+ template="../../app/templates/generic-edit.pt" />
+
+ <adapter
+ provides="lp.services.webapp.interfaces.IBreadcrumb"
+ for="lp.services.webhooks.interfaces.IWebhook"
+ factory="lp.services.webhooks.browser.WebhookBreadcrumb"
+ permission="zope.Public"/>
+ <browser:page
+ for="lp.services.webhooks.interfaces.IWebhook"
+ name="+index"
+ permission="launchpad.View"
+ class="lp.services.webhooks.browser.WebhookView"
+ template="templates/webhook-index.pt" />
+ <browser:defaultView
+ for="lp.services.webhooks.interfaces.IWebhook"
+ name="+index" />
+ <browser:page
+ for="lp.services.webhooks.interfaces.IWebhook"
+ name="+delete"
+ permission="launchpad.View"
+ class="lp.services.webhooks.browser.WebhookDeleteView"
+ template="templates/webhook-delete.pt" />
</configure>
=== modified file 'lib/lp/services/webhooks/interfaces.py'
--- lib/lp/services/webhooks/interfaces.py 2015-08-03 08:27:04 +0000
+++ lib/lp/services/webhooks/interfaces.py 2015-08-04 13:25:23 +0000
@@ -52,6 +52,7 @@
from lp import _
from lp.registry.interfaces.person import IPerson
+from lp.services.fields import URIField
from lp.services.job.interfaces.job import (
IJob,
IJobSource,
@@ -107,10 +108,13 @@
date_last_modified = exported(Datetime(
title=_("Date last modified"), required=True, readonly=True))
- delivery_url = exported(TextLine(
- title=_("URL"), required=True, readonly=False))
+ delivery_url = exported(URIField(
+ title=_("Delivery URL"), allowed_schemes=['http', 'https'],
+ required=True, readonly=False))
active = exported(Bool(
- title=_("Active"), required=True, readonly=False))
+ title=_("Active"),
+ description=_("Deliver details of subscribed events."),
+ required=True, readonly=False))
secret = TextLine(
title=_("Unique name"), required=False, readonly=True)
=== modified file 'lib/lp/services/webhooks/model.py'
--- lib/lp/services/webhooks/model.py 2015-08-03 10:49:46 +0000
+++ lib/lp/services/webhooks/model.py 2015-08-04 13:25:23 +0000
@@ -181,7 +181,8 @@
target_filter = Webhook.git_repository == target
else:
raise AssertionError("Unsupported target: %r" % (target,))
- return IStore(Webhook).find(Webhook, target_filter)
+ return IStore(Webhook).find(Webhook, target_filter).order_by(
+ Webhook.id)
class WebhookTargetMixin:
=== added directory 'lib/lp/services/webhooks/templates'
=== added file 'lib/lp/services/webhooks/templates/webhook-delete.pt'
--- lib/lp/services/webhooks/templates/webhook-delete.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/templates/webhook-delete.pt 2015-08-04 13:25:23 +0000
@@ -0,0 +1,28 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad">
+<body>
+
+ <div metal:fill-slot="main">
+ <div metal:use-macro="context/@@launchpad_form/form">
+ <div metal:fill-slot="extra_info">
+ <p>
+ Deleting this webhook will prevent future events from being
+ sent to
+ <tt tal:content="context/delivery_url">http://example.com/ep</tt>,
+ and any pending deliveries will be permanently lost.
+ </p>
+ <p>
+ If you just want to temporarily suspend deliveries, deactivate
+ the webhook instead.
+ </p>
+ </div>
+ </div>
+ </div>
+
+</body>
+</html>
=== added file 'lib/lp/services/webhooks/templates/webhook-index.pt'
--- lib/lp/services/webhooks/templates/webhook-index.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/templates/webhook-index.pt 2015-08-04 13:25:23 +0000
@@ -0,0 +1,23 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad">
+<body>
+ <div metal:fill-slot="main">
+ <div metal:use-macro="context/@@launchpad_form/form">
+ <div class="actions" id="launchpad-form-actions"
+ metal:fill-slot="buttons">
+ <tal:actions repeat="action view/actions">
+ <input tal:replace="structure action/render"
+ tal:condition="action/available"/>
+ </tal:actions>
+ <a tal:attributes="href context/fmt:url/+delete">Delete webhook</a>
+ </div>
+ </div>
+ </div>
+</body>
+</html>
+
=== added file 'lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt'
--- lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/templates/webhooktarget-webhooks.pt 2015-08-04 13:25:23 +0000
@@ -0,0 +1,52 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_side"
+ i18n:domain="launchpad">
+<body>
+
+ <div metal:fill-slot="main">
+ <div class="top-portlet">
+ <p>
+ Webhooks let you configure Launchpad to notify external services
+ when certain events occur. When an event happens, Launchpad will
+ send a POST request to any matching webhook URLs that you've
+ specified.
+ </p>
+ <div>
+ <div class="beta" style="display: inline">
+ <img class="beta" alt="[BETA]" src="/@@/beta" /></div>
+ The only currently supported events are Git pushes. We'll be
+ rolling webhooks out to other types of events soon.
+ </div>
+ <ul class="horizontal">
+ <li>
+ <a class="sprite add"
+ tal:attributes="href context/fmt:url/+new-webhook">Add webhook</a>
+ </li>
+ </ul>
+ </div>
+ <div class="portlet" tal:condition="view/batchnav/currentBatch">
+ <tal:navigation
+ condition="view/batchnav/has_multiple_pages"
+ replace="structure view/batchnav/@@+navigation-links-upper" />
+ <table class="listing">
+ <tbody>
+ <tr tal:repeat="webhook view/batchnav/currentBatch">
+ <td>
+ <a tal:content="webhook/delivery_url"
+ tal:attributes="href webhook/fmt:url">http://example.com/ep</a>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ <tal:navigation
+ condition="view/batchnav/has_multiple_pages"
+ replace="structure view/batchnav/@@+navigation-links-lower" />
+ </div>
+ </div>
+
+</body>
+</html>
=== added file 'lib/lp/services/webhooks/tests/test_browser.py'
--- lib/lp/services/webhooks/tests/test_browser.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/webhooks/tests/test_browser.py 2015-08-04 13:25:23 +0000
@@ -0,0 +1,282 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Unit tests for Webhook views."""
+
+__metaclass__ = type
+
+import re
+
+import soupmatchers
+from testtools.matchers import (
+ Equals,
+ MatchesAll,
+ MatchesStructure,
+ Not,
+ )
+import transaction
+
+from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.publisher import canonical_url
+from lp.testing import (
+ login_person,
+ record_two_runs,
+ TestCaseWithFactory,
+ )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.matchers import HasQueryCount
+from lp.testing.views import create_view
+
+breadcrumbs_tag = soupmatchers.Tag(
+ 'breadcrumbs', 'ol', attrs={'class': 'breadcrumbs'})
+webhooks_page_crumb_tag = soupmatchers.Tag(
+ 'webhooks page breadcrumb', 'li', text=re.compile('Webhooks'))
+webhooks_collection_crumb_tag = soupmatchers.Tag(
+ 'webhooks page breadcrumb', 'a', text=re.compile('Webhooks'),
+ attrs={'href': re.compile(r'/\+webhooks$')})
+add_webhook_tag = soupmatchers.Tag(
+ 'add webhook', 'a', text='Add webhook',
+ attrs={'href': re.compile(r'/\+new-webhook$')})
+webhook_listing_constants = soupmatchers.HTMLContains(
+ soupmatchers.Within(breadcrumbs_tag, webhooks_page_crumb_tag),
+ add_webhook_tag)
+
+webhook_listing_tag = soupmatchers.Tag(
+ 'webhook listing', 'table', attrs={'class': 'listing'})
+batch_nav_tag = soupmatchers.Tag(
+ 'batch nav links', 'td', attrs={'class': 'batch-navigation-links'})
+
+
+class WebhookTargetViewTestHelpers:
+
+ def setUp(self):
+ super(WebhookTargetViewTestHelpers, self).setUp()
+ self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'}))
+ self.target = self.factory.makeGitRepository()
+ self.owner = self.target.owner
+ login_person(self.owner)
+
+ def makeView(self, name, **kwargs):
+ view = create_view(self.target, name, principal=self.owner, **kwargs)
+ # To test the breadcrumbs we need a correct traversal stack.
+ view.request.traversed_objects = [
+ self.target.target, self.target, view]
+ view.initialize()
+ return view
+
+
+class TestWebhooksView(WebhookTargetViewTestHelpers, TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def makeHooksAndMatchers(self, count):
+ hooks = [
+ self.factory.makeWebhook(
+ target=self.target, delivery_url=u'http://example.com/%d' % i)
+ for i in range(count)]
+ # There is a link to each webhook.
+ link_matchers = [
+ soupmatchers.Tag(
+ "webhook link", "a", text=hook.delivery_url,
+ attrs={
+ "href": canonical_url(hook, path_only_if_possible=True)})
+ for hook in hooks]
+ return link_matchers
+
+ def test_empty(self):
+ # The table isn't shown if there are no webhooks yet.
+ self.assertThat(
+ self.makeView("+webhooks")(),
+ MatchesAll(
+ webhook_listing_constants,
+ Not(soupmatchers.HTMLContains(webhook_listing_tag))))
+
+ def test_few_hooks(self):
+ # The table is just a simple table if there is only one batch.
+ link_matchers = self.makeHooksAndMatchers(3)
+ self.assertThat(
+ self.makeView("+webhooks")(),
+ MatchesAll(
+ webhook_listing_constants,
+ soupmatchers.HTMLContains(webhook_listing_tag, *link_matchers),
+ Not(soupmatchers.HTMLContains(batch_nav_tag))))
+
+ def test_many_hooks(self):
+ # Batch navigation controls are shown once there are enough.
+ link_matchers = self.makeHooksAndMatchers(10)
+ self.assertThat(
+ self.makeView("+webhooks")(),
+ MatchesAll(
+ webhook_listing_constants,
+ soupmatchers.HTMLContains(
+ webhook_listing_tag, batch_nav_tag, *link_matchers[:5]),
+ Not(soupmatchers.HTMLContains(*link_matchers[5:]))))
+
+ def test_query_count(self):
+ # The query count is constant with number of webhooks.
+ def create_webhook():
+ self.factory.makeWebhook(target=self.target)
+
+ # Run once to get things stable, then check that adding more
+ # webhooks doesn't inflate the count.
+ self.makeView("+webhooks")()
+ recorder1, recorder2 = record_two_runs(
+ lambda: self.makeView("+webhooks")(), create_webhook, 10)
+ self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
+
+
+class TestWebhookAddView(WebhookTargetViewTestHelpers, TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def test_rendering(self):
+ self.assertThat(
+ self.makeView("+new-webhook")(),
+ soupmatchers.HTMLContains(
+ soupmatchers.Within(
+ breadcrumbs_tag, webhooks_collection_crumb_tag),
+ soupmatchers.Within(
+ breadcrumbs_tag,
+ soupmatchers.Tag(
+ 'add webhook breadcrumb', 'li',
+ text=re.compile('Add webhook'))),
+ soupmatchers.Tag(
+ 'cancel link', 'a', text='Cancel',
+ attrs={'href': re.compile(r'/\+webhooks$')})))
+
+ def test_creates(self):
+ view = self.makeView(
+ "+new-webhook", method="POST",
+ form={
+ "field.delivery_url": "http://example.com/test",
+ "field.active": "on", "field.event_types.count": "0",
+ "field.actions.new": "Add webhook"})
+ self.assertEqual([], view.errors)
+ hook = self.target.webhooks.one()
+ self.assertThat(
+ hook,
+ MatchesStructure.byEquality(
+ target=self.target,
+ registrant=self.owner,
+ delivery_url="http://example.com/test",
+ active=True,
+ event_types=[]))
+
+ def test_rejects_bad_scheme(self):
+ transaction.commit()
+ view = self.makeView(
+ "+new-webhook", method="POST",
+ form={
+ "field.delivery_url": "ftp://example.com/test",
+ "field.active": "on", "field.event_types.count": "0",
+ "field.actions.new": "Add webhook"})
+ self.assertEqual(
+ ['delivery_url'], [error.field_name for error in view.errors])
+ self.assertIs(None, self.target.webhooks.one())
+
+
+class WebhookViewTestHelpers:
+
+ def setUp(self):
+ super(WebhookViewTestHelpers, self).setUp()
+ self.useFixture(FeatureFixture({'webhooks.new.enabled': 'true'}))
+ self.target = self.factory.makeGitRepository()
+ self.owner = self.target.owner
+ self.webhook = self.factory.makeWebhook(
+ target=self.target, delivery_url=u'http://example.com/original')
+ login_person(self.owner)
+
+ def makeView(self, name, **kwargs):
+ view = create_view(self.webhook, name, principal=self.owner, **kwargs)
+ # To test the breadcrumbs we need a correct traversal stack.
+ view.request.traversed_objects = [
+ self.target.target, self.target, self.webhook, view]
+ view.initialize()
+ return view
+
+
+class TestWebhookView(WebhookViewTestHelpers, TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def test_rendering(self):
+ self.assertThat(
+ self.makeView("+index")(),
+ soupmatchers.HTMLContains(
+ soupmatchers.Within(
+ breadcrumbs_tag, webhooks_collection_crumb_tag),
+ soupmatchers.Within(
+ breadcrumbs_tag,
+ soupmatchers.Tag(
+ 'webhook breadcrumb', 'li',
+ text=re.compile(re.escape(
+ self.webhook.delivery_url)))),
+ soupmatchers.Tag(
+ 'delete link', 'a', text='Delete webhook',
+ attrs={'href': re.compile(r'/\+delete$')})))
+
+ def test_saves(self):
+ view = self.makeView(
+ "+index", method="POST",
+ form={
+ "field.delivery_url": "http://example.com/edited",
+ "field.active": "off", "field.event_types.count": "0",
+ "field.actions.save": "Save webhook"})
+ self.assertEqual([], view.errors)
+ self.assertThat(
+ self.webhook,
+ MatchesStructure.byEquality(
+ delivery_url="http://example.com/edited",
+ active=False,
+ event_types=[]))
+
+ def test_rejects_bad_scheme(self):
+ transaction.commit()
+ view = self.makeView(
+ "+index", method="POST",
+ form={
+ "field.delivery_url": "ftp://example.com/edited",
+ "field.active": "off", "field.event_types.count": "0",
+ "field.actions.save": "Save webhook"})
+ self.assertEqual(
+ ['delivery_url'], [error.field_name for error in view.errors])
+ self.assertThat(
+ self.webhook,
+ MatchesStructure.byEquality(
+ delivery_url="http://example.com/original",
+ active=True,
+ event_types=[]))
+
+
+class TestWebhookDeleteView(WebhookViewTestHelpers, TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def test_rendering(self):
+ self.assertThat(
+ self.makeView("+delete")(),
+ soupmatchers.HTMLContains(
+ soupmatchers.Within(
+ breadcrumbs_tag, webhooks_collection_crumb_tag),
+ soupmatchers.Within(
+ breadcrumbs_tag,
+ soupmatchers.Tag(
+ 'webhook breadcrumb', 'a',
+ text=re.compile(re.escape(
+ self.webhook.delivery_url)),
+ attrs={'href': canonical_url(self.webhook)})),
+ soupmatchers.Within(
+ breadcrumbs_tag,
+ soupmatchers.Tag(
+ 'delete breadcrumb', 'li',
+ text=re.compile('Delete webhook'))),
+ soupmatchers.Tag(
+ 'cancel link', 'a', text='Cancel',
+ attrs={'href': canonical_url(self.webhook)})))
+
+ def test_deletes(self):
+ view = self.makeView(
+ "+delete", method="POST",
+ form={"field.actions.delete": "Delete webhook"})
+ self.assertEqual([], view.errors)
+ self.assertIs(None, self.target.webhooks.one())
Follow ups