forked from GithubBackups/healthchecks
Per-profile, per-month counters of sent SMS messages.
This commit is contained in:
parent
25fb11bb3e
commit
5f2da08d7e
@ -26,7 +26,8 @@ class ProfileFieldset(Fieldset):
|
|||||||
class TeamFieldset(Fieldset):
|
class TeamFieldset(Fieldset):
|
||||||
name = "Team"
|
name = "Team"
|
||||||
fields = ("team_name", "team_access_allowed", "check_limit",
|
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)
|
@admin.register(Profile)
|
||||||
@ -41,7 +42,7 @@ class ProfileAdmin(admin.ModelAdmin):
|
|||||||
raw_id_fields = ("current_team", )
|
raw_id_fields = ("current_team", )
|
||||||
list_select_related = ("user", )
|
list_select_related = ("user", )
|
||||||
list_display = ("id", "users", "checks", "team_access_allowed",
|
list_display = ("id", "users", "checks", "team_access_allowed",
|
||||||
"reports_allowed", "ping_log_limit")
|
"reports_allowed", "ping_log_limit", "sms")
|
||||||
search_fields = ["id", "user__email"]
|
search_fields = ["id", "user__email"]
|
||||||
list_filter = ("team_access_allowed", "reports_allowed",
|
list_filter = ("team_access_allowed", "reports_allowed",
|
||||||
"check_limit", "next_report_date")
|
"check_limit", "next_report_date")
|
||||||
@ -68,6 +69,9 @@ class ProfileAdmin(admin.ModelAdmin):
|
|||||||
%d of %d
|
%d of %d
|
||||||
""" % (pct, num_checks, obj.check_limit)
|
""" % (pct, num_checks, obj.check_limit)
|
||||||
|
|
||||||
|
def sms(self, obj):
|
||||||
|
return "%d of %d" % (obj.sms_sent, obj.sms_limit)
|
||||||
|
|
||||||
def email(self, obj):
|
def email(self, obj):
|
||||||
return obj.user.email
|
return obj.user.email
|
||||||
|
|
||||||
|
30
hc/accounts/migrations/0009_auto_20170714_1734.py
Normal file
30
hc/accounts/migrations/0009_auto_20170714_1734.py
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
]
|
17
hc/accounts/migrations/0010_profile_sms_defaults.py
Normal file
17
hc/accounts/migrations/0010_profile_sms_defaults.py
Normal file
@ -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")
|
||||||
|
]
|
@ -13,14 +13,20 @@ from django.utils import timezone
|
|||||||
from hc.lib import emails
|
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):
|
class ProfileManager(models.Manager):
|
||||||
def for_user(self, user):
|
def for_user(self, user):
|
||||||
profile = self.filter(user=user).first()
|
profile = self.filter(user=user).first()
|
||||||
if profile is None:
|
if profile is None:
|
||||||
profile = Profile(user=user, team_access_allowed=user.is_superuser)
|
profile = Profile(user=user, team_access_allowed=user.is_superuser)
|
||||||
if not settings.USE_PAYMENTS:
|
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.check_limit = 500
|
||||||
|
profile.sms_limit = 500
|
||||||
|
|
||||||
profile.save()
|
profile.save()
|
||||||
return profile
|
return profile
|
||||||
@ -39,6 +45,9 @@ class Profile(models.Model):
|
|||||||
api_key = models.CharField(max_length=128, blank=True)
|
api_key = models.CharField(max_length=128, blank=True)
|
||||||
current_team = models.ForeignKey("self", models.SET_NULL, null=True)
|
current_team = models.ForeignKey("self", models.SET_NULL, null=True)
|
||||||
bill_to = models.TextField(blank=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()
|
objects = ProfileManager()
|
||||||
|
|
||||||
@ -103,6 +112,29 @@ class Profile(models.Model):
|
|||||||
|
|
||||||
user.profile.send_instant_login_link(self)
|
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):
|
class Member(models.Model):
|
||||||
team = models.ForeignKey(Profile, models.CASCADE)
|
team = models.ForeignKey(Profile, models.CASCADE)
|
||||||
|
20
hc/api/migrations/0033_auto_20170714_1715.py
Normal file
20
hc/api/migrations/0033_auto_20170714_1715.py
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -310,3 +310,47 @@ class NotifyTestCase(BaseTestCase):
|
|||||||
payload = kwargs["json"]
|
payload = kwargs["json"]
|
||||||
self.assertEqual(payload["chat_id"], 123)
|
self.assertEqual(payload["chat_id"], 123)
|
||||||
self.assertTrue("The check" in payload["text"])
|
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)
|
||||||
|
@ -5,6 +5,7 @@ import json
|
|||||||
import requests
|
import requests
|
||||||
from six.moves.urllib.parse import quote
|
from six.moves.urllib.parse import quote
|
||||||
|
|
||||||
|
from hc.accounts.models import Profile
|
||||||
from hc.lib import emails
|
from hc.lib import emails
|
||||||
|
|
||||||
|
|
||||||
@ -305,6 +306,10 @@ class Sms(HttpTransport):
|
|||||||
return check.status != "down"
|
return check.status != "down"
|
||||||
|
|
||||||
def notify(self, check):
|
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
|
url = self.URL % settings.TWILIO_ACCOUNT
|
||||||
auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
|
auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
|
||||||
text = tmpl("sms_message.html", check=check,
|
text = tmpl("sms_message.html", check=check,
|
||||||
|
@ -12,6 +12,14 @@ class AddSmsTestCase(BaseTestCase):
|
|||||||
r = self.client.get(self.url)
|
r = self.client.get(self.url)
|
||||||
self.assertContains(r, "Get a SMS message")
|
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):
|
def test_it_creates_channel(self):
|
||||||
form = {"value": "+1234567890"}
|
form = {"value": "+1234567890"}
|
||||||
|
|
||||||
|
@ -346,6 +346,7 @@ def channels(request):
|
|||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"page": "channels",
|
"page": "channels",
|
||||||
|
"profile": request.team,
|
||||||
"channels": channels,
|
"channels": channels,
|
||||||
"num_checks": num_checks,
|
"num_checks": num_checks,
|
||||||
"enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None,
|
"enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None,
|
||||||
@ -830,7 +831,11 @@ def add_sms(request):
|
|||||||
else:
|
else:
|
||||||
form = AddSmsForm()
|
form = AddSmsForm()
|
||||||
|
|
||||||
ctx = {"page": "channels", "form": form}
|
ctx = {
|
||||||
|
"page": "channels",
|
||||||
|
"form": form,
|
||||||
|
"profile": request.team
|
||||||
|
}
|
||||||
return render(request, "integrations/add_sms.html", ctx)
|
return render(request, "integrations/add_sms.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ class CancelPlanTestCase(BaseTestCase):
|
|||||||
|
|
||||||
self.profile.ping_log_limit = 1000
|
self.profile.ping_log_limit = 1000
|
||||||
self.profile.check_limit = 500
|
self.profile.check_limit = 500
|
||||||
|
self.profile.sms_limit = 50
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
@patch("hc.payments.models.braintree")
|
@patch("hc.payments.models.braintree")
|
||||||
@ -33,4 +34,5 @@ class CancelPlanTestCase(BaseTestCase):
|
|||||||
profile = Profile.objects.get(user=self.alice)
|
profile = Profile.objects.get(user=self.alice)
|
||||||
self.assertEqual(profile.ping_log_limit, 100)
|
self.assertEqual(profile.ping_log_limit, 100)
|
||||||
self.assertEqual(profile.check_limit, 20)
|
self.assertEqual(profile.check_limit, 20)
|
||||||
|
self.assertEqual(profile.sms_limit, 0)
|
||||||
self.assertFalse(profile.team_access_allowed)
|
self.assertFalse(profile.team_access_allowed)
|
||||||
|
@ -28,6 +28,11 @@ class CreatePlanTestCase(BaseTestCase):
|
|||||||
def test_it_works(self, mock):
|
def test_it_works(self, mock):
|
||||||
self._setup_mock(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()
|
r = self.run_create_plan()
|
||||||
self.assertRedirects(r, "/pricing/")
|
self.assertRedirects(r, "/pricing/")
|
||||||
|
|
||||||
@ -39,10 +44,12 @@ class CreatePlanTestCase(BaseTestCase):
|
|||||||
self.assertEqual(sub.plan_id, "P5")
|
self.assertEqual(sub.plan_id, "P5")
|
||||||
|
|
||||||
# User's profile should have a higher limits
|
# User's profile should have a higher limits
|
||||||
profile = Profile.objects.get(user=self.alice)
|
self.profile.refresh_from_db()
|
||||||
self.assertEqual(profile.ping_log_limit, 1000)
|
self.assertEqual(self.profile.ping_log_limit, 1000)
|
||||||
self.assertEqual(profile.check_limit, 500)
|
self.assertEqual(self.profile.check_limit, 500)
|
||||||
self.assertTrue(profile.team_access_allowed)
|
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
|
# braintree.Subscription.cancel should have not been called
|
||||||
assert not mock.Subscription.cancel.called
|
assert not mock.Subscription.cancel.called
|
||||||
|
@ -109,11 +109,15 @@ def create_plan(request):
|
|||||||
if plan_id == "P5":
|
if plan_id == "P5":
|
||||||
profile.ping_log_limit = 1000
|
profile.ping_log_limit = 1000
|
||||||
profile.check_limit = 500
|
profile.check_limit = 500
|
||||||
|
profile.sms_limit = 50
|
||||||
|
profile.sms_sent = 0
|
||||||
profile.team_access_allowed = True
|
profile.team_access_allowed = True
|
||||||
profile.save()
|
profile.save()
|
||||||
elif plan_id == "P75":
|
elif plan_id == "P75":
|
||||||
profile.ping_log_limit = 1000
|
profile.ping_log_limit = 1000
|
||||||
profile.check_limit = 500
|
profile.check_limit = 500
|
||||||
|
profile.sms_limit = 500
|
||||||
|
profile.sms_sent = 0
|
||||||
profile.team_access_allowed = True
|
profile.team_access_allowed = True
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
@ -164,6 +168,7 @@ def cancel_plan(request):
|
|||||||
profile = request.user.profile
|
profile = request.user.profile
|
||||||
profile.ping_log_limit = 100
|
profile.ping_log_limit = 100
|
||||||
profile.check_limit = 20
|
profile.check_limit = 20
|
||||||
|
profile.sms_limit = 0
|
||||||
profile.team_access_allowed = False
|
profile.team_access_allowed = False
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ class BaseTestCase(TestCase):
|
|||||||
|
|
||||||
self.profile = Profile(user=self.alice, api_key="abc")
|
self.profile = Profile(user=self.alice, api_key="abc")
|
||||||
self.profile.team_access_allowed = True
|
self.profile.team_access_allowed = True
|
||||||
|
self.profile.sms_limit = 50
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
# Bob is on Alice's team and should have access to her stuff
|
# Bob is on Alice's team and should have access to her stuff
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.channels-table .channel-row > td {
|
.channels-table .channel-row > td {
|
||||||
line-height: 40px;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels-table .value-cell {
|
.channels-table .value-cell {
|
||||||
|
@ -113,6 +113,9 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
Never
|
Never
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if ch.kind == "sms" %}
|
||||||
|
<p>Used {{ profile.sms_sent_this_month }} of {{ profile.sms_limit }} sends this month.</p>
|
||||||
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -157,7 +160,7 @@
|
|||||||
<img src="{% static 'img/integrations/sms.png' %}"
|
<img src="{% static 'img/integrations/sms.png' %}"
|
||||||
class="icon" alt="SMS icon" />
|
class="icon" alt="SMS icon" />
|
||||||
|
|
||||||
<h2>SMS</h2>
|
<h2>SMS {% if show_pricing %}<small>(paid plans)</small>{% endif %}</h2>
|
||||||
<p>Get a text message to your phone when check goes down.</p>
|
<p>Get a text message to your phone when check goes down.</p>
|
||||||
|
|
||||||
<a href="{% url 'hc-add-sms' %}" class="btn btn-primary">Add Integration</a>
|
<a href="{% url 'hc-add-sms' %}" class="btn btn-primary">Add Integration</a>
|
||||||
|
@ -11,6 +11,15 @@
|
|||||||
|
|
||||||
<p>Get a SMS message to your specified number when check goes down.</p>
|
<p>Get a SMS message to your specified number when check goes down.</p>
|
||||||
|
|
||||||
|
{% if show_pricing and profile.sms_limit == 0 %}
|
||||||
|
<p class="alert alert-success">
|
||||||
|
<strong>Paid plan required.</strong>
|
||||||
|
SMS messaging is not available on the free plan–sending the messages
|
||||||
|
costs too much! Please upgrade to a
|
||||||
|
<a href="{% url 'hc-pricing' %}">paid plan</a> to enable SMS messaging.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<h2>Integration Settings</h2>
|
<h2>Integration Settings</h2>
|
||||||
|
|
||||||
<form method="post" class="form-horizontal" action="{% url 'hc-add-sms' %}">
|
<form method="post" class="form-horizontal" action="{% url 'hc-add-sms' %}">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user