forked from GithubBackups/healthchecks
Add an option for weekly reports (in addition to monthly)
This commit is contained in:
parent
03a538c5e2
commit
df44ee58c0
@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Django 3.2.2
|
- Django 3.2.2
|
||||||
- Improve the handling of unknown email addresses in the Sign In form
|
- Improve the handling of unknown email addresses in the Sign In form
|
||||||
- Add support for "... is UP" SMS notifications
|
- Add support for "... is UP" SMS notifications
|
||||||
|
- Add an option for weekly reports (in addition to monthly)
|
||||||
|
|
||||||
## v1.20.0 - 2020-04-22
|
## v1.20.0 - 2020-04-22
|
||||||
|
|
||||||
|
@ -44,6 +44,7 @@ class ProfileFieldset(Fieldset):
|
|||||||
fields = (
|
fields = (
|
||||||
"email",
|
"email",
|
||||||
"reports",
|
"reports",
|
||||||
|
"tz",
|
||||||
"next_report_date",
|
"next_report_date",
|
||||||
"nag_period",
|
"nag_period",
|
||||||
"next_nag_date",
|
"next_nag_date",
|
||||||
|
@ -8,6 +8,7 @@ from django.contrib.auth import authenticate
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from hc.accounts.models import REPORT_CHOICES
|
from hc.accounts.models import REPORT_CHOICES
|
||||||
from hc.api.models import TokenBucket
|
from hc.api.models import TokenBucket
|
||||||
|
from hc.front.validators import TimezoneValidator
|
||||||
|
|
||||||
|
|
||||||
class LowercaseEmailField(forms.EmailField):
|
class LowercaseEmailField(forms.EmailField):
|
||||||
@ -87,6 +88,7 @@ class PasswordLoginForm(forms.Form):
|
|||||||
class ReportSettingsForm(forms.Form):
|
class ReportSettingsForm(forms.Form):
|
||||||
reports = forms.ChoiceField(choices=REPORT_CHOICES)
|
reports = forms.ChoiceField(choices=REPORT_CHOICES)
|
||||||
nag_period = forms.IntegerField(min_value=0, max_value=86400)
|
nag_period = forms.IntegerField(min_value=0, max_value=86400)
|
||||||
|
tz = forms.CharField(max_length=36, validators=[TimezoneValidator()])
|
||||||
|
|
||||||
def clean_nag_period(self):
|
def clean_nag_period(self):
|
||||||
seconds = self.cleaned_data["nag_period"]
|
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
|
from datetime import timedelta
|
||||||
|
import random
|
||||||
from secrets import token_urlsafe
|
from secrets import token_urlsafe
|
||||||
from urllib.parse import quote, urlencode
|
from urllib.parse import quote, urlencode
|
||||||
import uuid
|
import uuid
|
||||||
@ -14,7 +15,7 @@ from django.utils import timezone
|
|||||||
from fido2.ctap2 import AttestedCredentialData
|
from fido2.ctap2 import AttestedCredentialData
|
||||||
from hc.lib import emails
|
from hc.lib import emails
|
||||||
from hc.lib.date import month_boundaries
|
from hc.lib.date import month_boundaries
|
||||||
|
import pytz
|
||||||
|
|
||||||
NO_NAG = timedelta()
|
NO_NAG = timedelta()
|
||||||
NAG_PERIODS = (
|
NAG_PERIODS = (
|
||||||
@ -71,6 +72,7 @@ class Profile(models.Model):
|
|||||||
sort = models.CharField(max_length=20, default="created")
|
sort = models.CharField(max_length=20, default="created")
|
||||||
deletion_notice_date = models.DateTimeField(null=True, blank=True)
|
deletion_notice_date = models.DateTimeField(null=True, blank=True)
|
||||||
last_active_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()
|
objects = ProfileManager()
|
||||||
|
|
||||||
@ -283,6 +285,31 @@ class Profile(models.Model):
|
|||||||
self.next_nag_date = None
|
self.next_nag_date = None
|
||||||
self.save(update_fields=["next_nag_date"])
|
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):
|
class Project(models.Model):
|
||||||
code = models.UUIDField(default=uuid.uuid4, unique=True)
|
code = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||||
|
@ -6,6 +6,13 @@ from hc.test import BaseTestCase
|
|||||||
|
|
||||||
|
|
||||||
class NotificationsTestCase(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):
|
def test_it_saves_reports_monthly(self):
|
||||||
self.profile.reports = "off"
|
self.profile.reports = "off"
|
||||||
self.profile.reports_allowed = False
|
self.profile.reports_allowed = False
|
||||||
@ -13,14 +20,28 @@ class NotificationsTestCase(BaseTestCase):
|
|||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"reports": "monthly", "nag_period": "0"}
|
r = self.client.post(self.url, self._payload())
|
||||||
r = self.client.post("/accounts/profile/notifications/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertTrue(self.profile.reports_allowed)
|
self.assertTrue(self.profile.reports_allowed)
|
||||||
self.assertEqual(self.profile.reports, "monthly")
|
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):
|
def test_it_saves_reports_off(self):
|
||||||
self.profile.reports_allowed = True
|
self.profile.reports_allowed = True
|
||||||
@ -30,8 +51,7 @@ class NotificationsTestCase(BaseTestCase):
|
|||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"reports": "off", "nag_period": "0"}
|
r = self.client.post(self.url, self._payload(reports="off"))
|
||||||
r = self.client.post("/accounts/profile/notifications/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
@ -44,8 +64,7 @@ class NotificationsTestCase(BaseTestCase):
|
|||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"reports": "off", "nag_period": "3600"}
|
r = self.client.post(self.url, self._payload(nag_period="3600"))
|
||||||
r = self.client.post("/accounts/profile/notifications/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
@ -58,8 +77,7 @@ class NotificationsTestCase(BaseTestCase):
|
|||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"reports": "off", "nag_period": "3600"}
|
r = self.client.post(self.url, self._payload(nag_period="3600"))
|
||||||
r = self.client.post("/accounts/profile/notifications/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
@ -72,8 +90,7 @@ class NotificationsTestCase(BaseTestCase):
|
|||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
form = {"reports": "off", "nag_period": "1234"}
|
r = self.client.post(self.url, self._payload(nag_period="1234"))
|
||||||
r = self.client.post("/accounts/profile/notifications/", form)
|
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
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.decorators import require_sudo_mode
|
||||||
from hc.accounts.models import Credential, Profile, Project, Member
|
from hc.accounts.models import Credential, Profile, Project, Member
|
||||||
from hc.api.models import Channel, Check, TokenBucket
|
from hc.api.models import Channel, Check, TokenBucket
|
||||||
from hc.lib.date import choose_next_report_date
|
|
||||||
from hc.payments.models import Subscription
|
from hc.payments.models import Subscription
|
||||||
|
|
||||||
POST_LOGIN_ROUTES = (
|
POST_LOGIN_ROUTES = (
|
||||||
@ -447,13 +446,10 @@ def notifications(request):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = forms.ReportSettingsForm(request.POST)
|
form = forms.ReportSettingsForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
if profile.reports != form.cleaned_data["reports"]:
|
|
||||||
profile.reports = form.cleaned_data["reports"]
|
profile.reports = form.cleaned_data["reports"]
|
||||||
profile.reports_allowed = profile.reports == "monthly"
|
profile.tz = form.cleaned_data["tz"]
|
||||||
if profile.reports_allowed:
|
profile.next_report_date = profile.choose_next_report_date()
|
||||||
profile.next_report_date = choose_next_report_date()
|
profile.reports_allowed = profile.reports != "off"
|
||||||
else:
|
|
||||||
profile.next_report_date = None
|
|
||||||
|
|
||||||
if profile.nag_period != form.cleaned_data["nag_period"]:
|
if profile.nag_period != form.cleaned_data["nag_period"]:
|
||||||
# Set the new nag period
|
# Set the new nag period
|
||||||
|
@ -5,7 +5,6 @@ from django.db.models import Q
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from hc.accounts.models import NO_NAG, Profile
|
from hc.accounts.models import NO_NAG, Profile
|
||||||
from hc.api.models import Check
|
from hc.api.models import Check
|
||||||
from hc.lib.date import choose_next_report_date
|
|
||||||
|
|
||||||
|
|
||||||
def num_pinged_checks(profile):
|
def num_pinged_checks(profile):
|
||||||
@ -19,7 +18,7 @@ class Command(BaseCommand):
|
|||||||
tmpl = "Sent monthly report to %s"
|
tmpl = "Sent monthly report to %s"
|
||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
time.sleep(1)
|
time.sleep(3)
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@ -35,7 +34,7 @@ class Command(BaseCommand):
|
|||||||
report_not_scheduled = Q(next_report_date__isnull=True)
|
report_not_scheduled = Q(next_report_date__isnull=True)
|
||||||
|
|
||||||
q = Profile.objects.filter(report_due | report_not_scheduled)
|
q = Profile.objects.filter(report_due | report_not_scheduled)
|
||||||
q = q.filter(reports_allowed=True)
|
q = q.exclude(reports="off")
|
||||||
profile = q.first()
|
profile = q.first()
|
||||||
|
|
||||||
if profile is None:
|
if profile is None:
|
||||||
@ -50,10 +49,10 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Next report date is currently not scheduled: schedule it and move on.
|
# Next report date is currently not scheduled: schedule it and move on.
|
||||||
if profile.next_report_date is None:
|
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
|
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:
|
if num_updated != 1:
|
||||||
# next_report_date was already updated elsewhere, skipping
|
# next_report_date was already updated elsewhere, skipping
|
||||||
return True
|
return True
|
||||||
|
@ -20,9 +20,11 @@ class SendReportsTestCase(BaseTestCase):
|
|||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
# Disable bob's and charlie's monthly reports so they don't interfere
|
# 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.reports_allowed = False
|
||||||
self.bobs_profile.save()
|
self.bobs_profile.save()
|
||||||
|
|
||||||
|
self.charlies_profile.reports = "off"
|
||||||
self.charlies_profile.reports_allowed = False
|
self.charlies_profile.reports_allowed = False
|
||||||
self.charlies_profile.save()
|
self.charlies_profile.save()
|
||||||
|
|
||||||
@ -66,8 +68,8 @@ class SendReportsTestCase(BaseTestCase):
|
|||||||
self.assertEqual(self.profile.next_report_date.day, 1)
|
self.assertEqual(self.profile.next_report_date.day, 1)
|
||||||
self.assertEqual(len(mail.outbox), 0)
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
def test_it_obeys_reports_allowed_flag(self):
|
def test_it_obeys_reports_off(self):
|
||||||
self.profile.reports_allowed = False
|
self.profile.reports = "off"
|
||||||
self.profile.save()
|
self.profile.save()
|
||||||
|
|
||||||
found = Command().handle_one_monthly_report()
|
found = Command().handle_one_monthly_report()
|
||||||
|
@ -85,22 +85,3 @@ def month_boundaries(months=2):
|
|||||||
y = y - 1
|
y = y - 1
|
||||||
|
|
||||||
return result
|
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 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):
|
class DateFormattingTestCase(TestCase):
|
||||||
@ -28,22 +28,3 @@ class DateFormattingTestCase(TestCase):
|
|||||||
|
|
||||||
s = format_hms(td(seconds=60 * 60))
|
s = format_hms(td(seconds=60 * 60))
|
||||||
self.assertEqual(s, "1 h 0 min 0 sec")
|
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" %}
|
{% extends "base.html" %}
|
||||||
{% load hc_extras %}
|
{% load compress hc_extras static tz %}
|
||||||
|
|
||||||
{% block title %}Account Settings - {{ site_name }}{% endblock %}
|
{% block title %}Account Settings - {{ site_name }}{% endblock %}
|
||||||
|
|
||||||
@ -31,7 +31,9 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<h2>Email Reports</h2>
|
<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">
|
<label class="radio-container">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@ -41,6 +43,15 @@
|
|||||||
<span class="radiomark"></span>
|
<span class="radiomark"></span>
|
||||||
Do not send me email reports
|
Do not send me email reports
|
||||||
</label>
|
</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">
|
<label class="radio-container">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@ -86,8 +97,10 @@
|
|||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Reports will be delivered to {{ profile.user.email }}. <br />
|
Reports will be delivered to {{ profile.user.email }}. <br />
|
||||||
{% if profile.next_report_date %}
|
{% if profile.next_report_date %}
|
||||||
Next monthly report date is
|
{% timezone profile.tz %}
|
||||||
{{ profile.next_report_date.date }}.
|
Next {{ profile.reports }} report date is
|
||||||
|
{{ profile.next_report_date|date:"F j, Y" }}.
|
||||||
|
{% endtimezone %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<br />
|
<br />
|
||||||
@ -105,3 +118,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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…
x
Reference in New Issue
Block a user