mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-11 15:51:19 +00:00
Add an option for weekly reports (in addition to monthly)
This commit is contained in:
parent
03a538c5e2
commit
df44ee58c0
13 changed files with 120 additions and 71 deletions
|
@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Django 3.2.2
|
||||
- Improve the handling of unknown email addresses in the Sign In form
|
||||
- Add support for "... is UP" SMS notifications
|
||||
- Add an option for weekly reports (in addition to monthly)
|
||||
|
||||
## v1.20.0 - 2020-04-22
|
||||
|
||||
|
|
|
@ -44,6 +44,7 @@ class ProfileFieldset(Fieldset):
|
|||
fields = (
|
||||
"email",
|
||||
"reports",
|
||||
"tz",
|
||||
"next_report_date",
|
||||
"nag_period",
|
||||
"next_nag_date",
|
||||
|
|
|
@ -8,6 +8,7 @@ from django.contrib.auth import authenticate
|
|||
from django.contrib.auth.models import User
|
||||
from hc.accounts.models import REPORT_CHOICES
|
||||
from hc.api.models import TokenBucket
|
||||
from hc.front.validators import TimezoneValidator
|
||||
|
||||
|
||||
class LowercaseEmailField(forms.EmailField):
|
||||
|
@ -87,6 +88,7 @@ class PasswordLoginForm(forms.Form):
|
|||
class ReportSettingsForm(forms.Form):
|
||||
reports = forms.ChoiceField(choices=REPORT_CHOICES)
|
||||
nag_period = forms.IntegerField(min_value=0, max_value=86400)
|
||||
tz = forms.CharField(max_length=36, validators=[TimezoneValidator()])
|
||||
|
||||
def clean_nag_period(self):
|
||||
seconds = self.cleaned_data["nag_period"]
|
||||
|
|
18
hc/accounts/migrations/0037_profile_tz.py
Normal file
18
hc/accounts/migrations/0037_profile_tz.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.2 on 2021-05-24 09:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0036_fill_profile_reports'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='tz',
|
||||
field=models.CharField(default='UTC', max_length=36),
|
||||
),
|
||||
]
|
|
@ -1,4 +1,5 @@
|
|||
from datetime import timedelta
|
||||
import random
|
||||
from secrets import token_urlsafe
|
||||
from urllib.parse import quote, urlencode
|
||||
import uuid
|
||||
|
@ -14,7 +15,7 @@ from django.utils import timezone
|
|||
from fido2.ctap2 import AttestedCredentialData
|
||||
from hc.lib import emails
|
||||
from hc.lib.date import month_boundaries
|
||||
|
||||
import pytz
|
||||
|
||||
NO_NAG = timedelta()
|
||||
NAG_PERIODS = (
|
||||
|
@ -71,6 +72,7 @@ class Profile(models.Model):
|
|||
sort = models.CharField(max_length=20, default="created")
|
||||
deletion_notice_date = models.DateTimeField(null=True, blank=True)
|
||||
last_active_date = models.DateTimeField(null=True, blank=True)
|
||||
tz = models.CharField(max_length=36, default="UTC")
|
||||
|
||||
objects = ProfileManager()
|
||||
|
||||
|
@ -283,6 +285,31 @@ class Profile(models.Model):
|
|||
self.next_nag_date = None
|
||||
self.save(update_fields=["next_nag_date"])
|
||||
|
||||
def choose_next_report_date(self):
|
||||
""" Calculate the target date for the next monthly/weekly report.
|
||||
|
||||
Monthly reports should get sent on 1st of each month, between
|
||||
9AM and 10AM in user's timezone.
|
||||
|
||||
Weekly reports should get sent on Mondays, between
|
||||
9AM and 10AM in user's timezone.
|
||||
|
||||
"""
|
||||
|
||||
if self.reports == "off":
|
||||
return None
|
||||
|
||||
tz = pytz.timezone(self.tz)
|
||||
dt = timezone.now().astimezone(tz)
|
||||
dt = dt.replace(hour=9, minute=random.randrange(0, 60))
|
||||
|
||||
while True:
|
||||
dt += timedelta(days=1)
|
||||
if self.reports == "monthly" and dt.day == 1:
|
||||
return dt
|
||||
elif self.reports == "weekly" and dt.weekday() == 0:
|
||||
return dt
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
code = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
|
|
|
@ -6,6 +6,13 @@ from hc.test import BaseTestCase
|
|||
|
||||
|
||||
class NotificationsTestCase(BaseTestCase):
|
||||
url = "/accounts/profile/notifications/"
|
||||
|
||||
def _payload(self, **kwargs):
|
||||
result = {"reports": "monthly", "nag_period": "0", "tz": "Europe/Riga"}
|
||||
result.update(kwargs)
|
||||
return result
|
||||
|
||||
def test_it_saves_reports_monthly(self):
|
||||
self.profile.reports = "off"
|
||||
self.profile.reports_allowed = False
|
||||
|
@ -13,14 +20,28 @@ class NotificationsTestCase(BaseTestCase):
|
|||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"reports": "monthly", "nag_period": "0"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
r = self.client.post(self.url, self._payload())
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertTrue(self.profile.reports_allowed)
|
||||
self.assertEqual(self.profile.reports, "monthly")
|
||||
self.assertIsNotNone(self.profile.next_report_date)
|
||||
self.assertEqual(self.profile.next_report_date.day, 1)
|
||||
|
||||
def test_it_saves_reports_weekly(self):
|
||||
self.profile.reports = "off"
|
||||
self.profile.reports_allowed = False
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
r = self.client.post(self.url, self._payload(reports="weekly"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertTrue(self.profile.reports_allowed)
|
||||
self.assertEqual(self.profile.reports, "weekly")
|
||||
self.assertEqual(self.profile.next_report_date.weekday(), 0)
|
||||
|
||||
def test_it_saves_reports_off(self):
|
||||
self.profile.reports_allowed = True
|
||||
|
@ -30,8 +51,7 @@ class NotificationsTestCase(BaseTestCase):
|
|||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"reports": "off", "nag_period": "0"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
r = self.client.post(self.url, self._payload(reports="off"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
|
@ -44,8 +64,7 @@ class NotificationsTestCase(BaseTestCase):
|
|||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"reports": "off", "nag_period": "3600"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
r = self.client.post(self.url, self._payload(nag_period="3600"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
|
@ -58,8 +77,7 @@ class NotificationsTestCase(BaseTestCase):
|
|||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"reports": "off", "nag_period": "3600"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
r = self.client.post(self.url, self._payload(nag_period="3600"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
|
@ -72,8 +90,7 @@ class NotificationsTestCase(BaseTestCase):
|
|||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"reports": "off", "nag_period": "1234"}
|
||||
r = self.client.post("/accounts/profile/notifications/", form)
|
||||
r = self.client.post(self.url, self._payload(nag_period="1234"))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
|
|
|
@ -29,7 +29,6 @@ from hc.accounts import forms
|
|||
from hc.accounts.decorators import require_sudo_mode
|
||||
from hc.accounts.models import Credential, Profile, Project, Member
|
||||
from hc.api.models import Channel, Check, TokenBucket
|
||||
from hc.lib.date import choose_next_report_date
|
||||
from hc.payments.models import Subscription
|
||||
|
||||
POST_LOGIN_ROUTES = (
|
||||
|
@ -447,13 +446,10 @@ def notifications(request):
|
|||
if request.method == "POST":
|
||||
form = forms.ReportSettingsForm(request.POST)
|
||||
if form.is_valid():
|
||||
if profile.reports != form.cleaned_data["reports"]:
|
||||
profile.reports = form.cleaned_data["reports"]
|
||||
profile.reports_allowed = profile.reports == "monthly"
|
||||
if profile.reports_allowed:
|
||||
profile.next_report_date = choose_next_report_date()
|
||||
else:
|
||||
profile.next_report_date = None
|
||||
profile.reports = form.cleaned_data["reports"]
|
||||
profile.tz = form.cleaned_data["tz"]
|
||||
profile.next_report_date = profile.choose_next_report_date()
|
||||
profile.reports_allowed = profile.reports != "off"
|
||||
|
||||
if profile.nag_period != form.cleaned_data["nag_period"]:
|
||||
# Set the new nag period
|
||||
|
|
|
@ -5,7 +5,6 @@ from django.db.models import Q
|
|||
from django.utils import timezone
|
||||
from hc.accounts.models import NO_NAG, Profile
|
||||
from hc.api.models import Check
|
||||
from hc.lib.date import choose_next_report_date
|
||||
|
||||
|
||||
def num_pinged_checks(profile):
|
||||
|
@ -19,7 +18,7 @@ class Command(BaseCommand):
|
|||
tmpl = "Sent monthly report to %s"
|
||||
|
||||
def pause(self):
|
||||
time.sleep(1)
|
||||
time.sleep(3)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
|
@ -35,7 +34,7 @@ class Command(BaseCommand):
|
|||
report_not_scheduled = Q(next_report_date__isnull=True)
|
||||
|
||||
q = Profile.objects.filter(report_due | report_not_scheduled)
|
||||
q = q.filter(reports_allowed=True)
|
||||
q = q.exclude(reports="off")
|
||||
profile = q.first()
|
||||
|
||||
if profile is None:
|
||||
|
@ -50,10 +49,10 @@ class Command(BaseCommand):
|
|||
|
||||
# Next report date is currently not scheduled: schedule it and move on.
|
||||
if profile.next_report_date is None:
|
||||
qq.update(next_report_date=choose_next_report_date())
|
||||
qq.update(next_report_date=profile.choose_next_report_date())
|
||||
return True
|
||||
|
||||
num_updated = qq.update(next_report_date=choose_next_report_date())
|
||||
num_updated = qq.update(next_report_date=profile.choose_next_report_date())
|
||||
if num_updated != 1:
|
||||
# next_report_date was already updated elsewhere, skipping
|
||||
return True
|
||||
|
|
|
@ -20,9 +20,11 @@ class SendReportsTestCase(BaseTestCase):
|
|||
self.profile.save()
|
||||
|
||||
# Disable bob's and charlie's monthly reports so they don't interfere
|
||||
self.bobs_profile.reports = "off"
|
||||
self.bobs_profile.reports_allowed = False
|
||||
self.bobs_profile.save()
|
||||
|
||||
self.charlies_profile.reports = "off"
|
||||
self.charlies_profile.reports_allowed = False
|
||||
self.charlies_profile.save()
|
||||
|
||||
|
@ -66,8 +68,8 @@ class SendReportsTestCase(BaseTestCase):
|
|||
self.assertEqual(self.profile.next_report_date.day, 1)
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_it_obeys_reports_allowed_flag(self):
|
||||
self.profile.reports_allowed = False
|
||||
def test_it_obeys_reports_off(self):
|
||||
self.profile.reports = "off"
|
||||
self.profile.save()
|
||||
|
||||
found = Command().handle_one_monthly_report()
|
||||
|
|
|
@ -85,22 +85,3 @@ def month_boundaries(months=2):
|
|||
y = y - 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def choose_next_report_date(now=None):
|
||||
""" Calculate the target date for the next monthly report.
|
||||
|
||||
Monthly reports should get sent on 1st of each month, at a random
|
||||
time after 12PM UTC (so it's over the month boundary even in UTC-12).
|
||||
|
||||
"""
|
||||
|
||||
if now is None:
|
||||
now = timezone.now()
|
||||
|
||||
h, m, s = randint(12, 23), randint(0, 59), randint(0, 59)
|
||||
|
||||
if now.month == 12:
|
||||
return now.replace(now.year + 1, 1, 1, h, m, s)
|
||||
else:
|
||||
return now.replace(now.year, now.month + 1, 1, h, m, s)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from datetime import datetime as dt, timedelta as td
|
||||
from datetime import timedelta as td
|
||||
from django.test import TestCase
|
||||
|
||||
from hc.lib.date import format_hms, choose_next_report_date
|
||||
from hc.lib.date import format_hms
|
||||
|
||||
|
||||
class DateFormattingTestCase(TestCase):
|
||||
|
@ -28,22 +28,3 @@ class DateFormattingTestCase(TestCase):
|
|||
|
||||
s = format_hms(td(seconds=60 * 60))
|
||||
self.assertEqual(s, "1 h 0 min 0 sec")
|
||||
|
||||
|
||||
class NextReportDateTestCase(TestCase):
|
||||
def test_it_works(self):
|
||||
# October
|
||||
nao = dt(year=2019, month=10, day=15, hour=6)
|
||||
result = choose_next_report_date(nao)
|
||||
self.assertEqual(result.year, 2019)
|
||||
self.assertEqual(result.month, 11)
|
||||
self.assertEqual(result.day, 1)
|
||||
self.assertTrue(result.hour >= 12)
|
||||
|
||||
# December
|
||||
nao = dt(year=2019, month=12, day=15, hour=6)
|
||||
result = choose_next_report_date(nao)
|
||||
self.assertEqual(result.year, 2020)
|
||||
self.assertEqual(result.month, 1)
|
||||
self.assertEqual(result.day, 1)
|
||||
self.assertTrue(result.hour >= 12)
|
||||
|
|
3
static/js/notifications.js
Normal file
3
static/js/notifications.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
$(function () {
|
||||
$("#tz").val(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% load hc_extras %}
|
||||
{% load compress hc_extras static tz %}
|
||||
|
||||
{% block title %}Account Settings - {{ site_name }}{% endblock %}
|
||||
|
||||
|
@ -31,7 +31,9 @@
|
|||
{% csrf_token %}
|
||||
<h2>Email Reports</h2>
|
||||
|
||||
<p>Send me periodic emails reports:</p>
|
||||
<input id="tz" type="hidden" name="tz" value="{{ profile.tz }}" />
|
||||
|
||||
<p>Send me periodic email reports:</p>
|
||||
<label class="radio-container">
|
||||
<input
|
||||
type="radio"
|
||||
|
@ -41,6 +43,15 @@
|
|||
<span class="radiomark"></span>
|
||||
Do not send me email reports
|
||||
</label>
|
||||
<label class="radio-container">
|
||||
<input
|
||||
type="radio"
|
||||
name="reports"
|
||||
value="weekly"
|
||||
{% if profile.reports == "weekly" %} checked {% endif %}>
|
||||
<span class="radiomark"></span>
|
||||
Weekly on Mondays
|
||||
</label>
|
||||
<label class="radio-container">
|
||||
<input
|
||||
type="radio"
|
||||
|
@ -86,8 +97,10 @@
|
|||
<p class="text-muted">
|
||||
Reports will be delivered to {{ profile.user.email }}. <br />
|
||||
{% if profile.next_report_date %}
|
||||
Next monthly report date is
|
||||
{{ profile.next_report_date.date }}.
|
||||
{% timezone profile.tz %}
|
||||
Next {{ profile.reports }} report date is
|
||||
{{ profile.next_report_date|date:"F j, Y" }}.
|
||||
{% endtimezone %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<br />
|
||||
|
@ -105,3 +118,11 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% compress js %}
|
||||
<script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
|
||||
<script src="{% static 'js/bootstrap.min.js' %}"></script>
|
||||
<script src="{% static 'js/notifications.js' %}"></script>
|
||||
{% endcompress %}
|
||||
{% endblock %}
|
||||
|
|
Loading…
Add table
Reference in a new issue