From a869906fde99c67483ebc243e62aafb0a24ff999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Fri, 29 Dec 2017 22:53:09 +0200 Subject: [PATCH] Zendesk integration (experimental and hidden from Integrations page for now) --- hc/api/migrations/0035_auto_20171229_2008.py | 20 ++++++ hc/api/models.py | 18 ++++- hc/api/tests/test_notify.py | 64 +++++++++++++++++ hc/api/transports.py | 55 ++++++++++++++ hc/front/tests/test_add_zendesk.py | 68 ++++++++++++++++++ hc/front/urls.py | 1 + hc/front/views.py | 59 +++++++++++++++ hc/settings.py | 4 ++ static/css/channels.css | 9 +++ static/img/integrations/zendesk.png | Bin 0 -> 2942 bytes templates/front/channels.html | 13 ++++ templates/integrations/add_zendesk.html | 43 +++++++++++ .../integrations/zendesk_description.html | 8 +++ templates/integrations/zendesk_title.html | 5 ++ 14 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 hc/api/migrations/0035_auto_20171229_2008.py create mode 100644 hc/front/tests/test_add_zendesk.py create mode 100644 static/img/integrations/zendesk.png create mode 100644 templates/integrations/add_zendesk.html create mode 100644 templates/integrations/zendesk_description.html create mode 100644 templates/integrations/zendesk_title.html 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 0000000000000000000000000000000000000000..163bf4d1ba58c71927d70f5587a11fbef51689a8 GIT binary patch literal 2942 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 literal 0 HcmV?d00001 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 %} +
  • + Discord icon + +

    Zendesk Support

    +

    Create a Zendesk support ticket when a check goes down.

    + + Add Integration +
  • + {% endif %}