diff --git a/README.md b/README.md index c930d52f..dfeeb1e2 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,14 @@ in development environment. $ ./manage.py runserver +## Integrations + +### Pushover + +To enable Pushover integration, you will need to: + +* register a new application on https://pushover.net/apps/build +* enable subscriptions in your application and make sure to enable the URL + subscription type +* add the application token and subscription URL to `hc/local_settings.py`, as + `PUSHOVER_API_TOKEN` and `PUSHOVER_SUBSCRIPTION_URL` diff --git a/hc/api/admin.py b/hc/api/admin.py index 745f3cea..8e27cd91 100644 --- a/hc/api/admin.py +++ b/hc/api/admin.py @@ -146,6 +146,8 @@ class ChannelsAdmin(admin.ModelAdmin): def formatted_kind(self, obj): if obj.kind == "pd": return "PagerDuty" + elif obj.kind == "po": + return "Pushover" elif obj.kind == "webhook": return "Webhook" elif obj.kind == "slack": diff --git a/hc/api/migrations/0017_auto_20151117_1032.py b/hc/api/migrations/0017_auto_20151117_1032.py new file mode 100644 index 00000000..6ed2b6dc --- /dev/null +++ b/hc/api/migrations/0017_auto_20151117_1032.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0016_auto_20151030_1107'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='kind', + field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('po', 'Pushover')], max_length=20), + ), + ] diff --git a/hc/api/models.py b/hc/api/models.py index f43fd886..f4d9b37d 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -24,7 +24,7 @@ DEFAULT_TIMEOUT = td(days=1) DEFAULT_GRACE = td(hours=1) CHANNEL_KINDS = (("email", "Email"), ("webhook", "Webhook"), ("hipchat", "HipChat"), - ("slack", "Slack"), ("pd", "PagerDuty")) + ("slack", "Slack"), ("pd", "PagerDuty"), ("po", "Pushover")) class Check(models.Model): @@ -183,6 +183,44 @@ class Channel(models.Model): n.status = r.status_code n.save() + elif self.kind == "po": + tmpl = "integrations/pushover_message.html" + ctx = { + "check": check, + "down_checks": self.user.check_set.filter(status="down").exclude(code=check.code).order_by("created"), + } + text = render_to_string(tmpl, ctx).strip() + if check.status == "down": + title = "%s is DOWN" % check.name_then_code() + else: + title = "%s is now UP" % check.name_then_code() + + user_key, priority = self.po_value + payload = { + "token": settings.PUSHOVER_API_TOKEN, + "user": user_key, + "message": text, + "title": title, + "html": 1, + "priority": priority, + } + if priority == 2: # Emergency notification + payload["retry"] = settings.PUSHOVER_EMERGENCY_RETRY_DELAY + payload["expire"] = settings.PUSHOVER_EMERGENCY_EXPIRATION + + url = "https://api.pushover.net/1/messages.json" + r = requests.post(url, data=payload, timeout=5) + + n.status = r.status_code + n.save() + + @property + def po_value(self): + assert self.kind == "po" + user_key, prio = self.value.split("|") + prio = int(prio) + return user_key, prio + class Notification(models.Model): owner = models.ForeignKey(Check) diff --git a/hc/front/tests/test_add_channel.py b/hc/front/tests/test_add_channel.py index d77b1013..90781732 100644 --- a/hc/front/tests/test_add_channel.py +++ b/hc/front/tests/test_add_channel.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib.auth.models import User from django.test import TestCase from hc.api.models import Channel @@ -10,6 +11,9 @@ class AddChannelTestCase(TestCase): self.alice.set_password("password") self.alice.save() + settings.PUSHOVER_API_TOKEN = "bogus_token" + settings.PUSHOVER_SUBSCRIPTION_URL = "bogus_url" + def test_it_works(self): url = "/integrations/add/" form = {"kind": "email", "value": "alice@example.org"} @@ -31,7 +35,7 @@ class AddChannelTestCase(TestCase): def test_instructions_work(self): self.client.login(username="alice", password="password") - for frag in ("email", "webhook", "pd", "slack", "hipchat"): + for frag in ("email", "webhook", "pd", "pushover", "slack", "hipchat"): url = "/integrations/add_%s/" % frag r = self.client.get(url) self.assertContains(r, "Integration Settings", status_code=200) diff --git a/hc/front/urls.py b/hc/front/urls.py index b9a957b9..ea385500 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -20,6 +20,7 @@ urlpatterns = [ url(r'^integrations/add_pd/$', views.add_pd, name="hc-add-pd"), url(r'^integrations/add_slack/$', views.add_slack, name="hc-add-slack"), url(r'^integrations/add_hipchat/$', views.add_hipchat, name="hc-add-hipchat"), + url(r'^integrations/add_pushover/$', views.add_pushover, name="hc-add-pushover"), url(r'^integrations/([\w-]+)/checks/$', views.channel_checks, name="hc-channel-checks"), url(r'^integrations/([\w-]+)/remove/$', views.remove_channel, name="hc-remove-channel"), url(r'^integrations/([\w-]+)/verify/([\w-]+)/$', diff --git a/hc/front/views.py b/hc/front/views.py index ecd35135..8a6d589a 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -1,10 +1,13 @@ from datetime import timedelta as td from django.conf import settings +from django.core.urlresolvers import reverse from django.contrib.auth.decorators import login_required from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone +from django.utils.six.moves.urllib.parse import urlencode +from django.utils.crypto import get_random_string from hc.api.decorators import uuid_or_400 from hc.api.models import Channel, Check, Ping from hc.front.forms import AddChannelForm, TimeoutForm @@ -220,16 +223,14 @@ def channels(request): ctx = { "page": "channels", "channels": channels, - "num_checks": num_checks - + "num_checks": num_checks, + "enable_pushover": settings.PUSHOVER_API_TOKEN is not None, } return render(request, "front/channels.html", ctx) -@login_required -def add_channel(request): - assert request.method == "POST" - form = AddChannelForm(request.POST) +def do_add_channel(request, data): + form = AddChannelForm(data) if form.is_valid(): channel = form.save(commit=False) channel.user = request.user @@ -246,6 +247,11 @@ def add_channel(request): return HttpResponseBadRequest() +@login_required +def add_channel(request): + assert request.method == "POST" + return do_add_channel(request, request.POST) + @login_required @uuid_or_400 def channel_checks(request, code): @@ -318,3 +324,58 @@ def add_slack(request): def add_hipchat(request): ctx = {"page": "channels"} return render(request, "integrations/add_hipchat.html", ctx) + + +@login_required +def add_pushover(request): + if settings.PUSHOVER_API_TOKEN is None or settings.PUSHOVER_SUBSCRIPTION_URL is None: + return HttpResponseForbidden() + + if request.method == "POST": + # Initiate the subscription + nonce = get_random_string() + request.session["po_nonce"] = nonce + + failure_url = request.build_absolute_uri(reverse("hc-channels")) + success_url = request.build_absolute_uri(reverse("hc-add-pushover")) + "?" + urlencode({ + "nonce": nonce, + "prio": request.POST.get("po_priority", "0"), + }) + subscription_url = settings.PUSHOVER_SUBSCRIPTION_URL + "?" + urlencode({ + "success": success_url, + "failure": failure_url, + }) + + return redirect(subscription_url) + + # Handle successful subscriptions + if "pushover_user_key" in request.GET and "nonce" in request.GET and "prio" in request.GET: + # Validate nonce + if request.GET["nonce"] != request.session.get("po_nonce", None): + return HttpResponseForbidden() + del request.session["po_nonce"] + + if request.GET.get("pushover_unsubscribed", "0") == "1": + # Unsubscription: delete all Pushover channels for this user + for channel in Channel.objects.filter(user=request.user, kind="po"): + channel.delete() + + return redirect("hc-channels") + + else: + # Subscription + user_key = request.GET["pushover_user_key"] + priority = int(request.GET["prio"]) + + return do_add_channel(request, { + "kind": "po", + "value": "%s|%d" % (user_key, priority), + }) + + else: + ctx = { + "page": "channels", + "po_retry_delay": td(seconds=settings.PUSHOVER_EMERGENCY_RETRY_DELAY), + "po_expiration": td(seconds=settings.PUSHOVER_EMERGENCY_EXPIRATION), + } + return render(request, "integrations/add_pushover.html", ctx) diff --git a/hc/settings.py b/hc/settings.py index be6d12be..1b743fec 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -127,6 +127,12 @@ COMPRESS_OFFLINE = True EMAIL_BACKEND = "djmail.backends.default.EmailBackend" +# Pushover integration -- override these in local_settings +PUSHOVER_API_TOKEN = None +PUSHOVER_SUBSCRIPTION_URL = None +PUSHOVER_EMERGENCY_RETRY_DELAY = 300 +PUSHOVER_EMERGENCY_EXPIRATION = 86400 + 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 ec5806e4..5ff844dd 100644 --- a/static/css/channels.css +++ b/static/css/channels.css @@ -126,6 +126,10 @@ table.channels-table > tbody > tr > th { height: 48px; } +.btn img.ai-icon { + height: 1.4em; +} + .add-integration h2 { margin-top: 0; } diff --git a/static/img/integrations/pushover.png b/static/img/integrations/pushover.png new file mode 100644 index 00000000..73f37988 Binary files /dev/null and b/static/img/integrations/pushover.png differ diff --git a/templates/front/channel_checks.html b/templates/front/channel_checks.html index a117922e..005e8925 100644 --- a/templates/front/channel_checks.html +++ b/templates/front/channel_checks.html @@ -5,7 +5,7 @@
Receive instant push notifications on your phone or tablet.
+ + Add Integration +Pushover is a service to receive + instant push notifications on your phone or tablet from a variety of + sources. If you bought the app on your mobile device, you can integrate it + with your healthchecks.io account in a few simple steps.
+ +