From 488add5004807d6fbe3802fdee26400545a63f84 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Sat, 30 Jun 2012 16:46:31 +0000 Subject: [PATCH] Add script and code (and tests) to send milestone reminders to ADs (for reviews) and group chairs. - Legacy-Id: 4557 --- ietf/bin/send-milestone-reminders | 45 ++++++ .../wginfo/reminder_milestones_due.txt | 7 + .../reminder_milestones_need_review.txt | 10 ++ .../wginfo/reminder_milestones_overdue.txt | 7 + ietf/utils/test_data.py | 3 + ietf/wginfo/mails.py | 94 ++++++++++++- ietf/wginfo/tests.py | 130 +++++++++++++++++- 7 files changed, 293 insertions(+), 3 deletions(-) create mode 100755 ietf/bin/send-milestone-reminders create mode 100644 ietf/templates/wginfo/reminder_milestones_due.txt create mode 100644 ietf/templates/wginfo/reminder_milestones_need_review.txt create mode 100644 ietf/templates/wginfo/reminder_milestones_overdue.txt diff --git a/ietf/bin/send-milestone-reminders b/ietf/bin/send-milestone-reminders new file mode 100755 index 000000000..a23a7bcb9 --- /dev/null +++ b/ietf/bin/send-milestone-reminders @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# +# This script will send various milestone reminders. It's supposed to +# be run daily, and will then send reminders weekly/monthly as +# appropriate. + +import datetime, os +import syslog + +from ietf import settings +from django.core import management +management.setup_environ(settings) + +syslog.openlog(os.path.basename(__file__), syslog.LOG_PID, syslog.LOG_LOCAL0) + +from ietf.wginfo.mails import * + +today = datetime.date.today() + +MONDAY = 1 +FIRST_DAY_OF_MONTH = 1 + +if today.isoweekday() == MONDAY: + # send milestone review reminders - ideally we'd keep track of + # exactly when we sent one last time for a group, but it's a bit + # complicated because people can change the milestones in the mean + # time, so dodge all of this by simply sending once a week only + for g in groups_with_milestones_needing_review(): + mail_sent = email_milestone_review_reminder(g, grace_period=7) + if mail_sent: + syslog.syslog("Sent milestone review reminder for %s %s" % (g.acronym, g.type.name)) + + +early_warning_days = 30 + +# send any milestones due reminders +for g in groups_needing_milestones_due_reminder(early_warning_days): + email_milestones_due(g, early_warning_days) + syslog.syslog("Sent milestones due reminder for %s %s" % (g.acronym, g.type.name)) + +if today.day == FIRST_DAY_OF_MONTH: + # send milestone overdue reminders - once a month + for g in groups_needing_milestones_overdue_reminder(grace_period=30): + email_milestones_overdue(g) + syslog.syslog("Sent milestones overdue reminder for %s %s" % (g.acronym, g.type.name)) diff --git a/ietf/templates/wginfo/reminder_milestones_due.txt b/ietf/templates/wginfo/reminder_milestones_due.txt new file mode 100644 index 000000000..ad905e576 --- /dev/null +++ b/ietf/templates/wginfo/reminder_milestones_due.txt @@ -0,0 +1,7 @@ +{% autoescape off %}{% filter wordwrap:73 %}This is a reminder that milestones in "{{ group.name }}" are soon due. + +{% for m in milestones %}"{{ m.desc }}" is due {% if m.due == today %}today!{% else %}in {{ early_warning_days }} days.{% endif %} + +{% endfor %} +URL: {{ url }} +{% endfilter %}{% endautoescape %} diff --git a/ietf/templates/wginfo/reminder_milestones_need_review.txt b/ietf/templates/wginfo/reminder_milestones_need_review.txt new file mode 100644 index 000000000..e32dbfac7 --- /dev/null +++ b/ietf/templates/wginfo/reminder_milestones_need_review.txt @@ -0,0 +1,10 @@ +{% autoescape off %}{% filter wordwrap:73 %}{{ milestones|length }} new milestone{{ milestones|pluralize }} in "{{ group.name }}" {% if milestones|length > 1 %}need{% else %}needs{%endif %} an AD review: + +{% for m in milestones %}"{{ m.desc }}"{% if m.days_ready != None %} +Waiting for {{ m.days_ready }} day{{ m.days_ready|pluralize }}.{% endif %} + +{% endfor %} +Go here to either accept or reject the new milestones: + +{{ url }} +{% endfilter %}{% endautoescape %} diff --git a/ietf/templates/wginfo/reminder_milestones_overdue.txt b/ietf/templates/wginfo/reminder_milestones_overdue.txt new file mode 100644 index 000000000..9acaf096f --- /dev/null +++ b/ietf/templates/wginfo/reminder_milestones_overdue.txt @@ -0,0 +1,7 @@ +{% autoescape off %}{% filter wordwrap:73 %}This is a reminder that milestones in "{{ group.name }}" are overdue. + +{% for m in milestones %}"{{ m.desc }}" is overdue{% if m.months_overdue > 0 %} with {{ m.months_overdue }} month{{ m.months_overdue|pluralize }}{% endif %}! + +{% endfor %} +URL: {{ url }} +{% endfilter %}{% endautoescape %} diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index 0494be591..3663a9ce8 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -147,6 +147,9 @@ def make_test_data(): person=p, email=email) + mars_wg.ad = ad + mars_wg.save() + # create a bunch of ads for swarm tests for i in range(1, 10): u = User.objects.create(username="ad%s" % i) diff --git a/ietf/wginfo/mails.py b/ietf/wginfo/mails.py index 3c346301f..f967a2e4f 100644 --- a/ietf/wginfo/mails.py +++ b/ietf/wginfo/mails.py @@ -10,6 +10,8 @@ from django.core.urlresolvers import reverse as urlreverse from ietf.utils.mail import send_mail, send_mail_text +from ietf.group.models import * + def email_milestones_changed(request, group, text): to = [] if group.ad: @@ -20,8 +22,96 @@ def email_milestones_changed(request, group, text): text = wrap(strip_tags(text), 70) text += "\n\n" - text += "URL: %s" % (settings.IDTRACKER_BASE_URL + urlreverse("wg_charter", kwargs=dict(acronym=group.acronym))) + text += u"URL: %s" % (settings.IDTRACKER_BASE_URL + urlreverse("wg_charter", kwargs=dict(acronym=group.acronym))) send_mail_text(request, to, None, - "Milestones changed for %s %s" % (group.acronym, group.type.name), + u"Milestones changed for %s %s" % (group.acronym, group.type.name), text) + +def email_milestone_review_reminder(group, grace_period=7): + """Email reminders about milestones needing review to AD.""" + if not group.ad: + return False + + to = [group.ad.role_email("ad").formatted_email()] + cc = [r.formatted_email() for r in group.role_set.filter(name="chair")] + + now = datetime.datetime.now() + too_early = True + + milestones = group.groupmilestone_set.filter(state="review") + for m in milestones: + e = m.milestonegroupevent_set.filter(type="changed_milestone").order_by("-time")[:1] + m.days_ready = (now - e[0].time).days if e else None + + if m.days_ready == None or m.days_ready >= grace_period: + too_early = False + + if too_early: + return False + + subject = u"Reminder: Milestone%s needing review in %s %s" % ("s" if len(milestones) > 1 else "", group.acronym, group.type.name) + + send_mail(None, to, None, + subject, + "wginfo/reminder_milestones_need_review.txt", + dict(group=group, + milestones=milestones, + url=settings.IDTRACKER_BASE_URL + urlreverse("wg_edit_milestones", kwargs=dict(acronym=group.acronym)) + )) + + return True + +def groups_with_milestones_needing_review(): + return Group.objects.filter(groupmilestone__state="review").distinct() + +def email_milestones_due(group, early_warning_days): + to = [r.formatted_email() for r in group.role_set.filter(name="chair")] + + today = datetime.date.today() + early_warning = today + datetime.timedelta(days=early_warning_days) + + milestones = group.groupmilestone_set.filter(due__in=[today, early_warning], + resolved="", state="active") + + subject = u"Reminder: Milestone%s are soon due in %s %s" % ("s" if len(milestones) > 1 else "", group.acronym, group.type.name) + + send_mail(None, to, None, + subject, + "wginfo/reminder_milestones_due.txt", + dict(group=group, + milestones=milestones, + today=today, + early_warning_days=early_warning_days, + url=settings.IDTRACKER_BASE_URL + urlreverse("wg_charter", kwargs=dict(acronym=group.acronym)) + )) + +def groups_needing_milestones_due_reminder(early_warning_days): + """Return groups having milestones that are either + early_warning_days from being due or are due today.""" + today = datetime.date.today() + return Group.objects.filter(state="active", groupmilestone__due__in=[today, today + datetime.timedelta(days=early_warning_days)], groupmilestone__resolved="", groupmilestone__state="active").distinct() + +def email_milestones_overdue(group): + to = [r.formatted_email() for r in group.role_set.filter(name="chair")] + + today = datetime.date.today() + + milestones = group.groupmilestone_set.filter(due__lt=today, resolved="", state="active") + for m in milestones: + m.months_overdue = (today - m.due).days // 30 + + subject = u"Reminder: Milestone%s overdue in %s %s" % ("s" if len(milestones) > 1 else "", group.acronym, group.type.name) + + send_mail(None, to, None, + subject, + "wginfo/reminder_milestones_overdue.txt", + dict(group=group, + milestones=milestones, + url=settings.IDTRACKER_BASE_URL + urlreverse("wg_charter", kwargs=dict(acronym=group.acronym)) + )) + +def groups_needing_milestones_overdue_reminder(grace_period=30): + cut_off = datetime.date.today() - datetime.timedelta(days=grace_period) + return Group.objects.filter(state="active", groupmilestone__due__lt=cut_off, groupmilestone__resolved="", groupmilestone__state="active").distinct() + diff --git a/ietf/wginfo/tests.py b/ietf/wginfo/tests.py index 094071066..0c568ffbd 100644 --- a/ietf/wginfo/tests.py +++ b/ietf/wginfo/tests.py @@ -47,6 +47,7 @@ from ietf.group.models import * from ietf.group.utils import * from ietf.name.models import * from ietf.person.models import * +from ietf.wginfo.mails import * class WgInfoUrlTestCase(SimpleUrlTestCase): @@ -404,7 +405,7 @@ class MilestoneTestCase(django.test.TestCase): m = GroupMilestone.objects.get(pk=m1.pk) self.assertEquals(m.state_id, "active") self.assertEquals(group.groupevent_set.count(), events_before + 1) - self.assertTrue("from review to active" in m.milestonegroupevent_set.all()[0].desc) + self.assertTrue("to active from review" in m.milestonegroupevent_set.all()[0].desc) def test_delete_milestone(self): m1, m2, group = self.create_test_milestones() @@ -508,3 +509,130 @@ class MilestoneTestCase(django.test.TestCase): self.assertEquals(GroupMilestone.objects.filter(due=m1.due, desc=m1.desc, state="charter").count(), 1) self.assertEquals(group.charter.docevent_set.count(), events_before + 2) # 1 delete, 1 add + + def test_send_review_needed_reminders(self): + draft = make_test_data() + + group = Group.objects.get(acronym="mars") + person = Person.objects.get(user__username="marschairman") + + m1 = GroupMilestone.objects.create(group=group, + desc="Test 1", + due=datetime.date.today(), + resolved="", + state_id="review") + MilestoneGroupEvent.objects.create( + group=group, type="changed_milestone", + by=person, desc='Added milestone "%s"' % m1.desc, milestone=m1, + time=datetime.datetime.now() - datetime.timedelta(seconds=60)) + + # send + mailbox_before = len(outbox) + for g in groups_with_milestones_needing_review(): + email_milestone_review_reminder(g) + + self.assertEquals(len(outbox), mailbox_before) # too early to send reminder + + + # add earlier added milestone + m2 = GroupMilestone.objects.create(group=group, + desc="Test 2", + due=datetime.date.today(), + resolved="", + state_id="review") + MilestoneGroupEvent.objects.create( + group=group, type="changed_milestone", + by=person, desc='Added milestone "%s"' % m2.desc, milestone=m2, + time=datetime.datetime.now() - datetime.timedelta(days=10)) + + # send + mailbox_before = len(outbox) + for g in groups_with_milestones_needing_review(): + email_milestone_review_reminder(g) + + self.assertEquals(len(outbox), mailbox_before + 1) + self.assertTrue(group.acronym in outbox[-1]["Subject"]) + self.assertTrue(m1.desc in unicode(outbox[-1])) + self.assertTrue(m2.desc in unicode(outbox[-1])) + + def test_send_milestones_due_reminders(self): + draft = make_test_data() + + group = Group.objects.get(acronym="mars") + person = Person.objects.get(user__username="marschairman") + + early_warning_days = 30 + + m1 = GroupMilestone.objects.create(group=group, + desc="Test 1", + due=datetime.date.today(), + resolved="Done", + state_id="active") + m2 = GroupMilestone.objects.create(group=group, + desc="Test 2", + due=datetime.date.today() + datetime.timedelta(days=early_warning_days - 10), + resolved="", + state_id="active") + + # send + mailbox_before = len(outbox) + for g in groups_needing_milestones_due_reminder(early_warning_days): + email_milestones_due(g, early_warning_days) + + self.assertEquals(len(outbox), mailbox_before) # none found + + m1.resolved = "" + m1.save() + + m2.due = datetime.date.today() + datetime.timedelta(days=early_warning_days) + m2.save() + + # send + mailbox_before = len(outbox) + for g in groups_needing_milestones_due_reminder(early_warning_days): + email_milestones_due(g, early_warning_days) + + self.assertEquals(len(outbox), mailbox_before + 1) + self.assertTrue(group.acronym in outbox[-1]["Subject"]) + self.assertTrue(m1.desc in unicode(outbox[-1])) + self.assertTrue(m2.desc in unicode(outbox[-1])) + + def test_send_milestones_overdue_reminders(self): + draft = make_test_data() + + group = Group.objects.get(acronym="mars") + person = Person.objects.get(user__username="marschairman") + + m1 = GroupMilestone.objects.create(group=group, + desc="Test 1", + due=datetime.date.today() - datetime.timedelta(days=200), + resolved="Done", + state_id="active") + m2 = GroupMilestone.objects.create(group=group, + desc="Test 2", + due=datetime.date.today() - datetime.timedelta(days=10), + resolved="", + state_id="active") + + # send + mailbox_before = len(outbox) + for g in groups_needing_milestones_overdue_reminder(grace_period=30): + email_milestones_overdue(g) + + self.assertEquals(len(outbox), mailbox_before) # none found + + m1.resolved = "" + m1.save() + + m2.due = datetime.date.today() - datetime.timedelta(days=300) + m2.save() + + # send + mailbox_before = len(outbox) + for g in groups_needing_milestones_overdue_reminder(grace_period=30): + email_milestones_overdue(g) + + self.assertEquals(len(outbox), mailbox_before + 1) + self.assertTrue(group.acronym in outbox[-1]["Subject"]) + self.assertTrue(m1.desc in unicode(outbox[-1])) + self.assertTrue(m2.desc in unicode(outbox[-1]))