diff --git a/hc/accounts/admin.py b/hc/accounts/admin.py index 104fa759..f88b26ad 100644 --- a/hc/accounts/admin.py +++ b/hc/accounts/admin.py @@ -26,7 +26,8 @@ class ProfileFieldset(Fieldset): class TeamFieldset(Fieldset): name = "Team" fields = ("team_name", "team_access_allowed", "check_limit", - "ping_log_limit", "bill_to") + "ping_log_limit", "sms_limit", "sms_sent", "last_sms_date", + "bill_to") @admin.register(Profile) @@ -41,7 +42,7 @@ class ProfileAdmin(admin.ModelAdmin): raw_id_fields = ("current_team", ) list_select_related = ("user", ) list_display = ("id", "users", "checks", "team_access_allowed", - "reports_allowed", "ping_log_limit") + "reports_allowed", "ping_log_limit", "sms") search_fields = ["id", "user__email"] list_filter = ("team_access_allowed", "reports_allowed", "check_limit", "next_report_date") @@ -68,6 +69,9 @@ class ProfileAdmin(admin.ModelAdmin):   %d of %d """ % (pct, num_checks, obj.check_limit) + def sms(self, obj): + return "%d of %d" % (obj.sms_sent, obj.sms_limit) + def email(self, obj): return obj.user.email diff --git a/hc/accounts/migrations/0009_auto_20170714_1734.py b/hc/accounts/migrations/0009_auto_20170714_1734.py new file mode 100644 index 00000000..4805ecd3 --- /dev/null +++ b/hc/accounts/migrations/0009_auto_20170714_1734.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-07-14 17:34 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0008_profile_bill_to'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='last_sms_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='profile', + name='sms_limit', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='profile', + name='sms_sent', + field=models.IntegerField(default=0), + ), + ] diff --git a/hc/accounts/migrations/0010_profile_sms_defaults.py b/hc/accounts/migrations/0010_profile_sms_defaults.py new file mode 100644 index 00000000..6ecee1da --- /dev/null +++ b/hc/accounts/migrations/0010_profile_sms_defaults.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-07-14 17:45 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0009_auto_20170714_1734'), + ] + + operations = [ + migrations.RunSQL("ALTER TABLE accounts_profile ALTER COLUMN sms_sent SET DEFAULT 0"), + migrations.RunSQL("ALTER TABLE accounts_profile ALTER COLUMN sms_limit SET DEFAULT 0") + ] diff --git a/hc/accounts/models.py b/hc/accounts/models.py index 68280fdc..4602608e 100644 --- a/hc/accounts/models.py +++ b/hc/accounts/models.py @@ -13,14 +13,20 @@ from django.utils import timezone from hc.lib import emails +def month(dt): + """ For a given datetime, return the matching first-day-of-month date. """ + return dt.date().replace(day=1) + + class ProfileManager(models.Manager): def for_user(self, user): profile = self.filter(user=user).first() if profile is None: profile = Profile(user=user, team_access_allowed=user.is_superuser) if not settings.USE_PAYMENTS: - # If not using payments, set a high check_limit + # If not using payments, set high limits profile.check_limit = 500 + profile.sms_limit = 500 profile.save() return profile @@ -39,6 +45,9 @@ class Profile(models.Model): api_key = models.CharField(max_length=128, blank=True) current_team = models.ForeignKey("self", models.SET_NULL, null=True) bill_to = models.TextField(blank=True) + last_sms_date = models.DateTimeField(null=True, blank=True) + sms_limit = models.IntegerField(default=0) + sms_sent = models.IntegerField(default=0) objects = ProfileManager() @@ -103,6 +112,29 @@ class Profile(models.Model): user.profile.send_instant_login_link(self) + def sms_sent_this_month(self): + # IF last_sms_date was never set, we have not sent any messages yet. + if not self.last_sms_date: + return 0 + + # If last sent date is not from this month, we've sent 0 this month. + if month(timezone.now()) > month(self.last_sms_date): + return 0 + + return self.sms_sent + + def authorize_sms(self): + """ If monthly limit not exceeded, increase counter and return True """ + + sent_this_month = self.sms_sent_this_month() + if sent_this_month >= self.sms_limit: + return False + + self.sms_sent = sent_this_month + 1 + self.last_sms_date = timezone.now() + self.save() + return True + class Member(models.Model): team = models.ForeignKey(Profile, models.CASCADE) diff --git a/hc/api/migrations/0033_auto_20170714_1715.py b/hc/api/migrations/0033_auto_20170714_1715.py new file mode 100644 index 00000000..1e6178f4 --- /dev/null +++ b/hc/api/migrations/0033_auto_20170714_1715.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-07-14 17:15 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0032_auto_20170608_1158'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='kind', + field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS')], max_length=20), + ), + ] diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index b56e3f4d..9eda75e5 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -310,3 +310,47 @@ class NotifyTestCase(BaseTestCase): payload = kwargs["json"] self.assertEqual(payload["chat_id"], 123) self.assertTrue("The check" in payload["text"]) + + @patch("hc.api.transports.requests.request") + def test_sms(self, mock_post): + self._setup_data("sms", "+1234567890") + mock_post.return_value.status_code = 200 + + self.channel.notify(self.check) + assert Notification.objects.count() == 1 + + args, kwargs = mock_post.call_args + payload = kwargs["data"] + self.assertEqual(payload["To"], "+1234567890") + + # 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_sms_limit(self, mock_post): + # At limit already: + self.profile.last_sms_date = now() + self.profile.sms_sent = 50 + self.profile.save() + + self._setup_data("sms", "+1234567890") + + self.channel.notify(self.check) + self.assertFalse(mock_post.called) + + n = Notification.objects.get() + self.assertTrue("Monthly SMS limit exceeded" in n.error) + + @patch("hc.api.transports.requests.request") + def test_sms_limit_reset(self, mock_post): + # At limit, but also into a new month + self.profile.sms_sent = 50 + self.profile.last_sms_date = now() - td(days=100) + self.profile.save() + + self._setup_data("sms", "+1234567890") + mock_post.return_value.status_code = 200 + + self.channel.notify(self.check) + self.assertTrue(mock_post.called) diff --git a/hc/api/transports.py b/hc/api/transports.py index c6e06e6a..dd44cb5c 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -5,6 +5,7 @@ import json import requests from six.moves.urllib.parse import quote +from hc.accounts.models import Profile from hc.lib import emails @@ -305,6 +306,10 @@ class Sms(HttpTransport): return check.status != "down" def notify(self, check): + profile = Profile.objects.for_user(self.channel.user) + if not profile.authorize_sms(): + return "Monthly SMS limit exceeded" + url = self.URL % settings.TWILIO_ACCOUNT auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH) text = tmpl("sms_message.html", check=check, diff --git a/hc/front/tests/test_add_sms.py b/hc/front/tests/test_add_sms.py index d92c7797..0aec9171 100644 --- a/hc/front/tests/test_add_sms.py +++ b/hc/front/tests/test_add_sms.py @@ -12,6 +12,14 @@ class AddSmsTestCase(BaseTestCase): r = self.client.get(self.url) self.assertContains(r, "Get a SMS message") + 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 = {"value": "+1234567890"} diff --git a/hc/front/views.py b/hc/front/views.py index 1eef280c..e212fa2b 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -346,6 +346,7 @@ def channels(request): ctx = { "page": "channels", + "profile": request.team, "channels": channels, "num_checks": num_checks, "enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None, @@ -830,7 +831,11 @@ def add_sms(request): else: form = AddSmsForm() - ctx = {"page": "channels", "form": form} + ctx = { + "page": "channels", + "form": form, + "profile": request.team + } return render(request, "integrations/add_sms.html", ctx) diff --git a/hc/payments/tests/test_cancel_plan.py b/hc/payments/tests/test_cancel_plan.py index 47aae62b..7ba91b5f 100644 --- a/hc/payments/tests/test_cancel_plan.py +++ b/hc/payments/tests/test_cancel_plan.py @@ -16,6 +16,7 @@ class CancelPlanTestCase(BaseTestCase): self.profile.ping_log_limit = 1000 self.profile.check_limit = 500 + self.profile.sms_limit = 50 self.profile.save() @patch("hc.payments.models.braintree") @@ -33,4 +34,5 @@ class CancelPlanTestCase(BaseTestCase): profile = Profile.objects.get(user=self.alice) self.assertEqual(profile.ping_log_limit, 100) self.assertEqual(profile.check_limit, 20) + self.assertEqual(profile.sms_limit, 0) self.assertFalse(profile.team_access_allowed) diff --git a/hc/payments/tests/test_create_plan.py b/hc/payments/tests/test_create_plan.py index be13164f..b0ecd581 100644 --- a/hc/payments/tests/test_create_plan.py +++ b/hc/payments/tests/test_create_plan.py @@ -28,6 +28,11 @@ class CreatePlanTestCase(BaseTestCase): def test_it_works(self, mock): self._setup_mock(mock) + self.profile.team_access_allowed = False + self.profile.sms_limit = 0 + self.profile.sms_sent = 1 + self.profile.save() + r = self.run_create_plan() self.assertRedirects(r, "/pricing/") @@ -39,10 +44,12 @@ class CreatePlanTestCase(BaseTestCase): self.assertEqual(sub.plan_id, "P5") # User's profile should have a higher limits - profile = Profile.objects.get(user=self.alice) - self.assertEqual(profile.ping_log_limit, 1000) - self.assertEqual(profile.check_limit, 500) - self.assertTrue(profile.team_access_allowed) + self.profile.refresh_from_db() + self.assertEqual(self.profile.ping_log_limit, 1000) + self.assertEqual(self.profile.check_limit, 500) + self.assertEqual(self.profile.sms_limit, 50) + self.assertEqual(self.profile.sms_sent, 0) + self.assertTrue(self.profile.team_access_allowed) # braintree.Subscription.cancel should have not been called assert not mock.Subscription.cancel.called diff --git a/hc/payments/views.py b/hc/payments/views.py index c9e8726e..c63d260b 100644 --- a/hc/payments/views.py +++ b/hc/payments/views.py @@ -109,11 +109,15 @@ def create_plan(request): if plan_id == "P5": profile.ping_log_limit = 1000 profile.check_limit = 500 + profile.sms_limit = 50 + profile.sms_sent = 0 profile.team_access_allowed = True profile.save() elif plan_id == "P75": profile.ping_log_limit = 1000 profile.check_limit = 500 + profile.sms_limit = 500 + profile.sms_sent = 0 profile.team_access_allowed = True profile.save() @@ -164,6 +168,7 @@ def cancel_plan(request): profile = request.user.profile profile.ping_log_limit = 100 profile.check_limit = 20 + profile.sms_limit = 0 profile.team_access_allowed = False profile.save() diff --git a/hc/test.py b/hc/test.py index a13d57dc..ffe021cb 100644 --- a/hc/test.py +++ b/hc/test.py @@ -16,6 +16,7 @@ class BaseTestCase(TestCase): self.profile = Profile(user=self.alice, api_key="abc") self.profile.team_access_allowed = True + self.profile.sms_limit = 50 self.profile.save() # Bob is on Alice's team and should have access to her stuff diff --git a/static/css/channels.css b/static/css/channels.css index 19d8f2e0..05e094b5 100644 --- a/static/css/channels.css +++ b/static/css/channels.css @@ -3,7 +3,7 @@ } .channels-table .channel-row > td { - line-height: 40px; + padding: 10px 0; } .channels-table .value-cell { diff --git a/templates/front/channels.html b/templates/front/channels.html index 25cb0f4e..94db72ef 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -113,6 +113,9 @@ {% else %} Never {% endif %} + {% if ch.kind == "sms" %} +

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

+ {% endif %} {% endwith %} @@ -157,7 +160,7 @@ SMS icon -

SMS

+

SMS {% if show_pricing %}(paid plans){% endif %}

Get a text message to your phone when check goes down.

Add Integration diff --git a/templates/integrations/add_sms.html b/templates/integrations/add_sms.html index f8d44b86..43d701d2 100644 --- a/templates/integrations/add_sms.html +++ b/templates/integrations/add_sms.html @@ -11,6 +11,15 @@

Get a SMS message to your specified number when check goes down.

+ {% if show_pricing and profile.sms_limit == 0 %} +

+ Paid plan required. + SMS messaging is not available on the free plan–sending the messages + costs too much! Please upgrade to a + paid plan to enable SMS messaging. +

+ {% endif %} +

Integration Settings