diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b005f6..69343c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. - Increase "Success / Failure Keywords" field lengths to 200 - Django 3.2.2 - Improve the handling of unknown email addresses in the Sign In form +- Add support for "... is UP" SMS notifications ## v1.20.0 - 2020-04-22 diff --git a/hc/api/migrations/0078_sms_values.py b/hc/api/migrations/0078_sms_values.py new file mode 100644 index 00000000..903bbef6 --- /dev/null +++ b/hc/api/migrations/0078_sms_values.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.2 on 2021-05-21 09:15 + +import json + +from django.db import migrations + + +def normalize_sms_values(apps, schema_editor): + Channel = apps.get_model("api", "Channel") + for ch in Channel.objects.filter(kind="sms").only("value"): + if ch.value.startswith("{"): + doc = json.loads(ch.value) + phone_number = doc["value"] + else: + phone_number = ch.value + + ch.value = json.dumps({"value": phone_number, "up": False, "down": True}) + ch.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0077_auto_20210506_0755"), + ] + + operations = [migrations.RunPython(normalize_sms_values, migrations.RunPython.noop)] diff --git a/hc/api/models.py b/hc/api/models.py index e831489f..7311b44c 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -739,6 +739,18 @@ class Channel(models.Model): doc = json.loads(self.value) return doc["down"] + @property + def sms_notify_up(self): + assert self.kind == "sms" + doc = json.loads(self.value) + return doc["up"] + + @property + def sms_notify_down(self): + assert self.kind == "sms" + doc = json.loads(self.value) + return doc["down"] + @property def opsgenie_key(self): assert self.kind == "opsgenie" diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index e3c49bd2..a72a26ef 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -141,6 +141,7 @@ class NotifyTestCase(BaseTestCase): @patch("hc.api.transports.requests.request") def test_call_limit(self, mock_post): # At limit already: + self.profile.call_limit = 50 self.profile.last_call_date = now() self.profile.calls_sent = 50 self.profile.save() @@ -164,11 +165,12 @@ class NotifyTestCase(BaseTestCase): @patch("hc.api.transports.requests.request") def test_call_limit_reset(self, mock_post): # At limit, but also into a new month + self.profile.call_limit = 50 self.profile.calls_sent = 50 self.profile.last_call_date = now() - td(days=100) self.profile.save() - self._setup_data("sms", "+1234567890") + self._setup_data("call", "+1234567890") mock_post.return_value.status_code = 200 self.channel.notify(self.check) diff --git a/hc/api/tests/test_notify_sms.py b/hc/api/tests/test_notify_sms.py index cdfc0590..b52c8ad8 100644 --- a/hc/api/tests/test_notify_sms.py +++ b/hc/api/tests/test_notify_sms.py @@ -10,23 +10,24 @@ from hc.api.models import Channel, Check, Notification from hc.test import BaseTestCase -class NotifyTestCase(BaseTestCase): - def _setup_data(self, value): +class NotifySmsTestCase(BaseTestCase): + def setUp(self): + super().setUp() + self.check = Check(project=self.project) self.check.status = "down" self.check.last_ping = now() - td(minutes=61) self.check.save() + spec = {"value": "+1234567890", "up": False, "down": True} self.channel = Channel(project=self.project, kind="sms") - self.channel.value = value + self.channel.value = json.dumps(spec) self.channel.save() self.channel.checks.add(self.check) @patch("hc.api.transports.requests.request") def test_it_works(self, mock_post): - self._setup_data("+1234567890") self.check.last_ping = now() - td(hours=2) - mock_post.return_value.status_code = 200 self.channel.notify(self.check) @@ -34,7 +35,8 @@ class NotifyTestCase(BaseTestCase): args, kwargs = mock_post.call_args payload = kwargs["data"] self.assertEqual(payload["To"], "+1234567890") - self.assertFalse("\xa0" in payload["Body"]) + self.assertNotIn("\xa0", payload["Body"]) + self.assertIn("is DOWN", payload["Body"]) n = Notification.objects.get() callback_path = f"/api/v1/notifications/{n.code}/status" @@ -44,21 +46,6 @@ class NotifyTestCase(BaseTestCase): self.profile.refresh_from_db() self.assertEqual(self.profile.sms_sent, 1) - @patch("hc.api.transports.requests.request") - def test_it_handles_json_value(self, mock_post): - value = {"label": "foo", "value": "+1234567890"} - self._setup_data(json.dumps(value)) - self.check.last_ping = now() - td(hours=2) - - mock_post.return_value.status_code = 200 - - self.channel.notify(self.check) - assert Notification.objects.count() == 1 - - args, kwargs = mock_post.call_args - payload = kwargs["data"] - self.assertEqual(payload["To"], "+1234567890") - @patch("hc.api.transports.requests.request") def test_it_enforces_limit(self, mock_post): # At limit already: @@ -66,8 +53,6 @@ class NotifyTestCase(BaseTestCase): self.profile.sms_sent = 50 self.profile.save() - self._setup_data("+1234567890") - self.channel.notify(self.check) self.assertFalse(mock_post.called) @@ -88,7 +73,6 @@ class NotifyTestCase(BaseTestCase): self.profile.last_sms_date = now() - td(days=100) self.profile.save() - self._setup_data("+1234567890") mock_post.return_value.status_code = 200 self.channel.notify(self.check) @@ -96,7 +80,6 @@ class NotifyTestCase(BaseTestCase): @patch("hc.api.transports.requests.request") def test_it_does_not_escape_special_characters(self, mock_post): - self._setup_data("+1234567890") self.check.name = "Foo > Bar & Co" self.check.last_ping = now() - td(hours=2) @@ -107,3 +90,26 @@ class NotifyTestCase(BaseTestCase): args, kwargs = mock_post.call_args payload = kwargs["data"] self.assertIn("Foo > Bar & Co", payload["Body"]) + + @patch("hc.api.transports.requests.request") + def test_it_handles_disabled_down_notification(self, mock_post): + payload = {"value": "+123123123", "up": True, "down": False} + self.channel.value = json.dumps(payload) + + self.channel.notify(self.check) + self.assertFalse(mock_post.called) + + @patch("hc.api.transports.requests.request") + def test_it_sends_up_notification(self, mock_post): + payload = {"value": "+123123123", "up": True, "down": False} + self.channel.value = json.dumps(payload) + + self.check.last_ping = now() + self.check.status = "up" + mock_post.return_value.status_code = 200 + + self.channel.notify(self.check) + + args, kwargs = mock_post.call_args + payload = kwargs["data"] + self.assertIn("is UP", payload["Body"]) diff --git a/hc/api/transports.py b/hc/api/transports.py index 371a8295..c552c07f 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -491,7 +491,10 @@ class Sms(HttpTransport): URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json" def is_noop(self, check): - return check.status != "down" + if check.status == "down": + return not self.channel.sms_notify_down + else: + return not self.channel.sms_notify_up def notify(self, check): profile = Profile.objects.for_user(self.channel.project.owner) diff --git a/hc/front/tests/test_add_sms.py b/hc/front/tests/test_add_sms.py index 766d977d..6dbe7e65 100644 --- a/hc/front/tests/test_add_sms.py +++ b/hc/front/tests/test_add_sms.py @@ -24,7 +24,7 @@ class AddSmsTestCase(BaseTestCase): self.assertContains(r, "upgrade to a") def test_it_creates_channel(self): - form = {"label": "My Phone", "phone": "+1234567890"} + form = {"label": "My Phone", "phone": "+1234567890", "down": True} self.client.login(username="alice@example.org", password="password") r = self.client.post(self.url, form) @@ -34,6 +34,8 @@ class AddSmsTestCase(BaseTestCase): self.assertEqual(c.kind, "sms") self.assertEqual(c.phone_number, "+1234567890") self.assertEqual(c.name, "My Phone") + self.assertTrue(c.sms_notify_down) + self.assertFalse(c.sms_notify_up) self.assertEqual(c.project, self.project) def test_it_rejects_bad_number(self): @@ -95,3 +97,17 @@ class AddSmsTestCase(BaseTestCase): c = Channel.objects.get() self.assertEqual(c.phone_number, "+1234567890") + + def test_it_obeys_up_down_flags(self): + form = {"label": "My Phone", "phone": "+1234567890"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertRedirects(r, self.channels_url) + + c = Channel.objects.get() + self.assertEqual(c.kind, "sms") + self.assertEqual(c.phone_number, "+1234567890") + self.assertEqual(c.name, "My Phone") + self.assertFalse(c.sms_notify_down) + self.assertFalse(c.sms_notify_up) diff --git a/hc/front/tests/test_channels.py b/hc/front/tests/test_channels.py index 84db5671..0359dbbd 100644 --- a/hc/front/tests/test_channels.py +++ b/hc/front/tests/test_channels.py @@ -121,3 +121,23 @@ class ChannelsTestCase(BaseTestCase): self.assertNotContains(r, "Add Integration", status_code=200) self.assertNotContains(r, "ic-delete") self.assertNotContains(r, "edit_webhook") + + def test_it_shows_down_only_note_for_sms(self): + channel = Channel(project=self.project, kind="sms") + channel.value = json.dumps({"value": "+123123123", "up": False, "down": True}) + channel.save() + + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.channels_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, "(down only)") + + def test_it_shows_up_only_note_for_sms(self): + channel = Channel(project=self.project, kind="sms") + channel.value = json.dumps({"value": "+123123123", "up": True, "down": False}) + channel.save() + + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.channels_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, "(up only)") diff --git a/hc/front/views.py b/hc/front/views.py index 5fc1e54a..40c9808c 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -1638,7 +1638,7 @@ def add_telegram(request): def add_sms(request, code): project = _get_rw_project_for_user(request, code) if request.method == "POST": - form = forms.PhoneNumberForm(request.POST) + form = forms.PhoneUpDownForm(request.POST) if form.is_valid(): channel = Channel(project=project, kind="sms") channel.name = form.cleaned_data["label"] @@ -1648,7 +1648,7 @@ def add_sms(request, code): channel.assign_all_checks() return redirect("hc-channels", project.code) else: - form = forms.PhoneNumberForm() + form = forms.PhoneUpDownForm(initial={"up": False}) ctx = { "page": "channels", diff --git a/templates/front/channels.html b/templates/front/channels.html index 2abc4107..fb6d9327 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -68,6 +68,12 @@ {% endif %} {% elif ch.kind == "sms" %} SMS to {{ ch.phone_number }} + {% if ch.sms_notify_down and not ch.sms_notify_up %} + (down only) + {% endif %} + {% if ch.sms_notify_up and not ch.sms_notify_down %} + (up only) + {% endif %} {% elif ch.kind == "call" %} Phone call to {{ ch.phone_number }} {% elif ch.kind == "trello" %} diff --git a/templates/integrations/add_sms.html b/templates/integrations/add_sms.html index 66843811..8090a1a7 100644 --- a/templates/integrations/add_sms.html +++ b/templates/integrations/add_sms.html @@ -73,6 +73,35 @@ {% endif %} +