Skip to content

Commit 488add5

Browse files
committed
Add script and code (and tests) to send milestone reminders to ADs
(for reviews) and group chairs. - Legacy-Id: 4557
1 parent 2b5345c commit 488add5

7 files changed

Lines changed: 293 additions & 3 deletions

File tree

ietf/bin/send-milestone-reminders

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env python
2+
#
3+
# This script will send various milestone reminders. It's supposed to
4+
# be run daily, and will then send reminders weekly/monthly as
5+
# appropriate.
6+
7+
import datetime, os
8+
import syslog
9+
10+
from ietf import settings
11+
from django.core import management
12+
management.setup_environ(settings)
13+
14+
syslog.openlog(os.path.basename(__file__), syslog.LOG_PID, syslog.LOG_LOCAL0)
15+
16+
from ietf.wginfo.mails import *
17+
18+
today = datetime.date.today()
19+
20+
MONDAY = 1
21+
FIRST_DAY_OF_MONTH = 1
22+
23+
if today.isoweekday() == MONDAY:
24+
# send milestone review reminders - ideally we'd keep track of
25+
# exactly when we sent one last time for a group, but it's a bit
26+
# complicated because people can change the milestones in the mean
27+
# time, so dodge all of this by simply sending once a week only
28+
for g in groups_with_milestones_needing_review():
29+
mail_sent = email_milestone_review_reminder(g, grace_period=7)
30+
if mail_sent:
31+
syslog.syslog("Sent milestone review reminder for %s %s" % (g.acronym, g.type.name))
32+
33+
34+
early_warning_days = 30
35+
36+
# send any milestones due reminders
37+
for g in groups_needing_milestones_due_reminder(early_warning_days):
38+
email_milestones_due(g, early_warning_days)
39+
syslog.syslog("Sent milestones due reminder for %s %s" % (g.acronym, g.type.name))
40+
41+
if today.day == FIRST_DAY_OF_MONTH:
42+
# send milestone overdue reminders - once a month
43+
for g in groups_needing_milestones_overdue_reminder(grace_period=30):
44+
email_milestones_overdue(g)
45+
syslog.syslog("Sent milestones overdue reminder for %s %s" % (g.acronym, g.type.name))
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% autoescape off %}{% filter wordwrap:73 %}This is a reminder that milestones in "{{ group.name }}" are soon due.
2+
3+
{% for m in milestones %}"{{ m.desc }}" is due {% if m.due == today %}today!{% else %}in {{ early_warning_days }} days.{% endif %}
4+
5+
{% endfor %}
6+
URL: {{ url }}
7+
{% endfilter %}{% endautoescape %}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{% 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:
2+
3+
{% for m in milestones %}"{{ m.desc }}"{% if m.days_ready != None %}
4+
Waiting for {{ m.days_ready }} day{{ m.days_ready|pluralize }}.{% endif %}
5+
6+
{% endfor %}
7+
Go here to either accept or reject the new milestones:
8+
9+
{{ url }}
10+
{% endfilter %}{% endautoescape %}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% autoescape off %}{% filter wordwrap:73 %}This is a reminder that milestones in "{{ group.name }}" are overdue.
2+
3+
{% for m in milestones %}"{{ m.desc }}" is overdue{% if m.months_overdue > 0 %} with {{ m.months_overdue }} month{{ m.months_overdue|pluralize }}{% endif %}!
4+
5+
{% endfor %}
6+
URL: {{ url }}
7+
{% endfilter %}{% endautoescape %}

ietf/utils/test_data.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ def make_test_data():
147147
person=p,
148148
email=email)
149149

150+
mars_wg.ad = ad
151+
mars_wg.save()
152+
150153
# create a bunch of ads for swarm tests
151154
for i in range(1, 10):
152155
u = User.objects.create(username="ad%s" % i)

ietf/wginfo/mails.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from ietf.utils.mail import send_mail, send_mail_text
1212

13+
from ietf.group.models import *
14+
1315
def email_milestones_changed(request, group, text):
1416
to = []
1517
if group.ad:
@@ -20,8 +22,96 @@ def email_milestones_changed(request, group, text):
2022

2123
text = wrap(strip_tags(text), 70)
2224
text += "\n\n"
23-
text += "URL: %s" % (settings.IDTRACKER_BASE_URL + urlreverse("wg_charter", kwargs=dict(acronym=group.acronym)))
25+
text += u"URL: %s" % (settings.IDTRACKER_BASE_URL + urlreverse("wg_charter", kwargs=dict(acronym=group.acronym)))
2426

2527
send_mail_text(request, to, None,
26-
"Milestones changed for %s %s" % (group.acronym, group.type.name),
28+
u"Milestones changed for %s %s" % (group.acronym, group.type.name),
2729
text)
30+
31+
def email_milestone_review_reminder(group, grace_period=7):
32+
"""Email reminders about milestones needing review to AD."""
33+
if not group.ad:
34+
return False
35+
36+
to = [group.ad.role_email("ad").formatted_email()]
37+
cc = [r.formatted_email() for r in group.role_set.filter(name="chair")]
38+
39+
now = datetime.datetime.now()
40+
too_early = True
41+
42+
milestones = group.groupmilestone_set.filter(state="review")
43+
for m in milestones:
44+
e = m.milestonegroupevent_set.filter(type="changed_milestone").order_by("-time")[:1]
45+
m.days_ready = (now - e[0].time).days if e else None
46+
47+
if m.days_ready == None or m.days_ready >= grace_period:
48+
too_early = False
49+
50+
if too_early:
51+
return False
52+
53+
subject = u"Reminder: Milestone%s needing review in %s %s" % ("s" if len(milestones) > 1 else "", group.acronym, group.type.name)
54+
55+
send_mail(None, to, None,
56+
subject,
57+
"wginfo/reminder_milestones_need_review.txt",
58+
dict(group=group,
59+
milestones=milestones,
60+
url=settings.IDTRACKER_BASE_URL + urlreverse("wg_edit_milestones", kwargs=dict(acronym=group.acronym))
61+
))
62+
63+
return True
64+
65+
def groups_with_milestones_needing_review():
66+
return Group.objects.filter(groupmilestone__state="review").distinct()
67+
68+
def email_milestones_due(group, early_warning_days):
69+
to = [r.formatted_email() for r in group.role_set.filter(name="chair")]
70+
71+
today = datetime.date.today()
72+
early_warning = today + datetime.timedelta(days=early_warning_days)
73+
74+
milestones = group.groupmilestone_set.filter(due__in=[today, early_warning],
75+
resolved="", state="active")
76+
77+
subject = u"Reminder: Milestone%s are soon due in %s %s" % ("s" if len(milestones) > 1 else "", group.acronym, group.type.name)
78+
79+
send_mail(None, to, None,
80+
subject,
81+
"wginfo/reminder_milestones_due.txt",
82+
dict(group=group,
83+
milestones=milestones,
84+
today=today,
85+
early_warning_days=early_warning_days,
86+
url=settings.IDTRACKER_BASE_URL + urlreverse("wg_charter", kwargs=dict(acronym=group.acronym))
87+
))
88+
89+
def groups_needing_milestones_due_reminder(early_warning_days):
90+
"""Return groups having milestones that are either
91+
early_warning_days from being due or are due today."""
92+
today = datetime.date.today()
93+
return Group.objects.filter(state="active", groupmilestone__due__in=[today, today + datetime.timedelta(days=early_warning_days)], groupmilestone__resolved="", groupmilestone__state="active").distinct()
94+
95+
def email_milestones_overdue(group):
96+
to = [r.formatted_email() for r in group.role_set.filter(name="chair")]
97+
98+
today = datetime.date.today()
99+
100+
milestones = group.groupmilestone_set.filter(due__lt=today, resolved="", state="active")
101+
for m in milestones:
102+
m.months_overdue = (today - m.due).days // 30
103+
104+
subject = u"Reminder: Milestone%s overdue in %s %s" % ("s" if len(milestones) > 1 else "", group.acronym, group.type.name)
105+
106+
send_mail(None, to, None,
107+
subject,
108+
"wginfo/reminder_milestones_overdue.txt",
109+
dict(group=group,
110+
milestones=milestones,
111+
url=settings.IDTRACKER_BASE_URL + urlreverse("wg_charter", kwargs=dict(acronym=group.acronym))
112+
))
113+
114+
def groups_needing_milestones_overdue_reminder(grace_period=30):
115+
cut_off = datetime.date.today() - datetime.timedelta(days=grace_period)
116+
return Group.objects.filter(state="active", groupmilestone__due__lt=cut_off, groupmilestone__resolved="", groupmilestone__state="active").distinct()
117+

ietf/wginfo/tests.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from ietf.group.utils import *
4848
from ietf.name.models import *
4949
from ietf.person.models import *
50+
from ietf.wginfo.mails import *
5051

5152

5253
class WgInfoUrlTestCase(SimpleUrlTestCase):
@@ -404,7 +405,7 @@ def test_accept_milestone(self):
404405
m = GroupMilestone.objects.get(pk=m1.pk)
405406
self.assertEquals(m.state_id, "active")
406407
self.assertEquals(group.groupevent_set.count(), events_before + 1)
407-
self.assertTrue("from review to active" in m.milestonegroupevent_set.all()[0].desc)
408+
self.assertTrue("to active from review" in m.milestonegroupevent_set.all()[0].desc)
408409

409410
def test_delete_milestone(self):
410411
m1, m2, group = self.create_test_milestones()
@@ -508,3 +509,130 @@ def test_reset_charter_milestones(self):
508509
self.assertEquals(GroupMilestone.objects.filter(due=m1.due, desc=m1.desc, state="charter").count(), 1)
509510

510511
self.assertEquals(group.charter.docevent_set.count(), events_before + 2) # 1 delete, 1 add
512+
513+
def test_send_review_needed_reminders(self):
514+
draft = make_test_data()
515+
516+
group = Group.objects.get(acronym="mars")
517+
person = Person.objects.get(user__username="marschairman")
518+
519+
m1 = GroupMilestone.objects.create(group=group,
520+
desc="Test 1",
521+
due=datetime.date.today(),
522+
resolved="",
523+
state_id="review")
524+
MilestoneGroupEvent.objects.create(
525+
group=group, type="changed_milestone",
526+
by=person, desc='Added milestone "%s"' % m1.desc, milestone=m1,
527+
time=datetime.datetime.now() - datetime.timedelta(seconds=60))
528+
529+
# send
530+
mailbox_before = len(outbox)
531+
for g in groups_with_milestones_needing_review():
532+
email_milestone_review_reminder(g)
533+
534+
self.assertEquals(len(outbox), mailbox_before) # too early to send reminder
535+
536+
537+
# add earlier added milestone
538+
m2 = GroupMilestone.objects.create(group=group,
539+
desc="Test 2",
540+
due=datetime.date.today(),
541+
resolved="",
542+
state_id="review")
543+
MilestoneGroupEvent.objects.create(
544+
group=group, type="changed_milestone",
545+
by=person, desc='Added milestone "%s"' % m2.desc, milestone=m2,
546+
time=datetime.datetime.now() - datetime.timedelta(days=10))
547+
548+
# send
549+
mailbox_before = len(outbox)
550+
for g in groups_with_milestones_needing_review():
551+
email_milestone_review_reminder(g)
552+
553+
self.assertEquals(len(outbox), mailbox_before + 1)
554+
self.assertTrue(group.acronym in outbox[-1]["Subject"])
555+
self.assertTrue(m1.desc in unicode(outbox[-1]))
556+
self.assertTrue(m2.desc in unicode(outbox[-1]))
557+
558+
def test_send_milestones_due_reminders(self):
559+
draft = make_test_data()
560+
561+
group = Group.objects.get(acronym="mars")
562+
person = Person.objects.get(user__username="marschairman")
563+
564+
early_warning_days = 30
565+
566+
m1 = GroupMilestone.objects.create(group=group,
567+
desc="Test 1",
568+
due=datetime.date.today(),
569+
resolved="Done",
570+
state_id="active")
571+
m2 = GroupMilestone.objects.create(group=group,
572+
desc="Test 2",
573+
due=datetime.date.today() + datetime.timedelta(days=early_warning_days - 10),
574+
resolved="",
575+
state_id="active")
576+
577+
# send
578+
mailbox_before = len(outbox)
579+
for g in groups_needing_milestones_due_reminder(early_warning_days):
580+
email_milestones_due(g, early_warning_days)
581+
582+
self.assertEquals(len(outbox), mailbox_before) # none found
583+
584+
m1.resolved = ""
585+
m1.save()
586+
587+
m2.due = datetime.date.today() + datetime.timedelta(days=early_warning_days)
588+
m2.save()
589+
590+
# send
591+
mailbox_before = len(outbox)
592+
for g in groups_needing_milestones_due_reminder(early_warning_days):
593+
email_milestones_due(g, early_warning_days)
594+
595+
self.assertEquals(len(outbox), mailbox_before + 1)
596+
self.assertTrue(group.acronym in outbox[-1]["Subject"])
597+
self.assertTrue(m1.desc in unicode(outbox[-1]))
598+
self.assertTrue(m2.desc in unicode(outbox[-1]))
599+
600+
def test_send_milestones_overdue_reminders(self):
601+
draft = make_test_data()
602+
603+
group = Group.objects.get(acronym="mars")
604+
person = Person.objects.get(user__username="marschairman")
605+
606+
m1 = GroupMilestone.objects.create(group=group,
607+
desc="Test 1",
608+
due=datetime.date.today() - datetime.timedelta(days=200),
609+
resolved="Done",
610+
state_id="active")
611+
m2 = GroupMilestone.objects.create(group=group,
612+
desc="Test 2",
613+
due=datetime.date.today() - datetime.timedelta(days=10),
614+
resolved="",
615+
state_id="active")
616+
617+
# send
618+
mailbox_before = len(outbox)
619+
for g in groups_needing_milestones_overdue_reminder(grace_period=30):
620+
email_milestones_overdue(g)
621+
622+
self.assertEquals(len(outbox), mailbox_before) # none found
623+
624+
m1.resolved = ""
625+
m1.save()
626+
627+
m2.due = datetime.date.today() - datetime.timedelta(days=300)
628+
m2.save()
629+
630+
# send
631+
mailbox_before = len(outbox)
632+
for g in groups_needing_milestones_overdue_reminder(grace_period=30):
633+
email_milestones_overdue(g)
634+
635+
self.assertEquals(len(outbox), mailbox_before + 1)
636+
self.assertTrue(group.acronym in outbox[-1]["Subject"])
637+
self.assertTrue(m1.desc in unicode(outbox[-1]))
638+
self.assertTrue(m2.desc in unicode(outbox[-1]))

0 commit comments

Comments
 (0)