From 40f4adf78b7cfe3544f794c3472233fbf0bc8830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Fri, 31 May 2019 13:01:01 +0300 Subject: [PATCH 01/65] Add WhatsApp integration (uses Twilio same as the SMS integration) --- CHANGELOG.md | 1 + README.md | 1 + hc/api/models.py | 17 ++- hc/api/tests/test_notify.py | 50 ++++++++- hc/api/transports.py | 27 +++++ hc/front/forms.py | 2 + hc/front/tests/test_add_whatsapp.py | 70 ++++++++++++ hc/front/urls.py | 1 + hc/front/views.py | 35 ++++++ hc/settings.py | 3 +- static/img/integrations/whatsapp.png | Bin 0 -> 20780 bytes templates/front/channels.html | 29 ++++- templates/front/welcome.html | 9 ++ templates/integrations/add_whatsapp.html | 109 +++++++++++++++++++ templates/integrations/whatsapp_message.html | 7 ++ templates/payments/pricing.html | 4 +- 16 files changed, 357 insertions(+), 8 deletions(-) create mode 100644 hc/front/tests/test_add_whatsapp.py create mode 100644 static/img/integrations/whatsapp.png create mode 100644 templates/integrations/add_whatsapp.html create mode 100644 templates/integrations/whatsapp_message.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f167e2..132e7f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Webhooks support HTTP PUT (#249) - Webhooks can use different req. bodies and headers for "up" and "down" events. (#249) - Show check's code instead of full URL on 992px - 1200px wide screens. (#253) +- Add WhatsApp integration (uses Twilio same as the SMS integration) ### Bug Fixes - Fix badges for tags containing special characters (#240, #237) diff --git a/README.md b/README.md index 6151fd76..25890c2d 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ Configurations settings loaded from environment variables: | TWILIO_ACCOUNT | `None` | TWILIO_AUTH | `None` | TWILIO_FROM | `None` +| TWILIO_USE_WHATSAPP | `"False"` | PD_VENDOR_KEY | `None` | TRELLO_APP_KEY | `None` | MATRIX_HOMESERVER | `None` diff --git a/hc/api/models.py b/hc/api/models.py index 900045c1..08daa68c 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -39,6 +39,7 @@ CHANNEL_KINDS = ( ("zendesk", "Zendesk"), ("trello", "Trello"), ("matrix", "Matrix"), + ("whatsapp", "WhatsApp"), ) PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"} @@ -328,6 +329,8 @@ class Channel(models.Model): return transports.Trello(self) elif self.kind == "matrix": return transports.Matrix(self) + elif self.kind == "whatsapp": + return transports.WhatsApp(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) @@ -495,7 +498,7 @@ class Channel(models.Model): @property def sms_number(self): - assert self.kind == "sms" + assert self.kind in ("sms", "whatsapp") if self.value.startswith("{"): doc = json.loads(self.value) return doc["value"] @@ -556,6 +559,18 @@ class Channel(models.Model): doc = json.loads(self.value) return doc.get("down") + @property + def whatsapp_notify_up(self): + assert self.kind == "whatsapp" + doc = json.loads(self.value) + return doc["up"] + + @property + def whatsapp_notify_down(self): + assert self.kind == "whatsapp" + doc = json.loads(self.value) + return doc["down"] + class Notification(models.Model): class Meta: diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index 6eac293a..0ce48d8d 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -522,7 +522,7 @@ class NotifyTestCase(BaseTestCase): mock_post.return_value.status_code = 200 self.channel.notify(self.check) - assert Notification.objects.count() == 1 + self.assertEqual(Notification.objects.count(), 1) args, kwargs = mock_post.call_args payload = kwargs["data"] @@ -575,3 +575,51 @@ class NotifyTestCase(BaseTestCase): self.channel.notify(self.check) self.assertTrue(mock_post.called) + + @patch("hc.api.transports.requests.request") + def test_whatsapp(self, mock_post): + definition = {"value": "+1234567890", "up": True, "down": True} + + self._setup_data("whatsapp", json.dumps(definition)) + self.check.last_ping = now() - td(hours=2) + + mock_post.return_value.status_code = 200 + + self.channel.notify(self.check) + self.assertEqual(Notification.objects.count(), 1) + + args, kwargs = mock_post.call_args + payload = kwargs["data"] + self.assertEqual(payload["To"], "whatsapp:+1234567890") + + # sent SMS counter should go up + self.profile.refresh_from_db() + self.assertEqual(self.profile.sms_sent, 1) + + @patch("hc.api.transports.requests.request") + def test_whatsapp_obeys_up_down_flags(self, mock_post): + definition = {"value": "+1234567890", "up": True, "down": False} + + self._setup_data("whatsapp", json.dumps(definition)) + self.check.last_ping = now() - td(hours=2) + + self.channel.notify(self.check) + self.assertEqual(Notification.objects.count(), 0) + + self.assertFalse(mock_post.called) + + @patch("hc.api.transports.requests.request") + def test_whatsapp_limit(self, mock_post): + # At limit already: + self.profile.last_sms_date = now() + self.profile.sms_sent = 50 + self.profile.save() + + definition = {"value": "+1234567890", "up": True, "down": True} + self._setup_data("whatsapp", json.dumps(definition)) + + self.channel.notify(self.check) + self.assertFalse(mock_post.called) + + n = Notification.objects.get() + self.assertTrue("Monthly message limit exceeded" in n.error) diff --git a/hc/api/transports.py b/hc/api/transports.py index f61b6cf0..07f92eac 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -415,6 +415,33 @@ class Sms(HttpTransport): return self.post(url, data=data, auth=auth) +class WhatsApp(HttpTransport): + URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json" + + def is_noop(self, check): + if check.status == "down": + return not self.channel.whatsapp_notify_down + else: + return not self.channel.whatsapp_notify_up + + def notify(self, check): + profile = Profile.objects.for_user(self.channel.project.owner) + if not profile.authorize_sms(): + return "Monthly message limit exceeded" + + url = self.URL % settings.TWILIO_ACCOUNT + auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH) + text = tmpl("whatsapp_message.html", check=check, site_name=settings.SITE_NAME) + + data = { + "From": "whatsapp:%s" % settings.TWILIO_FROM, + "To": "whatsapp:%s" % self.channel.sms_number, + "Body": text, + } + + return self.post(url, data=data, auth=auth) + + class Trello(HttpTransport): URL = "https://api.trello.com/1/cards" diff --git a/hc/front/forms.py b/hc/front/forms.py index dda7af49..30494ade 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -133,6 +133,8 @@ class AddSmsForm(forms.Form): error_css_class = "has-error" label = forms.CharField(max_length=100, required=False) value = forms.CharField(max_length=16, validators=[phone_validator]) + down = forms.BooleanField(required=False, initial=True) + up = forms.BooleanField(required=False, initial=True) class ChannelNameForm(forms.Form): diff --git a/hc/front/tests/test_add_whatsapp.py b/hc/front/tests/test_add_whatsapp.py new file mode 100644 index 00000000..8c5dbd1f --- /dev/null +++ b/hc/front/tests/test_add_whatsapp.py @@ -0,0 +1,70 @@ +from django.test.utils import override_settings +from hc.api.models import Channel +from hc.test import BaseTestCase + +TEST_CREDENTIALS = { + "TWILIO_ACCOUNT": "foo", + "TWILIO_AUTH": "foo", + "TWILIO_FROM": "123", + "TWILIO_USE_WHATSAPP": True, +} + + +@override_settings(**TEST_CREDENTIALS) +class AddWhatsAppTestCase(BaseTestCase): + url = "/integrations/add_whatsapp/" + + 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 WhatsApp message") + + @override_settings(USE_PAYMENTS=True) + def test_it_warns_about_limits(self): + self.profile.sms_limit = 0 + self.profile.save() + + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "upgrade to a") + + def test_it_creates_channel(self): + form = { + "label": "My Phone", + "value": "+1234567890", + "down": "true", + "up": "true", + } + + 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, "whatsapp") + self.assertEqual(c.sms_number, "+1234567890") + self.assertEqual(c.name, "My Phone") + self.assertTrue(c.whatsapp_notify_down) + self.assertTrue(c.whatsapp_notify_up) + self.assertEqual(c.project, self.project) + + def test_it_obeys_up_down_flags(self): + form = {"label": "My Phone", "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, "whatsapp") + self.assertEqual(c.sms_number, "+1234567890") + self.assertEqual(c.name, "My Phone") + self.assertFalse(c.whatsapp_notify_down) + self.assertFalse(c.whatsapp_notify_up) + self.assertEqual(c.project, self.project) + + @override_settings(TWILIO_USE_WHATSAPP=False) + def test_it_obeys_use_whatsapp_flag(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertEqual(r.status_code, 404) diff --git a/hc/front/urls.py b/hc/front/urls.py index 4bb6b004..d3643d4f 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -39,6 +39,7 @@ channel_urls = [ path("telegram/bot/", views.telegram_bot, name="hc-telegram-webhook"), path("add_telegram/", views.add_telegram, name="hc-add-telegram"), path("add_sms/", views.add_sms, name="hc-add-sms"), + path("add_whatsapp/", views.add_whatsapp, name="hc-add-whatsapp"), 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"), diff --git a/hc/front/views.py b/hc/front/views.py index d9fa5837..8be9c81c 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -232,6 +232,7 @@ def index(request): "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, + "enable_whatsapp": settings.TWILIO_USE_WHATSAPP, "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, @@ -603,6 +604,7 @@ def channels(request): "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, + "enable_whatsapp": settings.TWILIO_USE_WHATSAPP, "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, @@ -1222,6 +1224,39 @@ def add_sms(request): return render(request, "integrations/add_sms.html", ctx) +@login_required +def add_whatsapp(request): + if not settings.TWILIO_USE_WHATSAPP: + raise Http404("whatsapp integration is not available") + + if request.method == "POST": + form = AddSmsForm(request.POST) + if form.is_valid(): + channel = Channel(project=request.project, kind="whatsapp") + channel.name = form.cleaned_data["label"] + channel.value = json.dumps( + { + "value": form.cleaned_data["value"], + "up": form.cleaned_data["up"], + "down": form.cleaned_data["down"], + } + ) + channel.save() + + channel.assign_all_checks() + return redirect("hc-channels") + else: + form = AddSmsForm() + + ctx = { + "page": "channels", + "project": request.project, + "form": form, + "profile": request.project.owner_profile, + } + return render(request, "integrations/add_whatsapp.html", ctx) + + @login_required def add_trello(request): if settings.TRELLO_APP_KEY is None: diff --git a/hc/settings.py b/hc/settings.py index 53f0e813..64946e59 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -187,10 +187,11 @@ PUSHBULLET_CLIENT_SECRET = os.getenv("PUSHBULLET_CLIENT_SECRET") TELEGRAM_BOT_NAME = os.getenv("TELEGRAM_BOT_NAME", "ExampleBot") TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN") -# SMS (Twilio) integration +# SMS and WhatsApp (Twilio) integration TWILIO_ACCOUNT = os.getenv("TWILIO_ACCOUNT") TWILIO_AUTH = os.getenv("TWILIO_AUTH") TWILIO_FROM = os.getenv("TWILIO_FROM") +TWILIO_USE_WHATSAPP = envbool("TWILIO_USE_WHATSAPP", "False") # PagerDuty PD_VENDOR_KEY = os.getenv("PD_VENDOR_KEY") diff --git a/static/img/integrations/whatsapp.png b/static/img/integrations/whatsapp.png new file mode 100644 index 0000000000000000000000000000000000000000..74eb6b9519fcd49d93e6e70a51d995df91573cc3 GIT binary patch literal 20780 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 + {% if channels %} - {% if channels %} @@ -90,6 +90,14 @@ list {{ ch.trello_board_list|last }} {% elif ch.kind == "matrix" %} Matrix {{ ch.value }} + {% elif ch.kind == "whatsapp" %} + WhatsApp to {{ ch.sms_number }} + {% if ch.whatsapp_notify_down and not ch.whatsapp_notify_up %} + (down only) + {% endif %} + {% if ch.whatsapp_notify_up and not ch.whatsapp_notify_down %} + (up only) + {% endif %} {% else %} {{ ch.kind }} {% endif %} @@ -127,7 +135,7 @@ {% else %} Never {% endif %} - {% if ch.kind == "sms" %} + {% if ch.kind == "sms" or ch.kind == "whatsapp" %}

Used {{ profile.sms_sent_this_month }} of {{ profile.sms_limit }} sends this month.

{% endif %} @@ -156,8 +164,12 @@ {% endwith %} {% endfor %} - {% endif %}
Name, Details
+ {% else %} +
+ The project {{ project }} has no integrations set up yet. +
+ {% endif %}

Add More

@@ -312,6 +324,17 @@ Add Integration {% endif %} + {% if enable_whatsapp %} +
  • + WhatsApp icon + +

    WhatsApp {% if use_payments %}(paid plans){% endif %}

    +

    Get a WhatsApp message when a check goes up or down.

    + + Add Integration +
  • + {% endif %}
  • 10 Team Members
  • 1000 Log Entries per Check
  • API Access
  • -
  • 50 SMS Alerts per Month
  • +
  • 50 SMS & WhatsApp Alerts per Month
  • Email Support
  • {% if not request.user.is_authenticated %} @@ -139,7 +139,7 @@
  • Unlimited Team Members
  • 1000 Log Entries per Check
  • API Access
  • -
  • 500 SMS Alerts per Month
  • +
  • 500 SMS & WhatsApp Alerts per Month
  • Priority Email Support
  • {% if not request.user.is_authenticated %} From 3c0b9834e9e7bf04ee28ec75fcefe153bc6562e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Tue, 4 Jun 2019 23:37:36 +0300 Subject: [PATCH 02/65] Django 2.2.2. Also, 1000th commit, cheers! --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b43cdcaa..e3339146 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ croniter==0.3.30 -Django==2.2.1 +Django==2.2.2 django_compressor==2.2 psycopg2==2.7.5 pytz==2019.1 From 080e44f7ba2d3c5016060fdd19f4c559d671e2fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Tue, 4 Jun 2019 23:38:21 +0300 Subject: [PATCH 03/65] Show refunded transactions correctly in the billing history. --- hc/payments/tests/test_billing_history.py | 5 +++-- static/css/billing.css | 3 +++ templates/payments/billing_history.html | 17 ++++++++++------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/hc/payments/tests/test_billing_history.py b/hc/payments/tests/test_billing_history.py index 8ca67150..16391785 100644 --- a/hc/payments/tests/test_billing_history.py +++ b/hc/payments/tests/test_billing_history.py @@ -1,5 +1,6 @@ from mock import Mock, patch +from django.utils.timezone import now from hc.payments.models import Subscription from hc.test import BaseTestCase @@ -15,8 +16,8 @@ class BillingHistoryTestCase(BaseTestCase): @patch("hc.payments.models.braintree") def test_it_works(self, mock_braintree): - m1 = Mock(id="abc123", amount=123) - m2 = Mock(id="def456", amount=456) + m1 = Mock(id="abc123", amount=123, created_at=now()) + m2 = Mock(id="def456", amount=456, created_at=now()) mock_braintree.Transaction.search.return_value = [m1, m2] self.client.login(username="alice@example.org", password="password") diff --git a/static/css/billing.css b/static/css/billing.css index d1816b1d..ec834e4e 100644 --- a/static/css/billing.css +++ b/static/css/billing.css @@ -82,4 +82,7 @@ margin-top: 20px; } +.text-muted code { + color: #777; +} diff --git a/templates/payments/billing_history.html b/templates/payments/billing_history.html index 2ae78c4d..8e31b7f0 100644 --- a/templates/payments/billing_history.html +++ b/templates/payments/billing_history.html @@ -4,12 +4,13 @@ Date Payment Method Amount + Type Status {% for tx in transactions %} - - {{ tx.created_at }} + + {{ tx.created_at|date:"N j, Y" }} {% if tx.payment_instrument_type == "paypal_account" %} Paypal from {{ tx.paypal.payer_email }} @@ -20,17 +21,19 @@ {% endif %} - {% if tx.currency_iso_code == "USD" %} - ${{ tx.amount }} - {% elif tx.currency_iso_code == "EUR" %} - €{{ tx.amount }} + {% if tx.type == "credit" %} + -{{ tx.amount }} {{ tx.currency_iso_code }} {% else %} - {{ tx.currency_iso_code }} {{ tx.amount }} + {{ tx.amount }} {{ tx.currency_iso_code }} {% endif %} + {{ tx.type|capfirst }} {{ tx.status }} + {% if tx.type == "credit" %} + {% else %} PDF Invoice + {% endif %} {% endfor%} From 71dd8a31ebcb6d8b5de04d451beea367cd0763d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Tue, 4 Jun 2019 23:39:50 +0300 Subject: [PATCH 04/65] Project's name with capital H --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 25890c2d..bac79895 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# healthchecks +# Healthchecks [![Build Status](https://travis-ci.org/healthchecks/healthchecks.svg?branch=master)](https://travis-ci.org/healthchecks/healthchecks) [![Coverage Status](https://coveralls.io/repos/healthchecks/healthchecks/badge.svg?branch=master&service=github)](https://coveralls.io/github/healthchecks/healthchecks?branch=master) From 479208abf0f9d9815568e2b2b82a59084dbe8c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Tue, 4 Jun 2019 23:40:08 +0300 Subject: [PATCH 05/65] Webhooks support the $TAGS placeholder --- CHANGELOG.md | 1 + hc/api/tests/test_notify.py | 23 ++++++++++++++++++----- hc/api/transports.py | 5 ++++- templates/integrations/add_webhook.html | 4 ++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 132e7f8b..90449772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Webhooks can use different req. bodies and headers for "up" and "down" events. (#249) - Show check's code instead of full URL on 992px - 1200px wide screens. (#253) - Add WhatsApp integration (uses Twilio same as the SMS integration) +- Webhooks support the $TAGS placeholder ### Bug Fixes - Fix badges for tags containing special characters (#240, #237) diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index 0ce48d8d..caaf5230 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -33,7 +33,7 @@ class NotifyTestCase(BaseTestCase): self.channel.notify(self.check) mock_get.assert_called_with( "get", - u"http://example", + "http://example", headers={"User-Agent": "healthchecks.io"}, timeout=5, ) @@ -72,6 +72,19 @@ class NotifyTestCase(BaseTestCase): n = Notification.objects.get() self.assertEqual(n.error, "Received status code 500") + @patch("hc.api.transports.requests.request") + def test_webhooks_support_tags(self, mock_get): + template = "http://host/$TAGS" + self._setup_data("webhook", template) + self.check.tags = "foo bar" + self.check.save() + + self.channel.notify(self.check) + + args, kwargs = mock_get.call_args + self.assertEqual(args[0], "get") + self.assertEqual(args[1], "http://host/foo%20bar") + @patch("hc.api.transports.requests.request") def test_webhooks_support_variables(self, mock_get): template = "http://host/$CODE/$STATUS/$TAG1/$TAG2/?name=$NAME" @@ -82,7 +95,7 @@ class NotifyTestCase(BaseTestCase): self.channel.notify(self.check) - url = u"http://host/%s/down/foo/bar/?name=Hello%%20World" % self.check.code + url = "http://host/%s/down/foo/bar/?name=Hello%%20World" % self.check.code args, kwargs = mock_get.call_args self.assertEqual(args[0], "get") @@ -118,7 +131,7 @@ class NotifyTestCase(BaseTestCase): self.channel.notify(self.check) - url = u"http://host/%24TAG1" + url = "http://host/%24TAG1" mock_get.assert_called_with( "get", url, headers={"User-Agent": "healthchecks.io"}, timeout=5 ) @@ -135,7 +148,7 @@ class NotifyTestCase(BaseTestCase): @patch("hc.api.transports.requests.request") def test_webhooks_handle_unicode_post_body(self, mock_request): - template = u"http://example.com\n\n(╯°□°)╯︵ ┻━┻" + template = "http://example.com\n\n(╯°□°)╯︵ ┻━┻" self._setup_data("webhook", template) self.check.save() @@ -527,7 +540,7 @@ class NotifyTestCase(BaseTestCase): args, kwargs = mock_post.call_args payload = kwargs["data"] self.assertEqual(payload["To"], "+1234567890") - self.assertFalse(u"\xa0" in payload["Body"]) + self.assertFalse("\xa0" in payload["Body"]) # sent SMS counter should go up self.profile.refresh_from_db() diff --git a/hc/api/transports.py b/hc/api/transports.py index 07f92eac..9475a89f 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -13,7 +13,7 @@ def tmpl(template_name, **ctx): template_path = "integrations/%s" % template_name # \xa0 is non-breaking space. It causes SMS messages to use UCS2 encoding # and cost twice the money. - return render_to_string(template_path, ctx).strip().replace(u"\xa0", " ") + return render_to_string(template_path, ctx).strip().replace("\xa0", " ") class Transport(object): @@ -161,6 +161,9 @@ class Webhook(HttpTransport): if "$NAME" in result: result = result.replace("$NAME", safe(check.name)) + if "$TAGS" in result: + result = result.replace("$TAGS", safe(check.tags)) + if "$TAG" in result: for i, tag in enumerate(check.tags_list()): placeholder = "$TAG%d" % (i + 1) diff --git a/templates/integrations/add_webhook.html b/templates/integrations/add_webhook.html index e739fc16..d28a6e41 100644 --- a/templates/integrations/add_webhook.html +++ b/templates/integrations/add_webhook.html @@ -181,6 +181,10 @@ $STATUS Check's current status ("up" or "down") + + $TAGS + Check's tags, separated by spaces + $TAG1, $TAG2, … Value of the first tag, the second tag, … From 4867fab29198c4991e627dbff40c0df6aa8ff5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Fri, 21 Jun 2019 13:12:05 +0300 Subject: [PATCH 06/65] Not using I18N so turning it off. --- hc/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hc/settings.py b/hc/settings.py index 64946e59..612811f1 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -135,9 +135,9 @@ if os.getenv("DB") == "mysql": TIME_ZONE = "UTC" -USE_I18N = True +USE_I18N = False -USE_L10N = True +USE_L10N = False USE_TZ = True From e0f161157dc9baadebfdbd1e982ca16d69f0f93c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Mon, 24 Jun 2019 18:02:36 +0300 Subject: [PATCH 07/65] Fix `prunepings` and `prunepingsslow`, fixes #264 --- CHANGELOG.md | 1 + hc/api/management/commands/prunepings.py | 4 ++-- hc/api/management/commands/prunepingsslow.py | 4 ++-- hc/api/tests/test_prunepings.py | 24 ++++++++++++++++++++ hc/api/tests/test_prunepingsslow.py | 24 ++++++++++++++++++++ 5 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 hc/api/tests/test_prunepings.py create mode 100644 hc/api/tests/test_prunepingsslow.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 90449772..e706b1fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file. - Fix badges for tags containing special characters (#240, #237) - Fix the "Integrations" page for when the user has no active project - Prevent email clients from opening the one-time login links (#255) +- Fix `prunepings` and `prunepingsslow`, they got broken when adding Projects (#264) ## 1.7.0 - 2019-05-02 diff --git a/hc/api/management/commands/prunepings.py b/hc/api/management/commands/prunepings.py index a0688cde..8b85dd99 100644 --- a/hc/api/management/commands/prunepings.py +++ b/hc/api/management/commands/prunepings.py @@ -14,8 +14,8 @@ class Command(BaseCommand): Profile.objects.get_or_create(user_id=user.id) q = Ping.objects - q = q.annotate(limit=F("owner__user__profile__ping_log_limit")) - q = q.filter(n__lt=F("owner__n_pings") - F("limit")) + q = q.annotate(limit=F("owner__project__owner__profile__ping_log_limit")) + q = q.filter(n__lte=F("owner__n_pings") - F("limit")) q = q.filter(n__gt=0) n_pruned, _ = q.delete() diff --git a/hc/api/management/commands/prunepingsslow.py b/hc/api/management/commands/prunepingsslow.py index 20b10c1c..b73a28a5 100644 --- a/hc/api/management/commands/prunepingsslow.py +++ b/hc/api/management/commands/prunepingsslow.py @@ -21,11 +21,11 @@ class Command(BaseCommand): Profile.objects.get_or_create(user_id=user.id) checks = Check.objects - checks = checks.annotate(limit=F("user__profile__ping_log_limit")) + checks = checks.annotate(limit=F("project__owner__profile__ping_log_limit")) for check in checks: q = Ping.objects.filter(owner_id=check.id) - q = q.filter(n__lt=check.n_pings - check.limit) + q = q.filter(n__lte=check.n_pings - check.limit) q = q.filter(n__gt=0) n_pruned, _ = q.delete() diff --git a/hc/api/tests/test_prunepings.py b/hc/api/tests/test_prunepings.py new file mode 100644 index 00000000..9c28262e --- /dev/null +++ b/hc/api/tests/test_prunepings.py @@ -0,0 +1,24 @@ +from datetime import timedelta + +from django.utils import timezone +from hc.api.management.commands.prunepings import Command +from hc.api.models import Check, Ping +from hc.test import BaseTestCase + + +class PrunePingsTestCase(BaseTestCase): + year_ago = timezone.now() - timedelta(days=365) + + def test_it_removes_old_pings(self): + self.profile.ping_log_limit = 1 + self.profile.save() + + c = Check(project=self.project, n_pings=2) + c.save() + + Ping.objects.create(owner=c, n=1) + Ping.objects.create(owner=c, n=2) + + Command().handle() + + self.assertEqual(Ping.objects.count(), 1) diff --git a/hc/api/tests/test_prunepingsslow.py b/hc/api/tests/test_prunepingsslow.py new file mode 100644 index 00000000..0db46549 --- /dev/null +++ b/hc/api/tests/test_prunepingsslow.py @@ -0,0 +1,24 @@ +from datetime import timedelta + +from django.utils import timezone +from hc.api.management.commands.prunepingsslow import Command +from hc.api.models import Check, Ping +from hc.test import BaseTestCase + + +class PrunePingsSlowTestCase(BaseTestCase): + year_ago = timezone.now() - timedelta(days=365) + + def test_it_removes_old_pings(self): + self.profile.ping_log_limit = 1 + self.profile.save() + + c = Check(project=self.project, n_pings=2) + c.save() + + Ping.objects.create(owner=c, n=1) + Ping.objects.create(owner=c, n=2) + + Command().handle() + + self.assertEqual(Ping.objects.count(), 1) From 3eef3c982f240f49c569f861a21e686ca2f99d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Tue, 2 Jul 2019 14:21:13 +0300 Subject: [PATCH 08/65] Django 2.2.3 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e3339146..caf73be4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ croniter==0.3.30 -Django==2.2.2 +Django==2.2.3 django_compressor==2.2 psycopg2==2.7.5 pytz==2019.1 From 1f1b1aedca949a52a592f8aefc523ad47b054ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Thu, 4 Jul 2019 09:36:27 +0300 Subject: [PATCH 09/65] Don't include ping URLs in API responses when the read-only key is used --- CHANGELOG.md | 1 + hc/api/decorators.py | 2 ++ hc/api/models.py | 26 +++++++++++++++++--------- hc/api/tests/test_list_channels.py | 7 ------- hc/api/tests/test_list_checks.py | 3 +++ hc/api/views.py | 4 ++-- 6 files changed, 25 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e706b1fa..5bc38e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. - Show check's code instead of full URL on 992px - 1200px wide screens. (#253) - Add WhatsApp integration (uses Twilio same as the SMS integration) - Webhooks support the $TAGS placeholder +- Don't include ping URLs in API responses when the read-only key is used ### Bug Fixes - Fix badges for tags containing special characters (#240, #237) diff --git a/hc/api/decorators.py b/hc/api/decorators.py index bf6c5718..b686f349 100644 --- a/hc/api/decorators.py +++ b/hc/api/decorators.py @@ -27,6 +27,7 @@ def authorize(f): except Project.DoesNotExist: return error("wrong api key", 401) + request.readonly = False return f(request, *args, **kwds) return wrapper @@ -50,6 +51,7 @@ def authorize_read(f): except Project.DoesNotExist: return error("wrong api key", 401) + request.readonly = api_key == request.project.api_key_readonly return f(request, *args, **kwds) return wrapper diff --git a/hc/api/models.py b/hc/api/models.py index 08daa68c..62b80f2d 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -167,26 +167,34 @@ class Check(models.Model): def matches_tag_set(self, tag_set): return tag_set.issubset(self.tags_list()) - def to_dict(self): - update_rel_url = reverse("hc-api-update", args=[self.code]) - pause_rel_url = reverse("hc-api-pause", args=[self.code]) - channel_codes = [str(ch.code) for ch in self.channel_set.all()] + def channels_str(self): + """ Return a comma-separated string of assigned channel codes. """ + + codes = self.channel_set.order_by("code").values_list("code", flat=True) + return ",".join(map(str, codes)) + + def to_dict(self, readonly=False): result = { "name": self.name, - "ping_url": self.url(), - "update_url": settings.SITE_ROOT + update_rel_url, - "pause_url": settings.SITE_ROOT + pause_rel_url, "tags": self.tags, "grace": int(self.grace.total_seconds()), "n_pings": self.n_pings, "status": self.get_status(), - "channels": ",".join(sorted(channel_codes)), "last_ping": isostring(self.last_ping), "next_ping": isostring(self.get_grace_start()), - "desc": self.desc, } + if not readonly: + update_rel_url = reverse("hc-api-update", args=[self.code]) + pause_rel_url = reverse("hc-api-pause", args=[self.code]) + + result["ping_url"] = self.url() + result["update_url"] = settings.SITE_ROOT + update_rel_url + result["pause_url"] = settings.SITE_ROOT + pause_rel_url + result["channels"] = self.channels_str() + result["desc"] = self.desc + if self.kind == "simple": result["timeout"] = int(self.timeout.total_seconds()) elif self.kind == "cron": diff --git a/hc/api/tests/test_list_channels.py b/hc/api/tests/test_list_channels.py index 0458aed6..17bfa65d 100644 --- a/hc/api/tests/test_list_channels.py +++ b/hc/api/tests/test_list_channels.py @@ -51,10 +51,3 @@ class ListChannelsTestCase(BaseTestCase): self.assertEqual(r.status_code, 200) self.assertContains(r, "Email to Alice") - - def test_readonly_key_works(self): - self.project.api_key_readonly = "R" * 32 - self.project.save() - - r = self.client.get("/api/v1/channels/", HTTP_X_API_KEY="R" * 32) - self.assertEqual(r.status_code, 200) diff --git a/hc/api/tests/test_list_checks.py b/hc/api/tests/test_list_checks.py index e6c69074..c887e267 100644 --- a/hc/api/tests/test_list_checks.py +++ b/hc/api/tests/test_list_checks.py @@ -150,3 +150,6 @@ class ListChecksTestCase(BaseTestCase): r = self.client.get("/api/v1/checks/", HTTP_X_API_KEY="R" * 32) self.assertEqual(r.status_code, 200) + + # When using readonly keys, the ping URLs should not be exposed: + self.assertNotContains(r, self.a1.url()) diff --git a/hc/api/views.py b/hc/api/views.py index 38a5a2e6..0520f1d9 100644 --- a/hc/api/views.py +++ b/hc/api/views.py @@ -121,7 +121,7 @@ def get_checks(request): for check in q: # precise, final filtering if not tags or check.matches_tag_set(tags): - checks.append(check.to_dict()) + checks.append(check.to_dict(readonly=request.readonly)) return JsonResponse({"checks": checks}) @@ -153,7 +153,7 @@ def checks(request): @cors("GET") @validate_json() -@authorize_read +@authorize def channels(request): q = Channel.objects.filter(project=request.project) channels = [ch.to_dict() for ch in q] From 5ab071ed560b604e3f8eecdd7e61d633ca976fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Thu, 4 Jul 2019 09:36:41 +0300 Subject: [PATCH 10/65] Cleanup. --- hc/front/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/hc/front/views.py b/hc/front/views.py index 8be9c81c..3f5649e0 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -166,7 +166,6 @@ def my_checks(request, code): "num_available": project.num_checks_available(), "sort": request.profile.sort, "selected_tags": selected_tags, - "show_search": True, "search": search, "hidden_checks": hidden_checks, } From e386ccaa0abab6feca78b2bc4a1024b6e78b5adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Thu, 4 Jul 2019 09:39:31 +0300 Subject: [PATCH 11/65] Don't mention whatsapp in the pricing page if it's not enabled in settings. --- hc/payments/views.py | 7 ++++++- templates/payments/pricing.html | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/hc/payments/views.py b/hc/payments/views.py index 98466bf6..aa239ef8 100644 --- a/hc/payments/views.py +++ b/hc/payments/views.py @@ -1,5 +1,6 @@ from io import BytesIO +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import ( @@ -33,7 +34,11 @@ def pricing(request): # subscription object is not created just by viewing a page. sub = Subscription.objects.filter(user_id=request.user.id).first() - ctx = {"page": "pricing", "sub": sub} + ctx = { + "page": "pricing", + "sub": sub, + "enable_whatsapp": settings.TWILIO_USE_WHATSAPP, + } return render(request, "payments/pricing.html", ctx) diff --git a/templates/payments/pricing.html b/templates/payments/pricing.html index abe85115..aba47891 100644 --- a/templates/payments/pricing.html +++ b/templates/payments/pricing.html @@ -110,7 +110,7 @@
  • 10 Team Members
  • 1000 Log Entries per Check
  • API Access
  • -
  • 50 SMS & WhatsApp Alerts per Month
  • +
  • 50 SMS {% if enable_whatsapp %}& WhatsApp{% endif %} Alerts per Month
  • Email Support
  • {% if not request.user.is_authenticated %} @@ -139,7 +139,7 @@
  • Unlimited Team Members
  • 1000 Log Entries per Check
  • API Access
  • -
  • 500 SMS & WhatsApp Alerts per Month
  • +
  • 500 SMS {% if enable_whatsapp %}& WhatsApp{% endif %} Alerts per Month
  • Priority Email Support
  • {% if not request.user.is_authenticated %} From 35b137a8d753a783c8eaf6bd1288fa0e80519cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Thu, 4 Jul 2019 12:50:01 +0300 Subject: [PATCH 12/65] Allow caching CORS responses. --- hc/api/decorators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hc/api/decorators.py b/hc/api/decorators.py index b686f349..8ee64f6a 100644 --- a/hc/api/decorators.py +++ b/hc/api/decorators.py @@ -109,6 +109,7 @@ def cors(*methods): response["Access-Control-Allow-Origin"] = "*" response["Access-Control-Allow-Headers"] = "X-Api-Key" response["Access-Control-Allow-Methods"] = methods_str + response["Access-Control-Max-Age"] = "600" return response return wrapper From 77fd0d00e0462c49e6d9044b0db844481ad6e380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Thu, 4 Jul 2019 19:33:26 +0300 Subject: [PATCH 13/65] Add "desc" back in the readonly API responses, and add "unique_key" field, derived from code. --- hc/api/models.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/hc/api/models.py b/hc/api/models.py index 62b80f2d..ca92fdd8 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -178,6 +178,7 @@ class Check(models.Model): result = { "name": self.name, "tags": self.tags, + "desc": self.desc, "grace": int(self.grace.total_seconds()), "n_pings": self.n_pings, "status": self.get_status(), @@ -185,7 +186,10 @@ class Check(models.Model): "next_ping": isostring(self.get_grace_start()), } - if not readonly: + if readonly: + code_half = self.code.hex[:16] + result["unique_key"] = hashlib.sha1(code_half.encode()).hexdigest() + else: update_rel_url = reverse("hc-api-update", args=[self.code]) pause_rel_url = reverse("hc-api-pause", args=[self.code]) @@ -193,7 +197,6 @@ class Check(models.Model): result["update_url"] = settings.SITE_ROOT + update_rel_url result["pause_url"] = settings.SITE_ROOT + pause_rel_url result["channels"] = self.channels_str() - result["desc"] = self.desc if self.kind == "simple": result["timeout"] = int(self.timeout.total_seconds()) From cc4f8b639b74e862b5a5867cb08ec711ba007d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sun, 7 Jul 2019 11:57:25 +0300 Subject: [PATCH 14/65] Add healthchecks/dashboard to "Third-Party Resources" --- templates/front/docs_resources.html | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/front/docs_resources.html b/templates/front/docs_resources.html index a8fb9a9a..69ce60c8 100644 --- a/templates/front/docs_resources.html +++ b/templates/front/docs_resources.html @@ -50,5 +50,6 @@
      {% include "front/single_resource.html" with url="https://github.com/taylus/HealthTray" name="HealthTray" desc="Watch your healthchecks in Windows system tray" %} + {% include "front/single_resource.html" with url="https://github.com/healthchecks/dashboard" name="healthchecks/dashboard" desc="A standalone HTML page showing the status of the checks in your account. " %}
    {% endblock %} \ No newline at end of file From 96c2cdbbb87220ede5ce2c92a702b6c65cabe5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Mon, 8 Jul 2019 11:35:20 +0300 Subject: [PATCH 15/65] More information about read-only API keys in API docs. --- hc/front/management/commands/pygmentize.py | 1 + templates/front/docs_api.html | 27 +++++++++++++++-- .../list_checks_response_readonly.html | 29 +++++++++++++++++++ .../list_checks_response_readonly.txt | 28 ++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 templates/front/snippets/list_checks_response_readonly.html create mode 100644 templates/front/snippets/list_checks_response_readonly.txt diff --git a/hc/front/management/commands/pygmentize.py b/hc/front/management/commands/pygmentize.py index 59a4e717..9f8ce124 100644 --- a/hc/front/management/commands/pygmentize.py +++ b/hc/front/management/commands/pygmentize.py @@ -47,6 +47,7 @@ class Command(BaseCommand): # API examples _process("list_checks_request", lexers.BashLexer()) _process("list_checks_response", lexers.JsonLexer()) + _process("list_checks_response_readonly", lexers.JsonLexer()) _process("list_channels_request", lexers.BashLexer()) _process("list_channels_response", lexers.JsonLexer()) _process("create_check_request_a", lexers.BashLexer()) diff --git a/templates/front/docs_api.html b/templates/front/docs_api.html index 80b4fb02..c2db1f80 100644 --- a/templates/front/docs_api.html +++ b/templates/front/docs_api.html @@ -56,11 +56,25 @@

    Authentication

    Your requests to {% site_name %} REST API must authenticate using an -API key. By default, an user account on {% site_name %} doesn't have -an API key. You can create read-write and read-only API keys -in the Project Settings page. +API key. Each project in your {% site_name %} account has separate API keys. +There are no account-wide API keys. By default, a project on {% site_name %} doesn't have +an API key. You can create read-write and read-only API keys in the +Project Settings page.

    + + + + + + + + + +
    Regular API keysHave full access to all documented API endpoints.
    Read-only API keysOnly work with the + Get a list of existing checks + endpoint. Some fields are omitted from the API responses.
    +

    The client can authenticate itself by sending an appropriate HTTP request header. The header's name should be X-Api-Key and its value should be your API key. @@ -126,6 +140,13 @@ one or more tags.

    Example Response

    {% include "front/snippets/list_checks_response.html" %} +

    When using the read-only API key, the following fields are omitted: + ping_url, update_url, pause_url, + channels. An extra unique_key field is added. + This identifier is stable across API calls. Example: +

    +{% include "front/snippets/list_checks_response_readonly.html" %} + diff --git a/templates/front/snippets/list_checks_response_readonly.html b/templates/front/snippets/list_checks_response_readonly.html new file mode 100644 index 00000000..d123702e --- /dev/null +++ b/templates/front/snippets/list_checks_response_readonly.html @@ -0,0 +1,29 @@ +
    {
    +  "checks": [
    +    {
    +      "desc": "Longer free-form description goes here",
    +      "grace": 900,
    +      "last_ping": "2017-01-04T13:24:39.903464+00:00",
    +      "n_pings": 1,
    +      "name": "Api test 1",
    +      "status": "up",
    +      "tags": "foo",
    +      "timeout": 3600,
    +      "unique_key": "2872190d95224bad120f41d3c06aab94b8175bb6"
    +    },
    +    {
    +      "desc": "",
    +      "grace": 3600,
    +      "last_ping": null,
    +      "n_pings": 0,
    +      "name": "Api test 2",
    +      "next_ping": null,
    +      "schedule": "0/10 * * * *",
    +      "status": "new",
    +      "tags": "bar baz",
    +      "tz": "UTC",
    +      "unique_key": "9b5fc29129560ff2c5c1803803a7415e4f80cf7e"
    +    }
    +  ]
    +}
    +
    diff --git a/templates/front/snippets/list_checks_response_readonly.txt b/templates/front/snippets/list_checks_response_readonly.txt new file mode 100644 index 00000000..a6e43685 --- /dev/null +++ b/templates/front/snippets/list_checks_response_readonly.txt @@ -0,0 +1,28 @@ +{ + "checks": [ + { + "desc": "Longer free-form description goes here", + "grace": 900, + "last_ping": "2017-01-04T13:24:39.903464+00:00", + "n_pings": 1, + "name": "Api test 1", + "status": "up", + "tags": "foo", + "timeout": 3600, + "unique_key": "2872190d95224bad120f41d3c06aab94b8175bb6" + }, + { + "desc": "", + "grace": 3600, + "last_ping": null, + "n_pings": 0, + "name": "Api test 2", + "next_ping": null, + "schedule": "0/10 * * * *", + "status": "new", + "tags": "bar baz", + "tz": "UTC", + "unique_key": "9b5fc29129560ff2c5c1803803a7415e4f80cf7e" + } + ] +} \ No newline at end of file From e54aca67250e45b01083be32f1cbc51008f590eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Mon, 8 Jul 2019 13:38:56 +0300 Subject: [PATCH 16/65] v1.8.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc38e33..b01d25f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog All notable changes to this project will be documented in this file. -## Unreleased +## 1.8.0 - 2019-07-08 ### Improvements - Add the `prunetokenbucket` management command From 429a69c2e97bb5050a799266c2e2be07383ce9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Fri, 12 Jul 2019 14:55:03 +0300 Subject: [PATCH 17/65] Fancy quotes in whatsapp messages. --- templates/integrations/whatsapp_message.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/integrations/whatsapp_message.html b/templates/integrations/whatsapp_message.html index 62505f5f..8d5e3a8e 100644 --- a/templates/integrations/whatsapp_message.html +++ b/templates/integrations/whatsapp_message.html @@ -1,7 +1,7 @@ {% load humanize %}{% spaceless %} {% if check.status == "down" %} - The check "{{ check.name_then_code }}" is DOWN. Last ping was {{ check.last_ping|naturaltime }}. + The check “{{ check.name_then_code }}” is DOWN. Last ping was {{ check.last_ping|naturaltime }}. {% else %} - The check "{{ check.name_then_code }}" is now UP. + The check “{{ check.name_then_code }}” is now UP. {% endif %} {% endspaceless %} From e174e1ef4c2a7fc02dba3cae611df2b196cf31b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Thu, 18 Jul 2019 22:52:35 +0300 Subject: [PATCH 18/65] Adding "and other contributors" in the copyright notice. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 1c156240..233bf77f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015, Pēteris Caune +Copyright (c) 2015, Pēteris Caune and other contributors All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: From b74e56a273ae1da1b7bf58ec0158846483dc2c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Fri, 19 Jul 2019 17:32:39 +0300 Subject: [PATCH 19/65] Experimental: show the number of outages and total downtime in monthly reports. (#104) --- CHANGELOG.md | 6 ++ hc/accounts/models.py | 2 + hc/api/models.py | 34 ++++++++ hc/front/templatetags/hc_extras.py | 7 +- hc/lib/date.py | 40 +++++++++- templates/emails/report-body-html.html | 7 +- templates/emails/summary-downtimes-html.html | 84 ++++++++++++++++++++ 7 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 templates/emails/summary-downtimes-html.html diff --git a/CHANGELOG.md b/CHANGELOG.md index b01d25f0..e93c2b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog All notable changes to this project will be documented in this file. +## Unreleased + +### Improvements +- Show the number of outages and total downtime in monthly reports. (#104) + + ## 1.8.0 - 2019-07-08 ### Improvements diff --git a/hc/accounts/models.py b/hc/accounts/models.py index db35b2b5..37ed5cd7 100644 --- a/hc/accounts/models.py +++ b/hc/accounts/models.py @@ -12,6 +12,7 @@ from django.db.models import Count, Q from django.urls import reverse from django.utils import timezone from hc.lib import emails +from hc.lib.date import month_boundaries NO_NAG = timedelta() @@ -176,6 +177,7 @@ class Profile(models.Model): "nag": nag, "nag_period": self.nag_period.total_seconds(), "num_down": num_down, + "month_boundaries": month_boundaries(), } emails.report(self.user.email, ctx, headers) diff --git a/hc/api/models.py b/hc/api/models.py index ca92fdd8..52824ca0 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -13,6 +13,7 @@ from django.utils import timezone from hc.accounts.models import Project from hc.api import transports from hc.lib import emails +from hc.lib.date import month_boundaries import pytz STATUSES = (("up", "Up"), ("down", "Down"), ("new", "New"), ("paused", "Paused")) @@ -245,6 +246,39 @@ class Check(models.Model): ping.body = body[:10000] ping.save() + def outages_by_month(self, months=2): + now = timezone.now() + + totals = {} + events = [] + for boundary in month_boundaries(months=months): + totals[(boundary.year, boundary.month)] = [boundary, 0, 0] + events.append((boundary, "---")) + + flips = self.flip_set.filter(created__gt=now - td(days=32 * months)) + for flip in flips: + events.append((flip.created, flip.old_status)) + + events.sort(reverse=True) + + needle, status = now, self.status + for dt, old_status in events: + if status == "down": + if (dt.year, dt.month) not in totals: + break + + delta = needle - dt + totals[(dt.year, dt.month)][1] += int(delta.total_seconds()) + totals[(dt.year, dt.month)][2] += 1 + + needle = dt + if old_status != "---": + status = old_status + + flattened = list(totals.values()) + flattened.sort(reverse=True) + return flattened + class Ping(models.Model): id = models.BigAutoField(primary_key=True) diff --git a/hc/front/templatetags/hc_extras.py b/hc/front/templatetags/hc_extras.py index bff76b5d..aaadc79f 100644 --- a/hc/front/templatetags/hc_extras.py +++ b/hc/front/templatetags/hc_extras.py @@ -5,7 +5,7 @@ from django.conf import settings from django.utils.html import escape from django.utils.safestring import mark_safe -from hc.lib.date import format_duration, format_hms +from hc.lib.date import format_duration, format_approx_duration, format_hms register = template.Library() @@ -15,6 +15,11 @@ def hc_duration(td): return format_duration(td) +@register.filter +def hc_approx_duration(td): + return format_approx_duration(td) + + @register.filter def hms(td): return format_hms(td) diff --git a/hc/lib/date.py b/hc/lib/date.py index a358a167..6c9ef704 100644 --- a/hc/lib/date.py +++ b/hc/lib/date.py @@ -1,3 +1,7 @@ +from datetime import datetime as dt +from django.utils import timezone + + class Unit(object): def __init__(self, name, nsecs): self.name = name @@ -5,6 +9,7 @@ class Unit(object): self.nsecs = nsecs +SECOND = Unit("second", 1) MINUTE = Unit("minute", 60) HOUR = Unit("hour", MINUTE.nsecs * 60) DAY = Unit("day", HOUR.nsecs * 24) @@ -13,6 +18,7 @@ WEEK = Unit("week", DAY.nsecs * 7) def format_duration(td): remaining_seconds = int(td.total_seconds()) + result = [] for unit in (WEEK, DAY, HOUR, MINUTE): @@ -30,7 +36,11 @@ def format_duration(td): def format_hms(td): - total_seconds = int(td.total_seconds()) + if isinstance(td, int): + total_seconds = td + else: + total_seconds = int(td.total_seconds()) + result = [] mins, secs = divmod(total_seconds, 60) @@ -45,3 +55,31 @@ def format_hms(td): result.append("%s sec" % secs) return " ".join(result) + + +def format_approx_duration(v): + for unit in (DAY, HOUR, MINUTE, SECOND): + if v >= unit.nsecs: + vv = v // unit.nsecs + if vv == 1: + return "1 %s" % unit.name + else: + return "%d %s" % (vv, unit.plural) + + return "" + + +def month_boundaries(months=2): + result = [] + + now = timezone.now() + y, m = now.year, now.month + for x in range(0, months): + result.append(dt(y, m, 1, tzinfo=timezone.utc)) + + m -= 1 + if m == 0: + m = 12 + y = y - 1 + + return result diff --git a/templates/emails/report-body-html.html b/templates/emails/report-body-html.html index da214c7a..d61bbf75 100644 --- a/templates/emails/report-body-html.html +++ b/templates/emails/report-body-html.html @@ -20,7 +20,12 @@ Hello,
    {% endif %}
    -{% include "emails/summary-html.html" %} + +{% if nag %} + {% include "emails/summary-html.html" %} +{% else %} + {% include "emails/summary-downtimes-html.html" %} +{% endif %} {% if nag %} Too many notifications? diff --git a/templates/emails/summary-downtimes-html.html b/templates/emails/summary-downtimes-html.html new file mode 100644 index 00000000..160fcf33 --- /dev/null +++ b/templates/emails/summary-downtimes-html.html @@ -0,0 +1,84 @@ +{% load humanize hc_extras %} +{% regroup checks by project as groups %} + + {% for group in groups %} + + + {% for dt in month_boundaries %} + + {% endfor %} + + {% for check in group.list|sortchecks:sort %} + + + + {% for boundary, seconds, count in check.outages_by_month %} + {% if count %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + {% endfor %} +
    + {{ group.grouper|mangle_link }} + + {{ dt|date:"N Y"}} +
    + + + {% if check.get_status == "new" %} + + {% elif check.get_status == "paused" %} + + {% elif check.get_status == "grace" %} + + {% elif check.get_status == "up" %} + + {% elif check.get_status == "started" %} + + {% elif check.get_status == "down" %} + + {% endif %} + +
    NEWPAUSEDLATEUPSTARTEDDOWN
    +
    + {% if check.name %} + {% if check.name|length > 20 %} + {{ check.name|mangle_link }} + {% else %} + {{ check.name|mangle_link }} + {% endif %} + {% else %} + unnamed + {% endif %} + {% if check.tags %} +
    + + + {% for tag in check.tags_list %} + + {% endfor %} + +
    + + + + +
    + {{ tag|mangle_link }} +
    +
    + {% endif %} +
    + {{ count }} outage{{ count|pluralize }} + +
    + ({{ seconds|hc_approx_duration }} total) +
    +
    + All good! +
    +
    \ No newline at end of file From 1de0ef16f6f15e1cbf580627639f046aa5eec058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Fri, 19 Jul 2019 17:47:47 +0300 Subject: [PATCH 20/65] Style tweaks. --- templates/emails/summary-downtimes-html.html | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/templates/emails/summary-downtimes-html.html b/templates/emails/summary-downtimes-html.html index 160fcf33..d735d5d0 100644 --- a/templates/emails/summary-downtimes-html.html +++ b/templates/emails/summary-downtimes-html.html @@ -66,13 +66,11 @@ {% if count %} {{ count }} outage{{ count|pluralize }} - -
    - ({{ seconds|hc_approx_duration }} total) -
    +
    + {{ seconds|hc_approx_duration }} total {% else %} - + All good! {% endif %} From cb2e763e98ee8906565580daa7c08edc6ac305cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Fri, 19 Jul 2019 19:42:37 +0300 Subject: [PATCH 21/65] Cleanup in `Check.outages_by_month()` and tests. --- hc/api/models.py | 41 +++++++----- hc/api/tests/test_check_model.py | 65 +++++++++++++++++++- templates/emails/summary-downtimes-html.html | 2 +- 3 files changed, 89 insertions(+), 19 deletions(-) diff --git a/hc/api/models.py b/hc/api/models.py index 52824ca0..aa5181ba 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -247,33 +247,40 @@ class Check(models.Model): ping.save() def outages_by_month(self, months=2): - now = timezone.now() + """ Calculate the number of outages and downtime minutes per month. + + Returns a list of (datetime, downtime_in_secs, number_of_outages) tuples. + + """ + def monthkey(dt): + return dt.year, dt.month + + # Will accumulate totals here. + # (year, month) -> [datetime, downtime_in_secs, number_of_outages] totals = {} + # Will collect flips and month boundaries here events = [] + for boundary in month_boundaries(months=months): - totals[(boundary.year, boundary.month)] = [boundary, 0, 0] + totals[monthkey(boundary)] = [boundary, 0, 0] events.append((boundary, "---")) - flips = self.flip_set.filter(created__gt=now - td(days=32 * months)) - for flip in flips: + for flip in self.flip_set.filter(created__gt=boundary): events.append((flip.created, flip.old_status)) - events.sort(reverse=True) - - needle, status = now, self.status - for dt, old_status in events: + # Iterate through flips and month boundaries in reverse order, + # and for each "down" event increase the counters in `totals`. + dt, status = timezone.now(), self.status + for prev_dt, prev_status in sorted(events, reverse=True): if status == "down": - if (dt.year, dt.month) not in totals: - break - - delta = needle - dt - totals[(dt.year, dt.month)][1] += int(delta.total_seconds()) - totals[(dt.year, dt.month)][2] += 1 + delta = dt - prev_dt + totals[monthkey(prev_dt)][1] += int(delta.total_seconds()) + totals[monthkey(prev_dt)][2] += 1 - needle = dt - if old_status != "---": - status = old_status + dt = prev_dt + if prev_status != "---": + status = prev_status flattened = list(totals.values()) flattened.sort(reverse=True) diff --git a/hc/api/tests/test_check_model.py b/hc/api/tests/test_check_model.py index fec533e5..3fea2eb8 100644 --- a/hc/api/tests/test_check_model.py +++ b/hc/api/tests/test_check_model.py @@ -1,8 +1,9 @@ from datetime import datetime, timedelta from django.utils import timezone -from hc.api.models import Check +from hc.api.models import Check, Flip from hc.test import BaseTestCase +from mock import patch class CheckModelTestCase(BaseTestCase): @@ -164,3 +165,65 @@ class CheckModelTestCase(BaseTestCase): d = check.to_dict() self.assertEqual(d["next_ping"], "2000-01-01T01:00:00+00:00") + + def test_outages_by_month_handles_no_flips(self): + check = Check.objects.create(project=self.project) + r = check.outages_by_month(10) + self.assertEqual(len(r), 10) + for dt, secs, outages in r: + self.assertEqual(secs, 0) + self.assertEqual(outages, 0) + + def test_outages_by_month_handles_currently_down_check(self): + check = Check.objects.create(project=self.project, status="down") + + r = check.outages_by_month(10) + self.assertEqual(len(r), 10) + for dt, secs, outages in r: + self.assertEqual(outages, 1) + + @patch("hc.api.models.timezone.now") + def test_outages_by_month_handles_flip_one_day_ago(self, mock_now): + mock_now.return_value = datetime(2019, 7, 19, tzinfo=timezone.utc) + + check = Check.objects.create(project=self.project, status="down") + flip = Flip(owner=check) + flip.created = datetime(2019, 7, 18, tzinfo=timezone.utc) + flip.old_status = "up" + flip.new_status = "down" + flip.save() + + r = check.outages_by_month(10) + self.assertEqual(len(r), 10) + for dt, secs, outages in r: + if dt.month == 7: + self.assertEqual(secs, 86400) + self.assertEqual(outages, 1) + else: + self.assertEqual(secs, 0) + self.assertEqual(outages, 0) + + @patch("hc.api.models.timezone.now") + def test_outages_by_month_handles_flip_two_months_ago(self, mock_now): + mock_now.return_value = datetime(2019, 7, 19, tzinfo=timezone.utc) + + check = Check.objects.create(project=self.project, status="down") + flip = Flip(owner=check) + flip.created = datetime(2019, 5, 19, tzinfo=timezone.utc) + flip.old_status = "up" + flip.new_status = "down" + flip.save() + + r = check.outages_by_month(10) + self.assertEqual(len(r), 10) + for dt, secs, outages in r: + if dt.month == 7: + self.assertEqual(outages, 1) + elif dt.month == 6: + self.assertEqual(secs, 30 * 86400) + self.assertEqual(outages, 1) + elif dt.month == 5: + self.assertEqual(outages, 1) + else: + self.assertEqual(secs, 0) + self.assertEqual(outages, 0) diff --git a/templates/emails/summary-downtimes-html.html b/templates/emails/summary-downtimes-html.html index d735d5d0..3b10b4cb 100644 --- a/templates/emails/summary-downtimes-html.html +++ b/templates/emails/summary-downtimes-html.html @@ -65,7 +65,7 @@ {% for boundary, seconds, count in check.outages_by_month %} {% if count %} - {{ count }} outage{{ count|pluralize }} + {{ count }} outage{{ count|pluralize }},
    {{ seconds|hc_approx_duration }} total From b7320b1b69a5f86f5804997310019a415b44aa6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sat, 20 Jul 2019 10:17:00 +0300 Subject: [PATCH 22/65] In monthly report, show months in ascending order. Cleanup. --- hc/api/models.py | 23 ++++++++++------------- hc/api/tests/test_check_model.py | 18 +++++++++--------- hc/lib/date.py | 10 ++++------ 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/hc/api/models.py b/hc/api/models.py index aa5181ba..d043dd00 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -256,17 +256,16 @@ class Check(models.Model): def monthkey(dt): return dt.year, dt.month - # Will accumulate totals here. - # (year, month) -> [datetime, downtime_in_secs, number_of_outages] - totals = {} - # Will collect flips and month boundaries here - events = [] + # Datetimes of the first days of months we're interested in. Ascending order. + boundaries = month_boundaries(months=months) - for boundary in month_boundaries(months=months): - totals[monthkey(boundary)] = [boundary, 0, 0] - events.append((boundary, "---")) + # Will accumulate totals here. + # (year, month) -> [datetime, total_downtime, number_of_outages] + totals = {monthkey(b): [b, td(), 0] for b in boundaries} - for flip in self.flip_set.filter(created__gt=boundary): + # A list of flips and month boundaries + events = [(b, "---") for b in boundaries] + for flip in self.flip_set.filter(created__gt=min(boundaries)): events.append((flip.created, flip.old_status)) # Iterate through flips and month boundaries in reverse order, @@ -275,16 +274,14 @@ class Check(models.Model): for prev_dt, prev_status in sorted(events, reverse=True): if status == "down": delta = dt - prev_dt - totals[monthkey(prev_dt)][1] += int(delta.total_seconds()) + totals[monthkey(prev_dt)][1] += delta totals[monthkey(prev_dt)][2] += 1 dt = prev_dt if prev_status != "---": status = prev_status - flattened = list(totals.values()) - flattened.sort(reverse=True) - return flattened + return sorted(totals.values()) class Ping(models.Model): diff --git a/hc/api/tests/test_check_model.py b/hc/api/tests/test_check_model.py index 3fea2eb8..b1315cf1 100644 --- a/hc/api/tests/test_check_model.py +++ b/hc/api/tests/test_check_model.py @@ -170,8 +170,8 @@ class CheckModelTestCase(BaseTestCase): check = Check.objects.create(project=self.project) r = check.outages_by_month(10) self.assertEqual(len(r), 10) - for dt, secs, outages in r: - self.assertEqual(secs, 0) + for dt, downtime, outages in r: + self.assertEqual(downtime.total_seconds(), 0) self.assertEqual(outages, 0) def test_outages_by_month_handles_currently_down_check(self): @@ -179,7 +179,7 @@ class CheckModelTestCase(BaseTestCase): r = check.outages_by_month(10) self.assertEqual(len(r), 10) - for dt, secs, outages in r: + for dt, downtime, outages in r: self.assertEqual(outages, 1) @patch("hc.api.models.timezone.now") @@ -195,12 +195,12 @@ class CheckModelTestCase(BaseTestCase): r = check.outages_by_month(10) self.assertEqual(len(r), 10) - for dt, secs, outages in r: + for dt, downtime, outages in r: if dt.month == 7: - self.assertEqual(secs, 86400) + self.assertEqual(downtime.total_seconds(), 86400) self.assertEqual(outages, 1) else: - self.assertEqual(secs, 0) + self.assertEqual(downtime.total_seconds(), 0) self.assertEqual(outages, 0) @patch("hc.api.models.timezone.now") @@ -216,14 +216,14 @@ class CheckModelTestCase(BaseTestCase): r = check.outages_by_month(10) self.assertEqual(len(r), 10) - for dt, secs, outages in r: + for dt, downtime, outages in r: if dt.month == 7: self.assertEqual(outages, 1) elif dt.month == 6: - self.assertEqual(secs, 30 * 86400) + self.assertEqual(downtime.total_seconds(), 30 * 86400) self.assertEqual(outages, 1) elif dt.month == 5: self.assertEqual(outages, 1) else: - self.assertEqual(secs, 0) + self.assertEqual(downtime.total_seconds(), 0) self.assertEqual(outages, 0) diff --git a/hc/lib/date.py b/hc/lib/date.py index 6c9ef704..a1a056d1 100644 --- a/hc/lib/date.py +++ b/hc/lib/date.py @@ -36,10 +36,7 @@ def format_duration(td): def format_hms(td): - if isinstance(td, int): - total_seconds = td - else: - total_seconds = int(td.total_seconds()) + total_seconds = int(td.total_seconds()) result = [] @@ -57,7 +54,8 @@ def format_hms(td): return " ".join(result) -def format_approx_duration(v): +def format_approx_duration(td): + v = td.total_seconds() for unit in (DAY, HOUR, MINUTE, SECOND): if v >= unit.nsecs: vv = v // unit.nsecs @@ -75,7 +73,7 @@ def month_boundaries(months=2): now = timezone.now() y, m = now.year, now.month for x in range(0, months): - result.append(dt(y, m, 1, tzinfo=timezone.utc)) + result.insert(0, dt(y, m, 1, tzinfo=timezone.utc)) m -= 1 if m == 0: From b2ebce6cf985ebbd5acfea87e9c67d76ab4dcc61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sat, 20 Jul 2019 11:42:16 +0300 Subject: [PATCH 23/65] Show the number of downtimes and total downtime minutes in "Check Details" page. --- CHANGELOG.md | 9 +++++---- hc/api/models.py | 4 ++-- hc/api/tests/test_check_model.py | 16 ++++++++-------- hc/front/views.py | 3 +++ static/css/details.css | 15 +++++++++++++++ static/js/details.js | 4 ++++ templates/emails/summary-downtimes-html.html | 4 ++-- templates/front/details.html | 11 ++++++++--- templates/front/details_downtimes.html | 16 ++++++++++++++++ 9 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 templates/front/details_downtimes.html diff --git a/CHANGELOG.md b/CHANGELOG.md index e93c2b2a..b2fc4171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ All notable changes to this project will be documented in this file. ## Unreleased ### Improvements -- Show the number of outages and total downtime in monthly reports. (#104) +- Show the number of downtimes and total downtime minutes in monthly reports (#104) +- Show the number of downtimes and total downtime minutes in "Check Details" page ## 1.8.0 - 2019-07-08 @@ -13,8 +14,8 @@ All notable changes to this project will be documented in this file. - Add the `prunetokenbucket` management command - Show check counts in JSON "badges" (#251) - Webhooks support HTTP PUT (#249) -- Webhooks can use different req. bodies and headers for "up" and "down" events. (#249) -- Show check's code instead of full URL on 992px - 1200px wide screens. (#253) +- Webhooks can use different req. bodies and headers for "up" and "down" events (#249) +- Show check's code instead of full URL on 992px - 1200px wide screens (#253) - Add WhatsApp integration (uses Twilio same as the SMS integration) - Webhooks support the $TAGS placeholder - Don't include ping URLs in API responses when the read-only key is used @@ -59,7 +60,7 @@ All notable changes to this project will be documented in this file. ### Improvements - Database schema: add uniqueness constraint to Check.code -- Database schema: add Ping.kind field. Remove "start" and "fail" fields. +- Database schema: add Ping.kind field. Remove "start" and "fail" fields - Add "Email Settings..." dialog and "Subject Must Contain" setting - Database schema: add the Project model - Move project-specific settings to a new "Project Settings" page diff --git a/hc/api/models.py b/hc/api/models.py index d043dd00..d3f648b6 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -246,8 +246,8 @@ class Check(models.Model): ping.body = body[:10000] ping.save() - def outages_by_month(self, months=2): - """ Calculate the number of outages and downtime minutes per month. + def downtimes(self, months=2): + """ Calculate the number of downtimes and downtime minutes per month. Returns a list of (datetime, downtime_in_secs, number_of_outages) tuples. diff --git a/hc/api/tests/test_check_model.py b/hc/api/tests/test_check_model.py index b1315cf1..8b74067b 100644 --- a/hc/api/tests/test_check_model.py +++ b/hc/api/tests/test_check_model.py @@ -166,24 +166,24 @@ class CheckModelTestCase(BaseTestCase): d = check.to_dict() self.assertEqual(d["next_ping"], "2000-01-01T01:00:00+00:00") - def test_outages_by_month_handles_no_flips(self): + def test_downtimes_handles_no_flips(self): check = Check.objects.create(project=self.project) - r = check.outages_by_month(10) + r = check.downtimes(10) self.assertEqual(len(r), 10) for dt, downtime, outages in r: self.assertEqual(downtime.total_seconds(), 0) self.assertEqual(outages, 0) - def test_outages_by_month_handles_currently_down_check(self): + def test_downtimes_handles_currently_down_check(self): check = Check.objects.create(project=self.project, status="down") - r = check.outages_by_month(10) + r = check.downtimes(10) self.assertEqual(len(r), 10) for dt, downtime, outages in r: self.assertEqual(outages, 1) @patch("hc.api.models.timezone.now") - def test_outages_by_month_handles_flip_one_day_ago(self, mock_now): + def test_downtimes_handles_flip_one_day_ago(self, mock_now): mock_now.return_value = datetime(2019, 7, 19, tzinfo=timezone.utc) check = Check.objects.create(project=self.project, status="down") @@ -193,7 +193,7 @@ class CheckModelTestCase(BaseTestCase): flip.new_status = "down" flip.save() - r = check.outages_by_month(10) + r = check.downtimes(10) self.assertEqual(len(r), 10) for dt, downtime, outages in r: if dt.month == 7: @@ -204,7 +204,7 @@ class CheckModelTestCase(BaseTestCase): self.assertEqual(outages, 0) @patch("hc.api.models.timezone.now") - def test_outages_by_month_handles_flip_two_months_ago(self, mock_now): + def test_downtimes_handles_flip_two_months_ago(self, mock_now): mock_now.return_value = datetime(2019, 7, 19, tzinfo=timezone.utc) check = Check.objects.create(project=self.project, status="down") @@ -214,7 +214,7 @@ class CheckModelTestCase(BaseTestCase): flip.new_status = "down" flip.save() - r = check.outages_by_month(10) + r = check.downtimes(10) self.assertEqual(len(r), 10) for dt, downtime, outages in r: if dt.month == 7: diff --git a/hc/front/views.py b/hc/front/views.py index 3f5649e0..398cc1db 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -58,6 +58,7 @@ VALID_SORT_VALUES = ("name", "-name", "last_ping", "-last_ping", "created") STATUS_TEXT_TMPL = get_template("front/log_status_text.html") LAST_PING_TMPL = get_template("front/last_ping_cell.html") EVENTS_TMPL = get_template("front/details_events.html") +DOWNTIMES_TMPL = get_template("front/details_downtimes.html") ONE_HOUR = td(hours=1) TWELVE_HOURS = td(hours=12) @@ -474,6 +475,7 @@ def details(request, code): "check": check, "channels": channels, "timezones": pytz.all_timezones, + "downtimes": check.downtimes(months=3), } return render(request, "front/details.html", ctx) @@ -523,6 +525,7 @@ def status_single(request, code): if updated != request.GET.get("u"): doc["events"] = EVENTS_TMPL.render({"check": check, "events": events}) + doc["downtimes"] = DOWNTIMES_TMPL.render({"downtimes": check.downtimes(3)}) return JsonResponse(doc) diff --git a/static/css/details.css b/static/css/details.css index 030a833c..188953bc 100644 --- a/static/css/details.css +++ b/static/css/details.css @@ -80,3 +80,18 @@ color: #d43f3a; background: #FFF; } + +#downtimes table { + width: 350px; +} + +#downtimes tr:first-child td, #downtimes tr:first-child th { + border-top: 0; +} + +#downtimes th { + width: 100px; + text-align: right; + font-weight: normal; + color: #888; +} diff --git a/static/js/details.js b/static/js/details.js index b92192f2..2a0628ea 100644 --- a/static/js/details.js +++ b/static/js/details.js @@ -66,6 +66,10 @@ $(function () { switchDateFormat(lastFormat); } + if (data.downtimes) { + $("#downtimes").html(data.downtimes); + } + if (document.title != data.title) { document.title = data.title; } diff --git a/templates/emails/summary-downtimes-html.html b/templates/emails/summary-downtimes-html.html index 3b10b4cb..c653fd16 100644 --- a/templates/emails/summary-downtimes-html.html +++ b/templates/emails/summary-downtimes-html.html @@ -62,10 +62,10 @@ {% endif %} - {% for boundary, seconds, count in check.outages_by_month %} + {% for boundary, seconds, count in check.downtimes %} {% if count %} - {{ count }} outage{{ count|pluralize }}, + {{ count }} downtime{{ count|pluralize }},
    {{ seconds|hc_approx_duration }} total diff --git a/templates/front/details.html b/templates/front/details.html index 44ef2ef9..34042088 100644 --- a/templates/front/details.html +++ b/templates/front/details.html @@ -87,8 +87,14 @@ - - {% include "front/log_status_text.html" %} + +

    {% include "front/log_status_text.html" %}

    + + + + + + {% include "front/details_downtimes.html" %} @@ -196,7 +202,6 @@ -

    Log diff --git a/templates/front/details_downtimes.html b/templates/front/details_downtimes.html new file mode 100644 index 00000000..bc614007 --- /dev/null +++ b/templates/front/details_downtimes.html @@ -0,0 +1,16 @@ +{% load hc_extras %} + + {% for boundary, seconds, count in downtimes reversed %} + + + + + {% endfor %} +
    {{ boundary|date:"N Y"}} + {% if count %} + {{ count }} downtime{{ count|pluralize }}, + {{ seconds|hc_approx_duration }} total + {% else %} + All good! + {% endif %} +
    \ No newline at end of file From b37d908879e80b81616c3e9a9f92d968d5c327de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sat, 20 Jul 2019 12:17:00 +0300 Subject: [PATCH 24/65] Optimization: don't instantiate Flip objects in Check.downtimes() --- hc/api/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hc/api/models.py b/hc/api/models.py index d3f648b6..d7e11e39 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -265,8 +265,9 @@ class Check(models.Model): # A list of flips and month boundaries events = [(b, "---") for b in boundaries] - for flip in self.flip_set.filter(created__gt=min(boundaries)): - events.append((flip.created, flip.old_status)) + q = self.flip_set.filter(created__gt=min(boundaries)) + for pair in q.values_list("created", "old_status"): + events.append(pair) # Iterate through flips and month boundaries in reverse order, # and for each "down" event increase the counters in `totals`. From c0d808271e04a740c9ee2d088048fb055228b168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sat, 20 Jul 2019 12:25:58 +0300 Subject: [PATCH 25/65] Add the `pruneflips` management command. --- CHANGELOG.md | 1 + README.md | 11 ++++++++++- hc/api/management/commands/pruneflips.py | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 hc/api/management/commands/pruneflips.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b2fc4171..2573badc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Improvements - Show the number of downtimes and total downtime minutes in monthly reports (#104) - Show the number of downtimes and total downtime minutes in "Check Details" page +- Add the `pruneflips` management command ## 1.8.0 - 2019-07-08 diff --git a/README.md b/README.md index bac79895..cd92fd6a 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ There are separate Django management commands for each task: $ ./manage.py pruneusers ``` -* Remove old records fromt he `api_tokenbucket` table. The TokenBucket +* Remove old records from the `api_tokenbucket` table. The TokenBucket model is used for rate-limiting login attempts and similar operations. Any records older than one day can be safely removed. @@ -277,6 +277,15 @@ There are separate Django management commands for each task: $ ./manage.py prunetokenbucket ``` +* Remove old records from the `api_flip` table. The Flip + objects are used to track status changes of checks, and to calculate + downtime statistics month by month. Flip objects from more than 3 months + ago are not used and can be safely removed. + + ``` + $ ./manage.py pruneflips + ``` + When you first try these commands on your data, it is a good idea to test them on a copy of your database, not on the live database right away. In a production setup, you should also have regular, automated database diff --git a/hc/api/management/commands/pruneflips.py b/hc/api/management/commands/pruneflips.py new file mode 100644 index 00000000..faa45963 --- /dev/null +++ b/hc/api/management/commands/pruneflips.py @@ -0,0 +1,16 @@ +from django.core.management.base import BaseCommand + +from hc.api.models import Flip +from hc.lib.date import month_boundaries + + +class Command(BaseCommand): + help = "Prune old Flip objects." + + def handle(self, *args, **options): + threshold = min(month_boundaries(months=3)) + + q = Flip.objects.filter(created__lt=threshold) + n_pruned, _ = q.delete() + + return "Done! Pruned %d flips." % n_pruned From 033d0ab197d0c031e060f36ed97409966fb3deaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sat, 20 Jul 2019 16:58:41 +0300 Subject: [PATCH 26/65] Partial indexes for api_check.alert_after and api_flip.processed fields. --- hc/api/migrations/0062_auto_20190720_1350.py | 37 ++++++++++++++++++++ hc/api/models.py | 24 ++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 hc/api/migrations/0062_auto_20190720_1350.py diff --git a/hc/api/migrations/0062_auto_20190720_1350.py b/hc/api/migrations/0062_auto_20190720_1350.py new file mode 100644 index 00000000..c4e428bb --- /dev/null +++ b/hc/api/migrations/0062_auto_20190720_1350.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.3 on 2019-07-20 13:50 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0061_webhook_values'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='kind', + field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('pagerteam', 'Pager Team'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello'), ('matrix', 'Matrix'), ('whatsapp', 'WhatsApp')], max_length=20), + ), + migrations.AlterField( + model_name='flip', + name='processed', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='tokenbucket', + name='updated', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddIndex( + model_name='check', + index=models.Index(condition=models.Q(_negated=True, status='down'), fields=['alert_after'], name='api_check_aa_not_down'), + ), + migrations.AddIndex( + model_name='flip', + index=models.Index(condition=models.Q(processed=None), fields=['processed'], name='api_flip_not_processed'), + ), + ] diff --git a/hc/api/models.py b/hc/api/models.py index d7e11e39..32313aa3 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -74,6 +74,17 @@ class Check(models.Model): alert_after = models.DateTimeField(null=True, blank=True, editable=False) status = models.CharField(max_length=6, choices=STATUSES, default="new") + class Meta: + indexes = [ + # Index for the alert_after field. Excludes rows with status=down. + # Used in the sendalerts management command. + models.Index( + fields=["alert_after"], + name="api_check_aa_not_down", + condition=~models.Q(status="down"), + ) + ] + def __str__(self): return "%s (%d)" % (self.name or self.code, self.id) @@ -640,10 +651,21 @@ class Notification(models.Model): class Flip(models.Model): owner = models.ForeignKey(Check, models.CASCADE) created = models.DateTimeField() - processed = models.DateTimeField(null=True, blank=True, db_index=True) + processed = models.DateTimeField(null=True, blank=True) old_status = models.CharField(max_length=8, choices=STATUSES) new_status = models.CharField(max_length=8, choices=STATUSES) + class Meta: + indexes = [ + # For quickly looking up unprocessed flips. + # Used in the sendalerts management command. + models.Index( + fields=["processed"], + name="api_flip_not_processed", + condition=models.Q(processed=None), + ) + ] + def send_alerts(self): if self.new_status == "up" and self.old_status in ("new", "paused"): # Don't send alerts on new->up and paused->up transitions From d39a1d59558742aea52bb8ece30352c11ed83bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Tue, 6 Aug 2019 10:36:12 +0300 Subject: [PATCH 27/65] Django 2.2.4 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index caf73be4..88ba406c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ croniter==0.3.30 -Django==2.2.3 +Django==2.2.4 django_compressor==2.2 psycopg2==2.7.5 pytz==2019.1 From c2b1d00422db971d4d1f758ef4191b65adfb1c9d Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Wed, 7 Aug 2019 19:10:47 -0400 Subject: [PATCH 28/65] Apprise Integration --- hc/api/models.py | 3 ++ hc/api/transports.py | 17 +++++- hc/front/forms.py | 5 ++ hc/front/tests/test_add_apprise.py | 21 ++++++++ hc/front/urls.py | 1 + hc/front/views.py | 24 +++++++++ hc/settings.py | 1 + requirements.txt | 1 + static/img/integrations/apprise.png | Bin 0 -> 28702 bytes templates/front/channels.html | 11 ++++ templates/front/welcome.html | 6 +++ templates/integrations/add_apprise.html | 49 ++++++++++++++++++ .../integrations/apprise_description.html | 5 ++ templates/integrations/apprise_title.html | 1 + 14 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 hc/front/tests/test_add_apprise.py create mode 100644 static/img/integrations/apprise.png create mode 100644 templates/integrations/add_apprise.html create mode 100644 templates/integrations/apprise_description.html create mode 100644 templates/integrations/apprise_title.html 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/transports.py b/hc/api/transports.py index 9475a89f..41e54ced 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -3,6 +3,7 @@ from django.template.loader import render_to_string from django.utils import timezone import json import requests +import apprise from urllib.parse import quote, urlencode from hc.accounts.models import Profile @@ -273,7 +274,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 +462,17 @@ class Trello(HttpTransport): } return self.post(self.URL, params=params) + +class Apprise(HttpTransport): + def notify(self, check): + 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..cd1cb30b --- /dev/null +++ b/hc/front/tests/test_add_apprise.py @@ -0,0 +1,21 @@ +from hc.api.models import Channel +from hc.test import BaseTestCase + + +class AddSlackTestCase(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) 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..c02025d1 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 @@ -1325,6 +1326,29 @@ def add_matrix(request): return render(request, "integrations/add_matrix.html", ctx) +@login_required +def add_apprise(request): + 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..6f31a3bf 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -204,6 +204,7 @@ MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER") MATRIX_USER_ID = os.getenv("MATRIX_USER_ID") MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN") + if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")): from .local_settings import * else: diff --git a/requirements.txt b/requirements.txt index 88ba406c..027adb1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ django_compressor==2.2 psycopg2==2.7.5 pytz==2019.1 requests==2.22.0 +apprise==0.7.9 diff --git a/static/img/integrations/apprise.png b/static/img/integrations/apprise.png new file mode 100644 index 0000000000000000000000000000000000000000..46533dc8cdde139593ccb40422bcf00072aebba6 GIT binary patch literal 28702 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 Integration
    +
  • + Pushover icon + +

    Apprise

    +

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

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

    WhatsApp
    Chat

    +
    +
    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 }} From b5a03369b6a28f0094c9779793e7fb7bfec35a6f Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 8 Aug 2019 20:28:54 -0400 Subject: [PATCH 29/65] Apprise Notifications are now a controlled via settings --- hc/api/transports.py | 12 +++++++++++- hc/front/tests/test_add_apprise.py | 10 +++++++++- hc/front/views.py | 5 +++++ hc/settings.py | 3 +++ requirements.txt | 1 - templates/front/channels.html | 2 ++ templates/front/welcome.html | 4 +++- 7 files changed, 33 insertions(+), 4 deletions(-) diff --git a/hc/api/transports.py b/hc/api/transports.py index 41e54ced..b08d52c4 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -3,12 +3,17 @@ from django.template.loader import render_to_string from django.utils import timezone import json import requests -import apprise 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 @@ -465,6 +470,11 @@ class Trello(HttpTransport): 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) diff --git a/hc/front/tests/test_add_apprise.py b/hc/front/tests/test_add_apprise.py index cd1cb30b..cc46383a 100644 --- a/hc/front/tests/test_add_apprise.py +++ b/hc/front/tests/test_add_apprise.py @@ -1,8 +1,10 @@ from hc.api.models import Channel from hc.test import BaseTestCase +from django.test.utils import override_settings -class AddSlackTestCase(BaseTestCase): +@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/") @@ -19,3 +21,9 @@ class AddSlackTestCase(BaseTestCase): 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/views.py b/hc/front/views.py index c02025d1..e391f875 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -237,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, } @@ -611,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, } @@ -1328,6 +1330,9 @@ def add_matrix(request): @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(): diff --git a/hc/settings.py b/hc/settings.py index 6f31a3bf..6fcc5c99 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -204,6 +204,9 @@ 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 * diff --git a/requirements.txt b/requirements.txt index 027adb1a..88ba406c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,3 @@ django_compressor==2.2 psycopg2==2.7.5 pytz==2019.1 requests==2.22.0 -apprise==0.7.9 diff --git a/templates/front/channels.html b/templates/front/channels.html index 5f398e6f..27aebc91 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -213,6 +213,7 @@ Add Integration + {% if enable_apprise %}
  • Pushover icon @@ -222,6 +223,7 @@ Add Integration
  • + {% endif %} {% if enable_pushover %}
  • {% endif %} + {% if enable_apprise %}
    Apprise icon -

    WhatsApp
    Chat

    +

    Apprise
    >Push Notifications

    + {% endif %}
    From 86ad70f6d53ff9091e669b8b347586fd5cf61fec Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 8 Aug 2019 21:09:18 -0400 Subject: [PATCH 30/65] improved testing --- .travis.yml | 2 +- hc/api/tests/test_notify.py | 41 ++++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) 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/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 From d70539b397a0aac798b130accc377a922205e017 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 8 Aug 2019 21:58:30 -0400 Subject: [PATCH 31/65] updated apprise documentation --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) 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. From a99a00949172becbd402422c67b554cbb0861c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sun, 11 Aug 2019 23:46:27 +0300 Subject: [PATCH 32/65] Fix typo. --- templates/front/welcome.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/front/welcome.html b/templates/front/welcome.html index 049efa09..01c6ca84 100644 --- a/templates/front/welcome.html +++ b/templates/front/welcome.html @@ -129,7 +129,7 @@

    {% site_name %} monitors the heartbeat messages sent by your cron jobs, services and APIs. - Get immediate alerts you when they don't arrive on schedule.

    + Get immediate alerts when they don't arrive on schedule. Sign Up – It's Free From f1d7b4b39be8114f84f2cf5753ec2790f99a42c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Mon, 12 Aug 2019 12:09:57 +0300 Subject: [PATCH 33/65] Fix alt text for the apprise icon. --- templates/front/channels.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/front/channels.html b/templates/front/channels.html index 27aebc91..efc57a4e 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -216,7 +216,7 @@ {% if enable_apprise %}
  • Pushover icon + class="icon" alt="Apprise icon" />

    Apprise

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

    From 554f76e57a66b30528f552c5f5383b4e6843c536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Mon, 12 Aug 2019 13:47:14 +0300 Subject: [PATCH 34/65] Icon for Apprise. --- static/css/icomoon.css | 17 +++++++++-------- static/fonts/icomoon.eot | Bin 11256 -> 11508 bytes static/fonts/icomoon.svg | 2 +- static/fonts/icomoon.ttf | Bin 11092 -> 11344 bytes static/fonts/icomoon.woff | Bin 11168 -> 11420 bytes 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/static/css/icomoon.css b/static/css/icomoon.css index 4cb986da..bd46e685 100644 --- a/static/css/icomoon.css +++ b/static/css/icomoon.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('../fonts/icomoon.eot?m3lvqd'); - src: url('../fonts/icomoon.eot?m3lvqd#iefix') format('embedded-opentype'), - url('../fonts/icomoon.ttf?m3lvqd') format('truetype'), - url('../fonts/icomoon.woff?m3lvqd') format('woff'), - url('../fonts/icomoon.svg?m3lvqd#icomoon') format('svg'); + src: url('../fonts/icomoon.eot?iewxyq'); + src: url('../fonts/icomoon.eot?iewxyq#iefix') format('embedded-opentype'), + url('../fonts/icomoon.ttf?iewxyq') format('truetype'), + url('../fonts/icomoon.woff?iewxyq') format('woff'), + url('../fonts/icomoon.svg?iewxyq#icomoon') format('svg'); font-weight: normal; font-style: normal; } @@ -28,6 +28,10 @@ content: "\e914"; color: #cd2a00; } +.icon-apprise:before { + content: "\e915"; + color: #236b6b; +} .icon-matrix:before { content: "\e900"; } @@ -94,9 +98,6 @@ content: "\e902"; color: #25d366; } -.icon-zendesk:before { - content: "\e907"; -} .icon-clippy:before { content: "\e903"; } diff --git a/static/fonts/icomoon.eot b/static/fonts/icomoon.eot index 30bd965bbff52cc3346fb60b065c7dab6174b713..6ed5c24ac04e5efcb4059f07c74bab4f654ec4f5 100644 GIT binary patch delta 810 zcmY+BT}V@57{{OYoU?PzoStp%|)4FIr)`%2$vX5vA2+6h1$$SZ@H$q9@mLYfaWy`SqR31Z8wkmIMc z8Yg~_1_21TH_2wxmSxMnE$yuDy!-fVccj1;Y{g&2qSgU@_|N}}J{0(zn{u$CfK5m) zNjX+8QjdkzS@XO&rvs{(XWHmj_(T<)gbdsO&;^MSB#p6E)7!p=lme0tNT-M_ZMmn`LAVre~YoaQA+Q3B_+GvV-Yov-aj*4jW!pqvm za^bQu{sjz-roe~k&-eeu;ximh9+?M9Q*mJ-oiFAAnDZJkZMl^1TJEJU!k|FqwtYbd j-^#zxPnBd1@v4^n%m(rgFfgc1$w*C15xLUTz`&pp0+ct)019xhvFHK$JwU!n zMs7((>pb=jAb$gpKOrYSIkB;+Y#9TCrUQ`gm77>mz+kG!_yH&Y(x8x+n43D&i}N>- zzXoVYTS0zt2?H}whC$l|B+tOi{BCjzqdlX@eG`?FF*hOBIl*Z|G)qL162X_K43tR{RWg}0LwD|f2_bFe@^C$%qQt5QjSs< z5@OCB2HWHU(6c - @@ -39,4 +38,5 @@ + \ No newline at end of file diff --git a/static/fonts/icomoon.ttf b/static/fonts/icomoon.ttf index 7c658b4b6e957544ce79e8c5ca17de9db7b84eab..ef3b55cdacc184f9c91eabafa34727f19051a8bc 100644 GIT binary patch delta 764 zcmYL{Ur19?9LLYO_wL@i-R<7ZZTFhaZEm~wZd2#}b?>O<+#2MhGBNuDQ4KT2oQ^ax ziZ%#q_+*ZCcRt_WpK}i9_x{M+ zV7mh!XFqN% zWDvgwK&}iA=Fjl`E%N{b4k5oXj0Bn?;)pAVeZz&x>6r~`7V#6rbED$}>HJLPQW=0? z5((Xf{B#lQgqw)91ov3JFnFh1{~PfmbjY>h_{1c-C>pI{%@1&V^TtvN+UMbJQOj_m z%i(}Uk8-cTmBgxbmH#5HEw8=%`g47>M%L`rlhBFP=;~$YAO#l1!s>lkUwsCt#pm!g zyZFIe7D_sRZ7mTw{3WW^1onbFxB!4I1mzHns9|2X zzeV6RhZZVJvP4o^1|>sDK_LoKP$^1-?I^9FEGe=gQBrAJN1_)#Xq1h{=D94-?Wxz> znLq2P!v??Im3C3h%#_?E3T?JvYs&$*%gUr|k|gbyM9Fm28I8vV!;w~3(v4%AZBe}$ z!-RUl8zdKb@06G^Rpxc zmvtj4drnM@B@8$g=qRTwjpjyCIv$Iu9*;AVPG8NWGf|A9AY?hN!6KR=RJlxpSC(^b zmyKshG66}JO9oqFos|%V!**M$DY}oL;snl_Rj*HM$fEz#ni2R=`+dGwJH;h`0EW;M z@M-G%&Hr}mF_EiY@$aB|G!5>2s!93`l)`>P+^j$U+g+}!s51! RSiaTSthDvj;%wjz{0l#+o%sL& delta 519 zcmcZ*aV4ytfsuiMft#U$ftkU;KUm+0Ux-ZyD6$8L6OwZi3##Uqtz=+elmYVRq$d^^ z0BHdre+Q7}NYANE+fei}8^}Mvz@RoIBQ-Hawh2g{ftmT;!~^z>B9j=cl^cK}GC;>MGw=XCrSO39fyHCt$6`-Z zpPqbr`T6%3IWI*fCosxQZeWy|JdaUv^FhWLeDy3qP3kNR%=!#W41x?I3@Qu^s)~$^ ziYDfEjOJ#>M&fde;$o_Tii#rae2naD%1Y{bjOuEpii|h^sWaYWQvateEaafAV`!+Y z?I0u!68n1-S%mSXI+OL^qabNvAt7N0ZS6Ts)r9!F+ZCA)JHE14Xy{(zfC3Brrk;F}o|n@aV-e zz<7k@qM|pOcT%~b>}iBNOW=trvh+1BB85E9);meBT(Y&=qs(A+`|}w)7lZX@LVT z{`+fG3xsN!A{M#jdgH86P$6QwOl8QY(60_1Mp<+oA(bD?ejJPiEP{n*I4p~P9E(H+ z8y0AZi9xmn$D*;_hhR|>idB|miDr}vM#fSUEKwlPicx|+m(oy{qH=ZglfU*I_`0sUEyG;x6Z1ras?<2bE3`MKzK%XT9nQV+ODM9QXzt2Fy{37 z8lz~yIHph4xMVrwu=5sq1AgOoSr!hM3DO)}-MCcP=h0 zp1iwTIi*j0Ls|+bLT{(P-1Im2CB>mFbDa zKrtp3AfE$@1=4dW(|}?;3=C>hKzKvZ%j}HQ#1sYwjRin8W*{tbrKuqUCB*;;pMQUm^HOB;8b-OvXBcHzfQlG_0V1=RhiN8XJ;>YYKrwv= zCI&$U5s)`j6&V>7P0Z~W&CQIB#N`;p#Z(0q6-C(j7}?pBmDKeZ)zwTD8E^hmXS~Uz z{!d+4$U$4j&`?|3K}Z-R_V*^T2;)t4ChNaPLDIrPLc$K(+H;t!fjmbcVPPRhZ61`MK)zvAQhZN4&agB-@daAT)DFcM(&|J5um%m;v64xnox0H}}oz<;0^ zizi5c#bxp{&1_HrO%Bj9ncShJC&S*w(Z%tBa~szw?m9jjel7laf=PmFgiM6OguOOj I(mKTm0C@m`&j0`b From dde2910c597bf1459bda710e17a138455eca8078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Mon, 12 Aug 2019 14:41:50 +0300 Subject: [PATCH 35/65] Cleanup. --- hc/api/tests/test_notify.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index a1ecd31e..ac37e926 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -668,10 +668,6 @@ class NotifyTestCase(BaseTestCase): def test_not_implimented(self): self._setup_data("webhook", "http://example") self.channel.kind = "invalid" - try: + + with self.assertRaises(NotImplementedError): self.channel.notify(self.check) - # Code should not reach here - assert False - except NotImplementedError: - # We expect to be here - assert True From 4c39aeea83dfa64b5b89e3b1c39e500d3c435057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Mon, 12 Aug 2019 16:10:49 +0300 Subject: [PATCH 36/65] Make sure account limits are reset when user cancels their subscription. --- hc/payments/tests/test_set_plan.py | 27 ++++++++++++++++++++++++++- hc/payments/views.py | 16 +++++++++------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/hc/payments/tests/test_set_plan.py b/hc/payments/tests/test_set_plan.py index 50709478..f5fbd696 100644 --- a/hc/payments/tests/test_set_plan.py +++ b/hc/payments/tests/test_set_plan.py @@ -150,11 +150,36 @@ class SetPlanTestCase(BaseTestCase): @patch("hc.payments.models.braintree") def test_subscription_creation_failure(self, mock): - self._setup_mock(mock) + mock.Subscription.create.return_value.is_success = False + mock.Subscription.create.return_value.message = "sub failure" + r = self.run_set_plan() + self.assertRedirects(r, "/accounts/profile/billing/") + self.assertContains(r, "sub failure") + + @patch("hc.payments.models.braintree") + def test_failed_plan_change_resets_limits(self, mock): + # Initial state: the user has a subscription and a high check limit: + sub = Subscription.objects.for_user(self.alice) + sub.subscription_id = "old-sub-id" + sub.save() + + self.profile.check_limit = 1000 + self.profile.save() + + # Simulate a subscription creation failure: mock.Subscription.create.return_value.is_success = False mock.Subscription.create.return_value.message = "sub failure" r = self.run_set_plan() + + # It should cancel the current plan + self.assertTrue(mock.Subscription.cancel.called) + + # It should clear out the limits: + self.profile.refresh_from_db() + self.assertEqual(self.profile.check_limit, 20) + + # And it should show the error message from API: self.assertRedirects(r, "/accounts/profile/billing/") self.assertContains(r, "sub failure") diff --git a/hc/payments/views.py b/hc/payments/views.py index aa239ef8..85e5e993 100644 --- a/hc/payments/views.py +++ b/hc/payments/views.py @@ -105,15 +105,17 @@ def set_plan(request): if sub.plan_id == plan_id: return redirect("hc-billing") - # Cancel the previous plan + # Cancel the previous plan and reset limits: sub.cancel() + + profile = request.user.profile + profile.ping_log_limit = 100 + profile.check_limit = 20 + profile.team_limit = 2 + profile.sms_limit = 0 + profile.save() + if plan_id == "": - profile = request.user.profile - profile.ping_log_limit = 100 - profile.check_limit = 20 - profile.team_limit = 2 - profile.sms_limit = 0 - profile.save() return redirect("hc-billing") result = sub.setup(plan_id) From 72d608902d66b5b6e2361a466baf9c9c538b8419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Mon, 12 Aug 2019 23:29:32 +0300 Subject: [PATCH 37/65] Fix JS to construct correct URLs when running from a subdirectory. Fixes #273 --- CHANGELOG.md | 3 + hc/front/urls.py | 2 +- static/js/add_trello.js | 3 +- static/js/checks.js | 13 +- static/js/collapse-native.js | 204 ------------------------- static/js/details.js | 3 +- static/js/signup.js | 3 +- static/js/tab-native.js | 117 -------------- static/js/update-timeout-modal.js | 34 +---- templates/base.html | 2 +- templates/front/details.html | 1 + templates/front/my_checks_desktop.html | 2 +- 12 files changed, 24 insertions(+), 363 deletions(-) delete mode 100644 static/js/collapse-native.js delete mode 100644 static/js/tab-native.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2573badc..2daaa5dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to this project will be documented in this file. - Show the number of downtimes and total downtime minutes in "Check Details" page - Add the `pruneflips` management command +## Bug Fixes +- Fix javascript code to construct correct URLs when running from a subdirectory (#273) + ## 1.8.0 - 2019-07-08 diff --git a/hc/front/urls.py b/hc/front/urls.py index e23ba45a..a3c98d5e 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -10,7 +10,7 @@ check_urls = [ path("pause/", views.pause, name="hc-pause"), path("remove/", views.remove_check, name="hc-remove-check"), path("log/", views.log, name="hc-log"), - path("status/", views.status_single), + path("status/", views.status_single, name="hc-status-single"), path("last_ping/", views.ping_details, name="hc-last-ping"), path("transfer/", views.transfer, name="hc-transfer"), path( diff --git a/static/js/add_trello.js b/static/js/add_trello.js index 9ebbdf88..258bcadd 100644 --- a/static/js/add_trello.js +++ b/static/js/add_trello.js @@ -15,9 +15,10 @@ $(function() { $("integration-settings").text("Loading..."); token = tokenMatch[1]; + var base = document.getElementById("base-url").getAttribute("href").slice(0, -1); var csrf = $('input[name=csrfmiddlewaretoken]').val(); $.ajax({ - url: "/integrations/add_trello/settings/", + url: base + "/integrations/add_trello/settings/", type: "post", headers: {"X-CSRFToken": csrf}, data: {token: token}, diff --git a/static/js/checks.js b/static/js/checks.js index 4e7b96a0..ad77a96d 100644 --- a/static/js/checks.js +++ b/static/js/checks.js @@ -1,8 +1,9 @@ $(function () { + var base = document.getElementById("base-url").getAttribute("href").slice(0, -1); $(".my-checks-name").click(function() { var code = $(this).closest("tr.checks-row").attr("id"); - var url = "/checks/" + code + "/name/"; + var url = base + "/checks/" + code + "/name/"; $("#update-name-form").attr("action", url); $("#update-name-input").val(this.dataset.name); @@ -31,7 +32,7 @@ $(function () { var checkCode = $(this).closest("tr.checks-row").attr("id"); var channelCode = $("#ch-" + idx).data("code"); - var url = "/checks/" + checkCode + "/channels/" + channelCode + "/enabled"; + var url = base + "/checks/" + checkCode + "/channels/" + channelCode + "/enabled"; $.ajax({ url: url, @@ -52,12 +53,12 @@ $(function () { $('#ping-details-modal').modal("show"); var code = $(this).closest("tr.checks-row").attr("id"); - var lastPingUrl = "/checks/" + code + "/last_ping/"; + var lastPingUrl = base + "/checks/" + code + "/last_ping/"; $.get(lastPingUrl, function(data) { $("#ping-details-body" ).html(data); }); - var logUrl = "/checks/" + code + "/log/"; + var logUrl = base + "/checks/" + code + "/log/"; $("#ping-details-log").attr("href", logUrl); return false; @@ -79,7 +80,7 @@ $(function () { // Update hash if (window.history && window.history.replaceState) { - var url = $("#checks-table").data("list-url");; + var url = $("#checks-table").data("list-url"); if (qs.length) { url += "?" + $.param(qs); } @@ -132,7 +133,7 @@ $(function () { $(".show-log").click(function(e) { var code = $(this).closest("tr.checks-row").attr("id"); - var url = "/checks/" + code + "/details/"; + var url = base + "/checks/" + code + "/details/"; window.location = url; return false; }); diff --git a/static/js/collapse-native.js b/static/js/collapse-native.js deleted file mode 100644 index 2da5eee2..00000000 --- a/static/js/collapse-native.js +++ /dev/null @@ -1,204 +0,0 @@ -// Native Javascript for Bootstrap 3 | Collapse -// by dnp_theme - -(function(factory){ - - // CommonJS/RequireJS and "native" compatibility - if(typeof module !== "undefined" && typeof exports == "object") { - // A commonJS/RequireJS environment - if(typeof window != "undefined") { - // Window and document exist, so return the factory's return value. - module.exports = factory(); - } else { - // Let the user give the factory a Window and Document. - module.exports = factory; - } - } else { - // Assume a traditional browser. - window.Collapse = factory(); - } - -})(function(){ - - // COLLAPSE DEFINITION - // =================== - var Collapse = function( element, options ) { - options = options || {}; - - this.btn = typeof element === 'object' ? element : document.querySelector(element); - this.accordion = null; - this.collapse = null; - this.duration = 300; // default collapse transition duration - this.options = {}; - this.options.duration = /ie/.test(document.documentElement.className) ? 0 : (options.duration || this.duration); - this.init(); - } - - // COLLAPSE METHODS - // ================ - Collapse.prototype = { - - init : function() { - this.actions(); - this.btn.addEventListener('click', this.toggle, false); - - // allows the collapse to expand - // ** when window gets resized - // ** or via internal clicks handers such as dropwowns or any other - document.addEventListener('click', this.update, false); - window.addEventListener('resize', this.update, false) - }, - - actions : function() { - var self = this; - - this.toggle = function(e) { - self.btn = self.getTarget(e).btn; - self.collapse = self.getTarget(e).collapse; - - if (!/in/.test(self.collapse.className)) { - self.open(e) - } else { - self.close(e) - } - }, - this.close = function(e) { - e.preventDefault(); - self.btn = self.getTarget(e).btn; - self.collapse = self.getTarget(e).collapse; - self._close(self.collapse); - self.btn.className = self.btn.className.replace(' collapsed',''); - }, - this.open = function(e) { - e.preventDefault(); - self.btn = self.getTarget(e).btn; - self.collapse = self.getTarget(e).collapse; - self.accordion = self.btn.getAttribute('data-parent') && self.getClosest(self.btn, self.btn.getAttribute('data-parent')); - - self._open(self.collapse); - self.btn.className += ' collapsed'; - - if ( self.accordion !== null ) { - var active = self.accordion.querySelectorAll('.collapse.in'), al = active.length, i = 0; - for (i;i 1 ) { - return activeTabs[activeTabs.length-1] - } - }, - this.getActiveContent = function() { - var a = self.getActiveTab().getElementsByTagName('A')[0].getAttribute('href').replace('#',''); - return a && document.getElementById(a) - } - } - } - - - // TAB DATA API - // ================= - var Tabs = document.querySelectorAll("[data-toggle='tab'], [data-toggle='pill']"), tbl = Tabs.length, i=0; - for ( i;iUpdating...

    "); - $("#schedule").val(schedule); - $("#tz").selectpicker("val", tz); - var minutes = parseInt(grace / 60); - $("#update-timeout-grace-cron").val(minutes); - updateCronPreview(); - - kind == "simple" ? showSimple() : showCron(); - $('#update-timeout-modal').modal({"show":true, "backdrop":"static"}); - return false; - - } - - var MINUTE = {name: "minute", nsecs: 60}; var HOUR = {name: "hour", nsecs: MINUTE.nsecs * 60}; var DAY = {name: "day", nsecs: HOUR.nsecs * 24}; @@ -166,7 +142,7 @@ $(function () { var token = $('input[name=csrfmiddlewaretoken]').val(); $.ajax({ - url: "/checks/cron_preview/", + url: base + "/checks/cron_preview/", type: "post", headers: {"X-CSRFToken": token}, data: {schedule: schedule, tz: tz}, diff --git a/templates/base.html b/templates/base.html index 899ee573..460bcc85 100644 --- a/templates/base.html +++ b/templates/base.html @@ -67,7 +67,7 @@ - + {% if request.user.is_authenticated and project %} {{ project }} diff --git a/templates/front/details.html b/templates/front/details.html index 34042088..f78cfc00 100644 --- a/templates/front/details.html +++ b/templates/front/details.html @@ -153,6 +153,7 @@ id="edit-timeout" class="btn btn-sm btn-default timeout-grace" data-code="{{ check.code }}" + data-status-url="{% url 'hc-status-single' check.code %}" data-kind="{{ check.kind }}" data-timeout="{{ check.timeout.total_seconds }}" data-grace="{{ check.grace.total_seconds }}" diff --git a/templates/front/my_checks_desktop.html b/templates/front/my_checks_desktop.html index 8561e5ec..14716ff7 100644 --- a/templates/front/my_checks_desktop.html +++ b/templates/front/my_checks_desktop.html @@ -112,7 +112,7 @@
  • -
    +
    {% include "front/last_ping_cell.html" with check=check %}
    From 33dece4ad26f48a279321343238cec0e9c91c8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Mon, 12 Aug 2019 23:37:18 +0300 Subject: [PATCH 38/65] Remove stray angle bracket. --- templates/front/welcome.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/front/welcome.html b/templates/front/welcome.html index 4853c71a..e0f3c453 100644 --- a/templates/front/welcome.html +++ b/templates/front/welcome.html @@ -436,7 +436,7 @@
    Apprise icon -

    Apprise
    >Push Notifications

    +

    Apprise
    Push Notifications

    {% endif %} From fa16bd4e427901c7c353450b85b9c14d11836e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sun, 18 Aug 2019 18:16:37 +0300 Subject: [PATCH 39/65] Prepare for 3DS 2 --- hc/payments/models.py | 42 +-- hc/payments/tests/test_get_client_token.py | 6 +- hc/payments/tests/test_payment_method.py | 67 +---- ...et_plan.py => test_update_subscription.py} | 50 +++- hc/payments/urls.py | 6 +- hc/payments/views.py | 30 +- static/css/billing.css | 7 +- static/js/billing.js | 109 ++++++-- templates/accounts/billing.html | 257 +++++++++--------- 9 files changed, 280 insertions(+), 294 deletions(-) rename hc/payments/tests/{test_set_plan.py => test_update_subscription.py} (80%) diff --git a/hc/payments/models.py b/hc/payments/models.py index 5903791c..d4bfa12b 100644 --- a/hc/payments/models.py +++ b/hc/payments/models.py @@ -66,11 +66,9 @@ class Subscription(models.Model): @property def payment_method(self): - if not self.payment_method_token: - return None - if not hasattr(self, "_pm"): - self._pm = braintree.PaymentMethod.find(self.payment_method_token) + o = self._get_braintree_subscription() + self._pm = braintree.PaymentMethod.find(o.payment_method_token) return self._pm def _get_braintree_subscription(self): @@ -79,43 +77,19 @@ class Subscription(models.Model): return self._sub def get_client_token(self): + assert self.customer_id return braintree.ClientToken.generate({"customer_id": self.customer_id}) def update_payment_method(self, nonce): - # Create customer record if it does not exist: - if not self.customer_id: - result = braintree.Customer.create({"email": self.user.email}) - if not result.is_success: - return result - - self.customer_id = result.customer.id - self.save() + assert self.subscription_id - # Create payment method - result = braintree.PaymentMethod.create( - { - "customer_id": self.customer_id, - "payment_method_nonce": nonce, - "options": {"make_default": True}, - } + result = braintree.Subscription.update( + self.subscription_id, {"payment_method_nonce": nonce} ) if not result.is_success: return result - self.payment_method_token = result.payment_method.token - self.save() - - # Update an existing subscription to use this payment method - if self.subscription_id: - result = braintree.Subscription.update( - self.subscription_id, - {"payment_method_token": self.payment_method_token}, - ) - - if not result.is_success: - return result - def update_address(self, post_data): # Create customer record if it does not exist: if not self.customer_id: @@ -141,9 +115,9 @@ class Subscription(models.Model): if not result.is_success: return result - def setup(self, plan_id): + def setup(self, plan_id, nonce): result = braintree.Subscription.create( - {"payment_method_token": self.payment_method_token, "plan_id": plan_id} + {"payment_method_nonce": nonce, "plan_id": plan_id} ) if result.is_success: diff --git a/hc/payments/tests/test_get_client_token.py b/hc/payments/tests/test_get_client_token.py index eb83a3c7..4256902d 100644 --- a/hc/payments/tests/test_get_client_token.py +++ b/hc/payments/tests/test_get_client_token.py @@ -7,10 +7,14 @@ from hc.test import BaseTestCase class GetClientTokenTestCase(BaseTestCase): @patch("hc.payments.models.braintree") def test_it_works(self, mock_braintree): + sub = Subscription(user=self.alice) + sub.customer_id = "fake-customer-id" + sub.save() + mock_braintree.ClientToken.generate.return_value = "test-token" self.client.login(username="alice@example.org", password="password") - r = self.client.get("/pricing/get_client_token/") + r = self.client.get("/pricing/token/") self.assertContains(r, "test-token", status_code=200) # A subscription object should have been created diff --git a/hc/payments/tests/test_payment_method.py b/hc/payments/tests/test_payment_method.py index 1a2b7ec6..f6a42064 100644 --- a/hc/payments/tests/test_payment_method.py +++ b/hc/payments/tests/test_payment_method.py @@ -5,23 +5,13 @@ from hc.test import BaseTestCase class UpdatePaymentMethodTestCase(BaseTestCase): - def _setup_mock(self, mock): - """ Set up Braintree calls that the controller will use. """ - - mock.PaymentMethod.create.return_value.is_success = True - mock.PaymentMethod.create.return_value.payment_method.token = "fake" - @patch("hc.payments.models.braintree") def test_it_retrieves_paypal(self, mock): - self._setup_mock(mock) - mock.paypal_account.PayPalAccount = dict mock.credit_card.CreditCard = list mock.PaymentMethod.find.return_value = {"email": "foo@example.org"} - self.sub = Subscription(user=self.alice) - self.sub.payment_method_token = "fake-token" - self.sub.save() + Subscription.objects.create(user=self.alice) self.client.login(username="alice@example.org", password="password") r = self.client.get("/accounts/profile/billing/payment_method/") @@ -29,65 +19,12 @@ class UpdatePaymentMethodTestCase(BaseTestCase): @patch("hc.payments.models.braintree") def test_it_retrieves_cc(self, mock): - self._setup_mock(mock) - mock.paypal_account.PayPalAccount = list mock.credit_card.CreditCard = dict mock.PaymentMethod.find.return_value = {"masked_number": "1***2"} - self.sub = Subscription(user=self.alice) - self.sub.payment_method_token = "fake-token" - self.sub.save() + Subscription.objects.create(user=self.alice) self.client.login(username="alice@example.org", password="password") r = self.client.get("/accounts/profile/billing/payment_method/") self.assertContains(r, "1***2") - - @patch("hc.payments.models.braintree") - def test_it_creates_payment_method(self, mock): - self._setup_mock(mock) - - self.sub = Subscription(user=self.alice) - self.sub.customer_id = "test-customer" - self.sub.save() - - self.client.login(username="alice@example.org", password="password") - form = {"payment_method_nonce": "test-nonce"} - r = self.client.post("/accounts/profile/billing/payment_method/", form) - - self.assertRedirects(r, "/accounts/profile/billing/") - - @patch("hc.payments.models.braintree") - def test_it_creates_customer(self, mock): - self._setup_mock(mock) - - mock.Customer.create.return_value.is_success = True - mock.Customer.create.return_value.customer.id = "test-customer-id" - - self.sub = Subscription(user=self.alice) - self.sub.save() - - self.client.login(username="alice@example.org", password="password") - form = {"payment_method_nonce": "test-nonce"} - self.client.post("/accounts/profile/billing/payment_method/", form) - - self.sub.refresh_from_db() - self.assertEqual(self.sub.customer_id, "test-customer-id") - - @patch("hc.payments.models.braintree") - def test_it_updates_subscription(self, mock): - self._setup_mock(mock) - - self.sub = Subscription(user=self.alice) - self.sub.customer_id = "test-customer" - self.sub.subscription_id = "fake-id" - self.sub.save() - - mock.Customer.create.return_value.is_success = True - mock.Customer.create.return_value.customer.id = "test-customer-id" - - self.client.login(username="alice@example.org", password="password") - form = {"payment_method_nonce": "test-nonce"} - self.client.post("/accounts/profile/billing/payment_method/", form) - - self.assertTrue(mock.Subscription.update.called) diff --git a/hc/payments/tests/test_set_plan.py b/hc/payments/tests/test_update_subscription.py similarity index 80% rename from hc/payments/tests/test_set_plan.py rename to hc/payments/tests/test_update_subscription.py index f5fbd696..da073405 100644 --- a/hc/payments/tests/test_set_plan.py +++ b/hc/payments/tests/test_update_subscription.py @@ -4,17 +4,17 @@ from hc.payments.models import Subscription from hc.test import BaseTestCase -class SetPlanTestCase(BaseTestCase): +class UpdateSubscriptionTestCase(BaseTestCase): def _setup_mock(self, mock): """ Set up Braintree calls that the controller will use. """ mock.Subscription.create.return_value.is_success = True mock.Subscription.create.return_value.subscription.id = "t-sub-id" - def run_set_plan(self, plan_id="P20"): - form = {"plan_id": plan_id} + def run_update(self, plan_id="P20", nonce="fake-nonce"): + form = {"plan_id": plan_id, "nonce": nonce} self.client.login(username="alice@example.org", password="password") - return self.client.post("/pricing/set_plan/", form, follow=True) + return self.client.post("/pricing/update/", form, follow=True) @patch("hc.payments.models.braintree") def test_it_works(self, mock): @@ -24,8 +24,9 @@ class SetPlanTestCase(BaseTestCase): self.profile.sms_sent = 1 self.profile.save() - r = self.run_set_plan() + r = self.run_update() self.assertRedirects(r, "/accounts/profile/billing/") + self.assertContains(r, "Your billing plan has been updated!") # Subscription should be filled out: sub = Subscription.objects.get(user=self.alice) @@ -42,7 +43,10 @@ class SetPlanTestCase(BaseTestCase): self.assertEqual(self.profile.sms_sent, 0) # braintree.Subscription.cancel should have not been called - assert not mock.Subscription.cancel.called + # because there was no previous subscription + self.assertFalse(mock.Subscription.cancel.called) + + self.assertTrue(mock.Subscription.create.called) @patch("hc.payments.models.braintree") def test_yearly_works(self, mock): @@ -52,7 +56,7 @@ class SetPlanTestCase(BaseTestCase): self.profile.sms_sent = 1 self.profile.save() - r = self.run_set_plan("Y192") + r = self.run_update("Y192") self.assertRedirects(r, "/accounts/profile/billing/") # Subscription should be filled out: @@ -80,7 +84,7 @@ class SetPlanTestCase(BaseTestCase): self.profile.sms_sent = 1 self.profile.save() - r = self.run_set_plan("P80") + r = self.run_update("P80") self.assertRedirects(r, "/accounts/profile/billing/") # Subscription should be filled out: @@ -114,8 +118,9 @@ class SetPlanTestCase(BaseTestCase): self.profile.sms_sent = 1 self.profile.save() - r = self.run_set_plan("") + r = self.run_update("") self.assertRedirects(r, "/accounts/profile/billing/") + self.assertContains(r, "Your billing plan has been updated!") # Subscription should be cleared sub = Subscription.objects.get(user=self.alice) @@ -130,10 +135,10 @@ class SetPlanTestCase(BaseTestCase): self.assertEqual(self.profile.team_limit, 2) self.assertEqual(self.profile.sms_limit, 0) - assert mock.Subscription.cancel.called + self.assertTrue(mock.Subscription.cancel.called) def test_bad_plan_id(self): - r = self.run_set_plan(plan_id="this-is-wrong") + r = self.run_update(plan_id="this-is-wrong") self.assertEqual(r.status_code, 400) @patch("hc.payments.models.braintree") @@ -144,16 +149,16 @@ class SetPlanTestCase(BaseTestCase): sub.subscription_id = "prev-sub" sub.save() - r = self.run_set_plan() + r = self.run_update() self.assertRedirects(r, "/accounts/profile/billing/") - assert mock.Subscription.cancel.called + self.assertTrue(mock.Subscription.cancel.called) @patch("hc.payments.models.braintree") def test_subscription_creation_failure(self, mock): mock.Subscription.create.return_value.is_success = False mock.Subscription.create.return_value.message = "sub failure" - r = self.run_set_plan() + r = self.run_update() self.assertRedirects(r, "/accounts/profile/billing/") self.assertContains(r, "sub failure") @@ -171,7 +176,7 @@ class SetPlanTestCase(BaseTestCase): mock.Subscription.create.return_value.is_success = False mock.Subscription.create.return_value.message = "sub failure" - r = self.run_set_plan() + r = self.run_update() # It should cancel the current plan self.assertTrue(mock.Subscription.cancel.called) @@ -183,3 +188,18 @@ class SetPlanTestCase(BaseTestCase): # And it should show the error message from API: self.assertRedirects(r, "/accounts/profile/billing/") self.assertContains(r, "sub failure") + + @patch("hc.payments.models.braintree") + def test_it_updates_payment_method(self, mock): + # Initial state: the user has a subscription and a high check limit: + sub = Subscription.objects.for_user(self.alice) + sub.plan_id = "P20" + sub.subscription_id = "old-sub-id" + sub.save() + + r = self.run_update() + + # It should update the existing subscription + self.assertTrue(mock.Subscription.update.called) + self.assertRedirects(r, "/accounts/profile/billing/") + self.assertContains(r, "Your payment method has been updated!") diff --git a/hc/payments/urls.py b/hc/payments/urls.py index e0fabb41..b72d9ed7 100644 --- a/hc/payments/urls.py +++ b/hc/payments/urls.py @@ -19,9 +19,7 @@ urlpatterns = [ path( "invoice/pdf//", views.pdf_invoice, name="hc-invoice-pdf" ), - path("pricing/set_plan/", views.set_plan, name="hc-set-plan"), - path( - "pricing/get_client_token/", views.get_client_token, name="hc-get-client-token" - ), + path("pricing/update/", views.update, name="hc-update-subscription"), + path("pricing/token/", views.token, name="hc-get-client-token"), path("pricing/charge/", views.charge_webhook), ] diff --git a/hc/payments/views.py b/hc/payments/views.py index 85e5e993..d864754f 100644 --- a/hc/payments/views.py +++ b/hc/payments/views.py @@ -20,7 +20,7 @@ from hc.payments.models import Subscription @login_required -def get_client_token(request): +def token(request): sub = Subscription.objects.for_user(request.user) return JsonResponse({"client_token": sub.get_client_token()}) @@ -96,13 +96,21 @@ def log_and_bail(request, result): @login_required @require_POST -def set_plan(request): +def update(request): plan_id = request.POST["plan_id"] + nonce = request.POST["nonce"] + if plan_id not in ("", "P20", "P80", "Y192", "Y768"): return HttpResponseBadRequest() sub = Subscription.objects.for_user(request.user) - if sub.plan_id == plan_id: + # If plan_id has not changed then just update the payment method: + if plan_id == sub.plan_id: + error = sub.update_payment_method(nonce) + if error: + return log_and_bail(request, error) + + request.session["payment_method_status"] = "success" return redirect("hc-billing") # Cancel the previous plan and reset limits: @@ -116,9 +124,10 @@ def set_plan(request): profile.save() if plan_id == "": + request.session["set_plan_status"] = "success" return redirect("hc-billing") - result = sub.setup(plan_id) + result = sub.setup(plan_id, nonce) if not result.is_success: return log_and_bail(request, result) @@ -161,19 +170,6 @@ def address(request): @login_required def payment_method(request): sub = get_object_or_404(Subscription, user=request.user) - - if request.method == "POST": - if "payment_method_nonce" not in request.POST: - return HttpResponseBadRequest() - - nonce = request.POST["payment_method_nonce"] - error = sub.update_payment_method(nonce) - if error: - return log_and_bail(request, error) - - request.session["payment_method_status"] = "success" - return redirect("hc-billing") - ctx = {"sub": sub, "pm": sub.payment_method} return render(request, "payments/payment_method.html", ctx) diff --git a/static/css/billing.css b/static/css/billing.css index ec834e4e..6163a54c 100644 --- a/static/css/billing.css +++ b/static/css/billing.css @@ -12,7 +12,7 @@ } @media (min-width: 992px) { - #change-billing-plan-modal .modal-dialog { + #change-billing-plan-modal .modal-dialog, #payment-method-modal .modal-dialog, #please-wait-modal .modal-dialog { width: 850px; } } @@ -86,3 +86,8 @@ color: #777; } +#please-wait-modal .modal-body { + text-align: center; + padding: 100px; + font-size: 18px; +} \ No newline at end of file diff --git a/static/js/billing.js b/static/js/billing.js index c336186e..b805ebbe 100644 --- a/static/js/billing.js +++ b/static/js/billing.js @@ -1,45 +1,92 @@ $(function () { - var clientTokenRequested = false; - function requestClientToken() { - if (!clientTokenRequested) { - clientTokenRequested = true; - $.getJSON("/pricing/get_client_token/", setupDropin); + var preloadedToken = null; + function getToken(callback) { + if (preloadedToken) { + callback(preloadedToken); + } else { + $.getJSON("/pricing/token/", function(response) { + preloadedToken = response.client_token; + callback(response.client_token); + }); } } - function setupDropin(data) { - braintree.dropin.create({ - authorization: data.client_token, - container: "#dropin", - paypal: { flow: 'vault' } - }, function(createErr, instance) { - $("#payment-form-submit").click(function() { - instance.requestPaymentMethod(function (requestPaymentMethodErr, payload) { - $("#pmm-nonce").val(payload.nonce); - $("#payment-form").submit(); + // Preload client token: + if ($("#billing-address").length) { + getToken(function(token){}); + } + + function getAmount(planId) { + return planId.substr(1); + } + + function showPaymentMethodForm(planId) { + $("#plan-id").val(planId); + $("#nonce").val(""); + + if (planId == "") { + // Don't need a payment method when switching to the free plan + // -- can submit the form right away: + $("#update-subscription-form").submit(); + return; + } + + $("#payment-form-submit").prop("disabled", true); + $("#payment-method-modal").modal("show"); + + getToken(function(token) { + braintree.dropin.create({ + authorization: token, + container: "#dropin", + threeDSecure: { + amount: getAmount(planId), + }, + paypal: { flow: 'vault' }, + preselectVaultedPaymentMethod: false + }, function(createErr, instance) { + $("#payment-form-submit").off().click(function() { + instance.requestPaymentMethod(function (err, payload) { + $("#payment-method-modal").modal("hide"); + $("#please-wait-modal").modal("show"); + + $("#nonce").val(payload.nonce); + $("#update-subscription-form").submit(); + }); + }); + + $("#payment-method-modal").off("hidden.bs.modal").on("hidden.bs.modal", function() { + instance.teardown(); }); - }).prop("disabled", false); + + instance.on("paymentMethodRequestable", function() { + $("#payment-form-submit").prop("disabled", false); + }); + + instance.on("noPaymentMethodRequestable", function() { + $("#payment-form-submit").prop("disabled", true); + }); + + }); }); } - $("#update-payment-method").hover(requestClientToken); + $("#change-plan-btn").click(function() { + $("#change-billing-plan-modal").modal("hide"); + showPaymentMethodForm(this.dataset.planId); + }); $("#update-payment-method").click(function() { - requestClientToken(); - $("#payment-form").attr("action", this.dataset.action); - $("#payment-form-submit").text("Update Payment Method"); - $("#payment-method-modal").modal("show"); + showPaymentMethodForm($("#old-plan-id").val()); }); - - $("#billing-history").load( "/accounts/profile/billing/history/" ); - $("#billing-address").load( "/accounts/profile/billing/address/", function() { + $("#billing-history").load("/accounts/profile/billing/history/"); + $("#billing-address").load("/accounts/profile/billing/address/", function() { $("#billing-address input").each(function(idx, obj) { $("#" + obj.name).val(obj.value); }); }); - $("#payment-method").load( "/accounts/profile/billing/payment_method/", function() { + $("#payment-method").load("/accounts/profile/billing/payment_method/", function() { $("#next-billing-date").text($("#nbd").val()); }); @@ -94,9 +141,7 @@ $(function () { if ($("#plan-business-plus").hasClass("selected")) { planId = period == "monthly" ? "P80" : "Y768"; } - - $("#plan-id").val(planId); - + if (planId == $("#old-plan-id").val()) { $("#change-plan-btn") .attr("disabled", "disabled") @@ -105,10 +150,14 @@ $(function () { } else { var caption = "Change Billing Plan"; if (planId) { - caption += " And Pay $" + planId.substr(1) + " Now"; + var amount = planId.substr(1); + caption += " And Pay $" + amount + " Now"; } - $("#change-plan-btn").removeAttr("disabled").text(caption); + $("#change-plan-btn") + .removeAttr("disabled") + .text(caption) + .attr("data-plan-id", planId); } } updateChangePlanForm(); diff --git a/templates/accounts/billing.html b/templates/accounts/billing.html index ee79c4b9..5131baf7 100644 --- a/templates/accounts/billing.html +++ b/templates/accounts/billing.html @@ -76,16 +76,13 @@ {% endif %}
    + {% if sub.subscription_id %}

    Payment Method

    - {% if sub.payment_method_token %}

    loading…

    - {% else %} -

    Not set

    - {% endif %}
    {% endif %}
    + {% endif %}
    @@ -170,110 +168,104 @@