Browse Source

Add an option for weekly reports (in addition to monthly)

pull/522/head
Pēteris Caune 4 years ago
parent
commit
df44ee58c0
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
13 changed files with 120 additions and 71 deletions
  1. +1
    -0
      CHANGELOG.md
  2. +1
    -0
      hc/accounts/admin.py
  3. +2
    -0
      hc/accounts/forms.py
  4. +18
    -0
      hc/accounts/migrations/0037_profile_tz.py
  5. +28
    -1
      hc/accounts/models.py
  6. +28
    -11
      hc/accounts/tests/test_notifications.py
  7. +4
    -8
      hc/accounts/views.py
  8. +4
    -5
      hc/api/management/commands/sendreports.py
  9. +4
    -2
      hc/api/tests/test_sendreports.py
  10. +0
    -19
      hc/lib/date.py
  11. +2
    -21
      hc/lib/tests/test_date.py
  12. +3
    -0
      static/js/notifications.js
  13. +25
    -4
      templates/accounts/notifications.html

+ 1
- 0
CHANGELOG.md View File

@ -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


+ 1
- 0
hc/accounts/admin.py View File

@ -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",


+ 2
- 0
hc/accounts/forms.py View File

@ -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
- 0
hc/accounts/migrations/0037_profile_tz.py View 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),
),
]

+ 28
- 1
hc/accounts/models.py View File

@ -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)


+ 28
- 11
hc/accounts/tests/test_notifications.py View File

@ -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="[email protected]", password="password") self.client.login(username="[email protected]", 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.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="[email protected]", 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="[email protected]", password="password") self.client.login(username="[email protected]", 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.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="[email protected]", password="password") self.client.login(username="[email protected]", 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.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="[email protected]", password="password") self.client.login(username="[email protected]", 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.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="[email protected]", password="password") self.client.login(username="[email protected]", 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.assertEqual(r.status_code, 200)
self.profile.refresh_from_db() self.profile.refresh_from_db()


+ 4
- 8
hc/accounts/views.py View File

@ -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_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"]: if profile.nag_period != form.cleaned_data["nag_period"]:
# Set the new nag period # Set the new nag period


+ 4
- 5
hc/api/management/commands/sendreports.py View File

@ -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


+ 4
- 2
hc/api/tests/test_sendreports.py View File

@ -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):
self.profile.reports_allowed = False
def test_it_obeys_reports_off(self):
self.profile.reports = "off"
self.profile.save() self.profile.save()
found = Command().handle_one_monthly_report() found = Command().handle_one_monthly_report()


+ 0
- 19
hc/lib/date.py View File

@ -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)

+ 2
- 21
hc/lib/tests/test_date.py View File

@ -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
- 0
static/js/notifications.js View File

@ -0,0 +1,3 @@
$(function () {
$("#tz").val(Intl.DateTimeFormat().resolvedOptions().timeZone);
});

+ 25
- 4
templates/accounts/notifications.html View File

@ -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
{{ profile.next_report_date.date }}.
{% timezone profile.tz %}
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…
Cancel
Save