diff --git a/.travis.yml b/.travis.yml index d1c132f9..7eb5e830 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,7 @@ python: - "3.7" install: - pip install -r requirements.txt - - pip install braintree coveralls mock mysqlclient reportlab + - pip install braintree coveralls mock mysqlclient reportlab apprise env: - DB=sqlite - DB=mysql diff --git a/README.md b/README.md index cd92fd6a..f7c93c18 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ Configurations settings loaded from environment variables: | MATRIX_HOMESERVER | `None` | MATRIX_USER_ID | `None` | MATRIX_ACCESS_TOKEN | `None` +| APPRISE_ENABLED | `"False"` Some useful settings keys to override are: @@ -336,3 +337,13 @@ where to forward channel messages by invoking Telegram's For this to work, your `SITE_ROOT` needs to be correct and use "https://" scheme. + +### Apprise + +To enable Apprise integration, you will need to: + +* ensure you have apprise installed in your local environment: +```bash +pip install apprise +``` +* enable the apprise functionality by setting the `APPRISE_ENABLED` environment variable. diff --git a/hc/api/models.py b/hc/api/models.py index 32313aa3..dd2f9245 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -41,6 +41,7 @@ CHANNEL_KINDS = ( ("trello", "Trello"), ("matrix", "Matrix"), ("whatsapp", "WhatsApp"), + ("apprise", "Apprise"), ) PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"} @@ -392,6 +393,8 @@ class Channel(models.Model): return transports.Matrix(self) elif self.kind == "whatsapp": return transports.WhatsApp(self) + elif self.kind == "apprise": + return transports.Apprise(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index caaf5230..a1ecd31e 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -7,8 +7,9 @@ from django.core import mail from django.utils.timezone import now from hc.api.models import Channel, Check, Notification from hc.test import BaseTestCase -from mock import patch +from mock import patch, Mock from requests.exceptions import ConnectionError, Timeout +from django.test.utils import override_settings class NotifyTestCase(BaseTestCase): @@ -636,3 +637,41 @@ class NotifyTestCase(BaseTestCase): n = Notification.objects.get() self.assertTrue("Monthly message limit exceeded" in n.error) + + @patch("apprise.Apprise") + @override_settings(APPRISE_ENABLED=True) + def test_apprise_enabled(self, mock_apprise): + self._setup_data("apprise", "123") + + mock_aobj = Mock() + mock_aobj.add.return_value = True + mock_aobj.notify.return_value = True + mock_apprise.return_value = mock_aobj + self.channel.notify(self.check) + self.assertEqual(Notification.objects.count(), 1) + + self.check.status = "up" + self.assertEqual(Notification.objects.count(), 1) + + @patch("apprise.Apprise") + @override_settings(APPRISE_ENABLED=False) + def test_apprise_disabled(self, mock_apprise): + self._setup_data("apprise", "123") + + mock_aobj = Mock() + mock_aobj.add.return_value = True + mock_aobj.notify.return_value = True + mock_apprise.return_value = mock_aobj + self.channel.notify(self.check) + self.assertEqual(Notification.objects.count(), 1) + + def test_not_implimented(self): + self._setup_data("webhook", "http://example") + self.channel.kind = "invalid" + try: + self.channel.notify(self.check) + # Code should not reach here + assert False + except NotImplementedError: + # We expect to be here + assert True diff --git a/hc/api/transports.py b/hc/api/transports.py index 9475a89f..b08d52c4 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -8,6 +8,12 @@ from urllib.parse import quote, urlencode from hc.accounts.models import Profile from hc.lib import emails +try: + import apprise +except ImportError: + # Enforce + settings.APPRISE_ENABLED = False + def tmpl(template_name, **ctx): template_path = "integrations/%s" % template_name @@ -273,7 +279,7 @@ class PagerTree(HttpTransport): class PagerTeam(HttpTransport): def notify(self, check): url = self.channel.value - headers = {"Conent-Type": "application/json"} + headers = {"Content-Type": "application/json"} payload = { "incident_key": str(check.code), "event_type": "trigger" if check.status == "down" else "resolve", @@ -461,3 +467,22 @@ class Trello(HttpTransport): } return self.post(self.URL, params=params) + +class Apprise(HttpTransport): + def notify(self, check): + + if not settings.APPRISE_ENABLED: + # Not supported and/or enabled + return "Apprise is disabled and/or not installed." + + a = apprise.Apprise() + title = tmpl("apprise_title.html", check=check) + body = tmpl("apprise_description.html", check=check) + + a.add(self.channel.value) + + notify_type = apprise.NotifyType.SUCCESS \ + if check.status == "up" else apprise.NotifyType.FAILURE + + return "Failed" if not \ + a.notify(body=body, title=title, notify_type=notify_type) else None diff --git a/hc/front/forms.py b/hc/front/forms.py index 30494ade..4e01e49a 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -159,3 +159,8 @@ class AddMatrixForm(forms.Form): self.cleaned_data["room_id"] = doc["room_id"] return v + + +class AddAppriseForm(forms.Form): + error_css_class = "has-error" + url = forms.CharField(max_length=512) diff --git a/hc/front/tests/test_add_apprise.py b/hc/front/tests/test_add_apprise.py new file mode 100644 index 00000000..cc46383a --- /dev/null +++ b/hc/front/tests/test_add_apprise.py @@ -0,0 +1,29 @@ +from hc.api.models import Channel +from hc.test import BaseTestCase +from django.test.utils import override_settings + + +@override_settings(APPRISE_ENABLED=True) +class AddAppriseTestCase(BaseTestCase): + def test_instructions_work(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get("/integrations/add_apprise/") + self.assertContains(r, "Integration Settings", status_code=200) + + def test_it_works(self): + form = {"url": "json://example.org"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post("/integrations/add_apprise/", form) + self.assertRedirects(r, "/integrations/") + + c = Channel.objects.get() + self.assertEqual(c.kind, "apprise") + self.assertEqual(c.value, "json://example.org") + self.assertEqual(c.project, self.project) + + @override_settings(APPRISE_ENABLED=False) + def test_it_requires_client_id(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get("/integrations/add_apprise/") + self.assertEqual(r.status_code, 404) diff --git a/hc/front/urls.py b/hc/front/urls.py index d3643d4f..e23ba45a 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -43,6 +43,7 @@ channel_urls = [ path("add_trello/", views.add_trello, name="hc-add-trello"), path("add_trello/settings/", views.trello_settings, name="hc-trello-settings"), path("add_matrix/", views.add_matrix, name="hc-add-matrix"), + path("add_apprise/", views.add_apprise, name="hc-add-apprise"), path("/checks/", views.channel_checks, name="hc-channel-checks"), path("/name/", views.update_channel_name, name="hc-channel-name"), path("/test/", views.send_test_notification, name="hc-channel-test"), diff --git a/hc/front/views.py b/hc/front/views.py index 398cc1db..e391f875 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -44,6 +44,7 @@ from hc.front.forms import ( ChannelNameForm, EmailSettingsForm, AddMatrixForm, + AddAppriseForm, ) from hc.front.schemas import telegram_callback from hc.front.templatetags.hc_extras import num_down_title, down_title, sortchecks @@ -236,6 +237,7 @@ def index(request): "enable_pd": settings.PD_VENDOR_KEY is not None, "enable_trello": settings.TRELLO_APP_KEY is not None, "enable_matrix": settings.MATRIX_ACCESS_TOKEN is not None, + "enable_apprise": settings.APPRISE_ENABLED is True, "registration_open": settings.REGISTRATION_OPEN, } @@ -610,6 +612,7 @@ def channels(request): "enable_pd": settings.PD_VENDOR_KEY is not None, "enable_trello": settings.TRELLO_APP_KEY is not None, "enable_matrix": settings.MATRIX_ACCESS_TOKEN is not None, + "enable_apprise": settings.APPRISE_ENABLED is True, "use_payments": settings.USE_PAYMENTS, } @@ -1325,6 +1328,32 @@ def add_matrix(request): return render(request, "integrations/add_matrix.html", ctx) +@login_required +def add_apprise(request): + if not settings.APPRISE_ENABLED: + raise Http404("apprise integration is not available") + + if request.method == "POST": + form = AddAppriseForm(request.POST) + if form.is_valid(): + channel = Channel(project=request.project, kind="apprise") + channel.value = form.cleaned_data["url"] + channel.save() + + channel.assign_all_checks() + messages.success(request, "The Apprise integration has been added!") + return redirect("hc-channels") + else: + form = AddAppriseForm() + + ctx = { + "page": "channels", + "project": request.project, + "form": form, + } + return render(request, "integrations/add_apprise.html", ctx) + + @login_required @require_POST def trello_settings(request): diff --git a/hc/settings.py b/hc/settings.py index 612811f1..6fcc5c99 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -204,6 +204,10 @@ MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER") MATRIX_USER_ID = os.getenv("MATRIX_USER_ID") MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN") +# Apprise +APPRISE_ENABLED = envbool("APPRISE_ENABLED", "False") + + if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")): from .local_settings import * else: diff --git a/static/img/integrations/apprise.png b/static/img/integrations/apprise.png new file mode 100644 index 00000000..46533dc8 Binary files /dev/null and b/static/img/integrations/apprise.png differ diff --git a/templates/front/channels.html b/templates/front/channels.html index 22400afe..27aebc91 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -69,6 +69,8 @@ {% endif %} {% elif ch.kind == "webhook" %} Webhook + {% elif ch.kind == "apprise" %} + Apprise {% elif ch.kind == "pushbullet" %} Pushbullet {% elif ch.kind == "discord" %} @@ -211,6 +213,17 @@ Add Integration + {% if enable_apprise %} +
  • + Pushover icon + +

    Apprise

    +

    Receive instant push notifications using Apprise; see all of the supported services here.

    + + Add Integration +
  • + {% endif %} {% if enable_pushover %}
  • {% endif %} + {% if enable_apprise %} +
    +
    + Apprise icon +

    Apprise
    >Push Notifications

    +
    +
    + {% endif %}
    diff --git a/templates/integrations/add_apprise.html b/templates/integrations/add_apprise.html new file mode 100644 index 00000000..e079ccfa --- /dev/null +++ b/templates/integrations/add_apprise.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% load humanize static hc_extras %} + +{% block title %}Add Apprise - {% site_name %}{% endblock %} + +{% block content %} +
    +
    +

    Apprise

    + +

    + Identify as many Apprise URLs as you wish. You can use a comma (,) to identify + more than on URL if you wish to. + + For a detailed list of all supported Apprise Notification URLs simply + click here. +

    + +

    Integration Settings

    + +
    + {% csrf_token %} + +
    + +
    + + + {% if form.url.errors %} +
    + {{ form.url.errors|join:"" }} +
    + {% endif %} +
    +
    +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/templates/integrations/apprise_description.html b/templates/integrations/apprise_description.html new file mode 100644 index 00000000..22c9e800 --- /dev/null +++ b/templates/integrations/apprise_description.html @@ -0,0 +1,5 @@ +{% load humanize %} +{{ check.name_then_code }} is {{ check.status|upper }}. +{% if check.status == "down" %} +Last ping was {{ check.last_ping|naturaltime }}. +{% endif %} diff --git a/templates/integrations/apprise_title.html b/templates/integrations/apprise_title.html new file mode 100644 index 00000000..29274284 --- /dev/null +++ b/templates/integrations/apprise_title.html @@ -0,0 +1 @@ +{{ check.name_then_code }} is {{ check.status|upper }}