diff --git a/hc/api/models.py b/hc/api/models.py index 5ce384f3..93c1b149 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -36,7 +36,8 @@ CHANNEL_KINDS = (("email", "Email"), ("opsgenie", "OpsGenie"), ("victorops", "VictorOps"), ("discord", "Discord"), - ("telegram", "Telegram")) + ("telegram", "Telegram"), + ("sms", "SMS")) PO_PRIORITIES = { -2: "lowest", @@ -270,6 +271,8 @@ class Channel(models.Model): return transports.Discord(self) elif self.kind == "telegram": return transports.Telegram(self) + elif self.kind == "sms": + return transports.Sms(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) diff --git a/hc/api/transports.py b/hc/api/transports.py index b32ef980..c6e06e6a 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -296,3 +296,24 @@ class Telegram(HttpTransport): def notify(self, check): text = tmpl("telegram_message.html", check=check) return self.send(self.channel.telegram_id, text) + + +class Sms(HttpTransport): + URL = 'https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json' + + def is_noop(self, check): + return check.status != "down" + + def notify(self, check): + url = self.URL % settings.TWILIO_ACCOUNT + auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH) + text = tmpl("sms_message.html", check=check, + site_name=settings.SITE_NAME) + + data = { + 'From': settings.TWILIO_FROM, + 'To': self.channel.value, + 'Body': text, + } + + return self.post(url, data=data, auth=auth) diff --git a/hc/front/forms.py b/hc/front/forms.py index 3721adb1..79c5e3a4 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.core.validators import RegexValidator from hc.front.validators import (CronExpressionValidator, TimezoneValidator, WebhookValidator) @@ -64,3 +65,12 @@ class AddWebhookForm(forms.Form): def get_value(self): d = self.cleaned_data return "\n".join((d["value_down"], d["value_up"], d["post_data"])) + + +phone_validator = RegexValidator(regex='^\+\d{5,15}$', + message="Invalid phone number format.") + + +class AddSmsForm(forms.Form): + error_css_class = "has-error" + value = forms.CharField(max_length=16, validators=[phone_validator]) diff --git a/hc/front/tests/test_add_sms.py b/hc/front/tests/test_add_sms.py new file mode 100644 index 00000000..d92c7797 --- /dev/null +++ b/hc/front/tests/test_add_sms.py @@ -0,0 +1,46 @@ +from django.test.utils import override_settings +from hc.api.models import Channel +from hc.test import BaseTestCase + + +@override_settings(TWILIO_ACCOUNT="foo", TWILIO_AUTH="foo", TWILIO_FROM="123") +class AddSmsTestCase(BaseTestCase): + url = "/integrations/add_sms/" + + def test_instructions_work(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "Get a SMS message") + + def test_it_creates_channel(self): + form = {"value": "+1234567890"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertRedirects(r, "/integrations/") + + c = Channel.objects.get() + self.assertEqual(c.kind, "sms") + self.assertEqual(c.value, "+1234567890") + + def test_it_rejects_bad_number(self): + form = {"value": "not a phone number address"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertContains(r, "Invalid phone number format.") + + def test_it_trims_whitespace(self): + form = {"value": " +1234567890 "} + + self.client.login(username="alice@example.org", password="password") + self.client.post(self.url, form) + + c = Channel.objects.get() + self.assertEqual(c.value, "+1234567890") + + @override_settings(TWILIO_AUTH=None) + def test_it_requires_credentials(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get("/integrations/add_sms/") + self.assertEqual(r.status_code, 404) diff --git a/hc/front/urls.py b/hc/front/urls.py index a6ce7df1..23553286 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -26,6 +26,7 @@ channel_urls = [ url(r'^add_victorops/$', views.add_victorops, name="hc-add-victorops"), url(r'^telegram/bot/$', views.telegram_bot), url(r'^add_telegram/$', views.add_telegram, name="hc-add-telegram"), + url(r'^add_sms/$', views.add_sms, name="hc-add-sms"), 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, diff --git a/hc/front/views.py b/hc/front/views.py index e38ccf0c..1eef280c 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -25,7 +25,7 @@ from hc.api.models import (DEFAULT_GRACE, DEFAULT_TIMEOUT, Channel, Check, from hc.api.transports import Telegram from hc.front.forms import (AddWebhookForm, NameTagsForm, TimeoutForm, AddUrlForm, AddPdForm, AddEmailForm, - AddOpsGenieForm, CronForm) + AddOpsGenieForm, CronForm, AddSmsForm) from hc.front.schemas import telegram_callback from hc.lib import jsonschema from pytz import all_timezones @@ -106,6 +106,7 @@ def index(request): "enable_pushover": settings.PUSHOVER_API_TOKEN is not None, "enable_discord": settings.DISCORD_CLIENT_ID is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None, + "enable_sms": settings.TWILIO_AUTH is not None, "registration_open": settings.REGISTRATION_OPEN } @@ -350,7 +351,8 @@ def channels(request): "enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None, "enable_pushover": settings.PUSHOVER_API_TOKEN is not None, "enable_discord": settings.DISCORD_CLIENT_ID is not None, - "enable_telegram": settings.TELEGRAM_TOKEN is not None + "enable_telegram": settings.TELEGRAM_TOKEN is not None, + "enable_sms": settings.TWILIO_AUTH is not None } return render(request, "front/channels.html", ctx) @@ -811,6 +813,27 @@ def add_telegram(request): return render(request, "integrations/add_telegram.html", ctx) +@login_required +def add_sms(request): + if settings.TWILIO_AUTH is None: + raise Http404("sms integration is not available") + + if request.method == "POST": + form = AddSmsForm(request.POST) + if form.is_valid(): + channel = Channel(user=request.team.user, kind="sms") + channel.value = form.cleaned_data["value"] + channel.save() + + channel.assign_all_checks() + return redirect("hc-channels") + else: + form = AddSmsForm() + + ctx = {"page": "channels", "form": form} + return render(request, "integrations/add_sms.html", ctx) + + def privacy(request): return render(request, "front/privacy.html", {}) diff --git a/hc/settings.py b/hc/settings.py index c2c49766..1cd71b5a 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -157,6 +157,11 @@ PUSHBULLET_CLIENT_SECRET = None TELEGRAM_BOT_NAME = "ExampleBot" TELEGRAM_TOKEN = None +# SMS (Twilio) integration -- override in local_settings.py +TWILIO_ACCOUNT = None +TWILIO_AUTH = None +TWILIO_FROM = None + if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")): from .local_settings import * else: diff --git a/static/img/integrations/sms.png b/static/img/integrations/sms.png new file mode 100644 index 00000000..8100074a Binary files /dev/null and b/static/img/integrations/sms.png differ diff --git a/templates/front/channels.html b/templates/front/channels.html index 604a162f..25cb0f4e 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -152,6 +152,17 @@ Add Integration + {% if enable_sms %} +
Get a text message to your phone when check goes down.
+ + Add Integration +Get a SMS message to your specified number when check goes down.
+ +