forked from GithubBackups/healthchecks
Zendesk integration (experimental and hidden from Integrations page for now)
This commit is contained in:
parent
d6b920551b
commit
a869906fde
20
hc/api/migrations/0035_auto_20171229_2008.py
Normal file
20
hc/api/migrations/0035_auto_20171229_2008.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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()
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
hc/front/tests/test_add_zendesk.py
Normal file
68
hc/front/tests/test_add_zendesk.py
Normal 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="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)
|
@ -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,
|
||||
|
@ -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", {})
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
Normal file
BIN
static/img/integrations/zendesk.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
@ -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
templates/integrations/add_zendesk.html
Normal file
43
templates/integrations/add_zendesk.html
Normal 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
templates/integrations/zendesk_description.html
Normal file
8
templates/integrations/zendesk_description.html
Normal 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
templates/integrations/zendesk_title.html
Normal file
5
templates/integrations/zendesk_title.html
Normal 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…
x
Reference in New Issue
Block a user