From 2bb769f7bb87bca9fdfb6411d36d43ee0d32988e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= Date: Sat, 12 Oct 2019 20:07:09 +0300 Subject: [PATCH] Send monthly reports on 1st of every month, not randomly during the month --- CHANGELOG.md | 1 + hc/accounts/views.py | 3 ++- hc/api/management/commands/sendreports.py | 18 +++++++------ hc/api/tests/test_sendreports.py | 33 +++++++++++++++++------ hc/lib/date.py | 20 ++++++++++++++ hc/lib/tests/test_date.py | 23 ++++++++++++++-- 6 files changed, 79 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c10b2cca..3d30e510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Add "last_duration" attribute to the Check API resource (#257) - Upgrade to psycopg2 2.8.3 - Add Go usage example +- Send monthly reports on 1st of every month, not randomly during the month ### Bug Fixes - Prevent double-clicking the submit button in signup form diff --git a/hc/accounts/views.py b/hc/accounts/views.py index 5c4e9a9b..5f16b4ba 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -32,6 +32,7 @@ from hc.accounts.forms import ( ) from hc.accounts.models import 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 NEXT_WHITELIST = ( @@ -355,7 +356,7 @@ def notifications(request): if profile.reports_allowed != form.cleaned_data["reports_allowed"]: profile.reports_allowed = form.cleaned_data["reports_allowed"] if profile.reports_allowed: - profile.next_report_date = now() + td(days=30) + profile.next_report_date = choose_next_report_date() else: profile.next_report_date = None diff --git a/hc/api/management/commands/sendreports.py b/hc/api/management/commands/sendreports.py index 3b5e2467..0214742f 100644 --- a/hc/api/management/commands/sendreports.py +++ b/hc/api/management/commands/sendreports.py @@ -6,6 +6,7 @@ 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): @@ -31,28 +32,29 @@ class Command(BaseCommand): ) def handle_one_monthly_report(self): - now = timezone.now() - month_before = now - timedelta(days=30) - month_after = now + timedelta(days=30) - - report_due = Q(next_report_date__lt=now) + report_due = Q(next_report_date__lt=timezone.now()) 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.filter(user__date_joined__lt=month_before) profile = q.first() if profile is None: + # No matching profiles found – nothing to do right now. return False - # A sort of optimistic lock. Try to update next_report_date, + # A sort of optimistic lock. Will try to update next_report_date, # and if does get modified, we're in drivers seat: qq = Profile.objects.filter( id=profile.id, next_report_date=profile.next_report_date ) - num_updated = qq.update(next_report_date=month_after) + # 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()) + return True + + num_updated = qq.update(next_report_date=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 e61fe093..22a49f4e 100644 --- a/hc/api/tests/test_sendreports.py +++ b/hc/api/tests/test_sendreports.py @@ -8,20 +8,24 @@ from hc.test import BaseTestCase from mock import Mock -class SendAlertsTestCase(BaseTestCase): +class SendReportsTestCase(BaseTestCase): def setUp(self): - super(SendAlertsTestCase, self).setUp() + super(SendReportsTestCase, self).setUp() - # Make alice eligible for reports: - # account needs to be more than one month old - self.alice.date_joined = now() - td(days=365) - self.alice.save() - - # Make alice eligible for nags: + # Make alice eligible for a monthly report: + self.profile.next_report_date = now() - td(hours=1) + # and for a nag self.profile.nag_period = td(hours=1) self.profile.next_nag_date = now() - td(seconds=10) self.profile.save() + # Disable bob's and charlie's monthly reports so they don't interfere + self.bobs_profile.reports_allowed = False + self.bobs_profile.save() + + self.charlies_profile.reports_allowed = False + self.charlies_profile.save() + # And it needs at least one check that has been pinged. self.check = Check(project=self.project, last_ping=now()) self.check.status = "down" @@ -37,6 +41,7 @@ class SendAlertsTestCase(BaseTestCase): self.profile.refresh_from_db() self.assertTrue(self.profile.next_report_date > now()) + self.assertEqual(self.profile.next_report_date.day, 1) self.assertEqual(len(mail.outbox), 1) email = mail.outbox[0] @@ -49,6 +54,18 @@ class SendAlertsTestCase(BaseTestCase): found = Command().handle_one_monthly_report() self.assertFalse(found) + def test_it_fills_blank_next_report_date(self): + self.profile.next_report_date = None + self.profile.save() + + found = Command().handle_one_monthly_report() + self.assertTrue(found) + + self.profile.refresh_from_db() + self.assertTrue(self.profile.next_report_date) + 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 self.profile.save() diff --git a/hc/lib/date.py b/hc/lib/date.py index a1a056d1..8cd1ae7a 100644 --- a/hc/lib/date.py +++ b/hc/lib/date.py @@ -1,4 +1,5 @@ from datetime import datetime as dt +from random import randint from django.utils import timezone @@ -81,3 +82,22 @@ 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 99be8e44..541a6367 100644 --- a/hc/lib/tests/test_date.py +++ b/hc/lib/tests/test_date.py @@ -1,7 +1,7 @@ -from datetime import timedelta as td +from datetime import datetime as dt, timedelta as td from django.test import TestCase -from hc.lib.date import format_hms +from hc.lib.date import format_hms, choose_next_report_date class DateFormattingTestCase(TestCase): @@ -24,3 +24,22 @@ 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)