diff --git a/hc/api/migrations/0026_auto_20160415_1824.py b/hc/api/migrations/0026_auto_20160415_1824.py new file mode 100644 index 00000000..729976fe --- /dev/null +++ b/hc/api/migrations/0026_auto_20160415_1824.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-04-15 18:24 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0025_auto_20160216_1214'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='value', + field=models.TextField(blank=True), + ), + ] diff --git a/hc/api/models.py b/hc/api/models.py index 80559dcb..60a000bd 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -117,10 +117,14 @@ class Channel(models.Model): user = models.ForeignKey(User) created = models.DateTimeField(auto_now_add=True) kind = models.CharField(max_length=20, choices=CHANNEL_KINDS) - value = models.CharField(max_length=200, blank=True) + value = models.TextField(blank=True) email_verified = models.BooleanField(default=False) checks = models.ManyToManyField(Check) + def assign_all_checks(self): + checks = Check.objects.filter(user=self.user) + self.checks.add(*checks) + def make_token(self): seed = "%s%s" % (self.code, settings.SECRET_KEY) seed = seed.encode("utf8") @@ -176,6 +180,18 @@ class Channel(models.Model): prio = int(prio) return user_key, prio, PO_PRIORITIES[prio] + @property + def value_down(self): + assert self.kind == "webhook" + parts = self.value.split("\n") + return parts[0] + + @property + def value_up(self): + assert self.kind == "webhook" + parts = self.value.split("\n") + return parts[1] if len(parts) == 2 else "" + 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 29dbb478..cdc0cba7 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -64,6 +64,49 @@ class NotifyTestCase(BaseTestCase): n = Notification.objects.get() self.assertEqual(n.error, "Received status code 500") + @patch("hc.api.transports.requests.request") + def test_webhooks_support_variables(self, mock_get): + template = "http://host/$CODE/$STATUS/$TAG1/$TAG2/?name=$NAME" + self._setup_data("webhook", template) + self.check.name = "Hello World" + self.check.tags = "foo bar" + self.check.save() + + self.channel.notify(self.check) + + url = u"http://host/%s/down/foo/bar/?name=Hello%%20World" \ + % self.check.code + + mock_get.assert_called_with( + "get", url, headers={"User-Agent": "healthchecks.io"}, timeout=5) + + @patch("hc.api.transports.requests.request") + def test_webhooks_dollarsign_escaping(self, mock_get): + # If name or tag contains what looks like a variable reference, + # that should be left alone: + + template = "http://host/$NAME" + self._setup_data("webhook", template) + self.check.name = "$TAG1" + self.check.tags = "foo" + self.check.save() + + self.channel.notify(self.check) + + url = u"http://host/%24TAG1" + mock_get.assert_called_with( + "get", url, headers={"User-Agent": "healthchecks.io"}, timeout=5) + + @patch("hc.api.transports.requests.request") + def test_webhook_fires_on_up_event(self, mock_get): + self._setup_data("webhook", "http://foo\nhttp://bar", status="up") + + self.channel.notify(self.check) + + mock_get.assert_called_with( + "get", "http://bar", headers={"User-Agent": "healthchecks.io"}, + timeout=5) + def test_email(self): self._setup_data("email", "alice@example.org") self.channel.notify(self.check) diff --git a/hc/api/transports.py b/hc/api/transports.py index df8fc3e7..e222346d 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -3,6 +3,7 @@ from django.template.loader import render_to_string from django.utils import timezone import json import requests +from six.moves.urllib.parse import quote from hc.lib import emails @@ -81,11 +82,33 @@ class HttpTransport(Transport): class Webhook(HttpTransport): def notify(self, check): - # Webhook integration only fires when check goes down. - if check.status != "down": + url = self.channel.value_down + if check.status == "up": + url = self.channel.value_up + + if not url: + # If the URL is empty then we do nothing return "no-op" - return self.get(self.channel.value) + # Replace variables with actual values. + # There should be no bad translations if users use $ symbol in + # check's name or tags, because $ gets urlencoded to %24 + + if "$CODE" in url: + url = url.replace("$CODE", str(check.code)) + + if "$STATUS" in url: + url = url.replace("$STATUS", check.status) + + if "$NAME" in url: + url = url.replace("$NAME", quote(check.name)) + + if "$TAG" in url: + for i, tag in enumerate(check.tags_list()): + placeholder = "$TAG%d" % (i + 1) + url = url.replace(placeholder, quote(tag)) + + return self.get(url) def test(self): return self.get(self.channel.value) diff --git a/hc/front/forms.py b/hc/front/forms.py index ca0c2ed1..439215d7 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -31,3 +31,13 @@ class AddChannelForm(forms.ModelForm): def clean_value(self): value = self.cleaned_data["value"] return value.strip() + + +class AddWebhookForm(forms.Form): + error_css_class = "has-error" + + value_down = forms.URLField(max_length=1000, required=False) + value_up = forms.URLField(max_length=1000, required=False) + + def get_value(self): + return "{value_down}\n{value_up}".format(**self.cleaned_data) diff --git a/hc/front/tests/test_add_channel.py b/hc/front/tests/test_add_channel.py index 7b0807d6..8d7953fd 100644 --- a/hc/front/tests/test_add_channel.py +++ b/hc/front/tests/test_add_channel.py @@ -81,3 +81,31 @@ class AddChannelTestCase(BaseTestCase): params = "pushover_user_key=a&nonce=INVALID&prio=0" r = self.client.get("/integrations/add_pushover/?%s" % params) assert r.status_code == 403 + + def test_it_adds_two_webhook_urls_and_redirects(self): + form = {"value_down": "http://foo.com", "value_up": "https://bar.com"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post("/integrations/add_webhook/", form) + self.assertRedirects(r, "/integrations/") + + c = Channel.objects.get() + self.assertEqual(c.value, "http://foo.com\nhttps://bar.com") + + def test_it_rejects_non_http_webhook_urls(self): + form = {"value_down": "foo", "value_up": "bar"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post("/integrations/add_webhook/", form) + self.assertContains(r, "Enter a valid URL.") + + self.assertEqual(Channel.objects.count(), 0) + + def test_it_handles_empty_down_url(self): + form = {"value_down": "", "value_up": "http://foo.com"} + + self.client.login(username="alice@example.org", password="password") + self.client.post("/integrations/add_webhook/", form) + + c = Channel.objects.get() + self.assertEqual(c.value, "\nhttp://foo.com") diff --git a/hc/front/views.py b/hc/front/views.py index 552825f2..9ce1d9ff 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -14,7 +14,8 @@ from django.utils.six.moves.urllib.parse import urlencode from hc.accounts.models import Profile from hc.api.decorators import uuid_or_400 from hc.api.models import Channel, Check, Ping, DEFAULT_TIMEOUT, DEFAULT_GRACE -from hc.front.forms import AddChannelForm, NameTagsForm, TimeoutForm +from hc.front.forms import (AddChannelForm, AddWebhookForm, NameTagsForm, + TimeoutForm) # from itertools recipes: @@ -302,8 +303,7 @@ def do_add_channel(request, data): channel.user = request.user channel.save() - checks = Check.objects.filter(user=request.user) - channel.checks.add(*checks) + channel.assign_all_checks() if channel.kind == "email": channel.send_verify_link() @@ -372,7 +372,19 @@ def add_email(request): @login_required def add_webhook(request): - ctx = {"page": "channels"} + if request.method == "POST": + form = AddWebhookForm(request.POST) + if form.is_valid(): + channel = Channel(user=request.user, kind="webhook") + channel.value = form.get_value() + channel.save() + + channel.assign_all_checks() + return redirect("hc-channels") + else: + form = AddWebhookForm() + + ctx = {"page": "channels", "form": form} return render(request, "integrations/add_webhook.html", ctx) @@ -454,6 +466,7 @@ def add_pushover(request): } return render(request, "integrations/add_pushover.html", ctx) + @login_required def add_victorops(request): ctx = {"page": "channels"} diff --git a/static/css/channels.css b/static/css/channels.css index 9ff571b9..03e455c9 100644 --- a/static/css/channels.css +++ b/static/css/channels.css @@ -169,4 +169,8 @@ table.channels-table > tbody > tr > th { border: ; max-width: 100%; border: 6px solid #EEE; +} + +.variable-column { + width: 160px; } \ No newline at end of file diff --git a/templates/front/channel_checks.html b/templates/front/channel_checks.html index 48127f2d..650552db 100644 --- a/templates/front/channel_checks.html +++ b/templates/front/channel_checks.html @@ -5,7 +5,7 @@ diff --git a/templates/front/channels.html b/templates/front/channels.html index ccb60fd6..3636d9fe 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -28,23 +28,40 @@ {% if ch.kind == "victorops" %} VictorOps {% endif %} - - {% if ch.kind == "email" %} to {% endif %} - {% if ch.kind == "pd" %} API key {% endif %} - {% if ch.kind == "po" %} user key {% endif %} - {% if ch.kind == "victorops" %} Post URL {% endif %} - - - {% if ch.kind == "po" %} + {% if ch.kind == "email" %} + to + {{ ch.value }} + {% if not ch.email_verified %} + (unconfirmed) + {% endif %} + {% elif ch.kind == "pd" %} + API key + {{ ch.value }} + {% elif ch.kind == "victorops" %} + Post URL + {{ ch.value }} + {% elif ch.kind == "po" %} + user key {{ ch.po_value|first }} ({{ ch.po_value|last }} priority) + {% elif ch.kind == "webhook" %} + + {% if ch.value_down %} + + + + + {% endif %} + {% if ch.value_up %} + + + + + {% endif %} +
down {{ ch.value_down }}
up {{ ch.value_up }}
{% else %} {{ ch.value }} {% endif %} - - {% if ch.kind == "email" and not ch.email_verified %} - (unconfirmed) - {% endif %}
- +
diff --git a/templates/integrations/add_webhook.html b/templates/integrations/add_webhook.html index 39f85055..9521a76d 100644 --- a/templates/integrations/add_webhook.html +++ b/templates/integrations/add_webhook.html @@ -1,27 +1,84 @@ {% extends "base.html" %} {% load compress humanize staticfiles hc_extras %} -{% block title %}Add WebHook - healthchecks.io{% endblock %} +{% block title %}Add Webhook - healthchecks.io{% endblock %} {% block content %}
-

WebHook

+

Webhook

-

WebHooks are a simple way to notify an external system when a check - goes down. healthcheks.io will run a normal HTTP GET call to your +

Webhooks are a simple way to notify an external system when a check + goes up or down. healthcheks.io will run a normal HTTP GET call to your specified URL.

+

You can use the following variables in webhook URLs:

+ + + + + + + + + + + + + + + + + + + + + +
VariableWill be replaced with…
$CODEThe UUID code of the check
$NAMEUrlencoded name of the check
$STATUSCheck's current status ("up" or "down")
$TAG1, $TAG2, …Urlencoded value of the first tag, the second tag, …
+ +

For example, a callback URL using variables might look like so: +

http://requestb.in/1hhct291?message=$NAME:$STATUS
+ +

+ After encoding and replacing the variables, healthchecks.io would then call: +

+
http://requestb.in/1hhct291?message=My%20Check:down

Integration Settings

-
+ {% csrf_token %} -
- -
- +
+ +
+ + {% if form.value_down.errors %} +
+ {{ form.value_down.errors|join:"" }} +
+ {% endif %} +
+
+
+ +
+ + {% if form.value_up.errors %} +
+ {{ form.value_up.errors|join:"" }} +
+ {% endif %}