Browse Source

Zendesk integration (experimental and hidden from Integrations page for now)

pull/149/head
Pēteris Caune 7 years ago
parent
commit
a869906fde
14 changed files with 365 additions and 2 deletions
  1. +20
    -0
      hc/api/migrations/0035_auto_20171229_2008.py
  2. +16
    -2
      hc/api/models.py
  3. +64
    -0
      hc/api/tests/test_notify.py
  4. +55
    -0
      hc/api/transports.py
  5. +68
    -0
      hc/front/tests/test_add_zendesk.py
  6. +1
    -0
      hc/front/urls.py
  7. +59
    -0
      hc/front/views.py
  8. +4
    -0
      hc/settings.py
  9. +9
    -0
      static/css/channels.css
  10. BIN
      static/img/integrations/zendesk.png
  11. +13
    -0
      templates/front/channels.html
  12. +43
    -0
      templates/integrations/add_zendesk.html
  13. +8
    -0
      templates/integrations/zendesk_description.html
  14. +5
    -0
      templates/integrations/zendesk_title.html

+ 20
- 0
hc/api/migrations/0035_auto_20171229_2008.py View File

@ -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),
),
]

+ 16
- 2
hc/api/models.py View File

@ -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()


+ 64
- 0
hc/api/tests/test_notify.py View File

@ -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)

+ 55
- 0
hc/api/transports.py View File

@ -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)

+ 68
- 0
hc/front/tests/test_add_zendesk.py View File

@ -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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", password="password")
r = self.client.get(url)
self.assertEqual(r.status_code, 400)

+ 1
- 0
hc/front/urls.py View File

@ -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,


+ 59
- 0
hc/front/views.py View File

@ -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", {})


+ 4
- 0
hc/settings.py View File

@ -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:


+ 9
- 0
static/css/channels.css View File

@ -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;
}

BIN
static/img/integrations/zendesk.png View File

Before After
Width: 200  |  Height: 200  |  Size: 2.9 KiB

+ 13
- 0
templates/front/channels.html View File

@ -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 @@
<a href="{% url 'hc-add-opsgenie' %}" class="btn btn-primary">Add Integration</a>
</li>
{% if enable_zendesk and false %}
<li>
<img src="{% static 'img/integrations/zendesk.png' %}"
class="icon" alt="Discord icon" />
<h2>Zendesk Support</h2>
<p>Create a Zendesk support ticket when a check goes down.</p>
<a href="{% url 'hc-add-zendesk' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
<li class="link-to-github">
<img src="{% static 'img/integrations/missing.png' %}"
class="icon" alt="Suggest New Integration" />


+ 43
- 0
templates/integrations/add_zendesk.html View File

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% load compress humanize staticfiles hc_extras %}
{% block title %}Add Zendesk - {% site_name %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-12">
<h1>Zendesk Support</h1>
<div class="jumbotron">
<p>
If your team uses <a href="http://zendesk.com/">Zendesk</a>,
you can set up {% site_name %} to create Zendesk support tickets
when checks go <strong>down</strong>, and comment on them when
checks go back <strong>up</strong>.
</p>
<form method="post">
{% csrf_token %}
<div class="row">
<div class="col-sm-offset-3 col-sm-6">
<div class="input-group input-group-lg zendesk-subdomain">
<input
name="subdomain"
placeholder="Subdomain"
type="text"
class="form-control">
<span class="input-group-addon">.zendesk.com</span>
</div>
<button type="submit" class="btn btn-lg btn-default btn-block">
<img class="ai-icon" src="{% static 'img/integrations/zendesk.png' %}" alt="Zendesk" />
Connect Zendesk Support
</button>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

+ 8
- 0
templates/integrations/zendesk_description.html View File

@ -0,0 +1,8 @@
{% load humanize %}
{% if check.status == "down" %}
{{ check.name_then_code }} is down.
Last ping was {{ check.last_ping|naturaltime }}.
Log: {{ check.log_url }}
{% else %}
{{ check.name_then_code }} received a ping and is now UP
{% endif %}

+ 5
- 0
templates/integrations/zendesk_title.html View File

@ -0,0 +1,5 @@
{% if check.status == "down" %}
{{ check.name_then_code }} is down
{% else %}
{{ check.name_then_code }} is now UP
{% endif %}

Loading…
Cancel
Save