diff --git a/hc/api/migrations/0035_auto_20171229_2008.py b/hc/api/migrations/0035_auto_20171229_2008.py new file mode 100644 index 00000000..577841c1 --- /dev/null +++ b/hc/api/migrations/0035_auto_20171229_2008.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-12-29 20:08 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0034_auto_20171227_1530'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='kind', + field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk')], max_length=20), + ), + ] diff --git a/hc/api/models.py b/hc/api/models.py index 51c70e54..73bd564a 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -39,7 +39,8 @@ CHANNEL_KINDS = (("email", "Email"), ("victorops", "VictorOps"), ("discord", "Discord"), ("telegram", "Telegram"), - ("sms", "SMS")) + ("sms", "SMS"), + ("zendesk", "Zendesk")) PO_PRIORITIES = { -2: "lowest", @@ -277,6 +278,8 @@ class Channel(models.Model): return transports.Telegram(self) elif self.kind == "sms": return transports.Sms(self) + elif self.kind == "zendesk": + return transports.Zendesk(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) @@ -316,7 +319,6 @@ class Channel(models.Model): doc = json.loads(self.value) return doc.get("url_down") - @property def url_up(self): assert self.kind == "webhook" @@ -450,6 +452,18 @@ class Channel(models.Model): doc = json.loads(self.value) return doc["account"] + @property + def zendesk_token(self): + assert self.kind == "zendesk" + doc = json.loads(self.value) + return doc["access_token"] + + @property + def zendesk_subdomain(self): + assert self.kind == "zendesk" + doc = json.loads(self.value) + return doc["subdomain"] + def latest_notification(self): return Notification.objects.filter(channel=self).latest() diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index 286e03a9..dac66746 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -489,3 +489,67 @@ class NotifyTestCase(BaseTestCase): self.channel.notify(self.check) self.assertTrue(mock_post.called) + + @patch("hc.api.transports.requests.request") + def test_zendesk_down(self, mock_post): + v = json.dumps({"access_token": "fake-token", "subdomain": "foo"}) + self._setup_data("zendesk", v) + mock_post.return_value.status_code = 200 + + self.channel.notify(self.check) + assert Notification.objects.count() == 1 + + args, kwargs = mock_post.call_args + method, url = args + self.assertEqual(method, "post") + self.assertTrue("foo.zendesk.com" in url) + + payload = kwargs["json"] + self.assertEqual(payload["request"]["type"], "incident") + self.assertTrue("down" in payload["request"]["subject"]) + + headers = kwargs["headers"] + self.assertEqual(headers["Authorization"], "Bearer fake-token") + + @patch("hc.api.transports.requests.request") + @patch("hc.api.transports.requests.get") + def test_zendesk_up(self, mock_get, mock_post): + v = json.dumps({"access_token": "fake-token", "subdomain": "foo"}) + self._setup_data("zendesk", v, status="up") + + mock_post.return_value.status_code = 200 + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "requests": [{ + "url": "https://foo.example.org/comment", + "description": "code is %s" % self.check.code + }] + } + + self.channel.notify(self.check) + assert Notification.objects.count() == 1 + + args, kwargs = mock_post.call_args + self.assertTrue("foo.example.org" in args[1]) + + payload = kwargs["json"] + self.assertEqual(payload["request"]["type"], "incident") + self.assertTrue("UP" in payload["request"]["subject"]) + + headers = kwargs["headers"] + self.assertEqual(headers["Authorization"], "Bearer fake-token") + + @patch("hc.api.transports.requests.request") + @patch("hc.api.transports.requests.get") + def test_zendesk_up_with_no_existing_ticket(self, mock_get, mock_post): + v = json.dumps({"access_token": "fake-token", "subdomain": "foo"}) + self._setup_data("zendesk", v, status="up") + + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"requests": []} + + self.channel.notify(self.check) + n = Notification.objects.get() + self.assertEqual(n.error, "Could not find a ticket to update") + + self.assertFalse(mock_post.called) diff --git a/hc/api/transports.py b/hc/api/transports.py index b94c1b40..b2b4a195 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -115,6 +115,16 @@ class HttpTransport(Transport): return error + @classmethod + def put(cls, url, **kwargs): + # Make 3 attempts-- + for x in range(0, 3): + error = cls._request("put", url, **kwargs) + if error is None: + break + + return error + class Webhook(HttpTransport): def prepare(self, template, check, urlencode=False): @@ -357,3 +367,48 @@ class Sms(HttpTransport): } return self.post(url, data=data, auth=auth) + + +class Zendesk(HttpTransport): + TMPL = "https://%s.zendesk.com/api/v2/requests.json" + + def get_payload(self, check): + return { + "request": { + "subject": tmpl("zendesk_title.html", check=check), + "type": "incident", + "comment": { + "body": tmpl("zendesk_description.html", check=check) + } + } + } + + def notify_down(self, check): + headers = {"Authorization": "Bearer %s" % self.channel.zendesk_token} + url = self.TMPL % self.channel.zendesk_subdomain + return self.post(url, headers=headers, json=self.get_payload(check)) + + def notify_up(self, check): + # Get the list of requests made by us, in newest-to-oldest order + url = self.TMPL % self.channel.zendesk_subdomain + url += "?sort_by=created_at&sort_order=desc" + headers = {"Authorization": "Bearer %s" % self.channel.zendesk_token} + r = requests.get(url, headers=headers, timeout=10) + if r.status_code != 200: + return "Received status code %d" % r.status_code + + # Update the first request that has check.code in its description + doc = r.json() + if "requests" in doc: + for obj in doc["requests"]: + if str(check.code) in obj["description"]: + payload = self.get_payload(check) + return self.put(obj["url"], headers=headers, json=payload) + + return "Could not find a ticket to update" + + def notify(self, check): + if check.status == "down": + return self.notify_down(check) + if check.status == "up": + return self.notify_up(check) diff --git a/hc/front/tests/test_add_zendesk.py b/hc/front/tests/test_add_zendesk.py new file mode 100644 index 00000000..d4dda826 --- /dev/null +++ b/hc/front/tests/test_add_zendesk.py @@ -0,0 +1,68 @@ +import json + +from django.test.utils import override_settings +from hc.api.models import Channel +from hc.test import BaseTestCase +from mock import patch + + +@override_settings(ZENDESK_CLIENT_ID="t1", ZENDESK_CLIENT_SECRET="s1") +class AddZendeskTestCase(BaseTestCase): + url = "/integrations/add_zendesk/" + + def test_instructions_work(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "Connect Zendesk Support", status_code=200) + + def test_post_works(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, {"subdomain": "foo"}) + self.assertEqual(r.status_code, 302) + self.assertTrue("foo.zendesk.com" in r["Location"]) + + # There should now be a key in session + self.assertTrue("zendesk" in self.client.session) + + @override_settings(ZENDESK_CLIENT_ID=None) + def test_it_requires_client_id(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertEqual(r.status_code, 404) + + @patch("hc.front.views.requests.post") + def test_it_handles_oauth_response(self, mock_post): + session = self.client.session + session["zendesk"] = "foo" + session["subdomain"] = "foodomain" + session.save() + + oauth_response = {"access_token": "test-token"} + mock_post.return_value.text = json.dumps(oauth_response) + mock_post.return_value.json.return_value = oauth_response + + url = self.url + "?code=12345678&state=foo" + + self.client.login(username="alice@example.org", password="password") + r = self.client.get(url, follow=True) + self.assertRedirects(r, "/integrations/") + self.assertContains(r, "The Zendesk integration has been added!") + + ch = Channel.objects.get() + self.assertEqual(ch.zendesk_token, "test-token") + self.assertEqual(ch.zendesk_subdomain, "foodomain") + + # Session should now be clean + self.assertFalse("zendesk" in self.client.session) + self.assertFalse("subdomain" in self.client.session) + + def test_it_avoids_csrf(self): + session = self.client.session + session["zendesk"] = "foo" + session.save() + + url = self.url + "?code=12345678&state=bar" + + self.client.login(username="alice@example.org", password="password") + r = self.client.get(url) + self.assertEqual(r.status_code, 400) diff --git a/hc/front/urls.py b/hc/front/urls.py index 65938671..5571bc6b 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -30,6 +30,7 @@ channel_urls = [ url(r'^telegram/bot/$', views.telegram_bot, name="hc-telegram-webhook"), url(r'^add_telegram/$', views.add_telegram, name="hc-add-telegram"), url(r'^add_sms/$', views.add_sms, name="hc-add-sms"), + url(r'^add_zendesk/$', views.add_zendesk, name="hc-add-zendesk"), url(r'^([\w-]+)/checks/$', views.channel_checks, name="hc-channel-checks"), url(r'^([\w-]+)/remove/$', views.remove_channel, name="hc-remove-channel"), url(r'^([\w-]+)/verify/([\w-]+)/$', views.verify_email, diff --git a/hc/front/views.py b/hc/front/views.py index d7043f50..08439e4b 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -352,6 +352,7 @@ def channels(request): "enable_telegram": settings.TELEGRAM_TOKEN is not None, "enable_sms": settings.TWILIO_AUTH is not None, "enable_pd": settings.PD_VENDOR_KEY is not None, + "enable_zendesk": settings.ZENDESK_CLIENT_ID is not None, "use_payments": settings.USE_PAYMENTS } @@ -895,6 +896,64 @@ def add_sms(request): return render(request, "integrations/add_sms.html", ctx) +@login_required +def add_zendesk(request): + if settings.ZENDESK_CLIENT_ID is None: + raise Http404("zendesk integration is not available") + + if request.method == "POST": + domain = request.POST.get("subdomain") + request.session["subdomain"] = domain + redirect_uri = settings.SITE_ROOT + reverse("hc-add-zendesk") + auth_url = "https://%s.zendesk.com/oauth/authorizations/new?" % domain + auth_url += urlencode({ + "client_id": settings.ZENDESK_CLIENT_ID, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": "requests:read requests:write", + "state": _prepare_state(request, "zendesk") + }) + + return redirect(auth_url) + + if "code" in request.GET: + code = _get_validated_code(request, "zendesk") + if code is None: + return HttpResponseBadRequest() + + domain = request.session.pop("subdomain") + url = "https://%s.zendesk.com/oauth/tokens" % domain + + redirect_uri = settings.SITE_ROOT + reverse("hc-add-zendesk") + result = requests.post(url, { + "client_id": settings.ZENDESK_CLIENT_ID, + "client_secret": settings.ZENDESK_CLIENT_SECRET, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + "scope": "read" + }) + + doc = result.json() + if "access_token" in doc: + doc["subdomain"] = domain + + channel = Channel(kind="zendesk") + channel.user = request.team.user + channel.value = json.dumps(doc) + channel.save() + channel.assign_all_checks() + messages.success(request, + "The Zendesk integration has been added!") + else: + messages.warning(request, "Something went wrong") + + return redirect("hc-channels") + + ctx = {"page": "channels"} + return render(request, "integrations/add_zendesk.html", ctx) + + def privacy(request): return render(request, "front/privacy.html", {}) diff --git a/hc/settings.py b/hc/settings.py index 65e68e5e..e52af2c1 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -165,6 +165,10 @@ TWILIO_FROM = None # PagerDuty PD_VENDOR_KEY = None +# Zendesk +ZENDESK_CLIENT_ID = None +ZENDESK_CLIENT_SECRET = None + if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")): from .local_settings import * else: diff --git a/static/css/channels.css b/static/css/channels.css index 6b6516b5..3ffdd725 100644 --- a/static/css/channels.css +++ b/static/css/channels.css @@ -221,4 +221,13 @@ table.channels-table > tbody > tr > th { .webhook-header { margin-bottom: 4px; +} + +/* Add Zendesk */ +.zendesk-subdomain { + margin-bottom: 8px; +} + +.zendesk-subdomain input { + border-right: 0; } \ No newline at end of file diff --git a/static/img/integrations/zendesk.png b/static/img/integrations/zendesk.png new file mode 100644 index 00000000..163bf4d1 Binary files /dev/null and b/static/img/integrations/zendesk.png differ diff --git a/templates/front/channels.html b/templates/front/channels.html index 9e8b14f1..8362e1d4 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -100,6 +100,8 @@ {{ ch.telegram_name }} {% elif ch.kind == "hipchat" %} {{ ch.hipchat_webhook_url }} + {% elif ch.kind == "zendesk" %} + {{ ch.zendesk_subdomain }}.zendesk.com {% else %} {{ ch.value }} {% endif %} @@ -277,6 +279,17 @@ Add Integration + {% if enable_zendesk and false %} +
Create a Zendesk support ticket when a check goes down.
+ + Add Integration ++ If your team uses Zendesk, + you can set up {% site_name %} to create Zendesk support tickets + when checks go down, and comment on them when + checks go back up. +
+ + +