Merged in ^/branch/iola/review-tracker-r12128@12397, bringing in the review tool functionality described in RFC7735. This adds the ability to set up review management pages for review teams such as genart, secdir, opsdir, etc.; letting the review team secretaries manage requested and completed reviews; letting the reviewers keep track of and document their reviews, and more. See the RFC for full specification, and the branch commit log for a full commit history.

- Legacy-Id: 12419
This commit is contained in:
Henrik Levkowetz 2016-11-29 14:54:19 +00:00
commit b914f46313
110 changed files with 9117 additions and 815 deletions

View file

@ -96,7 +96,6 @@ for _app in settings.INSTALLED_APPS:
_root, _name = _app.split('.', 1)
if _root == 'ietf':
if not '.' in _name:
_api = Api(api_name=_name)
_module_dict[_name] = _api
_api_list.append((_name, _api))

35
ietf/bin/send-review-reminders Executable file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env python
import os, sys
import syslog
# boilerplate
basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
sys.path = [ basedir ] + sys.path
os.environ["DJANGO_SETTINGS_MODULE"] = "ietf.settings"
virtualenv_activation = os.path.join(basedir, "bin", "activate_this.py")
if os.path.exists(virtualenv_activation):
execfile(virtualenv_activation, dict(__file__=virtualenv_activation))
syslog.openlog(os.path.basename(__file__), syslog.LOG_PID, syslog.LOG_USER)
import django
django.setup()
import datetime
from ietf.review.utils import (
review_requests_needing_reviewer_reminder, email_reviewer_reminder,
review_requests_needing_secretary_reminder, email_secretary_reminder,
)
today = datetime.date.today()
for review_req in review_requests_needing_reviewer_reminder(today):
email_reviewer_reminder(review_req)
print("Emailed reminder to {} for review of {} in {} (req. id {})".format(review_req.reviewer.address, review_req.doc_id, review_req.team.acronym, review_req.pk))
for review_req, secretary_role in review_requests_needing_secretary_reminder(today):
email_secretary_reminder(review_req, secretary_role)
print("Emailed reminder to {} for review of {} in {} (req. id {})".format(review_req.secretary_role.email.address, review_req.doc_id, review_req.team.acronym, review_req.pk))

View file

@ -98,6 +98,9 @@ def notify_events(sender, instance, **kwargs):
if instance.doc.type_id != 'draft':
return
if getattr(instance, "skip_community_list_notification", False):
return
from ietf.community.utils import notify_event_to_subscribers
notify_event_to_subscribers(instance)

View file

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('name', '0015_insert_review_name_data'),
('review', '0001_initial'),
('doc', '0015_auto_20161101_2313'),
]
operations = [
migrations.CreateModel(
name='ReviewRequestDocEvent',
fields=[
('docevent_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='doc.DocEvent')),
('review_request', models.ForeignKey(to='review.ReviewRequest')),
('state', models.ForeignKey(blank=True, to='name.ReviewRequestStateName', null=True)),
],
options={
},
bases=('doc.docevent',),
),
migrations.AlterField(
model_name='docevent',
name='type',
field=models.CharField(max_length=50, choices=[(b'new_revision', b'Added new revision'), (b'changed_document', b'Changed document metadata'), (b'added_comment', b'Added comment'), (b'added_message', b'Added message'), (b'deleted', b'Deleted document'), (b'changed_state', b'Changed state'), (b'changed_stream', b'Changed document stream'), (b'expired_document', b'Expired document'), (b'extended_expiry', b'Extended expiry of document'), (b'requested_resurrect', b'Requested resurrect'), (b'completed_resurrect', b'Completed resurrect'), (b'changed_consensus', b'Changed consensus'), (b'published_rfc', b'Published RFC'), (b'added_suggested_replaces', b'Added suggested replacement relationships'), (b'reviewed_suggested_replaces', b'Reviewed suggested replacement relationships'), (b'changed_group', b'Changed group'), (b'changed_protocol_writeup', b'Changed protocol writeup'), (b'changed_charter_milestone', b'Changed charter milestone'), (b'initial_review', b'Set initial review time'), (b'changed_review_announcement', b'Changed WG Review text'), (b'changed_action_announcement', b'Changed WG Action text'), (b'started_iesg_process', b'Started IESG process on document'), (b'created_ballot', b'Created ballot'), (b'closed_ballot', b'Closed ballot'), (b'sent_ballot_announcement', b'Sent ballot announcement'), (b'changed_ballot_position', b'Changed ballot position'), (b'changed_ballot_approval_text', b'Changed ballot approval text'), (b'changed_ballot_writeup_text', b'Changed ballot writeup text'), (b'changed_rfc_editor_note_text', b'Changed RFC Editor Note text'), (b'changed_last_call_text', b'Changed last call text'), (b'requested_last_call', b'Requested last call'), (b'sent_last_call', b'Sent last call'), (b'scheduled_for_telechat', b'Scheduled for telechat'), (b'iesg_approved', b'IESG approved document (no problem)'), (b'iesg_disapproved', b'IESG disapproved document (do not publish)'), (b'approved_in_minute', b'Approved in minute'), (b'iana_review', b'IANA review comment'), (b'rfc_in_iana_registry', b'RFC is in IANA registry'), (b'rfc_editor_received_announcement', b'Announcement was received by RFC Editor'), (b'requested_publication', b'Publication at RFC Editor requested'), (b'sync_from_rfc_editor', b'Received updated information from RFC Editor'), (b'requested_review', b'Requested review'), (b'assigned_review_request', b'Assigned review request'), (b'closed_review_request', b'Closed review request')]),
preserve_default=True,
),
]

View file

@ -14,7 +14,7 @@ import debug # pyflakes:ignore
from ietf.group.models import Group
from ietf.name.models import ( DocTypeName, DocTagName, StreamName, IntendedStdLevelName, StdLevelName,
DocRelationshipName, DocReminderTypeName, BallotPositionName )
DocRelationshipName, DocReminderTypeName, BallotPositionName, ReviewRequestStateName )
from ietf.person.models import Email, Person
from ietf.utils.admin import admin_link
@ -724,6 +724,11 @@ EVENT_TYPES = [
("rfc_editor_received_announcement", "Announcement was received by RFC Editor"),
("requested_publication", "Publication at RFC Editor requested"),
("sync_from_rfc_editor", "Received updated information from RFC Editor"),
# review
("requested_review", "Requested review"),
("assigned_review_request", "Assigned review request"),
("closed_review_request", "Closed review request"),
]
class DocEvent(models.Model):
@ -854,11 +859,14 @@ class TelechatDocEvent(DocEvent):
telechat_date = models.DateField(blank=True, null=True)
returning_item = models.BooleanField(default=False)
class ReviewRequestDocEvent(DocEvent):
review_request = models.ForeignKey('review.ReviewRequest')
state = models.ForeignKey(ReviewRequestStateName, blank=True, null=True)
# charter events
class InitialReviewDocEvent(DocEvent):
expires = models.DateTimeField(blank=True, null=True)
class AddedMessageEvent(DocEvent):
import ietf.message.models
message = models.ForeignKey(ietf.message.models.Message, null=True, blank=True,related_name='doc_manualevents')

View file

@ -11,8 +11,8 @@ from ietf.doc.models import (BallotType, DeletedEvent, StateType, State, Documen
DocumentAuthor, DocEvent, StateDocEvent, DocHistory, ConsensusDocEvent, DocAlias,
TelechatDocEvent, DocReminder, LastCallDocEvent, NewRevisionDocEvent, WriteupDocEvent,
InitialReviewDocEvent, DocHistoryAuthor, BallotDocEvent, RelatedDocument,
RelatedDocHistory, BallotPositionDocEvent, AddedMessageEvent, SubmissionDocEvent)
RelatedDocHistory, BallotPositionDocEvent, AddedMessageEvent, SubmissionDocEvent,
ReviewRequestDocEvent)
from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource
class BallotTypeResource(ModelResource):
@ -513,8 +513,6 @@ class BallotPositionDocEventResource(ModelResource):
}
api.doc.register(BallotPositionDocEventResource())
from ietf.person.resources import PersonResource
from ietf.message.resources import MessageResource
class AddedMessageEventResource(ModelResource):
@ -542,8 +540,6 @@ class AddedMessageEventResource(ModelResource):
}
api.doc.register(AddedMessageEventResource())
from ietf.person.resources import PersonResource
from ietf.submit.resources import SubmissionResource
class SubmissionDocEventResource(ModelResource):
@ -569,3 +565,29 @@ class SubmissionDocEventResource(ModelResource):
}
api.doc.register(SubmissionDocEventResource())
from ietf.person.resources import PersonResource
from ietf.name.resources import ReviewRequestStateNameResource
class ReviewRequestDocEventResource(ModelResource):
by = ToOneField(PersonResource, 'by')
doc = ToOneField(DocumentResource, 'doc')
docevent_ptr = ToOneField(DocEventResource, 'docevent_ptr')
review_request = ToOneField('ietf.review.resources.ReviewRequestResource', 'review_request')
state = ToOneField(ReviewRequestStateNameResource, 'state', null=True)
class Meta:
queryset = ReviewRequestDocEvent.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'reviewrequestdocevent'
filtering = {
"id": ALL,
"time": ALL,
"type": ALL,
"desc": ALL,
"by": ALL_WITH_RELATIONS,
"doc": ALL_WITH_RELATIONS,
"docevent_ptr": ALL_WITH_RELATIONS,
"review_request": ALL_WITH_RELATIONS,
"state": ALL_WITH_RELATIONS,
}
api.doc.register(ReviewRequestDocEventResource())

719
ietf/doc/tests_review.py Normal file
View file

@ -0,0 +1,719 @@
# -*- coding: utf-8 -*-
import datetime, os, shutil, json
import tarfile, tempfile, mailbox
import email.mime.multipart, email.mime.text, email.utils
from StringIO import StringIO
from django.core.urlresolvers import reverse as urlreverse
from django.conf import settings
from pyquery import PyQuery
import debug # pyflakes:ignore
from ietf.review.models import (ReviewRequest, ResultUsedInReviewTeam, ReviewerSettings,
ReviewWish, UnavailablePeriod, NextReviewerInTeam)
from ietf.review.utils import reviewer_rotation_list, possibly_advance_next_reviewer_for_team
import ietf.review.mailarch
from ietf.person.models import Email, Person
from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName, DocRelationshipName
from ietf.group.models import Group
from ietf.doc.models import DocumentAuthor, Document, DocAlias, RelatedDocument, DocEvent, ReviewRequestDocEvent
from ietf.utils.test_utils import TestCase
from ietf.utils.test_data import make_test_data, make_review_data, create_person
from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects
from ietf.utils.mail import outbox, empty_outbox
class ReviewTests(TestCase):
def setUp(self):
self.review_dir = os.path.abspath("tmp-review-dir")
if not os.path.exists(self.review_dir):
os.mkdir(self.review_dir)
self.old_document_path_pattern = settings.DOCUMENT_PATH_PATTERN
settings.DOCUMENT_PATH_PATTERN = self.review_dir + "/{doc.type_id}/"
self.review_subdir = os.path.join(self.review_dir, "review")
if not os.path.exists(self.review_subdir):
os.mkdir(self.review_subdir)
def tearDown(self):
shutil.rmtree(self.review_dir)
settings.DOCUMENT_PATH_PATTERN = self.old_document_path_pattern
def test_request_review(self):
doc = make_test_data()
review_req = make_review_data(doc)
review_team = review_req.team
url = urlreverse('ietf.doc.views_review.request_review', kwargs={ "name": doc.name })
login_testing_unauthorized(self, "reviewsecretary", url)
# get
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
deadline = datetime.date.today() + datetime.timedelta(days=10)
# post request
r = self.client.post(url, {
"type": "early",
"team": review_team.pk,
"deadline": deadline.isoformat(),
"requested_rev": "01",
"requested_by": Person.objects.get(user__username="reviewsecretary").pk,
})
self.assertEqual(r.status_code, 302)
req = ReviewRequest.objects.get(doc=doc, state="requested")
self.assertEqual(req.deadline, deadline)
self.assertEqual(req.team, review_team)
self.assertEqual(req.requested_rev, "01")
self.assertEqual(doc.latest_event().type, "requested_review")
def test_doc_page(self):
doc = make_test_data()
review_req = make_review_data(doc)
# move the review request to a doubly-replaced document to
# check we can fish it out
old_doc = Document.objects.get(name="draft-foo-mars-test")
older_doc = Document.objects.create(name="draft-older")
older_docalias = DocAlias.objects.create(name=older_doc.name, document=older_doc)
RelatedDocument.objects.create(source=old_doc, target=older_docalias, relationship=DocRelationshipName.objects.get(slug='replaces'))
review_req.doc = older_doc
review_req.save()
url = urlreverse('doc_view', kwargs={ "name": doc.name })
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
content = unicontent(r)
self.assertTrue("{} Review".format(review_req.type.name) in content)
def test_review_request(self):
doc = make_test_data()
review_req = make_review_data(doc)
url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertTrue(review_req.team.acronym.upper() in unicontent(r))
def test_close_request(self):
doc = make_test_data()
review_req = make_review_data(doc)
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
review_req.save()
close_url = urlreverse('ietf.doc.views_review.close_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
# follow link
req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
self.client.login(username="reviewsecretary", password="reviewsecretary+password")
r = self.client.get(req_url)
self.assertEqual(r.status_code, 200)
self.assertTrue(close_url in unicontent(r))
self.client.logout()
# get close page
login_testing_unauthorized(self, "reviewsecretary", close_url)
r = self.client.get(close_url)
self.assertEqual(r.status_code, 200)
# close
empty_outbox()
r = self.client.post(close_url, { "close_reason": "withdrawn" })
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "withdrawn")
e = doc.latest_event()
self.assertEqual(e.type, "closed_review_request")
self.assertTrue("closed" in e.desc.lower())
self.assertEqual(len(outbox), 1)
self.assertTrue("closed" in outbox[0].get_payload(decode=True).decode("utf-8").lower())
def test_possibly_advance_next_reviewer_for_team(self):
doc = make_test_data()
team = Group.objects.create(state_id="active", acronym="rotationteam", name="Review Team", type_id="dir",
list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut"))
# make a bunch of reviewers
reviewers = [
create_person(team, "reviewer", name="Test Reviewer{}".format(i), username="testreviewer{}".format(i))
for i in range(5)
]
self.assertEqual(reviewers, reviewer_rotation_list(team))
def get_skip_next(person):
settings = (ReviewerSettings.objects.filter(team=team, person=person).first()
or ReviewerSettings(team=team))
return settings.skip_next
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[0].pk)
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[1])
self.assertEqual(get_skip_next(reviewers[0]), 0)
self.assertEqual(get_skip_next(reviewers[1]), 0)
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[1].pk)
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
# skip reviewer 2
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[3].pk)
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
self.assertEqual(get_skip_next(reviewers[0]), 0)
self.assertEqual(get_skip_next(reviewers[1]), 0)
self.assertEqual(get_skip_next(reviewers[2]), 0)
self.assertEqual(get_skip_next(reviewers[3]), 1)
# pick reviewer 2, use up reviewer 3's skip_next
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[2].pk)
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[4])
self.assertEqual(get_skip_next(reviewers[0]), 0)
self.assertEqual(get_skip_next(reviewers[1]), 0)
self.assertEqual(get_skip_next(reviewers[2]), 0)
self.assertEqual(get_skip_next(reviewers[3]), 0)
self.assertEqual(get_skip_next(reviewers[4]), 0)
# check wrap-around
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[4].pk)
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[0])
self.assertEqual(get_skip_next(reviewers[0]), 0)
self.assertEqual(get_skip_next(reviewers[1]), 0)
self.assertEqual(get_skip_next(reviewers[2]), 0)
self.assertEqual(get_skip_next(reviewers[3]), 0)
self.assertEqual(get_skip_next(reviewers[4]), 0)
# unavailable
today = datetime.date.today()
UnavailablePeriod.objects.create(team=team, person=reviewers[1], start_date=today, end_date=today, availability="unavailable")
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[0].pk)
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
self.assertEqual(get_skip_next(reviewers[0]), 0)
self.assertEqual(get_skip_next(reviewers[1]), 0)
self.assertEqual(get_skip_next(reviewers[2]), 0)
self.assertEqual(get_skip_next(reviewers[3]), 0)
self.assertEqual(get_skip_next(reviewers[4]), 0)
# pick unavailable anyway
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[1].pk)
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
self.assertEqual(get_skip_next(reviewers[0]), 0)
self.assertEqual(get_skip_next(reviewers[1]), 1)
self.assertEqual(get_skip_next(reviewers[2]), 0)
self.assertEqual(get_skip_next(reviewers[3]), 0)
self.assertEqual(get_skip_next(reviewers[4]), 0)
# not through min_interval so advance past reviewer[2]
settings, _ = ReviewerSettings.objects.get_or_create(team=team, person=reviewers[2])
settings.min_interval = 30
settings.save()
ReviewRequest.objects.create(team=team, doc=doc, type_id="early", state_id="accepted", deadline=today, requested_by=reviewers[0], reviewer=reviewers[2].email_set.first())
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=reviewers[3].pk)
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[4])
self.assertEqual(get_skip_next(reviewers[0]), 0)
self.assertEqual(get_skip_next(reviewers[1]), 1)
self.assertEqual(get_skip_next(reviewers[2]), 0)
self.assertEqual(get_skip_next(reviewers[3]), 0)
self.assertEqual(get_skip_next(reviewers[4]), 0)
def test_assign_reviewer(self):
doc = make_test_data()
# review to assign to
review_req = make_review_data(doc)
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
review_req.reviewer = None
review_req.save()
# set up some reviewer-suitability factors
reviewer_email = Email.objects.get(person__user__username="reviewer")
DocumentAuthor.objects.create(
author=reviewer_email,
document=doc,
)
doc.rev = "10"
doc.save_with_history([DocEvent.objects.create(doc=doc, type="changed_document", by=Person.objects.get(user__username="secretary"), desc="Test")])
# previous review
ReviewRequest.objects.create(
time=datetime.datetime.now() - datetime.timedelta(days=100),
requested_by=Person.objects.get(name="(System)"),
doc=doc,
type=ReviewTypeName.objects.get(slug="early"),
team=review_req.team,
state=ReviewRequestStateName.objects.get(slug="completed"),
reviewed_rev="01",
deadline=datetime.date.today() - datetime.timedelta(days=80),
reviewer=reviewer_email,
)
reviewer_settings = ReviewerSettings.objects.get(person__email=reviewer_email, team=review_req.team)
reviewer_settings.filter_re = doc.name
reviewer_settings.skip_next = 1
reviewer_settings.save()
UnavailablePeriod.objects.create(
team=review_req.team,
person=reviewer_email.person,
start_date=datetime.date.today() - datetime.timedelta(days=10),
availability="unavailable",
)
ReviewWish.objects.create(person=reviewer_email.person, team=review_req.team, doc=doc)
# pick a non-existing reviewer as next to see that we can
# handle reviewers who have left
NextReviewerInTeam.objects.filter(team=review_req.team).delete()
NextReviewerInTeam.objects.create(
team=review_req.team,
next_reviewer=Person.objects.exclude(pk=reviewer_email.person_id).first(),
)
assign_url = urlreverse('ietf.doc.views_review.assign_reviewer', kwargs={ "name": doc.name, "request_id": review_req.pk })
# follow link
req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
self.client.login(username="reviewsecretary", password="reviewsecretary+password")
r = self.client.get(req_url)
self.assertEqual(r.status_code, 200)
self.assertTrue(assign_url in unicontent(r))
self.client.logout()
# get assign page
login_testing_unauthorized(self, "reviewsecretary", assign_url)
r = self.client.get(assign_url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
reviewer_label = q("option[value=\"{}\"]".format(reviewer_email.address)).text().lower()
self.assertIn("reviewed document before", reviewer_label)
self.assertIn("wishes to review", reviewer_label)
self.assertIn("is author", reviewer_label)
self.assertIn("regexp matches", reviewer_label)
self.assertIn("unavailable indefinitely", reviewer_label)
self.assertIn("skip next 1", reviewer_label)
self.assertIn("#1", reviewer_label)
self.assertIn("no response 0/1", reviewer_label)
# assign
empty_outbox()
rotation_list = reviewer_rotation_list(review_req.team)
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team, person=rotation_list[0]).first()
r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk })
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "requested")
self.assertEqual(review_req.reviewer, reviewer)
self.assertEqual(len(outbox), 1)
self.assertTrue("assigned" in outbox[0].get_payload(decode=True).decode("utf-8"))
self.assertEqual(NextReviewerInTeam.objects.get(team=review_req.team).next_reviewer, rotation_list[1])
# re-assign
empty_outbox()
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
review_req.save()
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team, person=rotation_list[1]).first()
r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk })
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "requested") # check that state is reset
self.assertEqual(review_req.reviewer, reviewer)
self.assertEqual(len(outbox), 2)
self.assertTrue("cancelled your assignment" in outbox[0].get_payload(decode=True).decode("utf-8"))
self.assertTrue("assigned" in outbox[1].get_payload(decode=True).decode("utf-8"))
def test_accept_reviewer_assignment(self):
doc = make_test_data()
review_req = make_review_data(doc)
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
review_req.save()
url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
username = review_req.reviewer.person.user.username
self.client.login(username=username, password=username + "+password")
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertTrue(q("[name=action][value=accept]"))
# accept
r = self.client.post(url, { "action": "accept" })
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "accepted")
def test_reject_reviewer_assignment(self):
doc = make_test_data()
review_req = make_review_data(doc)
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
review_req.save()
reject_url = urlreverse('ietf.doc.views_review.reject_reviewer_assignment', kwargs={ "name": doc.name, "request_id": review_req.pk })
# follow link
req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
self.client.login(username="reviewsecretary", password="reviewsecretary+password")
r = self.client.get(req_url)
self.assertEqual(r.status_code, 200)
self.assertTrue(reject_url in unicontent(r))
self.client.logout()
# get reject page
login_testing_unauthorized(self, "reviewsecretary", reject_url)
r = self.client.get(reject_url)
self.assertEqual(r.status_code, 200)
self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r))
# reject
empty_outbox()
r = self.client.post(reject_url, { "action": "reject", "message_to_secretary": "Test message" })
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "rejected")
e = doc.latest_event()
self.assertEqual(e.type, "closed_review_request")
self.assertTrue("rejected" in e.desc)
self.assertEqual(ReviewRequest.objects.filter(doc=review_req.doc, team=review_req.team, state="requested").count(), 1)
self.assertEqual(len(outbox), 1)
self.assertTrue("Test message" in outbox[0].get_payload(decode=True).decode("utf-8"))
def make_test_mbox_tarball(self, review_req):
mbox_path = os.path.join(self.review_dir, "testmbox.tar.gz")
with tarfile.open(mbox_path, "w:gz") as tar:
with tempfile.NamedTemporaryFile(dir=self.review_dir, suffix=".mbox") as tmp:
mbox = mailbox.mbox(tmp.name)
# plain text
msg = email.mime.text.MIMEText("Hello,\n\nI have reviewed the document and did not find any problems.\n\nJohn Doe")
msg["From"] = "John Doe <johndoe@example.com>"
msg["To"] = review_req.team.list_email
msg["Subject"] = "Review of {}-01".format(review_req.doc.name)
msg["Message-ID"] = email.utils.make_msgid()
msg["Archived-At"] = "<https://www.example.com/testmessage>"
msg["Date"] = email.utils.formatdate()
mbox.add(msg)
# plain text + HTML
msg = email.mime.multipart.MIMEMultipart('alternative')
msg["From"] = "John Doe II <johndoe2@example.com>"
msg["To"] = review_req.team.list_email
msg["Subject"] = "Review of {}".format(review_req.doc.name)
msg["Message-ID"] = email.utils.make_msgid()
msg["Archived-At"] = "<https://www.example.com/testmessage2>"
msg.attach(email.mime.text.MIMEText("Hi!,\r\nLooks OK!\r\n-John", "plain"))
msg.attach(email.mime.text.MIMEText("<html><body><p>Hi!,</p><p>Looks OK!</p><p>-John</p></body></html>", "html"))
mbox.add(msg)
tmp.flush()
tar.add(os.path.relpath(tmp.name))
return mbox_path
def test_search_mail_archive(self):
doc = make_test_data()
review_req = make_review_data(doc)
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
review_req.save()
review_req.team.save()
# test URL construction
query_urls = ietf.review.mailarch.construct_query_urls(review_req)
self.assertTrue(review_req.doc.name in query_urls["query_data_url"])
# test parsing
mbox_path = self.make_test_mbox_tarball(review_req)
try:
# mock URL generator and point it to local file - for this
# to work, the module (and not the function) must be
# imported in the view
real_fn = ietf.review.mailarch.construct_query_urls
ietf.review.mailarch.construct_query_urls = lambda review_req, query=None: { "query_data_url": "file://" + os.path.abspath(mbox_path) }
url = urlreverse('ietf.doc.views_review.search_mail_archive', kwargs={ "name": doc.name, "request_id": review_req.pk })
login_testing_unauthorized(self, "reviewsecretary", url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
messages = json.loads(r.content)["messages"]
self.assertEqual(len(messages), 2)
today = datetime.date.today()
self.assertEqual(messages[0]["url"], "https://www.example.com/testmessage")
self.assertTrue("John Doe" in messages[0]["content"])
self.assertEqual(messages[0]["subject"], "Review of {}-01".format(review_req.doc.name))
self.assertEqual(messages[0]["splitfrom"], ["John Doe", "johndoe@example.com"])
self.assertEqual(messages[0]["utcdate"][0], today.isoformat())
self.assertEqual(messages[1]["url"], "https://www.example.com/testmessage2")
self.assertTrue("Looks OK" in messages[1]["content"])
self.assertTrue("<html>" not in messages[1]["content"])
self.assertEqual(messages[1]["subject"], "Review of {}".format(review_req.doc.name))
self.assertEqual(messages[1]["splitfrom"], ["John Doe II", "johndoe2@example.com"])
self.assertEqual(messages[1]["utcdate"][0], "")
finally:
ietf.review.mailarch.construct_query_urls = real_fn
def setup_complete_review_test(self):
doc = make_test_data()
review_req = make_review_data(doc)
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
review_req.save()
for r in ReviewResultName.objects.filter(slug__in=("issues", "ready")):
ResultUsedInReviewTeam.objects.get_or_create(team=review_req.team, result=r)
review_req.team.save()
url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": doc.name, "request_id": review_req.pk })
return review_req, url
def test_complete_review_upload_content(self):
review_req, url = self.setup_complete_review_test()
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
# get
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
# faulty post
r = self.client.post(url, data={
"result": "ready",
"state": "completed",
"reviewed_rev": "abc",
"review_submission": "upload",
"review_content": "",
"review_url": "",
"review_file": "",
})
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertTrue(q("[name=reviewed_rev]").closest(".form-group").filter(".has-error"))
self.assertTrue(q("[name=review_file]").closest(".form-group").filter(".has-error"))
# complete by uploading file
empty_outbox()
test_file = StringIO("This is a review\nwith two lines")
test_file.name = "unnamed"
r = self.client.post(url, data={
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
"reviewed_rev": review_req.doc.rev,
"review_submission": "upload",
"review_content": "",
"review_url": "",
"review_file": test_file,
})
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "completed")
self.assertEqual(review_req.result_id, "ready")
self.assertEqual(review_req.reviewed_rev, review_req.doc.rev)
self.assertTrue(review_req.team.acronym.lower() in review_req.review.name)
self.assertTrue(review_req.doc.rev in review_req.review.name)
with open(os.path.join(self.review_subdir, review_req.review.name + ".txt")) as f:
self.assertEqual(f.read(), "This is a review\nwith two lines")
self.assertEqual(len(outbox), 1)
self.assertTrue(review_req.team.list_email in outbox[0]["To"])
self.assertTrue("This is a review" in outbox[0].get_payload(decode=True).decode("utf-8"))
self.assertTrue(settings.MAILING_LIST_ARCHIVE_URL in review_req.review.external_url)
# check the review document page
url = urlreverse('doc_view', kwargs={ "name": review_req.review.name })
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
content = unicontent(r)
self.assertTrue("{} Review".format(review_req.type.name) in content)
self.assertTrue("This is a review" in content)
def test_complete_review_enter_content(self):
review_req, url = self.setup_complete_review_test()
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
empty_outbox()
r = self.client.post(url, data={
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
"reviewed_rev": review_req.doc.rev,
"review_submission": "enter",
"review_content": "This is a review\nwith two lines",
"review_url": "",
"review_file": "",
})
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "completed")
with open(os.path.join(self.review_subdir, review_req.review.name + ".txt")) as f:
self.assertEqual(f.read(), "This is a review\nwith two lines")
self.assertEqual(len(outbox), 1)
self.assertTrue(review_req.team.list_email in outbox[0]["To"])
self.assertTrue("This is a review" in outbox[0].get_payload(decode=True).decode("utf-8"))
self.assertTrue(settings.MAILING_LIST_ARCHIVE_URL in review_req.review.external_url)
def test_complete_review_link_to_mailing_list(self):
review_req, url = self.setup_complete_review_test()
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
empty_outbox()
r = self.client.post(url, data={
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
"reviewed_rev": review_req.doc.rev,
"review_submission": "link",
"review_content": "This is a review\nwith two lines",
"review_url": "http://example.com/testreview/",
"review_file": "",
})
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "completed")
with open(os.path.join(self.review_subdir, review_req.review.name + ".txt")) as f:
self.assertEqual(f.read(), "This is a review\nwith two lines")
self.assertEqual(len(outbox), 0)
self.assertTrue("http://example.com" in review_req.review.external_url)
def test_partially_complete_review(self):
review_req, url = self.setup_complete_review_test()
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
# partially complete
empty_outbox()
r = self.client.post(url, data={
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
"state": ReviewRequestStateName.objects.get(slug="part-completed").pk,
"reviewed_rev": review_req.doc.rev,
"review_submission": "enter",
"review_content": "This is a review\nwith two lines",
})
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "part-completed")
self.assertTrue(review_req.doc.rev in review_req.review.name)
self.assertEqual(len(outbox), 2)
self.assertTrue("reviewsecretary@example.com" in outbox[0]["To"])
self.assertTrue("partially" in outbox[0]["Subject"].lower())
self.assertTrue("new review request" in outbox[0].get_payload(decode=True).decode("utf-8"))
self.assertTrue(review_req.team.list_email in outbox[1]["To"])
self.assertTrue("partial review" in outbox[1]["Subject"].lower())
self.assertTrue("This is a review" in outbox[1].get_payload(decode=True).decode("utf-8"))
first_review = review_req.review
first_reviewer = review_req.reviewer
# complete
review_req = ReviewRequest.objects.get(state="requested", doc=review_req.doc, team=review_req.team)
self.assertEqual(review_req.reviewer, None)
review_req.reviewer = first_reviewer # same reviewer, so we can test uniquification
review_req.save()
url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": review_req.doc.name, "request_id": review_req.pk })
r = self.client.post(url, data={
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
"reviewed_rev": review_req.doc.rev,
"review_submission": "enter",
"review_content": "This is another review\nwith\nthree lines",
})
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "completed")
self.assertTrue(review_req.doc.rev in review_req.review.name)
second_review = review_req.review
self.assertTrue(first_review.name != second_review.name)
self.assertTrue(second_review.name.endswith("-2")) # uniquified
def test_revise_review_enter_content(self):
review_req, url = self.setup_complete_review_test()
review_req.state = ReviewRequestStateName.objects.get(slug="no-response")
review_req.save()
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
empty_outbox()
r = self.client.post(url, data={
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
"reviewed_rev": review_req.doc.rev,
"review_submission": "enter",
"review_content": "This is a review\nwith two lines",
"review_url": "",
"review_file": "",
"completion_date": "2012-12-24",
"completion_time": "12:13:14",
})
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.state_id, "completed")
event = ReviewRequestDocEvent.objects.get(type="closed_review_request", review_request=review_req)
self.assertEqual(event.time, datetime.datetime(2012, 12, 24, 12, 13, 14))
with open(os.path.join(self.review_subdir, review_req.review.name + ".txt")) as f:
self.assertEqual(f.read(), "This is a review\nwith two lines")
self.assertEqual(len(outbox), 0)
# revise again
empty_outbox()
r = self.client.post(url, data={
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
"state": ReviewRequestStateName.objects.get(slug="part-completed").pk,
"reviewed_rev": review_req.doc.rev,
"review_submission": "enter",
"review_content": "This is a revised review",
"review_url": "",
"review_file": "",
"completion_date": "2013-12-24",
"completion_time": "11:11:11",
})
self.assertEqual(r.status_code, 302)
review_req = reload_db_objects(review_req)
self.assertEqual(review_req.review.rev, "01")
event = ReviewRequestDocEvent.objects.get(type="closed_review_request", review_request=review_req)
self.assertEqual(event.time, datetime.datetime(2013, 12, 24, 11, 11, 11))
self.assertEqual(len(outbox), 0)

View file

@ -77,6 +77,7 @@ urlpatterns = patterns('',
url(r'^%(name)s/ballot/(?P<ballot_id>[0-9]+)/emailposition/$' % settings.URL_REGEXPS, views_ballot.send_ballot_comment, name='doc_send_ballot_comment'),
(r'^%(name)s/(?:%(rev)s/)?doc.json$' % settings.URL_REGEXPS, views_doc.document_json),
(r'^%(name)s/ballotpopup/(?P<ballot_id>[0-9]+)/$' % settings.URL_REGEXPS, views_doc.ballot_popup),
url(r'^(?P<name>[A-Za-z0-9._+-]+)/reviewrequest/', include("ietf.doc.urls_review")),
url(r'^%(name)s/email-aliases/$' % settings.URL_REGEXPS, RedirectView.as_view(pattern_name='doc_email', permanent=False),name='doc_specific_email_aliases'),

13
ietf/doc/urls_review.py Normal file
View file

@ -0,0 +1,13 @@
from django.conf.urls import patterns, url
from ietf.doc import views_review
urlpatterns = patterns('',
url(r'^$', views_review.request_review),
url(r'^(?P<request_id>[0-9]+)/$', views_review.review_request),
url(r'^(?P<request_id>[0-9]+)/close/$', views_review.close_request),
url(r'^(?P<request_id>[0-9]+)/assignreviewer/$', views_review.assign_reviewer),
url(r'^(?P<request_id>[0-9]+)/rejectreviewerassignment/$', views_review.reject_reviewer_assignment),
url(r'^(?P<request_id>[0-9]+)/complete/$', views_review.complete_review),
url(r'^(?P<request_id>[0-9]+)/searchmailarchive/$', views_review.search_mail_archive),
)

View file

@ -5,6 +5,7 @@ import math
import datetime
import hashlib
import json
from collections import defaultdict
from django.conf import settings
from django.forms import ValidationError
@ -110,7 +111,6 @@ def can_adopt_draft(user, doc):
group__state="active",
person__user=user).exists())
def two_thirds_rule( recused=0 ):
# For standards-track, need positions from 2/3 of the non-recused current IESG.
active = Role.objects.filter(name="ad",group__type="area",group__state="active").count()
@ -598,6 +598,39 @@ def uppercase_std_abbreviated_name(name):
else:
return name
def extract_complete_replaces_ancestor_mapping_for_docs(names):
"""Return dict mapping all replaced by relationships of the
replacement ancestors to docs. So if x is directly replaced by y
and y is in names or replaced by something in names, x in
replaces[y]."""
replaces = defaultdict(set)
checked = set()
front = names
while True:
if not front:
break
relations = RelatedDocument.objects.filter(
source__in=front, relationship="replaces"
).select_related("target").values_list("source", "target__document")
if not relations:
break
checked.update(front)
front = []
for source_doc, target_doc in relations:
replaces[source_doc].add(target_doc)
if target_doc not in checked:
front.append(target_doc)
return replaces
def crawl_history(doc):
# return document history data for inclusion in doc.json (used by timeline)
def get_ancestors(doc):

View file

@ -60,8 +60,9 @@ def fill_in_document_table_attributes(docs):
d.expirable = expirable_draft(d)
if d.get_state_slug() != "rfc":
d.milestones = d.groupmilestone_set.filter(state="active").order_by("time").select_related("group")
d.milestones = sorted((m for m in d.groupmilestone_set.all() if m.state_id == "active"), key=lambda m: m.time)
d.reviewed_by_teams = sorted(set(r.team for r in d.reviewrequest_set.all()), key=lambda g: g.acronym)
# RFCs
@ -101,7 +102,7 @@ def prepare_document_table(request, docs, query=None, max_results=500):
# evaluate and fill in attribute results immediately to decrease
# the number of queries
docs = docs.select_related("ad", "ad__person", "std_level", "intended_std_level", "group", "stream")
docs = docs.prefetch_related("states__type", "tags")
docs = docs.prefetch_related("states__type", "tags", "groupmilestone_set__group", "reviewrequest_set__team")
docs = list(docs[:max_results])

View file

@ -344,14 +344,6 @@ class UploadForm(forms.Form):
def clean_txt(self):
return get_cleaned_text_file_content(self.cleaned_data["txt"])
def save(self, group, rev):
filename = os.path.join(settings.CHARTER_PATH, '%s-%s.txt' % (group.charter.canonical_name(), rev))
with open(filename, 'wb') as destination:
if self.cleaned_data['txt']:
destination.write(self.cleaned_data['txt'])
else:
destination.write(self.cleaned_data['content'].encode("utf-8"))
@login_required
def submit(request, name, option=None):
if not name.startswith('charter-'):
@ -417,7 +409,12 @@ def submit(request, name, option=None):
events.append(e)
# Save file on disk
form.save(group, charter.rev)
filename = os.path.join(settings.CHARTER_PATH, '%s-%s.txt' % (charter.canonical_name(), charter.rev))
with open(filename, 'wb') as destination:
if form.cleaned_data['txt']:
destination.write(form.cleaned_data['txt'])
else:
destination.write(form.cleaned_data['content'].encode("utf-8"))
if option in ['initcharter','recharter'] and charter.ad == None:
charter.ad = getattr(group.ad_role(),'person',None)

View file

@ -60,10 +60,13 @@ from ietf.name.models import StreamName, BallotPositionName
from ietf.person.models import Email
from ietf.utils.history import find_history_active_at
from ietf.doc.forms import TelechatForm, NotifyForm
from ietf.doc.mails import email_comment
from ietf.doc.mails import email_comment
from ietf.mailtrigger.utils import gather_relevant_expansions
from ietf.meeting.models import Session
from ietf.meeting.utils import group_sessions, get_upcoming_manageable_sessions, sort_sessions
from ietf.review.models import ReviewRequest
from ietf.review.utils import can_request_review_of_doc, review_requests_to_list_for_docs
from ietf.review.utils import no_review_from_teams_on_doc
def render_document_top(request, doc, tab, name):
tabs = []
@ -282,8 +285,8 @@ def document_main(request, name, rev=None):
can_edit_stream_info = is_authorized_in_doc_stream(request.user, doc)
can_edit_shepherd_writeup = can_edit_stream_info or user_is_person(request.user, doc.shepherd and doc.shepherd.person) or has_role(request.user, ["Area Director"])
can_edit_notify = can_edit_shepherd_writeup
can_edit_consensus = False
can_edit_consensus = False
consensus = nice_consensus(default_consensus(doc))
if doc.stream_id == "ietf" and iesg_state:
show_in_states = set(IESG_BALLOT_ACTIVE_STATES)
@ -297,6 +300,8 @@ def document_main(request, name, rev=None):
e = doc.latest_event(ConsensusDocEvent, type="changed_consensus")
consensus = nice_consensus(e and e.consensus)
can_request_review = can_request_review_of_doc(request.user, doc)
# mailing list search archive
search_archive = "www.ietf.org/mail-archive/web/"
if doc.stream_id == "ietf" and group.type_id == "wg" and group.list_archive:
@ -357,6 +362,9 @@ def document_main(request, name, rev=None):
published = doc.latest_event(type="published_rfc")
started_iesg_process = doc.latest_event(type="started_iesg_process")
review_requests = review_requests_to_list_for_docs([doc]).get(doc.pk, [])
no_review_from_teams = no_review_from_teams_on_doc(doc, rev or doc.rev)
return render_to_response("doc/document_draft.html",
dict(doc=doc,
group=group,
@ -378,6 +386,7 @@ def document_main(request, name, rev=None):
can_edit_consensus=can_edit_consensus,
can_edit_replaces=can_edit_replaces,
can_view_possibly_replaces=can_view_possibly_replaces,
can_request_review=can_request_review,
rfc_number=rfc_number,
draft_name=draft_name,
@ -416,6 +425,8 @@ def document_main(request, name, rev=None):
search_archive=search_archive,
actions=actions,
presentations=presentations,
review_requests=review_requests,
no_review_from_teams=no_review_from_teams,
),
context_instance=RequestContext(request))
@ -567,6 +578,29 @@ def document_main(request, name, rev=None):
),
context_instance=RequestContext(request))
if doc.type_id == "review":
basename = "{}.txt".format(doc.name, doc.rev)
pathname = os.path.join(doc.get_file_path(), basename)
content = get_document_content(basename, pathname, split=False)
review_req = ReviewRequest.objects.filter(review=doc.name).first()
other_reviews = []
if review_req:
other_reviews = [r for r in review_requests_to_list_for_docs([review_req.doc]).get(doc.pk, []) if r != review_req]
return render(request, "doc/document_review.html",
dict(doc=doc,
top=top,
content=content,
revisions=revisions,
latest_rev=latest_rev,
snapshot=snapshot,
review_req=review_req,
other_reviews=other_reviews,
))
raise Http404

View file

@ -792,11 +792,15 @@ def resurrect(request, name):
if doc.get_state_slug() != "expired":
raise Http404
resurrect_requested_by = None
e = doc.latest_event(type__in=('requested_resurrect', "completed_resurrect"))
if e.type == 'requested_resurrect':
resurrect_requested_by = e.by
if request.method == 'POST':
e = doc.latest_event(type__in=('requested_resurrect', "completed_resurrect"))
if e and e.type == 'requested_resurrect':
email_resurrection_completed(request, doc, requester=e.by)
if resurrect_requested_by:
email_resurrection_completed(request, doc, requester=resurrect_requested_by)
events = []
e = DocEvent(doc=doc, by=request.user.person)
e.type = "completed_resurrect"
@ -812,6 +816,7 @@ def resurrect(request, name):
return render_to_response('doc/draft/resurrect.html',
dict(doc=doc,
resurrect_requested_by=resurrect_requested_by,
back_url=doc.get_absolute_url()),
context_instance=RequestContext(request))

587
ietf/doc/views_review.py Normal file
View file

@ -0,0 +1,587 @@
import datetime, os, email.utils
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import render, get_object_or_404, redirect
from django import forms
from django.contrib.auth.decorators import login_required
from django.utils.html import mark_safe
from django.core.exceptions import ValidationError
from django.template.loader import render_to_string
from django.core.urlresolvers import reverse as urlreverse
from ietf.doc.models import (Document, NewRevisionDocEvent, State, DocAlias,
LastCallDocEvent, ReviewRequestDocEvent)
from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName
from ietf.review.models import ReviewRequest, TypeUsedInReviewTeam
from ietf.group.models import Group
from ietf.person.fields import PersonEmailChoiceField, SearchablePersonField
from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role
from ietf.review.utils import (active_review_teams, assign_review_request_to_reviewer,
can_request_review_of_doc, can_manage_review_requests_for_team,
email_review_request_change, make_new_review_request_from_existing,
close_review_request_states, close_review_request,
setup_reviewer_field)
from ietf.review import mailarch
from ietf.utils.fields import DatepickerDateField
from ietf.utils.text import strip_prefix, xslugify
from ietf.utils.textupload import get_cleaned_text_file_content
from ietf.utils.mail import send_mail
def clean_doc_revision(doc, rev):
if rev:
rev = rev.rjust(2, "0")
if not NewRevisionDocEvent.objects.filter(doc=doc, rev=rev).exists():
raise forms.ValidationError("Could not find revision \"{}\" of the document.".format(rev))
return rev
class RequestReviewForm(forms.ModelForm):
team = forms.ModelMultipleChoiceField(queryset=Group.objects.all(), widget=forms.CheckboxSelectMultiple)
deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" })
class Meta:
model = ReviewRequest
fields = ('requested_by', 'type', 'deadline', 'requested_rev')
def __init__(self, user, doc, *args, **kwargs):
super(RequestReviewForm, self).__init__(*args, **kwargs)
self.doc = doc
f = self.fields["team"]
f.queryset = active_review_teams()
f.initial = [group.pk for group in f.queryset if can_manage_review_requests_for_team(user, group, allow_personnel_outside_team=False)]
self.fields['type'].queryset = self.fields['type'].queryset.filter(used=True, typeusedinreviewteam__team__in=self.fields["team"].queryset).distinct()
self.fields['type'].widget = forms.RadioSelect(choices=[t for t in self.fields['type'].choices if t[0]])
self.fields["requested_rev"].label = "Document revision"
if has_role(user, "Secretariat"):
self.fields["requested_by"] = SearchablePersonField()
else:
self.fields["requested_by"].widget = forms.HiddenInput()
self.fields["requested_by"].initial = user.person.pk
def clean_deadline(self):
v = self.cleaned_data.get('deadline')
if v < datetime.date.today():
raise forms.ValidationError("Select today or a date in the future.")
return v
def clean_requested_rev(self):
return clean_doc_revision(self.doc, self.cleaned_data.get("requested_rev"))
def clean(self):
chosen_type = self.cleaned_data.get("type")
chosen_teams = self.cleaned_data.get("team")
if chosen_type and chosen_teams:
for t in chosen_teams:
if not TypeUsedInReviewTeam.objects.filter(type=chosen_type, team=t).exists():
self.add_error("type", "{} does not use the review type {}.".format(t.name, chosen_type.name))
return self.cleaned_data
@login_required
def request_review(request, name):
doc = get_object_or_404(Document, name=name)
if not can_request_review_of_doc(request.user, doc):
return HttpResponseForbidden("You do not have permission to perform this action")
now = datetime.datetime.now()
lc_ends = None
e = doc.latest_event(LastCallDocEvent, type="sent_last_call")
if e and e.expires >= now:
lc_ends = e.expires
scheduled_for_telechat = doc.telechat_date()
if request.method == "POST":
form = RequestReviewForm(request.user, doc, request.POST)
if form.is_valid():
teams = form.cleaned_data["team"]
for team in teams:
review_req = form.save(commit=False)
review_req.doc = doc
review_req.state = ReviewRequestStateName.objects.get(slug="requested", used=True)
review_req.team = team
review_req.save()
ReviewRequestDocEvent.objects.create(
type="requested_review",
doc=doc,
by=request.user.person,
desc="Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()),
time=review_req.time,
review_request=review_req,
state=None,
)
return redirect('doc_view', name=doc.name)
else:
if lc_ends:
review_type = "lc"
elif scheduled_for_telechat:
review_type = "telechat"
else:
review_type = "early"
form = RequestReviewForm(request.user, doc, initial={ "type": review_type })
return render(request, 'doc/review/request_review.html', {
'doc': doc,
'form': form,
'lc_ends': lc_ends,
'lc_ends_days': (lc_ends - now).days if lc_ends else None,
'scheduled_for_telechat': scheduled_for_telechat,
'scheduled_for_telechat_days': (scheduled_for_telechat - now.date()).days if scheduled_for_telechat else None,
})
def review_request(request, name, request_id):
doc = get_object_or_404(Document, name=name)
review_req = get_object_or_404(ReviewRequest, pk=request_id)
is_reviewer = review_req.reviewer and user_is_person(request.user, review_req.reviewer.person)
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
can_close_request = (review_req.state_id in ["requested", "accepted"]
and (is_authorized_in_doc_stream(request.user, doc)
or can_manage_request))
can_assign_reviewer = (review_req.state_id in ["requested", "accepted"]
and can_manage_request)
can_accept_reviewer_assignment = (review_req.state_id == "requested"
and review_req.reviewer
and (is_reviewer or can_manage_request))
can_reject_reviewer_assignment = (review_req.state_id in ["requested", "accepted"]
and review_req.reviewer
and (is_reviewer or can_manage_request))
can_complete_review = (review_req.state_id in ["requested", "accepted", "overtaken", "no-response", "part-completed", "completed"]
and review_req.reviewer
and (is_reviewer or can_manage_request))
if request.method == "POST" and request.POST.get("action") == "accept" and can_accept_reviewer_assignment:
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
review_req.save()
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
return render(request, 'doc/review/review_request.html', {
'doc': doc,
'review_req': review_req,
'can_close_request': can_close_request,
'can_reject_reviewer_assignment': can_reject_reviewer_assignment,
'can_assign_reviewer': can_assign_reviewer,
'can_accept_reviewer_assignment': can_accept_reviewer_assignment,
'can_complete_review': can_complete_review,
})
class CloseReviewRequestForm(forms.Form):
close_reason = forms.ModelChoiceField(queryset=close_review_request_states(), widget=forms.RadioSelect, empty_label=None)
def __init__(self, can_manage_request, *args, **kwargs):
super(CloseReviewRequestForm, self).__init__(*args, **kwargs)
if not can_manage_request:
self.fields["close_reason"].queryset = self.fields["close_reason"].queryset.filter(slug__in=["withdrawn"])
if len(self.fields["close_reason"].queryset) == 1:
self.fields["close_reason"].initial = self.fields["close_reason"].queryset.first().pk
self.fields["close_reason"].widget = forms.HiddenInput()
@login_required
def close_request(request, name, request_id):
doc = get_object_or_404(Document, name=name)
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
can_request = is_authorized_in_doc_stream(request.user, doc)
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
if not (can_request or can_manage_request):
return HttpResponseForbidden("You do not have permission to perform this action")
if request.method == "POST":
form = CloseReviewRequestForm(can_manage_request, request.POST)
if form.is_valid():
close_review_request(request, review_req, form.cleaned_data["close_reason"])
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
else:
form = CloseReviewRequestForm(can_manage_request)
return render(request, 'doc/review/close_request.html', {
'doc': doc,
'review_req': review_req,
'form': form,
})
class AssignReviewerForm(forms.Form):
reviewer = PersonEmailChoiceField(empty_label="(None)", required=False)
def __init__(self, review_req, *args, **kwargs):
super(AssignReviewerForm, self).__init__(*args, **kwargs)
setup_reviewer_field(self.fields["reviewer"], review_req)
@login_required
def assign_reviewer(request, name, request_id):
doc = get_object_or_404(Document, name=name)
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
if not can_manage_review_requests_for_team(request.user, review_req.team):
return HttpResponseForbidden("You do not have permission to perform this action")
if request.method == "POST" and request.POST.get("action") == "assign":
form = AssignReviewerForm(review_req, request.POST)
if form.is_valid():
reviewer = form.cleaned_data["reviewer"]
assign_review_request_to_reviewer(request, review_req, reviewer)
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
else:
form = AssignReviewerForm(review_req)
return render(request, 'doc/review/assign_reviewer.html', {
'doc': doc,
'review_req': review_req,
'form': form,
})
class RejectReviewerAssignmentForm(forms.Form):
message_to_secretary = forms.CharField(widget=forms.Textarea, required=False, help_text="Optional explanation of rejection, will be emailed to team secretary if filled in")
@login_required
def reject_reviewer_assignment(request, name, request_id):
doc = get_object_or_404(Document, name=name)
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
if not review_req.reviewer:
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
is_reviewer = user_is_person(request.user, review_req.reviewer.person)
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
if not (is_reviewer or can_manage_request):
return HttpResponseForbidden("You do not have permission to perform this action")
if request.method == "POST" and request.POST.get("action") == "reject":
form = RejectReviewerAssignmentForm(request.POST)
if form.is_valid():
# reject the request
review_req.state = ReviewRequestStateName.objects.get(slug="rejected")
review_req.save()
ReviewRequestDocEvent.objects.create(
type="closed_review_request",
doc=review_req.doc,
by=request.user.person,
desc="Assignment of request for {} review by {} to {} was rejected".format(
review_req.type.name,
review_req.team.acronym.upper(),
review_req.reviewer.person,
),
review_request=review_req,
state=review_req.state,
)
# make a new unassigned review request
new_review_req = make_new_review_request_from_existing(review_req)
new_review_req.save()
msg = render_to_string("review/reviewer_assignment_rejected.txt", {
"by": request.user.person,
"message_to_secretary": form.cleaned_data.get("message_to_secretary")
})
email_review_request_change(request, review_req, "Reviewer assignment rejected", msg, by=request.user.person, notify_secretary=True, notify_reviewer=True, notify_requested_by=False)
return redirect(review_request, name=new_review_req.doc.name, request_id=new_review_req.pk)
else:
form = RejectReviewerAssignmentForm()
return render(request, 'doc/review/reject_reviewer_assignment.html', {
'doc': doc,
'review_req': review_req,
'form': form,
})
class CompleteReviewForm(forms.Form):
state = forms.ModelChoiceField(queryset=ReviewRequestStateName.objects.filter(slug__in=("completed", "part-completed")).order_by("-order"), widget=forms.RadioSelect, initial="completed")
reviewed_rev = forms.CharField(label="Reviewed revision", max_length=4)
result = forms.ModelChoiceField(queryset=ReviewResultName.objects.filter(used=True), widget=forms.RadioSelect, empty_label=None)
ACTIONS = [
("enter", "Enter review content (automatically posts to {mailing_list})"),
("upload", "Upload review content in text file (automatically posts to {mailing_list})"),
("link", "Link to review message already sent to {mailing_list}"),
]
review_submission = forms.ChoiceField(choices=ACTIONS, widget=forms.RadioSelect)
review_url = forms.URLField(label="Link to message", required=False)
review_file = forms.FileField(label="Text file to upload", required=False)
review_content = forms.CharField(widget=forms.Textarea, required=False)
completion_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1" }, initial=datetime.date.today, help_text="Date of announcement of the results of this review")
completion_time = forms.TimeField(widget=forms.HiddenInput, initial=datetime.time.min)
cc = forms.CharField(required=False, help_text="Email addresses to send to in addition to the review team list")
def __init__(self, review_req, *args, **kwargs):
self.review_req = review_req
super(CompleteReviewForm, self).__init__(*args, **kwargs)
doc = self.review_req.doc
known_revisions = NewRevisionDocEvent.objects.filter(doc=doc).order_by("time", "id").values_list("rev", flat=True)
revising_review = review_req.state_id not in ["requested", "accepted"]
if not revising_review:
self.fields["state"].choices = [
(slug, "{} - extra reviewer is to be assigned".format(label)) if slug == "part-completed" else (slug, label)
for slug, label in self.fields["state"].choices
]
self.fields["reviewed_rev"].help_text = mark_safe(
" ".join("<a class=\"rev label label-default\">{}</a>".format(r)
for r in known_revisions))
self.fields["result"].queryset = self.fields["result"].queryset.filter(resultusedinreviewteam__team=review_req.team)
def format_submission_choice(label):
if revising_review:
label = label.replace(" (automatically posts to {mailing_list})", "")
return label.format(mailing_list=review_req.team.list_email or "[error: team has no mailing list set]")
self.fields["review_submission"].choices = [ (k, format_submission_choice(label)) for k, label in self.fields["review_submission"].choices]
if revising_review:
del self.fields["cc"]
else:
del self.fields["completion_date"]
del self.fields["completion_time"]
def clean_reviewed_rev(self):
return clean_doc_revision(self.review_req.doc, self.cleaned_data.get("reviewed_rev"))
def clean_review_content(self):
return self.cleaned_data["review_content"].replace("\r", "")
def clean_review_file(self):
return get_cleaned_text_file_content(self.cleaned_data["review_file"])
def clean(self):
if "@" in self.review_req.reviewer.person.ascii:
raise forms.ValidationError("Reviewer name must be filled in (the ASCII version is currently \"{}\" - since it contains an @ sign the name is probably still the original email address).".format(self.review_req.reviewer.person.ascii))
def require_field(f):
if not self.cleaned_data.get(f):
self.add_error(f, ValidationError("You must fill in this field."))
submission_method = self.cleaned_data.get("review_submission")
if submission_method == "enter":
require_field("review_content")
elif submission_method == "upload":
require_field("review_file")
elif submission_method == "link":
require_field("review_url")
require_field("review_content")
@login_required
def complete_review(request, name, request_id):
doc = get_object_or_404(Document, name=name)
review_req = get_object_or_404(ReviewRequest, pk=request_id)
revising_review = review_req.state_id not in ["requested", "accepted"]
if not review_req.reviewer:
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
is_reviewer = user_is_person(request.user, review_req.reviewer.person)
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
if not (is_reviewer or can_manage_request):
return HttpResponseForbidden("You do not have permission to perform this action")
if request.method == "POST":
form = CompleteReviewForm(review_req, request.POST, request.FILES)
if form.is_valid():
review_submission = form.cleaned_data['review_submission']
review = review_req.review
if not review:
# create review doc
for i in range(1, 100):
name_components = [
"review",
strip_prefix(review_req.doc.name, "draft-"),
form.cleaned_data["reviewed_rev"],
review_req.team.acronym,
review_req.type.slug,
xslugify(review_req.reviewer.person.ascii_parts()[3]),
datetime.date.today().isoformat(),
]
if i > 1:
name_components.append(str(i))
name = "-".join(c for c in name_components if c).lower()
if not Document.objects.filter(name=name).exists():
review = Document.objects.create(name=name)
DocAlias.objects.create(document=review, name=review.name)
break
review.type = DocTypeName.objects.get(slug="review")
review.group = review_req.team
review.rev = "00" if not review.rev else "{:02}".format(int(review.rev) + 1)
review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, form.cleaned_data["reviewed_rev"])
review.time = datetime.datetime.now()
if review_submission == "link":
review.external_url = form.cleaned_data['review_url']
e = NewRevisionDocEvent.objects.create(
type="new_revision",
doc=review,
by=request.user.person,
rev=review.rev,
desc='New revision available',
time=review.time,
)
review.set_state(State.objects.get(type="review", slug="active"))
review.save_with_history([e])
# save file on disk
if review_submission == "upload":
encoded_content = form.cleaned_data['review_file']
else:
encoded_content = form.cleaned_data['review_content'].encode("utf-8")
filename = os.path.join(review.get_file_path(), '{}.txt'.format(review.name, review.rev))
with open(filename, 'wb') as destination:
destination.write(encoded_content)
# close review request
review_req.state = form.cleaned_data["state"]
review_req.reviewed_rev = form.cleaned_data["reviewed_rev"]
review_req.result = form.cleaned_data["result"]
review_req.review = review
review_req.save()
need_to_email_review = review_submission != "link" and review_req.team.list_email and not revising_review
desc = "Request for {} review by {} {}: {}. Reviewer: {}.".format(
review_req.type.name,
review_req.team.acronym.upper(),
review_req.state.name,
review_req.result.name,
review_req.reviewer.person,
)
if need_to_email_review:
desc += " " + "Sent review to list."
completion_datetime = datetime.datetime.now()
if "completion_date" in form.cleaned_data:
completion_datetime = datetime.datetime.combine(form.cleaned_data["completion_date"], form.cleaned_data.get("completion_time") or datetime.time.min)
close_event = ReviewRequestDocEvent.objects.filter(type="closed_review_request", review_request=review_req).first()
if not close_event:
close_event = ReviewRequestDocEvent(type="closed_review_request", review_request=review_req)
close_event.doc = review_req.doc
close_event.by = request.user.person
close_event.desc = desc
close_event.state = review_req.state
close_event.time = completion_datetime
close_event.save()
if review_req.state_id == "part-completed" and not revising_review:
existing_open_reqs = ReviewRequest.objects.filter(doc=review_req.doc, team=review_req.team, state__in=("requested", "accepted"))
new_review_req_url = new_review_req = None
if not existing_open_reqs:
new_review_req = make_new_review_request_from_existing(review_req)
new_review_req.save()
new_review_req_url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": new_review_req.doc.name, "request_id": new_review_req.pk })
new_review_req_url = request.build_absolute_uri(new_review_req_url)
subject = "Review of {}-{} completed partially".format(review_req.doc.name, review_req.reviewed_rev)
msg = render_to_string("review/partially_completed_review.txt", {
"new_review_req_url": new_review_req_url,
"existing_open_reqs": existing_open_reqs,
"by": request.user.person,
})
email_review_request_change(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=False)
if need_to_email_review:
# email the review
subject = "{} of {}-{}".format("Partial review" if review_req.state_id == "part-completed" else "Review", review_req.doc.name, review_req.reviewed_rev)
msg = send_mail(request, [(review_req.team.name, review_req.team.list_email)], None,
subject,
"review/completed_review.txt", {
"review_req": review_req,
"content": encoded_content.decode("utf-8"),
},
cc=form.cleaned_data["cc"])
list_name = mailarch.list_name_from_email(review_req.team.list_email)
if list_name:
review.external_url = mailarch.construct_message_url(list_name, email.utils.unquote(msg["Message-ID"]))
review.save_with_history([close_event])
return redirect("doc_view", name=review_req.review.name)
else:
form = CompleteReviewForm(review_req, initial={
"reviewed_rev": review_req.reviewed_rev,
"result": review_req.result_id
})
mail_archive_query_urls = mailarch.construct_query_urls(review_req)
return render(request, 'doc/review/complete_review.html', {
'doc': doc,
'review_req': review_req,
'form': form,
'mail_archive_query_urls': mail_archive_query_urls,
'revising_review': revising_review,
})
def search_mail_archive(request, name, request_id):
#doc = get_object_or_404(Document, name=name)
review_req = get_object_or_404(ReviewRequest, pk=request_id)
is_reviewer = user_is_person(request.user, review_req.reviewer.person)
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
if not (is_reviewer or can_manage_request):
return HttpResponseForbidden("You do not have permission to perform this action")
res = mailarch.construct_query_urls(review_req, query=request.GET.get("query"))
if not res:
return JsonResponse({ "error": "Couldn't do lookup in mail archive - don't know where to look"})
MAX_RESULTS = 30
try:
res["messages"] = mailarch.retrieve_messages(res["query_data_url"])[:MAX_RESULTS]
except Exception as e:
res["error"] = "Retrieval from mail archive failed: {}".format(unicode(e))
# raise # useful when debugging
return JsonResponse(res)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,10 +6,12 @@ class GroupFeatures(object):
has_chartering_process = False
has_documents = False # i.e. drafts/RFCs
has_materials = False
has_reviews = False
customize_workflow = False
about_page = "group_about"
default_tab = about_page
material_types = ["slides"]
admin_roles = ["chair"]
def __init__(self, group):
if group.type_id in ("wg", "rg"):
@ -24,3 +26,12 @@ class GroupFeatures(object):
if self.has_chartering_process:
self.about_page = "group_charter"
from ietf.review.utils import active_review_teams
if group in active_review_teams():
self.has_reviews = True
import ietf.group.views
self.default_tab = ietf.group.views_review.review_requests
if group.type_id == "dir":
self.admin_roles = ["chair", "secr"]

View file

@ -94,7 +94,7 @@ def edit_milestones(request, acronym, group_type=None, milestone_set="current"):
needs_review = False
if not can_manage_group(request.user, group):
if group.has_role(request.user, "chair"):
if group.has_role(request.user, group.features.admin_roles):
if milestone_set == "current":
needs_review = True
else:

View file

@ -29,8 +29,8 @@ from ietf.meeting.factories import SessionFactory
from ietf.name.models import DocTagName, GroupStateName, GroupTypeName
from ietf.person.models import Person, Email
from ietf.utils.mail import outbox, empty_outbox
from ietf.utils.test_data import make_test_data, create_person
from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent
from ietf.utils.test_data import make_test_data, create_person, make_review_data
from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects
def group_urlreverse_list(group, viewname):
return [
@ -575,10 +575,10 @@ class GroupEditTests(TestCase):
parent=area.pk,
ad=ad.pk,
state=state.pk,
chairs="aread@ietf.org, ad1@ietf.org",
secretaries="aread@ietf.org, ad1@ietf.org, ad2@ietf.org",
techadv="aread@ietf.org",
delegates="ad2@ietf.org",
chair_roles="aread@ietf.org, ad1@ietf.org",
secr_roles="aread@ietf.org, ad1@ietf.org, ad2@ietf.org",
techadv_roles="aread@ietf.org",
delegate_roles="ad2@ietf.org",
list_email="mars@mail",
list_subscribe="subscribe.mars",
list_archive="archive.mars",
@ -604,6 +604,40 @@ class GroupEditTests(TestCase):
for prefix in ['ad1','ad2','aread','marschairman','marsdelegate']:
self.assertTrue(prefix+'@' in outbox[0]['To'])
def test_edit_reviewers(self):
doc = make_test_data()
review_req = make_review_data(doc)
group = review_req.team
url = urlreverse('group_edit', kwargs=dict(group_type=group.type_id, acronym=group.acronym))
login_testing_unauthorized(self, "secretary", url)
# normal get
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertEqual(len(q('form input[name=reviewer_roles]')), 1)
# set reviewers
empty_outbox()
r = self.client.post(url,
dict(name=group.name,
acronym=group.acronym,
parent=group.parent_id,
ad=Person.objects.get(name="Areað Irector").pk,
state=group.state_id,
reviewer_roles="ad2@ietf.org",
list_email=group.list_email,
list_subscribe=group.list_subscribe,
list_archive=group.list_archive,
urls=""
))
self.assertEqual(r.status_code, 302)
group = reload_db_objects(group)
self.assertEqual(list(group.role_set.filter(name="reviewer").values_list("email", flat=True)), ["ad2@ietf.org"])
self.assertTrue('Personnel change' in outbox[0]['Subject'])
def test_conclude(self):
make_test_data()

497
ietf/group/tests_review.py Normal file
View file

@ -0,0 +1,497 @@
import datetime
from pyquery import PyQuery
from django.core.urlresolvers import reverse as urlreverse
from ietf.utils.test_data import make_test_data, make_review_data
from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects
from ietf.doc.models import TelechatDocEvent
from ietf.group.models import Role
from ietf.iesg.models import TelechatDate
from ietf.person.models import Email, Person
from ietf.review.models import ReviewRequest, ReviewerSettings, UnavailablePeriod, ReviewSecretarySettings
from ietf.review.utils import (
suggested_review_requests_for_team,
review_requests_needing_reviewer_reminder, email_reviewer_reminder,
review_requests_needing_secretary_reminder, email_secretary_reminder,
)
from ietf.name.models import ReviewTypeName, ReviewResultName, ReviewRequestStateName
import ietf.group.views_review
from ietf.utils.mail import outbox, empty_outbox
class ReviewTests(TestCase):
def test_review_requests(self):
doc = make_test_data()
review_req = make_review_data(doc)
group = review_req.team
for url in [urlreverse(ietf.group.views_review.review_requests, kwargs={ 'acronym': group.acronym }),
urlreverse(ietf.group.views_review.review_requests, kwargs={ 'acronym': group.acronym , 'group_type': group.type_id})]:
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertTrue(review_req.doc.name in unicontent(r))
self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r))
url = urlreverse(ietf.group.views_review.review_requests, kwargs={ 'acronym': group.acronym })
# close request, listed under closed
review_req.state = ReviewRequestStateName.objects.get(slug="completed")
review_req.result = ReviewResultName.objects.get(slug="ready")
review_req.save()
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertTrue(review_req.doc.name in unicontent(r))
def test_suggested_review_requests(self):
doc = make_test_data()
review_req = make_review_data(doc)
team = review_req.team
# put on telechat
e = TelechatDocEvent.objects.create(
type="scheduled_for_telechat",
by=Person.objects.get(name="(System)"),
doc=doc,
telechat_date=TelechatDate.objects.all().first().date,
)
doc.rev = "10"
doc.save_with_history([e])
prev_rev = "{:02}".format(int(doc.rev) - 1)
# blocked by existing request
review_req.requested_rev = ""
review_req.save()
self.assertEqual(len(suggested_review_requests_for_team(team)), 0)
# ... but not to previous version
review_req.requested_rev = prev_rev
review_req.save()
suggestions = suggested_review_requests_for_team(team)
self.assertEqual(len(suggestions), 1)
self.assertEqual(suggestions[0].doc, doc)
self.assertEqual(suggestions[0].team, team)
# blocked by non-versioned refusal
review_req.requested_rev = ""
review_req.state = ReviewRequestStateName.objects.get(slug="no-review-document")
review_req.save()
self.assertEqual(list(suggested_review_requests_for_team(team)), [])
# blocked by versioned refusal
review_req.reviewed_rev = doc.rev
review_req.state = ReviewRequestStateName.objects.get(slug="no-review-document")
review_req.save()
self.assertEqual(list(suggested_review_requests_for_team(team)), [])
# blocked by completion
review_req.state = ReviewRequestStateName.objects.get(slug="completed")
review_req.save()
self.assertEqual(list(suggested_review_requests_for_team(team)), [])
# ... but not to previous version
review_req.reviewed_rev = prev_rev
review_req.state = ReviewRequestStateName.objects.get(slug="completed")
review_req.save()
self.assertEqual(len(suggested_review_requests_for_team(team)), 1)
def test_reviewer_overview(self):
doc = make_test_data()
review_req1 = make_review_data(doc)
review_req1.state = ReviewRequestStateName.objects.get(slug="completed")
review_req1.save()
reviewer = review_req1.reviewer.person
ReviewRequest.objects.create(
doc=review_req1.doc,
team=review_req1.team,
type_id="early",
deadline=datetime.date.today() + datetime.timedelta(days=30),
state_id="accepted",
reviewer=review_req1.reviewer,
requested_by=Person.objects.get(user__username="reviewer"),
)
UnavailablePeriod.objects.create(
team=review_req1.team,
person=reviewer,
start_date=datetime.date.today() - datetime.timedelta(days=10),
availability="unavailable",
)
settings = ReviewerSettings.objects.get(person=reviewer)
settings.skip_next = 1
settings.save()
group = review_req1.team
# get
for url in [urlreverse(ietf.group.views_review.reviewer_overview, kwargs={ 'acronym': group.acronym }),
urlreverse(ietf.group.views_review.reviewer_overview, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id })]:
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertTrue(unicode(reviewer) in unicontent(r))
self.assertTrue(review_req1.doc.name in unicontent(r))
self.client.login(username="secretary", password="secretary+password")
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
def test_manage_review_requests(self):
doc = make_test_data()
review_req1 = make_review_data(doc)
group = review_req1.team
url = urlreverse(ietf.group.views_review.manage_review_requests, kwargs={ 'acronym': group.acronym, "assignment_status": "assigned" })
login_testing_unauthorized(self, "secretary", url)
assigned_url = urlreverse(ietf.group.views_review.manage_review_requests, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id, "assignment_status": "assigned" })
unassigned_url = urlreverse(ietf.group.views_review.manage_review_requests, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id, "assignment_status": "unassigned" })
review_req2 = ReviewRequest.objects.create(
doc=review_req1.doc,
team=review_req1.team,
type_id="early",
deadline=datetime.date.today() + datetime.timedelta(days=30),
state_id="accepted",
reviewer=review_req1.reviewer,
requested_by=Person.objects.get(user__username="reviewer"),
)
review_req3 = ReviewRequest.objects.create(
doc=review_req1.doc,
team=review_req1.team,
type_id="early",
deadline=datetime.date.today() + datetime.timedelta(days=30),
state_id="requested",
requested_by=Person.objects.get(user__username="reviewer"),
)
# previous reviews
ReviewRequest.objects.create(
time=datetime.datetime.now() - datetime.timedelta(days=100),
requested_by=Person.objects.get(name="(System)"),
doc=doc,
type=ReviewTypeName.objects.get(slug="early"),
team=review_req1.team,
state=ReviewRequestStateName.objects.get(slug="completed"),
result=ReviewResultName.objects.get(slug="ready-nits"),
reviewed_rev="01",
deadline=datetime.date.today() - datetime.timedelta(days=80),
reviewer=review_req1.reviewer,
)
ReviewRequest.objects.create(
time=datetime.datetime.now() - datetime.timedelta(days=100),
requested_by=Person.objects.get(name="(System)"),
doc=doc,
type=ReviewTypeName.objects.get(slug="early"),
team=review_req1.team,
state=ReviewRequestStateName.objects.get(slug="completed"),
result=ReviewResultName.objects.get(slug="ready"),
reviewed_rev="01",
deadline=datetime.date.today() - datetime.timedelta(days=80),
reviewer=review_req1.reviewer,
)
# get
r = self.client.get(assigned_url)
self.assertEqual(r.status_code, 200)
self.assertTrue(review_req1.doc.name in unicontent(r))
# can't save assigned: conflict
new_reviewer = Email.objects.get(role__name="reviewer", role__group=group, person__user__username="marschairman")
# provoke conflict by posting bogus data
r = self.client.post(assigned_url, {
"reviewrequest": [str(review_req1.pk), str(review_req2.pk), str(123456)],
# close
"r{}-existing_reviewer".format(review_req1.pk): "123456",
"r{}-action".format(review_req1.pk): "close",
"r{}-close".format(review_req1.pk): "no-response",
# assign
"r{}-existing_reviewer".format(review_req2.pk): "123456",
"r{}-action".format(review_req2.pk): "assign",
"r{}-reviewer".format(review_req2.pk): new_reviewer.pk,
"action": "save-continue",
})
self.assertEqual(r.status_code, 200)
content = unicontent(r).lower()
self.assertTrue("1 request closed" in content)
self.assertTrue("2 requests changed assignment" in content)
# can't save unassigned: conflict
r = self.client.post(unassigned_url, {
"reviewrequest": [str(123456)],
"action": "save-continue",
})
self.assertEqual(r.status_code, 200)
content = unicontent(r).lower()
self.assertTrue("1 request opened" in content)
# close and reassign assigned
new_reviewer = Email.objects.get(role__name="reviewer", role__group=group, person__user__username="marschairman")
r = self.client.post(assigned_url, {
"reviewrequest": [str(review_req1.pk), str(review_req2.pk)],
# close
"r{}-existing_reviewer".format(review_req1.pk): review_req1.reviewer_id or "",
"r{}-action".format(review_req1.pk): "close",
"r{}-close".format(review_req1.pk): "no-response",
# assign
"r{}-existing_reviewer".format(review_req2.pk): review_req2.reviewer_id or "",
"r{}-action".format(review_req2.pk): "assign",
"r{}-reviewer".format(review_req2.pk): new_reviewer.pk,
"action": "save",
})
self.assertEqual(r.status_code, 302)
# no change on unassigned
r = self.client.post(unassigned_url, {
"reviewrequest": [str(review_req3.pk)],
# no change
"r{}-existing_reviewer".format(review_req3.pk): review_req3.reviewer_id or "",
"r{}-action".format(review_req3.pk): "",
"r{}-close".format(review_req3.pk): "no-response",
"r{}-reviewer".format(review_req3.pk): "",
"action": "save",
})
self.assertEqual(r.status_code, 302)
review_req1, review_req2, review_req3 = reload_db_objects(review_req1, review_req2, review_req3)
self.assertEqual(review_req1.state_id, "no-response")
self.assertEqual(review_req2.state_id, "requested")
self.assertEqual(review_req2.reviewer, new_reviewer)
self.assertEqual(review_req3.state_id, "requested")
def test_email_open_review_assignments(self):
doc = make_test_data()
review_req1 = make_review_data(doc)
group = review_req1.team
url = urlreverse(ietf.group.views_review.email_open_review_assignments, kwargs={ 'acronym': group.acronym })
login_testing_unauthorized(self, "secretary", url)
url = urlreverse(ietf.group.views_review.email_open_review_assignments, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id })
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
generated_text = q("[name=body]").text()
self.assertTrue(review_req1.doc.name in generated_text)
self.assertTrue(unicode(Person.objects.get(user__username="marschairman")) in generated_text)
empty_outbox()
r = self.client.post(url, {
"to": group.list_email,
"subject": "Test subject",
"body": "Test body",
"action": "email",
})
self.assertEqual(r.status_code, 302)
self.assertEqual(len(outbox), 1)
self.assertTrue(group.list_email in outbox[0]["To"])
self.assertEqual(outbox[0]["subject"], "Test subject")
self.assertTrue("Test body" in outbox[0].get_payload(decode=True).decode("utf-8"))
def test_change_reviewer_settings(self):
doc = make_test_data()
review_req = make_review_data(doc)
review_req.reviewer = Email.objects.get(person__user__username="reviewer")
review_req.save()
reviewer = review_req.reviewer.person
url = urlreverse(ietf.group.views_review.change_reviewer_settings, kwargs={
"acronym": review_req.team.acronym,
"reviewer_email": review_req.reviewer_id,
})
login_testing_unauthorized(self, reviewer.user.username, url)
url = urlreverse(ietf.group.views_review.change_reviewer_settings, kwargs={
"group_type": review_req.team.type_id,
"acronym": review_req.team.acronym,
"reviewer_email": review_req.reviewer_id,
})
# get
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
# set settings
empty_outbox()
r = self.client.post(url, {
"action": "change_settings",
"min_interval": "7",
"filter_re": "test-[regexp]",
"skip_next": "2",
"remind_days_before_deadline": "6"
})
self.assertEqual(r.status_code, 302)
settings = ReviewerSettings.objects.get(person=reviewer, team=review_req.team)
self.assertEqual(settings.min_interval, 7)
self.assertEqual(settings.filter_re, "test-[regexp]")
self.assertEqual(settings.skip_next, 2)
self.assertEqual(settings.remind_days_before_deadline, 6)
self.assertEqual(len(outbox), 1)
self.assertTrue("reviewer availability" in outbox[0]["subject"].lower())
msg_content = outbox[0].get_payload(decode=True).decode("utf-8").lower()
self.assertTrue("frequency changed", msg_content)
self.assertTrue("skip next", msg_content)
# add unavailable period
start_date = datetime.date.today() + datetime.timedelta(days=10)
empty_outbox()
r = self.client.post(url, {
"action": "add_period",
'start_date': start_date.isoformat(),
'end_date': "",
'availability': "unavailable",
})
self.assertEqual(r.status_code, 302)
period = UnavailablePeriod.objects.get(person=reviewer, team=review_req.team, start_date=start_date)
self.assertEqual(period.end_date, None)
self.assertEqual(period.availability, "unavailable")
self.assertEqual(len(outbox), 1)
msg_content = outbox[0].get_payload(decode=True).decode("utf-8").lower()
self.assertTrue(start_date.isoformat(), msg_content)
self.assertTrue("indefinite", msg_content)
# end unavailable period
empty_outbox()
end_date = start_date + datetime.timedelta(days=10)
r = self.client.post(url, {
"action": "end_period",
'period_id': period.pk,
'end_date': end_date.isoformat(),
})
self.assertEqual(r.status_code, 302)
period = reload_db_objects(period)
self.assertEqual(period.end_date, end_date)
self.assertEqual(len(outbox), 1)
msg_content = outbox[0].get_payload(decode=True).decode("utf-8").lower()
self.assertTrue(start_date.isoformat(), msg_content)
self.assertTrue("indefinite", msg_content)
# delete unavailable period
empty_outbox()
r = self.client.post(url, {
"action": "delete_period",
'period_id': period.pk,
})
self.assertEqual(r.status_code, 302)
self.assertEqual(UnavailablePeriod.objects.filter(person=reviewer, team=review_req.team, start_date=start_date).count(), 0)
self.assertEqual(len(outbox), 1)
msg_content = outbox[0].get_payload(decode=True).decode("utf-8").lower()
self.assertTrue(start_date.isoformat(), msg_content)
self.assertTrue(end_date.isoformat(), msg_content)
def test_change_review_secretary_settings(self):
doc = make_test_data()
review_req = make_review_data(doc)
secretary = Person.objects.get(user__username="reviewsecretary")
url = urlreverse(ietf.group.views_review.change_review_secretary_settings, kwargs={
"acronym": review_req.team.acronym,
})
login_testing_unauthorized(self, secretary.user.username, url)
url = urlreverse(ietf.group.views_review.change_review_secretary_settings, kwargs={
"group_type": review_req.team.type_id,
"acronym": review_req.team.acronym,
})
# get
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
# set settings
r = self.client.post(url, {
"remind_days_before_deadline": "6"
})
self.assertEqual(r.status_code, 302)
settings = ReviewSecretarySettings.objects.get(person=secretary, team=review_req.team)
self.assertEqual(settings.remind_days_before_deadline, 6)
def test_review_reminders(self):
doc = make_test_data()
review_req = make_review_data(doc)
remind_days = 6
reviewer = Person.objects.get(user__username="reviewer")
reviewer_settings = ReviewerSettings.objects.get(team=review_req.team, person=reviewer)
reviewer_settings.remind_days_before_deadline = remind_days
reviewer_settings.save()
secretary = Person.objects.get(user__username="reviewsecretary")
secretary_role = Role.objects.get(group=review_req.team, name="secr", person=secretary)
secretary_settings = ReviewSecretarySettings(team=review_req.team, person=secretary)
secretary_settings.remind_days_before_deadline = remind_days
secretary_settings.save()
today = datetime.date.today()
review_req.reviewer = reviewer.email_set.first()
review_req.deadline = today + datetime.timedelta(days=remind_days)
review_req.save()
# reviewer
needing_reminders = review_requests_needing_reviewer_reminder(today - datetime.timedelta(days=1))
self.assertEqual(list(needing_reminders), [])
needing_reminders = review_requests_needing_reviewer_reminder(today)
self.assertEqual(list(needing_reminders), [review_req])
needing_reminders = review_requests_needing_reviewer_reminder(today + datetime.timedelta(days=1))
self.assertEqual(list(needing_reminders), [])
# secretary
needing_reminders = review_requests_needing_secretary_reminder(today - datetime.timedelta(days=1))
self.assertEqual(list(needing_reminders), [])
needing_reminders = review_requests_needing_secretary_reminder(today)
self.assertEqual(list(needing_reminders), [(review_req, secretary_role)])
needing_reminders = review_requests_needing_secretary_reminder(today + datetime.timedelta(days=1))
self.assertEqual(list(needing_reminders), [])
# email reviewer
empty_outbox()
email_reviewer_reminder(review_req)
self.assertEqual(len(outbox), 1)
self.assertTrue(review_req.doc_id in outbox[0].get_payload(decode=True).decode("utf-8"))
# email secretary
empty_outbox()
email_secretary_reminder(review_req, secretary_role)
self.assertEqual(len(outbox), 1)
self.assertTrue(review_req.doc_id in outbox[0].get_payload(decode=True).decode("utf-8"))

View file

@ -1,6 +1,6 @@
from django.conf.urls import patterns, url
from django.views.generic import RedirectView
import views
from ietf.group import views, views_review
urlpatterns = patterns('',
(r'^$', 'ietf.group.views.group_home', None, "group_home"),
@ -30,5 +30,11 @@ urlpatterns = patterns('',
(r'^materials/new/(?P<doc_type>[\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.review_requests),
(r'^reviews/manage/(?P<assignment_status>assigned|unassigned)/$', views_review.manage_review_requests),
(r'^reviews/email-assignments/$', views_review.email_open_review_assignments),
(r'^reviewers/$', views_review.reviewer_overview),
(r'^reviewers/(?P<reviewer_email>[\w%+-.@]+)/settings/$', views_review.change_reviewer_settings),
(r'^secretarysettings/$', views_review.change_review_secretary_settings),
url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'),
)

View file

@ -1,16 +1,19 @@
import os
from django.shortcuts import get_object_or_404
from django.utils.safestring import mark_safe
from django.core.urlresolvers import reverse as urlreverse
import debug # pyflakes:ignore
from ietf.group.models import Group, RoleHistory
from ietf.group.models import Group, RoleHistory, Role
from ietf.person.models import Email
from ietf.utils.history import get_history_object_for, copy_many_to_many_for_history
from ietf.ietfauth.utils import has_role
from ietf.community.models import CommunityList, SearchRule
from ietf.community.utils import reset_name_contains_index_for_rule
from ietf.doc.models import State
from ietf.community.utils import reset_name_contains_index_for_rule, can_manage_community_list
from ietf.doc.models import Document, State
from ietf.review.utils import can_manage_review_requests_for_team
def save_group_in_history(group):
@ -149,3 +152,91 @@ def setup_default_community_list_for_group(group):
state=State.objects.get(slug="active", type="draft"),
)
reset_name_contains_index_for_rule(related_docs_rule)
def get_group_materials(group):
return Document.objects.filter(
group=group,
type__in=group.features.material_types
).exclude(states__slug__in=['deleted','archived'])
def construct_group_menu_context(request, group, selected, group_type, others):
"""Return context with info for the group menu filled in."""
kwargs = dict(acronym=group.acronym)
if group_type:
kwargs["group_type"] = group_type
# menu entries
entries = []
if group.features.has_documents:
entries.append(("Documents", urlreverse("ietf.group.views.group_documents", kwargs=kwargs)))
if group.features.has_chartering_process:
entries.append(("Charter", urlreverse("group_charter", kwargs=kwargs)))
else:
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:
import ietf.group.views_review
entries.append(("Review requests", urlreverse(ietf.group.views_review.review_requests, kwargs=kwargs)))
entries.append(("Reviewers", urlreverse(ietf.group.views_review.reviewer_overview, 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)))
entries.append(("Photos", urlreverse("ietf.group.views.group_photos", kwargs=kwargs)))
entries.append(("Email expansions", urlreverse("ietf.group.views.email", kwargs=kwargs)))
if group.list_archive.startswith("http:") or group.list_archive.startswith("https:") or group.list_archive.startswith("ftp:"):
if 'mailarchive.ietf.org' in group.list_archive:
entries.append(("List archive", urlreverse("ietf.group.views.derived_archives", kwargs=kwargs)))
else:
entries.append((mark_safe("List archive &raquo;"), group.list_archive))
if group.has_tools_page():
entries.append((mark_safe("Tools &raquo;"), "https://tools.ietf.org/%s/%s/" % (group.type_id, group.acronym)))
# actions
actions = []
is_admin = group.has_role(request.user, group.features.admin_roles)
can_manage = can_manage_group(request.user, group)
if group.features.has_milestones:
if group.state_id != "proposed" and (is_admin or can_manage):
actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs)))
if group.features.has_documents:
clist = CommunityList.objects.filter(group=group).first()
if clist and can_manage_community_list(request.user, clist):
import ietf.community.views
actions.append((u'Manage document list', urlreverse(ietf.community.views.manage_list, kwargs=kwargs)))
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 and can_manage_review_requests_for_team(request.user, group):
import ietf.group.views_review
actions.append((u"Manage unassigned reviews", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=dict(assignment_status="unassigned", **kwargs))))
actions.append((u"Manage assigned reviews", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=dict(assignment_status="assigned", **kwargs))))
if Role.objects.filter(name="secr", group=group, person__user=request.user).exists():
actions.append((u"Secretary settings", urlreverse(ietf.group.views_review.change_review_secretary_settings, kwargs=kwargs)))
if group.state_id != "conclude" and (is_admin or can_manage):
actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs)))
if group.features.customize_workflow and (is_admin or can_manage):
actions.append((u"Customize workflow", urlreverse("ietf.group.views_edit.customize_workflow", kwargs=kwargs)))
if group.state_id in ("active", "dormant") and not group.type_id in ["sdo", "rfcedtyp", "isoc", ] and can_manage:
actions.append((u"Request closing group", urlreverse("ietf.group.views_edit.conclude", kwargs=kwargs)))
d = {
"group": group,
"selected_menu_entry": selected,
"menu_entries": entries,
"menu_actions": actions,
"group_type": group_type,
}
d.update(others)
return d

View file

@ -49,18 +49,19 @@ from django.conf import settings
from django.core.urlresolvers import reverse as urlreverse
from django.views.decorators.cache import cache_page
from django.db.models import Q
from django.utils.safestring import mark_safe
from ietf.doc.models import Document, State, DocAlias, RelatedDocument
from ietf.doc.models import State, DocAlias, RelatedDocument
from ietf.doc.utils import get_chartering_type
from ietf.doc.templatetags.ietf_filters import clean_whitespace
from ietf.doc.utils_search import prepare_document_table
from ietf.doc.utils_charter import charter_name_for_group
from ietf.group.models import Group, Role, ChangeStateGroupEvent
from ietf.name.models import GroupTypeName
from ietf.group.utils import get_charter_text, can_manage_group_type, can_manage_group, milestone_reviewer_for_group_type, can_provide_status_update
from ietf.group.utils import can_manage_materials, get_group_or_404
from ietf.community.utils import docs_tracked_by_community_list, can_manage_community_list
from ietf.group.utils import (get_charter_text, can_manage_group_type, can_manage_group,
milestone_reviewer_for_group_type, can_provide_status_update,
can_manage_materials, get_group_or_404,
construct_group_menu_context, get_group_materials)
from ietf.community.utils import docs_tracked_by_community_list
from ietf.community.models import CommunityList, EmailSubscription
from ietf.utils.pipe import pipe
from ietf.utils.textupload import get_cleaned_text_file_content
@ -70,6 +71,7 @@ from ietf.ietfauth.utils import has_role
from ietf.meeting.utils import group_sessions
from ietf.meeting.helpers import get_meeting
def roles(group, role_name):
return Role.objects.filter(group=group, name=role_name).select_related("email", "person")
@ -327,79 +329,6 @@ def concluded_groups(request):
return render(request, 'group/concluded_groups.html',
dict(sections=sections))
def get_group_materials(group):
# return Document.objects.filter(group=group, type__in=group.features.material_types, session=None).exclude(states__slug="deleted")
return Document.objects.filter(group=group, type__in=group.features.material_types).exclude(states__slug__in=['deleted','archived'])
def construct_group_menu_context(request, group, selected, group_type, others):
"""Return context with info for the group menu filled in."""
kwargs = dict(acronym=group.acronym)
if group_type:
kwargs["group_type"] = group_type
# menu entries
entries = []
if group.features.has_documents:
entries.append(("Documents", urlreverse("ietf.group.views.group_documents", kwargs=kwargs)))
if group.features.has_chartering_process:
entries.append(("Charter", urlreverse("group_charter", kwargs=kwargs)))
else:
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.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)))
entries.append(("Photos", urlreverse("ietf.group.views.group_photos", kwargs=kwargs)))
entries.append(("Email expansions", urlreverse("ietf.group.views.email", kwargs=kwargs)))
if group.list_archive.startswith("http:") or group.list_archive.startswith("https:") or group.list_archive.startswith("ftp:"):
if 'mailarchive.ietf.org' in group.list_archive:
entries.append(("List archive", urlreverse("ietf.group.views.derived_archives", kwargs=kwargs)))
else:
entries.append((mark_safe("List archive &raquo;"), group.list_archive))
if group.has_tools_page():
entries.append((mark_safe("Tools &raquo;"), "https://tools.ietf.org/%s/%s/" % (group.type_id, group.acronym)))
# actions
actions = []
is_chair = group.has_role(request.user, "chair")
can_manage = can_manage_group(request.user, group)
if group.features.has_milestones:
if group.state_id != "proposed" and (is_chair or can_manage):
actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs)))
if group.features.has_documents:
clist = CommunityList.objects.filter(group=group).first()
if clist and can_manage_community_list(request.user, clist):
import ietf.community.views
actions.append((u'Manage document list', urlreverse(ietf.community.views.manage_list, kwargs=kwargs)))
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.state_id != "conclude" and (is_chair or can_manage):
actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs)))
if group.features.customize_workflow and (is_chair or can_manage):
actions.append((u"Customize workflow", urlreverse("ietf.group.views_edit.customize_workflow", kwargs=kwargs)))
if group.state_id in ("active", "dormant") and not group.type_id in ["sdo", "rfcedtyp", "isoc", ] and can_manage:
actions.append((u"Request closing group", urlreverse("ietf.group.views_edit.conclude", kwargs=kwargs)))
d = {
"group": group,
"selected_menu_entry": selected,
"menu_entries": entries,
"menu_actions": actions,
"group_type": group_type,
}
d.update(others)
return d
def prepare_group_documents(request, group, clist):
found_docs, meta = prepare_document_table(request, docs_tracked_by_community_list(clist), request.GET)

View file

@ -23,19 +23,31 @@ from ietf.person.fields import SearchableEmailsField
from ietf.person.models import Person, Email
from ietf.group.mails import ( email_admin_re_charter, email_personnel_change)
from ietf.utils.ordereddict import insert_after_in_ordered_dict
from ietf.utils.text import strip_suffix
MAX_GROUP_DELEGATES = 3
def roles_for_group_type(group_type):
roles = ["chair", "secr", "techadv", "delegate"]
if group_type == "dir":
roles.append("reviewer")
return roles
class GroupForm(forms.Form):
name = forms.CharField(max_length=255, label="Name", required=True)
acronym = forms.CharField(max_length=10, label="Acronym", required=True)
state = forms.ModelChoiceField(GroupStateName.objects.all(), label="State", required=True)
chairs = SearchableEmailsField(label="Chairs", required=False, only_users=True)
secretaries = SearchableEmailsField(label="Secretaries", required=False, only_users=True)
techadv = SearchableEmailsField(label="Technical Advisors", required=False, only_users=True)
delegates = SearchableEmailsField(label="Delegates", required=False, only_users=True, max_entries=MAX_GROUP_DELEGATES,
# roles
chair_roles = SearchableEmailsField(label="Chairs", required=False, only_users=True)
secr_roles = SearchableEmailsField(label="Secretaries", required=False, only_users=True)
techadv_roles = SearchableEmailsField(label="Technical Advisors", required=False, only_users=True)
delegate_roles = SearchableEmailsField(label="Delegates", required=False, only_users=True, max_entries=MAX_GROUP_DELEGATES,
help_text=mark_safe("Chairs can delegate the authority to update the state of group documents - at most %s persons at a given time." % MAX_GROUP_DELEGATES))
reviewer_roles = SearchableEmailsField(label="Reviewers", required=False, only_users=True)
ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active", role__group__type='area').order_by('name'), label="Shepherding AD", empty_label="(None)", required=False)
parent = forms.ModelChoiceField(Group.objects.filter(state="active").order_by('name'), empty_label="(None)", required=False)
list_email = forms.CharField(max_length=64, required=False)
list_subscribe = forms.CharField(max_length=255, required=False)
@ -69,6 +81,11 @@ class GroupForm(forms.Form):
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(type="area")
self.fields['parent'].label = "IETF Area"
role_fields_to_remove = (set(strip_suffix(attr, "_roles") for attr in self.fields if attr.endswith("_roles"))
- set(roles_for_group_type(self.group_type)))
for r in role_fields_to_remove:
del self.fields[r + "_roles"]
def clean_acronym(self):
# Changing the acronym of an already existing group will cause 404s all
# over the place, loose history, and generally muck up a lot of
@ -219,7 +236,8 @@ def edit(request, group_type=None, acronym=None, action="edit"):
group = get_group_or_404(acronym, group_type)
if not group_type and group:
group_type = group.type_id
if not (can_manage_group(request.user, group) or group.has_role(request.user, "chair")):
if not (can_manage_group(request.user, group)
or group.has_role(request.user, group.features.admin_roles)):
return HttpResponseForbidden("You don't have permission to access this view")
if request.method == 'POST':
@ -283,10 +301,18 @@ def edit(request, group_type=None, acronym=None, action="edit"):
personnel_change_text=""
changed_personnel = set()
# update roles
for attr, slug, title in [('ad','ad','Shepherding AD'), ('chairs', 'chair', "Chairs"), ('secretaries', 'secr', "Secretaries"), ('techadv', 'techadv', "Tech Advisors"), ('delegates', 'delegate', "Delegates")]:
for attr, f in form.fields.iteritems():
if not (attr.endswith("_roles") or attr == "ad"):
continue
slug = attr
slug = strip_suffix(slug, "_roles")
title = f.label
new = clean[attr]
if attr == 'ad':
new = [ new.role_email('ad'),] if new else []
new = [ new.role_email('ad') ] if new else []
old = Email.objects.filter(role__group=group, role__name=slug).select_related("person")
if set(new) != set(old):
changes.append((attr, new, desc(title,
@ -345,10 +371,6 @@ def edit(request, group_type=None, acronym=None, action="edit"):
init = dict(name=group.name,
acronym=group.acronym,
state=group.state,
chairs=Email.objects.filter(role__group=group, role__name="chair"),
secretaries=Email.objects.filter(role__group=group, role__name="secr"),
techadv=Email.objects.filter(role__group=group, role__name="techadv"),
delegates=Email.objects.filter(role__group=group, role__name="delegate"),
ad=ad_role and ad_role.person and ad_role.person.id,
parent=group.parent.id if group.parent else None,
list_email=group.list_email if group.list_email else None,
@ -356,6 +378,9 @@ def edit(request, group_type=None, acronym=None, action="edit"):
list_archive=group.list_archive if group.list_archive else None,
urls=format_urls(group.groupurl_set.all()),
)
for slug in roles_for_group_type(group_type):
init[slug + "_roles"] = Email.objects.filter(role__group=group, role__name=slug)
else:
init = dict(ad=request.user.person.id if group_type == "wg" and has_role(request.user, "Area Director") else None,
)
@ -409,8 +434,8 @@ def customize_workflow(request, group_type=None, acronym=None):
if not group.features.customize_workflow:
raise Http404
if (not has_role(request.user, "Secretariat") and
not group.role_set.filter(name="chair", person__user=request.user)):
if not (can_manage_group(request.user, group)
or group.has_role(request.user, group.features.admin_roles)):
return HttpResponseForbidden("You don't have permission to access this view")
if group_type == "rg":

606
ietf/group/views_review.py Normal file
View file

@ -0,0 +1,606 @@
import datetime, math
from collections import defaultdict
from django.shortcuts import render, redirect, get_object_or_404
from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse as urlreverse
from django import forms
from django.template.loader import render_to_string
from ietf.review.models import ReviewRequest, ReviewerSettings, UnavailablePeriod, ReviewSecretarySettings
from ietf.review.utils import (can_manage_review_requests_for_team,
can_access_review_stats_for_team,
close_review_request_states,
extract_revision_ordered_review_requests_for_documents_and_replaced,
assign_review_request_to_reviewer,
close_review_request,
setup_reviewer_field,
suggested_review_requests_for_team,
unavailable_periods_to_list,
current_unavailable_periods_for_reviewers,
email_reviewer_availability_change,
reviewer_rotation_list,
latest_review_requests_for_reviewers,
augment_review_requests_with_events)
from ietf.group.models import Role
from ietf.group.utils import get_group_or_404, construct_group_menu_context
from ietf.person.fields import PersonEmailChoiceField
from ietf.name.models import ReviewRequestStateName
from ietf.utils.mail import send_mail_text
from ietf.utils.fields import DatepickerDateField
from ietf.ietfauth.utils import user_is_person
def get_open_review_requests_for_team(team, assignment_status=None):
open_review_requests = ReviewRequest.objects.filter(
team=team,
state__in=("requested", "accepted")
).prefetch_related(
"reviewer__person", "type", "state", "doc", "doc__states",
).order_by("-time", "-id")
if assignment_status == "unassigned":
open_review_requests = suggested_review_requests_for_team(team) + list(open_review_requests.filter(reviewer=None))
elif assignment_status == "assigned":
open_review_requests = list(open_review_requests.exclude(reviewer=None))
else:
open_review_requests = suggested_review_requests_for_team(team) + list(open_review_requests)
today = datetime.date.today()
unavailable_periods = current_unavailable_periods_for_reviewers(team)
for r in open_review_requests:
if r.reviewer:
r.reviewer_unavailable = any(p.availability == "unavailable"
for p in unavailable_periods.get(r.reviewer.person_id, []))
r.due = max(0, (today - r.deadline).days)
return open_review_requests
def review_requests(request, acronym, group_type=None):
group = get_group_or_404(acronym, group_type)
if not group.features.has_reviews:
raise Http404
assigned_review_requests = []
unassigned_review_requests = []
for r in get_open_review_requests_for_team(group):
if r.reviewer:
assigned_review_requests.append(r)
else:
unassigned_review_requests.append(r)
open_review_requests = [
("Unassigned", unassigned_review_requests),
("Assigned", assigned_review_requests),
]
closed_review_requests = ReviewRequest.objects.filter(
team=group,
).exclude(
state__in=("requested", "accepted")
).prefetch_related("reviewer__person", "type", "state", "doc", "result").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),
"can_access_stats": can_access_review_stats_for_team(request.user, group),
}))
def reviewer_overview(request, acronym, group_type=None):
group = get_group_or_404(acronym, group_type)
if not group.features.has_reviews:
raise Http404
can_manage = can_manage_review_requests_for_team(request.user, group)
reviewers = reviewer_rotation_list(group)
reviewer_settings = { s.person_id: s for s in ReviewerSettings.objects.filter(team=group) }
unavailable_periods = defaultdict(list)
for p in unavailable_periods_to_list().filter(team=group):
unavailable_periods[p.person_id].append(p)
reviewer_roles = { r.person_id: r for r in Role.objects.filter(group=group, name="reviewer").select_related("email") }
today = datetime.date.today()
req_data_for_reviewers = latest_review_requests_for_reviewers(group)
review_state_by_slug = { n.slug: n for n in ReviewRequestStateName.objects.all() }
for person in reviewers:
person.settings = reviewer_settings.get(person.pk) or ReviewerSettings(team=group, person=person)
person.settings_url = None
person.role = reviewer_roles.get(person.pk)
if person.role and (can_manage or user_is_person(request.user, person)):
kwargs = { "acronym": group.acronym, "reviewer_email": person.role.email.address }
if group_type:
kwargs["group_type"] = group_type
person.settings_url = urlreverse("ietf.group.views_review.change_reviewer_settings", kwargs=kwargs)
person.unavailable_periods = unavailable_periods.get(person.pk, [])
person.completely_unavailable = any(p.availability == "unavailable"
and (p.start_date is None or p.start_date <= today) and (p.end_date is None or today <= p.end_date)
for p in person.unavailable_periods)
MAX_CLOSED_REQS = 10
req_data = req_data_for_reviewers.get(person.pk, [])
open_reqs = sum(1 for d in req_data if d.state in ["requested", "accepted"])
latest_reqs = []
for d in req_data:
if d.state in ["requested", "accepted"] or len(latest_reqs) < MAX_CLOSED_REQS + open_reqs:
latest_reqs.append((d.req_pk, d.doc, d.reviewed_rev, d.deadline,
review_state_by_slug.get(d.state),
int(math.ceil(d.assignment_to_closure_days)) if d.assignment_to_closure_days is not None else None))
person.latest_reqs = latest_reqs
return render(request, 'group/reviewer_overview.html',
construct_group_menu_context(request, group, "reviewers", group_type, {
"reviewers": reviewers,
"can_access_stats": can_access_review_stats_for_team(request.user, group)
}))
class ManageReviewRequestForm(forms.Form):
ACTIONS = [
("assign", "Assign"),
("close", "Close"),
]
action = forms.ChoiceField(choices=ACTIONS, widget=forms.HiddenInput, required=False)
close = forms.ModelChoiceField(queryset=close_review_request_states(), 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)
if review_req.pk is None:
self.fields["close"].queryset = self.fields["close"].queryset.filter(slug__in=["no-review-version", "no-review-document"])
close_initial = None
if review_req.pk is None:
close_initial = "no-review-version"
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"
setup_reviewer_field(self.fields["reviewer"], review_req)
self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm"
if self.is_bound:
if self.data.get("action") == "close":
self.fields["close"].required = True
@login_required
def manage_review_requests(request, acronym, group_type=None, assignment_status=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 = get_open_review_requests_for_team(group, assignment_status=assignment_status)
document_requests = extract_revision_ordered_review_requests_for_documents_and_replaced(
ReviewRequest.objects.filter(state__in=("part-completed", "completed"), team=group).prefetch_related("result"),
set(r.doc_id for r in review_requests),
)
# we need a mutable query dict for resetting upon saving with
# conflicts
query_dict = request.POST.copy() if request.method == "POST" else None
for req in review_requests:
req.form = ManageReviewRequestForm(req, query_dict)
# add previous requests
l = []
for r in document_requests.get(req.doc_id, []):
# take all on the latest reviewed rev
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]
augment_review_requests_with_events(l)
req.latest_reqs = l
saving = False
newly_closed = newly_opened = newly_assigned = 0
if request.method == "POST":
form_action = request.POST.get("action", "")
saving = form_action.startswith("save")
# check for conflicts
review_requests_dict = { unicode(r.pk): r for r in review_requests }
posted_reqs = set(request.POST.getlist("reviewrequest", []))
current_reqs = set(review_requests_dict.iterkeys())
closed_reqs = posted_reqs - current_reqs
newly_closed = len(closed_reqs)
opened_reqs = current_reqs - posted_reqs
newly_opened = len(opened_reqs)
for r in opened_reqs:
review_requests_dict[r].form.add_error(None, "New request.")
for req in review_requests:
existing_reviewer = request.POST.get(req.form.prefix + "-existing_reviewer")
if existing_reviewer is None:
continue
if existing_reviewer != unicode(req.reviewer_id or ""):
msg = "Assignment was changed."
a = req.form["action"].value()
if a == "assign":
msg += " Didn't assign reviewer."
elif a == "close":
msg += " Didn't close request."
req.form.add_error(None, msg)
req.form.data[req.form.prefix + "-action"] = "" # cancel the action
newly_assigned += 1
form_results = []
for req in review_requests:
form_results.append(req.form.is_valid())
if saving and all(form_results) and not (newly_closed > 0 or newly_opened > 0 or newly_assigned > 0):
for review_req in review_requests:
action = review_req.form.cleaned_data.get("action")
if action == "assign":
assign_review_request_to_reviewer(request, review_req, review_req.form.cleaned_data["reviewer"])
elif action == "close":
close_review_request(request, review_req, review_req.form.cleaned_data["close"])
kwargs = { "acronym": group.acronym }
if group_type:
kwargs["group_type"] = group_type
if form_action == "save-continue":
if assignment_status:
kwargs["assignment_status"] = assignment_status
return redirect(manage_review_requests, **kwargs)
else:
import ietf.group.views_review
return redirect(ietf.group.views_review.review_requests, **kwargs)
other_assignment_status = {
"unassigned": "assigned",
"assigned": "unassigned",
}.get(assignment_status)
return render(request, 'group/manage_review_requests.html', {
'group': group,
'review_requests': review_requests,
'newly_closed': newly_closed,
'newly_opened': newly_opened,
'newly_assigned': newly_assigned,
'saving': saving,
'assignment_status': assignment_status,
'other_assignment_status': other_assignment_status,
})
class EmailOpenAssignmentsForm(forms.Form):
to = forms.EmailField(widget=forms.EmailInput(attrs={ "readonly": True }))
subject = forms.CharField()
body = forms.CharField(widget=forms.Textarea)
@login_required
def email_open_review_assignments(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"),
).exclude(
reviewer=None,
).prefetch_related("reviewer", "type", "state", "doc").distinct().order_by("deadline", "reviewer"))
back_url = request.GET.get("next")
if not back_url:
kwargs = { "acronym": group.acronym }
if group_type:
kwargs["group_type"] = group_type
import ietf.group.views_review
back_url = urlreverse(ietf.group.views_review.review_requests, kwargs=kwargs)
if request.method == "POST" and request.POST.get("action") == "email":
form = EmailOpenAssignmentsForm(request.POST)
if form.is_valid():
send_mail_text(request, form.cleaned_data["to"], None, form.cleaned_data["subject"], form.cleaned_data["body"])
return HttpResponseRedirect(back_url)
else:
to = group.list_email
subject = "Open review assignments in {}".format(group.acronym)
body = render_to_string("group/email_open_review_assignments.txt", {
"review_requests": review_requests,
"rotation_list": reviewer_rotation_list(group)[:10],
})
form = EmailOpenAssignmentsForm(initial={
"to": to,
"subject": subject,
"body": body,
})
return render(request, 'group/email_open_review_assignments.html', {
'group': group,
'review_requests': review_requests,
'form': form,
'back_url': back_url,
})
class ReviewerSettingsForm(forms.ModelForm):
class Meta:
model = ReviewerSettings
fields = ['min_interval', 'filter_re', 'skip_next', 'remind_days_before_deadline']
class AddUnavailablePeriodForm(forms.ModelForm):
class Meta:
model = UnavailablePeriod
fields = ['start_date', 'end_date', 'availability']
def __init__(self, *args, **kwargs):
super(AddUnavailablePeriodForm, self).__init__(*args, **kwargs)
self.fields["start_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label=self.fields["start_date"].label, help_text=self.fields["start_date"].help_text, required=self.fields["start_date"].required)
self.fields["end_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label=self.fields["end_date"].label, help_text=self.fields["end_date"].help_text, required=self.fields["end_date"].required)
self.fields['availability'].widget = forms.RadioSelect(choices=UnavailablePeriod.LONG_AVAILABILITY_CHOICES)
def clean(self):
start = self.cleaned_data.get("start_date")
end = self.cleaned_data.get("end_date")
if start and end and start > end:
self.add_error("start_date", "Start date must be before or equal to end date.")
return self.cleaned_data
class EndUnavailablePeriodForm(forms.Form):
def __init__(self, start_date, *args, **kwargs):
super(EndUnavailablePeriodForm, self).__init__(*args, **kwargs)
self.fields["end_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1", "start-date": start_date.isoformat() if start_date else "" })
self.start_date = start_date
def clean_end_date(self):
end = self.cleaned_data["end_date"]
if self.start_date and end < self.start_date:
raise forms.ValidationError("End date must be equal to or come after start date.")
return end
@login_required
def change_reviewer_settings(request, acronym, reviewer_email, group_type=None):
group = get_group_or_404(acronym, group_type)
if not group.features.has_reviews:
raise Http404
reviewer_role = get_object_or_404(Role, name="reviewer", group=group, email=reviewer_email)
reviewer = reviewer_role.person
if not (user_is_person(request.user, reviewer)
or can_manage_review_requests_for_team(request.user, group)):
return HttpResponseForbidden("You do not have permission to perform this action")
settings = (ReviewerSettings.objects.filter(person=reviewer, team=group).first()
or ReviewerSettings(person=reviewer, team=group))
back_url = request.GET.get("next")
if not back_url:
import ietf.group.views_review
kwargs = { "acronym": group.acronym}
if group_type:
kwargs["group_type"] = group_type
back_url = urlreverse(ietf.group.views_review.reviewer_overview, kwargs=kwargs)
# settings
if request.method == "POST" and request.POST.get("action") == "change_settings":
prev_min_interval = settings.get_min_interval_display()
prev_skip_next = settings.skip_next
settings_form = ReviewerSettingsForm(request.POST, instance=settings)
if settings_form.is_valid():
settings = settings_form.save()
changes = []
if settings.get_min_interval_display() != prev_min_interval:
changes.append("Frequency changed to \"{}\" from \"{}\".".format(settings.get_min_interval_display() or "Not specified", prev_min_interval or "Not specified"))
if settings.skip_next != prev_skip_next:
changes.append("Skip next assignments changed to {} from {}.".format(settings.skip_next, prev_skip_next))
if changes:
email_reviewer_availability_change(request, group, reviewer_role, "\n\n".join(changes), request.user.person)
return HttpResponseRedirect(back_url)
else:
settings_form = ReviewerSettingsForm(instance=settings)
# periods
unavailable_periods = unavailable_periods_to_list().filter(person=reviewer, team=group)
if request.method == "POST" and request.POST.get("action") == "add_period":
period_form = AddUnavailablePeriodForm(request.POST)
if period_form.is_valid():
period = period_form.save(commit=False)
period.team = group
period.person = reviewer
period.save()
today = datetime.date.today()
in_the_past = period.end_date and period.end_date < today
if not in_the_past:
msg = "Unavailable for review: {} - {} ({})".format(
period.start_date.isoformat() if period.start_date else "indefinite",
period.end_date.isoformat() if period.end_date else "indefinite",
period.get_availability_display(),
)
if period.availability == "unavailable":
# the secretary might need to reassign
# assignments, so mention the current ones
review_reqs = ReviewRequest.objects.filter(state__in=["requested", "accepted"], reviewer=reviewer_role.email, team=group)
msg += "\n\n"
if review_reqs:
msg += "{} is currently assigned to review:".format(reviewer_role.person)
for r in review_reqs:
msg += "\n\n"
msg += "{} (deadline: {})".format(r.doc_id, r.deadline.isoformat())
else:
msg += "{} does not have any assignments currently.".format(reviewer_role.person)
email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person)
return HttpResponseRedirect(request.get_full_path())
else:
period_form = AddUnavailablePeriodForm()
if request.method == "POST" and request.POST.get("action") == "delete_period":
period_id = request.POST.get("period_id")
if period_id is not None:
for period in unavailable_periods:
if str(period.pk) == period_id:
period.delete()
today = datetime.date.today()
in_the_past = period.end_date and period.end_date < today
if not in_the_past:
msg = "Removed unavailable period: {} - {} ({})".format(
period.start_date.isoformat() if period.start_date else "indefinite",
period.end_date.isoformat() if period.end_date else "indefinite",
period.get_availability_display(),
)
email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person)
return HttpResponseRedirect(request.get_full_path())
for p in unavailable_periods:
if not p.end_date:
p.end_form = EndUnavailablePeriodForm(p.start_date, request.POST if request.method == "POST" and request.POST.get("action") == "end_period" else None)
if request.method == "POST" and request.POST.get("action") == "end_period":
period_id = request.POST.get("period_id")
for period in unavailable_periods:
if str(period.pk) == period_id:
if not period.end_date and period.end_form.is_valid():
period.end_date = period.end_form.cleaned_data["end_date"]
period.save()
msg = "Set end date of unavailable period: {} - {} ({})".format(
period.start_date.isoformat() if period.start_date else "indefinite",
period.end_date.isoformat() if period.end_date else "indefinite",
period.get_availability_display(),
)
email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person)
return HttpResponseRedirect(request.get_full_path())
return render(request, 'group/change_reviewer_settings.html', {
'group': group,
'reviewer_email': reviewer_email,
'back_url': back_url,
'settings_form': settings_form,
'period_form': period_form,
'unavailable_periods': unavailable_periods,
})
class ReviewSecretarySettingsForm(forms.ModelForm):
class Meta:
model = ReviewSecretarySettings
fields = ['remind_days_before_deadline']
@login_required
def change_review_secretary_settings(request, acronym, group_type=None):
group = get_group_or_404(acronym, group_type)
if not group.features.has_reviews:
raise Http404
if not Role.objects.filter(name="secr", group=group, person__user=request.user).exists():
raise Http404
person = request.user.person
settings = (ReviewSecretarySettings.objects.filter(person=person, team=group).first()
or ReviewSecretarySettings(person=person, team=group))
import ietf.group.views_review
back_url = urlreverse(ietf.group.views_review.review_requests, kwargs={ "acronym": acronym, "group_type": group.type_id })
# settings
if request.method == "POST":
settings_form = ReviewSecretarySettingsForm(request.POST, instance=settings)
if settings_form.is_valid():
settings_form.save()
return HttpResponseRedirect(back_url)
else:
settings_form = ReviewSecretarySettingsForm(instance=settings)
return render(request, 'group/change_review_secretary_settings.html', {
'group': group,
'back_url': back_url,
'settings_form': settings_form,
})

View file

@ -9,7 +9,7 @@ from django.http import Http404
from ietf.doc.models import Document, TelechatDocEvent, LastCallDocEvent, ConsensusDocEvent
from ietf.iesg.models import TelechatDate, TelechatAgendaItem
from ietf.review.utils import review_requests_to_list_for_docs
def get_agenda_date(date=None):
if not date:
@ -152,6 +152,8 @@ def fill_in_agenda_docs(date, sections, matches=None):
matches = Document.objects.filter(docevent__telechatdocevent__telechat_date=date)
matches = matches.select_related("stream", "group").distinct()
review_requests_for_docs = review_requests_to_list_for_docs(matches)
for doc in matches:
if doc.latest_event(TelechatDocEvent, type="scheduled_for_telechat").telechat_date != date:
continue
@ -174,6 +176,8 @@ def fill_in_agenda_docs(date, sections, matches=None):
e = doc.latest_event(ConsensusDocEvent, type="changed_consensus")
if e and (e.consensus != None):
doc.consensus = "Yes" if e.consensus else "No"
doc.review_requests = review_requests_for_docs.get(doc.pk, [])
elif doc.type_id == "conflrev":
doc.conflictdoc = doc.relateddocument_set.get(relationship__slug='conflrev').target.document
elif doc.type_id == "charter":

View file

@ -3,7 +3,6 @@ import re
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.forms import ModelForm
from django.db import models
from django.contrib.auth.models import User
from django.utils.html import mark_safe
@ -68,7 +67,7 @@ def get_person_form(*args, **kwargs):
if not roles:
exclude_list += ['biography', 'photo', ]
class PersonForm(ModelForm):
class PersonForm(forms.ModelForm):
class Meta:
model = Person
exclude = exclude_list
@ -161,7 +160,7 @@ class ResetPasswordForm(forms.Form):
class TestEmailForm(forms.Form):
email = forms.EmailField(required=False)
class WhitelistForm(ModelForm):
class WhitelistForm(forms.ModelForm):
class Meta:
model = Whitelisted
exclude = ['by', 'time' ]

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os, shutil, time
import os, shutil, time, datetime
from urlparse import urlsplit
from pyquery import PyQuery
from unittest import skipIf
@ -12,12 +12,13 @@ from django.contrib.auth.models import User
from django.conf import settings
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent
from ietf.utils.test_data import make_test_data
from ietf.utils.test_data import make_test_data, make_review_data
from ietf.utils.mail import outbox, empty_outbox
from ietf.person.models import Person, Email
from ietf.group.models import Group, Role, RoleName
from ietf.ietfauth.htpasswd import update_htpasswd_file
from ietf.mailinglists.models import Subscribed
from ietf.review.models import ReviewWish, UnavailablePeriod
from ietf.utils.decorators import skip_coverage
import ietf.ietfauth.views
@ -338,6 +339,48 @@ class IetfAuthTests(TestCase):
self.assertEqual(len(q("form .has-error")), 0)
self.assertTrue(self.username_in_htpasswd_file(user.username))
def test_review_overview(self):
doc = make_test_data()
review_req = make_review_data(doc)
review_req.reviewer = Email.objects.get(person__user__username="reviewer")
review_req.save()
reviewer = review_req.reviewer.person
UnavailablePeriod.objects.create(
team=review_req.team,
person=reviewer,
start_date=datetime.date.today() - datetime.timedelta(days=10),
availability="unavailable",
)
url = urlreverse(ietf.ietfauth.views.review_overview)
login_testing_unauthorized(self, reviewer.user.username, url)
# get
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertTrue(review_req.doc.name in unicontent(r))
# wish to review
r = self.client.post(url, {
"action": "add_wish",
'doc': doc.pk,
"team": review_req.team_id,
})
self.assertEqual(r.status_code, 302)
self.assertEqual(ReviewWish.objects.filter(doc=doc, team=review_req.team).count(), 1)
# delete wish
r = self.client.post(url, {
"action": "delete_wish",
'wish_id': ReviewWish.objects.get(doc=doc, team=review_req.team).pk,
})
self.assertEqual(r.status_code, 302)
self.assertEqual(ReviewWish.objects.filter(doc=doc, team=review_req.team).count(), 0)
def test_htpasswd_file_with_python(self):
# make sure we test both Python and call-out to binary
settings.USE_PYTHON_HTDIGEST = True

View file

@ -21,4 +21,5 @@ urlpatterns = patterns('ietf.ietfauth.views',
url(r'^reset/confirm/(?P<auth>[^/]+)/$', 'confirm_password_reset'),
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', 'confirm_new_email'),
(r'whitelist/add/?$', add_account_whitelist),
url(r'^review/$', 'review_overview'),
)

View file

@ -69,13 +69,14 @@ def has_role(user, role_names, *args, **kwargs):
"Nomcom": Q(person=person, group__type="nomcom", group__acronym__icontains=kwargs.get('year', '0000')),
"Liaison Manager": Q(person=person,name="liaiman",group__type="sdo",group__state="active", ),
"Authorized Individual": Q(person=person,name="auth",group__type="sdo",group__state="active", ),
"Reviewer": Q(person=person, name="reviewer", group__state="active"),
}
filter_expr = Q()
for r in role_names:
filter_expr |= role_qs[r]
user.roles_check_cache[key] = bool(Role.objects.filter(filter_expr)[:1])
user.roles_check_cache[key] = bool(Role.objects.filter(filter_expr).exists())
return user.roles_check_cache[key]

View file

@ -32,7 +32,8 @@
# Copyright The IETF Trust 2007, All Rights Reserved
from datetime import datetime as DateTime, timedelta as TimeDelta
from datetime import datetime as DateTime, timedelta as TimeDelta, date as Date
from collections import defaultdict
from django.conf import settings
from django.http import Http404 #, HttpResponse, HttpResponseRedirect
@ -43,17 +44,21 @@ from django.contrib.auth.decorators import login_required
import django.core.signing
from django.contrib.sites.models import Site
from django.contrib.auth.models import User
from django import forms
import debug # pyflakes:ignore
from ietf.group.models import Role
from ietf.group.models import Role, Group
from ietf.ietfauth.forms import RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm, WhitelistForm
from ietf.ietfauth.forms import get_person_form, RoleEmailForm, NewEmailForm
from ietf.ietfauth.htpasswd import update_htpasswd_file
from ietf.ietfauth.utils import role_required
from ietf.mailinglists.models import Subscribed, Whitelisted
from ietf.person.models import Person, Email, Alias
from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewWish
from ietf.review.utils import unavailable_periods_to_list
from ietf.utils.mail import send_mail
from ietf.doc.fields import SearchableDocumentField
def index(request):
return render(request, 'registration/index.html')
@ -389,3 +394,74 @@ def add_account_whitelist(request):
'success': success,
})
class AddReviewWishForm(forms.Form):
doc = SearchableDocumentField(label="Document", doc_type="draft")
team = forms.ModelChoiceField(queryset=Group.objects.all(), empty_label="(Choose review team)")
def __init__(self, teams, *args, **kwargs):
super(AddReviewWishForm, self).__init__(*args, **kwargs)
f = self.fields["team"]
f.queryset = teams
if len(f.queryset) == 1:
f.initial = f.queryset[0].pk
f.widget = forms.HiddenInput()
@login_required
def review_overview(request):
open_review_requests = ReviewRequest.objects.filter(
reviewer__person__user=request.user,
state__in=["requested", "accepted"],
)
today = Date.today()
for r in open_review_requests:
r.due = max(0, (today - r.deadline).days)
closed_review_requests = ReviewRequest.objects.filter(
reviewer__person__user=request.user,
state__in=["no-response", "part-completed", "completed"],
).order_by("-time")[:20]
teams = Group.objects.filter(role__name="reviewer", role__person__user=request.user, state="active")
settings = { o.team_id: o for o in ReviewerSettings.objects.filter(person__user=request.user, team__in=teams) }
unavailable_periods = defaultdict(list)
for o in unavailable_periods_to_list().filter(person__user=request.user, team__in=teams):
unavailable_periods[o.team_id].append(o)
roles = { o.group_id: o for o in Role.objects.filter(name="reviewer", person__user=request.user, group__in=teams) }
for t in teams:
t.reviewer_settings = settings.get(t.pk) or ReviewerSettings(team=t)
t.unavailable_periods = unavailable_periods.get(t.pk, [])
t.role = roles.get(t.pk)
if request.method == "POST" and request.POST.get("action") == "add_wish":
review_wish_form = AddReviewWishForm(teams, request.POST)
if review_wish_form.is_valid():
ReviewWish.objects.get_or_create(
person=request.user.person,
doc=review_wish_form.cleaned_data["doc"],
team=review_wish_form.cleaned_data["team"],
)
return redirect(review_overview)
else:
review_wish_form = AddReviewWishForm(teams)
if request.method == "POST" and request.POST.get("action") == "delete_wish":
wish_id = request.POST.get("wish_id")
if wish_id is not None:
ReviewWish.objects.filter(pk=wish_id, person=request.user.person).delete()
return redirect(review_overview)
review_wishes = ReviewWish.objects.filter(person__user=request.user).prefetch_related("team")
return render(request, 'ietfauth/review_overview.html', {
'open_review_requests': open_review_requests,
'closed_review_requests': closed_review_requests,
'teams': teams,
'review_wishes': review_wishes,
'review_wish_form': review_wish_form,
})

View file

@ -261,17 +261,17 @@ class MeetingTests(TestCase):
r = self.client.get(urlreverse("ietf.meeting.views.materials", kwargs=dict(num=meeting.number)))
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
row = q('#content td div:contains("%s")' % str(session.group.acronym)).closest("tr")
row = q('#content #%s' % str(session.group.acronym)).closest("tr")
self.assertTrue(row.find('a:contains("Agenda")'))
self.assertTrue(row.find('a:contains("Minutes")'))
self.assertTrue(row.find('a:contains("Slideshow")'))
self.assertFalse(row.find("a:contains(\"Bad Slideshow\")"))
#test with no meeting number in url
# test with no meeting number in url
r = self.client.get(urlreverse("ietf.meeting.views.materials", kwargs=dict()))
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
row = q('#content td div:contains("%s")' % str(session.group.acronym)).closest("tr")
row = q('#content #%s' % str(session.group.acronym)).closest("tr")
self.assertTrue(row.find('a:contains("Agenda")'))
self.assertTrue(row.find('a:contains("Minutes")'))
self.assertTrue(row.find('a:contains("Slideshow")'))
@ -544,8 +544,8 @@ class SessionDetailsTests(TestCase):
r = self.client.post(url,dict(drafts=[new_draft.name,old_draft.name]))
self.assertTrue(r.status_code, 200)
q=PyQuery(r.content)
self.assertTrue(q('form .alert-danger:contains("Already linked:")'))
q = PyQuery(r.content)
self.assertTrue("Already linked:" in q('form .alert-danger').text())
self.assertEqual(1,session.sessionpresentation_set.count())
r = self.client.post(url,dict(drafts=[new_draft.name,]))

View file

@ -1,14 +1,14 @@
from django.contrib import admin
from ietf.name.models import (
BallotPositionName, ConstraintName, DBTemplateTypeName,
DocRelationshipName, DocReminderTypeName, DocTagName, DocTypeName,
DraftSubmissionStateName, FeedbackTypeName, GroupMilestoneStateName,
GroupStateName, GroupTypeName, IntendedStdLevelName,
IprDisclosureStateName, IprEventTypeName, IprLicenseTypeName,
LiaisonStatementEventTypeName, LiaisonStatementPurposeName,
LiaisonStatementState, LiaisonStatementTagName, MeetingTypeName,
NomineePositionStateName, RoleName, RoomResourceName, SessionStatusName,
StdLevelName, StreamName, TimeSlotTypeName, )
BallotPositionName, ConstraintName, DBTemplateTypeName, DocRelationshipName,
DocReminderTypeName, DocTagName, DocTypeName, DraftSubmissionStateName,
FeedbackTypeName, GroupMilestoneStateName, GroupStateName, GroupTypeName,
IntendedStdLevelName, IprDisclosureStateName, IprEventTypeName, IprLicenseTypeName,
LiaisonStatementEventTypeName, LiaisonStatementPurposeName, LiaisonStatementState,
LiaisonStatementTagName, MeetingTypeName, NomineePositionStateName,
ReviewRequestStateName, ReviewResultName, ReviewTypeName, RoleName, RoomResourceName,
SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName, )
class NameAdmin(admin.ModelAdmin):
list_display = ["slug", "name", "desc", "used"]
@ -37,17 +37,21 @@ admin.site.register(GroupMilestoneStateName, NameAdmin)
admin.site.register(GroupStateName, NameAdmin)
admin.site.register(IntendedStdLevelName, NameAdmin)
admin.site.register(IprDisclosureStateName, NameAdmin)
admin.site.register(IprLicenseTypeName, NameAdmin)
admin.site.register(IprEventTypeName, NameAdmin)
admin.site.register(LiaisonStatementState, NameAdmin)
admin.site.register(IprLicenseTypeName, NameAdmin)
admin.site.register(LiaisonStatementEventTypeName, NameAdmin)
admin.site.register(LiaisonStatementPurposeName, NameAdmin)
admin.site.register(LiaisonStatementState, NameAdmin)
admin.site.register(LiaisonStatementTagName, NameAdmin)
admin.site.register(MeetingTypeName, NameAdmin)
admin.site.register(NomineePositionStateName, NameAdmin)
admin.site.register(ReviewRequestStateName, NameAdmin)
admin.site.register(ReviewResultName, NameAdmin)
admin.site.register(ReviewTypeName, NameAdmin)
admin.site.register(RoleName, NameAdmin)
admin.site.register(RoomResourceName, NameAdmin)
admin.site.register(SessionStatusName, NameAdmin)
admin.site.register(StdLevelName, NameAdmin)
admin.site.register(StreamName, NameAdmin)
admin.site.register(TimeSlotTypeName, NameAdmin)

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
#!/usr/bin/python
# simple script for exporting name related base data for the tests
# boiler plate
import os, sys
import django

View file

@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('group', '0008_auto_20160505_0523'),
('name', '0013_add_group_type_verbose_name_data'),
]
operations = [
migrations.CreateModel(
name='ReviewRequestStateName',
fields=[
('slug', models.CharField(max_length=32, serialize=False, primary_key=True)),
('name', models.CharField(max_length=255)),
('desc', models.TextField(blank=True)),
('used', models.BooleanField(default=True)),
('order', models.IntegerField(default=0)),
],
options={
'ordering': ['order'],
'abstract': False,
},
bases=(models.Model,),
),
migrations.CreateModel(
name='ReviewResultName',
fields=[
('slug', models.CharField(max_length=32, serialize=False, primary_key=True)),
('name', models.CharField(max_length=255)),
('desc', models.TextField(blank=True)),
('used', models.BooleanField(default=True)),
('order', models.IntegerField(default=0)),
],
options={
'ordering': ['order'],
'abstract': False,
},
bases=(models.Model,),
),
migrations.CreateModel(
name='ReviewTypeName',
fields=[
('slug', models.CharField(max_length=32, serialize=False, primary_key=True)),
('name', models.CharField(max_length=255)),
('desc', models.TextField(blank=True)),
('used', models.BooleanField(default=True)),
('order', models.IntegerField(default=0)),
],
options={
'ordering': ['order'],
'abstract': False,
},
bases=(models.Model,),
),
]

View file

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
def insert_initial_review_data(apps, schema_editor):
ReviewRequestStateName = apps.get_model("name", "ReviewRequestStateName")
ReviewRequestStateName.objects.get_or_create(slug="requested", name="Requested", order=1)
ReviewRequestStateName.objects.get_or_create(slug="accepted", name="Accepted", order=2)
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="no-response", name="No Response", order=6)
ReviewRequestStateName.objects.get_or_create(slug="no-review-version", name="Team Will not Review Version", order=7)
ReviewRequestStateName.objects.get_or_create(slug="no-review-document", name="Team Will not Review Document", order=8)
ReviewRequestStateName.objects.get_or_create(slug="part-completed", name="Partially Completed", order=9)
ReviewRequestStateName.objects.get_or_create(slug="completed", name="Completed", order=10)
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)
ReviewResultName = apps.get_model("name", "ReviewResultName")
ReviewResultName.objects.get_or_create(slug="serious-issues", name="Serious Issues", order=1)
ReviewResultName.objects.get_or_create(slug="issues", name="Has Issues", order=2)
ReviewResultName.objects.get_or_create(slug="nits", name="Has Nits", order=3)
ReviewResultName.objects.get_or_create(slug="not-ready", name="Not Ready", order=4)
ReviewResultName.objects.get_or_create(slug="right-track", name="On the Right Track", order=5)
ReviewResultName.objects.get_or_create(slug="almost-ready", name="Almost Ready", order=6)
ReviewResultName.objects.get_or_create(slug="ready-issues", name="Ready with Issues", order=7)
ReviewResultName.objects.get_or_create(slug="ready-nits", name="Ready with Nits", order=8)
ReviewResultName.objects.get_or_create(slug="ready", name="Ready", order=9)
RoleName = apps.get_model("name", "RoleName")
RoleName.objects.get_or_create(slug="reviewer", name="Reviewer", order=max(r.order for r in RoleName.objects.exclude(slug="reviewer")) + 1)
DocTypeName = apps.get_model("name", "DocTypeName")
DocTypeName.objects.get_or_create(slug="review", name="Review")
StateType = apps.get_model("doc", "StateType")
review_state_type, _ = StateType.objects.get_or_create(slug="review", label="Review")
State = apps.get_model("doc", "State")
State.objects.get_or_create(type=review_state_type, slug="active", name="Active", order=1)
State.objects.get_or_create(type=review_state_type, slug="deleted", name="Deleted", order=2)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('name', '0014_reviewrequeststatename_reviewresultname_reviewtypename'),
('group', '0001_initial'),
('doc', '0001_initial'),
]
operations = [
migrations.RunPython(insert_initial_review_data, noop),
]

View file

@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('name', '0015_insert_review_name_data'),
]
operations = [
migrations.AlterModelOptions(
name='ballotpositionname',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='constraintname',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='dbtemplatetypename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='docrelationshipname',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='docremindertypename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='doctagname',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='doctypename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='draftsubmissionstatename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='feedbacktypename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='groupmilestonestatename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='groupstatename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='grouptypename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='intendedstdlevelname',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='iprdisclosurestatename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='ipreventtypename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='iprlicensetypename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='liaisonstatementeventtypename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='liaisonstatementpurposename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='liaisonstatementstate',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='liaisonstatementtagname',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='meetingtypename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='nomineepositionstatename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='reviewrequeststatename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='reviewresultname',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='reviewtypename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='rolename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='roomresourcename',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='sessionstatusname',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='stdlevelname',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='streamname',
options={'ordering': ['order', 'name']},
),
migrations.AlterModelOptions(
name='timeslottypename',
options={'ordering': ['order', 'name']},
),
]

View file

@ -14,7 +14,7 @@ class NameModel(models.Model):
class Meta:
abstract = True
ordering = ['order']
ordering = ['order', 'name']
class GroupStateName(NameModel):
"""BOF, Proposed, Active, Dormant, Concluded, Abandoned"""
@ -88,3 +88,13 @@ class LiaisonStatementEventTypeName(NameModel):
"Submitted, Modified, Approved, Posted, Killed, Resurrected, MsgIn, MsgOut, Comment"
class LiaisonStatementTagName(NameModel):
"Action Required, Action Taken"
class ReviewRequestStateName(NameModel):
"""Requested, Accepted, Rejected, Withdrawn, Overtaken By Events,
No Response, No Review of Version, No Review of Document, Partially Completed, Completed"""
class ReviewTypeName(NameModel):
"""Early Review, Last Call, Telechat"""
class ReviewResultName(NameModel):
"""Almost ready, Has issues, Has nits, Not Ready,
On the right track, Ready, Ready with issues,
Ready with nits, Serious Issues"""

View file

@ -13,7 +13,8 @@ from ietf.name.models import (TimeSlotTypeName, GroupStateName, DocTagName, Inte
IprEventTypeName, GroupMilestoneStateName, SessionStatusName, DocReminderTypeName,
ConstraintName, MeetingTypeName, DocRelationshipName, RoomResourceName, IprLicenseTypeName,
LiaisonStatementTagName, FeedbackTypeName, LiaisonStatementState, StreamName,
BallotPositionName, DBTemplateTypeName, NomineePositionStateName)
BallotPositionName, DBTemplateTypeName, NomineePositionStateName,
ReviewRequestStateName, ReviewTypeName, ReviewResultName)
class TimeSlotTypeNameResource(ModelResource):
@ -413,3 +414,45 @@ class NomineePositionStateNameResource(ModelResource):
}
api.name.register(NomineePositionStateNameResource())
class ReviewRequestStateNameResource(ModelResource):
class Meta:
cache = SimpleCache()
queryset = ReviewRequestStateName.objects.all()
#resource_name = 'reviewrequeststatename'
filtering = {
"slug": ALL,
"name": ALL,
"desc": ALL,
"used": ALL,
"order": ALL,
}
api.name.register(ReviewRequestStateNameResource())
class ReviewTypeNameResource(ModelResource):
class Meta:
cache = SimpleCache()
queryset = ReviewTypeName.objects.all()
#resource_name = 'reviewtypename'
filtering = {
"slug": ALL,
"name": ALL,
"desc": ALL,
"used": ALL,
"order": ALL,
}
api.name.register(ReviewTypeNameResource())
class ReviewResultNameResource(ModelResource):
class Meta:
cache = SimpleCache()
queryset = ReviewResultName.objects.all()
#resource_name = 'reviewresultname'
filtering = {
"slug": ALL,
"name": ALL,
"desc": ALL,
"used": ALL,
"order": ALL,
}
api.name.register(ReviewResultNameResource())

View file

@ -52,7 +52,7 @@ class SearchablePersonsField(forms.CharField):
model=Person, # or Email
hint_text="Type in name to search for person.",
*args, **kwargs):
kwargs["max_length"] = 1000
kwargs["max_length"] = 10000
self.max_entries = max_entries
self.only_users = only_users
self.all_emails = all_emails
@ -147,3 +147,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)

View file

@ -1,14 +1,14 @@
import re
def name_parts(name):
prefix, first, middle, last, suffix = "", "", "", "", ""
prefix, first, middle, last, suffix = u"", u"", u"", u"", u""
if not name.strip():
return prefix, first, middle, last, suffix
# if we got a name on the form "Some Name (Foo Bar)", get rid of
# the paranthesized part
name_with_paren_match = re.search("^([^(]+)\s*\(.*\)$", name)
name_with_paren_match = re.search(r"^([^(]+)\s*\(.*\)$", name)
if name_with_paren_match:
name = name_with_paren_match.group(1)
@ -24,8 +24,8 @@ def name_parts(name):
suffix = parts[-1]
parts = parts[:-1]
if len(parts) > 2:
name = " ".join(parts)
compound = re.search(" (de|hadi|van|ver|von|el|le|st\.?) ", name.lower())
name = u" ".join(parts)
compound = re.search(r" (de|hadi|van|ver|von|el|le|st\.?) ", name.lower())
if compound:
pos = compound.start()
parts = name[:pos].split() + [name[pos+1:]]
@ -35,7 +35,7 @@ def name_parts(name):
# Handle reverse-order names with uppercase surname correctly
if re.search("^[A-Z-]+$", first):
first, last = last, first
middle = " ".join(parts[1:-1])
middle = u" ".join(parts[1:-1])
elif len(parts) == 2:
first, last = parts
else:
@ -46,13 +46,13 @@ def initials(name):
prefix, first, middle, last, suffix = name_parts(name)
given = first
if middle:
given += " "+middle
initials = " ".join([ n[0]+'.' for n in given.split() ])
given += u" "+middle
initials = u" ".join([ n[0]+'.' for n in given.split() ])
return initials
if __name__ == "__main__":
import sys
name = " ".join(sys.argv[1:])
name = u" ".join(sys.argv[1:])
print name_parts(name)
print initials(name)

View file

@ -8,8 +8,10 @@ from django.core.urlresolvers import reverse as urlreverse
import debug # pyflakes:ignore
from ietf.person.factories import EmailFactory,PersonFactory
from ietf.person.models import Person
from ietf.utils.test_utils import TestCase
from ietf.utils.test_data import make_test_data
from ietf.utils.mail import outbox, empty_outbox
class PersonTests(TestCase):
@ -68,3 +70,8 @@ class PersonTests(TestCase):
# Maybe use ietf.person.cjk.*
self.assertEqual(person.ascii_name(), u"Wu Jian Ping")
def test_duplicate_person_name(self):
empty_outbox()
Person.objects.create(name="Duplicate Test")
Person.objects.create(name="Duplicate Test")
self.assertTrue("possible duplicate" in outbox[0]["Subject"].lower())

0
ietf/review/__init__.py Normal file
View file

71
ietf/review/admin.py Normal file
View file

@ -0,0 +1,71 @@
from django.contrib import admin
from ietf.review.models import (ReviewerSettings, UnavailablePeriod, ReviewWish,
ResultUsedInReviewTeam, TypeUsedInReviewTeam, NextReviewerInTeam,
ReviewRequest)
class ReviewerSettingsAdmin(admin.ModelAdmin):
list_filter = ["team"]
search_fields = ["person__name"]
ordering = ["-id"]
raw_id_fields = ["team", "person"]
admin.site.register(ReviewerSettings, ReviewerSettingsAdmin)
class UnavailablePeriodAdmin(admin.ModelAdmin):
list_display = ["person", "team", "start_date", "end_date", "availability"]
list_display_links = ["person"]
list_filter = ["team"]
date_hierarchy = "start_date"
search_fields = ["person__name"]
ordering = ["-id"]
raw_id_fields = ["team", "person"]
admin.site.register(UnavailablePeriod, UnavailablePeriodAdmin)
class ReviewWishAdmin(admin.ModelAdmin):
list_display = ["person", "team", "doc"]
list_display_links = ["person"]
list_filter = ["team"]
search_fields = ["person__name"]
ordering = ["-id"]
raw_id_fields = ["team", "person", "doc"]
admin.site.register(ReviewWish, ReviewWishAdmin)
class ResultUsedInReviewTeamAdmin(admin.ModelAdmin):
list_display = ["team", "result"]
list_display_links = ["team"]
list_filter = ["team"]
ordering = ["team", "result__order"]
raw_id_fields = ["team"]
admin.site.register(ResultUsedInReviewTeam, ResultUsedInReviewTeamAdmin)
class TypeUsedInReviewTeamAdmin(admin.ModelAdmin):
list_display = ["team", "type"]
list_display_links = ["team"]
list_filter = ["team"]
ordering = ["team", "type__order"]
raw_id_fields = ["team"]
admin.site.register(TypeUsedInReviewTeam, TypeUsedInReviewTeamAdmin)
class NextReviewerInTeamAdmin(admin.ModelAdmin):
list_display = ["team", "next_reviewer"]
list_display_links = ["team"]
ordering = ["team"]
raw_id_fields = ["team", "next_reviewer"]
admin.site.register(NextReviewerInTeam, NextReviewerInTeamAdmin)
class ReviewRequestAdmin(admin.ModelAdmin):
list_display = ["doc", "time", "type", "team", "deadline"]
list_display_links = ["doc"]
list_filter = ["team", "type", "state", "result"]
ordering = ["-id"]
raw_id_fields = ["doc", "team", "requested_by", "reviewer", "review"]
date_hierarchy = "time"
search_fields = ["doc__name", "reviewer__person__name"]
admin.site.register(ReviewRequest, ReviewRequestAdmin)

View file

@ -0,0 +1,779 @@
#!/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, re, itertools
from collections import namedtuple
from django.db import connections
from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, ResultUsedInReviewTeam,
ReviewRequestStateName, ReviewTypeName, TypeUsedInReviewTeam,
UnavailablePeriod, NextReviewerInTeam)
from ietf.group.models import Group, Role, RoleName
from ietf.person.models import Person, Email, Alias
from ietf.doc.models import Document, DocAlias, ReviewRequestDocEvent, NewRevisionDocEvent, DocTypeName, State
from ietf.utils.text import strip_prefix, xslugify
from ietf.review.utils import possibly_advance_next_reviewer_for_team
import argparse
from unidecode import unidecode
parser = argparse.ArgumentParser()
parser.add_argument("database", help="database must be included in settings")
parser.add_argument("team", help="team acronym, must exist")
parser.add_argument("--genartdata", help="genart data file")
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):
import time
if not t:
return None
return datetime.datetime(*time.gmtime(t)[:6])
# 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() }
autopolicy_days = {
'weekly': 7,
'biweekly': 14,
'monthly': 30,
'bimonthly': 61,
'quarterly': 91,
}
reviewer_blacklist = set([("genart", "alice")])
name_to_login = {}
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
if (team.acronym, row.login) in reviewer_blacklist:
continue # ignore
name_to_login[row.name] = row.login
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", unicode(person).encode("utf-8")
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
if "@" in email.person.name and row.name:
old_name = email.person.name
email.person.name = row.name
email.person.ascii = row.name
email.person.save()
print "fixed name of", email, old_name, "->", row.name
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", unicode(role).encode("utf-8")
if row.login in known_reviewers:
if (row.comment != "Inactive"
and row.available != 2145916800 # corresponds to 2038-01-01
and (not parse_timestamp(row.available) or parse_timestamp(row.available).date() < datetime.date(2020, 1, 1))):
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", unicode(role).encode("utf-8")
reviewer_settings, created = ReviewerSettings.objects.get_or_create(
team=team,
person=email.person,
)
if created:
print "created reviewer settings", reviewer_settings.pk, unicode(reviewer_settings).encode("utf-8")
reviewer_settings.min_interval = None
if autopolicy_days.get(row.autopolicy):
reviewer_settings.min_interval = autopolicy_days.get(row.autopolicy)
reviewer_settings.filter_re = row.donotassign
try:
reviewer_settings.skip_next = int(row.autopolicy)
except ValueError:
pass
reviewer_settings.save()
unavailable_until = parse_timestamp(row.available)
if unavailable_until:
today = datetime.date.today()
end_date = unavailable_until.date()
if end_date >= today:
if end_date >= datetime.date(2020, 1, 1):
# convert hacked end dates to indefinite
end_date = None
UnavailablePeriod.objects.filter(person=email.person, team=team).delete()
UnavailablePeriod.objects.create(
team=team,
person=email.person,
start_date=None,
end_date=end_date,
availability="unavailable",
)
# 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())
# configuration options
with db_con.cursor() as c:
c.execute("select * from config;")
for row in namedtuplefetchall(c):
if row.name == "next": # next reviewer
NextReviewerInTeam.objects.filter(team=team).delete()
NextReviewerInTeam.objects.create(team=team, next_reviewer=known_personnel[row.value].person)
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=known_personnel[row.value].person.pk)
if row.name == "summary-list": # review results used in team
summaries = [v.strip().lower() for v in row.value.split(";") if v.strip()]
for s in summaries:
ResultUsedInReviewTeam.objects.get_or_create(team=team, result=results[s])
for t in ReviewTypeName.objects.filter(slug__in=["early", "lc", "telechat"]):
TypeUsedInReviewTeam.objects.get_or_create(team=team, type=t)
# review requests
ReviewRequestStateName.objects.get_or_create(slug="unknown", name="Unknown", order=20, used=False)
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
document_history = {}
requested_re = re.compile("(?:ADDED docname =>|Created: remote=|AUTO UPDATED status TO new|CHANGED status FROM [^ ]+ => new|CHANGE status done => working)")
add_docstatus_re = re.compile('([a-zA-Z-_@.]+) ADD docstatus => (\w+)')
update_docstatus_re = re.compile('([a-zA-Z-_@.]+) (?:UPDATE|CHANGE) docstatus \w+ => (\w+)')
iesgstatus_re = re.compile('(?:ADD|ADDED|CHANGED) iesgstatus (?:FROM )?(?:[^ ]+ )?=> ([a-zA-Z-_]+)?')
deadline_re = re.compile('(?:ADD|ADDED|CHANGED) deadline (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)')
telechat_re = re.compile('(?:ADD|ADDED|CHANGED) telechat (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)')
lcend_re = re.compile('(?:ADD|ADDED|CHANGED) lcend (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)')
close_states = ["done", "rejected", "withdrawn", "noresponse"]
document_blacklist = set([(u"tsvdir", u"draft-arkko-ipv6-transition-guidelines-09 ")])
with db_con.cursor() as c:
c.execute("""select docname, time, who, text from doclog where
text like 'Created: remote=%'
or text like '%ADDED docname => %'
or text like '%AUTO UPDATED status TO new%'
or text like '%CHANGED status FROM % => new%'
or text like '%CHANGE status done => working%'
or text like '% ADD docstatus => %'
or text like '% UPDATE docstatus % => %'
or text like '% CHANGE docstatus % => %'
or text like '%CHANGE status working => done%'
or text like '%ADDED iesgstatus => %'
or text like '%ADD iesgstatus => %'
or text like '%CHANGED iesgstatus % => %'
order by docname, time asc;""")
for docname, rows in itertools.groupby(namedtuplefetchall(c), lambda row: row.docname):
if (team.acronym, docname) in document_blacklist:
continue # ignore
branches = {}
latest_requested = None
non_specific_close = None
latest_iesg_status = None
latest_deadline = None
for row in rows:
if (team.acronym, row.who) in reviewer_blacklist:
continue # ignore
state = None
used = False
if requested_re.search(row.text):
if "Created: remote" in row.text:
latest_iesg_status = "early"
state = "requested"
membername = None
used = True
else:
if "ADD docstatus" in row.text:
m = add_docstatus_re.match(row.text)
assert m, 'row.text "{}" does not match add regexp {}'.format(row.text, docname)
membername, state = m.groups()
used = True
elif "UPDATE docstatus" in row.text or "CHANGE docstatus" in row.text:
m = update_docstatus_re.match(row.text)
assert m, 'row.text "{}" does not match update regexp {}'.format(row.text, docname)
membername, state = m.groups()
used = True
if telechat_re.search(row.text) and not lcend_re.search(row.text):
latest_iesg_status = "telechat"
used = True
elif ((not telechat_re.search(row.text) and lcend_re.search(row.text))
or (deadline_re.search(row.text) and not telechat_re.search(row.text) and not lcend_re.search(row.text))):
latest_iesg_status = "lc"
used = True
elif (deadline_re.search(row.text) and telechat_re.search(row.text) and lcend_re.search(row.text)):
# if we got both, assume it was a Last Call
latest_iesg_status = "lc"
used = True
if deadline_re.search(row.text):
m = deadline_re.search(row.text)
latest_deadline = parse_timestamp(int(m.groups()[0]))
if iesgstatus_re.search(row.text):
m = iesgstatus_re.search(row.text)
if m.groups():
literal_iesg_status = m.groups()[0]
if literal_iesg_status == "IESG_Evaluation":
latest_iesg_status = "telechat"
elif literal_iesg_status == "In_Last_Call":
latest_iesg_status = "lc"
used = True
if "CHANGE status working => done" in row.text:
non_specific_close = (row, latest_iesg_status, latest_deadline)
used = True
if not used:
raise Exception("Unknown text {}".format(row.text))
if not state:
continue
if state == "working":
state = "assigned"
if state == "requested":
latest_requested = (row.time, row.who, membername, state, latest_iesg_status, latest_deadline)
else:
if membername not in branches:
branches[membername] = []
latest = branches[membername][-1] if branches[membername] else None
if not latest or ((state == "assigned" and ("assigned" in latest or "closed" in latest))
or (state not in ("assigned", "accepted") and "closed" in latest)):
# open new
branches[membername].append({})
latest = branches[membername][-1]
if latest_requested:
latest["requested"] = latest_requested
latest_requested = None
if state in ("assigned", 'accepted'):
latest[state] = (row.time, row.who, membername, state, latest_iesg_status, latest_deadline)
else:
latest["closed"] = (row.time, row.who, membername, state, latest_iesg_status, latest_deadline)
if branches:
if non_specific_close:
# find any open branches
for m, hs in branches.iteritems():
latest = hs[-1]
if "assigned" in latest and "closed" not in latest:
#print "closing with non specific", docname
close_row, iesg_status, deadline = non_specific_close
latest["closed"] = (close_row.time, close_row.who, m, "done", iesg_status, deadline)
document_history[docname] = branches
# if docname in document_history:
# print docname, document_history[docname]
# 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)
genart_data = {}
if team.acronym == "genart":
with open(args.genartdata) as f:
for line in f:
t = line.strip().split("\t")
document, reviewer, version_number, result_url, d, review_type, results_time, result_name = t
d = datetime.datetime.strptime(d, "%Y-%m-%d")
if results_time:
results_time = datetime.datetime.strptime(results_time, "%Y-%m-%dT%H:%M:%S")
else:
results_time = None
reviewer_login = name_to_login.get(reviewer)
if not reviewer_login:
print "WARNING: unknown genart reviewer", reviewer
continue
genart_data[(document, reviewer_login)] = genart_data[(document, reviewer_login, version_number)] = (result_url, d, review_type, results_time, result_name)
system_person = Person.objects.get(name="(System)")
seen_review_requests = {}
with db_con.cursor() as c:
c.execute("select * from reviews order by reviewid;")
for row in namedtuplefetchall(c):
if (team.acronym, row.docname) in document_blacklist:
continue # ignore
if (team.acronym, row.reviewer) in reviewer_blacklist:
continue # ignore
reviewed_rev = row.version if row.version and row.version != "99" else ""
doc_deadline, telechat, lcend, status = doc_metadata.get(row.docname) or (None, None, None, None)
reviewurl = row.reviewurl
reviewstatus = row.docstatus
reviewsummary = row.summary
donetime = None
if team.acronym == "genart":
extra_data = genart_data.get((row.docname, row.reviewer, reviewed_rev))
#if not extra_data:
# extra_data = genart_data.get(row.docname)
if extra_data:
extra_result_url, extra_d, extra_review_type, extra_results_time, extra_result_name = extra_data
extra_data = []
if not reviewurl and extra_result_url:
reviewurl = extra_result_url
extra_data.append(reviewurl)
if not reviewsummary and extra_result_name:
reviewsummary = extra_result_name
extra_data.append(reviewsummary)
if reviewstatus != "done" and (reviewurl or reviewsummary):
reviewstatus = "done"
extra_data.append("done")
if extra_results_time:
donetime = extra_results_time
extra_data.append(donetime)
if extra_data:
print "EXTRA DATA", row.docname, extra_data
event_collection = {}
branches = document_history.get(row.docname)
if not branches:
print "WARNING: no history for", row.docname
else:
history = branches.get(row.reviewer)
if not history:
print "WARNING: reviewer {} not found in history for".format(row.reviewer), row.docname
else:
event_collection = history.pop(0)
if "requested" not in event_collection:
print "WARNING: no requested log entry for", row.docname, [event_collection] + history
if "assigned" not in event_collection:
print "WARNING: no assigned log entry for", row.docname, [event_collection] + history
if "closed" not in event_collection and reviewstatus in close_states:
print "WARNING: no {} log entry for".format("/".join(close_states)), row.docname, [event_collection] + history
def day_delta(time_from, time_to):
if time_from is None or time_to is None:
return None
return float(time_to[0] - time_from[0]) / (24 * 60 * 60)
requested_assigned_days = day_delta(event_collection.get("requested"), event_collection.get("assigned"))
if requested_assigned_days is not None and requested_assigned_days < 0:
print "WARNING: assignment before request", requested_assigned_days, row.docname
at_most_days = 20
if requested_assigned_days is not None and requested_assigned_days > at_most_days:
print "WARNING: more than {} days between request and assignment".format(at_most_days), round(requested_assigned_days), event_collection, row.docname
if "closed" in event_collection:
assigned_closed_days = day_delta(event_collection.get("assigned"), event_collection.get("closed"))
if assigned_closed_days is not None and assigned_closed_days < 0:
print "WARNING: closure before assignment", assigned_closed_days, row.docname
at_most_days = 60
if assigned_closed_days is not None and assigned_closed_days > at_most_days and event_collection.get("closed")[3] not in ("noresponse", "withdrawn"):
print "WARNING: more than {} days between assignment and completion".format(at_most_days), round(assigned_closed_days), event_collection, row.docname
deadline = None
request_time = None
if event_collection:
if "closed" in event_collection and not deadline:
deadline = event_collection["closed"][5]
if "assigned" in event_collection and not deadline:
deadline = event_collection["assigned"][5]
if "requested" in event_collection and not deadline:
deadline = event_collection["requested"][5]
if "requested" in event_collection:
request_time = parse_timestamp(event_collection["requested"][0])
elif "assigned" in event_collection:
request_time = parse_timestamp(event_collection["assigned"][0])
elif "closed" in event_collection:
request_time = parse_timestamp(event_collection["closed"][0])
if not deadline:
deadline = doc_deadline
if not deadline:
deadline = parse_timestamp(row.timeout)
if not deadline and "closed" in event_collection:
deadline = parse_timestamp(event_collection["closed"][0])
if not deadline and "assigned" in event_collection:
deadline = parse_timestamp(event_collection["assigned"][0])
if deadline:
deadline += datetime.timedelta(days=30)
if not deadline:
print "SKIPPING WITH NO DEADLINE", row.reviewid, row.docname, event_collection
continue
if not request_time and donetime:
request_time = donetime
if not request_time:
request_time = deadline
type_slug = None
if "assigned" in event_collection:
type_slug = event_collection["assigned"][4]
if type_slug:
print "deduced type slug at assignment", type_slug, row.docname
if not type_slug and "requested" in event_collection:
type_slug = event_collection["requested"][4]
if type_slug:
print "deduced type slug at request", type_slug, row.docname
if not type_slug and "closed" in event_collection and "assigned" not in event_collection:
type_slug = event_collection["closed"][4]
if type_slug:
print "deduced type slug at closure", type_slug, row.docname
if not type_slug:
print "did not deduce type slug, assuming early", row.docname, deadline
type_slug = "early"
type_name = type_names[type_slug]
def fix_docname(docname):
if docname == "draft-fenner-obsolete":
docname = "draft-fenner-obsolete-1264"
return docname
fixed_docname = fix_docname(row.docname)
review_req = ReviewRequest.objects.filter(
doc_id=fixed_docname,
team=team,
old_id=row.reviewid,
).first()
if not review_req:
review_req = ReviewRequest(
doc_id=fixed_docname,
team=team,
old_id=row.reviewid,
)
review_req.reviewer = known_personnel[row.reviewer] if row.reviewer else None
review_req.result = results.get(reviewsummary.lower()) if reviewsummary else None
review_req.state = states.get(reviewstatus) if reviewstatus else None
review_req.type = type_name
review_req.time = request_time
review_req.reviewed_rev = reviewed_rev if review_req.state_id not in ("requested", "accepted") else ""
review_req.deadline = deadline.date()
review_req.requested_by = system_person
k = (review_req.doc_id, review_req.result_id, review_req.state_id, review_req.reviewer_id, review_req.reviewed_rev, reviewurl)
if k in seen_review_requests:
print "SKIPPING SEEN", k
# there's one special thing we're going to do here, and
# that's checking whether we have a real completion event
# for this skipped entry where the previous match didn't
if "closed" in event_collection and review_req.state_id not in ("requested", "accepted"):
review_req = seen_review_requests[k]
if ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc, review_request=review_req).exists():
continue
data = event_collection['closed']
timestamp, who_did_it, reviewer, state, latest_iesg_status, latest_deadline = data
if who_did_it in known_personnel:
by = known_personnel[who_did_it].person
else:
by = system_person
e = ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc, review_request=review_req).first()
if not e:
e = ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc, review_request=review_req)
e.time = parse_timestamp(timestamp)
e.by = by
e.state = states.get(state) if state else None
if e.state_id == "rejected":
e.desc = "Assignment of request for {} review by {} to {} was rejected".format(
review_req.type.name,
review_req.team.acronym.upper(),
review_req.reviewer.person,
)
elif e.state_id == "completed":
e.desc = "Request for {} review by {} {}{}. Reviewer: {}.".format(
review_req.type.name,
review_req.team.acronym.upper(),
review_req.state.name,
": {}".format(review_req.result.name) if review_req.result else "",
review_req.reviewer.person,
)
else:
e.desc = "Closed request for {} review by {} with state '{}'".format(review_req.type.name, review_req.team.acronym.upper(), e.state.name)
e.skip_community_list_notification = True
e.save()
completion_event = e
print "imported event closed_review_request", e.desc, e.doc_id
continue
review_req.save()
seen_review_requests[k] = review_req
completion_event = None
# review request events
for key, data in event_collection.iteritems():
timestamp, who_did_it, reviewer, state, latest_iesg_status, latest_deadline = data
if who_did_it in known_personnel:
by = known_personnel[who_did_it].person
else:
by = system_person
if key == "requested":
if "assigned" in event_collection:
continue # skip requested unless there's no assigned event
e = ReviewRequestDocEvent.objects.filter(type="requested_review", doc=review_req.doc, review_request=review_req).first()
if not e:
e = ReviewRequestDocEvent(type="requested_review", doc=review_req.doc, review_request=review_req)
e.time = request_time
e.by = by
e.desc = "Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper())
e.state = None
e.skip_community_list_notification = True
e.save()
print "imported event requested_review", e.desc, e.doc_id
elif key == "assigned":
e = ReviewRequestDocEvent.objects.filter(type="assigned_review_request", doc=review_req.doc, review_request=review_req).first()
if not e:
e = ReviewRequestDocEvent(type="assigned_review_request", doc=review_req.doc, review_request=review_req)
e.time = parse_timestamp(timestamp)
e.by = by
e.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)",
)
e.state = None
e.skip_community_list_notification = True
e.save()
print "imported event assigned_review_request", e.pk, e.desc, e.doc_id
elif key == "closed" and review_req.state_id not in ("requested", "accepted"):
e = ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc, review_request=review_req).first()
if not e:
e = ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc, review_request=review_req)
e.time = parse_timestamp(timestamp)
e.by = by
e.state = states.get(state) if state else None
if e.state_id == "rejected":
e.desc = "Assignment of request for {} review by {} to {} was rejected".format(
review_req.type.name,
review_req.team.acronym.upper(),
review_req.reviewer.person,
)
elif e.state_id == "completed":
e.desc = "Request for {} review by {} {}{}. Reviewer: {}.".format(
review_req.type.name,
review_req.team.acronym.upper(),
review_req.state.name,
": {}".format(review_req.result.name) if review_req.result else "",
review_req.reviewer.person,
)
else:
e.desc = "Closed request for {} review by {} with state '{}'".format(review_req.type.name, review_req.team.acronym.upper(), e.state.name)
e.skip_community_list_notification = True
e.save()
completion_event = e
print "imported event closed_review_request", e.desc, e.doc_id
if review_req.state_id == "completed":
if not reviewurl: # don't have anything to store, so skip
continue
if completion_event:
completion_time = completion_event.time
completion_by = completion_event.by
else:
completion_time = donetime or deadline
completion_by = system_person
# create the review document
if review_req.review:
review = review_req.review
else:
for i in range(1, 100):
name_components = [
"review",
strip_prefix(review_req.doc_id, "draft-"),
review_req.reviewed_rev,
review_req.team.acronym,
review_req.type_id,
xslugify(unicode(review_req.reviewer.person.ascii_parts()[3])),
completion_time.date().isoformat(),
]
if i > 1:
name_components.append(str(i))
name = "-".join(c for c in name_components if c).lower()
if not Document.objects.filter(name=name).exists():
review = Document.objects.create(name=name)
DocAlias.objects.create(document=review, name=review.name)
break
review.set_state(State.objects.get(type="review", slug="active"))
review.time = completion_time
review.type = DocTypeName.objects.get(slug="review")
review.rev = "00"
review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, review_req.reviewed_rev)
review.group = review_req.team
review.external_url = reviewurl
existing = NewRevisionDocEvent.objects.filter(doc=review).first() or NewRevisionDocEvent(doc=review)
e.type = "new_revision"
e.by = completion_by
e.rev = review.rev
e.desc = 'New revision available'
e.time = completion_time
e.skip_community_list_notification = True
review.save_with_history([e])
review_req.review = review
review_req.save()
print "imported review document", review_req.doc, review.name
if review_req.state_id in ("requested", "accepted") and status == "done":
review_req.state = states["unknown"]
review_req.save()
if "closed" not in event_collection and "assigned" in event_collection:
e = ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc, review_request=review_req).first()
if not e:
e = ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc, review_request=review_req)
e.time = donetime or datetime.datetime.now()
e.by = by
e.state = review_req.state
e.desc = "Closed request for {} review by {} with state '{}'".format(review_req.type.name, review_req.team.acronym.upper(), e.state.name)
e.skip_community_list_notification = True
e.save()
completion_event = e
print "imported event closed_review_request (generated upon closing)", e.desc, e.doc_id
print "imported review request", row.reviewid, "as", review_req.pk, review_req.time, review_req.deadline, review_req.type, review_req.doc_id, review_req.state, review_req.doc.get_state_slug("draft-iesg")

103
ietf/review/mailarch.py Normal file
View file

@ -0,0 +1,103 @@
# various utilities for working with the mailarch mail archive at
# mailarchive.ietf.org
import datetime, tarfile, mailbox, tempfile, hashlib, base64, email.utils
import urllib
import urllib2, contextlib
from django.conf import settings
def list_name_from_email(list_email):
if not list_email.endswith("@ietf.org"):
return None
return list_email[:-len("@ietf.org")]
def hash_list_message_id(list_name, msgid):
# hash in mailarch is computed similar to
# https://www.mail-archive.com/faq.html#listserver except the list
# name (without "@ietf.org") is used instead of the full address,
# and rightmost "=" signs are (optionally) stripped
sha = hashlib.sha1(msgid)
sha.update(list_name)
return base64.urlsafe_b64encode(sha.digest()).rstrip("=")
def construct_query_urls(review_req, query=None):
list_name = list_name_from_email(review_req.team.list_email)
if not list_name:
return None
if not query:
query = review_req.doc.name
encoded_query = "?" + urllib.urlencode({
"qdr": "c", # custom time frame
"start_date": (datetime.date.today() - datetime.timedelta(days=180)).isoformat(),
"email_list": list_name,
"q": "subject:({})".format(query),
"as": "1", # this is an advanced search
})
return {
"query": query,
"query_url": settings.MAILING_LIST_ARCHIVE_URL + "/arch/search/" + encoded_query,
"query_data_url": settings.MAILING_LIST_ARCHIVE_URL + "/arch/export/mbox/" + encoded_query,
}
def construct_message_url(list_name, msgid):
return "{}/arch/msg/{}/{}".format(settings.MAILING_LIST_ARCHIVE_URL, list_name, hash_list_message_id(list_name, msgid))
def retrieve_messages_from_mbox(mbox_fileobj):
"""Return selected content in message from mbox from mailarch."""
res = []
with tempfile.NamedTemporaryFile(suffix=".mbox") as mbox_file:
# mailbox.mbox needs a path, so we need to put the contents
# into a file
mbox_data = mbox_fileobj.read()
mbox_file.write(mbox_data)
mbox_file.flush()
mbox = mailbox.mbox(mbox_file.name, create=False)
for msg in mbox:
content = u""
for part in msg.walk():
if part.get_content_type() == "text/plain":
charset = part.get_content_charset() or "utf-8"
content += part.get_payload(decode=True).decode(charset, "ignore")
# parse a couple of things for the front end
utcdate = None
d = email.utils.parsedate_tz(msg["Date"])
if d:
utcdate = datetime.datetime.fromtimestamp(email.utils.mktime_tz(d))
res.append({
"from": msg["From"],
"splitfrom": email.utils.parseaddr(msg["From"]),
"subject": msg["Subject"],
"content": content.replace("\r\n", "\n").replace("\r", "\n").strip("\n"),
"message_id": email.utils.unquote(msg["Message-ID"]),
"url": email.utils.unquote(msg["Archived-At"]),
"date": msg["Date"],
"utcdate": (utcdate.date().isoformat(), utcdate.time().isoformat()) if utcdate else ("", ""),
})
return res
def retrieve_messages(query_data_url):
"""Retrieve and return selected content from mailarch."""
res = []
with contextlib.closing(urllib2.urlopen(query_data_url, timeout=15)) as fileobj:
content_type = fileobj.info()["Content-type"]
if not content_type.startswith("application/x-tar"):
raise Exception("Export failed - this usually means no matches were found")
with tarfile.open(fileobj=fileobj, mode='r|*') as tar:
for entry in tar:
if entry.isfile():
mbox_fileobj = tar.extractfile(entry)
res.extend(retrieve_messages_from_mbox(mbox_fileobj))
return res

View file

@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import datetime
class Migration(migrations.Migration):
dependencies = [
('name', '0015_insert_review_name_data'),
('group', '0008_auto_20160505_0523'),
('person', '0014_auto_20160613_0751'),
('doc', '0012_auto_20160207_0537'),
]
operations = [
migrations.CreateModel(
name='NextReviewerInTeam',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('next_reviewer', models.ForeignKey(to='person.Person')),
('team', models.ForeignKey(to='group.Group')),
],
options={
'verbose_name': 'next reviewer in team setting',
'verbose_name_plural': 'next reviewer in team settings',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='ResultUsedInReviewTeam',
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={
'verbose_name': 'review result used in team setting',
'verbose_name_plural': 'review result used in team settings',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='ReviewerSettings',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('min_interval', models.IntegerField(default=30, verbose_name=b'Can review at most', choices=[(7, b'Once per week'), (14, b'Once per fortnight'), (30, b'Once per month'), (61, b'Once per two months'), (91, b'Once per quarter')])),
('filter_re', models.CharField(help_text=b'Draft names matching regular expression should not be assigned', max_length=255, verbose_name=b'Filter regexp', blank=True)),
('skip_next', models.IntegerField(default=0, verbose_name=b'Skip next assignments')),
('remind_days_before_deadline', models.IntegerField(help_text=b"To get an email reminder in case you forget to do an assigned review, enter the number of days before a review deadline you want to receive it. Clear the field if you don't want a reminder.", null=True, blank=True)),
('person', models.ForeignKey(to='person.Person')),
('team', models.ForeignKey(to='group.Group')),
],
options={
'verbose_name_plural': 'reviewer settings',
},
bases=(models.Model,),
),
migrations.CreateModel(
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(default=datetime.datetime.now)),
('deadline', models.DateField()),
('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)),
('reviewed_rev', models.CharField(max_length=16, verbose_name=b'reviewed revision', blank=True)),
('doc', models.ForeignKey(related_name='reviewrequest_set', to='doc.Document')),
('requested_by', models.ForeignKey(to='person.Person')),
('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='person.Email', null=True)),
('state', models.ForeignKey(to='name.ReviewRequestStateName')),
('team', models.ForeignKey(to='group.Group')),
('type', models.ForeignKey(to='name.ReviewTypeName')),
],
options={
},
bases=(models.Model,),
),
migrations.CreateModel(
name='ReviewWish',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('time', models.DateTimeField(default=datetime.datetime.now)),
('doc', models.ForeignKey(to='doc.Document')),
('person', models.ForeignKey(to='person.Person')),
('team', models.ForeignKey(to='group.Group')),
],
options={
'verbose_name_plural': 'review wishes',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='TypeUsedInReviewTeam',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('team', models.ForeignKey(to='group.Group')),
('type', models.ForeignKey(to='name.ReviewTypeName')),
],
options={
'verbose_name': 'review type used in team setting',
'verbose_name_plural': 'review type used in team settings',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='UnavailablePeriod',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('start_date', models.DateField(default=datetime.date.today, help_text=b"Choose the start date so that you can still do a review if it's assigned just before the start date - this usually means you should mark yourself unavailable for assignment some time before you are actually away.")),
('end_date', models.DateField(help_text=b'Leaving the end date blank means that the period continues indefinitely. You can end it later.', null=True, blank=True)),
('availability', models.CharField(max_length=30, choices=[(b'canfinish', b'Can do follow-ups'), (b'unavailable', b'Completely unavailable')])),
('person', models.ForeignKey(to='person.Person')),
('team', models.ForeignKey(to='group.Group')),
],
options={
},
bases=(models.Model,),
),
]

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import datetime
class Migration(migrations.Migration):
dependencies = [
('review', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='unavailableperiod',
name='start_date',
field=models.DateField(default=datetime.date.today, help_text=b"Choose the start date so that you can still do a review if it's assigned just before the start date - this usually means you should mark yourself unavailable for assignment some time before you are actually away.", null=True),
preserve_default=True,
),
]

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('review', '0002_auto_20161017_1218'),
]
operations = [
migrations.AlterField(
model_name='reviewersettings',
name='min_interval',
field=models.IntegerField(blank=True, null=True, verbose_name=b'Can review at most', choices=[(7, b'Once per week'), (14, b'Once per fortnight'), (30, b'Once per month'), (61, b'Once per two months'), (91, b'Once per quarter')]),
preserve_default=True,
),
]

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('person', '0014_auto_20160613_0751'),
('group', '0009_auto_20150930_0758'),
('review', '0003_auto_20161018_0254'),
]
operations = [
migrations.CreateModel(
name='ReviewSecretarySettings',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('remind_days_before_deadline', models.IntegerField(help_text=b"To get an email reminder in case an assigned review gets near its deadline, enter the number of days before a review deadline you want to receive it. Clear the field if you don't want a reminder.", null=True, blank=True)),
('person', models.ForeignKey(to='person.Person')),
('team', models.ForeignKey(to='group.Group')),
],
options={
'verbose_name_plural': 'review secretary settings',
},
bases=(models.Model,),
),
]

View file

155
ietf/review/models.py Normal file
View file

@ -0,0 +1,155 @@
import datetime
from django.db import models
from ietf.doc.models import Document
from ietf.group.models import Group
from ietf.person.models import Person, Email
from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName
class ReviewerSettings(models.Model):
"""Keeps track of admin data associated with a reviewer in a team."""
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
person = models.ForeignKey(Person)
INTERVALS = [
(7, "Once per week"),
(14, "Once per fortnight"),
(30, "Once per month"),
(61, "Once per two months"),
(91, "Once per quarter"),
]
min_interval = models.IntegerField(verbose_name="Can review at most", choices=INTERVALS, blank=True, null=True)
filter_re = models.CharField(max_length=255, verbose_name="Filter regexp", blank=True, help_text="Draft names matching regular expression should not be assigned")
skip_next = models.IntegerField(default=0, verbose_name="Skip next assignments")
remind_days_before_deadline = models.IntegerField(null=True, blank=True, help_text="To get an email reminder in case you forget to do an assigned review, enter the number of days before review deadline you want to receive it. Clear the field if you don't want a reminder.")
def __unicode__(self):
return u"{} in {}".format(self.person, self.team)
class Meta:
verbose_name_plural = "reviewer settings"
class ReviewSecretarySettings(models.Model):
"""Keeps track of admin data associated with a secretary in a team."""
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
person = models.ForeignKey(Person)
remind_days_before_deadline = models.IntegerField(null=True, blank=True, help_text="To get an email reminder in case a reviewer forgets to do an assigned review, enter the number of days before review deadline you want to receive it. Clear the field if you don't want a reminder.")
def __unicode__(self):
return u"{} in {}".format(self.person, self.team)
class Meta:
verbose_name_plural = "review secretary settings"
class UnavailablePeriod(models.Model):
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
person = models.ForeignKey(Person)
start_date = models.DateField(default=datetime.date.today, null=True, help_text="Choose the start date so that you can still do a review if it's assigned just before the start date - this usually means you should mark yourself unavailable for assignment some time before you are actually away.")
end_date = models.DateField(blank=True, null=True, help_text="Leaving the end date blank means that the period continues indefinitely. You can end it later.")
AVAILABILITY_CHOICES = [
("canfinish", "Can do follow-ups"),
("unavailable", "Completely unavailable"),
]
LONG_AVAILABILITY_CHOICES = [
("canfinish", "Can do follow-up reviews and finish outstanding reviews"),
("unavailable", "Completely unavailable - reassign any outstanding reviews"),
]
availability = models.CharField(max_length=30, choices=AVAILABILITY_CHOICES)
def state(self):
import datetime
today = datetime.date.today()
if self.start_date is None or self.start_date <= today:
if not self.end_date or today <= self.end_date:
return "active"
else:
return "past"
else:
return "future"
def __unicode__(self):
return u"{} is unavailable in {} {} - {}".format(self.person, self.team.acronym, self.start_date or "", self.end_date or "")
class ReviewWish(models.Model):
"""Reviewer wishes to review a document when it becomes available for review."""
time = models.DateTimeField(default=datetime.datetime.now)
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
person = models.ForeignKey(Person)
doc = models.ForeignKey(Document)
def __unicode__(self):
return u"{} wishes to review {} in {}".format(self.person, self.doc.name, self.team.acronym)
class Meta:
verbose_name_plural = "review wishes"
class ResultUsedInReviewTeam(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)
def __unicode__(self):
return u"{} in {}".format(self.result.name, self.team.acronym)
class Meta:
verbose_name = "review result used in team setting"
verbose_name_plural = "review result used in team settings"
class TypeUsedInReviewTeam(models.Model):
"""Captures that a type name is valid for a given team for new
reviews. """
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
type = models.ForeignKey(ReviewTypeName)
def __unicode__(self):
return u"{} in {}".format(self.type.name, self.team.acronym)
class Meta:
verbose_name = "review type used in team setting"
verbose_name_plural = "review type used in team settings"
class NextReviewerInTeam(models.Model):
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
next_reviewer = models.ForeignKey(Person)
def __unicode__(self):
return u"{} next in {}".format(self.next_reviewer, self.team)
class Meta:
verbose_name = "next reviewer in team setting"
verbose_name_plural = "next reviewer in team settings"
class ReviewRequest(models.Model):
"""Represents a request for a review and the process it goes through.
There should be one ReviewRequest entered for each combination of
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(default=datetime.datetime.now)
type = models.ForeignKey(ReviewTypeName)
doc = models.ForeignKey(Document, related_name='reviewrequest_set')
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
deadline = models.DateField()
requested_by = models.ForeignKey(Person)
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 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)
result = models.ForeignKey(ReviewResultName, blank=True, null=True)
def __unicode__(self):
return u"%s review on %s by %s %s" % (self.type, self.doc, self.team, self.state)

187
ietf/review/resources.py Normal file
View file

@ -0,0 +1,187 @@
# Autogenerated by the makeresources management command 2016-06-14 04:21 PDT
from tastypie.resources import ModelResource
from tastypie.fields import ToManyField # pyflakes:ignore
from tastypie.constants import ALL, ALL_WITH_RELATIONS # pyflakes:ignore
from tastypie.cache import SimpleCache
from ietf import api
from ietf.api import ToOneField # pyflakes:ignore
from ietf.review.models import (ReviewerSettings, ReviewRequest,
ResultUsedInReviewTeam, TypeUsedInReviewTeam,
UnavailablePeriod, ReviewWish, NextReviewerInTeam,
ReviewSecretarySettings)
from ietf.person.resources import PersonResource
from ietf.group.resources import GroupResource
class ReviewerSettingsResource(ModelResource):
team = ToOneField(GroupResource, 'team')
person = ToOneField(PersonResource, 'person')
class Meta:
queryset = ReviewerSettings.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'reviewer'
filtering = {
"id": ALL,
"min_interval": ALL,
"filter_re": ALL,
"skip_next": ALL,
"team": ALL_WITH_RELATIONS,
"person": ALL_WITH_RELATIONS,
}
api.review.register(ReviewerSettingsResource())
from ietf.doc.resources import DocumentResource
from ietf.group.resources import RoleResource, GroupResource
from ietf.name.resources import ReviewRequestStateNameResource, ReviewResultNameResource, ReviewTypeNameResource
class ReviewRequestResource(ModelResource):
state = ToOneField(ReviewRequestStateNameResource, 'state')
type = ToOneField(ReviewTypeNameResource, 'type')
doc = ToOneField(DocumentResource, 'doc')
team = ToOneField(GroupResource, 'team')
reviewer = ToOneField(RoleResource, 'reviewer', null=True)
review = ToOneField(DocumentResource, 'review', null=True)
result = ToOneField(ReviewResultNameResource, 'result', null=True)
class Meta:
queryset = ReviewRequest.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'reviewrequest'
filtering = {
"id": ALL,
"time": ALL,
"deadline": ALL,
"requested_rev": ALL,
"reviewed_rev": ALL,
"state": ALL_WITH_RELATIONS,
"type": ALL_WITH_RELATIONS,
"doc": ALL_WITH_RELATIONS,
"team": ALL_WITH_RELATIONS,
"reviewer": ALL_WITH_RELATIONS,
"review": ALL_WITH_RELATIONS,
"result": ALL_WITH_RELATIONS,
}
api.review.register(ReviewRequestResource())
from ietf.group.resources import GroupResource
from ietf.name.resources import ReviewResultNameResource
class ResultUsedInReviewTeamResource(ModelResource):
team = ToOneField(GroupResource, 'team')
result = ToOneField(ReviewResultNameResource, 'result')
class Meta:
queryset = ResultUsedInReviewTeam.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'resultusedinreviewteam'
filtering = {
"id": ALL,
"team": ALL_WITH_RELATIONS,
"result": ALL_WITH_RELATIONS,
}
api.review.register(ResultUsedInReviewTeamResource())
from ietf.person.resources import PersonResource
from ietf.group.resources import GroupResource
class UnavailablePeriodResource(ModelResource):
team = ToOneField(GroupResource, 'team')
person = ToOneField(PersonResource, 'person')
class Meta:
queryset = UnavailablePeriod.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'unavailableperiod'
filtering = {
"id": ALL,
"start_date": ALL,
"end_date": ALL,
"availability": ALL,
"team": ALL_WITH_RELATIONS,
"person": ALL_WITH_RELATIONS,
}
api.review.register(UnavailablePeriodResource())
from ietf.person.resources import PersonResource
from ietf.group.resources import GroupResource
from ietf.doc.resources import DocumentResource
class ReviewWishResource(ModelResource):
team = ToOneField(GroupResource, 'team')
person = ToOneField(PersonResource, 'person')
doc = ToOneField(DocumentResource, 'doc')
class Meta:
queryset = ReviewWish.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'reviewwish'
filtering = {
"id": ALL,
"time": ALL,
"team": ALL_WITH_RELATIONS,
"person": ALL_WITH_RELATIONS,
"doc": ALL_WITH_RELATIONS,
}
api.review.register(ReviewWishResource())
from ietf.person.resources import PersonResource
from ietf.group.resources import GroupResource
class NextReviewerInTeamResource(ModelResource):
team = ToOneField(GroupResource, 'team')
next_reviewer = ToOneField(PersonResource, 'next_reviewer')
class Meta:
queryset = NextReviewerInTeam.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'nextreviewerinteam'
filtering = {
"id": ALL,
"team": ALL_WITH_RELATIONS,
"next_reviewer": ALL_WITH_RELATIONS,
}
api.review.register(NextReviewerInTeamResource())
from ietf.group.resources import GroupResource
from ietf.name.resources import ReviewTypeNameResource
class TypeUsedInReviewTeamResource(ModelResource):
team = ToOneField(GroupResource, 'team')
type = ToOneField(ReviewTypeNameResource, 'type')
class Meta:
queryset = TypeUsedInReviewTeam.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'typeusedinreviewteam'
filtering = {
"id": ALL,
"team": ALL_WITH_RELATIONS,
"type": ALL_WITH_RELATIONS,
}
api.review.register(TypeUsedInReviewTeamResource())
from ietf.person.resources import PersonResource
from ietf.group.resources import GroupResource
class ReviewSecretarySettingsResource(ModelResource):
team = ToOneField(GroupResource, 'team')
person = ToOneField(PersonResource, 'person')
class Meta:
queryset = ReviewSecretarySettings.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'reviewsecretarysettings'
filtering = {
"id": ALL,
"remind_days_before_deadline": ALL,
"team": ALL_WITH_RELATIONS,
"person": ALL_WITH_RELATIONS,
}
api.review.register(ReviewSecretarySettingsResource())

910
ietf/review/utils.py Normal file
View file

@ -0,0 +1,910 @@
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
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, TypeUsedInReviewTeam,
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():
# if there's a ResultUsedInReviewTeam defined, it's a review team
return Group.objects.filter(state="active").exclude(resultusedinreviewteam=None)
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
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=doc,
reviewrequest__reviewed_rev=rev,
reviewrequest__state="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, None, 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,
"Assigned to review %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, 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):
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 TypeUsedInReviewTeam.objects.filter(team=team, type=last_call_type).exists():
# 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 TypeUsedInReviewTeam.objects.filter(team=team, type=telechat_type).exists():
# 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 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)
# 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",
)
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, author__person__in=possible_person_ids).values_list("author__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,
})

View file

@ -305,6 +305,7 @@ INSTALLED_APPS = (
'ietf.person',
'ietf.redirects',
'ietf.release',
'ietf.review',
'ietf.submit',
'ietf.sync',
'ietf.utils',
@ -386,10 +387,17 @@ TEST_CODE_COVERAGE_EXCLUDE = [
"*/admin.py",
"*/migrations/*",
"*/management/commands/*",
"idindex/generate_all_id2_txt.py",
"idindex/generate_all_id_txt.py",
"idindex/generate_id_abstracts_txt.py",
"idindex/generate_id_index_txt.py",
"name/generate_fixtures.py",
"review/import_from_review_tool.py",
"ietf/settings*",
"ietf/utils/test_runner.py",
"ietf/checks.py",
"ietf/utils/templatetags/debug_filters.py",
"ietf/review/import_from_review_tool.py",
]
# These are filename globs. They are used by test_parse_templates() and
@ -453,6 +461,7 @@ MEETING_RECORDINGS_DIR = '/a/www/audio'
# Mailing list info URL for lists hosted on the IETF servers
MAILING_LIST_INFO_URL = "https://www.ietf.org/mailman/listinfo/%(list_addr)s"
MAILING_LIST_ARCHIVE_URL = "https://mailarchive.ietf.org"
# Liaison Statement Tool settings (one is used in DOC_HREFS below)
LIAISON_UNIVERSAL_FROM = 'Liaison Statement Management Tool <lsmt@' + IETF_DOMAIN + '>'

View file

@ -475,6 +475,125 @@ label#list-feeds {
margin-left: 3em;
}
/* === Review flow ========================================================== */
.reviewer-assignment-not-accepted {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
form.complete-review .mail-archive-search .query-input {
width: 30em;
}
form.complete-review .mail-archive-search .results .list-group {
margin-left: 1em;
margin-right: 1em;
margin-bottom: 0.5em;
}
form.complete-review .mail-archive-search-result .from {
width: 9em;
padding-left: 0.4em;
}
form.complete-review .mail-archive-search-result .date {
width: 6em;
padding-left: 0.4em;
}
.closed-review-filter {
margin-bottom: 1em;
}
form.review-requests .reviewer-controls, form.review-requests .close-controls {
display: none;
}
form.review-requests .assign-action, form.review-requests .close-action {
display: inline-block;
margin-left: 0.5em;
}
form.review-requests .request-metadata {
margin-bottom: 0.5em;
}
form.review-requests .abstract {
font-size: 95%;
}
form.review-requests label {
font-weight: normal;
padding-right: 0.3em;
}
form.email-open-review-assignments [name=body] {
height: 50em;
font-family: monospace;
}
table.simple-table td {
padding-right: 0.5em;
}
table.simple-table td:last-child {
padding-right: 0;
}
.unavailable-period-past {
color: #777;
}
.unavailable-period-active {
font-weight: bold;
}
.reviewer-overview .completely-unavailable {
opacity: 0.6;
}
/* === Statistics =========================================================== */
.stats-options > * {
margin-bottom: 1em;
}
.stats-options > *:last-child {
margin-bottom: 0;
}
.stats-options .date-range input.form-control {
display: inline-block;
width: 7em;
}
.stats-time-graph {
height: 15em;
}
.review-stats th:first-child, .review-stats td:first-child {
text-align: left;
}
.review-stats th, .review-stats td {
text-align: center;
}
.review-stats tr.totals {
font-weight: bold;
}
.review-stats-teams {
-moz-column-width: 18em;
-webkit-column-width: 18em;
column-width: 18em;
}
.review-stats-teams a {
display: block;
}
/* === Photo pages ========================================================== */
.photo-name {

View file

@ -0,0 +1,136 @@
$(document).ready(function () {
var form = $("form.complete-review");
var reviewedRev = form.find("[name=reviewed_rev]");
reviewedRev.closest(".form-group").find("a.rev").on("click", function (e) {
e.preventDefault();
reviewedRev.val($(this).text());
});
// mail archive search functionality
var mailArchiveSearchTemplate = form.find(".template .mail-archive-search").parent().html();
var mailArchiveSearchResultTemplate = form.find(".template .mail-archive-search-result").parent().html();
form.find("[name=review_url]").closest(".form-group").before(mailArchiveSearchTemplate);
var mailArchiveSearch = form.find(".mail-archive-search");
var retrievingData = null;
function searchMailArchive() {
if (retrievingData)
return;
var queryInput = mailArchiveSearch.find(".query-input");
if (queryInput.length == 0 || !$.trim(queryInput.val()))
return;
mailArchiveSearch.find(".search").prop("disabled", true);
mailArchiveSearch.find(".error").addClass("hidden");
mailArchiveSearch.find(".retrieving").removeClass("hidden");
mailArchiveSearch.find(".results").addClass("hidden");
retrievingData = $.ajax({
url: searchMailArchiveUrl,
method: "GET",
data: {
query: queryInput.val()
},
dataType: "json",
timeout: 20 * 1000
}).then(function (data) {
retrievingData = null;
mailArchiveSearch.find(".search").prop("disabled", false);
mailArchiveSearch.find(".retrieving").addClass("hidden");
var err = data.error;
if (!err && (!data.messages || !data.messages.length))
err = "No messages matching document name found in archive";
if (err) {
var errorDiv = mailArchiveSearch.find(".error");
errorDiv.removeClass("hidden");
errorDiv.find(".content").text(err);
if (data.query && data.query_url && data.query_data_url) {
errorDiv.find(".try-yourself .query").text(data.query);
errorDiv.find(".try-yourself .query-url").prop("href", data.query_url);
errorDiv.find(".try-yourself .query-data-url").prop("href", data.query_data_url);
errorDiv.find(".try-yourself").removeClass("hidden");
}
}
else {
mailArchiveSearch.find(".results").removeClass("hidden");
var results = mailArchiveSearch.find(".results .list-group");
results.children().remove();
for (var i = 0; i < data.messages.length; ++i) {
var msg = data.messages[i];
var row = $(mailArchiveSearchResultTemplate).attr("title", "Click to fill in link and content from this message");
row.find(".subject").text(msg.subject);
row.find(".date").text(msg.utcdate[0]);
row.find(".from").text(msg.splitfrom[0]);
row.data("url", msg.url);
row.data("content", msg.content);
row.data("date", msg.utcdate[0]);
row.data("time", msg.utcdate[1]);
results.append(row);
}
}
}, function () {
retrievingData = null;
mailArchiveSearch.find(".search").prop("disabled", false);
mailArchiveSearch.find(".retrieving").addClass("hidden");
var errorDiv = mailArchiveSearch.find(".error");
errorDiv.removeClass("hidden");
errorDiv.find(".content").text("Error trying to retrieve data from mailing list archive.");
});
}
mailArchiveSearch.find(".search").on("click", function () {
searchMailArchive();
});
mailArchiveSearch.find(".results").on("click", ".mail-archive-search-result", function (e) {
e.preventDefault();
var row = $(this);
if (!row.is(".mail-archive-search-result"))
row = row.closest(".mail-archive-search-result");
form.find("[name=review_url]").val(row.data("url"));
form.find("[name=review_content]").val(row.data("content")).prop("scrollTop", 0);
form.find("[name=completion_date]").val(row.data("date"));
form.find("[name=completion_time]").val(row.data("time"));
});
// review submission selection
form.find("[name=review_submission]").on("click change", function () {
var val = form.find("[name=review_submission]:checked").val();
var shouldBeVisible = {
"enter": ['[name="review_content"]', '[name="cc"]'],
"upload": ['[name="review_file"]', '[name="cc"]'],
"link": [".mail-archive-search", '[name="review_url"]', '[name="review_content"]']
};
for (var v in shouldBeVisible) {
for (var i in shouldBeVisible[v]) {
var selector = shouldBeVisible[v][i];
var row = form.find(selector);
if (!row.is(".form-group"))
row = row.closest(".form-group");
if ($.inArray(selector, shouldBeVisible[val]) != -1)
row.show();
else
row.hide();
}
}
if (val == "link")
searchMailArchive();
}).trigger("change");
});

View file

@ -191,7 +191,7 @@ $(".snippet .show-all").click(function () {
// });
// Use the Bootstrap3 tooltip plugin for all elements with a title attribute
$('[title][title!=""]').tooltip();
$('[title][title!=""]').not("th").tooltip();
$(document).ready(function () {
// add a required class on labels on forms that should have

View file

@ -0,0 +1,101 @@
$(document).ready(function () {
var form = $("form.review-requests");
var saveButtons = form.find("[name=action][value^=\"save\"]");
function updateSaveButtons() {
saveButtons.prop("disabled", form.find("[name$=\"-action\"][value][value!=\"\"]").length == 0);
}
function setControlDisplay(row) {
var action = row.find("[name$=\"-action\"]").val();
if (action == "assign") {
row.find(".reviewer-controls").show();
row.find(".close-controls").hide();
row.find(".assign-action,.close-action").hide();
}
else if (action == "close") {
row.find(".reviewer-controls").hide();
row.find(".close-controls").show();
row.find(".assign-action,.close-action").hide();
}
else {
row.find(".reviewer-controls,.close-controls").hide();
row.find(".assign-action,.close-action").show();
}
updateSaveButtons();
}
form.find(".assign-action button").on("click", function () {
var row = $(this).closest(".review-request");
var select = row.find(".reviewer-controls [name$=\"-reviewer\"]");
if (!select.val()) {
// collect reviewers already assigned in this session
var reviewerAssigned = {};
select.find("option").each(function () {
if (this.value)
reviewerAssigned[this.value] = 0;
});
form.find("[name$=\"-action\"][value=\"assign\"]").each(function () {
var v = $(this).closest(".review-request").find("[name$=\"-reviewer\"]").val();
if (v)
reviewerAssigned[v] += 1;
});
// by default, the select box contains a sorted list, so
// we should be able to select the first, unless that
// person has already been assigned to review in this
// session
var found = null;
var options = select.find("option").get();
for (var round = 0; round < 100 && !found; ++round) {
for (var i = 0; i < options.length && !found; ++i) {
var v = options[i].value;
if (!v)
continue;
if (reviewerAssigned[v] == round)
found = v;
}
}
if (found)
select.val(found);
}
row.find("[name$=\"-action\"]").val("assign");
setControlDisplay(row);
});
form.find(".reviewer-controls .undo").on("click", function () {
var row = $(this).closest(".review-request");
row.find("[name$=\"-action\"]").val("");
row.find("[name$=\"-reviewer\"]").val($(this).data("initial"));
setControlDisplay(row);
});
form.find(".close-action button").on("click", function () {
var row = $(this).closest(".review-request");
row.find("[name$=\"-action\"]").val("close");
setControlDisplay(row);
});
form.find(".close-controls .undo").on("click", function () {
var row = $(this).closest(".review-request");
row.find("[name$=\"-action\"]").val("");
setControlDisplay(row);
});
form.find("[name$=\"-action\"]").each(function () {
var v = $(this).val();
if (!v)
return;
var row = $(this).closest(".review-request");
setControlDisplay(row);
});
updateSaveButtons();
});

View file

@ -0,0 +1,8 @@
$(document).ready(function () {
if (window.timeSeriesData && window.timeSeriesOptions) {
var placeholder = $(".stats-time-graph");
placeholder.height(Math.round(placeholder.width() * 1 / 3));
$.plot(placeholder, window.timeSeriesData, window.timeSeriesOptions);
}
});

1
ietf/stats/__init__.py Normal file
View file

@ -0,0 +1 @@

58
ietf/stats/tests.py Normal file
View file

@ -0,0 +1,58 @@
from pyquery import PyQuery
from django.core.urlresolvers import reverse as urlreverse
from ietf.utils.test_data import make_test_data, make_review_data
from ietf.utils.test_utils import login_testing_unauthorized, TestCase
import ietf.stats.views
class StatisticsTests(TestCase):
def test_stats_index(self):
url = urlreverse(ietf.stats.views.stats_index)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
def test_review_stats(self):
doc = make_test_data()
review_req = make_review_data(doc)
# check redirect
url = urlreverse(ietf.stats.views.review_stats)
login_testing_unauthorized(self, "secretary", url)
completion_url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion" })
r = self.client.get(url)
self.assertEqual(r.status_code, 302)
self.assertTrue(completion_url in r["Location"])
self.client.logout()
self.client.login(username="plain", password="plain+password")
r = self.client.get(completion_url)
self.assertEqual(r.status_code, 403)
# check tabular
self.client.login(username="secretary", password="secretary+password")
for stats_type in ["completion", "results", "states"]:
url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": stats_type })
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
if stats_type != "results":
self.assertTrue(q('.review-stats td:contains("1")'))
# check chart
url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "time" })
url += "?team={}".format(review_req.team.acronym)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertTrue(q('.stats-time-graph'))
# check reviewer level
url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion", "acronym": review_req.team.acronym })
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertTrue(q('.review-stats td:contains("1")'))

9
ietf/stats/urls.py Normal file
View file

@ -0,0 +1,9 @@
from django.conf.urls import patterns, url
from django.conf import settings
import ietf.stats.views
urlpatterns = patterns('',
url("^$", ietf.stats.views.stats_index),
url("^review/(?:(?P<stats_type>completion|results|states|time)/)?(?:%(acronym)s/)?$" % settings.URL_REGEXPS, ietf.stats.views.review_stats),
)

353
ietf/stats/views.py Normal file
View file

@ -0,0 +1,353 @@
import datetime, itertools, json, calendar
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse as urlreverse
from django.http import HttpResponseRedirect, HttpResponseForbidden
import dateutil.relativedelta
from ietf.review.utils import (extract_review_request_data,
aggregate_raw_review_request_stats,
ReviewRequestData,
compute_review_request_stats,
sum_raw_review_request_aggregations)
from ietf.group.models import Role, Group
from ietf.person.models import Person
from ietf.name.models import ReviewRequestStateName, ReviewResultName
from ietf.ietfauth.utils import has_role
def stats_index(request):
return render(request, "stats/index.html")
@login_required
def review_stats(request, stats_type=None, acronym=None):
# This view is a bit complex because we want to show a bunch of
# tables with various filtering options, and both a team overview
# and a reviewers-within-team overview - and a time series chart.
# And in order to make the UI quick to navigate, we're not using
# one big form but instead presenting a bunch of immediate
# actions, with a URL scheme where the most common options (level
# and statistics type) are incorporated directly into the URL to
# be a bit nicer.
def build_review_stats_url(stats_type_override=Ellipsis, acronym_override=Ellipsis, get_overrides={}):
kwargs = {
"stats_type": stats_type if stats_type_override is Ellipsis else stats_type_override,
}
acr = acronym if acronym_override is Ellipsis else acronym_override
if acr:
kwargs["acronym"] = acr
base_url = urlreverse(review_stats, kwargs=kwargs)
query_part = u""
if request.GET or get_overrides:
d = request.GET.copy()
for k, v in get_overrides.iteritems():
if type(v) in (list, tuple):
if not v:
if k in d:
del d[k]
else:
d.setlist(k, v)
else:
if v is None or v == u"":
if k in d:
del d[k]
else:
d[k] = v
if d:
query_part = u"?" + d.urlencode()
return base_url + query_part
def get_choice(get_parameter, possible_choices, multiple=False):
values = request.GET.getlist(get_parameter)
found = [t[0] for t in possible_choices if t[0] in values]
if multiple:
return found
else:
if found:
return found[0]
else:
return None
# which overview - team or reviewer
if acronym:
level = "reviewer"
else:
level = "team"
# statistics type - one of the tables or the chart
possible_stats_types = [
("completion", "Completion status"),
("results", "Review results"),
("states", "Request states"),
]
if level == "team":
possible_stats_types.append(("time", "Changes over time"))
possible_stats_types = [ (slug, label, build_review_stats_url(stats_type_override=slug))
for slug, label in possible_stats_types ]
if not stats_type:
return HttpResponseRedirect(build_review_stats_url(stats_type_override=possible_stats_types[0][0]))
# what to count
possible_count_choices = [
("", "Review requests"),
("pages", "Reviewed pages"),
]
possible_count_choices = [ (slug, label, build_review_stats_url(get_overrides={ "count": slug })) for slug, label in possible_count_choices ]
count = get_choice("count", possible_count_choices) or ""
# time range
def parse_date(s):
if not s:
return None
try:
return datetime.datetime.strptime(s.strip(), "%Y-%m-%d").date()
except ValueError:
return None
today = datetime.date.today()
from_date = parse_date(request.GET.get("from")) or today - dateutil.relativedelta.relativedelta(years=1)
to_date = parse_date(request.GET.get("to")) or today
from_time = datetime.datetime.combine(from_date, datetime.time.min)
to_time = datetime.datetime.combine(to_date, datetime.time.max)
# teams/reviewers
teams = list(Group.objects.exclude(reviewrequest=None).distinct().order_by("name"))
reviewer_filter_args = {}
# - interlude: access control
if has_role(request.user, ["Secretariat", "Area Director"]):
pass
else:
secr_access = set()
reviewer_only_access = set()
for r in Role.objects.filter(person__user=request.user, name__in=["secr", "reviewer"], group__in=teams).distinct():
if r.name_id == "secr":
secr_access.add(r.group_id)
reviewer_only_access.discard(r.group_id)
elif r.name_id == "reviewer":
if not r.group_id in secr_access:
reviewer_only_access.add(r.group_id)
if not secr_access and not reviewer_only_access:
return HttpResponseForbidden("You do not have the necessary permissions to view this page")
teams = [t for t in teams if t.pk in secr_access or t.pk in reviewer_only_access]
for t in reviewer_only_access:
reviewer_filter_args[t] = { "user": request.user }
reviewers_for_team = None
if level == "team":
for t in teams:
t.reviewer_stats_url = build_review_stats_url(acronym_override=t.acronym)
query_teams = teams
query_reviewers = None
group_by_objs = { t.pk: t for t in query_teams }
group_by_index = ReviewRequestData._fields.index("team")
elif level == "reviewer":
for t in teams:
if t.acronym == acronym:
reviewers_for_team = t
break
else:
return HttpResponseRedirect(urlreverse(review_stats))
query_reviewers = list(Person.objects.filter(
email__reviewrequest__time__gte=from_time,
email__reviewrequest__time__lte=to_time,
email__reviewrequest__team=reviewers_for_team,
**reviewer_filter_args.get(t.pk, {})
).distinct())
query_reviewers.sort(key=lambda p: p.last_name())
query_teams = [t]
group_by_objs = { r.pk: r for r in query_reviewers }
group_by_index = ReviewRequestData._fields.index("reviewer")
# now filter and aggregate the data
possible_teams = possible_completion_types = possible_results = possible_states = None
selected_teams = selected_completion_type = selected_result = selected_state = None
if stats_type == "time":
possible_teams = [(t.acronym, t.acronym) for t in teams]
selected_teams = get_choice("team", possible_teams, multiple=True)
def add_if_exists_else_subtract(element, l):
if element in l:
return [x for x in l if x != element]
else:
return l + [element]
possible_teams = [(slug, label, build_review_stats_url(get_overrides={
"team": add_if_exists_else_subtract(slug, selected_teams)
})) for slug, label in possible_teams]
query_teams = [t for t in query_teams if t.acronym in selected_teams]
extracted_data = extract_review_request_data(query_teams, query_reviewers, from_time, to_time)
req_time_index = ReviewRequestData._fields.index("req_time")
def time_key_fn(t):
d = t[req_time_index].date()
#d -= datetime.timedelta(days=d.weekday()) # weekly
d -= datetime.timedelta(days=d.day) # monthly
return d
found_results = set()
found_states = set()
aggrs = []
for d, request_data_items in itertools.groupby(extracted_data, key=time_key_fn):
raw_aggr = aggregate_raw_review_request_stats(request_data_items, count=count)
aggr = compute_review_request_stats(raw_aggr)
aggrs.append((d, aggr))
for slug in aggr["result"]:
found_results.add(slug)
for slug in aggr["state"]:
found_states.add(slug)
results = ReviewResultName.objects.filter(slug__in=found_results)
states = ReviewRequestStateName.objects.filter(slug__in=found_states)
# choice
possible_completion_types = [
("completed_in_time", "Completed in time"),
("completed_late", "Completed late"),
("not_completed", "Not completed"),
("average_assignment_to_closure_days", "Avg. compl. days"),
]
possible_completion_types = [
(slug, label, build_review_stats_url(get_overrides={ "completion": slug, "result": None, "state": None }))
for slug, label in possible_completion_types
]
selected_completion_type = get_choice("completion", possible_completion_types)
possible_results = [
(r.slug, r.name, build_review_stats_url(get_overrides={ "completion": None, "result": r.slug, "state": None }))
for r in results
]
selected_result = get_choice("result", possible_results)
possible_states = [
(s.slug, s.name, build_review_stats_url(get_overrides={ "completion": None, "result": None, "state": s.slug }))
for s in states
]
selected_state = get_choice("state", possible_states)
if not selected_completion_type and not selected_result and not selected_state:
selected_completion_type = "completed_in_time"
series_data = []
for d, aggr in aggrs:
v = 0
if selected_completion_type is not None:
v = aggr[selected_completion_type]
elif selected_result is not None:
v = aggr["result"][selected_result]
elif selected_state is not None:
v = aggr["state"][selected_state]
series_data.append((calendar.timegm(d.timetuple()) * 1000, v))
data = json.dumps([{
"data": series_data
}])
else: # tabular data
extracted_data = extract_review_request_data(query_teams, query_reviewers, from_time, to_time, ordering=[level])
data = []
found_results = set()
found_states = set()
raw_aggrs = []
for group_pk, request_data_items in itertools.groupby(extracted_data, key=lambda t: t[group_by_index]):
raw_aggr = aggregate_raw_review_request_stats(request_data_items, count=count)
raw_aggrs.append(raw_aggr)
aggr = compute_review_request_stats(raw_aggr)
# skip zero-valued rows
if aggr["open"] == 0 and aggr["completed"] == 0 and aggr["not_completed"] == 0:
continue
aggr["obj"] = group_by_objs.get(group_pk)
for slug in aggr["result"]:
found_results.add(slug)
for slug in aggr["state"]:
found_states.add(slug)
data.append(aggr)
# add totals row
if len(raw_aggrs) > 1:
totals = compute_review_request_stats(sum_raw_review_request_aggregations(raw_aggrs))
totals["obj"] = "Totals"
data.append(totals)
results = ReviewResultName.objects.filter(slug__in=found_results)
states = ReviewRequestStateName.objects.filter(slug__in=found_states)
# massage states/results breakdowns for template rendering
for aggr in data:
aggr["state_list"] = [aggr["state"].get(x.slug, 0) for x in states]
aggr["result_list"] = [aggr["result"].get(x.slug, 0) for x in results]
return render(request, 'stats/review_stats.html', {
"team_level_url": build_review_stats_url(acronym_override=None),
"level": level,
"reviewers_for_team": reviewers_for_team,
"teams": teams,
"data": data,
"states": states,
"results": results,
# options
"possible_stats_types": possible_stats_types,
"stats_type": stats_type,
"possible_count_choices": possible_count_choices,
"count": count,
"from_date": from_date,
"to_date": to_date,
"today": today,
# time options
"possible_teams": possible_teams,
"selected_teams": selected_teams,
"possible_completion_types": possible_completion_types,
"selected_completion_type": selected_completion_type,
"possible_results": possible_results,
"selected_result": selected_result,
"possible_states": possible_states,
"selected_state": selected_state,
})

View file

@ -1244,7 +1244,7 @@ ZSBvZiBsaW5lcyAtIGJ1dCBpdCBjb3VsZCBiZSBhIGRyYWZ0Cg==
self.assertEqual(len(q(selector)), 0)
# Find the link for our submission in those awaiting drafts
submission_url = self.get_href(q, "#waiting-for-draft a#aw{}:contains({})".
submission_url = self.get_href(q, "#waiting-for-draft a#aw{}:contains('{}')".
format(submission.pk, submission_name_fragment))
# Follow the link to the status page for this submission

View file

@ -106,6 +106,7 @@
<li><a href="/ipr/">IPR disclosures</a></li>
<li><a href="/liaison/">Liaison statements</a></li>
<li><a href="/iesg/agenda/">IESG agenda</a></li>
<li><a href="{% url "ietf.stats.views.stats_index" %}">Statistics</a></li>
<li><a href="/group/edu/materials/">Tutorials</a></li>
{% if flavor == "top" %}<li class="divider hidden-xs"></li>{% endif %}
<li><a href="https://tools.ietf.org/tools/ietfdb/newticket"><span class="fa fa-bug"></span> Report a bug</a></li>

View file

@ -25,6 +25,10 @@
<li><a href="{% url "ietf.ietfauth.views.create_account" %}">{% if request.user.is_authenticated %}Manage account{% else %}New account{% endif %}</a></li>
<li><a href="{%url "ietf.cookies.views.preferences" %}" rel="nofollow">Preferences</a></li>
{% if user|has_role:"Reviewer" %}
<li><a href="{% url "ietf.ietfauth.views.review_overview" %}">My reviews</a></li>
{% endif %}
{% if user|has_role:"Area Director" %}
{% if flavor == "top" %}<li class="divider hidden-xs"></li>{% endif %}
<li {%if flavor == "top" %}class="dropdown-header hidden-xs"{% else %}class="nav-header"{% endif %}>AD dashboard</li>

View file

@ -192,6 +192,33 @@
</td>
</tr>
{% if review_requests or can_request_review %}
<tr>
<th></th>
<th>Reviews</th>
<td class="edit"></td>
<td>
{% for review_request in review_requests %}
{% include "doc/review_request_summary.html" with current_doc_name=doc.name current_rev=doc.rev %}
{% endfor %}
{% if no_review_from_teams %}
{% for team in no_review_from_teams %}
{{ team.acronym.upper }}{% if not forloop.last %},{% endif %}
{% endfor %}
will not review this version
{% endif %}
{% if can_request_review %}
<div>
<a class="btn btn-default btn-xs" href="{% url "ietf.doc.views_review.request_review" doc.name %}"><span class="fa fa-check-circle-o"></span> Request review</a>
</div>
{% endif %}
</td>
</tr>
{% endif %}
{% if conflict_reviews %}
<tr>
<th></th>

View file

@ -0,0 +1,118 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin %}
{% load staticfiles %}
{% load ietf_filters %}
{% block title %}{{ doc.title }}{% endblock %}
{% block content %}
{% origin %}
{{ top|safe }}
{% include "doc/revisions_list.html" %}
<table class="table table-condensed">
<thead id="message-row">
<tr>
{% if doc.rev != latest_rev %}
<th colspan="4" class="alert-warning">The information below is for an old version of the document</th>
{% else %}
<th colspan="4"></th>
{% endif %}
</tr>
</thead>
<tbody class="meta">
<tr>
<th></th>
<th>Team</th>
<td class="edit"></td>
<td>
{{ doc.group.name }}
<a href="{{ doc.group.about_url }}">({{ doc.group.acronym }})</a>
{% if snapshot %}
<span class="label label-warning">Snapshot</span>
{% endif %}
</td>
</tr>
<tr>
<th></th>
<th>Title</th>
<td class="edit"></td>
<td>{{ doc.title }}</td>
</tr>
{% if doc.get_state_slug != "active" %}
<tr>
<th></th>
<th>State</th>
<td class="edit"></td>
<td>{{ doc.get_state.name }}</td>
</tr>
{% endif %}
{% if review_req %}
<tr>
<th></th>
<th>Request</th>
<td class="edit"></td>
<td>{{ review_req.type.name }} - <a href="{% url "ietf.doc.views_review.review_request" review_req.doc.name review_req.pk %}">requested {{ review_req.time|date:"Y-m-d" }}</a></td>
</tr>
<tr>
<th></th>
<th>Reviewer</th>
<td class="edit"></td>
<td>{{ review_req.reviewer.person }}</td>
</tr>
{% if review_req.result %}
<tr>
<th></th>
<th>Review result</th>
<td class="edit"></td>
<td>{{ review_req.result.name }}</td>
</tr>
{% endif %}
{% endif %}
{% if doc.external_url %}
<tr>
<th></th>
<th>Posted at</th>
<td class="edit"></td>
<td><a href="{{ doc.external_url }}">{{ doc.external_url }}</a></td>
</tr>
{% endif %}
<tr>
<th></th>
<th>Last updated</th>
<td class="edit"></td>
<td>{{ doc.time|date:"Y-m-d" }}</td>
</tr>
{% if other_reviews %}
<tr>
<th></th>
<th>Other reviews</th>
<td class="edit"></td>
<td>
{% for review_request in other_reviews %}
{% include "doc/review_request_summary.html" with current_doc_name=review_req.doc_id current_rev=review_req.reviewed_rev %}
{% endfor %}
</td>
</tr>
{% endif %}
</tbody>
</table>
<h2>{{ doc.type.name }}<br><small>{{ doc.name }}</small></h2>
{% if doc.rev and content != None %}
{{ content|fill:"80"|safe|linebreaksbr|keep_spacing|sanitize_html|safe }}
{% endif %}
{% endblock %}

View file

@ -14,7 +14,7 @@
<p>Resurrect {{ doc }}?</p>
<p>
This will change the status to Active {% if doc.idinternal.resurrect_requested_by %} and email a notice to {{ doc.idinternal.resurrect_requested_by }}{% endif %}.
This will change the status to Active{% if resurrect_requested_by %} and email a notice to {{ resurrect_requested_by }}{% endif %}.
</p>
<form method="post">

View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}Assign reviewer for {{ review_req.doc.name }}{% endblock %}
{% block content %}
{% origin %}
<h1>Assign reviewer<br><small>{{ review_req.doc.name }}</small></h1>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
<button type="submit" class="btn btn-primary" name="action" value="assign">Assign reviewer</button>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}Close review request for {{ review_req.doc.name }}{% endblock %}
{% block content %}
{% origin %}
<h1>Close review request<br><small>{{ review_req.doc.name }}</small></h1>
<p>Do you want to close the review request?</p>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
<button type="submit" class="btn btn-primary">Close request</button>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -0,0 +1,92 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}{% if revising_review %}Revise{% else %}Complete{% endif %} review of {{ review_req.doc.name }}{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
{% endblock %}
{% block content %}
{% origin %}
<h1>{% if revising_review %}Revise{% else %}Complete{% endif %} review<br><small>{{ review_req.doc.name }}</small></h1>
{% if not revising_review %}
<p>The review findings should be made available here and the review
posted to the mailing list. If you enter the findings below, the
system will post the review for you. If you already have posted
the review, you can try to let the system find the link to the
archive and retrieve the email body.</p>
{% else %}
<p>You can revise this review by entering the results below.</p>
{% endif %}
<form class="complete-review form-horizontal" method="post" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form form layout="horizontal" %}
{% buttons %}
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
<button type="submit" class="btn btn-primary">{% if revising_review %}Revise{% else %}Complete{% endif %} review</button>
{% endbuttons %}
<div class="template" style="display:none">
{% if mail_archive_query_urls %}
<div class="mail-archive-search form-group">
<div class="col-md-offset-2 col-md-10">
<p class="form-inline">
Search mail archive subjects for:
<input class="query-input form-control input-sm" value="{{ mail_archive_query_urls.query }}">
<button type="button" class="search btn btn-default btn-sm">Search</button>
</p>
<div class="retrieving hidden">
<span class="fa fa-spin fa-circle-o-notch"></span>
Searching...
</div>
<div class="results hidden">
<p>Select one of the following messages to automatically pre-fill link and content:</p>
<div class="list-group">
</div>
</div>
<div class="error alert alert-warning hidden">
<p>
<span class="content"></span>
<span class="hidden try-yourself">(searched for <a class="query-url" href="">"<span class="query"></span>"</a>, corresponding <a class="query-data-url" href="">export</a>).</span>
You have to fill in link and content yourself.
</p>
</div>
</div>
</div>
{% else %}
<div class="mail-archive-search">
<small class="text-muted">Mailing list does not have a recognized ietf.org archive. Auto-searching disabled.</small>
</div>
{% endif %}
</div>
{% spaceless %}
<div class="template" style="display:none">
<button type="button" class="mail-archive-search-result list-group-item">
<small class="date pull-right"></small>
<small class="from pull-right"></small>
<span class="subject"></span>
</button>
</div>
{% endspaceless %}
</form>
{% endblock %}
{% block js %}
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
<script>
var searchMailArchiveUrl = "{% url "ietf.doc.views_review.search_mail_archive" name=review_req.doc.name request_id=review_req.pk %}";
</script>
<script src="{% static 'ietf/js/complete-review.js' %}"></script>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}Reject review assignment for {{ review_req.doc.name }}{% endblock %}
{% block content %}
{% origin %}
<h1>Reject review assignment<br><small>{{ review_req.doc.name }}</small></h1>
<p>{{ review_req.reviewer.person }} is currently assigned to do the review. Do you want to reject this assignment?</p>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
<button type="submit" class="btn btn-primary" name="action" value="reject">Reject assignment</button>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
{% endblock %}
{% block title %}Request review of {{ doc.name }} {% endblock %}
{% block content %}
{% origin %}
<h1>Request review<br><small>{{ doc.name }}</small></h1>
<p>Submit a request to have the document reviewed.</p>
<p>
<div>Current revision of the document: <strong>{{ doc.rev }}</strong>.</div>
{% if lc_ends %}
<div>Last Call ends: <strong>{{ lc_ends|date:"Y-m-d" }}</strong> (in {{ lc_ends_days }} day{{ lc_ends_days|pluralize }}).</div>
{% endif %}
{% if scheduled_for_telechat %}
<div>Scheduled for telechat: <strong>{{ scheduled_for_telechat|date:"Y-m-d" }}</strong> (in {{ scheduled_for_telechat_days }} day{{ scheduled_for_telechat_days|pluralize }}).</div>
{% endif %}
</p>
<form class="form-horizontal" method="post">
{% csrf_token %}
{% bootstrap_field form.requested_by layout="horizontal" %}
{% bootstrap_field form.type layout="horizontal" %}
{% bootstrap_field form.team layout="horizontal" %}
{% bootstrap_field form.deadline layout="horizontal" %}
{% bootstrap_field form.requested_rev layout="horizontal" %}
{% buttons %}
<button type="submit" class="btn btn-primary">Request review</button>
<a class="btn btn-default pull-right" href="{% url "doc_view" name=doc.canonical_name %}">Back</a>
{% endbuttons %}
</form>
{% endblock %}
{% block js %}
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{% endblock %}

View file

@ -0,0 +1,167 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}Review request for {{ review_req.doc.name }}{% endblock %}
{% block content %}
{% origin %}
<h1>Review request<br><small>{{ review_req.doc.name }}</small></h1>
<table class="table table-condensed">
<tbody class="meta">
<tr>
<th>Request</th>
<th>Review of</th>
<td>
{% if review_req.requested_rev %}
<a href="{% url "doc_view" name=review_req.doc.name rev=review_req.requested_rev %}">{{ review_req.doc.name }}-{{ review_req.requested_rev }}</a>
{% else %}
<a href="{% url "doc_view" name=review_req.doc.name %}">{{ review_req.doc.name }}</a>
{% endif %}
</td>
</tr>
<tr>
<th></th>
<th>Requested rev.</th>
<td>
{% if review_req.requested_rev %}
{{ review_req.requested_rev }}
{% else %}
no specific revision
{% endif %}
{% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %}
</td>
</tr>
<tr>
<th></th>
<th>Type</th>
<td>{{ review_req.type.name }} Review</td>
</tr>
<tr>
<th></th>
<th>Team</th>
<td><a href="{% url "ietf.group.views_review.review_requests" group_type=review_req.team.type_id acronym=review_req.team.acronym %}">{{ review_req.team.acronym|upper }}</a></td>
</tr>
<tr>
<th></th>
<th>Deadline</th>
<td>{{ review_req.deadline|date:"Y-m-d" }}</td>
</tr>
<tr>
<th></th>
<th>Requested</th>
<td>{{ review_req.time|date:"Y-m-d" }}</td>
</tr>
{% if review_req.requested_by.name != "(System)" %}
<tr>
<th></th>
<th>Requested by</th>
<td>{{ review_req.requested_by }}</td>
</tr>
{% endif %}
</tbody>
<tbody class="meta">
<tr>
<th>Review</th>
<th>State</th>
<td>{{ review_req.state.name }}</td>
</tr>
<tr>
<th></th>
<th>Reviewer</th>
<td>
{% if review_req.reviewer %}
{{ review_req.reviewer.person }}
{% else %}
None assigned yet
{% endif %}
{% if can_assign_reviewer %}
<a class="btn btn-default btn-xs" href="{% url "ietf.doc.views_review.assign_reviewer" name=doc.name request_id=review_req.pk %}"><span class="fa fa-user"></span> {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer</a>
{% endif %}
{% if review_req.reviewer %}
{% if can_reject_reviewer_assignment or can_accept_reviewer_assignment %}
<div class="reviewer-assignment-not-accepted">
{% if review_req.state_id == "requested"%}
<em>Assignment not accepted yet:</em>
{% else %}
<em>Assignment accepted:</em>
{% endif %}
{% if can_reject_reviewer_assignment %}
<a class="btn btn-danger btn-xs" href="{% url "ietf.doc.views_review.reject_reviewer_assignment" name=doc.name request_id=review_req.pk %}"><span class="fa fa-ban"></span> Reject</a>
{% endif %}
{% if can_accept_reviewer_assignment %}
<form style="display:inline" method="post" action="{% url "ietf.doc.views_review.review_request" name=doc.name request_id=review_req.pk %}">{% csrf_token %}<button class="btn btn-success btn-xs" type="submit" name="action" value="accept"><span class="fa fa-check"></span> Accept</button></form>
{% endif %}
</div>
{% endif %}
{% endif %}
</td>
</tr>
<tr>
<th></th>
<th>Review</th>
<td>
{% if review_req.review %}
<a href="{{ review_req.review.get_absolute_url }}">{{ review_req.review.name }}</a>
{% elif review_req.state_id == "requested" or review_req.state_id == "accepted" %}
Not completed yet
{% else %}
Not available
{% endif %}
{% if can_complete_review %}
<a class="btn btn-primary btn-xs" href="{% url "ietf.doc.views_review.complete_review" name=doc.name request_id=review_req.pk %}"><span class="fa fa-pencil-square-o"></span> {% if review_req.state_id == "requested" or review_req.state_id == "accepted" %}Complete review{% else %}Correct review{% endif %}</a>
{% endif %}
</td>
</tr>
{% if review_req.review and review_req.review.external_url %}
<tr>
<th></th>
<th>Posted at</th>
<td>
<a href="{{ review_req.review.external_url }}">{{ review_req.review.external_url }}</a>
</td>
</tr>
{% endif %}
{% if review_req.reviewed_rev %}
<tr>
<th></th>
<th>Reviewed rev.</th>
<td><a href="{% url "doc_view" name=review_req.doc.name rev=review_req.reviewed_rev %}">{{ review_req.reviewed_rev }}</a> {% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %}</td>
</tr>
{% endif %}
{% if review_req.result %}
<tr>
<th></th>
<th>Review result</th>
<td>{{ review_req.result.name }}</td>
</tr>
{% endif %}
</tbody>
</table>
<div>
{% if can_close_request %}
<a class="btn btn-danger btn-xs" href="{% url "ietf.doc.views_review.close_request" name=doc.name request_id=review_req.pk %}"><span class="fa fa-ban"></span> Close request</a>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,12 @@
<div class="review-request-summary">
{% if review_request.state_id == "completed" or review_request.state_id == "part-completed" %}
<a href="{% if review_request.review %}{% url "doc_view" review_request.review.name %}{% else %}{% url "ietf.doc.views_review.review_request" review_request.doc_id review_request.pk %}{% endif %}">
{{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev or review_request.doc_id != current_doc_name %} (of {% if review_request.doc_id != current_doc_name %}{{ review_request.doc_id }}{% endif %}-{{ review_request.reviewed_rev }}){% endif %}{% if review_request.result %}:
{{ review_request.result.name }}{% endif %} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %}
</a>
{% else %}
<i>
<a href="{% url "ietf.doc.views_review.review_request" review_request.doc_id review_request.pk %}">{{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review
- due: {{ review_request.deadline|date:"Y-m-d" }}</a></i>
{% endif %}
</div>

View file

@ -42,9 +42,16 @@
{{ doc.intended_std_level }}
{% endif %}
{% if doc.reviewed_by_teams %}
<br>Reviews:
{% for g in doc.reviewed_by_teams %}
{{ g.acronym }}{% if not forloop.last %}, {% endif %}
{% endfor %}
{% endif %}
{% for m in doc.milestones %}
{% if forloop.first %}<br>{% else %}, {% endif %}
<span title="Part of {{ m.group.acronym }} milestone: {{ m.desc }}" class="milestone">{{ m.due|date:"M Y" }}</span>
{% if forloop.first %}<br>{% endif %}
<span title="Part of {{ m.group.acronym }} milestone: {{ m.desc }}" class="milestone">{{ m.due|date:"M Y" }}</span>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}{# RFC #}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}{% origin %}
{% load ietf_filters staticfiles bootstrap3 %}
{% block content %}
{% origin %}
<h1>{% block title %}Change your review secretary settings for {{ group.acronym }}{% endblock %}</h1>
<form class="change-review-secretary-settings" method="post">{% csrf_token %}
{% bootstrap_form settings_form %}
{% buttons %}
<a href="{{ back_url }}" class="btn btn-default pull-right">Cancel</a>
<button class="btn btn-primary" type="submit" name="action" value="change_settings">Save</button>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -0,0 +1,87 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}{% origin %}
{% load ietf_filters staticfiles bootstrap3 %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
{% endblock %}
{% block title %}Change reviewer settings for {{ group.acronym }} for {{ reviewer_email }}{% endblock %}
{% block content %}
{% origin %}
<h1>Change reviewer settings for {{ group.acronym }} for {{ reviewer_email }}</h1>
<h3>Settings</h3>
<form class="change-reviewer-settings" method="post">{% csrf_token %}
{% bootstrap_form settings_form %}
{% buttons %}
<a href="{{ back_url }}" class="btn btn-default pull-right">Cancel</a>
<button class="btn btn-primary" type="submit" name="action" value="change_settings">Save</button>
{% endbuttons %}
</form>
<h3>Unavailable periods</h3>
<p>You can register periods where reviews should not be assigned.</p>
{% if unavailable_periods %}
<table class="table">
{% for o in unavailable_periods %}
<tr class="unavailable-period-{{ o.state }}">
<td>
{{ o.start_date|default:"indefinite" }} - {{ o.end_date|default:"indefinite" }}
</td>
<td>{{ o.get_availability_display }}</td>
<td>
{% if not o.end_date %}
<form method="post" class="form-inline" style="display:inline-block">
{% csrf_token %}
<input type="hidden" name="period_id" value="{{ o.pk }}">
{% bootstrap_form o.end_form layout="inline" %}
<button type="submit" class="btn btn-default btn-sm" name="action" value="end_period">End period</button>
</form>
{% endif %}
</td>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="period_id" value="{{ o.pk }}">
<button type="submit" class="btn btn-danger btn-sm" name="action" value="delete_period">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>No periods found.</p>
{% endif %}
<div><a class="btn btn-default" data-toggle="collapse" data-target="#add-new-period">Add a new period</a></div>
<div id="add-new-period" {% if not period_form.errors %}class="collapse"{% endif %}>
<h4>Add a new period</h4>
<form method="post">
{% csrf_token %}
{% bootstrap_form period_form %}
{% buttons %}
<button type="submit" class="btn btn-primary" name="action" value="add_period">Add period</button>
{% endbuttons %}
</form>
</div>
<p style="padding-top: 2em;">
<a href="{{ back_url }}" class="btn btn-default">Back</a>
</p>
{% endblock %}
{% block js %}
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}{% origin %}
{% load ietf_filters staticfiles bootstrap3 %}
{% block title %}Email summary of assigned review requests for {{ group.acronym }}{% endblock %}
{% block content %}
{% origin %}
<h1>Email summary of assigned review requests for {{ group.acronym }}</h1>
{% if review_requests %}
<form class="email-open-review-assignments" method="post">{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<a href="{{ back_url }}" class="btn btn-default pull-right">Cancel</a>
<button class="btn btn-primary" type="submit" name="action" value="email">Send to team mailing list</button>
{% endbuttons %}
</form>
{% else %}
<p>There are currently no open requests.</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,8 @@
{% autoescape off %}
Reviewer Deadline Draft
{% for r in review_requests %}{{ r.reviewer.person.plain_name|ljust:"22" }} {{ r.deadline|date:"Y-m-d" }} {{ r.doc_id }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}
{% endfor %}
{% if rotation_list %}Next in the reviewer rotation:
{% for p in rotation_list %} {{ p }}
{% endfor %}{% endif %}{% endautoescape %}

View file

@ -0,0 +1,172 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}{% origin %}
{% load ietf_filters staticfiles bootstrap3 %}
{% block title %}Manage open review requests for {{ group.acronym }}{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static "jquery.tablesorter/css/theme.bootstrap.min.css" %}">
{% endblock %}
{% block content %}
{% origin %}
<h1>Manage {{ assignment_status }} open review requests for {{ group.acronym }}</h1>
<p>Other options:
<a href="{% url "ietf.group.views_review.review_requests" group_type=group.type_id acronym=group.acronym %}">All review requests</a>
- <a href="{% url "ietf.group.views_review.reviewer_overview" group_type=group.type_id acronym=group.acronym %}">Reviewers</a>
- <a href="{% url "ietf.group.views_review.email_open_review_assignments" group_type=group.type_id acronym=group.acronym %}?next={{ request.get_full_path|urlencode }}">Email open assignments summary</a>
{% if other_assignment_status %}
- <a href="{% url "ietf.group.views_review.manage_review_requests" group_type=group.type_id acronym=group.acronym assignment_status=other_assignment_status %}">Manage {{ other_assignment_status }} reviews</a>
{% endif %}
</p>
{% if newly_closed > 0 or newly_opened > 0 or newly_assigned > 0 %}
<p class="alert alert-danger">
Changes since last refresh:
{% if newly_closed %}{{ newly_closed }} request{{ newly_closed|pluralize }} closed.{% endif %}
{% if newly_opened %}{{ newly_opened }} request{{ newly_opened|pluralize }} opened.{% endif %}
{% if newly_assigned %}{{ newly_assigned }} request{{ newly_assigned|pluralize }} changed assignment.{% endif %}
{% if saving %}
Check that you are happy with the results, then re-save.
{% endif %}
</p>
{% endif %}
{% if review_requests %}
<form class="review-requests" method="post">{% csrf_token %}
{% for r in review_requests %}
<div class="panel panel-default review-request">
<div class="panel-heading">
<h3 class="panel-title">
<span class="pull-right">
{{ r.type.name }}
- deadline {{ r.deadline|date:"Y-m-d" }}
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
</span>
<a href="{% if r.requested_rev %}{% url "doc_view" name=r.doc.name rev=r.requested_rev %}{% else %}{% url "doc_view" name=r.doc.name %}{% endif %}?include_text=1">{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a>
</h3>
</div>
<div class="panel-body">
<div class="row">
<div class="col-sm-6">
<div class="request-metadata">
<p>
{% if r.pk != None %}Requested: <a href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}">{{ r.time|date:"Y-m-d" }}</a>
{% else %}
Auto-suggested
{% endif %}
</p>
{% if r.latest_reqs %}
{% for rlatest in r.latest_reqs %}
<div>
Revious review of <a href="{% url "doc_view" name=rlatest.doc_id rev=rlatest.reviewed_rev %}?include_text=1">{% if rlatest.doc_id != r.doc_id %}{{ rlatest.doc_id }}{% endif %}-{{ rlatest.reviewed_rev }}</a>
(<a href="{{ rfcdiff_base_url }}?url1={{ rlatest.doc.name }}-{{ rlatest.reviewed_rev }}&url2={{ r.doc.name }}-{{ r.doc.rev }}">diff</a>):
<a href="{% url "ietf.doc.views_review.review_request" name=rlatest.doc_id request_id=rlatest.pk %}">{% if rlatest.result %}{{ rlatest.result.name }}{% else %}result unavail.{% endif %}</a>
by {{ rlatest.reviewer.person }}{% if rlatest.closed_review_request_event %} {{ rlatest.closed_review_request_event.time.date|date }}{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
<div><strong>{{ r.doc.title }}</strong></div>
<div>
{{ r.doc.pages }} page{{ r.doc.pages|pluralize }}
- {{ r.doc.friendly_state }}
{% if r.doc.telechat_date %}
- IESG telechat: {{ r.doc.telechat_date }}
{% endif %}
{% if r.doc.group.type_id != "individ" %}
- <a href="{% url "ietf.group.views.group_home" acronym=r.doc.group.acronym group_type=r.doc.group.type_id %}">{{ r.doc.group.acronym }} {{ r.doc.group.type.name }}</a>
{% endif %}
</div>
</div>
<div class="col-sm-6 abstract">
{{ r.doc.abstract|linebreaks }}
</div>
</div>
</div>
<div class="panel-footer">
{% if r.form.non_field_errors %}
<div class="alert alert-danger">
{% for e in r.form.non_field_errors %}
{{ e }}
{% endfor %}
</div>
{% endif %}
<input type="hidden" name="reviewrequest" value="{{ r.pk }}">
<input type="hidden" name="{{ r.form.prefix }}-existing_reviewer" value="{{ r.reviewer_id|default:"" }}">
<span class="assign-action">
{% if r.reviewer %}
<button type="button" class="btn btn-default btn-success" title="Click to reassign reviewer">
{{ r.reviewer.person }}
{% if r.state_id == "accepted" %} <span class="label label-default">accepted</span>{% endif %}
{% if r.reviewer_unavailable %}<span class="label label-danger">unavailable</span>{% endif %}
</button>
{% else %}
<button type="button" class="btn btn-default btn-success" title="Click to assign reviewer"><em>Not yet assigned</em></button>
{% endif %}
</span>
{{ r.form.action }}
<span class="reviewer-controls form-inline">
<label for="{{ r.form.reviewer.id_for_label }}">Assign:</label>
{{ r.form.reviewer }}
<button type="button" class="btn btn-default undo" title="Cancel assignment" data-initial="{{ r.form.fields.reviewer.initial|default:"" }}">Cancel</button>
{% if r.form.reviewer.errors %}
<div class="alert alert-danger">
{% for e in r.form.reviewer.errors %}
{{ e }}
{% endfor %}
</div>
{% endif %}
</span>
<span class="close-action">
<button type="button" class="btn btn-default btn-danger">Close...</button>
</span>
<span class="close-controls form-inline">
<label for="{{ r.form.reviewer.id_for_label }}">Close:</label>
{{ r.form.close }}
<button type="button" class="btn btn-default undo" title="Cancel closing">Cancel</button>
{% if r.form.close.errors %}
<br>
{{ r.form.close.errors }}
{% endif %}
</span>
</div>
</div>
{% endfor %}
{% buttons %}
<a href="{% url "ietf.group.views_review.review_requests" group_type=group.type_id acronym=group.acronym %}" class="btn btn-default pull-right">Cancel</a>
<button class="btn btn-primary" type="submit" name="action" value="save">Save changes</button>
<button class="btn btn-primary" type="submit" name="action" value="save-continue">Save and continue editing</button>
<button class="btn btn-default" type="submit" name="action" value="refresh">Refresh (keeping changes)</button>
{% endbuttons %}
</form>
{% else %}
<p>There are currently no {{ assignment_status }} open requests.</p>
{% endif %}
{% endblock %}
{% block js %}
<script src="{% static "jquery.tablesorter/js/jquery.tablesorter.combined.min.js" %}"></script>
<script src="{% static "ietf/js/manage-review-requests.js" %}"></script>
{% endblock %}

View file

@ -0,0 +1,129 @@
{% extends "group/group_base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}{% origin %}
{% load ietf_filters staticfiles bootstrap3 %}
{% block group_subtitle %}Review requests{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static "jquery.tablesorter/css/theme.bootstrap.min.css" %}">
{% endblock %}
{% block group_content %}
{% origin %}
{% if can_access_stats %}
<h1 class="pull-right"><a href="{% url "ietf.stats.views.review_stats" %}" class="icon-link">&nbsp;<span class="small fa fa-bar-chart">&nbsp;</span></a></h1>
{% endif %}
{% for label, review_requests in open_review_requests %}
{% if review_requests %}
<h2>{{ label }} open review requests</h2>
<table class="table table-condensed table-striped tablesorter">
<thead>
<tr>
<th>Request</th>
<th>Type</th>
<th>Requested</th>
<th>Deadline</th>
{% if review_requests.0.reviewer %}
<th>Reviewer</th>
{% endif %}
<th>Document state</th>
<th>IESG Telechat</th>
</tr>
</thead>
<tbody>
{% for r in review_requests %}
<tr>
<td>{% if r.pk != None %}<a href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}">{% endif %}{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}{% if r.pk != None %}</a>{% endif %}</td>
<td>{{ r.type.name }}</td>
<td>{% if r.pk %}{{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %}</td>
<td>
{{ r.deadline|date:"Y-m-d" }}
{% if r.due %}<span class="label label-warning" title="{{ r.due }} day{{ r.due|pluralize }} past deadline">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
</td>
{% if r.reviewer %}
<td>
{{ r.reviewer.person }}
{% if r.state_id == "accepted" %}<span class="label label-default">Accepted</span>{% endif %}
{% if r.reviewer_unavailable %}<span class="label label-danger">Unavailable</span>{% endif %}
</td>
{% endif %}
<td>
{{ r.doc.friendly_state }}
</td>
<td>
{% if r.doc.telechat_date %}
{{ r.doc.telechat_date }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endfor %}
<h2 id="closed-review-requests">Closed review requests</h2>
<form class="closed-review-filter" action="#closed-review-requests">
Past:
<div class="btn-group" role="group">
{% for key, label in since_choices %}
<button class="btn btn-default {% if since == key %}active{% endif %}" {% if key %}name="since" value="{{ key }}"{% endif %} type="submit">{{ label }}</button>
{% endfor %}
</div>
</form>
{% if closed_review_requests %}
<table class="table table-condensed table-striped materials tablesorter">
<thead>
<tr>
<th>Request</th>
<th>Type</th>
<th>Requested</th>
<th>Deadline</th>
<th>Reviewer</th>
<th>State</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{% for r in closed_review_requests %}
<tr>
<td><a href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}">{{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %}</a></td>
<td>{{ r.type }}</td>
<td>{{ r.time|date:"Y-m-d" }}</td>
<td>{{ r.deadline|date:"Y-m-d" }}</td>
<td>
{% if r.reviewer %}
{{ r.reviewer.person }}
{% else %}
<em>not yet assigned</em>
{% endif %}
</td>
<td>{{ r.state.name }}</td>
<td>
{% if r.result %}
{{ r.result.name }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No closed requests found.</p>
{% endif %}
{% endblock %}
{% block js %}
<script src="{% static "jquery.tablesorter/js/jquery.tablesorter.combined.min.js" %}"></script>
{% endblock %}

View file

@ -0,0 +1,76 @@
{% extends "group/group_base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}{% origin %}
{% load ietf_filters staticfiles bootstrap3 %}
{% block group_subtitle %}Reviewers{% endblock %}
{% block group_content %}
{% origin %}
{% if can_access_stats %}
<h1 class="pull-right"><a href="{% url "ietf.stats.views.review_stats" stats_type="completion" acronym=group.acronym %}" class="icon-link">&nbsp;<span class="small fa fa-bar-chart">&nbsp;</span></a></h1>
{% endif %}
<h2>Reviewers</h2>
<p>Status of the reviewers in {{ group.acronym }}, ordered by their
rotation with the next reviewer in the rotation at the top.</p>
{% if reviewers %}
<table class="table reviewer-overview">
<thead>
<tr>
<th>Reviewer</th>
<th>Deadline/state/time between assignment and closure for latest assignments</th>
<th>Settings</th>
</tr>
</thead>
<tbody>
{% for person in reviewers %}
<tr {% if person.completely_unavailable %}class="completely-unavailable"{% endif %}>
<td>{% if person.settings_url %}<a href="{{ person.settings_url }}">{% endif %}{{ person }}{% if person.settings_url %}</a>{% endif %}</td>
<td>
<table class="simple-table">
{% for req_pk, doc_name, reviewed_rev, deadline, state, assignment_to_closure_days in person.latest_reqs %}
<tr>
<td><a href="{% url "ietf.doc.views_review.review_request" name=doc_name request_id=req_pk %}">{{ deadline|date }}</a></td>
<td>
<span class="label label-{% if state.slug == "completed" or state.slug == "part-completed" %}success{% elif state.slug == "no-response" %}danger{% elif state.slug == "overtaken" %}warning{% elif state.slug == "requested" or state.slug == "accepted" %}primary{% else %}default{% endif %}">{{ state.name }}</span>
</td>
<td>
{% if assignment_to_closure_days != None %}{{ assignment_to_closure_days }}&nbsp;day{{ assignment_to_closure_days|pluralize }}{% endif %}
</td>
<td>{{ doc_name }}{% if reviewed_rev %}-{{ reviewed_rev }}{% endif %}</td>
</div>
{% endfor %}
</table>
</td>
<td>
{% if person.settings.min_interval %}
{{ person.settings.get_min_interval_display }}<br>
{% endif %}
{% if person.settings.skip_next %}
Skip: {{ person.settings.skip_next }}<br>
{% endif %}
{% if person.settings.filter_re %}
Filter: <code title="{{ person.settings.filter_re }}">{{ person.settings.filter_re|truncatechars:15 }}</code><br>
{% endif %}
{% if person.unavailable_periods %}
{% include "review/unavailable_table.html" with unavailable_periods=person.unavailable_periods %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No reviewers found.</p>
{% endif %}
{% endblock %}

View file

@ -47,6 +47,15 @@
<dt>Consensus</dt><dd>{{ doc.consensus }}</dd>
{% endif %}
{% if doc.review_requests %}
<dt>Reviews</dt>
<dd>
{% for review_request in doc.review_requests %}
{% include "doc/review_request_summary.html" with current_doc_name=doc.name current_rev=doc.rev %}
{% endfor %}
</dd>
{% endif %}
{% if doc.lastcall_expires %}
<dt>Last call expires</dt><dd>{{ doc.lastcall_expires|date:"Y-m-d" }}</dd>
{% endif %}

View file

@ -5,5 +5,7 @@
{% endif %} Token: {{ doc.ad }}{% if doc.iana_review_state %}
IANA Review: {{ doc.iana_review_state }}{% endif %}{% if doc.consensus %}
Consensus: {{ doc.consensus }}{% endif %}{% if doc.lastcall_expires %}
Last call expires: {{ doc.lastcall_expires|date:"Y-m-d" }}{% endif %}
Last call expires: {{ doc.lastcall_expires|date:"Y-m-d" }}{% endif %}{% if doc.review_requests %}
Reviews: {% for review_request in doc.review_requests %}{% with current_doc_name=doc.name current_rev=doc.rev %}{% if not forloop.first %} {% endif %}{{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.state_id == "completed" or review_request.state_id == "part-completed" %}{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev or review_request.doc_id != current_doc_name %} (of {% if review_request.doc_id != current_doc_name %}{{ review_request.doc_id }}{% endif %}-{{ review_request.reviewed_rev }}){% endif %}{% if review_request.result %}: {{ review_request.result.name }}{% endif %} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %}{% else %} - due: {{ review_request.deadline|date:"Y-m-d" }}{% endif %}{% endwith %}
{% endfor %}{% endif %}
{% with doc.active_defer_event as defer %}{% if defer %} Was deferred by {{defer.by}} on {{defer.time|date:"Y-m-d"}}{% endif %}{% endwith %}

View file

@ -0,0 +1,170 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load bootstrap3 static %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{% endblock %}
{% block title %}Review overview for {{ request.user }}{% endblock %}
{% block content %}
{% origin %}
<h1>Review overview for {{ request.user }}</h1>
<h2>Assigned reviews</h2>
{% if open_review_requests %}
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Request</th>
<th>Team</th>
<th>Type</th>
<th>Deadline</th>
</tr>
</thead>
<tbody>
{% for r in open_review_requests %}
<tr>
<td><a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a></td>
<td>{{ r.team.acronym }}</td>
<td>{{ r.type.name }}</td>
<td>
{{ r.deadline|date:"Y-m-d" }}
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>You do not have any open review requests assigned.</p>
{% endif %}
<h2>Latest closed review requests</h2>
{% if closed_review_requests %}
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Request</th>
<th>Team</th>
<th>Type</th>
<th>Deadline</th>
<th>State</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{% for r in closed_review_requests %}
<tr>
<td><a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a></td>
<td>{{ r.team.acronym }}</td>
<td>{{ r.type.name }}</td>
<td>
{{ r.deadline|date:"Y-m-d" }}
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
</td>
<td><span class="{% if r.state_id == "completed" or r.state_id == "part-completed" %}bg-success{% endif %}">{{ r.state.name }}</span></td>
<td>{% if r.result %}{{ r.result.name }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Did not find any closed review requests assigned to you.</p>
{% endif %}
<h2>Review wishes</h2>
{% if review_wishes %}
<p>You have indicated that you would like to review:</p>
<table class="table">
{% for w in review_wishes %}
<tr>
<td><a href="{% url "doc_view" w.doc_id %}">{{ w.doc_id }}</a></td>
<td>{{ w.team.acronym }}</td>
<td>
<form method="post">
{% csrf_token %}
<input name="wish_id" value="{{ w.pk }}" type="hidden">
<button class="btn btn-sm btn-danger" type="submit" name="action" value="delete_wish">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>You do not have any review wishes.</p>
{% endif %}
{% if teams %}
<p>Add a draft that you would like to review when it becomes available for review:</p>
<form role="form" method="post" class="form-inline">
{% csrf_token %}
{% bootstrap_form review_wish_form layout="inline" %}
{% buttons %}
<button class="btn btn-default" type="submit" name="action" value="add_wish">Add draft</button>
{% endbuttons %}
</form>
{% endif %}
{% for t in teams %}
<h2>Settings for {{ t }}</h2>
<table class="table">
<tr>
<th>Can review</th>
<td>{{ t.reviewer_settings.get_min_interval_display|default:"No max frequency set" }}</td>
</tr>
<tr>
<th>Skip next assignments</th>
<td>{{ t.reviewer_settings.skip_next }}</td>
</tr>
<tr>
<th>Filter regexp</th>
<td>{% if t.reviewer_settings.filter_re %}<code>{{ t.reviewer_settings.filter_re }}</code>{% else %}(None){% endif %}</td>
</tr>
<tr>
<th>Remind days before deadline</th>
<td>{{ t.reviewer_settings.remind_days_before_deadline|default:"(Do not remind)" }}</td>
</tr>
<tr>
<th>Unavailable periods</th>
<td>
{% if t.unavailable_periods %}
{% include "review/unavailable_table.html" with unavailable_periods=t.unavailable_periods %}
{% else %}
(No periods)
{% endif %}
</td>
</table>
<div>
<a class="btn btn-default" href="{% url "ietf.group.views_review.change_reviewer_settings" group_type=t.type_id acronym=t.acronym reviewer_email=t.role.email.address %}?next={{ request.get_full_path|urlencode }}">Change settings</a>
</div>
{% empty %}
<h2>Settings</h2>
<p>It looks like you are not a reviewer in any active review team.</p>
{% endfor %}
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{% endblock %}

View file

@ -19,7 +19,7 @@
<p>Value of <code>testmailcc</code>: {{ cookie }}</p>
<form role-"form" method="post">
<form role="form" method="post">
{% csrf_token %}
{% bootstrap_form form %}

View file

@ -81,7 +81,7 @@
</li>
</ol>
</p>
<form role-"form" method="post">
<form role="form" method="post">
{% csrf_token %}
{% bootstrap_form form %}

View file

@ -103,11 +103,13 @@
</div>
</div>
<div class="p col-lg-12 col-md-12 col-sm-12 col-xs-12">
The material posted as IPR disclosures should be viewed as originating
from the source of that information, and any issue or question related
to the material should be directed to the source rather than the
IETF. There is no implied endorsement or agreement by the IETF, the
IESG or any other IETF entities with any of the material.
</div>
<a class="btn btn-default pull-right" href="{% url "ietf.ipr.views.showlist" %}">Back</a>

View file

@ -23,7 +23,7 @@
<div class="panel panel-default">
<div class="panel-heading">Drafts already linked to this sesssion</div>
<div class="panel-body">
<table class="table table-contensed table-striped">
<table class="table table-condensed table-striped">
<tr>
<th class="col-md-1">Revision</th>
<th>Document</th>

View file

@ -0,0 +1,7 @@
{% autoescape off %}{% filter wordwrap:70 %}{% if review_req.state_id == "part-completed" %}Review is partially done. Another review request has been registered for completing it.
{% endif %}Reviewer: {{ review_req.reviewer.person }}
Review result: {{ review_req.result.name }}
{{ content }}
{% endfilter %}{% endautoescape %}

View file

@ -0,0 +1,10 @@
{% autoescape off %}Review was partially completed by {{ by }}.
{% if new_review_req_url %}
A new review request has been added for completing the review:
{{ new_review_req_url }}
{% else %}
Found {{ existing_open_reqs|length }} open review request{{ existing_open_reqs|pluralize }} on the document so a new
review request has not been added.
{% endif %}{% endautoescape %}

View file

@ -0,0 +1,9 @@
{% autoescape off %}
{{ review_req.type.name }} review of: {{ review_req.doc.name }} ({% if review_req.requested_rev %}rev. {{ review_req.requested_rev }}{% else %}no specific version{% endif %})
Deadline: {{ review_req.deadline|date:"Y-m-d" }}
{{ review_req_url }}
{{ msg|wordwrap:72 }}
{% endautoescape %}

View file

@ -0,0 +1,6 @@
{% autoescape off %}Reviewer assignment rejected by {{ by }}.{% if message_to_secretary %}
Explanation:
{{ message_to_secretary }}
{% endif %}{% endautoescape %}

View file

@ -0,0 +1,8 @@
{% autoescape off %}{% filter wordwrap:72 %}
Reviewer availability of {{ reviewer }} in {{ team.acronym }} changed by {{ by }}.
{{ msg }}
{{ reviewer_overview_url }}
{% endfilter %}{% endautoescape %}

View file

@ -0,0 +1,8 @@
{% autoescape off %}{% filter wordwrap:70 %}This is just a friendly reminder that the deadline for the review of {{ review_request.doc_id }} is in {{ deadline_days }} day{{ deadline_days|pluralize }}:
{{ review_request_url }}
You are receiving this reminder because you have configured the Datatracker to remind you {{ remind_days }} day{{ remind_days|pluralize }} before deadlines in {{ review_request.team.name }}. You can see your reviews and change your settings here:
{{ reviewer_overview_url }}
{% endfilter %}{% endautoescape %}

Some files were not shown because too many files have changed in this diff Show more