[\w-]+)/$', 'ietf.doc.views_material.edit_material', { 'action': "new" }, "group_new_material"),
(r'^archives/$', 'ietf.group.views.derived_archives'),
(r'^photos/$', views.group_photos),
+ (r'^reviews/$', views.review_requests),
+ (r'^reviews/manage/$', views_review.manage_review_requests),
url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'),
)
diff --git a/ietf/group/views.py b/ietf/group/views.py
index 5388819c0..292f32ff1 100644
--- a/ietf/group/views.py
+++ b/ietf/group/views.py
@@ -38,6 +38,7 @@ import re
from tempfile import mkstemp
import datetime
from collections import OrderedDict
+import math
import debug # pyflakes:ignore
@@ -67,6 +68,8 @@ from ietf.settings import MAILING_LIST_INFO_URL
from ietf.mailtrigger.utils import gather_relevant_expansions
from ietf.ietfauth.utils import has_role
from ietf.meeting.utils import group_sessions
+from ietf.review.models import ReviewRequest
+from ietf.review.utils import can_manage_review_requests_for_team, suggested_review_requests_for_team
def roles(group, role_name):
return Role.objects.filter(group=group, name=role_name).select_related("email", "person")
@@ -345,6 +348,8 @@ def construct_group_menu_context(request, group, selected, group_type, others):
entries.append(("About", urlreverse("group_about", kwargs=kwargs)))
if group.features.has_materials and get_group_materials(group).exists():
entries.append(("Materials", urlreverse("ietf.group.views.materials", kwargs=kwargs)))
+ if group.features.has_reviews:
+ entries.append(("Review requests", urlreverse(review_requests, kwargs=kwargs)))
if group.type_id in ('rg','wg','team'):
entries.append(("Meetings", urlreverse("ietf.group.views.meetings", kwargs=kwargs)))
entries.append(("History", urlreverse("ietf.group.views.history", kwargs=kwargs)))
@@ -375,6 +380,10 @@ def construct_group_menu_context(request, group, selected, group_type, others):
if group.features.has_materials and can_manage_materials(request.user, group):
actions.append((u"Upload material", urlreverse("ietf.doc.views_material.choose_material_type", kwargs=kwargs)))
+ if group.features.has_reviews:
+ import ietf.group.views_review
+ actions.append((u"Manage review requests", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=kwargs)))
+
if group.state_id != "conclude" and (is_chair or can_manage):
actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs)))
@@ -633,6 +642,60 @@ def history(request, acronym, group_type=None):
"events": events,
}))
+def review_requests(request, acronym, group_type=None):
+ group = get_group_or_404(acronym, group_type)
+ if not group.features.has_reviews:
+ raise Http404
+
+ open_review_requests = list(ReviewRequest.objects.filter(
+ team=group, state__in=("requested", "accepted")
+ ).prefetch_related("reviewer", "type", "state").order_by("time", "id"))
+
+ open_review_requests += suggested_review_requests_for_team(group)
+
+ now = datetime.datetime.now()
+ for r in open_review_requests:
+ delta = now - r.deadline
+ r.due = max(0, int(math.ceil(delta.total_seconds() / 3600.0)))
+
+ closed_review_requests = ReviewRequest.objects.filter(
+ team=group,
+ ).exclude(
+ state__in=("requested", "accepted")
+ ).prefetch_related("reviewer", "type", "state").order_by("-time", "-id")
+
+ since_choices = [
+ (None, "1 month"),
+ ("3m", "3 months"),
+ ("6m", "6 months"),
+ ("1y", "1 year"),
+ ("2y", "2 years"),
+ ("all", "All"),
+ ]
+ since = request.GET.get("since", None)
+ if since not in [key for key, label in since_choices]:
+ since = None
+
+ if since != "all":
+ date_limit = {
+ None: datetime.timedelta(days=31),
+ "3m": datetime.timedelta(days=31 * 3),
+ "6m": datetime.timedelta(days=180),
+ "1y": datetime.timedelta(days=365),
+ "2y": datetime.timedelta(days=2 * 365),
+ }[since]
+
+ closed_review_requests = closed_review_requests.filter(time__gte=datetime.date.today() - date_limit)
+
+ return render(request, 'group/review_requests.html',
+ construct_group_menu_context(request, group, "review requests", group_type, {
+ "open_review_requests": open_review_requests,
+ "closed_review_requests": closed_review_requests,
+ "since_choices": since_choices,
+ "since": since,
+ "can_manage_review_requests": can_manage_review_requests_for_team(request.user, group)
+ }))
+
def materials(request, acronym, group_type=None):
group = get_group_or_404(acronym, group_type)
if not group.features.has_materials:
diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py
new file mode 100644
index 000000000..e46c45ae2
--- /dev/null
+++ b/ietf/group/views_review.py
@@ -0,0 +1,142 @@
+from django.shortcuts import render, redirect
+from django.http import Http404, HttpResponseForbidden
+from django.contrib.auth.decorators import login_required
+from django import forms
+
+from ietf.review.models import ReviewRequest, ReviewRequestStateName
+from ietf.review.utils import (can_manage_review_requests_for_team,
+ extract_revision_ordered_review_requests_for_documents,
+ assign_review_request_to_reviewer,
+# email_about_review_request, make_new_review_request_from_existing,
+ suggested_review_requests_for_team)
+from ietf.group.utils import get_group_or_404
+from ietf.person.fields import PersonEmailChoiceField
+
+
+class ManageReviewRequestForm(forms.Form):
+ ACTIONS = [
+ ("assign", "Assign"),
+ ("close", "Close"),
+ ]
+
+ action = forms.ChoiceField(choices=ACTIONS, widget=forms.HiddenInput, required=False)
+
+ CLOSE_OPTIONS = [
+ ("noreviewversion", "No review of this version"),
+ ("noreviewdocument", "No review of document"),
+ ("withdraw", "Withdraw request"),
+ ("no-response", "No response"),
+ ("overtaken", "Overtaken by events"),
+ ]
+ close = forms.ChoiceField(choices=CLOSE_OPTIONS, required=False)
+
+ reviewer = PersonEmailChoiceField(empty_label="(None)", required=False, label_with="person")
+
+ def __init__(self, review_req, *args, **kwargs):
+ if not "prefix" in kwargs:
+ if review_req.pk is None:
+ kwargs["prefix"] = "r{}-{}".format(review_req.type_id, review_req.doc_id)
+ else:
+ kwargs["prefix"] = "r{}".format(review_req.pk)
+
+ super(ManageReviewRequestForm, self).__init__(*args, **kwargs)
+
+ close_initial = None
+ if review_req.pk is None:
+ if review_req.latest_reqs:
+ close_initial = "noreviewversion"
+ else:
+ close_initial = "noreviewdocument"
+ elif review_req.reviewer:
+ close_initial = "no-response"
+ else:
+ close_initial = "overtaken"
+
+ if close_initial:
+ self.fields["close"].initial = close_initial
+
+ self.fields["close"].widget.attrs["class"] = "form-control input-sm"
+
+ self.fields["reviewer"].queryset = self.fields["reviewer"].queryset.filter(
+ role__name="reviewer",
+ role__group=review_req.team,
+ )
+
+ self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm"
+
+ if self.is_bound:
+ action = self.data.get("action")
+ if action == "close":
+ self.fields["close"].required = True
+ elif action == "assign":
+ self.fields["reviewer"].required = True
+
+
+@login_required
+def manage_review_requests(request, acronym, group_type=None):
+ group = get_group_or_404(acronym, group_type)
+ if not group.features.has_reviews:
+ raise Http404
+
+ if not can_manage_review_requests_for_team(request.user, group):
+ return HttpResponseForbidden("You do not have permission to perform this action")
+
+ review_requests = list(ReviewRequest.objects.filter(
+ team=group, state__in=("requested", "accepted")
+ ).prefetch_related("reviewer", "type", "state").order_by("time", "id"))
+
+ review_requests += suggested_review_requests_for_team(group)
+
+ document_requests = extract_revision_ordered_review_requests_for_documents(
+ ReviewRequest.objects.filter(state__in=("part-completed", "completed")).prefetch_related("result"),
+ set(r.doc_id for r in review_requests),
+ )
+
+ for req in review_requests:
+ l = []
+ # take all on the latest reviewed rev
+ for r in document_requests[req.doc_id]:
+ if l and l[0].reviewed_rev:
+ if r.doc_id == l[0].doc_id and r.reviewed_rev:
+ if int(r.reviewed_rev) > int(l[0].reviewed_rev):
+ l = [r]
+ elif int(r.reviewed_rev) == int(l[0].reviewed_rev):
+ l.append(r)
+ else:
+ l = [r]
+
+ req.latest_reqs = l
+
+ req.form = ManageReviewRequestForm(req, request.POST if request.method == "POST" else None)
+
+ if request.method == "POST":
+ form_results = []
+ for req in review_requests:
+ form_results.append(req.form.is_valid())
+
+ if all(form_results):
+ for req in review_requests:
+ action = req.form.cleaned_data.get("action")
+ if action == "assign":
+ assign_review_request_to_reviewer(request, req, req.form.cleaned_data["reviewer"])
+ elif action == "close":
+ close_reason = req.form.cleaned_data["close"]
+ if close_reason in ("withdraw", "no-response", "overtaken"):
+ req.state = ReviewRequestStateName.objects.get(slug=close_reason, used=True)
+ req.save()
+ # FIXME: notify?
+ else:
+ FIXME
+
+ kwargs = { "acronym": group.acronym }
+ if group_type:
+ kwargs["group_type"] = group_type
+ import ietf.group.views
+ return redirect(ietf.group.views.review_requests, **kwargs)
+
+
+ return render(request, 'group/manage_review_requests.html', {
+ 'group': group,
+ 'review_requests': review_requests,
+ })
+
diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json
index 72aeb7b2f..72837bf88 100644
--- a/ietf/name/fixtures/names.json
+++ b/ietf/name/fixtures/names.json
@@ -1821,11 +1821,11 @@
"desc": ""
},
"model": "name.reviewrequeststatename",
- "pk": "noresponse"
+ "pk": "no-response"
},
{
"fields": {
- "order": 6,
+ "order": 7,
"used": true,
"name": "Partially Completed",
"desc": ""
@@ -1847,7 +1847,6 @@
"fields": {
"order": 1,
"used": true,
- "teams": [],
"name": "Serious Issues",
"desc": ""
},
@@ -1858,7 +1857,6 @@
"fields": {
"order": 2,
"used": true,
- "teams": [],
"name": "Has Issues",
"desc": ""
},
@@ -1869,7 +1867,6 @@
"fields": {
"order": 3,
"used": true,
- "teams": [],
"name": "Has Nits",
"desc": ""
},
@@ -1880,7 +1877,6 @@
"fields": {
"order": 4,
"used": true,
- "teams": [],
"name": "Not Ready",
"desc": ""
},
@@ -1891,7 +1887,6 @@
"fields": {
"order": 5,
"used": true,
- "teams": [],
"name": "On the Right Track",
"desc": ""
},
@@ -1902,7 +1897,6 @@
"fields": {
"order": 6,
"used": true,
- "teams": [],
"name": "Almost Ready",
"desc": ""
},
@@ -1913,7 +1907,6 @@
"fields": {
"order": 7,
"used": true,
- "teams": [],
"name": "Ready with Issues",
"desc": ""
},
@@ -1924,7 +1917,6 @@
"fields": {
"order": 8,
"used": true,
- "teams": [],
"name": "Ready with Nits",
"desc": ""
},
@@ -1935,7 +1927,6 @@
"fields": {
"order": 9,
"used": true,
- "teams": [],
"name": "Ready",
"desc": ""
},
@@ -1972,6 +1963,16 @@
"model": "name.reviewtypename",
"pk": "telechat"
},
+{
+ "fields": {
+ "order": 4,
+ "used": false,
+ "name": "Unknown",
+ "desc": ""
+ },
+ "model": "name.reviewtypename",
+ "pk": "unknown"
+},
{
"fields": {
"order": 0,
diff --git a/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py b/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py
index fb62deea3..59e57a76f 100644
--- a/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py
+++ b/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py
@@ -35,7 +35,6 @@ class Migration(migrations.Migration):
('desc', models.TextField(blank=True)),
('used', models.BooleanField(default=True)),
('order', models.IntegerField(default=0)),
- ('teams', models.ManyToManyField(help_text=b"Which teams this result can be set for. This also implicitly defines which teams are review teams - if there are no possible review results defined for a given team, it can't be a review team.", to='group.Group', blank=True)),
],
options={
'ordering': ['order'],
diff --git a/ietf/name/migrations/0012_insert_review_name_data.py b/ietf/name/migrations/0012_insert_review_name_data.py
index 7cc1a7d52..92da06b94 100644
--- a/ietf/name/migrations/0012_insert_review_name_data.py
+++ b/ietf/name/migrations/0012_insert_review_name_data.py
@@ -11,14 +11,15 @@ def insert_initial_review_data(apps, schema_editor):
ReviewRequestStateName.objects.get_or_create(slug="rejected", name="Rejected", order=3)
ReviewRequestStateName.objects.get_or_create(slug="withdrawn", name="Withdrawn", order=4)
ReviewRequestStateName.objects.get_or_create(slug="overtaken", name="Overtaken By Events", order=5)
- ReviewRequestStateName.objects.get_or_create(slug="noresponse", name="No Response", order=6)
- ReviewRequestStateName.objects.get_or_create(slug="part-completed", name="Partially Completed", order=6)
+ ReviewRequestStateName.objects.get_or_create(slug="no-response", name="No Response", order=6)
+ ReviewRequestStateName.objects.get_or_create(slug="part-completed", name="Partially Completed", order=7)
ReviewRequestStateName.objects.get_or_create(slug="completed", name="Completed", order=8)
ReviewTypeName = apps.get_model("name", "ReviewTypeName")
ReviewTypeName.objects.get_or_create(slug="early", name="Early", order=1)
ReviewTypeName.objects.get_or_create(slug="lc", name="Last Call", order=2)
ReviewTypeName.objects.get_or_create(slug="telechat", name="Telechat", order=3)
+ ReviewTypeName.objects.get_or_create(slug="unknown", name="Unknown", order=4, used=False)
ReviewResultName = apps.get_model("name", "ReviewResultName")
ReviewResultName.objects.get_or_create(slug="serious-issues", name="Serious Issues", order=1)
diff --git a/ietf/name/models.py b/ietf/name/models.py
index f17a55203..58b861326 100644
--- a/ietf/name/models.py
+++ b/ietf/name/models.py
@@ -96,5 +96,4 @@ class ReviewResultName(NameModel):
"""Almost ready, Has issues, Has nits, Not Ready,
On the right track, Ready, Ready with issues,
Ready with nits, Serious Issues"""
- teams = models.ManyToManyField("group.Group", help_text="Which teams this result can be set for. This also implicitly defines which teams are review teams - if there are no possible review results defined for a given team, it can't be a review team.", blank=True)
diff --git a/ietf/name/resources.py b/ietf/name/resources.py
index 521f4ee79..64626273c 100644
--- a/ietf/name/resources.py
+++ b/ietf/name/resources.py
@@ -453,7 +453,6 @@ class ReviewResultNameResource(ModelResource):
"desc": ALL,
"used": ALL,
"order": ALL,
- "teams": ALL_WITH_RELATIONS,
}
api.name.register(ReviewResultNameResource())
diff --git a/ietf/person/fields.py b/ietf/person/fields.py
index 177da9cb7..cc86d4fb4 100644
--- a/ietf/person/fields.py
+++ b/ietf/person/fields.py
@@ -139,3 +139,23 @@ class SearchableEmailField(SearchableEmailsField):
return super(SearchableEmailField, self).clean(value).first()
+class PersonEmailChoiceField(forms.ModelChoiceField):
+ """ModelChoiceField targeting Email and displaying choices with the
+ person name as well as the email address. Needs further
+ restrictions, e.g. on role, to useful."""
+ def __init__(self, *args, **kwargs):
+ if not "queryset" in kwargs:
+ kwargs["queryset"] = Email.objects.select_related("person")
+
+ self.label_with = kwargs.pop("label_with", None)
+
+ super(PersonEmailChoiceField, self).__init__(*args, **kwargs)
+
+ def label_from_instance(self, email):
+ if self.label_with == "person":
+ return unicode(email.person)
+ elif self.label_with == "email":
+ return email.address
+ else:
+ return u"{} <{}>".format(email.person, email.address)
+
diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py
new file mode 100755
index 000000000..325f6e06b
--- /dev/null
+++ b/ietf/review/import_from_review_tool.py
@@ -0,0 +1,231 @@
+#!/usr/bin/env python
+
+import sys, os
+
+# boilerplate
+basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
+sys.path = [ basedir ] + sys.path
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ietf.settings")
+
+import django
+django.setup()
+
+
+# script
+
+import datetime
+from collections import namedtuple
+from django.db import connections
+from ietf.review.models import ReviewRequest, Reviewer, ReviewResultName
+from ietf.review.models import ReviewRequestStateName, ReviewTypeName, ReviewTeamResult
+from ietf.group.models import Group, Role, RoleName
+from ietf.person.models import Person, Email, Alias
+import argparse
+from unidecode import unidecode
+from collections import defaultdict
+
+parser = argparse.ArgumentParser()
+parser.add_argument("database", help="database must be included in settings")
+parser.add_argument("team", help="team acronym, must exist")
+args = parser.parse_args()
+
+db_con = connections[args.database]
+team = Group.objects.get(acronym=args.team)
+
+def namedtuplefetchall(cursor):
+ "Return all rows from a cursor as a namedtuple"
+ desc = cursor.description
+ nt_result = namedtuple('Result', [col[0] for col in desc])
+ return (nt_result(*row) for row in cursor.fetchall())
+
+def parse_timestamp(t):
+ if not t:
+ return None
+ return datetime.datetime.fromtimestamp(t)
+
+# personnel
+with db_con.cursor() as c:
+ c.execute("select distinct reviewer from reviews;")
+ known_reviewers = { row[0] for row in c.fetchall() }
+
+with db_con.cursor() as c:
+ c.execute("select distinct who from doclog;")
+ docloggers = { row[0] for row in c.fetchall() }
+
+with db_con.cursor() as c:
+ c.execute("select distinct login from members where permissions like '%secretary%';")
+ secretaries = { row[0] for row in c.fetchall() }
+
+known_personnel = {}
+with db_con.cursor() as c:
+ c.execute("select * from members;")
+
+ needed_personnel = known_reviewers | docloggers | secretaries
+
+ for row in namedtuplefetchall(c):
+ if row.login not in needed_personnel:
+ continue
+
+ email = Email.objects.filter(address=row.email).select_related("person").first()
+ if not email:
+ person = Person.objects.filter(alias__name=row.name).first()
+ if not person:
+ person, created = Person.objects.get_or_create(name=row.name, ascii=unidecode(row.name))
+ if created:
+ print "created person", person
+ existing_aliases = set(Alias.objects.filter(person=person).values_list("name", flat=True))
+ curr_names = set(x for x in [person.name, person.ascii, person.ascii_short, person.plain_name(), ] if x)
+ new_aliases = curr_names - existing_aliases
+ for name in new_aliases:
+ Alias.objects.create(person=person, name=name)
+
+ email, created = Email.objects.get_or_create(address=row.email, person=person)
+ if created:
+ print "created email", email
+
+ known_personnel[row.login] = email
+
+ if "secretary" in row.permissions:
+ role, created = Role.objects.get_or_create(name=RoleName.objects.get(slug="secr"), person=email.person, email=email, group=team)
+ if created:
+ print "created role", role
+
+ if row.login in known_reviewers:
+ if row.comment != "Inactive" and row.available != 2145916800: # corresponds to 2038-01-01
+ assert not row.autopolicy or row.autopolicy == "monthly"
+
+ role, created = Role.objects.get_or_create(name=RoleName.objects.get(slug="reviewer"), person=email.person, email=email, group=team)
+
+ if created:
+ print "created role", role
+
+ reviewer, created = Reviewer.objects.get_or_create(
+ team=team,
+ person=email.person,
+ )
+ if reviewer:
+ print "created reviewer", reviewer
+
+ if row.autopolicy == "monthly":
+ reviewer.frequency = 30
+ reviewer.unavailable_until = parse_timestamp(row.available)
+ reviewer.filter_re = row.donotassign
+ reviewer.save()
+
+
+# review requests
+
+# check that we got the needed names
+results = { n.name.lower(): n for n in ReviewResultName.objects.all() }
+
+with db_con.cursor() as c:
+ c.execute("select distinct summary from reviews;")
+ summaries = [r[0].lower() for r in c.fetchall() if r[0]]
+ missing_result_names = set(summaries) - set(results.keys())
+ assert not missing_result_names, "missing result names: {} {}".format(missing_result_names, results.keys())
+
+ for s in summaries:
+ ReviewTeamResult.objects.get_or_create(team=team, result=results[s])
+
+states = { n.slug: n for n in ReviewRequestStateName.objects.all() }
+# map some names
+states["assigned"] = states["requested"]
+states["done"] = states["completed"]
+states["noresponse"] = states["no-response"]
+
+with db_con.cursor() as c:
+ c.execute("select distinct docstatus from reviews;")
+ docstates = [r[0] for r in c.fetchall() if r[0]]
+ missing_state_names = set(docstates) - set(states.keys())
+ assert not missing_state_names, "missing state names: {}".format(missing_state_names)
+
+type_names = { n.slug: n for n in ReviewTypeName.objects.all() }
+
+# extract relevant log entries
+
+request_assigned = defaultdict(list)
+
+with db_con.cursor() as c:
+ c.execute("select docname, time, who from doclog where text = 'AUTO UPDATED status TO working' order by time desc;")
+ for row in namedtuplefetchall(c):
+ request_assigned[row.docname].append((row.time, row.who))
+
+# extract document request metadata
+
+doc_metadata = {}
+
+with db_con.cursor() as c:
+ c.execute("select docname, version, deadline, telechat, lcend, status from documents order by docname, version;")
+
+ for row in namedtuplefetchall(c):
+ doc_metadata[(row.docname, row.version)] = doc_metadata[row.docname] = (parse_timestamp(row.deadline), parse_timestamp(row.telechat), parse_timestamp(row.lcend), row.status)
+
+
+with db_con.cursor() as c:
+ c.execute("select * from reviews order by reviewid;")
+
+ for row in namedtuplefetchall(c):
+ meta = doc_metadata.get((row.docname, row.version))
+ if not meta:
+ meta = doc_metadata.get(row.docname)
+
+ deadline, telechat, lcend, status = meta or (None, None, None, None)
+
+ if not deadline:
+ deadline = parse_timestamp(row.timeout)
+
+ type_name = type_names["unknown"]
+ # FIXME: use lcend and telechat to try to deduce type
+
+ reviewed_rev = row.version if row.version and row.version != "99" else ""
+ if row.summary == "noresponse":
+ reviewed_rev = ""
+
+ assignment_logs = request_assigned.get(row.docname, [])
+ if assignment_logs:
+ time, who = assignment_logs.pop()
+
+ time = parse_timestamp(time)
+ else:
+ time = deadline
+
+ if not deadline and row.docstatus == "assigned":
+ # bogus row
+ print "SKIPPING WITH NO DEADLINE", time, row
+ continue
+
+ if status == "done" and row.docstatus in ("assigned", "accepted"):
+ # filter out some apparently dead requests
+ print "SKIPPING MARKED DONE even if assigned/accepted", time, row
+ continue
+
+ req, _ = ReviewRequest.objects.get_or_create(
+ doc_id=row.docname,
+ team=team,
+ old_id=row.reviewid,
+ defaults={
+ "state": states["requested"],
+ "type": type_name,
+ "deadline": deadline,
+ }
+ )
+
+ req.reviewer = known_personnel[row.reviewer] if row.reviewer else None
+ req.result = results.get(row.summary.lower()) if row.summary else None
+ req.state = states.get(row.docstatus) if row.docstatus else None
+ req.type = type_name
+ req.time = time
+ req.reviewed_rev = reviewed_rev
+ req.deadline = deadline
+ req.save()
+
+ # FIXME: add log entries
+ # FIXME: add review from reviewurl
+ # adcomments IGNORED
+ # lccomments IGNORED
+ # nits IGNORED
+ # reviewurl review.external_url
+
+ #print meta and meta[0], telechat, lcend, req.type
+
+ print "imported review", row.reviewid, "as", req.pk, req.time, req.deadline, req.type, req.doc_id
diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py
index d9187804b..cb85178c2 100644
--- a/ietf/review/migrations/0001_initial.py
+++ b/ietf/review/migrations/0001_initial.py
@@ -7,10 +7,10 @@ from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
- ('group', '0008_auto_20160505_0523'),
('name', '0012_insert_review_name_data'),
+ ('group', '0008_auto_20160505_0523'),
+ ('person', '0014_auto_20160613_0751'),
('doc', '0012_auto_20160207_0537'),
- ('person', '0006_auto_20160503_0937'),
]
operations = [
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
('frequency', models.IntegerField(default=30, help_text=b'Can review every N days')),
('unavailable_until', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)),
('filter_re', models.CharField(max_length=255, blank=True)),
- ('skip_next', models.IntegerField(help_text=b'Skip the next N review assignments')),
+ ('skip_next', models.IntegerField(default=0, help_text=b'Skip the next N review assignments')),
('person', models.ForeignKey(to='person.Person')),
('team', models.ForeignKey(to='group.Group')),
],
@@ -33,6 +33,7 @@ class Migration(migrations.Migration):
name='ReviewRequest',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+ ('old_id', models.IntegerField(help_text=b'ID in previous review system', null=True, blank=True)),
('time', models.DateTimeField(auto_now_add=True)),
('deadline', models.DateTimeField()),
('requested_rev', models.CharField(help_text=b'Fill in if a specific revision is to be reviewed, e.g. 02', max_length=16, verbose_name=b'requested revision', blank=True)),
@@ -40,7 +41,7 @@ class Migration(migrations.Migration):
('doc', models.ForeignKey(related_name='review_request_set', to='doc.Document')),
('result', models.ForeignKey(blank=True, to='name.ReviewResultName', null=True)),
('review', models.OneToOneField(null=True, blank=True, to='doc.Document')),
- ('reviewer', models.ForeignKey(blank=True, to='group.Role', null=True)),
+ ('reviewer', models.ForeignKey(blank=True, to='person.Email', null=True)),
('state', models.ForeignKey(to='name.ReviewRequestStateName')),
('team', models.ForeignKey(to='group.Group')),
('type', models.ForeignKey(to='name.ReviewTypeName')),
@@ -49,4 +50,15 @@ class Migration(migrations.Migration):
},
bases=(models.Model,),
),
+ migrations.CreateModel(
+ name='ReviewTeamResult',
+ fields=[
+ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+ ('result', models.ForeignKey(to='name.ReviewResultName')),
+ ('team', models.ForeignKey(to='group.Group')),
+ ],
+ options={
+ },
+ bases=(models.Model,),
+ ),
]
diff --git a/ietf/review/models.py b/ietf/review/models.py
index 79092e8b1..589899b91 100644
--- a/ietf/review/models.py
+++ b/ietf/review/models.py
@@ -1,8 +1,8 @@
from django.db import models
from ietf.doc.models import Document
-from ietf.group.models import Group, Role
-from ietf.person.models import Person
+from ietf.group.models import Group
+from ietf.person.models import Person, Email
from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName
class Reviewer(models.Model):
@@ -11,10 +11,21 @@ class Reviewer(models.Model):
reviewer and team."""
team = models.ForeignKey(Group)
person = models.ForeignKey(Person)
- frequency = models.IntegerField(help_text="Can review every N days", default=30)
+ frequency = models.IntegerField(default=30, help_text="Can review every N days")
unavailable_until = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again")
filter_re = models.CharField(max_length=255, blank=True)
- skip_next = models.IntegerField(help_text="Skip the next N review assignments")
+ skip_next = models.IntegerField(default=0, help_text="Skip the next N review assignments")
+
+ def __unicode__(self):
+ return "{} in {}".format(self.person, self.team)
+
+class ReviewTeamResult(models.Model):
+ """Captures that a result name is valid for a given team for new
+ reviews. This also implicitly defines which teams are review
+ teams - if there are no possible review results valid for a given
+ team, it can't be a review team."""
+ team = models.ForeignKey(Group)
+ result = models.ForeignKey(ReviewResultName)
class ReviewRequest(models.Model):
"""Represents a request for a review and the process it goes through.
@@ -22,21 +33,23 @@ class ReviewRequest(models.Model):
document, rev, and reviewer."""
state = models.ForeignKey(ReviewRequestStateName)
+ old_id = models.IntegerField(blank=True, null=True, help_text="ID in previous review system") # FIXME: remove this when everything has been migrated
+
# Fields filled in on the initial record creation - these
# constitute the request part.
time = models.DateTimeField(auto_now_add=True)
type = models.ForeignKey(ReviewTypeName)
doc = models.ForeignKey(Document, related_name='review_request_set')
- team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewresultname=None))
+ team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewteamresult=None))
deadline = models.DateTimeField()
requested_rev = models.CharField(verbose_name="requested revision", max_length=16, blank=True, help_text="Fill in if a specific revision is to be reviewed, e.g. 02")
# Fields filled in as reviewer is assigned and as the review is
- # uploaded. Once these are filled in and we progress beyond the
- # states requested/assigned, any changes to the assignment happens
- # by closing down the current request and making a new one,
- # copying the request-part fields above.
- reviewer = models.ForeignKey(Role, blank=True, null=True)
+ # uploaded. Once these are filled in and we progress beyond being
+ # requested/assigned, any changes to the assignment happens by
+ # closing down the current request and making a new one, copying
+ # the request-part fields above.
+ reviewer = models.ForeignKey(Email, blank=True, null=True)
review = models.OneToOneField(Document, blank=True, null=True)
reviewed_rev = models.CharField(verbose_name="reviewed revision", max_length=16, blank=True)
diff --git a/ietf/review/resources.py b/ietf/review/resources.py
index ee2c9c5d9..02aa29d7b 100644
--- a/ietf/review/resources.py
+++ b/ietf/review/resources.py
@@ -7,7 +7,7 @@ from tastypie.cache import SimpleCache
from ietf import api
from ietf.api import ToOneField # pyflakes:ignore
-from ietf.review.models import Reviewer, ReviewRequest
+from ietf.review.models import Reviewer, ReviewRequest, ReviewTeamResult
from ietf.person.resources import PersonResource
@@ -63,3 +63,22 @@ class ReviewRequestResource(ModelResource):
}
api.review.register(ReviewRequestResource())
+
+
+from ietf.group.resources import GroupResource
+from ietf.name.resources import ReviewResultNameResource
+class ReviewTeamResultResource(ModelResource):
+ team = ToOneField(GroupResource, 'team')
+ result = ToOneField(ReviewResultNameResource, 'result')
+ class Meta:
+ queryset = ReviewTeamResult.objects.all()
+ serializer = api.Serializer()
+ cache = SimpleCache()
+ #resource_name = 'reviewteamresult'
+ filtering = {
+ "id": ALL,
+ "team": ALL_WITH_RELATIONS,
+ "result": ALL_WITH_RELATIONS,
+ }
+api.review.register(ReviewTeamResultResource())
+
diff --git a/ietf/review/utils.py b/ietf/review/utils.py
index 7da5e1b6b..911b6390b 100644
--- a/ietf/review/utils.py
+++ b/ietf/review/utils.py
@@ -1,14 +1,19 @@
+import datetime
+from collections import defaultdict
+
from django.contrib.sites.models import Site
from ietf.group.models import Group, Role
-from ietf.doc.models import DocEvent
+from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent
+from ietf.iesg.models import TelechatDate
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream
-from ietf.review.models import ReviewRequestStateName, ReviewRequest
+from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewTypeName
from ietf.utils.mail import send_mail
+from ietf.doc.utils import extract_complete_replaces_ancestor_mapping_for_docs
def active_review_teams():
- # if there's a ReviewResultName defined, it's a review team
- return Group.objects.filter(state="active").exclude(reviewresultname=None)
+ # if there's a ReviewTeamResult defined, it's a review team
+ return Group.objects.filter(state="active").exclude(reviewteamresult=None)
def can_request_review_of_doc(user, doc):
if not user.is_authenticated():
@@ -37,11 +42,11 @@ def email_about_review_request(request, review_req, subject, msg, by, notify_sec
"""Notify possibly both secretary and reviewer about change, skipping
a party if the change was done by that party."""
- def extract_email_addresses(roles):
- if any(r.person == by for r in roles if r):
+ def extract_email_addresses(objs):
+ if any(o.person == by for o in objs if o):
return []
else:
- return [r.formatted_email() for r in roles if r]
+ return [o.formatted_email() for o in objs if o]
to = []
@@ -92,3 +97,110 @@ def assign_review_request_to_reviewer(request, review_req, reviewer):
"Assigned to review of %s" % review_req.doc.name,
"%s has assigned you to review the document." % request.user.person,
by=request.user.person, notify_secretary=False, notify_reviewer=True)
+
+def suggested_review_requests_for_team(team):
+ def fixup_deadline(d):
+ if d.time() == datetime.time(0):
+ d = d - datetime.timedelta(seconds=1) # 23:59:59 is treated specially in the view code
+ return d
+
+ seen_deadlines = {}
+
+ requests = {}
+
+ if True:
+ # in Last Call
+ last_call_type = ReviewTypeName.objects.get(slug="lc")
+ last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True))
+ last_call_expires = { e.doc_id: e.expires for e in LastCallDocEvent.objects.order_by("time", "id") }
+ for doc in last_call_docs:
+ deadline = fixup_deadline(last_call_expires.get(doc.pk)) if doc.pk in last_call_expires else datetime.datetime.now()
+
+ if deadline > seen_deadlines.get(doc.pk, datetime.datetime.max):
+ continue
+
+ requests[doc.pk] = ReviewRequest(
+ time=None,
+ type=last_call_type,
+ doc=doc,
+ team=team,
+ deadline=deadline,
+ )
+
+ seen_deadlines[doc.pk] = deadline
+
+
+ if True:
+ # on Telechat Agenda
+ telechat_dates = list(TelechatDate.objects.active().order_by('date').values_list("date", flat=True)[:4])
+
+ telechat_type = ReviewTypeName.objects.get(slug="telechat")
+ telechat_deadline_delta = datetime.timedelta(days=2)
+ telechat_docs = Document.objects.filter(docevent__telechatdocevent__telechat_date__in=telechat_dates)
+ for doc in telechat_docs:
+ d = doc.telechat_date()
+ if d not in telechat_dates:
+ continue
+
+ deadline = datetime.datetime.combine(d - telechat_deadline_delta, datetime.time(23, 59, 59))
+
+ if deadline > seen_deadlines.get(doc.pk, datetime.datetime.max):
+ continue
+
+ requests[doc.pk] = ReviewRequest(
+ time=None,
+ type=telechat_type,
+ doc=doc,
+ team=team,
+ deadline=deadline,
+ )
+
+ seen_deadlines[doc.pk] = deadline
+
+ # filter those with existing requests
+ existing_requests = defaultdict(list)
+ for r in ReviewRequest.objects.filter(doc__in=requests.iterkeys()):
+ existing_requests[r.doc_id].append(r)
+
+ def blocks(existing, request):
+ return (existing.doc_id == request.doc_id
+ and existing.reviewed_rev == request.doc.rev
+ and existing.state_id not in ("part-completed", "rejected", "overtaken"))
+
+ 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))
+ return res
+
+def extract_revision_ordered_review_requests_for_documents(queryset, names):
+ names = set(names)
+
+ replaces = extract_complete_replaces_ancestor_mapping_for_docs(names)
+
+ requests_for_each_doc = defaultdict(list)
+ for r in 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, []))
+
+ while front:
+ replaces_reqs = []
+ for replaces_name in front:
+ reqs = requests_for_each_doc.get(replaces_name, [])
+ if reqs:
+ replaces_reqs.append(reqs)
+
+ # 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 = [n for l in requests_for_each_doc.get(replaces_name, []) for n in l]
+
+ return res
diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css
index 0da7cec77..c8230106c 100644
--- a/ietf/static/ietf/css/ietf.css
+++ b/ietf/static/ietf/css/ietf.css
@@ -462,6 +462,11 @@ label#list-feeds {
/* Review flow */
+.reviewer-assignment-not-accepted {
+ margin-top: 0.5em;
+ margin-bottom: 0.5em;
+}
+
form.complete-review .mail-archive-search .query-input {
width: 30em;
}
@@ -472,6 +477,16 @@ form.complete-review .mail-archive-search .results .list-group {
margin-bottom: 0.5em;
}
+.closed-review-filter {
+ margin-bottom: 1em;
+}
+
+form.review-requests .reviewer-controls, form.review-requests .close-controls {
+ display: none;
+}
+
+/* Profile */
+
.photo-name {
height: 3em;
}
diff --git a/ietf/static/ietf/js/manage-review-requests.js b/ietf/static/ietf/js/manage-review-requests.js
new file mode 100644
index 000000000..62a38438d
--- /dev/null
+++ b/ietf/static/ietf/js/manage-review-requests.js
@@ -0,0 +1,37 @@
+$(document).ready(function () {
+ var form = $("form.review-requests");
+
+ form.find(".reviewer-action").on("click", function () {
+ var row = $(this).closest("tr");
+ row.find(".close-controls .undo").click();
+ row.find("[name$=\"-action\"]").val("assign");
+ row.find(".reviewer-action").hide();
+ row.find(".reviewer-controls").show();
+ });
+
+ form.find(".reviewer-controls .undo").on("click", function () {
+ var row = $(this).closest("tr");
+ row.find(".reviewer-controls").hide();
+ row.find(".reviewer-action").show();
+ row.find("[name$=\"-action\"]").val("");
+ });
+
+ form.find(".close-action").on("click", function () {
+ var row = $(this).closest("tr");
+ row.find(".reviewer-controls .undo").click();
+ row.find("[name$=\"-action\"]").val("close");
+ row.find(".close-action").hide();
+ row.find(".close-controls").show();
+ });
+
+ form.find(".close-controls .undo").on("click", function () {
+ var row = $(this).closest("tr");
+ row.find("[name$=\"-action\"]").val("");
+ row.find(".close-controls").hide();
+ row.find(".close-action").show();
+ });
+
+ form.find("[name$=\"-action\"]").each(function () {
+ console.log(this);
+ });
+});
diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html
index 3a0cd68aa..8bb31a851 100644
--- a/ietf/templates/doc/document_draft.html
+++ b/ietf/templates/doc/document_draft.html
@@ -201,9 +201,9 @@
{% for r in review_requests %}
{% endfor %}
diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html
index 5e144904f..3c9826b37 100644
--- a/ietf/templates/doc/review/review_request.html
+++ b/ietf/templates/doc/review/review_request.html
@@ -102,8 +102,10 @@
{% if review_req.review %}
{{ review_req.review.name }}
- {% else %}
+ {% elif review_req.state_id == "requested" or review_req.state_id == "accepted" %}
Not completed yet
+ {% else %}
+ Not available
{% endif %}
{% if can_complete_review %}
diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html
new file mode 100644
index 000000000..9e13bb04f
--- /dev/null
+++ b/ietf/templates/group/manage_review_requests.html
@@ -0,0 +1,109 @@
+{% extends "base.html" %}
+{# Copyright The IETF Trust 2015, All Rights Reserved #}
+{% load origin %}{% origin %}
+
+{% load ietf_filters staticfiles bootstrap3 %}
+
+{% block title %}Manage pending review requests for {{ group.acronym }}{% endblock %}
+
+{% block pagehead %}
+
+{% endblock %}
+
+{% block content %}
+ {% origin %}
+
+ Manage open review requests for {{ group.acronym }}
+
+ For reference: closed review requests
+
+ {% if review_requests %}
+
+ {% else %}
+ There are currently no open requests.
+ {% endif %}
+{% endblock %}
+
+{% block js %}
+
+
+{% endblock %}
diff --git a/ietf/templates/group/review_requests.html b/ietf/templates/group/review_requests.html
new file mode 100644
index 000000000..38a75e20d
--- /dev/null
+++ b/ietf/templates/group/review_requests.html
@@ -0,0 +1,121 @@
+{% extends "group/group_base.html" %}
+{# Copyright The IETF Trust 2015, All Rights Reserved #}
+{% load origin %}{% origin %}
+
+{% load ietf_filters staticfiles bootstrap3 %}
+
+{% block group_subtitle %}Reviews for {{ group.name }}{% endblock %}
+
+{% block pagehead %}
+
+{% endblock %}
+
+{% block group_content %}
+ {% origin %}
+
+ Open review requests
+
+ {% if open_review_requests %}
+
+
+
+ Request |
+ Type |
+ Requested |
+ Deadline |
+ Reviewer |
+
+
+
+ {% for r in open_review_requests %}
+
+ {{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %} |
+ {{ r.type.name }} |
+ {% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} |
+
+ {% if r.deadline|date:"H:i" != "23:59" %}
+ {{ r.deadline|date:"Y-m-d H:i" }}
+ {% else %}
+ {{ r.deadline|date:"Y-m-d" }}
+ {% endif %}
+ {% if r.due %}{{ r.due }} hour{{ r.due|pluralize }}{% endif %}
+ |
+
+ {% if r.reviewer %}
+ {{ r.reviewer.person }} {% if r.state_id == "accepted" %}Accepted{% endif %}
+ {% elif r.pk != None %}
+ not yet assigned
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+ There are currently no open requests.
+ {% endif %}
+
+ Closed review requests
+
+
+
+ {% if closed_review_requests %}
+
+
+
+ Request |
+ Type |
+ Requested |
+ Deadline |
+ Reviewer |
+ State |
+ Result |
+
+
+
+ {% for r in closed_review_requests %}
+
+ {{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %} |
+ {{ r.type }} |
+ {{ r.time|date:"Y-m-d" }} |
+
+ {% if r.deadline|date:"H:i" != "23:59" %}
+ {{ r.deadline|date:"Y-m-d H:i" }}
+ {% else %}
+ {{ r.deadline|date:"Y-m-d" }}
+ {% endif %}
+ |
+
+ {% if r.reviewer %}
+ {{ r.reviewer.person }}
+ {% else %}
+ not yet assigned
+ {% endif %}
+ |
+ {{ r.state.name }} |
+
+ {% if r.result %}
+ {{ r.result.name }}
+ {% endif %} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+ No closed requests found.
+ {% endif %}
+
+{% endblock %}
+
+{% block js %}
+
+{% endblock %}
diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py
index 638294058..d19be0f93 100644
--- a/ietf/utils/test_data.py
+++ b/ietf/utils/test_data.py
@@ -13,6 +13,7 @@ from ietf.ipr.models import HolderIprDisclosure, IprDocRel, IprDisclosureStateNa
from ietf.meeting.models import Meeting
from ietf.name.models import StreamName, DocRelationshipName
from ietf.person.models import Person, Email
+from ietf.review.models import ReviewRequest, Reviewer, ReviewResultName, ReviewTeamResult
def create_person(group, role_name, name=None, username=None, email_address=None, password=None):
"""Add person/user/email and role."""
@@ -357,3 +358,31 @@ def make_test_data():
#other_doc_factory('recording','recording-42-mars-1-00')
return draft
+
+def make_review_data(doc):
+ team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team")
+ for r in ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"]):
+ ReviewTeamResult.objects.create(team=team, result=r)
+
+ p = Person.objects.get(user__username="plain")
+ email = p.email_set.first()
+ Role.objects.create(name_id="reviewer", person=p, email=email, group=team)
+ Reviewer.objects.create(team=team, person=p, frequency=14, skip_next=0)
+
+ review_req = ReviewRequest.objects.create(
+ doc=doc,
+ team=team,
+ type_id="early",
+ deadline=datetime.datetime.now() + datetime.timedelta(days=20),
+ state_id="accepted",
+ reviewer=email,
+ )
+
+ p = Person.objects.get(user__username="marschairman")
+ Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team)
+
+ p = Person.objects.get(user__username="secretary")
+ Role.objects.create(name_id="secretary", person=p, email=p.email_set.first(), group=team)
+
+ return review_req
+
|