DocumentAuthor, rename author field to email and make it optional (for modeling old email-less submissions), remove the authors many to many referencing field from Document as it is not really pointing the right place. Update the Secretariat tools to show affiliation and country. Add migration for getting rid of the fake email addresses that the migration script created some years ago (just set the author email field to null). - Legacy-Id: 12739
928 lines
37 KiB
Python
928 lines
37 KiB
Python
import datetime, re, itertools
|
|
from collections import defaultdict, namedtuple
|
|
|
|
from django.db.models import Q, Max, F
|
|
from django.core.urlresolvers import reverse as urlreverse
|
|
from django.contrib.sites.models import Site
|
|
|
|
import debug # pyflakes:ignore
|
|
|
|
from ietf.group.models import Group, Role
|
|
from ietf.doc.models import (Document, ReviewRequestDocEvent, State,
|
|
LastCallDocEvent, TelechatDocEvent,
|
|
DocumentAuthor, DocAlias)
|
|
from ietf.iesg.models import TelechatDate
|
|
from ietf.person.models import Person
|
|
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream
|
|
from ietf.review.models import (ReviewRequest, ReviewRequestStateName, ReviewTypeName,
|
|
ReviewerSettings, UnavailablePeriod, ReviewWish, NextReviewerInTeam,
|
|
ReviewSecretarySettings)
|
|
from ietf.utils.mail import send_mail
|
|
from ietf.doc.utils import extract_complete_replaces_ancestor_mapping_for_docs
|
|
|
|
def active_review_teams():
|
|
return Group.objects.filter(reviewteamsettings__isnull=False,state="active")
|
|
|
|
def close_review_request_states():
|
|
return ReviewRequestStateName.objects.filter(used=True).exclude(slug__in=["requested", "accepted", "rejected", "part-completed", "completed"])
|
|
|
|
def can_request_review_of_doc(user, doc):
|
|
if not user.is_authenticated():
|
|
return False
|
|
|
|
if doc.type_id == 'draft' and doc.get_state_slug() != 'active':
|
|
return False
|
|
|
|
return (is_authorized_in_doc_stream(user, doc)
|
|
or Role.objects.filter(person__user=user, name="secr", group__in=active_review_teams()).exists())
|
|
|
|
def can_manage_review_requests_for_team(user, team, allow_personnel_outside_team=True):
|
|
if not user.is_authenticated():
|
|
return False
|
|
|
|
return (Role.objects.filter(name="secr", person__user=user, group=team).exists()
|
|
or (allow_personnel_outside_team and has_role(user, "Secretariat")))
|
|
|
|
def can_access_review_stats_for_team(user, team):
|
|
if not user.is_authenticated():
|
|
return False
|
|
|
|
return (Role.objects.filter(name__in=("secr", "reviewer"), person__user=user, group=team).exists()
|
|
or has_role(user, ["Secretariat", "Area Director"]))
|
|
|
|
def review_requests_to_list_for_docs(docs):
|
|
request_qs = ReviewRequest.objects.filter(
|
|
state__in=["requested", "accepted", "part-completed", "completed"],
|
|
).prefetch_related("result")
|
|
|
|
doc_names = [d.name for d in docs]
|
|
|
|
return extract_revision_ordered_review_requests_for_documents_and_replaced(request_qs, doc_names)
|
|
|
|
def augment_review_requests_with_events(review_reqs):
|
|
req_dict = { r.pk: r for r in review_reqs }
|
|
for e in ReviewRequestDocEvent.objects.filter(review_request__in=review_reqs, type__in=["assigned_review_request", "closed_review_request"]).order_by("time"):
|
|
setattr(req_dict[e.review_request_id], e.type + "_event", e)
|
|
|
|
def no_review_from_teams_on_doc(doc, rev):
|
|
return Group.objects.filter(
|
|
reviewrequest__doc__name=doc.name,
|
|
reviewrequest__reviewed_rev=rev,
|
|
reviewrequest__state__slug="no-review-version",
|
|
).distinct()
|
|
|
|
def unavailable_periods_to_list(past_days=14):
|
|
return UnavailablePeriod.objects.filter(
|
|
Q(end_date=None) | Q(end_date__gte=datetime.date.today() - datetime.timedelta(days=past_days)),
|
|
).order_by("start_date")
|
|
|
|
def current_unavailable_periods_for_reviewers(team):
|
|
"""Return dict with currently active unavailable periods for reviewers."""
|
|
today = datetime.date.today()
|
|
|
|
unavailable_period_qs = UnavailablePeriod.objects.filter(
|
|
Q(end_date__gte=today) | Q(end_date=None),
|
|
Q(start_date__lte=today) | Q(start_date=None),
|
|
team=team,
|
|
).order_by("end_date")
|
|
|
|
res = defaultdict(list)
|
|
for period in unavailable_period_qs:
|
|
res[period.person_id].append(period)
|
|
|
|
return res
|
|
|
|
def reviewer_rotation_list(team, skip_unavailable=False, dont_skip=[]):
|
|
"""Returns person id -> index in rotation (next reviewer has index 0)."""
|
|
reviewers = list(Person.objects.filter(role__name="reviewer", role__group=team))
|
|
reviewers.sort(key=lambda p: p.last_name())
|
|
|
|
next_reviewer_index = 0
|
|
|
|
# now to figure out where the rotation is currently at
|
|
saved_reviewer = NextReviewerInTeam.objects.filter(team=team).select_related("next_reviewer").first()
|
|
if saved_reviewer:
|
|
n = saved_reviewer.next_reviewer
|
|
|
|
if n not in reviewers:
|
|
# saved reviewer might not still be here, if not just
|
|
# insert and use that position (Python will wrap around,
|
|
# so no harm done by using the index on the original list
|
|
# afterwards)
|
|
reviewers_with_next = reviewers[:] + [n]
|
|
reviewers_with_next.sort(key=lambda p: p.last_name())
|
|
next_reviewer_index = reviewers_with_next.index(n)
|
|
else:
|
|
next_reviewer_index = reviewers.index(n)
|
|
|
|
rotation_list = reviewers[next_reviewer_index:] + reviewers[:next_reviewer_index]
|
|
|
|
if skip_unavailable:
|
|
# prune reviewers not in the rotation (but not the assigned
|
|
# reviewer who must have been available for assignment anyway)
|
|
reviewers_to_skip = set()
|
|
|
|
unavailable_periods = current_unavailable_periods_for_reviewers(team)
|
|
for person_id, periods in unavailable_periods.iteritems():
|
|
if periods and person_id not in dont_skip:
|
|
reviewers_to_skip.add(person_id)
|
|
|
|
days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(team)
|
|
for person_id, days_needed in days_needed_for_reviewers.iteritems():
|
|
if person_id not in dont_skip:
|
|
reviewers_to_skip.add(person_id)
|
|
|
|
rotation_list = [p.pk for p in rotation_list if p.pk not in reviewers_to_skip]
|
|
|
|
return rotation_list
|
|
|
|
def days_needed_to_fulfill_min_interval_for_reviewers(team):
|
|
"""Returns person_id -> days needed until min_interval is fulfilled
|
|
for reviewer (in case it is necessary to wait, otherwise reviewer
|
|
is absent in result)."""
|
|
latest_assignments = dict(ReviewRequest.objects.filter(
|
|
team=team,
|
|
).values_list("reviewer__person").annotate(Max("time")))
|
|
|
|
min_intervals = dict(ReviewerSettings.objects.filter(team=team).values_list("person_id", "min_interval"))
|
|
|
|
now = datetime.datetime.now()
|
|
|
|
res = {}
|
|
for person_id, latest_assignment_time in latest_assignments.iteritems():
|
|
if latest_assignment_time is not None:
|
|
min_interval = min_intervals.get(person_id)
|
|
if min_interval is None:
|
|
continue
|
|
|
|
days_needed = max(0, min_interval - (now - latest_assignment_time).days)
|
|
if days_needed > 0:
|
|
res[person_id] = days_needed
|
|
|
|
return res
|
|
|
|
ReviewRequestData = namedtuple("ReviewRequestData", [
|
|
"req_pk", "doc", "doc_pages", "req_time", "state", "deadline", "reviewed_rev", "result", "team", "reviewer",
|
|
"late_days",
|
|
"request_to_assignment_days", "assignment_to_closure_days", "request_to_closure_days"])
|
|
|
|
|
|
def extract_review_request_data(teams=None, reviewers=None, time_from=None, time_to=None, ordering=[]):
|
|
"""Yield data on each review request, sorted by (*ordering, time)
|
|
for easy use with itertools.groupby. Valid entries in *ordering are "team" and "reviewer"."""
|
|
|
|
filters = Q()
|
|
|
|
if teams:
|
|
filters &= Q(team__in=teams)
|
|
|
|
if reviewers:
|
|
filters &= Q(reviewer__person__in=reviewers)
|
|
|
|
if time_from:
|
|
filters &= Q(time__gte=time_from)
|
|
|
|
if time_to:
|
|
filters &= Q(time__lte=time_to)
|
|
|
|
# we may be dealing with a big bunch of data, so treat it carefully
|
|
event_qs = ReviewRequest.objects.filter(filters)
|
|
|
|
# left outer join with RequestRequestDocEvent for request/assign/close time
|
|
event_qs = event_qs.values_list(
|
|
"pk", "doc", "doc__pages", "time", "state", "deadline", "reviewed_rev", "result", "team",
|
|
"reviewer__person", "reviewrequestdocevent__time", "reviewrequestdocevent__type"
|
|
)
|
|
|
|
event_qs = event_qs.order_by(*[o.replace("reviewer", "reviewer__person") for o in ordering] + ["time", "pk", "-reviewrequestdocevent__time"])
|
|
|
|
def positive_days(time_from, time_to):
|
|
if time_from is None or time_to is None:
|
|
return None
|
|
|
|
delta = time_to - time_from
|
|
seconds = delta.total_seconds()
|
|
if seconds > 0:
|
|
return seconds / float(24 * 60 * 60)
|
|
else:
|
|
return 0.0
|
|
|
|
for _, events in itertools.groupby(event_qs.iterator(), lambda t: t[0]):
|
|
requested_time = assigned_time = closed_time = None
|
|
|
|
for e in events:
|
|
req_pk, doc, doc_pages, req_time, state, deadline, reviewed_rev, result, team, reviewer, event_time, event_type = e
|
|
|
|
if event_type == "requested_review" and requested_time is None:
|
|
requested_time = event_time
|
|
elif event_type == "assigned_review_request" and assigned_time is None:
|
|
assigned_time = event_time
|
|
elif event_type == "closed_review_request" and closed_time is None:
|
|
closed_time = event_time
|
|
|
|
late_days = positive_days(datetime.datetime.combine(deadline, datetime.time.max), closed_time)
|
|
request_to_assignment_days = positive_days(requested_time, assigned_time)
|
|
assignment_to_closure_days = positive_days(assigned_time, closed_time)
|
|
request_to_closure_days = positive_days(requested_time, closed_time)
|
|
|
|
d = ReviewRequestData(req_pk, doc, doc_pages, req_time, state, deadline, reviewed_rev, result, team, reviewer,
|
|
late_days, request_to_assignment_days, assignment_to_closure_days,
|
|
request_to_closure_days)
|
|
|
|
yield d
|
|
|
|
def aggregate_raw_review_request_stats(review_request_data, count=None):
|
|
"""Take a sequence of review request data from
|
|
extract_review_request_data and aggregate them."""
|
|
|
|
state_dict = defaultdict(int)
|
|
late_state_dict = defaultdict(int)
|
|
result_dict = defaultdict(int)
|
|
assignment_to_closure_days_list = []
|
|
assignment_to_closure_days_count = 0
|
|
|
|
for (req_pk, doc, doc_pages, req_time, state, deadline, reviewed_rev, result, team, reviewer,
|
|
late_days, request_to_assignment_days, assignment_to_closure_days, request_to_closure_days) in review_request_data:
|
|
if count == "pages":
|
|
c = doc_pages
|
|
else:
|
|
c = 1
|
|
|
|
state_dict[state] += c
|
|
|
|
if late_days is not None and late_days > 0:
|
|
late_state_dict[state] += c
|
|
|
|
if state in ("completed", "part-completed"):
|
|
result_dict[result] += c
|
|
if assignment_to_closure_days is not None:
|
|
assignment_to_closure_days_list.append(assignment_to_closure_days)
|
|
assignment_to_closure_days_count += c
|
|
|
|
return state_dict, late_state_dict, result_dict, assignment_to_closure_days_list, assignment_to_closure_days_count
|
|
|
|
def compute_review_request_stats(raw_aggregation):
|
|
"""Compute statistics from aggregated review request data."""
|
|
state_dict, late_state_dict, result_dict, assignment_to_closure_days_list, assignment_to_closure_days_count = raw_aggregation
|
|
|
|
res = {}
|
|
res["state"] = state_dict
|
|
res["result"] = result_dict
|
|
|
|
res["open"] = sum(state_dict.get(s, 0) for s in ("requested", "accepted"))
|
|
res["completed"] = sum(state_dict.get(s, 0) for s in ("completed", "part-completed"))
|
|
res["not_completed"] = sum(state_dict.get(s, 0) for s in state_dict if s in ("rejected", "withdrawn", "overtaken", "no-response"))
|
|
|
|
res["open_late"] = sum(late_state_dict.get(s, 0) for s in ("requested", "accepted"))
|
|
res["open_in_time"] = res["open"] - res["open_late"]
|
|
res["completed_late"] = sum(late_state_dict.get(s, 0) for s in ("completed", "part-completed"))
|
|
res["completed_in_time"] = res["completed"] - res["completed_late"]
|
|
|
|
res["average_assignment_to_closure_days"] = float(sum(assignment_to_closure_days_list)) / (assignment_to_closure_days_count or 1) if assignment_to_closure_days_list else None
|
|
|
|
return res
|
|
|
|
def sum_raw_review_request_aggregations(raw_aggregations):
|
|
"""Collapse a sequence of aggregations into one aggregation."""
|
|
state_dict = defaultdict(int)
|
|
late_state_dict = defaultdict(int)
|
|
result_dict = defaultdict(int)
|
|
assignment_to_closure_days_list = []
|
|
assignment_to_closure_days_count = 0
|
|
|
|
for raw_aggr in raw_aggregations:
|
|
i_state_dict, i_late_state_dict, i_result_dict, i_assignment_to_closure_days_list, i_assignment_to_closure_days_count = raw_aggr
|
|
for s, v in i_state_dict.iteritems():
|
|
state_dict[s] += v
|
|
for s, v in i_late_state_dict.iteritems():
|
|
late_state_dict[s] += v
|
|
for r, v in i_result_dict.iteritems():
|
|
result_dict[r] += v
|
|
|
|
assignment_to_closure_days_list.extend(i_assignment_to_closure_days_list)
|
|
assignment_to_closure_days_count += i_assignment_to_closure_days_count
|
|
|
|
return state_dict, late_state_dict, result_dict, assignment_to_closure_days_list, assignment_to_closure_days_count
|
|
|
|
def latest_review_requests_for_reviewers(team, days_back=365):
|
|
"""Collect and return stats for reviewers on latest requests, in
|
|
extract_review_request_data format."""
|
|
|
|
extracted_data = extract_review_request_data(
|
|
teams=[team],
|
|
time_from=datetime.date.today() - datetime.timedelta(days=days_back),
|
|
ordering=["reviewer"],
|
|
)
|
|
|
|
req_data_for_reviewers = {
|
|
reviewer: list(reversed(list(req_data_items)))
|
|
for reviewer, req_data_items in itertools.groupby(extracted_data, key=lambda data: data.reviewer)
|
|
}
|
|
|
|
return req_data_for_reviewers
|
|
|
|
def make_new_review_request_from_existing(review_req):
|
|
obj = ReviewRequest()
|
|
obj.time = review_req.time
|
|
obj.type = review_req.type
|
|
obj.doc = review_req.doc
|
|
obj.team = review_req.team
|
|
obj.deadline = review_req.deadline
|
|
obj.requested_rev = review_req.requested_rev
|
|
obj.requested_by = review_req.requested_by
|
|
obj.state = ReviewRequestStateName.objects.get(slug="requested")
|
|
return obj
|
|
|
|
def email_review_request_change(request, review_req, subject, msg, by, notify_secretary, notify_reviewer, notify_requested_by):
|
|
|
|
"""Notify stakeholders about change, skipping a party if the change
|
|
was done by that party."""
|
|
|
|
system_email = Person.objects.get(name="(System)").formatted_email()
|
|
|
|
to = []
|
|
|
|
def extract_email_addresses(objs):
|
|
if any(o.person == by for o in objs if o):
|
|
l = []
|
|
else:
|
|
l = []
|
|
for o in objs:
|
|
if o:
|
|
e = o.formatted_email()
|
|
if e != system_email:
|
|
l.append(e)
|
|
|
|
for e in l:
|
|
if e not in to:
|
|
to.append(e)
|
|
|
|
if notify_secretary:
|
|
extract_email_addresses(Role.objects.filter(name="secr", group=review_req.team).distinct())
|
|
if notify_reviewer:
|
|
extract_email_addresses([review_req.reviewer])
|
|
if notify_requested_by:
|
|
extract_email_addresses([review_req.requested_by.email()])
|
|
|
|
if not to:
|
|
return
|
|
|
|
url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": review_req.doc.name, "request_id": review_req.pk })
|
|
url = request.build_absolute_uri(url)
|
|
send_mail(request, to, request.user.person.formatted_email(), subject, "review/review_request_changed.txt", {
|
|
"review_req_url": url,
|
|
"review_req": review_req,
|
|
"msg": msg,
|
|
})
|
|
|
|
def email_reviewer_availability_change(request, team, reviewer_role, msg, by):
|
|
"""Notify possibly both secretary and reviewer about change, skipping
|
|
a party if the change was done by that party."""
|
|
|
|
system_email = Person.objects.get(name="(System)").formatted_email()
|
|
|
|
to = []
|
|
|
|
def extract_email_addresses(objs):
|
|
if any(o.person == by for o in objs if o):
|
|
l = []
|
|
else:
|
|
l = []
|
|
for o in objs:
|
|
if o:
|
|
e = o.formatted_email()
|
|
if e != system_email:
|
|
l.append(e)
|
|
|
|
for e in l:
|
|
if e not in to:
|
|
to.append(e)
|
|
|
|
extract_email_addresses(Role.objects.filter(name="secr", group=team).distinct())
|
|
|
|
extract_email_addresses([reviewer_role])
|
|
|
|
if not to:
|
|
return
|
|
|
|
subject = "Reviewer availability of {} changed in {}".format(reviewer_role.person, team.acronym)
|
|
|
|
url = urlreverse("ietf.group.views_review.reviewer_overview", kwargs={ "group_type": team.type_id, "acronym": team.acronym })
|
|
url = request.build_absolute_uri(url)
|
|
send_mail(request, to, None, subject, "review/reviewer_availability_changed.txt", {
|
|
"reviewer_overview_url": url,
|
|
"reviewer": reviewer_role.person,
|
|
"team": team,
|
|
"msg": msg,
|
|
"by": by,
|
|
})
|
|
|
|
def assign_review_request_to_reviewer(request, review_req, reviewer):
|
|
assert review_req.state_id in ("requested", "accepted")
|
|
|
|
if reviewer == review_req.reviewer:
|
|
return
|
|
|
|
if review_req.reviewer:
|
|
email_review_request_change(
|
|
request, review_req,
|
|
"Unassigned from review of %s" % review_req.doc.name,
|
|
"%s has cancelled your assignment to the review." % request.user.person,
|
|
by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=False)
|
|
|
|
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
|
|
review_req.reviewer = reviewer
|
|
review_req.save()
|
|
|
|
if review_req.reviewer:
|
|
possibly_advance_next_reviewer_for_team(review_req.team, review_req.reviewer.person_id)
|
|
|
|
ReviewRequestDocEvent.objects.create(
|
|
type="assigned_review_request",
|
|
doc=review_req.doc,
|
|
by=request.user.person,
|
|
desc="Request for {} review by {} is assigned to {}".format(
|
|
review_req.type.name,
|
|
review_req.team.acronym.upper(),
|
|
review_req.reviewer.person if review_req.reviewer else "(None)",
|
|
),
|
|
review_request=review_req,
|
|
state=None,
|
|
)
|
|
|
|
email_review_request_change(
|
|
request, review_req,
|
|
"%s %s assignment: %s" % (review_req.team.acronym.capitalize(), review_req.type.name,review_req.doc.name),
|
|
"%s has assigned you as a reviewer for this document." % request.user.person,
|
|
by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=False)
|
|
|
|
def possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id):
|
|
assert assigned_review_to_person_id is not None
|
|
|
|
rotation_list = reviewer_rotation_list(team, skip_unavailable=True, dont_skip=[assigned_review_to_person_id])
|
|
|
|
def reviewer_at_index(i):
|
|
if not rotation_list:
|
|
return None
|
|
|
|
return rotation_list[i % len(rotation_list)]
|
|
|
|
def reviewer_settings_for(person_id):
|
|
return (ReviewerSettings.objects.filter(team=team, person=person_id).first()
|
|
or ReviewerSettings(team=team, person_id=person_id))
|
|
|
|
current_i = 0
|
|
|
|
if assigned_review_to_person_id == reviewer_at_index(current_i):
|
|
# move 1 ahead
|
|
current_i += 1
|
|
else:
|
|
settings = reviewer_settings_for(assigned_review_to_person_id)
|
|
settings.skip_next += 1
|
|
settings.save()
|
|
|
|
if not rotation_list:
|
|
return
|
|
|
|
while True:
|
|
# as a clean-up step go through any with a skip next > 0
|
|
current_reviewer_person_id = reviewer_at_index(current_i)
|
|
settings = reviewer_settings_for(current_reviewer_person_id)
|
|
if settings.skip_next > 0:
|
|
settings.skip_next -= 1
|
|
settings.save()
|
|
|
|
current_i += 1
|
|
else:
|
|
nr = NextReviewerInTeam.objects.filter(team=team).first() or NextReviewerInTeam(team=team)
|
|
nr.next_reviewer_id = current_reviewer_person_id
|
|
nr.save()
|
|
|
|
break
|
|
|
|
def close_review_request(request, review_req, close_state):
|
|
suggested_req = review_req.pk is None
|
|
|
|
prev_state = review_req.state
|
|
review_req.state = close_state
|
|
if close_state.slug == "no-review-version":
|
|
review_req.reviewed_rev = review_req.requested_rev or review_req.doc.rev # save rev for later reference
|
|
review_req.save()
|
|
|
|
if not suggested_req:
|
|
ReviewRequestDocEvent.objects.create(
|
|
type="closed_review_request",
|
|
doc=review_req.doc,
|
|
by=request.user.person,
|
|
desc="Closed request for {} review by {} with state '{}'".format(
|
|
review_req.type.name, review_req.team.acronym.upper(), close_state.name),
|
|
review_request=review_req,
|
|
state=review_req.state,
|
|
)
|
|
|
|
if prev_state.slug != "requested":
|
|
email_review_request_change(
|
|
request, review_req,
|
|
"Closed review request for {}: {}".format(review_req.doc.name, close_state.name),
|
|
"Review request has been closed by {}.".format(request.user.person),
|
|
by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=True)
|
|
|
|
def suggested_review_requests_for_team(team):
|
|
|
|
if not team.reviewteamsettings.autosuggest:
|
|
return []
|
|
|
|
system_person = Person.objects.get(name="(System)")
|
|
|
|
seen_deadlines = {}
|
|
|
|
requests = {}
|
|
|
|
now = datetime.datetime.now()
|
|
|
|
reviewable_docs_qs = Document.objects.filter(type="draft").exclude(stream="ise")
|
|
|
|
requested_state = ReviewRequestStateName.objects.get(slug="requested", used=True)
|
|
|
|
last_call_type = ReviewTypeName.objects.get(slug="lc")
|
|
if last_call_type in team.reviewteamsettings.review_types.all():
|
|
# in Last Call
|
|
last_call_docs = reviewable_docs_qs.filter(
|
|
states=State.objects.get(type="draft-iesg", slug="lc", used=True)
|
|
)
|
|
last_call_expiry_events = { e.doc_id: e for e in LastCallDocEvent.objects.order_by("time", "id") }
|
|
for doc in last_call_docs:
|
|
e = last_call_expiry_events[doc.pk] if doc.pk in last_call_expiry_events else LastCallDocEvent(expires=now.date(), time=now)
|
|
|
|
deadline = e.expires.date()
|
|
|
|
if deadline > seen_deadlines.get(doc.pk, datetime.date.max) or deadline < now.date():
|
|
continue
|
|
|
|
requests[doc.pk] = ReviewRequest(
|
|
time=e.time,
|
|
type=last_call_type,
|
|
doc=doc,
|
|
team=team,
|
|
deadline=deadline,
|
|
requested_by=system_person,
|
|
state=requested_state,
|
|
)
|
|
|
|
seen_deadlines[doc.pk] = deadline
|
|
|
|
|
|
telechat_type = ReviewTypeName.objects.get(slug="telechat")
|
|
if telechat_type in team.reviewteamsettings.review_types.all():
|
|
# on Telechat Agenda
|
|
telechat_dates = list(TelechatDate.objects.active().order_by('date').values_list("date", flat=True)[:4])
|
|
|
|
telechat_deadline_delta = datetime.timedelta(days=2)
|
|
|
|
telechat_docs = reviewable_docs_qs.filter(
|
|
docevent__telechatdocevent__telechat_date__in=telechat_dates
|
|
)
|
|
|
|
# we need to check the latest telechat event for each document
|
|
# scheduled for the telechat, as the appearance might have been
|
|
# cancelled/moved
|
|
telechat_events = TelechatDocEvent.objects.filter(
|
|
# turn into list so we don't get a complex and slow join sent down to the DB
|
|
doc__in=list(telechat_docs.values_list("pk", flat=True)),
|
|
).values_list(
|
|
"doc", "pk", "time", "telechat_date"
|
|
).order_by("doc", "-time", "-id").distinct()
|
|
|
|
for doc_pk, events in itertools.groupby(telechat_events, lambda t: t[0]):
|
|
_, _, event_time, event_telechat_date = list(events)[0]
|
|
|
|
deadline = None
|
|
if event_telechat_date in telechat_dates:
|
|
deadline = event_telechat_date - telechat_deadline_delta
|
|
|
|
if not deadline or deadline > seen_deadlines.get(doc_pk, datetime.date.max):
|
|
continue
|
|
|
|
requests[doc_pk] = ReviewRequest(
|
|
time=event_time,
|
|
type=telechat_type,
|
|
doc_id=doc_pk,
|
|
team=team,
|
|
deadline=deadline,
|
|
requested_by=system_person,
|
|
state=requested_state,
|
|
)
|
|
|
|
seen_deadlines[doc_pk] = deadline
|
|
|
|
# filter those with existing requests
|
|
existing_requests = defaultdict(list)
|
|
for r in ReviewRequest.objects.filter(doc__in=requests.iterkeys(), team=team):
|
|
existing_requests[r.doc_id].append(r)
|
|
|
|
def blocks(existing, request):
|
|
if existing.doc_id != request.doc_id:
|
|
return False
|
|
|
|
no_review_document = existing.state_id == "no-review-document"
|
|
pending = (existing.state_id in ("requested", "accepted")
|
|
and (not existing.requested_rev or existing.requested_rev == request.doc.rev))
|
|
completed_or_closed = (existing.state_id not in ("part-completed", "rejected", "overtaken", "no-response")
|
|
and existing.reviewed_rev == request.doc.rev)
|
|
|
|
return no_review_document or pending or completed_or_closed
|
|
|
|
res = [r for r in requests.itervalues()
|
|
if not any(blocks(e, r) for e in existing_requests[r.doc_id])]
|
|
res.sort(key=lambda r: (r.deadline, r.doc_id), reverse=True)
|
|
return res
|
|
|
|
def extract_revision_ordered_review_requests_for_documents_and_replaced(review_request_queryset, names):
|
|
"""Extracts all review requests for document names (including replaced ancestors), return them neatly sorted."""
|
|
|
|
names = set(names)
|
|
|
|
replaces = extract_complete_replaces_ancestor_mapping_for_docs(names)
|
|
|
|
requests_for_each_doc = defaultdict(list)
|
|
for r in review_request_queryset.filter(doc__in=set(e for l in replaces.itervalues() for e in l) | names).order_by("-reviewed_rev", "-time", "-id").iterator():
|
|
requests_for_each_doc[r.doc_id].append(r)
|
|
|
|
# now collect in breadth-first order to keep the revision order intact
|
|
res = defaultdict(list)
|
|
for name in names:
|
|
front = replaces.get(name, [])
|
|
res[name].extend(requests_for_each_doc.get(name, []))
|
|
|
|
seen = set()
|
|
|
|
while front:
|
|
replaces_reqs = []
|
|
next_front = []
|
|
for replaces_name in front:
|
|
if replaces_name in seen:
|
|
continue
|
|
|
|
seen.add(replaces_name)
|
|
|
|
reqs = requests_for_each_doc.get(replaces_name, [])
|
|
if reqs:
|
|
replaces_reqs.append(reqs)
|
|
|
|
next_front.extend(replaces.get(replaces_name, []))
|
|
|
|
# in case there are multiple replaces, move the ones with
|
|
# the latest reviews up front
|
|
replaces_reqs.sort(key=lambda l: l[0].time, reverse=True)
|
|
|
|
for reqs in replaces_reqs:
|
|
res[name].extend(reqs)
|
|
|
|
# move one level down
|
|
front = next_front
|
|
|
|
return res
|
|
|
|
def setup_reviewer_field(field, review_req):
|
|
field.queryset = field.queryset.filter(role__name="reviewer", role__group=review_req.team)
|
|
if review_req.reviewer:
|
|
field.initial = review_req.reviewer_id
|
|
|
|
choices = make_assignment_choices(field.queryset, review_req)
|
|
if not field.required:
|
|
choices = [("", field.empty_label)] + choices
|
|
|
|
field.choices = choices
|
|
|
|
def get_default_filter_re(person):
|
|
if type(person) != Person:
|
|
person = Person.objects.get(id=person)
|
|
groups_to_avoid = [r.group for r in person.role_set.filter(name='chair',group__type__in=['wg','rg'])]
|
|
if not groups_to_avoid:
|
|
return '^draft-%s-.*$' % ( person.last_name().lower(), )
|
|
else:
|
|
return '^draft-(%s|%s)-.*$' % ( person.last_name().lower(), '|'.join(['ietf-%s' % g.acronym for g in groups_to_avoid]))
|
|
|
|
def make_assignment_choices(email_queryset, review_req):
|
|
doc = review_req.doc
|
|
team = review_req.team
|
|
|
|
possible_emails = list(email_queryset)
|
|
possible_person_ids = [e.person_id for e in possible_emails]
|
|
|
|
aliases = DocAlias.objects.filter(document=doc).values_list("name", flat=True)
|
|
|
|
# settings
|
|
reviewer_settings = {
|
|
r.person_id: r
|
|
for r in ReviewerSettings.objects.filter(team=team, person__in=possible_person_ids)
|
|
}
|
|
|
|
for p in possible_person_ids:
|
|
if p not in reviewer_settings:
|
|
reviewer_settings[p] = ReviewerSettings(team=team, filter_re = get_default_filter_re(p))
|
|
|
|
# frequency
|
|
days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(team)
|
|
|
|
# rotation
|
|
rotation_index = { p.pk: i for i, p in enumerate(reviewer_rotation_list(team)) }
|
|
|
|
# previous review of document
|
|
has_reviewed_previous = ReviewRequest.objects.filter(
|
|
doc=doc,
|
|
reviewer__person__in=possible_person_ids,
|
|
state="completed",
|
|
team=team,
|
|
)
|
|
|
|
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("reviewer__person", flat=True))
|
|
|
|
# review wishes
|
|
wish_to_review = set(ReviewWish.objects.filter(team=team, person__in=possible_person_ids, doc=doc).values_list("person", flat=True))
|
|
|
|
# connections
|
|
connections = {}
|
|
# examine the closest connections last to let them override
|
|
connections[doc.ad_id] = "is associated Area Director"
|
|
for r in Role.objects.filter(group=doc.group_id, person__in=possible_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=possible_person_ids).values_list("person", flat=True):
|
|
connections[author] = "is author of document"
|
|
|
|
# unavailable periods
|
|
unavailable_periods = current_unavailable_periods_for_reviewers(team)
|
|
|
|
# reviewers statistics
|
|
req_data_for_reviewers = latest_review_requests_for_reviewers(team)
|
|
|
|
ranking = []
|
|
for e in possible_emails:
|
|
settings = reviewer_settings.get(e.person_id)
|
|
|
|
# we sort the reviewers by separate axes, listing the most
|
|
# important things first
|
|
scores = []
|
|
explanations = []
|
|
|
|
def add_boolean_score(direction, expr, explanation=None):
|
|
scores.append(direction if expr else -direction)
|
|
if expr and explanation:
|
|
explanations.append(explanation)
|
|
|
|
# unavailable for review periods
|
|
periods = unavailable_periods.get(e.person_id, [])
|
|
unavailable_at_the_moment = periods and not (e.person_id in has_reviewed_previous and all(p.availability == "canfinish" for p in periods))
|
|
add_boolean_score(-1, unavailable_at_the_moment)
|
|
|
|
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))
|
|
|
|
# misc
|
|
add_boolean_score(+1, e.person_id in has_reviewed_previous, "reviewed document before")
|
|
add_boolean_score(+1, e.person_id in wish_to_review, "wishes to review document")
|
|
add_boolean_score(-1, e.person_id in connections, connections.get(e.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 aliases), "filter regexp matches")
|
|
|
|
# minimum interval between reviews
|
|
days_needed = days_needed_for_reviewers.get(e.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
|
|
scores.append(-settings.skip_next)
|
|
if settings.skip_next > 0:
|
|
explanations.append("skip next {}".format(settings.skip_next))
|
|
|
|
# index
|
|
index = rotation_index.get(e.person_id, 0)
|
|
scores.append(-index)
|
|
explanations.append("#{}".format(index + 1))
|
|
|
|
# stats
|
|
stats = []
|
|
req_data = req_data_for_reviewers.get(e.person_id, [])
|
|
|
|
currently_open = sum(1 for d in req_data if d.state in ["requested", "accepted"])
|
|
if currently_open > 0:
|
|
stats.append("currently {} open".format(currently_open))
|
|
could_have_completed = [d for d in req_data if d.state in ["part-completed", "completed", "no-response"]]
|
|
if could_have_completed:
|
|
no_response = sum(1 for d in could_have_completed if d.state == "no-response")
|
|
stats.append("no response {}/{}".format(no_response, len(could_have_completed)))
|
|
|
|
if stats:
|
|
explanations.append(", ".join(stats))
|
|
|
|
label = unicode(e.person)
|
|
if explanations:
|
|
label = u"{}: {}".format(label, u"; ".join(explanations))
|
|
|
|
ranking.append({
|
|
"email": e,
|
|
"scores": scores,
|
|
"label": label,
|
|
})
|
|
|
|
ranking.sort(key=lambda r: r["scores"], reverse=True)
|
|
|
|
return [(r["email"].pk, r["label"]) for r in ranking]
|
|
|
|
def review_requests_needing_reviewer_reminder(remind_date):
|
|
reqs_qs = ReviewRequest.objects.filter(
|
|
state__in=("requested", "accepted"),
|
|
reviewer__person__reviewersettings__remind_days_before_deadline__isnull=False,
|
|
reviewer__person__reviewersettings__team=F("team"),
|
|
).exclude(
|
|
reviewer=None
|
|
).values_list("pk", "deadline", "reviewer__person__reviewersettings__remind_days_before_deadline").distinct()
|
|
|
|
req_pks = []
|
|
for r_pk, deadline, remind_days in reqs_qs:
|
|
if (deadline - remind_date).days == remind_days:
|
|
req_pks.append(r_pk)
|
|
|
|
return ReviewRequest.objects.filter(pk__in=req_pks).select_related("reviewer", "reviewer__person", "state", "team")
|
|
|
|
def email_reviewer_reminder(review_request):
|
|
team = review_request.team
|
|
|
|
deadline_days = (review_request.deadline - datetime.date.today()).days
|
|
|
|
subject = "Reminder: deadline for review of {} in {} is {}".format(review_request.doc_id, team.acronym, review_request.deadline.isoformat())
|
|
|
|
import ietf.ietfauth.views
|
|
overview_url = urlreverse(ietf.ietfauth.views.review_overview)
|
|
import ietf.doc.views_review
|
|
request_url = urlreverse(ietf.doc.views_review.review_request, kwargs={ "name": review_request.doc_id, "request_id": review_request.pk })
|
|
|
|
domain = Site.objects.get_current().domain
|
|
|
|
settings = ReviewerSettings.objects.filter(person=review_request.reviewer.person, team=team).first()
|
|
remind_days = settings.remind_days_before_deadline if settings else 0
|
|
|
|
send_mail(None, [review_request.reviewer.formatted_email()], None, subject, "review/reviewer_reminder.txt", {
|
|
"reviewer_overview_url": "https://{}{}".format(domain, overview_url),
|
|
"review_request_url": "https://{}{}".format(domain, request_url),
|
|
"review_request": review_request,
|
|
"deadline_days": deadline_days,
|
|
"remind_days": remind_days,
|
|
})
|
|
|
|
def review_requests_needing_secretary_reminder(remind_date):
|
|
reqs_qs = ReviewRequest.objects.filter(
|
|
state__in=("requested", "accepted"),
|
|
team__role__person__reviewsecretarysettings__remind_days_before_deadline__isnull=False,
|
|
team__role__person__reviewsecretarysettings__team=F("team"),
|
|
).exclude(
|
|
reviewer=None
|
|
).values_list("pk", "deadline", "team__role", "team__role__person__reviewsecretarysettings__remind_days_before_deadline").distinct()
|
|
|
|
req_pks = {}
|
|
for r_pk, deadline, secretary_role_pk, remind_days in reqs_qs:
|
|
if (deadline - remind_date).days == remind_days:
|
|
req_pks[r_pk] = secretary_role_pk
|
|
|
|
review_reqs = { r.pk: r for r in ReviewRequest.objects.filter(pk__in=req_pks.keys()).select_related("reviewer", "reviewer__person", "state", "team") }
|
|
secretary_roles = { r.pk: r for r in Role.objects.filter(pk__in=req_pks.values()).select_related("email", "person") }
|
|
|
|
return [ (review_reqs[req_pk], secretary_roles[secretary_role_pk]) for req_pk, secretary_role_pk in req_pks.iteritems() ]
|
|
|
|
def email_secretary_reminder(review_request, secretary_role):
|
|
team = review_request.team
|
|
|
|
deadline_days = (review_request.deadline - datetime.date.today()).days
|
|
|
|
subject = "Reminder: deadline for review of {} in {} is {}".format(review_request.doc_id, team.acronym, review_request.deadline.isoformat())
|
|
|
|
import ietf.group.views_review
|
|
settings_url = urlreverse(ietf.group.views_review.change_review_secretary_settings, kwargs={ "acronym": team.acronym, "group_type": team.type_id })
|
|
import ietf.doc.views_review
|
|
request_url = urlreverse(ietf.doc.views_review.review_request, kwargs={ "name": review_request.doc_id, "request_id": review_request.pk })
|
|
|
|
domain = Site.objects.get_current().domain
|
|
|
|
settings = ReviewSecretarySettings.objects.filter(person=secretary_role.person_id, team=team).first()
|
|
remind_days = settings.remind_days_before_deadline if settings else 0
|
|
|
|
send_mail(None, [review_request.reviewer.formatted_email()], None, subject, "review/secretary_reminder.txt", {
|
|
"review_request_url": "https://{}{}".format(domain, request_url),
|
|
"settings_url": "https://{}{}".format(domain, settings_url),
|
|
"review_request": review_request,
|
|
"deadline_days": deadline_days,
|
|
"remind_days": remind_days,
|
|
})
|