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..40b9b76a 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,41 @@ 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, + } + + 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..db069412 100644 --- a/hc/front/tests/test_add_channel.py +++ b/hc/front/tests/test_add_channel.py @@ -31,7 +31,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 2c5289b3..201f9a35 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -21,6 +21,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 afe89586..a1485733 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 @@ -224,16 +227,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 @@ -250,6 +251,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): @@ -322,3 +328,54 @@ 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"} + return render(request, "integrations/add_pushover.html", ctx) diff --git a/hc/settings.py b/hc/settings.py index 30d359b6..24301ed6 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -125,6 +125,10 @@ COMPRESS_OFFLINE = True EMAIL_BACKEND = "djmail.backends.default.EmailBackend" +# Pushover integration -- override these in local_settings +PUSHOVER_API_TOKEN = None +PUSHOVER_SUBSCRIPTION_URL = None + try: from .local_settings import * except ImportError as e: 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 @@ diff --git a/templates/front/channels.html b/templates/front/channels.html index e22811cc..12de0b30 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -23,14 +23,20 @@ {% if ch.kind == "slack" %} Slack {% endif %} {% if ch.kind == "hipchat" %} HipChat {% endif %} {% if ch.kind == "pd" %} PagerDuty {% endif %} + {% if ch.kind == "po" %} Pushover {% endif %} {% if ch.kind == "email" %} to {% endif %} {% if ch.kind == "pd" %} API key {% endif %} + {% if ch.kind == "po" %} User key / priority {% endif %} - {{ ch.value }} + {% if ch.kind == "po" %} + {{ ch.po_value|join:" / " }} + {% else %} + {{ ch.value }} + {% endif %} {% if ch.kind == "email" and not ch.email_verified %} (unconfirmed) @@ -108,6 +114,17 @@ Add Integration + {% if enable_pushover %} +
  • + Pushover icon + +

    Pushover

    +

    Receive instant push notifications on your phone or tablet.

    + + Add Integration +
  • + {% endif %} diff --git a/templates/integrations/add_pushover.html b/templates/integrations/add_pushover.html new file mode 100644 index 00000000..80bfd463 --- /dev/null +++ b/templates/integrations/add_pushover.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% load compress humanize staticfiles hc_extras %} + +{% block title %}Add Pushover - healthchecks.io{% endblock %} + + +{% block content %} +
    +
    +

    Pushover

    + +

    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.

    + +

    Integration Settings

    + +
    + {% csrf_token %} +
    + +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} + +{% block scripts %} +{% compress js %} + + +{% endcompress %} +{% endblock %} diff --git a/templates/integrations/pushover_message.html b/templates/integrations/pushover_message.html new file mode 100644 index 00000000..d3f5595c --- /dev/null +++ b/templates/integrations/pushover_message.html @@ -0,0 +1,14 @@ +{% load humanize %} + +{% if check.status == "down" %} +The check "{{ check.name_then_code }}" is DOWN. +Last ping was {{ check.last_ping|naturaltime }}. +{% else %} +The check "{{ check.name_then_code }}" received a ping and is now UP. +{% endif %}{% if down_checks %} +The following checks are {% if check.status == "down" %}also{% else %}still{% endif %} down: +{% for down_check in down_checks %}- "{{ down_check.name_then_code }}" (last ping: {{ down_check.last_ping|naturaltime }}) +{% endfor %} +{% else %} +All the other checks are up. +{% endif %}