Skip to content

Commit fcb6806

Browse files
committed
Merged in work from sasha@dashcare.nl on Review Queue Managemnt:
This abstracts queue management, making it possible to implement different policies for each team. It provides two concrete policies: RotateAlphabeticallyReviewerQueuePolicy, which rotates an alphabetically ordered reviewer list with consideration for skip indications, and is the default policy; and LeastRecentlyUsedReviewerQueuePolicy, a simple least-recently-used policy. Also see issues ietf-tools#2721 and ietf-tools#2656. - Legacy-Id: 17121
2 parents ac6b664 + 9e2dfbf commit fcb6806

18 files changed

Lines changed: 996 additions & 372 deletions

ietf/doc/tests_review.py

Lines changed: 4 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,10 @@
3333
from ietf.person.models import Email, Person
3434
from ietf.review.factories import ReviewRequestFactory, ReviewAssignmentFactory
3535
from ietf.review.models import (ReviewRequest, ReviewerSettings,
36-
ReviewWish, UnavailablePeriod, NextReviewerInTeam)
37-
from ietf.review.utils import reviewer_rotation_list, possibly_advance_next_reviewer_for_team
36+
ReviewWish, NextReviewerInTeam)
37+
from ietf.review.policies import get_reviewer_queue_policy
3838

3939
from ietf.utils.test_utils import TestCase
40-
from ietf.utils.test_data import create_person
4140
from ietf.utils.test_utils import login_testing_unauthorized, reload_db_objects
4241
from ietf.utils.mail import outbox, empty_outbox, parseaddr, on_behalf_of
4342
from ietf.person.factories import PersonFactory
@@ -235,101 +234,14 @@ def test_close_request(self):
235234
self.assertIn("closed", mail_content)
236235
self.assertIn("review_request_close_comment", mail_content)
237236

238-
def test_possibly_advance_next_reviewer_for_team(self):
239-
240-
team = ReviewTeamFactory(acronym="rotationteam", name="Review Team", list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut"))
241-
doc = WgDraftFactory()
242-
243-
# make a bunch of reviewers
244-
reviewers = [
245-
create_person(team, "reviewer", name="Test Reviewer{}".format(i), username="testreviewer{}".format(i))
246-
for i in range(5)
247-
]
248-
249-
self.assertEqual(reviewers, reviewer_rotation_list(team))
250-
251-
def get_skip_next(person):
252-
settings = (ReviewerSettings.objects.filter(team=team, person=person).first()
253-
or ReviewerSettings(team=team))
254-
return settings.skip_next
255-
256-
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[0].pk, add_skip=False)
257-
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[1])
258-
self.assertEqual(get_skip_next(reviewers[0]), 0)
259-
self.assertEqual(get_skip_next(reviewers[1]), 0)
260-
261-
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[1].pk, add_skip=True)
262-
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
263-
self.assertEqual(get_skip_next(reviewers[1]), 1)
264-
self.assertEqual(get_skip_next(reviewers[2]), 0)
265-
266-
# skip reviewer 2
267-
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[3].pk, add_skip=True)
268-
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
269-
self.assertEqual(get_skip_next(reviewers[0]), 0)
270-
self.assertEqual(get_skip_next(reviewers[1]), 1)
271-
self.assertEqual(get_skip_next(reviewers[2]), 0)
272-
self.assertEqual(get_skip_next(reviewers[3]), 1)
273-
274-
# pick reviewer 2, use up reviewer 3's skip_next
275-
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[2].pk, add_skip=False)
276-
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[4])
277-
self.assertEqual(get_skip_next(reviewers[0]), 0)
278-
self.assertEqual(get_skip_next(reviewers[1]), 1)
279-
self.assertEqual(get_skip_next(reviewers[2]), 0)
280-
self.assertEqual(get_skip_next(reviewers[3]), 0)
281-
self.assertEqual(get_skip_next(reviewers[4]), 0)
282-
283-
# check wrap-around
284-
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[4].pk)
285-
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[0])
286-
self.assertEqual(get_skip_next(reviewers[0]), 0)
287-
self.assertEqual(get_skip_next(reviewers[1]), 1)
288-
self.assertEqual(get_skip_next(reviewers[2]), 0)
289-
self.assertEqual(get_skip_next(reviewers[3]), 0)
290-
self.assertEqual(get_skip_next(reviewers[4]), 0)
291-
292-
# unavailable
293-
today = datetime.date.today()
294-
UnavailablePeriod.objects.create(team=team, person=reviewers[1], start_date=today, end_date=today, availability="unavailable")
295-
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[0].pk)
296-
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
297-
self.assertEqual(get_skip_next(reviewers[0]), 0)
298-
self.assertEqual(get_skip_next(reviewers[1]), 1) # don't consume that skip while the reviewer is unavailable
299-
self.assertEqual(get_skip_next(reviewers[2]), 0)
300-
self.assertEqual(get_skip_next(reviewers[3]), 0)
301-
self.assertEqual(get_skip_next(reviewers[4]), 0)
302-
303-
# pick unavailable anyway
304-
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[1].pk, add_skip=False)
305-
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
306-
self.assertEqual(get_skip_next(reviewers[0]), 0)
307-
self.assertEqual(get_skip_next(reviewers[1]), 1)
308-
self.assertEqual(get_skip_next(reviewers[2]), 0)
309-
self.assertEqual(get_skip_next(reviewers[3]), 0)
310-
self.assertEqual(get_skip_next(reviewers[4]), 0)
311-
312-
# not through min_interval so advance past reviewer[2]
313-
settings, _ = ReviewerSettings.objects.get_or_create(team=team, person=reviewers[2])
314-
settings.min_interval = 30
315-
settings.save()
316-
req = ReviewRequest.objects.create(team=team, doc=doc, type_id="early", state_id="assigned", deadline=today, requested_by=reviewers[0])
317-
ReviewAssignmentFactory(review_request=req, state_id="accepted", reviewer = reviewers[2].email_set.first(),assigned_on = req.time)
318-
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[3].pk)
319-
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[4])
320-
self.assertEqual(get_skip_next(reviewers[0]), 0)
321-
self.assertEqual(get_skip_next(reviewers[1]), 1)
322-
self.assertEqual(get_skip_next(reviewers[2]), 0)
323-
self.assertEqual(get_skip_next(reviewers[3]), 0)
324-
self.assertEqual(get_skip_next(reviewers[4]), 0)
325-
326237
def test_assign_reviewer(self):
327238
doc = WgDraftFactory(pages=2)
328239
review_team = ReviewTeamFactory(acronym="reviewteam", name="Review Team", type_id="review", list_email="reviewteam@ietf.org", parent=Group.objects.get(acronym="farfut"))
329240
rev_role = RoleFactory(group=review_team,person__user__username='reviewer',person__user__email='reviewer@example.com',person__name='Some Reviewer',name_id='reviewer')
330241
RoleFactory(group=review_team,person__user__username='marschairman',person__name='WG Cháir Man',name_id='reviewer')
331242
secretary = RoleFactory(group=review_team,person__user__username='reviewsecretary',person__user__email='reviewsecretary@example.com',name_id='secr')
332243
ReviewerSettings.objects.create(team=review_team, person=rev_role.person, min_interval=14, skip_next=0)
244+
queue_policy = get_reviewer_queue_policy(review_team)
333245

334246
# review to assign to
335247
review_req = ReviewRequestFactory(team=review_team,doc=doc,state_id='requested')
@@ -371,17 +283,8 @@ def test_assign_reviewer(self):
371283
another_reviewer = PersonFactory.create(name = "Extra TestReviewer") # needs to be lexically greater than the existing one
372284
another_reviewer.role_set.create(name_id='reviewer', email=another_reviewer.email(), group=review_req.team)
373285

374-
UnavailablePeriod.objects.create(
375-
team=review_req.team,
376-
person=reviewer_email.person,
377-
start_date=datetime.date.today() - datetime.timedelta(days=10),
378-
availability="unavailable",
379-
)
380-
381286
ReviewWish.objects.create(person=reviewer_email.person, team=review_req.team, doc=doc)
382287

383-
# pick a non-existing reviewer as next to see that we can
384-
# handle reviewers who have left
385288
NextReviewerInTeam.objects.filter(team=review_req.team).delete()
386289
NextReviewerInTeam.objects.create(
387290
team=review_req.team,
@@ -409,14 +312,13 @@ def test_assign_reviewer(self):
409312
self.assertIn("wishes to review", reviewer_label)
410313
self.assertIn("is author", reviewer_label)
411314
self.assertIn("regexp matches", reviewer_label)
412-
self.assertIn("unavailable indefinitely", reviewer_label)
413315
self.assertIn("skip next 1", reviewer_label)
414316
self.assertIn("#1", reviewer_label)
415317
self.assertIn("1 fully completed", reviewer_label)
416318

417319
# assign
418320
empty_outbox()
419-
rotation_list = reviewer_rotation_list(review_req.team)
321+
rotation_list = queue_policy.default_reviewer_rotation_list()
420322
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team, person=rotation_list[0]).first()
421323
r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk })
422324
self.assertEqual(r.status_code, 302)
@@ -427,7 +329,6 @@ def test_assign_reviewer(self):
427329
assignment = review_req.reviewassignment_set.first()
428330
self.assertEqual(assignment.reviewer, reviewer)
429331
self.assertEqual(assignment.state_id, "assigned")
430-
self.assertEqual(NextReviewerInTeam.objects.get(team=review_req.team).next_reviewer, rotation_list[1])
431332

432333
self.assertEqual(len(outbox), 1)
433334
self.assertEqual('"Some Reviewer" <reviewer@example.com>', outbox[0]["To"])

ietf/doc/views_review.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,12 @@
3838
from ietf.message.models import Message
3939
from ietf.message.utils import infer_message
4040
from ietf.person.fields import PersonEmailChoiceField, SearchablePersonField
41+
from ietf.review.policies import get_reviewer_queue_policy
4142
from ietf.review.utils import (active_review_teams, assign_review_request_to_reviewer,
4243
can_request_review_of_doc, can_manage_review_requests_for_team,
4344
email_review_assignment_change, email_review_request_change,
4445
close_review_request_states,
45-
close_review_request, setup_reviewer_field)
46+
close_review_request)
4647
from ietf.review import mailarch
4748
from ietf.utils.fields import DatepickerDateField
4849
from ietf.utils.text import strip_prefix, xslugify
@@ -307,7 +308,7 @@ class AssignReviewerForm(forms.Form):
307308

308309
def __init__(self, review_req, *args, **kwargs):
309310
super(AssignReviewerForm, self).__init__(*args, **kwargs)
310-
setup_reviewer_field(self.fields["reviewer"], review_req)
311+
get_reviewer_queue_policy(review_req.team).setup_reviewer_field(self.fields["reviewer"], review_req)
311312

312313

313314
@login_required
@@ -378,6 +379,9 @@ def reject_reviewer_assignment(request, name, assignment_id):
378379
state=review_assignment.state,
379380
)
380381

382+
policy = get_reviewer_queue_policy(review_assignment.review_request.team)
383+
policy.return_reviewer_to_rotation_top(review_assignment.reviewer.person)
384+
381385
msg = render_to_string("review/reviewer_assignment_rejected.txt", {
382386
"by": request.user.person,
383387
"message_to_secretary": form.cleaned_data.get("message_to_secretary")
@@ -425,6 +429,9 @@ def withdraw_reviewer_assignment(request, name, assignment_id):
425429
state=review_assignment.state,
426430
)
427431

432+
policy = get_reviewer_queue_policy(review_assignment.review_request.team)
433+
policy.return_reviewer_to_rotation_top(review_assignment.reviewer.person)
434+
428435
msg = "Review assignment withdrawn by %s"%request.user.person
429436

430437
email_review_assignment_change(request, review_assignment, "Reviewer assignment withdrawn", msg, by=request.user.person, notify_secretary=True, notify_reviewer=True, notify_requested_by=False)

ietf/group/forms.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
from ietf.person.fields import SearchableEmailsField, PersonEmailChoiceField
2121
from ietf.person.models import Person
2222
from ietf.review.models import ReviewerSettings, UnavailablePeriod, ReviewSecretarySettings
23-
from ietf.review.utils import close_review_request_states, setup_reviewer_field
23+
from ietf.review.policies import get_reviewer_queue_policy
24+
from ietf.review.utils import close_review_request_states
2425
from ietf.utils.textupload import get_cleaned_text_file_content
2526
from ietf.utils.text import strip_suffix
2627
#from ietf.utils.ordereddict import insert_after_in_ordered_dict
@@ -256,7 +257,7 @@ def __init__(self, review_req, *args, **kwargs):
256257

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

259-
setup_reviewer_field(self.fields["reviewer"], review_req)
260+
get_reviewer_queue_policy(review_req.team).setup_reviewer_field(self.fields["reviewer"], review_req)
260261
self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm"
261262

262263
if not getattr(review_req, 'in_lc_and_telechat', False):
@@ -280,7 +281,7 @@ class ReviewerSettingsForm(forms.ModelForm):
280281
class Meta:
281282
model = ReviewerSettings
282283
fields = ['min_interval', 'filter_re', 'skip_next', 'remind_days_before_deadline',
283-
'remind_days_open_reviews', 'expertise']
284+
'remind_days_open_reviews', 'request_assignment_next', 'expertise']
284285

285286
def __init__(self, *args, **kwargs):
286287
exclude_fields = kwargs.pop('exclude_fields', [])

ietf/group/tests_review.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from django.urls import reverse as urlreverse
1414

15+
from ietf.review.policies import get_reviewer_queue_policy
1516
from ietf.utils.test_utils import login_testing_unauthorized, TestCase, reload_db_objects
1617
from ietf.doc.models import TelechatDocEvent, LastCallDocEvent, State
1718
from ietf.group.models import Role
@@ -23,7 +24,6 @@
2324
suggested_review_requests_for_team,
2425
review_assignments_needing_reviewer_reminder, email_reviewer_reminder,
2526
review_assignments_needing_secretary_reminder, email_secretary_reminder,
26-
reviewer_rotation_list,
2727
send_unavaibility_period_ending_reminder, send_reminder_all_open_reviews,
2828
send_review_reminder_overdue_assignment, send_reminder_unconfirmed_assignments)
2929
from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewAssignmentStateName, \
@@ -576,6 +576,7 @@ def test_change_reviewer_settings(self):
576576
"filter_re": "test-[regexp]",
577577
"remind_days_before_deadline": "6",
578578
"remind_days_open_reviews": "8",
579+
"request_assignment_next": "1",
579580
"expertise": "Some expertise",
580581
})
581582
self.assertEqual(r.status_code, 302)
@@ -591,6 +592,7 @@ def test_change_reviewer_settings(self):
591592
msg_content = outbox[0].get_payload(decode=True).decode("utf-8").lower()
592593
self.assertTrue("frequency changed", msg_content)
593594
self.assertTrue("skip next", msg_content)
595+
self.assertTrue("requested to be the next person", msg_content)
594596

595597
# Normal reviewer should not be able to change skip_next
596598
r = self.client.post(url, {
@@ -903,7 +905,8 @@ def test_rotation_queue_update(self):
903905
secretary = RoleFactory.create(group=group,name_id='secr')
904906
docs = [DocumentFactory.create(type_id='draft',group=None) for i in range(4)]
905907
requests = [ReviewRequestFactory(team=group,doc=docs[i]) for i in range(4)]
906-
rot_list = reviewer_rotation_list(group)
908+
policy = get_reviewer_queue_policy(group)
909+
rot_list = policy.default_reviewer_rotation_list()
907910

908911
expected_ending_head_of_rotation = rot_list[3]
909912

@@ -924,6 +927,6 @@ def test_rotation_queue_update(self):
924927
self.client.login(username=secretary.person.user.username,password=secretary.person.user.username+'+password')
925928
r = self.client.post(unassigned_url, postdict)
926929
self.assertEqual(r.status_code,302)
927-
self.assertEqual(expected_ending_head_of_rotation,reviewer_rotation_list(group)[0])
930+
self.assertEqual(expected_ending_head_of_rotation, policy.default_reviewer_rotation_list()[0])
928931
self.assertMailboxContains(outbox, subject='Last Call assignment', text='Requested by', count=4)
929932

ietf/group/views.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
from ietf.person.models import Email
9494
from ietf.review.models import (ReviewRequest, ReviewAssignment, ReviewerSettings,
9595
ReviewSecretarySettings, UnavailablePeriod )
96+
from ietf.review.policies import get_reviewer_queue_policy
9697
from ietf.review.utils import (can_manage_review_requests_for_team,
9798
can_access_review_stats_for_team,
9899

@@ -104,7 +105,6 @@
104105
unavailable_periods_to_list,
105106
current_unavailable_periods_for_reviewers,
106107
email_reviewer_availability_change,
107-
reviewer_rotation_list,
108108
latest_review_assignments_for_reviewers,
109109
augment_review_requests_with_events,
110110
get_default_filter_re,
@@ -1395,7 +1395,7 @@ def reviewer_overview(request, acronym, group_type=None):
13951395

13961396
can_manage = can_manage_review_requests_for_team(request.user, group)
13971397

1398-
reviewers = reviewer_rotation_list(group)
1398+
reviewers = get_reviewer_queue_policy(group).default_reviewer_rotation_list(include_unavailable=True)
13991399

14001400
reviewer_settings = { s.person_id: s for s in ReviewerSettings.objects.filter(team=group) }
14011401
unavailable_periods = defaultdict(list)
@@ -1566,13 +1566,14 @@ def manage_review_requests(request, acronym, group_type=None, assignment_status=
15661566
# Make sure the any assignments to the person at the head
15671567
# of the rotation queue are processed first so that the queue
15681568
# rotates before any more assignments are processed
1569-
head_of_rotation = reviewer_rotation_list(group)[0]
1569+
reviewer_policy = get_reviewer_queue_policy(group)
1570+
head_of_rotation = reviewer_policy.default_reviewer_rotation_list_without_skipped()[0]
15701571
while head_of_rotation in assignments_by_person:
15711572
for review_req in assignments_by_person[head_of_rotation]:
15721573
assign_review_request_to_reviewer(request, review_req, review_req.form.cleaned_data["reviewer"],review_req.form.cleaned_data["add_skip"])
15731574
reqs_to_assign.remove(review_req)
15741575
del assignments_by_person[head_of_rotation]
1575-
head_of_rotation = reviewer_rotation_list(group)[0]
1576+
head_of_rotation = reviewer_policy.default_reviewer_rotation_list_without_skipped()[0]
15761577

15771578
for review_req in reqs_to_assign:
15781579
assign_review_request_to_reviewer(request, review_req, review_req.form.cleaned_data["reviewer"],review_req.form.cleaned_data["add_skip"])
@@ -1685,7 +1686,7 @@ def should_be_replicated_in_last_call_section(r):
16851686

16861687
partial_msg = render_to_string(template.path, {
16871688
"review_assignments": review_assignments,
1688-
"rotation_list": reviewer_rotation_list(group)[:10],
1689+
"rotation_list": get_reviewer_queue_policy(group).default_reviewer_rotation_list()[:10],
16891690
"group" : group,
16901691
})
16911692

@@ -1756,7 +1757,10 @@ def change_reviewer_settings(request, acronym, reviewer_email, group_type=None):
17561757
changes.append("Frequency changed to \"{}\" from \"{}\".".format(settings.get_min_interval_display() or "Not specified", prev_min_interval or "Not specified"))
17571758
if settings.skip_next != prev_skip_next:
17581759
changes.append("Skip next assignments changed to {} from {}.".format(settings.skip_next, prev_skip_next))
1759-
1760+
if settings.request_assignment_next:
1761+
changes.append("Reviewer has requested to be the next person selected for an "
1762+
"assignment, as soon as possible, and will be on the top of "
1763+
"the queue.")
17601764
if changes:
17611765
email_reviewer_availability_change(request, group, reviewer_role, "\n\n".join(changes), request.user.person)
17621766

0 commit comments

Comments
 (0)