mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-11 15:51:19 +00:00
Adding an option to send daily or hourly reminders if any check is down. Fixes #48
This commit is contained in:
parent
ca2393d0a4
commit
d520706c27
21 changed files with 549 additions and 77 deletions
|
@ -21,7 +21,8 @@ class Fieldset:
|
|||
class ProfileFieldset(Fieldset):
|
||||
name = "User Profile"
|
||||
fields = ("email", "api_key", "current_team", "reports_allowed",
|
||||
"next_report_date", "token", "sort")
|
||||
"next_report_date", "nag_period", "next_nag_date",
|
||||
"token", "sort")
|
||||
|
||||
|
||||
class TeamFieldset(Fieldset):
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from datetime import timedelta as td
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
@ -16,6 +17,15 @@ class EmailPasswordForm(forms.Form):
|
|||
|
||||
class ReportSettingsForm(forms.Form):
|
||||
reports_allowed = forms.BooleanField(required=False)
|
||||
nag_period = forms.IntegerField(min_value=0, max_value=86400)
|
||||
|
||||
def clean_nag_period(self):
|
||||
seconds = self.cleaned_data["nag_period"]
|
||||
|
||||
if seconds not in (0, 3600, 86400):
|
||||
raise forms.ValidationError("Bad nag_period: %d" % seconds)
|
||||
|
||||
return td(seconds=seconds)
|
||||
|
||||
|
||||
class SetPasswordForm(forms.Form):
|
||||
|
|
26
hc/accounts/migrations/0012_auto_20171014_1002.py
Normal file
26
hc/accounts/migrations/0012_auto_20171014_1002.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-10-14 10:02
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0011_profile_sort'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='nag_period',
|
||||
field=models.DurationField(choices=[(datetime.timedelta(0), 'Disabled'), (datetime.timedelta(0, 3600), 'Hourly'), (datetime.timedelta(1), 'Daily')], default=datetime.timedelta(0)),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='next_nag_date',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -13,6 +13,12 @@ from django.utils import timezone
|
|||
from hc.lib import emails
|
||||
|
||||
|
||||
NO_NAG = timedelta()
|
||||
NAG_PERIODS = ((NO_NAG, "Disabled"),
|
||||
(timedelta(hours=1), "Hourly"),
|
||||
(timedelta(days=1), "Daily"))
|
||||
|
||||
|
||||
def month(dt):
|
||||
""" For a given datetime, return the matching first-day-of-month date. """
|
||||
return dt.date().replace(day=1)
|
||||
|
@ -23,7 +29,7 @@ class ProfileManager(models.Manager):
|
|||
try:
|
||||
return user.profile
|
||||
except Profile.DoesNotExist:
|
||||
profile = Profile(user=user, team_access_allowed=user.is_superuser)
|
||||
profile = Profile(user=user)
|
||||
if not settings.USE_PAYMENTS:
|
||||
# If not using payments, set high limits
|
||||
profile.check_limit = 500
|
||||
|
@ -41,6 +47,8 @@ class Profile(models.Model):
|
|||
team_access_allowed = models.BooleanField(default=False)
|
||||
next_report_date = models.DateTimeField(null=True, blank=True)
|
||||
reports_allowed = models.BooleanField(default=True)
|
||||
nag_period = models.DurationField(default=NO_NAG, choices=NAG_PERIODS)
|
||||
next_nag_date = models.DateTimeField(null=True, blank=True)
|
||||
ping_log_limit = models.IntegerField(default=100)
|
||||
check_limit = models.IntegerField(default=20)
|
||||
token = models.CharField(max_length=128, blank=True)
|
||||
|
@ -58,6 +66,9 @@ class Profile(models.Model):
|
|||
def __str__(self):
|
||||
return self.team_name or self.user.email
|
||||
|
||||
def notifications_url(self):
|
||||
return settings.SITE_ROOT + reverse("hc-notifications")
|
||||
|
||||
def team(self):
|
||||
# compare ids to avoid SQL queries
|
||||
if self.current_team_id and self.current_team_id != self.id:
|
||||
|
@ -106,11 +117,15 @@ class Profile(models.Model):
|
|||
self.api_key = base64.urlsafe_b64encode(os.urandom(24))
|
||||
self.save()
|
||||
|
||||
def send_report(self):
|
||||
# reset next report date first:
|
||||
now = timezone.now()
|
||||
self.next_report_date = now + timedelta(days=30)
|
||||
self.save()
|
||||
def send_report(self, nag=False):
|
||||
# Are there any non-new checks in the account?
|
||||
q = self.user.check_set.filter(last_ping__isnull=False)
|
||||
if not q.exists():
|
||||
return False
|
||||
|
||||
num_down = q.filter(status="down").count()
|
||||
if nag and num_down == 0:
|
||||
return False
|
||||
|
||||
token = signing.Signer().sign(uuid.uuid4())
|
||||
path = reverse("hc-unsubscribe-reports", args=[self.user.username])
|
||||
|
@ -118,11 +133,16 @@ class Profile(models.Model):
|
|||
|
||||
ctx = {
|
||||
"checks": self.user.check_set.order_by("created"),
|
||||
"now": now,
|
||||
"unsub_link": unsub_link
|
||||
"now": timezone.now(),
|
||||
"unsub_link": unsub_link,
|
||||
"notifications_url": self.notifications_url,
|
||||
"nag": nag,
|
||||
"nag_period": self.nag_period.total_seconds(),
|
||||
"num_down": num_down
|
||||
}
|
||||
|
||||
emails.report(self.user.email, ctx)
|
||||
return True
|
||||
|
||||
def can_invite(self):
|
||||
return self.member_set.count() < self.team_limit
|
||||
|
@ -161,6 +181,16 @@ class Profile(models.Model):
|
|||
self.save()
|
||||
return True
|
||||
|
||||
def set_next_nag_date(self):
|
||||
""" Set next_nag_date for all members of this team. """
|
||||
|
||||
is_owner = models.Q(id=self.id)
|
||||
is_member = models.Q(user__member__team=self)
|
||||
q = Profile.objects.filter(is_owner | is_member)
|
||||
q = q.exclude(nag_period=NO_NAG)
|
||||
|
||||
q.update(next_nag_date=timezone.now() + models.F("nag_period"))
|
||||
|
||||
|
||||
class Member(models.Model):
|
||||
team = models.ForeignKey(Profile, models.CASCADE)
|
||||
|
|
|
@ -1,24 +1,60 @@
|
|||
from datetime import timedelta as td
|
||||
|
||||
from django.utils.timezone import now
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class NotificationsTestCase(BaseTestCase):
|
||||
|
||||
def test_it_saves_reports_allowed_true(self):
|
||||
self.profile.reports_allowed = False
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"reports_allowed": "on"}
|
||||
form = {"reports_allowed": "on", "nag_period": "0"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
assert r.status_code == 200
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.alice.profile.refresh_from_db()
|
||||
self.assertTrue(self.alice.profile.reports_allowed)
|
||||
self.profile.refresh_from_db()
|
||||
self.assertTrue(self.profile.reports_allowed)
|
||||
self.assertIsNotNone(self.profile.next_report_date)
|
||||
|
||||
def test_it_saves_reports_allowed_false(self):
|
||||
self.profile.reports_allowed = True
|
||||
self.profile.next_report_date = now()
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {}
|
||||
form = {"nag_period": "0"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
assert r.status_code == 200
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.alice.profile.refresh_from_db()
|
||||
self.assertFalse(self.alice.profile.reports_allowed)
|
||||
self.profile.refresh_from_db()
|
||||
self.assertFalse(self.profile.reports_allowed)
|
||||
self.assertIsNone(self.profile.next_report_date)
|
||||
|
||||
def test_it_saves_hourly_nag_period(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"nag_period": "3600"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
||||
self.assertIsNotNone(self.profile.next_nag_date)
|
||||
|
||||
def test_it_does_not_save_nonstandard_nag_period(self):
|
||||
self.profile.nag_period = td(seconds=3600)
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"nag_period": "1234"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.nag_period.total_seconds(), 3600)
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
from datetime import timedelta as td
|
||||
from django.core import mail
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import now
|
||||
from hc.test import BaseTestCase
|
||||
from hc.accounts.models import Member
|
||||
from hc.api.models import Check
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class ProfileTestCase(BaseTestCase):
|
||||
|
@ -16,8 +18,8 @@ class ProfileTestCase(BaseTestCase):
|
|||
assert r.status_code == 302
|
||||
|
||||
# profile.token should be set now
|
||||
self.alice.profile.refresh_from_db()
|
||||
token = self.alice.profile.token
|
||||
self.profile.refresh_from_db()
|
||||
token = self.profile.token
|
||||
self.assertTrue(len(token) > 10)
|
||||
|
||||
# And an email should have been sent
|
||||
|
@ -32,8 +34,8 @@ class ProfileTestCase(BaseTestCase):
|
|||
r = self.client.post("/accounts/profile/", form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.alice.profile.refresh_from_db()
|
||||
api_key = self.alice.profile.api_key
|
||||
self.profile.refresh_from_db()
|
||||
api_key = self.profile.api_key
|
||||
self.assertTrue(len(api_key) > 10)
|
||||
|
||||
def test_it_revokes_api_key(self):
|
||||
|
@ -43,14 +45,16 @@ class ProfileTestCase(BaseTestCase):
|
|||
r = self.client.post("/accounts/profile/", form)
|
||||
assert r.status_code == 200
|
||||
|
||||
self.alice.profile.refresh_from_db()
|
||||
self.assertEqual(self.alice.profile.api_key, "")
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.api_key, "")
|
||||
|
||||
def test_it_sends_report(self):
|
||||
check = Check(name="Test Check", user=self.alice)
|
||||
check.last_ping = now()
|
||||
check.save()
|
||||
|
||||
self.alice.profile.send_report()
|
||||
sent = self.profile.send_report()
|
||||
self.assertTrue(sent)
|
||||
|
||||
# And an email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
@ -59,6 +63,38 @@ class ProfileTestCase(BaseTestCase):
|
|||
self.assertEqual(message.subject, 'Monthly Report')
|
||||
self.assertIn("Test Check", message.body)
|
||||
|
||||
def test_it_sends_nag(self):
|
||||
check = Check(name="Test Check", user=self.alice)
|
||||
check.status = "down"
|
||||
check.last_ping = now()
|
||||
check.save()
|
||||
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.save()
|
||||
|
||||
sent = self.profile.send_report(nag=True)
|
||||
self.assertTrue(sent)
|
||||
|
||||
# And an email should have been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
message = mail.outbox[0]
|
||||
|
||||
self.assertEqual(message.subject, 'Reminder: 1 check still down')
|
||||
self.assertIn("Test Check", message.body)
|
||||
|
||||
def test_it_skips_nag_if_none_down(self):
|
||||
check = Check(name="Test Check", user=self.alice)
|
||||
check.last_ping = now()
|
||||
check.save()
|
||||
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.save()
|
||||
|
||||
sent = self.profile.send_report(nag=True)
|
||||
self.assertFalse(sent)
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_it_adds_team_member(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
|
@ -67,7 +103,7 @@ class ProfileTestCase(BaseTestCase):
|
|||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
member_emails = set()
|
||||
for member in self.alice.profile.member_set.all():
|
||||
for member in self.profile.member_set.all():
|
||||
member_emails.add(member.user.email)
|
||||
|
||||
self.assertEqual(len(member_emails), 2)
|
||||
|
@ -107,8 +143,8 @@ class ProfileTestCase(BaseTestCase):
|
|||
r = self.client.post("/accounts/profile/", form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.alice.profile.refresh_from_db()
|
||||
self.assertEqual(self.alice.profile.team_name, "Alpha Team")
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.team_name, "Alpha Team")
|
||||
|
||||
def test_it_switches_to_own_team(self):
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
|
@ -128,8 +164,8 @@ class ProfileTestCase(BaseTestCase):
|
|||
assert r.status_code == 302
|
||||
|
||||
# profile.token should be set now
|
||||
self.alice.profile.refresh_from_db()
|
||||
token = self.alice.profile.token
|
||||
self.profile.refresh_from_db()
|
||||
token = self.profile.token
|
||||
self.assertTrue(len(token) > 10)
|
||||
|
||||
# And an email should have been sent
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from datetime import timedelta as td
|
||||
|
||||
from django.core import signing
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
@ -5,6 +7,9 @@ from hc.test import BaseTestCase
|
|||
class UnsubscribeReportsTestCase(BaseTestCase):
|
||||
|
||||
def test_it_works(self):
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.save()
|
||||
|
||||
token = signing.Signer().sign("foo")
|
||||
url = "/accounts/unsubscribe_reports/alice/?token=%s" % token
|
||||
r = self.client.get(url)
|
||||
|
@ -12,3 +17,4 @@ class UnsubscribeReportsTestCase(BaseTestCase):
|
|||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertFalse(self.profile.reports_allowed)
|
||||
self.assertEqual(self.profile.nag_period.total_seconds(), 0)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from datetime import timedelta as td
|
||||
import uuid
|
||||
import re
|
||||
|
||||
|
@ -11,6 +12,7 @@ from django.contrib.auth.models import User
|
|||
from django.core import signing
|
||||
from django.http import HttpResponseForbidden, HttpResponseBadRequest
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.timezone import now
|
||||
from django.views.decorators.http import require_POST
|
||||
from hc.accounts.forms import (ChangeEmailForm, EmailPasswordForm,
|
||||
InviteTeamMemberForm, RemoveTeamMemberForm,
|
||||
|
@ -238,7 +240,22 @@ def notifications(request):
|
|||
if request.method == "POST":
|
||||
form = ReportSettingsForm(request.POST)
|
||||
if form.is_valid():
|
||||
profile.reports_allowed = form.cleaned_data["reports_allowed"]
|
||||
if profile.reports_allowed != form.cleaned_data["reports_allowed"]:
|
||||
profile.reports_allowed = form.cleaned_data["reports_allowed"]
|
||||
if profile.reports_allowed:
|
||||
profile.next_report_date = now() + td(days=30)
|
||||
else:
|
||||
profile.next_report_date = None
|
||||
|
||||
if profile.nag_period != form.cleaned_data["nag_period"]:
|
||||
# Set the new nag period
|
||||
profile.nag_period = form.cleaned_data["nag_period"]
|
||||
# and schedule next_nag_date:
|
||||
if profile.nag_period:
|
||||
profile.next_nag_date = now() + profile.nag_period
|
||||
else:
|
||||
profile.next_nag_date = None
|
||||
|
||||
profile.save()
|
||||
messages.success(request, "Your settings have been updated!")
|
||||
|
||||
|
@ -338,6 +355,7 @@ def unsubscribe_reports(request, username):
|
|||
user = User.objects.get(username=username)
|
||||
profile = Profile.objects.for_user(user)
|
||||
profile.reports_allowed = False
|
||||
profile.nag_period = td()
|
||||
profile.save()
|
||||
|
||||
return render(request, "accounts/unsubscribed.html")
|
||||
|
|
|
@ -8,9 +8,14 @@ from hc.api.models import Check
|
|||
|
||||
def notify(check_id, stdout):
|
||||
check = Check.objects.get(id=check_id)
|
||||
|
||||
tmpl = "Sending alert, status=%s, code=%s\n"
|
||||
stdout.write(tmpl % (check.status, check.code))
|
||||
|
||||
# Set dates for followup nags
|
||||
if check.status == "down" and check.user.profile:
|
||||
check.user.profile.set_next_nag_date()
|
||||
|
||||
# Send notifications
|
||||
errors = check.send_alert()
|
||||
for ch, error in errors:
|
||||
stdout.write("ERROR: %s %s %s\n" % (ch.kind, ch.value, error))
|
||||
|
|
|
@ -15,8 +15,8 @@ def num_pinged_checks(profile):
|
|||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Send due monthly reports'
|
||||
tmpl = "Sending monthly report to %s"
|
||||
help = 'Send due monthly reports and nags'
|
||||
tmpl = "Sent monthly report to %s"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
|
@ -27,7 +27,7 @@ class Command(BaseCommand):
|
|||
help='Keep running indefinitely in a 300 second wait loop',
|
||||
)
|
||||
|
||||
def handle_one_run(self):
|
||||
def handle_one_monthly_report(self):
|
||||
now = timezone.now()
|
||||
month_before = now - timedelta(days=30)
|
||||
month_after = now + timedelta(days=30)
|
||||
|
@ -38,39 +38,70 @@ class Command(BaseCommand):
|
|||
q = Profile.objects.filter(report_due | report_not_scheduled)
|
||||
q = q.filter(reports_allowed=True)
|
||||
q = q.filter(user__date_joined__lt=month_before)
|
||||
profiles = list(q)
|
||||
profile = q.first()
|
||||
|
||||
sent = 0
|
||||
for profile in profiles:
|
||||
qq = Profile.objects
|
||||
qq = qq.filter(id=profile.id,
|
||||
next_report_date=profile.next_report_date)
|
||||
if profile is None:
|
||||
return False
|
||||
|
||||
num_updated = qq.update(next_report_date=month_after)
|
||||
if num_updated != 1:
|
||||
# Was updated elsewhere, skipping
|
||||
continue
|
||||
# A sort of optimistic lock. Try to update next_report_date,
|
||||
# and if does get modified, we're in drivers seat:
|
||||
qq = Profile.objects.filter(id=profile.id,
|
||||
next_report_date=profile.next_report_date)
|
||||
|
||||
if num_pinged_checks(profile) == 0:
|
||||
continue
|
||||
num_updated = qq.update(next_report_date=month_after)
|
||||
if num_updated != 1:
|
||||
# next_report_date was already updated elsewhere, skipping
|
||||
return True
|
||||
|
||||
if profile.send_report():
|
||||
self.stdout.write(self.tmpl % profile.user.email)
|
||||
profile.send_report()
|
||||
# Pause before next report to avoid hitting sending quota
|
||||
time.sleep(1)
|
||||
sent += 1
|
||||
|
||||
return sent
|
||||
return True
|
||||
|
||||
def handle_one_nag(self):
|
||||
now = timezone.now()
|
||||
q = Profile.objects.filter(next_nag_date__lt=now)
|
||||
profile = q.first()
|
||||
|
||||
if profile is None:
|
||||
return False
|
||||
|
||||
qq = Profile.objects.filter(id=profile.id,
|
||||
next_nag_date=profile.next_nag_date)
|
||||
|
||||
num_updated = qq.update(next_nag_date=now + profile.nag_period)
|
||||
if num_updated != 1:
|
||||
# next_rag_date was already updated elsewhere, skipping
|
||||
return True
|
||||
|
||||
if profile.send_report(nag=True):
|
||||
self.stdout.write("Sent nag to %s" % profile.user.email)
|
||||
# Pause before next report to avoid hitting sending quota
|
||||
time.sleep(1)
|
||||
else:
|
||||
profile.next_nag_date = None
|
||||
profile.save()
|
||||
|
||||
return True
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if not options["loop"]:
|
||||
return "Sent %d reports" % self.handle_one_run()
|
||||
|
||||
self.stdout.write("sendreports is now running")
|
||||
while True:
|
||||
self.handle_one_run()
|
||||
# Monthly reports
|
||||
while self.handle_one_monthly_report():
|
||||
pass
|
||||
|
||||
# Daily and hourly nags
|
||||
while self.handle_one_nag():
|
||||
pass
|
||||
|
||||
if not options["loop"]:
|
||||
break
|
||||
|
||||
formatted = timezone.now().isoformat()
|
||||
self.stdout.write("-- MARK %s --" % formatted)
|
||||
|
||||
time.sleep(300)
|
||||
# Sleep for 1 minute before looking for more work
|
||||
time.sleep(60)
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
from datetime import timedelta
|
||||
from mock import patch
|
||||
from mock import Mock, patch
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
from hc.api.management.commands.sendalerts import Command
|
||||
from hc.api.management.commands.sendalerts import Command, notify
|
||||
from hc.api.models import Check
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
@ -93,3 +93,31 @@ class SendAlertsTestCase(BaseTestCase):
|
|||
|
||||
# It should call `notify` instead of `notify_on_thread`
|
||||
self.assertTrue(mock_notify.called)
|
||||
|
||||
def test_it_updates_owners_next_nag_date(self):
|
||||
self.profile.nag_period = timedelta(hours=1)
|
||||
self.profile.save()
|
||||
|
||||
check = Check(user=self.alice, status="down")
|
||||
check.last_ping = timezone.now() - timedelta(days=2)
|
||||
check.alert_after = check.get_alert_after()
|
||||
check.save()
|
||||
|
||||
notify(check.id, Mock())
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertIsNotNone(self.profile.next_nag_date)
|
||||
|
||||
def test_it_updates_members_next_nag_date(self):
|
||||
self.bobs_profile.nag_period = timedelta(hours=1)
|
||||
self.bobs_profile.save()
|
||||
|
||||
check = Check(user=self.alice, status="down")
|
||||
check.last_ping = timezone.now() - timedelta(days=2)
|
||||
check.alert_after = check.get_alert_after()
|
||||
check.save()
|
||||
|
||||
notify(check.id, Mock())
|
||||
|
||||
self.bobs_profile.refresh_from_db()
|
||||
self.assertIsNotNone(self.bobs_profile.next_nag_date)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from datetime import timedelta as td
|
||||
|
||||
from django.core import mail
|
||||
from django.utils.timezone import now
|
||||
from hc.api.management.commands.sendreports import Command
|
||||
from hc.api.models import Check
|
||||
|
@ -16,34 +17,72 @@ class SendAlertsTestCase(BaseTestCase):
|
|||
self.alice.date_joined = now() - td(days=365)
|
||||
self.alice.save()
|
||||
|
||||
# Make alice eligible for nags:
|
||||
self.profile.nag_period = td(hours=1)
|
||||
self.profile.next_nag_date = now() - td(seconds=10)
|
||||
self.profile.save()
|
||||
|
||||
# And it needs at least one check that has been pinged.
|
||||
self.check = Check(user=self.alice, last_ping=now())
|
||||
self.check.status = "down"
|
||||
self.check.save()
|
||||
|
||||
def test_it_sends_report(self):
|
||||
sent = Command().handle_one_run()
|
||||
self.assertEqual(sent, 1)
|
||||
found = Command().handle_one_monthly_report()
|
||||
self.assertTrue(found)
|
||||
|
||||
# Alice's profile should have been updated
|
||||
self.profile.refresh_from_db()
|
||||
self.assertTrue(self.profile.next_report_date > now())
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
def test_it_obeys_next_report_date(self):
|
||||
self.profile.next_report_date = now() + td(days=1)
|
||||
self.profile.save()
|
||||
|
||||
sent = Command().handle_one_run()
|
||||
self.assertEqual(sent, 0)
|
||||
found = Command().handle_one_monthly_report()
|
||||
self.assertFalse(found)
|
||||
|
||||
def test_it_obeys_reports_allowed_flag(self):
|
||||
self.profile.reports_allowed = False
|
||||
self.profile.save()
|
||||
|
||||
sent = Command().handle_one_run()
|
||||
self.assertEqual(sent, 0)
|
||||
found = Command().handle_one_monthly_report()
|
||||
self.assertFalse(found)
|
||||
|
||||
def test_it_requires_pinged_checks(self):
|
||||
self.check.delete()
|
||||
|
||||
sent = Command().handle_one_run()
|
||||
self.assertEqual(sent, 0)
|
||||
found = Command().handle_one_monthly_report()
|
||||
self.assertTrue(found)
|
||||
|
||||
# No email should have been sent:
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_it_sends_nag(self):
|
||||
found = Command().handle_one_nag()
|
||||
self.assertTrue(found)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertTrue(self.profile.next_nag_date > now())
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
def test_it_obeys_next_nag_date(self):
|
||||
self.profile.next_nag_date = now() + td(days=1)
|
||||
self.profile.save()
|
||||
|
||||
found = Command().handle_one_nag()
|
||||
self.assertFalse(found)
|
||||
|
||||
def test_nags_require_down_checks(self):
|
||||
self.check.status = "up"
|
||||
self.check.save()
|
||||
|
||||
found = Command().handle_one_nag()
|
||||
self.assertTrue(found)
|
||||
|
||||
# No email should have been sent:
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
# next_nag_date should now be unset
|
||||
self.profile.refresh_from_db()
|
||||
self.assertIsNone(self.profile.next_nag_date)
|
||||
|
|
67
static/css/checkbox.css
Normal file
67
static/css/checkbox.css
Normal file
|
@ -0,0 +1,67 @@
|
|||
/* Customize the label (the container) */
|
||||
.checkbox-container {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
margin-left: 20px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Hide the browser's default checkbox */
|
||||
.checkbox-container input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Create a custom checkbox */
|
||||
.checkmark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #DDD;
|
||||
}
|
||||
|
||||
/* On mouse-over tint the border */
|
||||
.checkmark:hover {
|
||||
border-color: #5db4ea;
|
||||
}
|
||||
|
||||
/* When the checkbox is checked, add a colored background */
|
||||
.checkbox-container input:checked ~ .checkmark {
|
||||
border-color: #0091EA;
|
||||
background-color: #0091EA;
|
||||
}
|
||||
|
||||
/* Create the checkmark/indicator (hidden when not checked) */
|
||||
.checkmark:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show the checkmark when checked */
|
||||
.checkbox-container input:checked ~ .checkmark:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Style the checkmark/indicator */
|
||||
.checkbox-container .checkmark:after {
|
||||
left: 7px;
|
||||
top: 3px;
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
-webkit-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
64
static/css/radio.css
Normal file
64
static/css/radio.css
Normal file
|
@ -0,0 +1,64 @@
|
|||
/* Customize the label (the container) */
|
||||
.radio-container {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
margin-bottom: 12px;
|
||||
margin-left: 20px;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Hide the browser's default radio button */
|
||||
.radio-container input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Create a custom radio button */
|
||||
.radiomark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #DDD;
|
||||
}
|
||||
|
||||
/* On mouse-over, tint the border */
|
||||
.radiomark:hover {
|
||||
border-color: #5db4ea;
|
||||
}
|
||||
|
||||
/* When the radio button is checked, add a colored background */
|
||||
.radio-container input:checked ~ .radiomark {
|
||||
border-color: #0091EA;
|
||||
background-color: #0091EA;
|
||||
}
|
||||
|
||||
/* Create the indicator (the dot/circle - hidden when not checked) */
|
||||
.radiomark:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show the indicator (dot/circle) when checked */
|
||||
.radio-container input:checked ~ .radiomark:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Style the indicator (dot/circle) */
|
||||
.radio-container .radiomark:after {
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
}
|
|
@ -14,7 +14,7 @@
|
|||
<div class="col-sm-2">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li><a href="{% url 'hc-profile' %}">Security</a></li>
|
||||
<li><a href="{% url 'hc-notifications' %}">Notifications</a></li>
|
||||
<li><a href="{% url 'hc-notifications' %}">Email Reports</a></li>
|
||||
<li class="active"><a href="{% url 'hc-badges' %}">Badges</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
<div class="col-sm-3">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li><a href="{% url 'hc-profile' %}">Account</a></li>
|
||||
<li class="active"><a href="{% url 'hc-notifications' %}">Notifications</a></li>
|
||||
<li class="active"><a href="{% url 'hc-notifications' %}">Email Reports</a></li>
|
||||
<li><a href="{% url 'hc-badges' %}">Badges</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -29,20 +29,66 @@
|
|||
<div class="col-sm-9 col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body settings-block">
|
||||
<h2>Monthly Reports</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
<h2>Email Reports</h2>
|
||||
|
||||
<p>Send me monthly emails about:</p>
|
||||
<label class="checkbox-container">
|
||||
<input
|
||||
name="reports_allowed"
|
||||
type="checkbox"
|
||||
{% if profile.reports_allowed %} checked {% endif %}>
|
||||
Each month send me a summary of my checks
|
||||
<span class="checkmark"></span>
|
||||
The status of checks my checks
|
||||
</label>
|
||||
|
||||
<br>
|
||||
<p>If any checks are down:</p>
|
||||
|
||||
<label class="radio-container">
|
||||
<input
|
||||
type="radio"
|
||||
name="nag_period"
|
||||
value="0"
|
||||
{% if profile.nag_period.total_seconds == 0 %} checked {% endif %}>
|
||||
<span class="radiomark"></span>
|
||||
Do not remind me
|
||||
</label>
|
||||
<label class="radio-container">
|
||||
<input
|
||||
type="radio"
|
||||
name="nag_period"
|
||||
value="86400"
|
||||
{% if profile.nag_period.total_seconds == 86400 %} checked {% endif %}>
|
||||
<span class="radiomark"></span>
|
||||
Remind me daily
|
||||
</label>
|
||||
<label class="radio-container">
|
||||
<input
|
||||
type="radio"
|
||||
name="nag_period"
|
||||
value="3600"
|
||||
{% if profile.nag_period.total_seconds == 3600 %} checked {% endif %}>
|
||||
<span class="radiomark"></span>
|
||||
Remind me hourly
|
||||
</label>
|
||||
|
||||
<br />
|
||||
<p style="color: #888">
|
||||
Reports will be delivered to {{ profile.user.email }}. <br />
|
||||
{% if profile.next_report_date %}
|
||||
Next monthly report date is
|
||||
{{ profile.next_report_date.date }}.
|
||||
{% endif %}
|
||||
</p>
|
||||
<br />
|
||||
|
||||
|
||||
<button
|
||||
name="update_reports_allowed"
|
||||
type="submit"
|
||||
class="btn btn-default pull-right">Save</button>
|
||||
class="btn btn-default pull-right">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
<div class="col-sm-3">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li class="active"><a href="{% url 'hc-profile' %}">Account</a></li>
|
||||
<li><a href="{% url 'hc-notifications' %}">Notifications</a></li>
|
||||
<li><a href="{% url 'hc-notifications' %}">Email Reports</a></li>
|
||||
<li><a href="{% url 'hc-badges' %}">Badges</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -37,6 +37,8 @@
|
|||
<link rel="stylesheet" href="{% static 'css/settings.css' %}" type="text/css">
|
||||
<link rel="stylesheet" href="{% static 'css/last_ping.css' %}" type="text/css">
|
||||
<link rel="stylesheet" href="{% static 'css/profile.css' %}" type="text/css">
|
||||
<link rel="stylesheet" href="{% static 'css/checkbox.css' %}" type="text/css">
|
||||
<link rel="stylesheet" href="{% static 'css/radio.css' %}" type="text/css">
|
||||
{% endcompress %}
|
||||
</head>
|
||||
<body class="page-{{ page }}">
|
||||
|
|
|
@ -3,15 +3,35 @@
|
|||
|
||||
{% block content %}
|
||||
Hello,<br />
|
||||
This is a monthly report sent by <a href="{% site_root %}">{% site_name %}</a>.
|
||||
|
||||
{% if nag %}
|
||||
This is a
|
||||
{% if nag_period == 3600 %}hourly{% endif %}
|
||||
{% if nag_period == 86400 %}daily{% endif %}
|
||||
reminder sent by <a href="{% site_root %}">{% site_name %}</a>.<br />
|
||||
|
||||
{% if num_down == 1%}
|
||||
One check is currently <strong>DOWN</strong>.
|
||||
{% else %}
|
||||
{{ num_down }} checks are currently <strong>DOWN</strong>.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
This is a monthly report sent by <a href="{% site_root %}">{% site_name %}</a>.
|
||||
{% endif %}
|
||||
|
||||
<br />
|
||||
{% include "emails/summary-html.html" %}
|
||||
|
||||
{% if nag %}
|
||||
<strong>Too many notifications?</strong>
|
||||
Visit the <a href="{{ notifications_url }}">Email Reports</a>
|
||||
page on {% site_name %} to set your notification preferences.
|
||||
{% else %}
|
||||
<strong>Just one more thing to check:</strong>
|
||||
Do you have more cron jobs,
|
||||
not yet on this list, that would benefit from monitoring?
|
||||
Get the ball rolling by adding one more!
|
||||
{% endif %}
|
||||
<br /><br />
|
||||
|
||||
Cheers,<br>
|
||||
|
@ -22,6 +42,6 @@ The {% escaped_site_name %} Team
|
|||
{% block unsub %}
|
||||
<br>
|
||||
<a href="{{ unsub_link }}" target="_blank" style="color: #666666; text-decoration: underline;">
|
||||
Unsubscribe from Monthly Reports
|
||||
Unsubscribe
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
{% load hc_extras %}
|
||||
Hello,
|
||||
|
||||
This is a monthly report sent by {% site_name %}.
|
||||
{% if nag %}This is a {% if nag_period == 3600 %}hourly {% endif %}{% if nag_period == 86400 %}daily {% endif %}reminder sent by {% site_name %}.
|
||||
|
||||
{% if num_down == 1%}One check is currently DOWN.{% else %}{{ num_down }} checks are currently DOWN.{% endif %}{% else %}This is a monthly report sent by {% site_name %}.{% endif %}
|
||||
|
||||
|
||||
{% include 'emails/summary-text.html' %}
|
||||
|
||||
|
|
|
@ -1,2 +1,6 @@
|
|||
Monthly Report
|
||||
{% if nag %}
|
||||
Reminder: {{ num_down }} check{{ num_down|pluralize }} still down
|
||||
{% else %}
|
||||
Monthly Report
|
||||
{% endif %}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue