diff --git a/ietf/review/policies.py b/ietf/review/policies.py index fc679122e..e89c5cd10 100644 --- a/ietf/review/policies.py +++ b/ietf/review/policies.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, print_function, unicode_literals import re +from collections import OrderedDict import six @@ -11,7 +12,8 @@ from ietf.doc.utils import extract_complete_replaces_ancestor_mapping_for_docs from ietf.group.models import Role from ietf.person.models import Person import debug # pyflakes:ignore -from ietf.review.models import NextReviewerInTeam, ReviewerSettings, ReviewWish, ReviewRequest +from ietf.review.models import NextReviewerInTeam, ReviewerSettings, ReviewWish, ReviewRequest, \ + ReviewAssignment from ietf.review.utils import (current_unavailable_periods_for_reviewers, days_needed_to_fulfill_min_interval_for_reviewers, get_default_filter_re, @@ -41,12 +43,58 @@ class AbstractReviewerQueuePolicy: def update_policy_state_for_assignment(self, assignee_person, add_skip=False): """ - Update the internal state of a policy to reflect an assignment. + Update the skip_count if the assignment was in order, and + update NextReviewerInTeam. Note that NextReviewerInTeam is + not used by all policies. """ settings = self._reviewer_settings_for(assignee_person) settings.request_assignment_next = False settings.save() + rotation_list = self.default_reviewer_rotation_list( + dont_skip_person_ids=[assignee_person.pk]) + def reviewer_at_index(i): + if not rotation_list: + return None + return rotation_list[i % len(rotation_list)] + + if not rotation_list: + return + + rotation_list_without_skip = [r for r in rotation_list if + not self._reviewer_settings_for(r).skip_next] + # In order means: assigned to the first person in the rotation list with skip_next=0 + # If the assignment is not in order, skip_next and NextReviewerInTeam are not modified. + in_order_assignment = rotation_list_without_skip[0] == assignee_person + + # Loop through the list until finding the first person with skip_next=0, + # who is not the current assignee. Anyone with skip_next>0 encountered before + # has their skip_next decreased. + current_idx = 0 + if in_order_assignment: + while True: + current_idx_person = reviewer_at_index(current_idx) + settings = self._reviewer_settings_for(current_idx_person) + if settings.skip_next > 0: + settings.skip_next -= 1 + settings.save() + elif current_idx_person != assignee_person: + # NextReviewerInTeam is not used by all policies to determine + # default rotation order, but updated regardless. + nr = NextReviewerInTeam.objects.filter( + team=self.team).first() or NextReviewerInTeam( + team=self.team) + nr.next_reviewer = current_idx_person + nr.save() + + break + current_idx += 1 + + if add_skip: + settings = self._reviewer_settings_for(assignee_person) + settings.skip_next += 1 + settings.save() + # TODO : Change this field to deal with multiple already assigned reviewers??? def setup_reviewer_field(self, field, review_req): """ @@ -298,50 +346,11 @@ class AssignmentOrderResolver: class RotateAlphabeticallyReviewerQueuePolicy(AbstractReviewerQueuePolicy): - - def update_policy_state_for_assignment(self, assignee_person, add_skip=False): - super(RotateAlphabeticallyReviewerQueuePolicy, self).update_policy_state_for_assignment(assignee_person, add_skip) - assert assignee_person is not None - - rotation_list = self.default_reviewer_rotation_list(dont_skip_person_ids=[assignee_person.pk]) - - def reviewer_at_index(i): - if not rotation_list: - return None - return rotation_list[i % len(rotation_list)] - - if not rotation_list: - return - - rotation_list_without_skip = [r for r in rotation_list if not self._reviewer_settings_for(r).skip_next] - # In order means: assigned to the first person in the rotation list with skip_next=0 - # If the assignment is not in order, skip_next and NextReviewerInTeam are not modified. - in_order_assignment = rotation_list_without_skip[0] == assignee_person - - # Loop through the list until finding the first person with skip_next=0, - # who is not the current assignee. Anyone with skip_next>0 encountered before - # has their skip_next decreased. - current_idx = 0 - if in_order_assignment: - while True: - current_idx_person = reviewer_at_index(current_idx) - settings = self._reviewer_settings_for(current_idx_person) - if settings.skip_next > 0: - settings.skip_next -= 1 - settings.save() - elif current_idx_person != assignee_person: - nr = NextReviewerInTeam.objects.filter(team=self.team).first() or NextReviewerInTeam( - team=self.team) - nr.next_reviewer = current_idx_person - nr.save() - - break - current_idx += 1 - - if add_skip: - settings = self._reviewer_settings_for(assignee_person) - settings.skip_next += 1 - settings.save() + """ + A policy in which the default rotation list is based on last name, alphabetically. + NextReviewerInTeam is used to store a pointer to where the queue is currently + positioned. + """ def default_reviewer_rotation_list(self, include_unavailable=False, dont_skip_person_ids=None): reviewers = list(Person.objects.filter(role__name="reviewer", role__group=self.team)) @@ -372,3 +381,28 @@ class RotateAlphabeticallyReviewerQueuePolicy(AbstractReviewerQueuePolicy): return rotation_list + +class LeastRecentlyUsedReviewerQueuePolicy(AbstractReviewerQueuePolicy): + """ + A policy where the default rotation list is based on the most recent + assigned, accepted or completed review assignment. + """ + def default_reviewer_rotation_list(self, include_unavailable=False, dont_skip_person_ids=None): + reviewers = list(Person.objects.filter(role__name="reviewer", role__group=self.team)) + assignments = ReviewAssignment.objects.filter( + review_request__team=self.team, + state__in=['accepted', 'assigned', 'completed'], + ).order_by('assigned_on') + + reviewers_with_assignment = [assignment.reviewer.person for assignment in assignments] + reviewers_without_assignment = set(reviewers) - set(reviewers_with_assignment) + + rotation_list = sorted(list(reviewers_without_assignment), key=lambda r: r.pk) + rotation_list += list(OrderedDict.fromkeys(reviewers_with_assignment)) + + if not include_unavailable: + reviewers_to_skip = self._entirely_unavailable_reviewers(dont_skip_person_ids) + rotation_list = [p for p in rotation_list if p.pk not in reviewers_to_skip] + + return rotation_list + diff --git a/ietf/review/test_policies.py b/ietf/review/test_policies.py index 568905cd5..b33921497 100644 --- a/ietf/review/test_policies.py +++ b/ietf/review/test_policies.py @@ -7,15 +7,20 @@ from ietf.person.fields import PersonEmailChoiceField from ietf.person.models import Email from ietf.review.factories import ReviewAssignmentFactory, ReviewRequestFactory from ietf.review.models import ReviewerSettings, NextReviewerInTeam, UnavailablePeriod, ReviewWish -from ietf.review.policies import get_reviewer_queue_policy, AssignmentOrderResolver +from ietf.review.policies import get_reviewer_queue_policy, AssignmentOrderResolver, \ + LeastRecentlyUsedReviewerQueuePolicy, RotateAlphabeticallyReviewerQueuePolicy from ietf.utils.test_data import create_person from ietf.utils.test_utils import TestCase class RotateAlphabeticallyReviewerQueuePolicyTest(TestCase): + """ + These tests also cover the common behaviour in RotateAlphabeticallyReviewerQueuePolicy, + as that's difficult to test on it's own. + """ def test_default_reviewer_rotation_list(self): team = ReviewTeamFactory(acronym="rotationteam", name="Review Team", list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut")) - policy = get_reviewer_queue_policy(team) + policy = RotateAlphabeticallyReviewerQueuePolicy(team) reviewers = [ create_person(team, "reviewer", name="Test Reviewer{}".format(i), username="testreviewer{}".format(i)) @@ -171,6 +176,64 @@ class RotateAlphabeticallyReviewerQueuePolicyTest(TestCase): self.assertEqual(get_skip_next(reviewers[4]), 0) +class LeastRecentlyUsedReviewerQueuePolicyTest(TestCase): + """ + These tests only cover where this policy deviates from + RotateAlphabeticallyReviewerQueuePolicy - the common behaviour + inherited from AbstractReviewerQueuePolicy is covered in + RotateAlphabeticallyReviewerQueuePolicyTest. + """ + def test_default_reviewer_rotation_list(self): + team = ReviewTeamFactory(acronym="rotationteam", name="Review Team", + list_email="rotationteam@ietf.org", + parent=Group.objects.get(acronym="farfut")) + policy = LeastRecentlyUsedReviewerQueuePolicy(team) + + reviewers = [ + create_person(team, "reviewer", name="Test Reviewer{}".format(i), + username="testreviewer{}".format(i)) + for i in range(5) + ] + + # This reviewer should never be included. + unavailable_reviewer = create_person(team, "reviewer", name="unavailable reviewer", + username="unavailablereviewer") + UnavailablePeriod.objects.create( + team=team, + person=unavailable_reviewer, + start_date='2000-01-01', + availability='unavailable', + ) + # This should not have any impact. Canfinish unavailable reviewers are included in + # the default rotation, and filtered further when making assignment choices. + UnavailablePeriod.objects.create( + team=team, + person=reviewers[1], + start_date='2000-01-01', + availability='canfinish', + ) + + # No known assignments + rotation = policy.default_reviewer_rotation_list() + self.assertNotIn(unavailable_reviewer, rotation) + self.assertEqual(rotation, reviewers) + + # Regular accepted assignment + ReviewAssignmentFactory(reviewer=reviewers[1].email(), assigned_on='2019-01-01', + state_id='accepted', review_request__team=team) + # Rejected assignment, should not affect reviewer 2's position + ReviewAssignmentFactory(reviewer=reviewers[2].email(), state_id='rejected', + review_request__team=team) + # Completed assignment, assigned before reviewer 1, + # but completed after (assign date should count). + ReviewAssignmentFactory(reviewer=reviewers[0].email(), assigned_on='2018-01-01', + completed_on='2020-01-01', state_id='completed', + review_request__team=team) + rotation = policy.default_reviewer_rotation_list() + self.assertNotIn(unavailable_reviewer, rotation) + self.assertEqual(rotation, [reviewers[2], reviewers[3], reviewers[4], reviewers[0], reviewers[1]]) + + class AssignmentOrderResolverTests(TestCase): def test_determine_ranking(self): # reviewer_high is second in the default rotation, reviewer_low is first