datatracker/ietf/review/policies.py
Lars Eggert 57f23f5198
chore: feat/dark-mode <- main (#6103)
* 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>
2023-08-10 09:49:15 -05:00

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,
}