Skip to content

Commit 99beb58

Browse files
committed
Implement a round-robin rotation scheme for reviewers instead of
relying on when they last reviewed. In-order assignments automatically move the rotation forwards while out-of-order assignments increment the skip next of the assigned reviewer. Include rotation in open review assignments email. Fix a couple of issues in the importer. - Legacy-Id: 12015
1 parent 6b3e93d commit 99beb58

10 files changed

Lines changed: 313 additions & 49 deletions

File tree

ietf/doc/tests_review.py

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@
1212

1313
import debug # pyflakes:ignore
1414

15-
from ietf.review.models import ReviewRequest, ReviewTeamResult, ReviewerSettings, ReviewWish, UnavailablePeriod
15+
from ietf.review.models import (ReviewRequest, ReviewTeamResult, ReviewerSettings,
16+
ReviewWish, UnavailablePeriod, NextReviewerInTeam)
17+
from ietf.review.utils import reviewer_rotation_list, possibly_advance_next_reviewer_for_team
1618
import ietf.review.mailarch
1719
from ietf.person.models import Email, Person
1820
from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName, DocRelationshipName
21+
from ietf.group.models import Group
1922
from ietf.doc.models import DocumentAuthor, Document, DocAlias, RelatedDocument, DocEvent
2023
from ietf.utils.test_utils import TestCase
21-
from ietf.utils.test_data import make_test_data, make_review_data
24+
from ietf.utils.test_data import make_test_data, make_review_data, create_person
2225
from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects
2326
from ietf.utils.mail import outbox, empty_outbox
2427

@@ -133,6 +136,94 @@ def test_close_request(self):
133136
self.assertEqual(len(outbox), 1)
134137
self.assertTrue("closed" in unicode(outbox[0]).lower())
135138

139+
def make_data_for_rotation_tests(self, doc):
140+
team = Group.objects.create(state_id="active", acronym="rotationteam", name="Review Team", type_id="dir",
141+
list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut"))
142+
143+
# make a bunch of reviewers
144+
reviewers = [
145+
create_person(team, "reviewer", name="Test Reviewer{}".format(i), username="testreviewer{}".format(i))
146+
for i in range(5)
147+
]
148+
149+
self.assertEqual(reviewers, reviewer_rotation_list(team))
150+
151+
return team, reviewers
152+
153+
def test_possibly_advance_next_reviewer_for_team(self):
154+
doc = make_test_data()
155+
156+
team, reviewers = self.make_data_for_rotation_tests(doc)
157+
158+
def get_skip_next(person):
159+
settings = (ReviewerSettings.objects.filter(team=team, person=person).first()
160+
or ReviewerSettings(team=team))
161+
return settings.skip_next
162+
163+
possibly_advance_next_reviewer_for_team(team, reviewers[0].pk)
164+
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[1])
165+
self.assertEqual(get_skip_next(reviewers[0]), 0)
166+
self.assertEqual(get_skip_next(reviewers[1]), 0)
167+
168+
possibly_advance_next_reviewer_for_team(team, reviewers[1].pk)
169+
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
170+
171+
# skip reviewer 2
172+
possibly_advance_next_reviewer_for_team(team, reviewers[3].pk)
173+
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
174+
self.assertEqual(get_skip_next(reviewers[0]), 0)
175+
self.assertEqual(get_skip_next(reviewers[1]), 0)
176+
self.assertEqual(get_skip_next(reviewers[2]), 0)
177+
self.assertEqual(get_skip_next(reviewers[3]), 1)
178+
179+
# pick reviewer 2, use up reviewer 3's skip_next
180+
possibly_advance_next_reviewer_for_team(team, reviewers[2].pk)
181+
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[4])
182+
self.assertEqual(get_skip_next(reviewers[0]), 0)
183+
self.assertEqual(get_skip_next(reviewers[1]), 0)
184+
self.assertEqual(get_skip_next(reviewers[2]), 0)
185+
self.assertEqual(get_skip_next(reviewers[3]), 0)
186+
self.assertEqual(get_skip_next(reviewers[4]), 0)
187+
188+
# check wrap-around
189+
possibly_advance_next_reviewer_for_team(team, reviewers[4].pk)
190+
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[0])
191+
self.assertEqual(get_skip_next(reviewers[0]), 0)
192+
self.assertEqual(get_skip_next(reviewers[1]), 0)
193+
self.assertEqual(get_skip_next(reviewers[2]), 0)
194+
self.assertEqual(get_skip_next(reviewers[3]), 0)
195+
self.assertEqual(get_skip_next(reviewers[4]), 0)
196+
197+
# unavailable
198+
today = datetime.date.today()
199+
UnavailablePeriod.objects.create(team=team, person=reviewers[1], start_date=today, end_date=today, availability="unavailable")
200+
possibly_advance_next_reviewer_for_team(team, reviewers[0].pk)
201+
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
202+
self.assertEqual(get_skip_next(reviewers[0]), 0)
203+
self.assertEqual(get_skip_next(reviewers[1]), 0)
204+
self.assertEqual(get_skip_next(reviewers[2]), 0)
205+
self.assertEqual(get_skip_next(reviewers[3]), 0)
206+
self.assertEqual(get_skip_next(reviewers[4]), 0)
207+
208+
# pick unavailable anyway
209+
possibly_advance_next_reviewer_for_team(team, reviewers[1].pk)
210+
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
211+
self.assertEqual(get_skip_next(reviewers[0]), 0)
212+
self.assertEqual(get_skip_next(reviewers[1]), 1)
213+
self.assertEqual(get_skip_next(reviewers[2]), 0)
214+
self.assertEqual(get_skip_next(reviewers[3]), 0)
215+
self.assertEqual(get_skip_next(reviewers[4]), 0)
216+
217+
# not through min_interval
218+
ReviewRequest.objects.create(team=team, doc=doc, type_id="early", state_id="accepted", deadline=today, requested_by=reviewers[0], reviewer=reviewers[2].email_set.first())
219+
possibly_advance_next_reviewer_for_team(team, reviewers[3].pk)
220+
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[4])
221+
self.assertEqual(get_skip_next(reviewers[0]), 0)
222+
self.assertEqual(get_skip_next(reviewers[1]), 1)
223+
self.assertEqual(get_skip_next(reviewers[2]), 0)
224+
self.assertEqual(get_skip_next(reviewers[3]), 0)
225+
self.assertEqual(get_skip_next(reviewers[4]), 0)
226+
136227
def test_assign_reviewer(self):
137228
doc = make_test_data()
138229

@@ -166,6 +257,7 @@ def test_assign_reviewer(self):
166257

167258
reviewer_settings = ReviewerSettings.objects.get(person__email=plain_email)
168259
reviewer_settings.filter_re = doc.name
260+
reviewer_settings.skip_next = 1
169261
reviewer_settings.save()
170262

171263
UnavailablePeriod.objects.create(
@@ -177,6 +269,14 @@ def test_assign_reviewer(self):
177269

178270
ReviewWish.objects.create(person=plain_email.person, team=review_req.team, doc=doc)
179271

272+
# pick a non-existing reviewer as next to see that we can
273+
# handle reviewers who have left
274+
NextReviewerInTeam.objects.filter(team=review_req.team).delete()
275+
NextReviewerInTeam.objects.create(
276+
team=review_req.team,
277+
next_reviewer=Person.objects.exclude(pk=plain_email.person_id).first(),
278+
)
279+
180280
assign_url = urlreverse('ietf.doc.views_review.assign_reviewer', kwargs={ "name": doc.name, "request_id": review_req.pk })
181281

182282

@@ -194,16 +294,18 @@ def test_assign_reviewer(self):
194294
self.assertEqual(r.status_code, 200)
195295
q = PyQuery(r.content)
196296
plain_label = q("option[value=\"{}\"]".format(plain_email.address)).text().lower()
197-
self.assertIn("ready for", plain_label)
198297
self.assertIn("reviewed document before", plain_label)
199298
self.assertIn("wishes to review", plain_label)
200299
self.assertIn("is author", plain_label)
201300
self.assertIn("regexp matches", plain_label)
202-
self.assertIn("unavailable", plain_label)
301+
self.assertIn("unavailable indefinitely", plain_label)
302+
self.assertIn("skip next 1", plain_label)
303+
self.assertIn("#1", plain_label)
203304

204305
# assign
205306
empty_outbox()
206-
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team).first()
307+
rotation_list = reviewer_rotation_list(review_req.team)
308+
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team, person=rotation_list[0]).first()
207309
r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk })
208310
self.assertEqual(r.status_code, 302)
209311

@@ -212,12 +314,13 @@ def test_assign_reviewer(self):
212314
self.assertEqual(review_req.reviewer, reviewer)
213315
self.assertEqual(len(outbox), 1)
214316
self.assertTrue("assigned" in unicode(outbox[0]))
317+
self.assertEqual(NextReviewerInTeam.objects.get(team=review_req.team).next_reviewer, rotation_list[1])
215318

216319
# re-assign
217320
empty_outbox()
218321
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
219322
review_req.save()
220-
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team).exclude(pk=reviewer.pk).first()
323+
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team, person=rotation_list[1]).first()
221324
r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk })
222325
self.assertEqual(r.status_code, 302)
223326

ietf/group/tests_review.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import datetime
22

3-
#from pyquery import PyQuery
3+
from pyquery import PyQuery
44

55
from django.core.urlresolvers import reverse as urlreverse
66

@@ -175,7 +175,10 @@ def test_email_open_review_assignments(self):
175175

176176
r = self.client.get(url)
177177
self.assertEqual(r.status_code, 200)
178-
self.assertTrue(review_req1.doc.name in unicontent(r))
178+
q = PyQuery(r.content)
179+
generated_text = q("[name=body]").text()
180+
self.assertTrue(review_req1.doc.name in generated_text)
181+
self.assertTrue(unicode(Person.objects.get(user__username="marschairman")) in generated_text)
179182

180183
empty_outbox()
181184
r = self.client.post(url, {

ietf/group/views_review.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
suggested_review_requests_for_team,
1717
unavailability_periods_to_list,
1818
current_unavailable_periods_for_reviewers,
19-
email_reviewer_availability_change)
19+
email_reviewer_availability_change,
20+
reviewer_rotation_list)
2021
from ietf.group.models import Role
2122
from ietf.group.utils import get_group_or_404
2223
from ietf.person.fields import PersonEmailChoiceField
@@ -221,9 +222,10 @@ def email_open_review_assignments(request, acronym, group_type=None):
221222
else:
222223
to = group.list_email
223224
subject = "Open review assignments in {}".format(group.acronym)
224-
# FIXME: add rotation info
225+
225226
body = render_to_string("group/email_open_review_assignments.txt", {
226227
"review_requests": review_requests,
228+
"rotation_list": reviewer_rotation_list(group)[:10],
227229
})
228230

229231
form = EmailOpenAssignmentsForm(initial={

ietf/review/import_from_review_tool.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from django.db import connections
1919
from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName,
2020
ReviewRequestStateName, ReviewTypeName, ReviewTeamResult,
21-
UnavailablePeriod)
21+
UnavailablePeriod, NextReviewerInTeam)
2222
from ietf.group.models import Group, Role, RoleName
2323
from ietf.person.models import Person, Email, Alias
2424
import argparse
@@ -138,9 +138,6 @@ def parse_timestamp(t):
138138
availability="unavailable",
139139
)
140140

141-
142-
# review requests
143-
144141
# check that we got the needed names
145142
results = { n.name.lower(): n for n in ReviewResultName.objects.all() }
146143

@@ -150,8 +147,23 @@ def parse_timestamp(t):
150147
missing_result_names = set(summaries) - set(results.keys())
151148
assert not missing_result_names, "missing result names: {} {}".format(missing_result_names, results.keys())
152149

153-
for s in summaries:
154-
ReviewTeamResult.objects.get_or_create(team=team, result=results[s])
150+
151+
# configuration options
152+
with db_con.cursor() as c:
153+
c.execute("select * from config;")
154+
155+
for row in namedtuplefetchall(c):
156+
if row.name == "next": # next reviewer
157+
NextReviewerInTeam.objects.filter(team=team).delete()
158+
NextReviewerInTeam.objects.create(team=team, next_reviewer=known_personnel[row.value].person)
159+
160+
if row.name == "summary-list": # review results used in team
161+
summaries = [v.strip().lower() for v in row.value.split(";") if v.strip()]
162+
163+
for s in summaries:
164+
ReviewTeamResult.objects.get_or_create(team=team, result=results[s])
165+
166+
# review requests
155167

156168
states = { n.slug: n for n in ReviewRequestStateName.objects.all() }
157169
# map some names

ietf/review/migrations/0001_initial.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ class Migration(migrations.Migration):
1515
]
1616

1717
operations = [
18+
migrations.CreateModel(
19+
name='NextReviewerInTeam',
20+
fields=[
21+
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
22+
('next_reviewer', models.ForeignKey(to='person.Person')),
23+
('team', models.ForeignKey(to='group.Group')),
24+
],
25+
options={
26+
},
27+
bases=(models.Model,),
28+
),
1829
migrations.CreateModel(
1930
name='ReviewerSettings',
2031
fields=[

ietf/review/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ class ReviewTeamResult(models.Model):
7777
def __unicode__(self):
7878
return u"{} in {}".format(self.result.name, self.group.acronym)
7979

80+
class NextReviewerInTeam(models.Model):
81+
team = models.ForeignKey(Group)
82+
next_reviewer = models.ForeignKey(Person)
83+
84+
def __unicode__(self):
85+
return u"{} next in {}".format(self.next_reviewer, self.team)
86+
8087
class ReviewRequest(models.Model):
8188
"""Represents a request for a review and the process it goes through.
8289
There should be one ReviewRequest entered for each combination of

ietf/review/resources.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from ietf.api import ToOneField # pyflakes:ignore
99

1010
from ietf.review.models import (ReviewerSettings, ReviewRequest, ReviewTeamResult,
11-
UnavailablePeriod, ReviewWish)
11+
UnavailablePeriod, ReviewWish, NextReviewerInTeam)
1212

1313

1414
from ietf.person.resources import PersonResource
@@ -125,3 +125,22 @@ class Meta:
125125
}
126126
api.review.register(ReviewWishResource())
127127

128+
129+
130+
from ietf.person.resources import PersonResource
131+
from ietf.group.resources import GroupResource
132+
class NextReviewerInTeamResource(ModelResource):
133+
team = ToOneField(GroupResource, 'team')
134+
next_reviewer = ToOneField(PersonResource, 'next_reviewer')
135+
class Meta:
136+
queryset = NextReviewerInTeam.objects.all()
137+
serializer = api.Serializer()
138+
cache = SimpleCache()
139+
#resource_name = 'nextreviewerinteam'
140+
filtering = {
141+
"id": ALL,
142+
"team": ALL_WITH_RELATIONS,
143+
"next_reviewer": ALL_WITH_RELATIONS,
144+
}
145+
api.review.register(NextReviewerInTeamResource())
146+

0 commit comments

Comments
 (0)