forked from adamlaska/datatracker
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpolicies.py
More file actions
474 lines (404 loc) · 22.1 KB
/
Copy pathpolicies.py
File metadata and controls
474 lines (404 loc) · 22.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# Copyright The IETF Trust 2019-2020, All Rights Reserved
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)")
"""
# 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 = (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.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,
}