459 lines
22 KiB
Python
459 lines
22 KiB
Python
# Copyright The IETF Trust 2019-2020, All Rights Reserved
|
|
|
|
from __future__ import absolute_import, print_function, unicode_literals
|
|
|
|
import re
|
|
|
|
from django.db.models.aggregates import Max
|
|
|
|
from ietf.doc.models import DocumentAuthor, DocAlias
|
|
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, \
|
|
ReviewAssignment, ReviewTeamSettings
|
|
from ietf.review.utils import (current_unavailable_periods_for_reviewers,
|
|
days_needed_to_fulfill_min_interval_for_reviewers,
|
|
get_default_filter_re,
|
|
latest_review_assignments_for_reviewers)
|
|
|
|
"""
|
|
This file contains policies regarding reviewer queues.
|
|
The policies are documented in more detail on:
|
|
https://trac.tools.ietf.org/tools/ietfdb/wiki/ReviewerQueuePolicy
|
|
Terminology used here should match terminology used in that document.
|
|
"""
|
|
|
|
|
|
def get_reviewer_queue_policy(team):
|
|
try:
|
|
settings = ReviewTeamSettings.objects.get(group=team)
|
|
except ReviewTeamSettings.DoesNotExist:
|
|
raise ValueError('Request for a reviewer queue policy for team {} '
|
|
'which has no ReviewTeamSettings'.format(team))
|
|
try:
|
|
policy = QUEUE_POLICY_NAME_MAPPING[settings.reviewer_queue_policy.slug]
|
|
except KeyError:
|
|
raise ValueError('Team {} has unknown reviewer queue policy: '
|
|
'{}'.format(team, settings.reviewer_queue_policy.slug))
|
|
return policy(team)
|
|
|
|
|
|
class AbstractReviewerQueuePolicy:
|
|
def __init__(self, team):
|
|
self.team = team
|
|
|
|
def default_reviewer_rotation_list(self, dont_skip_person_ids=None):
|
|
"""
|
|
Return a list of reviewers (Person objects) in the default reviewer rotation for a policy.
|
|
"""
|
|
raise NotImplementedError # pragma: no cover
|
|
|
|
def return_reviewer_to_rotation_top(self, reviewer_person):
|
|
"""
|
|
Return a reviewer to the top of the rotation, e.g. because they rejected a review,
|
|
and should retroactively not have been rotated over.
|
|
"""
|
|
raise NotImplementedError # pragma: no cover
|
|
|
|
def default_reviewer_rotation_list_without_skipped(self):
|
|
"""
|
|
Return a list of reviewers (Person objects) in the default reviewer rotation for a policy,
|
|
while skipping those with a skip_next>0.
|
|
"""
|
|
return [r for r in self.default_reviewer_rotation_list() if not self._reviewer_settings_for(r).skip_next]
|
|
|
|
def update_policy_state_for_assignment(self, assignee_person, add_skip=False):
|
|
"""
|
|
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 = self.default_reviewer_rotation_list_without_skipped()
|
|
# 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. There is a cap on the number of iterations, which can be hit e.g. if there
|
|
# is only a single reviewer, and the current assignee is excluded from being set as
|
|
# NextReviewerInTeam.
|
|
current_idx = 0
|
|
max_iter = sum([self._reviewer_settings_for(r).skip_next for r in rotation_list]) + len(rotation_list)
|
|
if in_order_assignment:
|
|
while current_idx <= max_iter:
|
|
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):
|
|
"""
|
|
Fill a choice field with the recommended assignment order of reviewers for a review request.
|
|
The field should be an instance similar to
|
|
PersonEmailChoiceField(label="Assign Reviewer", empty_label="(None)")
|
|
"""
|
|
field.queryset = field.queryset.filter(role__name="reviewer", role__group=review_req.team)
|
|
one_assignment = review_req.reviewassignment_set.first()
|
|
if one_assignment:
|
|
field.initial = one_assignment.reviewer_id
|
|
|
|
choices = self.recommended_assignment_order(field.queryset, review_req)
|
|
if not field.required:
|
|
choices = [("", field.empty_label)] + choices
|
|
|
|
field.choices = choices
|
|
|
|
def recommended_assignment_order(self, email_queryset, review_req):
|
|
"""
|
|
Determine the recommended assignment order for a review request,
|
|
choosing from the reviewers in email_queryset, which should be a queryset
|
|
returning Email objects.
|
|
"""
|
|
if review_req.team != self.team:
|
|
raise ValueError('Reviewer queue policy was passed a review request belonging to a different team.')
|
|
resolver = AssignmentOrderResolver(email_queryset, review_req, self.default_reviewer_rotation_list())
|
|
return [(r['email'].pk, r['label']) for r in resolver.determine_ranking()]
|
|
|
|
def _entirely_unavailable_reviewers(self, dont_skip_person_ids=None):
|
|
"""
|
|
Return a set of PKs of Persons that should not be considered
|
|
to be in the rotation list at all.
|
|
"""
|
|
reviewers_to_skip = set()
|
|
if not dont_skip_person_ids:
|
|
dont_skip_person_ids = []
|
|
|
|
unavailable_periods = current_unavailable_periods_for_reviewers(self.team)
|
|
for person_id, periods in unavailable_periods.items():
|
|
if periods and person_id not in dont_skip_person_ids and not any(p.availability == "canfinish" for p in periods):
|
|
reviewers_to_skip.add(person_id)
|
|
|
|
return reviewers_to_skip
|
|
|
|
def _reviewer_settings_for(self, person):
|
|
return (ReviewerSettings.objects.filter(team=self.team, person=person).first()
|
|
or ReviewerSettings(team=self.team, person=person))
|
|
|
|
|
|
class AssignmentOrderResolver:
|
|
"""
|
|
The AssignmentOrderResolver resolves the "recommended assignment order",
|
|
for a set of possible reviewers (email_queryset), a review request, and a
|
|
rotation list.
|
|
"""
|
|
def __init__(self, email_queryset, review_req, rotation_list):
|
|
self.review_req = review_req
|
|
self.doc = review_req.doc
|
|
self.team = review_req.team
|
|
self.rotation_list = rotation_list
|
|
|
|
self.possible_emails = list(email_queryset)
|
|
self.possible_person_ids = [e.person_id for e in self.possible_emails]
|
|
self._collect_context()
|
|
|
|
def _collect_context(self):
|
|
"""Collect all relevant data about this team, document and review request."""
|
|
|
|
self.doc_aliases = DocAlias.objects.filter(docs=self.doc).values_list("name", flat=True)
|
|
|
|
# This data is collected as a dict, keys being person IDs, values being numbers/objects.
|
|
self.rotation_index = {p.pk: i for i, p in enumerate(self.rotation_list)}
|
|
self.reviewer_settings = self._reviewer_settings_for_person_ids(self.possible_person_ids)
|
|
self.days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(self.team)
|
|
self.connections = self._connections_with_doc(self.doc, self.possible_person_ids)
|
|
self.unavailable_periods = current_unavailable_periods_for_reviewers(self.team)
|
|
self.assignment_data_for_reviewers = latest_review_assignments_for_reviewers(self.team)
|
|
self.unavailable_periods = current_unavailable_periods_for_reviewers(self.team)
|
|
|
|
# This data is collected as a set of person IDs.
|
|
self.has_completed_review_previous = self._persons_with_previous_review(self.review_req, self.possible_person_ids, 'completed')
|
|
self.has_rejected_review_previous = self._persons_with_previous_review(self.review_req, self.possible_person_ids, 'rejected')
|
|
self.wish_to_review = set(ReviewWish.objects.filter(team=self.team, person__in=self.possible_person_ids,
|
|
doc=self.doc).values_list("person", flat=True))
|
|
|
|
def determine_ranking(self):
|
|
"""
|
|
Determine the ranking of reviewers.
|
|
Returns a list of tuples, each tuple containing an Email pk and an explanation label.
|
|
"""
|
|
ranking = []
|
|
for e in self.possible_emails:
|
|
ranking_for_email = self._ranking_for_email(e)
|
|
if ranking_for_email:
|
|
ranking.append(ranking_for_email)
|
|
|
|
ranking.sort(key=lambda r: r["scores"], reverse=True)
|
|
return ranking
|
|
|
|
def _ranking_for_email(self, email):
|
|
"""
|
|
Determine the ranking for a specific Email.
|
|
Returns a dict with an email object, the scores and an explanation label.
|
|
The scores are a list of individual scores, i.e. they are prioritised, not
|
|
cumulative; so when comparing scores, elements later in the scores list
|
|
will only matter if all earlier scores in the list are equal.
|
|
"""
|
|
settings = self.reviewer_settings.get(email.person_id)
|
|
scores = []
|
|
explanations = []
|
|
|
|
def add_boolean_score(direction, expr, explanation=None):
|
|
scores.append(direction if expr else -direction)
|
|
if expr and explanation:
|
|
explanations.append(explanation)
|
|
|
|
if email.person_id not in self.rotation_index:
|
|
return
|
|
|
|
# If a reviewer is unavailable at the moment, they are ignored.
|
|
periods = self.unavailable_periods.get(email.person_id, [])
|
|
unavailable_at_the_moment = periods and not (
|
|
email.person_id in self.has_completed_review_previous and
|
|
all(p.availability == "canfinish" for p in periods)
|
|
)
|
|
if unavailable_at_the_moment:
|
|
return
|
|
|
|
def format_period(p):
|
|
if p.end_date:
|
|
res = "unavailable until {}".format(p.end_date.isoformat())
|
|
else:
|
|
res = "unavailable indefinitely"
|
|
return "{} ({})".format(res, p.get_availability_display())
|
|
if periods:
|
|
explanations.append(", ".join(format_period(p) for p in periods))
|
|
|
|
add_boolean_score(-1, email.person_id in self.has_rejected_review_previous, "rejected review of document before")
|
|
add_boolean_score(+1, settings.request_assignment_next, "requested to be selected next for assignment")
|
|
add_boolean_score(+1, email.person_id in self.has_completed_review_previous, "reviewed document before")
|
|
add_boolean_score(+1, email.person_id in self.wish_to_review, "wishes to review document")
|
|
add_boolean_score(-1, email.person_id in self.connections,
|
|
self.connections.get(email.person_id)) # reviewer is somehow connected: bad
|
|
add_boolean_score(-1, settings.filter_re and any(
|
|
re.search(settings.filter_re, n) for n in self.doc_aliases), "filter regexp matches")
|
|
|
|
# minimum interval between reviews
|
|
days_needed = self.days_needed_for_reviewers.get(email.person_id, 0)
|
|
scores.append(-days_needed)
|
|
if days_needed > 0:
|
|
explanations.append("max frequency exceeded, ready in {} {}".format(days_needed,
|
|
"day" if days_needed == 1 else "days"))
|
|
# skip next value
|
|
scores.append(-settings.skip_next)
|
|
if settings.skip_next > 0:
|
|
explanations.append("skip next {}".format(settings.skip_next))
|
|
|
|
# index in the default rotation order
|
|
index = self.rotation_index.get(email.person_id, 0)
|
|
scores.append(-index)
|
|
explanations.append("#{}".format(index + 1))
|
|
|
|
# stats (for information, do not affect score)
|
|
stats = self._collect_reviewer_stats(email)
|
|
if stats:
|
|
explanations.append(", ".join(stats))
|
|
|
|
label = str(email.person)
|
|
if explanations:
|
|
label = "{}: {}".format(label, "; ".join(explanations))
|
|
return {
|
|
"email": email,
|
|
"scores": scores,
|
|
"label": label,
|
|
}
|
|
|
|
def _collect_reviewer_stats(self, email):
|
|
"""Collect statistics on past reviews for a particular Email."""
|
|
stats = []
|
|
assignment_data = self.assignment_data_for_reviewers.get(email.person_id, [])
|
|
currently_open = sum(1 for d in assignment_data if d.state in ["assigned", "accepted"])
|
|
pages = sum(
|
|
rd.doc_pages for rd in assignment_data if rd.state in ["assigned", "accepted"])
|
|
if currently_open > 0:
|
|
stats.append("currently {count} open, {pages} pages".format(count=currently_open,
|
|
pages=pages))
|
|
could_have_completed = [d for d in assignment_data if
|
|
d.state in ["part-completed", "completed", "no-response"]]
|
|
if could_have_completed:
|
|
no_response = len([d for d in assignment_data if d.state == 'no-response'])
|
|
if no_response:
|
|
stats.append("%s no response" % no_response)
|
|
part_completed = len([d for d in assignment_data if d.state == 'part-completed'])
|
|
if part_completed:
|
|
stats.append("%s partially complete" % part_completed)
|
|
completed = len([d for d in assignment_data if d.state == 'completed'])
|
|
if completed:
|
|
stats.append("%s fully completed" % completed)
|
|
return stats
|
|
|
|
def _connections_with_doc(self, doc, person_ids):
|
|
"""
|
|
Collect any connections any Person in person_ids has with a document.
|
|
Returns a dict containing Person IDs that have a connection as keys,
|
|
values being an explanation string,
|
|
"""
|
|
connections = {}
|
|
# examine the closest connections last to let them override the label
|
|
connections[doc.ad_id] = "is associated Area Director"
|
|
for r in Role.objects.filter(group=doc.group_id,
|
|
person__in=person_ids).select_related("name"):
|
|
connections[r.person_id] = "is group {}".format(r.name)
|
|
if doc.shepherd:
|
|
connections[doc.shepherd.person_id] = "is shepherd of document"
|
|
for author in DocumentAuthor.objects.filter(document=doc,
|
|
person__in=person_ids).values_list(
|
|
"person", flat=True):
|
|
connections[author] = "is author of document"
|
|
return connections
|
|
|
|
def _persons_with_previous_review(self, review_req, possible_person_ids, state_id):
|
|
"""
|
|
Collect anyone in possible_person_ids that have reviewed the document before,
|
|
or an ancestor document.
|
|
Returns a set with Person IDs of anyone who has.
|
|
"""
|
|
doc_names = {review_req.doc.name}.union(*extract_complete_replaces_ancestor_mapping_for_docs([review_req.doc.name]).values())
|
|
has_reviewed_previous = ReviewRequest.objects.filter(
|
|
doc__name__in=doc_names,
|
|
reviewassignment__reviewer__person__in=possible_person_ids,
|
|
reviewassignment__state=state_id,
|
|
team=self.team,
|
|
).distinct()
|
|
if review_req.pk is not None:
|
|
has_reviewed_previous = has_reviewed_previous.exclude(pk=review_req.pk)
|
|
has_reviewed_previous = set(
|
|
has_reviewed_previous.values_list("reviewassignment__reviewer__person", flat=True))
|
|
return has_reviewed_previous
|
|
|
|
def _reviewer_settings_for_person_ids(self, person_ids):
|
|
reviewer_settings = {
|
|
r.person_id: r
|
|
for r in ReviewerSettings.objects.filter(team=self.team, person__in=person_ids)
|
|
}
|
|
for p in person_ids:
|
|
if p not in reviewer_settings:
|
|
reviewer_settings[p] = ReviewerSettings(team=self.team,
|
|
filter_re=get_default_filter_re(p))
|
|
return reviewer_settings
|
|
|
|
|
|
class RotateAlphabeticallyReviewerQueuePolicy(AbstractReviewerQueuePolicy):
|
|
"""
|
|
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))
|
|
reviewers.sort(key=lambda p: p.last_name())
|
|
next_reviewer_index = 0
|
|
|
|
next_reviewer_in_team = NextReviewerInTeam.objects.filter(team=self.team).select_related("next_reviewer").first()
|
|
if next_reviewer_in_team:
|
|
next_reviewer = next_reviewer_in_team.next_reviewer
|
|
|
|
if next_reviewer not in reviewers:
|
|
# If the next reviewer is no longer on the team,
|
|
# advance to the person that would be after them in
|
|
# the rotation. (Python will deal with too large slice indexes
|
|
# so no harm done by using the index on the original list
|
|
# afterwards)
|
|
reviewers_with_next = reviewers[:] + [next_reviewer]
|
|
reviewers_with_next.sort(key=lambda p: p.last_name())
|
|
next_reviewer_index = reviewers_with_next.index(next_reviewer)
|
|
else:
|
|
next_reviewer_index = reviewers.index(next_reviewer)
|
|
|
|
rotation_list = reviewers[next_reviewer_index:] + reviewers[:next_reviewer_index]
|
|
|
|
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
|
|
|
|
def return_reviewer_to_rotation_top(self, reviewer_person):
|
|
# As RotateAlphabetically does not keep a full rotation list,
|
|
# returning someone to a particular order is complex.
|
|
# Instead, the "assign me next" flag is set.
|
|
settings = self._reviewer_settings_for(reviewer_person)
|
|
settings.request_assignment_next = True
|
|
settings.save()
|
|
|
|
|
|
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))
|
|
reviewers_dict = {p.pk: p for p in reviewers}
|
|
assignments = ReviewAssignment.objects.filter(
|
|
review_request__team=self.team,
|
|
state__in=['accepted', 'assigned', 'completed'],
|
|
reviewer__person__in=reviewers,
|
|
).values('reviewer__person').annotate(most_recent=Max('assigned_on')).order_by('most_recent')
|
|
|
|
reviewers_with_assignment = [
|
|
reviewers_dict[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 += 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
|
|
|
|
def return_reviewer_to_rotation_top(self, reviewer_person):
|
|
# Reviewer rotation for this policy ignores rejected/withdrawn
|
|
# reviews, so it automatically adjusts the position of someone
|
|
# who rejected a review and no further action is needed.
|
|
pass
|
|
|
|
|
|
QUEUE_POLICY_NAME_MAPPING = {
|
|
'RotateAlphabetically': RotateAlphabeticallyReviewerQueuePolicy,
|
|
'LeastRecentlyUsed': LeastRecentlyUsedReviewerQueuePolicy,
|
|
}
|