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:

+