Skip to content

Commit db4ea01

Browse files
committed
Take a bunch of factors into account when sorting reviewers for
assignment to a review request - Legacy-Id: 11626
1 parent 5c8be91 commit db4ea01

5 files changed

Lines changed: 120 additions & 40 deletions

File tree

ietf/doc/tests_review.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212

1313
import debug # pyflakes:ignore
1414

15-
from ietf.review.models import ReviewRequest, ReviewTeamResult
15+
from ietf.review.models import ReviewRequest, ReviewTeamResult, Reviewer
1616
import ietf.review.mailarch
1717
from ietf.person.models import Email, Person
18-
from ietf.name.models import ReviewResultName, ReviewRequestStateName
18+
from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName
19+
from ietf.doc.models import DocumentAuthor
1920
from ietf.utils.test_utils import TestCase
2021
from ietf.utils.test_data import make_test_data, make_review_data
2122
from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects
@@ -120,11 +121,40 @@ def test_close_request(self):
120121

121122
def test_assign_reviewer(self):
122123
doc = make_test_data()
124+
125+
# set up some reviewer-suitability factors
126+
plain_email = Email.objects.filter(person__user__username="plain").first()
127+
DocumentAuthor.objects.create(
128+
author=plain_email,
129+
document=doc,
130+
)
131+
doc.rev = "10"
132+
doc.save()
133+
134+
# review to assign to
123135
review_req = make_review_data(doc)
124136
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
125137
review_req.reviewer = None
126138
review_req.save()
127139

140+
# previous review
141+
ReviewRequest.objects.create(
142+
time=datetime.datetime.now() - datetime.timedelta(days=100),
143+
requested_by=Person.objects.get(name="(System)"),
144+
doc=doc,
145+
type=ReviewTypeName.objects.get(slug="early"),
146+
team=review_req.team,
147+
state=ReviewRequestStateName.objects.get(slug="completed"),
148+
reviewed_rev="01",
149+
deadline=datetime.datetime.now() - datetime.timedelta(days=80),
150+
reviewer=plain_email,
151+
)
152+
153+
reviewer_obj = Reviewer.objects.get(person__email=plain_email)
154+
reviewer_obj.filter_re = doc.name
155+
reviewer_obj.unavailable_until = datetime.datetime.now() + datetime.timedelta(days=10)
156+
reviewer_obj.save()
157+
128158
assign_url = urlreverse('ietf.doc.views_review.assign_reviewer', kwargs={ "name": doc.name, "request_id": review_req.pk })
129159

130160

@@ -140,6 +170,13 @@ def test_assign_reviewer(self):
140170
login_testing_unauthorized(self, "secretary", assign_url)
141171
r = self.client.get(assign_url)
142172
self.assertEqual(r.status_code, 200)
173+
q = PyQuery(r.content)
174+
plain_label = q("option[value=\"{}\"]".format(plain_email.address)).text().lower()
175+
self.assertIn("ready for", plain_label)
176+
self.assertIn("reviewed document before", plain_label)
177+
self.assertIn("is author", plain_label)
178+
self.assertIn("regexp matches", plain_label)
179+
self.assertIn("unavailable until", plain_label)
143180

144181
# assign
145182
empty_outbox()

ietf/doc/views_review.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
can_request_review_of_doc, can_manage_review_requests_for_team,
1919
email_review_request_change, make_new_review_request_from_existing,
2020
close_review_request_states, close_review_request,
21-
construct_review_request_assignment_choices)
21+
setup_reviewer_field)
2222
from ietf.review import mailarch
2323
from ietf.utils.fields import DatepickerDateField
2424
from ietf.utils.text import skip_prefix
@@ -205,15 +205,11 @@ def close_request(request, name, request_id):
205205

206206

207207
class AssignReviewerForm(forms.Form):
208-
reviewer = PersonEmailChoiceField(widget=forms.RadioSelect, empty_label="(None)", required=False)
208+
reviewer = PersonEmailChoiceField(empty_label="(None)", required=False)
209209

210210
def __init__(self, review_req, *args, **kwargs):
211211
super(AssignReviewerForm, self).__init__(*args, **kwargs)
212-
f = self.fields["reviewer"]
213-
f.queryset = f.queryset.filter(role__name="reviewer", role__group=review_req.team)
214-
if review_req.reviewer:
215-
f.initial = review_req.reviewer_id
216-
f.choices = construct_review_request_assignment_choices(f.queryset, review_req.team, review_req)
212+
setup_reviewer_field(self.fields["reviewer"], review_req)
217213

218214

219215
@login_required

ietf/group/views_review.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
extract_revision_ordered_review_requests_for_documents,
99
assign_review_request_to_reviewer,
1010
close_review_request,
11-
construct_review_request_assignment_choices,
11+
setup_reviewer_field,
1212
# make_new_review_request_from_existing,
1313
suggested_review_requests_for_team)
1414
from ietf.group.utils import get_group_or_404
@@ -55,11 +55,7 @@ def __init__(self, review_req, *args, **kwargs):
5555

5656
self.fields["close"].widget.attrs["class"] = "form-control input-sm"
5757

58-
self.fields["reviewer"].queryset = self.fields["reviewer"].queryset.filter(
59-
role__name="reviewer",
60-
role__group=review_req.team,
61-
)
62-
self.fields["reviewer"].choices = construct_review_request_assignment_choices(self.fields["reviewer"].queryset, review_req.team, review_req)
58+
setup_reviewer_field(self.fields["reviewer"], review_req)
6359
self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm"
6460

6561
if self.is_bound:

ietf/review/models.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import datetime
2+
13
from django.db import models
24

35
from ietf.doc.models import Document
@@ -44,7 +46,7 @@ class ReviewRequest(models.Model):
4446

4547
# Fields filled in on the initial record creation - these
4648
# constitute the request part.
47-
time = models.DateTimeField(auto_now_add=True)
49+
time = models.DateTimeField(default=datetime.datetime.now)
4850
type = models.ForeignKey(ReviewTypeName)
4951
doc = models.ForeignKey(Document, related_name='review_request_set')
5052
team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewteamresult=None))

ietf/review/utils.py

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import datetime
1+
import datetime, re
22
from collections import defaultdict
33

44
from django.contrib.sites.models import Site
55
from django.db import models
66

77
from ietf.group.models import Group, Role
8-
from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent
8+
from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent, DocumentAuthor, DocAlias
99
from ietf.iesg.models import TelechatDate
10-
from ietf.person.models import Person
10+
from ietf.person.models import Person, Email
1111
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream
1212
from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewTypeName, Reviewer
1313
from ietf.utils.mail import send_mail
@@ -275,38 +275,80 @@ def extract_revision_ordered_review_requests_for_documents(queryset, names):
275275

276276
return res
277277

278-
def construct_review_request_assignment_choices(possible_emails, team, review_req=None):
279-
possible_emails = list(possible_emails)
278+
def setup_reviewer_field(field, review_req):
279+
field.queryset = field.queryset.filter(role__name="reviewer", role__group=review_req.team)
280+
if review_req.reviewer:
281+
field.initial = review_req.reviewer_id
282+
283+
choices = make_assignment_choices(field.queryset, review_req)
284+
if not field.required:
285+
choices = [("", field.empty_label)] + choices
286+
287+
field.choices = choices
288+
289+
def make_assignment_choices(email_queryset, review_req):
290+
doc = review_req.doc
291+
team = review_req.team
292+
293+
possible_emails = list(email_queryset)
294+
295+
aliases = DocAlias.objects.filter(document=doc).values_list("name", flat=True)
280296

281297
reviewers = { r.person_id: r for r in Reviewer.objects.filter(team=team, person__in=[e.person_id for e in possible_emails]) }
282298

299+
# time since past assignment
283300
latest_assignment_for_reviewer = dict(ReviewRequest.objects.filter(
284301
reviewer__in=possible_emails,
285302
).values_list("reviewer").annotate(models.Max("time")))
286303

304+
# previous review of document
305+
has_reviewed_previous = ReviewRequest.objects.filter(
306+
doc=doc,
307+
reviewer__in=possible_emails,
308+
state="completed",
309+
)
310+
311+
if review_req.pk is not None:
312+
has_reviewed_previous = has_reviewed_previous.exclude(pk=review_req.pk)
313+
314+
has_reviewed_previous = set(has_reviewed_previous.values_list("reviewer", flat=True))
315+
316+
# review indications
317+
would_like_to_review = set() # FIXME: fill in
318+
319+
# connections
320+
connections = {}
321+
# examine the closest connections last to let them override
322+
for e in Email.objects.filter(pk__in=possible_emails, person=doc.ad_id):
323+
connections[e] = "is associated Area Director"
324+
for r in Role.objects.filter(group=doc.group_id, email__in=possible_emails).select_related("name"):
325+
connections[r.email_id] = "is group {}".format(r.name)
326+
if doc.shepherd_id:
327+
connections[doc.shepherd_id] = "is shepherd of document"
328+
for e in DocumentAuthor.objects.filter(document=doc, author__in=possible_emails).values_list("author", flat=True):
329+
connections[e] = "is author of document"
330+
287331
now = datetime.datetime.now()
288332

289-
rankings = []
333+
def add_boolean_score(scores, direction, expr, expl):
334+
scores.append(int(bool(expr)) * direction)
335+
if expr:
336+
explanations.append(expl)
337+
338+
ranking = []
290339
for e in possible_emails:
291340
reviewer = reviewers.get(e.person_id)
292341
if not reviewer:
293342
reviewer = Reviewer()
294343

344+
explanations = []
345+
scores = [] # build up score in separate independent components
346+
295347
days_past = None
296348
latest = latest_assignment_for_reviewer.get(e.pk)
297349
if latest is not None:
298350
days_past = (now - latest).days - reviewer.frequency
299351

300-
# FIXME:
301-
# positive: (Perhaps do these separately? As initial values?)
302-
# has done review of previous rev
303-
# would like to review
304-
305-
# blocks:
306-
# connections to doc + filter_re
307-
# has rejected same request/completed partial review
308-
# is unavailable_until
309-
310352
if days_past is None:
311353
ready_for = "first time"
312354
else:
@@ -315,19 +357,26 @@ def construct_review_request_assignment_choices(possible_emails, team, review_re
315357
ready_for = "ready for {} {}".format(d, "day" if d == 1 else "days")
316358
else:
317359
d = -d
318-
ready_for = "frequency exceeded - ready in {} {}".format(d, "day" if d == 1 else "days")
360+
ready_for = "frequency exceeded, ready in {} {}".format(d, "day" if d == 1 else "days")
361+
362+
explanations.append(ready_for)
363+
364+
add_boolean_score(scores, +1, e.pk in has_reviewed_previous, "reviewed document before")
365+
add_boolean_score(scores, +1, e.pk in would_like_to_review, "wants to review document")
366+
add_boolean_score(scores, -1, e.pk in connections, connections.get(e.pk))
367+
add_boolean_score(scores, -1, reviewer.filter_re and any(re.search(reviewer.filter_re, n) for n in aliases), "filter regexp matches")
368+
add_boolean_score(scores, -1, reviewer.unavailable_until and reviewer.unavailable_until > now, "unavailable until {}".format((reviewer.unavailable_until or now).strftime("%Y-%m-%d %H:%M:%S")))
319369

320-
label = "{}: {}".format(e.person, ready_for)
370+
scores.append(100000 if days_past is None else days_past)
321371

322-
rank = (-100000 if days_past is None else -days_past,)
372+
label = "{}: {}".format(e.person, "; ".join(explanations))
323373

324-
rankings.append({
374+
ranking.append({
325375
"email": e,
326-
"rank": rank,
376+
"scores": scores,
327377
"label": label,
328378
})
329379

330-
rankings.sort(key=lambda r: r["rank"])
380+
ranking.sort(key=lambda r: r["scores"], reverse=True)
331381

332-
# FIXME: empty choices
333-
return [(r["email"].pk, r["label"]) for r in rankings]
382+
return [(r["email"].pk, r["label"]) for r in ranking]

0 commit comments

Comments
 (0)