Skip to content

Commit 57ec2b3

Browse files
author
Sasha Romijn
committed
Add LeastRecentlyUsed reviewer queue policy.
- Legacy-Id: 17049
1 parent 554a839 commit 57ec2b3

2 files changed

Lines changed: 145 additions & 48 deletions

File tree

ietf/review/policies.py

Lines changed: 80 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import absolute_import, print_function, unicode_literals
44

55
import re
6+
from collections import OrderedDict
67

78
import six
89

@@ -11,7 +12,8 @@
1112
from ietf.group.models import Role
1213
from ietf.person.models import Person
1314
import debug # pyflakes:ignore
14-
from ietf.review.models import NextReviewerInTeam, ReviewerSettings, ReviewWish, ReviewRequest
15+
from ietf.review.models import NextReviewerInTeam, ReviewerSettings, ReviewWish, ReviewRequest, \
16+
ReviewAssignment
1517
from ietf.review.utils import (current_unavailable_periods_for_reviewers,
1618
days_needed_to_fulfill_min_interval_for_reviewers,
1719
get_default_filter_re,
@@ -41,12 +43,58 @@ def default_reviewer_rotation_list(self, dont_skip_person_ids=None):
4143

4244
def update_policy_state_for_assignment(self, assignee_person, add_skip=False):
4345
"""
44-
Update the internal state of a policy to reflect an assignment.
46+
Update the skip_count if the assignment was in order, and
47+
update NextReviewerInTeam. Note that NextReviewerInTeam is
48+
not used by all policies.
4549
"""
4650
settings = self._reviewer_settings_for(assignee_person)
4751
settings.request_assignment_next = False
4852
settings.save()
53+
rotation_list = self.default_reviewer_rotation_list(
54+
dont_skip_person_ids=[assignee_person.pk])
4955

56+
def reviewer_at_index(i):
57+
if not rotation_list:
58+
return None
59+
return rotation_list[i % len(rotation_list)]
60+
61+
if not rotation_list:
62+
return
63+
64+
rotation_list_without_skip = [r for r in rotation_list if
65+
not self._reviewer_settings_for(r).skip_next]
66+
# In order means: assigned to the first person in the rotation list with skip_next=0
67+
# If the assignment is not in order, skip_next and NextReviewerInTeam are not modified.
68+
in_order_assignment = rotation_list_without_skip[0] == assignee_person
69+
70+
# Loop through the list until finding the first person with skip_next=0,
71+
# who is not the current assignee. Anyone with skip_next>0 encountered before
72+
# has their skip_next decreased.
73+
current_idx = 0
74+
if in_order_assignment:
75+
while True:
76+
current_idx_person = reviewer_at_index(current_idx)
77+
settings = self._reviewer_settings_for(current_idx_person)
78+
if settings.skip_next > 0:
79+
settings.skip_next -= 1
80+
settings.save()
81+
elif current_idx_person != assignee_person:
82+
# NextReviewerInTeam is not used by all policies to determine
83+
# default rotation order, but updated regardless.
84+
nr = NextReviewerInTeam.objects.filter(
85+
team=self.team).first() or NextReviewerInTeam(
86+
team=self.team)
87+
nr.next_reviewer = current_idx_person
88+
nr.save()
89+
90+
break
91+
current_idx += 1
92+
93+
if add_skip:
94+
settings = self._reviewer_settings_for(assignee_person)
95+
settings.skip_next += 1
96+
settings.save()
97+
5098
# TODO : Change this field to deal with multiple already assigned reviewers???
5199
def setup_reviewer_field(self, field, review_req):
52100
"""
@@ -298,50 +346,11 @@ def _reviewer_settings_for_person_ids(self, person_ids):
298346

299347

300348
class RotateAlphabeticallyReviewerQueuePolicy(AbstractReviewerQueuePolicy):
301-
302-
def update_policy_state_for_assignment(self, assignee_person, add_skip=False):
303-
super(RotateAlphabeticallyReviewerQueuePolicy, self).update_policy_state_for_assignment(assignee_person, add_skip)
304-
assert assignee_person is not None
305-
306-
rotation_list = self.default_reviewer_rotation_list(dont_skip_person_ids=[assignee_person.pk])
307-
308-
def reviewer_at_index(i):
309-
if not rotation_list:
310-
return None
311-
return rotation_list[i % len(rotation_list)]
312-
313-
if not rotation_list:
314-
return
315-
316-
rotation_list_without_skip = [r for r in rotation_list if not self._reviewer_settings_for(r).skip_next]
317-
# In order means: assigned to the first person in the rotation list with skip_next=0
318-
# If the assignment is not in order, skip_next and NextReviewerInTeam are not modified.
319-
in_order_assignment = rotation_list_without_skip[0] == assignee_person
320-
321-
# Loop through the list until finding the first person with skip_next=0,
322-
# who is not the current assignee. Anyone with skip_next>0 encountered before
323-
# has their skip_next decreased.
324-
current_idx = 0
325-
if in_order_assignment:
326-
while True:
327-
current_idx_person = reviewer_at_index(current_idx)
328-
settings = self._reviewer_settings_for(current_idx_person)
329-
if settings.skip_next > 0:
330-
settings.skip_next -= 1
331-
settings.save()
332-
elif current_idx_person != assignee_person:
333-
nr = NextReviewerInTeam.objects.filter(team=self.team).first() or NextReviewerInTeam(
334-
team=self.team)
335-
nr.next_reviewer = current_idx_person
336-
nr.save()
337-
338-
break
339-
current_idx += 1
340-
341-
if add_skip:
342-
settings = self._reviewer_settings_for(assignee_person)
343-
settings.skip_next += 1
344-
settings.save()
349+
"""
350+
A policy in which the default rotation list is based on last name, alphabetically.
351+
NextReviewerInTeam is used to store a pointer to where the queue is currently
352+
positioned.
353+
"""
345354

346355
def default_reviewer_rotation_list(self, include_unavailable=False, dont_skip_person_ids=None):
347356
reviewers = list(Person.objects.filter(role__name="reviewer", role__group=self.team))
@@ -372,3 +381,28 @@ def default_reviewer_rotation_list(self, include_unavailable=False, dont_skip_pe
372381

373382
return rotation_list
374383

384+
385+
class LeastRecentlyUsedReviewerQueuePolicy(AbstractReviewerQueuePolicy):
386+
"""
387+
A policy where the default rotation list is based on the most recent
388+
assigned, accepted or completed review assignment.
389+
"""
390+
def default_reviewer_rotation_list(self, include_unavailable=False, dont_skip_person_ids=None):
391+
reviewers = list(Person.objects.filter(role__name="reviewer", role__group=self.team))
392+
assignments = ReviewAssignment.objects.filter(
393+
review_request__team=self.team,
394+
state__in=['accepted', 'assigned', 'completed'],
395+
).order_by('assigned_on')
396+
397+
reviewers_with_assignment = [assignment.reviewer.person for assignment in assignments]
398+
reviewers_without_assignment = set(reviewers) - set(reviewers_with_assignment)
399+
400+
rotation_list = sorted(list(reviewers_without_assignment), key=lambda r: r.pk)
401+
rotation_list += list(OrderedDict.fromkeys(reviewers_with_assignment))
402+
403+
if not include_unavailable:
404+
reviewers_to_skip = self._entirely_unavailable_reviewers(dont_skip_person_ids)
405+
rotation_list = [p for p in rotation_list if p.pk not in reviewers_to_skip]
406+
407+
return rotation_list
408+

ietf/review/test_policies.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,20 @@
77
from ietf.person.models import Email
88
from ietf.review.factories import ReviewAssignmentFactory, ReviewRequestFactory
99
from ietf.review.models import ReviewerSettings, NextReviewerInTeam, UnavailablePeriod, ReviewWish
10-
from ietf.review.policies import get_reviewer_queue_policy, AssignmentOrderResolver
10+
from ietf.review.policies import get_reviewer_queue_policy, AssignmentOrderResolver, \
11+
LeastRecentlyUsedReviewerQueuePolicy, RotateAlphabeticallyReviewerQueuePolicy
1112
from ietf.utils.test_data import create_person
1213
from ietf.utils.test_utils import TestCase
1314

1415

1516
class RotateAlphabeticallyReviewerQueuePolicyTest(TestCase):
17+
"""
18+
These tests also cover the common behaviour in RotateAlphabeticallyReviewerQueuePolicy,
19+
as that's difficult to test on it's own.
20+
"""
1621
def test_default_reviewer_rotation_list(self):
1722
team = ReviewTeamFactory(acronym="rotationteam", name="Review Team", list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut"))
18-
policy = get_reviewer_queue_policy(team)
23+
policy = RotateAlphabeticallyReviewerQueuePolicy(team)
1924

2025
reviewers = [
2126
create_person(team, "reviewer", name="Test Reviewer{}".format(i), username="testreviewer{}".format(i))
@@ -171,6 +176,64 @@ def get_skip_next(person):
171176
self.assertEqual(get_skip_next(reviewers[4]), 0)
172177

173178

179+
class LeastRecentlyUsedReviewerQueuePolicyTest(TestCase):
180+
"""
181+
These tests only cover where this policy deviates from
182+
RotateAlphabeticallyReviewerQueuePolicy - the common behaviour
183+
inherited from AbstractReviewerQueuePolicy is covered in
184+
RotateAlphabeticallyReviewerQueuePolicyTest.
185+
"""
186+
def test_default_reviewer_rotation_list(self):
187+
team = ReviewTeamFactory(acronym="rotationteam", name="Review Team",
188+
list_email="rotationteam@ietf.org",
189+
parent=Group.objects.get(acronym="farfut"))
190+
policy = LeastRecentlyUsedReviewerQueuePolicy(team)
191+
192+
reviewers = [
193+
create_person(team, "reviewer", name="Test Reviewer{}".format(i),
194+
username="testreviewer{}".format(i))
195+
for i in range(5)
196+
]
197+
198+
# This reviewer should never be included.
199+
unavailable_reviewer = create_person(team, "reviewer", name="unavailable reviewer",
200+
username="unavailablereviewer")
201+
UnavailablePeriod.objects.create(
202+
team=team,
203+
person=unavailable_reviewer,
204+
start_date='2000-01-01',
205+
availability='unavailable',
206+
)
207+
# This should not have any impact. Canfinish unavailable reviewers are included in
208+
# the default rotation, and filtered further when making assignment choices.
209+
UnavailablePeriod.objects.create(
210+
team=team,
211+
person=reviewers[1],
212+
start_date='2000-01-01',
213+
availability='canfinish',
214+
)
215+
216+
# No known assignments
217+
rotation = policy.default_reviewer_rotation_list()
218+
self.assertNotIn(unavailable_reviewer, rotation)
219+
self.assertEqual(rotation, reviewers)
220+
221+
# Regular accepted assignment
222+
ReviewAssignmentFactory(reviewer=reviewers[1].email(), assigned_on='2019-01-01',
223+
state_id='accepted', review_request__team=team)
224+
# Rejected assignment, should not affect reviewer 2's position
225+
ReviewAssignmentFactory(reviewer=reviewers[2].email(), state_id='rejected',
226+
review_request__team=team)
227+
# Completed assignment, assigned before reviewer 1,
228+
# but completed after (assign date should count).
229+
ReviewAssignmentFactory(reviewer=reviewers[0].email(), assigned_on='2018-01-01',
230+
completed_on='2020-01-01', state_id='completed',
231+
review_request__team=team)
232+
rotation = policy.default_reviewer_rotation_list()
233+
self.assertNotIn(unavailable_reviewer, rotation)
234+
self.assertEqual(rotation, [reviewers[2], reviewers[3], reviewers[4], reviewers[0], reviewers[1]])
235+
236+
174237
class AssignmentOrderResolverTests(TestCase):
175238
def test_determine_ranking(self):
176239
# reviewer_high is second in the default rotation, reviewer_low is first

0 commit comments

Comments
 (0)