* chore: Remove unused "rendertest" stuff (#6015) * fix: restore ability to create status change documents (#5963) * fix: restore ability to create status change documents Fixes #5962 * chore: address review comment * fix: Provide human-friendly status in submission status API response (#6011) Co-authored-by: nectostr <bastinda96@gmail.com> * fix: Make name/email lookups case-insensitive (#5972) (#6007) * fix: Make name/email lookups case-insensitive (#5972) Use icontains so that looking up name or email is case insensitive Added a test Fixes: 5972 * fix: Use __iexact not __icontains * fix: Clarify no-action-needed (#5918) (#6020) When a draft is submitted for manual processing, clarify that no action is needed; the Secretariat has the next steps. Fixes: #5918 * fix: Fix menu hover issue (#6019) * fix: Fix menu hover issue Fixes #5702 * Fix leftmenu hover issue * fix: Server error from api_get_session_materials() (#6025) Fixes #5877 * fix: Clarify Questionnaire label (#4688) (#6017) When filtering nominees, `Questionnaire` implies `Accepted == yes` so fix the dropdown test tosay that. Fixes: #4688 * chore: Merge from @martinthomson's rfc-txt-html (#6023) * fix:no history entry when changing RFC Editor note for doc (#6021) * fix:no history entry when changing RFC Editor note for doc * fix:no history entry when changing RFC Editor note for doc --------- Co-authored-by: Priyanka Narkar <priyankanarkar@dhcp-91f8.meeting.ietf.org> * fix: avoid deprecation warning on view_list() for objs without CommunityList Fixes #5942 * fix: return 404 for non-existing revisions (#6014) * fix: return 404 for non-existing revisions Links to non-existing revisions to docs should return 404 * fix: change rfc/rev and search behaviour * refactor: fix tab level * fix: return 404 for rfc revision for bibtex * fix: provide date for revisions in bibtex output (#6029) * fix: provide date for revisions in bibtex output * refactor: change walrus to if's * fix: specify particular revision for events * fix: review refactoring issue fixes #5447 * fix: Remove automatically suggested document for document that is already has review request (fixes #3211) (#5425) * Added check that if there is already review request for the document in question, ignore the automatic suggestion for that document. Fixes #3211. * fix: dont block on open requests for a previous version. Add tests --------- Co-authored-by: Nicolas Giard <github@ngpixel.com> Co-authored-by: Robert Sparks <rjsparks@nostrum.com> * feat: IAB statements (#5940) * feat: support iab and iesg statements. Import iab statements. (#5895) * feat: infrastructure for statements doctype * chore: basic test framework * feat: basic statement document view * feat: show replaced statements * chore: black * fix: state help for statements * fix: cleanout non-relevant email expansions * feat: import iab statements, provide group statements tab * fix: guard against running import twice * feat: build redirect csv for iab statements * fix: set document state on import * feat: show published date on main doc view * feat: handle pdf statements * feat: create new and update statements * chore: copyright block updates * chore: remove flakes * chore: black * feat: add edit/new buttons for the secretariat * fix: address PR #5895 review comments * fix: pin pydantic until inflect catches up (#5901) (#5902) * chore: re-un-pin pydantic * feat: include submitter in email about submitted slides (#6033) * feat: include submitter in email about submitted slides fixes #6031 * chore: remove unintended whitespace change * chore(dev): update .vscode/settings.json with new taskExplorer settings * fix: Add editorial stream to proceedings (#6027) * fix: Add editorial stream to proceedings Fixes #5717 * fix: Move editorial stream after the irtf in proceedings * fix: Add editorial stream to meeting materials (#6047) Fixes #6042 * fix: Shows requested reviews for doc fixes (#6022) * Fix: Shows requested reviews for doc * Changed template includes to only give required variables to them. * feat: allow openId to choose an unactive email if there are none active (#6041) * feat: allow openId to choose an unactive email if there are no active ones * chore: correct typo * chore: rename unactive to inactive * fix: Make review table more responsive (#6053) * fix: Improve layout of review table * Progress * Progress * Final changes * Fix tests * Remove fluff * Undo commits * ci: add --validate-html-harder to tests * ci: add --validate-html-harder to build.yml workflow * fix: Set colspan to actual number of columns (#6069) * fix: Clean up view_feedback_pending (#6070) - Remove "Unclassified" column header, which caused misalignment in the table body. - Show the message author - previously displayed as `(None)`. * docs: Update LICENSE year * fix: Remove IESG state edit button when state is 'dead' (#6051) (#6065) * fix: Correctly order "last call requested" column in the IESG dashboard (#6079) * ci: update dev sandbox init script to start memcached * feat: Reclassify nomcom feedback (#6002) * fix: Clean up view_feedback_pending - Remove "Unclassified" column header, which caused misalignment in the table body. - Show the message author - previously displayed as `(None)`. * feat: Reclassify nomcom feedback (#4669) - There's a new `Chair/Advisor Tasks` menu item `Reclassify feedback`. - I overloaded `view_feedback*` URLs with a `?reclassify` parameter. - This adds a checkbox to each feedback message, and a `Reclassify` button at the bottom of each feedback page. - "Reclassifying" basically de-classifies the feedback, and punts it back to the "Pending emails" view for reclassification. - If a feedback has been applied to multiple nominees, declassifying it from one nominee removes it from all. * fix: Remove unused local variables * fix: Fix some missing and mis-nested html * test: Add tests for reclassifying feedback * refactor: Substantial redesign of feedback reclassification - Break out reclassify_feedback* as their own URLs and views, and revert changes to view_feedback*.html. - Replace checkboxes with a Reclassify button on each message. * fix: Remember to clear the feedback associations when reclassifying * feat: Add an 'Overcome by events' feedback type * refactor: When invoking reclassification from a view-feedback page, load the corresponding reclassify-feedback page * fix: De-conflict migration with 0004_statements Also change the coding style to match, and add a reverse migration. * fix: Fix a test case to account for new feedback type * fix: 842e730 broke the Back button * refactor: Reclassify feedback directly instead of putting it back in the work queue * fix: Adjust tests to new workflow * refactor: Further refine reclassification to avoid redirects * refactor: Impose a FeedbackTypeName ordering Also add FeedbackTypeName.legend field, rather than synthesizing it every time we classify or reclassify feedback. In the reclassification forms, only show the relevant feedback types. * refactor: Merge reclassify_feedback_* back into view_feedback_* This means the "Reclassify" button is always present, but eliminates some complexity. * refactor: Add filter(used=True) on FeedbackTypeName querysets * refactor: Add the new FeedbackTypeName to the reclassification success message * fix: Secure reclassification against rogue nomcom members * fix: Print decoded key and fully clean up test nomcom (#6094) * fix: Delete Person records when deleting a test nomcom * fix: Decode test nomcom private key before printing * test: Use correct time zone for test_statement_doc_view (#6064) * chore(deps): update all npm dependencies for playwright (#6061) Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com> * chore(deps): update all npm dependencies for dev/diff (#6062) Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com> * chore(deps): update all npm dependencies for dev/coverage-action (#6063) Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com> * fix: Hash cache key for default memcached cache (#6089) * feat: Show docs that an AD hasn't balloted on that need ballots to progress (#6075) * fix(doc): Unify help texts for document states (#6060) * Fix IESG State help text link (only) * Intermediate checkpoint * Correct URL filtering of state descriptions * Unify help texts for document states * Remove redundant load static from template --------- Co-authored-by: Robert Sparks <rjsparks@nostrum.com> * ci: fix sandbox start.sh memcached user * fix: refactor how settings handles cache definitions (#6099) * fix: refactor how settings handles cache definitions * chore: more english-speaker readable expression * fix: Cast cache key to str before calling encode (#6100) --------- Co-authored-by: Robert Sparks <rjsparks@nostrum.com> Co-authored-by: Liubov Kurafeeva <liubov.kurafeeva@gmail.com> Co-authored-by: nectostr <bastinda96@gmail.com> Co-authored-by: Rich Salz <rsalz@akamai.com> Co-authored-by: PriyankaN <priyanka@amsl.com> Co-authored-by: Priyanka Narkar <priyankanarkar@dhcp-91f8.meeting.ietf.org> Co-authored-by: Ali <alireza83@gmail.com> Co-authored-by: Roman Beltiukov <maybe.hello.world@gmail.com> Co-authored-by: Tero Kivinen <kivinen@iki.fi> Co-authored-by: Nicolas Giard <github@ngpixel.com> Co-authored-by: Kesara Rathnayake <kesara@fq.nz> Co-authored-by: Jennifer Richards <jennifer@staff.ietf.org> Co-authored-by: Paul Selkirk <paul@painless-security.com> Co-authored-by: depfu[bot] <23717796+depfu[bot]@users.noreply.github.com> Co-authored-by: Jim Fenton <fenton@bluepopcorn.net>
585 lines
27 KiB
Python
585 lines
27 KiB
Python
# Copyright The IETF Trust 2019-2021, All Rights Reserved
|
|
|
|
|
|
import re
|
|
|
|
from django.db.models.aggregates import Max
|
|
from django.utils import timezone
|
|
from simple_history.utils import bulk_update_with_history
|
|
|
|
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)
|
|
from ietf.utils import log
|
|
|
|
"""
|
|
This file contains policies regarding reviewer queues.
|
|
The policies are documented in more detail on:
|
|
https://github.com/ietf-tools/datatracker/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)
|
|
|
|
|
|
def persons_with_previous_review(team, review_req, possible_person_ids, state_id):
|
|
""" Collect anyone in possible_person_ids that have reviewed the document before
|
|
|
|
Also considers ancestor documents. The possible_person_ids elements can be Person objects or PKs
|
|
Returns a set of Person IDs.
|
|
"""
|
|
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=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
|
|
|
|
|
|
class AbstractReviewerQueuePolicy:
|
|
def __init__(self, team):
|
|
self.team = team
|
|
|
|
def assign_reviewer(self, review_req, reviewer, add_skip):
|
|
"""Assign a reviewer to a request and update policy state accordingly"""
|
|
# Update policy state first - needed by LRU policy to correctly compute whether assignment was in-order
|
|
self.update_policy_state_for_assignment(review_req, reviewer.person, add_skip)
|
|
return review_req.reviewassignment_set.create(state_id='assigned', reviewer=reviewer, assigned_on=timezone.now())
|
|
|
|
def default_reviewer_rotation_list(self, include_unavailable=False):
|
|
""" Return a list of reviewers (Person objects) in the default reviewer rotation for a policy.
|
|
|
|
Subclasses should pretty much always override this. The default implementation provides the filtering
|
|
behavior expected of queue policies.
|
|
"""
|
|
# Default is just a filtered list of reviewers for the team, in arbitrary order.
|
|
rotation_list = list(Person.objects.filter(role__name="reviewer", role__group=self.team))
|
|
if not include_unavailable:
|
|
rotation_list = self._filter_unavailable_reviewers(rotation_list)
|
|
return rotation_list
|
|
|
|
def set_wants_to_be_next(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, review_req, assignee_person, add_skip=False):
|
|
"""Update the skip_count if the assignment was in order."""
|
|
self._clear_request_next_assignment(assignee_person)
|
|
|
|
rotation = self._filter_unavailable_reviewers(
|
|
self.default_reviewer_rotation_list(include_unavailable=True), # we are going to filter for ourselves
|
|
review_req,
|
|
)
|
|
|
|
# Use PKs, not objects, to avoid bugs arising from object identity comparisons
|
|
rotation_pks = [r.pk for r in rotation]
|
|
if len(rotation_pks) == 0:
|
|
return
|
|
# assignee_person should be in the rotation list, otherwise they should not have been an option
|
|
log.assertion('assignee_person.pk in rotation_pks')
|
|
|
|
if self._assignment_in_order(rotation_pks, assignee_person):
|
|
self._update_skip_next(rotation_pks, assignee_person)
|
|
|
|
if add_skip:
|
|
self._add_skip(assignee_person)
|
|
|
|
def _update_skip_next(self, rotation_pks, assignee_person):
|
|
"""Decrement skip_next for all users skipped"""
|
|
assignee_index = rotation_pks.index(assignee_person.pk)
|
|
skipped = rotation_pks[0:assignee_index]
|
|
skipped_settings = self.team.reviewersettings_set.filter(person__in=skipped) # list of PKs is valid here
|
|
for ss in skipped_settings:
|
|
ss.skip_next = max(0, ss.skip_next - 1) # ensure we don't go negative
|
|
bulk_update_with_history(skipped_settings,
|
|
ReviewerSettings,
|
|
['skip_next'],
|
|
default_change_reason='skipped')
|
|
|
|
def _assignment_in_order(self, rotation_pks, assignee_person):
|
|
"""Is this an in-order assignment?"""
|
|
if assignee_person.pk not in rotation_pks:
|
|
return False # picking from off the list is not in order
|
|
|
|
# map from person ID to skip_next
|
|
skip_next = dict(
|
|
self.team.reviewersettings_set.filter(
|
|
person__in=rotation_pks
|
|
).values_list('person_id', 'skip_next')
|
|
)
|
|
rotation_skips = [skip_next.get(pk, 0) for pk in rotation_pks]
|
|
min_skip = min(rotation_skips) # usually 0, but not guaranteed
|
|
assignee_index = rotation_pks.index(assignee_person.pk)
|
|
assignee_skip = rotation_skips[assignee_index]
|
|
|
|
# If the assignee should be skipped, the selection is not in order
|
|
if assignee_skip != min_skip:
|
|
return False
|
|
|
|
# If any of the preceding reviewers should not have been skipped, the selection is not in order
|
|
for earlier_skip in rotation_skips[0:assignee_index]:
|
|
if earlier_skip <= min_skip:
|
|
return False
|
|
|
|
# The selection was in order
|
|
return True
|
|
|
|
# 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)")
|
|
"""
|
|
|
|
# Collect a set of person IDs for people who have either not responded
|
|
# to or outright rejected reviewing this document in the past
|
|
rejecting_reviewer_ids = review_req.doc.reviewrequest_set.filter(
|
|
reviewassignment__state__slug__in=('rejected', 'no-response')
|
|
).values_list(
|
|
'reviewassignment__reviewer__person_id', flat=True
|
|
)
|
|
|
|
# Query the Email objects for reviewers who haven't rejected or
|
|
# not responded to this document in the past
|
|
field.queryset = field.queryset.filter(
|
|
role__name="reviewer",
|
|
role__group=review_req.team
|
|
).exclude( person_id__in=rejecting_reviewer_ids )
|
|
|
|
one_assignment = None
|
|
if review_req.pk is not None:
|
|
# cannot use reviewassignment_set relation until review_req has been created
|
|
one_assignment = (review_req.reviewassignment_set
|
|
.exclude(state__slug__in=('rejected', 'no-response'))
|
|
.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._filter_unavailable_reviewers(
|
|
self.default_reviewer_rotation_list(include_unavailable=True),
|
|
review_req,
|
|
)
|
|
)
|
|
return [(r['email'].pk, r['label']) for r in resolver.determine_ranking()]
|
|
|
|
def _filter_unavailable_reviewers(self, reviewers, review_req=None):
|
|
"""Remove any reviewers who are not available for the specified review request
|
|
|
|
Reviewers who have an unavailability reason of 'unavailable' are always excluded from the
|
|
output.
|
|
|
|
If review_req is specified, 'canfinish' reviewers who have previously completed a review of
|
|
the doc in the ReviewRequest will be treated as available.
|
|
|
|
If no review_req is None, reviewers who are 'canfinish' for *any* review are included in the
|
|
output. Only 'unavailable' reviewers are excluded. In this case, the caller must account for
|
|
these 'canfinish' reviewers only being available for some reviews.
|
|
|
|
If multiple UnavailablePeriods apply, a 'canfinish' will take priority over an 'unavailable'.
|
|
"""
|
|
unavailable_periods = current_unavailable_periods_for_reviewers(self.team)
|
|
if len(unavailable_periods) == 0:
|
|
return reviewers.copy() # nothing to do
|
|
|
|
available_reviewers = []
|
|
if review_req:
|
|
previous_reviewers = persons_with_previous_review(self.team,
|
|
review_req,
|
|
[r.pk for r in reviewers],
|
|
'completed')
|
|
else:
|
|
# treat all reviewers as previous_reviewers if no review_req
|
|
previous_reviewers = [r.pk for r in reviewers]
|
|
|
|
for reviewer in reviewers:
|
|
current_periods = unavailable_periods.get(reviewer.pk)
|
|
keep = (current_periods is None) or (
|
|
'canfinish' in [p.availability for p in current_periods] and reviewer.pk in previous_reviewers
|
|
)
|
|
if keep:
|
|
available_reviewers.append(reviewer)
|
|
return available_reviewers
|
|
|
|
def _clear_request_next_assignment(self, person):
|
|
s = self._reviewer_settings_for(person)
|
|
s.request_assignment_next = False
|
|
s.save()
|
|
|
|
def _add_skip(self, person):
|
|
s = self._reviewer_settings_for(person)
|
|
s.skip_next += 1
|
|
s.save()
|
|
|
|
def _reviewer_settings_for(self, person):
|
|
return ReviewerSettings.objects.get_or_create(team=self.team, person=person)[0]
|
|
|
|
|
|
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 = persons_with_previous_review(
|
|
self.team, self.review_req, self.possible_person_ids, 'completed'
|
|
)
|
|
self.has_rejected_review_previous = persons_with_previous_review(
|
|
self.team, 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 = [self._ranking_for_email(e) for e in self.possible_emails if e.person_id in self.rotation_index]
|
|
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.
|
|
|
|
Only valid if email.person_id is in self.rotation_index.
|
|
"""
|
|
log.assertion('email.person_id in self.rotation_index')
|
|
|
|
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)
|
|
|
|
periods = self.unavailable_periods.get(email.person_id, [])
|
|
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 _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):
|
|
reviewers = super(
|
|
RotateAlphabeticallyReviewerQueuePolicy, self
|
|
).default_reviewer_rotation_list(include_unavailable)
|
|
|
|
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)
|
|
|
|
return reviewers[next_reviewer_index:] + reviewers[:next_reviewer_index]
|
|
|
|
def set_wants_to_be_next(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()
|
|
|
|
def _update_skip_next(self, rotation_pks, assignee_person):
|
|
"""Decrement skip_next for all users skipped
|
|
|
|
In addition to the base class behavior, this looks ahead to the next reviewer in the
|
|
team and updates NextReviewerInTeam appropriately. Accounts for skip counts along the
|
|
way.
|
|
"""
|
|
|
|
super(RotateAlphabeticallyReviewerQueuePolicy, self)._update_skip_next(rotation_pks,
|
|
assignee_person)
|
|
# All reviewers in the list ahead of the assignee have already had their skip_next
|
|
# values decremented. Now need to update NextReviewerInTeam.
|
|
|
|
# Copy and unfold the rotation list, putting the assignee at the front
|
|
unfolded_rotation_pks = rotation_pks.copy()
|
|
assignee_index = unfolded_rotation_pks.index(assignee_person.pk)
|
|
unfolded_rotation_pks = unfolded_rotation_pks[assignee_index:] + unfolded_rotation_pks[:assignee_index]
|
|
# Then remove the assignee
|
|
unfolded_rotation_pks.pop(0)
|
|
|
|
# Nothing to do if the assignee is the only person in the list
|
|
if len(unfolded_rotation_pks) == 0:
|
|
return
|
|
|
|
# Get a map from person PK to their settings, if any
|
|
rotation_settings = {
|
|
settings.person_id: settings
|
|
for settings in self.team.reviewersettings_set.filter(person__in=unfolded_rotation_pks)
|
|
}
|
|
|
|
# Update any skip_counts we skip while finding the next reviewer. Handle the case where all skip_count > 0.
|
|
if len(rotation_settings) < len(unfolded_rotation_pks):
|
|
min_skip_next = 0 # one or more reviewers has no settings object, so they have skip_count=0
|
|
else:
|
|
min_skip_next = min([rs.skip_next for rs in rotation_settings.values()])
|
|
|
|
next_reviewer_index = None
|
|
for index, pk in enumerate(unfolded_rotation_pks):
|
|
rs = rotation_settings.get(pk)
|
|
if (rs is None) or (rs.skip_next == min_skip_next):
|
|
next_reviewer_index = index
|
|
break
|
|
else:
|
|
rs.skip_next = max(0, rs.skip_next - 1) # ensure never negative
|
|
|
|
log.assertion('next_reviewer_index is not None') # some entry in the list must have the minimum value
|
|
|
|
bulk_update_with_history(rotation_settings.values(),
|
|
ReviewerSettings,
|
|
['skip_next'],
|
|
default_change_reason='skipped')
|
|
|
|
next_reviewer_pk = unfolded_rotation_pks[next_reviewer_index]
|
|
NextReviewerInTeam.objects.update_or_create(
|
|
team=self.team,
|
|
defaults=dict(next_reviewer_id=next_reviewer_pk)
|
|
)
|
|
|
|
|
|
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):
|
|
reviewers = super(
|
|
LeastRecentlyUsedReviewerQueuePolicy, self
|
|
).default_reviewer_rotation_list(include_unavailable)
|
|
|
|
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
|
|
return rotation_list
|
|
|
|
def set_wants_to_be_next(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.
|
|
settings = self._reviewer_settings_for(reviewer_person)
|
|
settings.request_assignment_next = True
|
|
settings.save()
|
|
|
|
|
|
QUEUE_POLICY_NAME_MAPPING = {
|
|
'RotateAlphabetically': RotateAlphabeticallyReviewerQueuePolicy,
|
|
'LeastRecentlyUsed': LeastRecentlyUsedReviewerQueuePolicy,
|
|
}
|