From df44ee58c0db69e13e52222864d1b2777bf496e4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?=
Date: Mon, 24 May 2021 13:44:34 +0300
Subject: [PATCH] Add an option for weekly reports (in addition to monthly)
---
CHANGELOG.md | 1 +
hc/accounts/admin.py | 1 +
hc/accounts/forms.py | 2 ++
hc/accounts/migrations/0037_profile_tz.py | 18 +++++++++++
hc/accounts/models.py | 29 ++++++++++++++++-
hc/accounts/tests/test_notifications.py | 39 ++++++++++++++++-------
hc/accounts/views.py | 12 +++----
hc/api/management/commands/sendreports.py | 9 +++---
hc/api/tests/test_sendreports.py | 6 ++--
hc/lib/date.py | 19 -----------
hc/lib/tests/test_date.py | 23 ++-----------
static/js/notifications.js | 3 ++
templates/accounts/notifications.html | 29 ++++++++++++++---
13 files changed, 120 insertions(+), 71 deletions(-)
create mode 100644 hc/accounts/migrations/0037_profile_tz.py
create mode 100644 static/js/notifications.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 69343c9b..d9a20912 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/hc/accounts/admin.py b/hc/accounts/admin.py
index 02db5c7c..5bc79920 100644
--- a/hc/accounts/admin.py
+++ b/hc/accounts/admin.py
@@ -44,6 +44,7 @@ class ProfileFieldset(Fieldset):
fields = (
"email",
"reports",
+ "tz",
"next_report_date",
"nag_period",
"next_nag_date",
diff --git a/hc/accounts/forms.py b/hc/accounts/forms.py
index 6f714c0b..88720a17 100644
--- a/hc/accounts/forms.py
+++ b/hc/accounts/forms.py
@@ -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"]
diff --git a/hc/accounts/migrations/0037_profile_tz.py b/hc/accounts/migrations/0037_profile_tz.py
new file mode 100644
index 00000000..6260c7be
--- /dev/null
+++ b/hc/accounts/migrations/0037_profile_tz.py
@@ -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),
+ ),
+ ]
diff --git a/hc/accounts/models.py b/hc/accounts/models.py
index f3c36ee8..4498e4e3 100644
--- a/hc/accounts/models.py
+++ b/hc/accounts/models.py
@@ -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)
diff --git a/hc/accounts/tests/test_notifications.py b/hc/accounts/tests/test_notifications.py
index e68f89da..3fc0939a 100644
--- a/hc/accounts/tests/test_notifications.py
+++ b/hc/accounts/tests/test_notifications.py
@@ -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()
diff --git a/hc/accounts/views.py b/hc/accounts/views.py
index d7b2daf6..af1d5a1e 100644
--- a/hc/accounts/views.py
+++ b/hc/accounts/views.py
@@ -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
diff --git a/hc/api/management/commands/sendreports.py b/hc/api/management/commands/sendreports.py
index 3e543ee7..fd54ae81 100644
--- a/hc/api/management/commands/sendreports.py
+++ b/hc/api/management/commands/sendreports.py
@@ -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
diff --git a/hc/api/tests/test_sendreports.py b/hc/api/tests/test_sendreports.py
index a5af735c..d06821bc 100644
--- a/hc/api/tests/test_sendreports.py
+++ b/hc/api/tests/test_sendreports.py
@@ -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()
diff --git a/hc/lib/date.py b/hc/lib/date.py
index 92620b26..76859c02 100644
--- a/hc/lib/date.py
+++ b/hc/lib/date.py
@@ -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)
diff --git a/hc/lib/tests/test_date.py b/hc/lib/tests/test_date.py
index 2a017f04..9ec960d7 100644
--- a/hc/lib/tests/test_date.py
+++ b/hc/lib/tests/test_date.py
@@ -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)
diff --git a/static/js/notifications.js b/static/js/notifications.js
new file mode 100644
index 00000000..c7f4b90c
--- /dev/null
+++ b/static/js/notifications.js
@@ -0,0 +1,3 @@
+$(function () {
+ $("#tz").val(Intl.DateTimeFormat().resolvedOptions().timeZone);
+});
diff --git a/templates/accounts/notifications.html b/templates/accounts/notifications.html
index 51a70228..e41ec4db 100644
--- a/templates/accounts/notifications.html
+++ b/templates/accounts/notifications.html
@@ -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 %}
Email Reports
- Send me periodic emails reports:
+
+
+ Send me periodic email reports:
+
@@ -105,3 +118,11 @@
{% endblock %}
+
+{% block scripts %}
+{% compress js %}
+
+
+
+{% endcompress %}
+{% endblock %}