Create new branch from trunk@r11921, and merge review-tracker-r11360 into it

- Legacy-Id: 11923
This commit is contained in:
Ole Laursen 2016-09-06 10:17:12 +00:00
commit 74a02be9bf
61 changed files with 5012 additions and 654 deletions

View file

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

View file

@ -682,6 +682,10 @@ 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"),
("changed_review_request", "Changed review request"),
]
class DocEvent(models.Model):

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

@ -0,0 +1,540 @@
# -*- 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, ReviewTeamResult, ReviewerSettings
import ietf.review.mailarch
from ietf.person.models import Email, Person
from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName, DocRelationshipName
from ietf.doc.models import DocumentAuthor, Document, DocAlias, RelatedDocument, DocEvent
from ietf.utils.test_utils import TestCase
from ietf.utils.test_data import make_test_data, make_review_data
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, "secretary", 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="plain").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="secretary", password="secretary+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, "secretary", 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, "changed_review_request")
self.assertTrue("closed" in e.desc.lower())
self.assertEqual(len(outbox), 1)
self.assertTrue("closed" in unicode(outbox[0]).lower())
def test_assign_reviewer(self):
doc = make_test_data()
# set up some reviewer-suitability factors
plain_email = Email.objects.filter(person__user__username="plain").first()
DocumentAuthor.objects.create(
author=plain_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")])
# 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()
# 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=plain_email,
)
reviewer_settings = ReviewerSettings.objects.get(person__email=plain_email)
reviewer_settings.filter_re = doc.name
reviewer_settings.unavailable_until = datetime.datetime.now() + datetime.timedelta(days=10)
reviewer_settings.save()
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="secretary", password="secretary+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, "secretary", assign_url)
r = self.client.get(assign_url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
plain_label = q("option[value=\"{}\"]".format(plain_email.address)).text().lower()
self.assertIn("ready for", plain_label)
self.assertIn("reviewed document before", plain_label)
self.assertIn("is author", plain_label)
self.assertIn("regexp matches", plain_label)
self.assertIn("unavailable until", plain_label)
# assign
empty_outbox()
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team).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 unicode(outbox[0]))
# 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).exclude(pk=reviewer.pk).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 unicode(outbox[0]))
self.assertTrue("assigned" in unicode(outbox[1]))
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="secretary", password="secretary+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, "secretary", 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, "changed_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 unicode(outbox[0]))
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"] = "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"] = "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, "secretary", url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
messages = json.loads(r.content)["messages"]
self.assertEqual(len(messages), 2)
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[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))
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")):
ReviewTeamResult.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(reviewteamresult__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 unicode(outbox[0]))
self.assertTrue(settings.MAILING_LIST_ARCHIVE_URL in review_req.review.external_url)
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)
# complete by uploading file
empty_outbox()
r = self.client.post(url, data={
"result": ReviewResultName.objects.get(reviewteamresult__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 unicode(outbox[0]))
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)
# complete by uploading file
empty_outbox()
r = self.client.post(url, data={
"result": ReviewResultName.objects.get(reviewteamresult__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(reviewteamresult__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("secretary" in outbox[0]["To"])
self.assertTrue("partially" in outbox[0]["Subject"].lower())
self.assertTrue("new review request" in unicode(outbox[0]))
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 unicode(outbox[1]))
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(reviewteamresult__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

View file

@ -74,6 +74,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

@ -3,6 +3,7 @@ import re
import urllib
import math
import datetime
from collections import defaultdict
from django.conf import settings
from django.db.models.query import EmptyQuerySet
@ -109,7 +110,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()
@ -587,6 +587,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

@ -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

@ -50,7 +50,7 @@ from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDo
from ietf.doc.utils import ( add_links_in_new_revision_events, augment_events_with_revision,
can_adopt_draft, get_chartering_type, get_document_content, get_tags_for_stream_id,
needed_ballot_positions, nice_consensus, prettify_std_name, update_telechat, has_same_ballot,
get_initial_notify, make_notify_changed_event, crawl_history, default_consensus)
get_initial_notify, make_notify_changed_event, crawl_history, default_consensus )
from ietf.community.utils import augment_docs_with_tracking_info
from ietf.group.models import Role
from ietf.group.utils import can_manage_group, can_manage_materials
@ -59,10 +59,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_doc
from ietf.review.utils import no_review_from_teams_on_doc
def render_document_top(request, doc, tab, name):
tabs = []
@ -281,8 +284,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)
@ -296,6 +299,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:
@ -355,6 +360,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_doc(doc)
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,
@ -376,6 +384,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,
@ -414,6 +423,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))
@ -565,6 +576,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_doc(review_req.doc) 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

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

@ -0,0 +1,533 @@
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, DocEvent, State, DocAlias, LastCallDocEvent
from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName
from ietf.review.models import ReviewRequest
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
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
self.fields['type'].queryset = self.fields['type'].queryset.filter(used=True)
self.fields['type'].widget = forms.RadioSelect(choices=[t for t in self.fields['type'].choices if t[0]])
f = self.fields["team"]
f.queryset = active_review_teams()
if not is_authorized_in_doc_stream(user, doc): # user is a reviewer
f.queryset = f.queryset.filter(role__name="reviewer", role__person__user=user)
f.initial = [group.pk for group in f.queryset if can_manage_review_requests_for_team(user, group, allow_non_team_personnel=False)]
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"))
@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()
DocEvent.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,
)
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"]
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()
DocEvent.objects.create(
type="changed_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,
),
)
# 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("doc/mail/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)
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)
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(reviewteamresult__team=review_req.team)
self.fields["review_submission"].choices = [
(k, label.format(mailing_list=review_req.team.list_email or "[error: team has no mailing list set]"))
for k, label in self.fields["review_submission"].choices
]
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):
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, 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":
form = CompleteReviewForm(review_req, request.POST, request.FILES)
if form.is_valid():
review_submission = form.cleaned_data['review_submission']
# 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 if review_req.type.slug != "unknown" else "",
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)
break
e = NewRevisionDocEvent.objects.create(
type="new_revision",
doc=review,
by=request.user.person,
rev=review.rev,
desc='New revision available',
time=review.time,
)
review.type = DocTypeName.objects.get(slug="review")
review.rev = "00"
review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, form.cleaned_data["reviewed_rev"])
review.group = review_req.team
if review_submission == "link":
review.external_url = form.cleaned_data['review_url']
review.save_with_history([e])
review.set_state(State.objects.get(type="review", slug="active"))
DocAlias.objects.create(document=review, name=review.name)
# 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()
DocEvent.objects.create(
type="changed_review_request",
doc=review_req.doc,
by=request.user.person,
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,
),
)
if review_req.state_id == "part-completed":
new_review_req = make_new_review_request_from_existing(review_req)
new_review_req.save()
subject = "Review of {}-{} completed partially".format(review_req.doc.name, review_req.reviewed_rev)
url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": new_review_req.doc.name, "request_id": new_review_req.pk })
url = request.build_absolute_uri(url)
msg = render_to_string("doc/mail/partially_completed_review.txt", {
'new_review_req_url': url,
"by": request.user.person,
"new_review_req": new_review_req,
})
email_review_request_change(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=False)
if review_submission != "link" and review_req.team.list_email:
# 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,
"doc/mail/completed_review.txt", {
"review_req": review_req,
"content": encoded_content.decode("utf-8"),
},
cc=form.cleaned_data["cc"])
e = DocEvent.objects.create(
type="changed_review_request",
doc=review_req.doc,
by=request.user.person,
desc="Sent review to list.",
)
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([e])
return redirect("doc_view", name=review_req.review.name)
else:
form = CompleteReviewForm(review_req)
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,
})
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, state__in=["requested", "accepted"])
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)

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_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,9 @@ 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
import ietf.group.views
def group_urlreverse_list(group, viewname):
return [
@ -335,6 +336,31 @@ class GroupPagesTests(TestCase):
self.assertEqual(r.status_code, 200)
self.assertTrue(doc.title not in unicontent(r))
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_requests, kwargs={ 'acronym': group.acronym }),
urlreverse(ietf.group.views.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_requests, kwargs={ 'acronym': group.acronym })
# close request, listed under closed
review_req.state_id = "completed"
review_req.result_id = "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_history(self):
draft = make_test_data()
group = draft.group
@ -575,10 +601,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 +630,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()

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

@ -0,0 +1,192 @@
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.review.models import ReviewRequest, ReviewRequestStateName
from ietf.doc.models import TelechatDocEvent
from ietf.iesg.models import TelechatDate
from ietf.person.models import Email, Person
from ietf.review.utils import suggested_review_requests_for_team
import ietf.group.views_review
from ietf.utils.mail import outbox, empty_outbox
class ReviewTests(TestCase):
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_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, 'group_type': group.type_id })
login_testing_unauthorized(self, "secretary", url)
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="plain"),
)
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="plain"),
)
# get
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertTrue(review_req1.doc.name in unicontent(r))
# can't save: 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(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("1 request opened" in content)
self.assertTrue("2 requests changed assignment" in content)
# close and assign
new_reviewer = Email.objects.get(role__name="reviewer", role__group=group, person__user__username="marschairman")
r = self.client.post(url, {
"reviewrequest": [str(review_req1.pk), str(review_req2.pk), str(review_req3.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,
# 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, 'group_type': group.type_id })
login_testing_unauthorized(self, "secretary", url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertTrue(review_req1.doc.name in unicontent(r))
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 unicode(outbox[0]))

View file

@ -4,7 +4,7 @@ from django.conf.urls import patterns, include
from django.views.generic import RedirectView
from django.conf import settings
from ietf.group import views, views_edit
from ietf.group import views, views_edit, views_review
urlpatterns = patterns('',
(r'^$', views.active_groups),
@ -21,5 +21,7 @@ urlpatterns = patterns('',
(r'^email-aliases/$', 'ietf.group.views.email_aliases'),
(r'^bofs/create/$', views_edit.edit, {'action': "create", }, "bof_create"),
(r'^photos/$', views.chair_photos),
(r'^reviews/$', views.review_requests),
(r'^reviews/manage/$', views_review.manage_review_requests),
(r'^%(acronym)s/' % settings.URL_REGEXPS, include('ietf.group.urls_info_details')),
)

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,8 @@ 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_requests),
(r'^reviews/manage/$', views_review.manage_review_requests),
(r'^reviews/email-assignments/$', views_review.email_open_review_assignments),
url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'),
)

View file

@ -69,6 +69,8 @@ from ietf.mailtrigger.utils import gather_relevant_expansions
from ietf.ietfauth.utils import has_role
from ietf.meeting.utils import group_sessions
from ietf.meeting.helpers import get_meeting
from ietf.review.models import ReviewRequest
from ietf.review.utils import can_manage_review_requests_for_team, suggested_review_requests_for_team
def roles(group, role_name):
return Role.objects.filter(group=group, name=role_name).select_related("email", "person")
@ -347,6 +349,8 @@ def construct_group_menu_context(request, group, selected, group_type, others):
entries.append(("About", urlreverse("group_about", kwargs=kwargs)))
if group.features.has_materials and get_group_materials(group).exists():
entries.append(("Materials", urlreverse("ietf.group.views.materials", kwargs=kwargs)))
if group.features.has_reviews:
entries.append(("Review requests", urlreverse(review_requests, kwargs=kwargs)))
if group.type_id in ('rg','wg','team'):
entries.append(("Meetings", urlreverse("ietf.group.views.meetings", kwargs=kwargs)))
entries.append(("History", urlreverse("ietf.group.views.history", kwargs=kwargs)))
@ -363,11 +367,11 @@ def construct_group_menu_context(request, group, selected, group_type, others):
# actions
actions = []
is_chair = group.has_role(request.user, "chair")
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_chair or can_manage):
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:
@ -379,10 +383,14 @@ def construct_group_menu_context(request, group, selected, group_type, others):
if group.features.has_materials and can_manage_materials(request.user, group):
actions.append((u"Upload material", urlreverse("ietf.doc.views_material.choose_material_type", kwargs=kwargs)))
if group.state_id != "conclude" and (is_chair or can_manage):
if group.features.has_reviews:
import ietf.group.views_review
actions.append((u"Manage review requests", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=kwargs)))
if group.state_id != "conclude" and (is_admin or can_manage):
actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs)))
if group.features.customize_workflow and (is_chair or can_manage):
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:
@ -652,6 +660,60 @@ def history(request, acronym, group_type=None):
"events": events,
}))
def review_requests(request, acronym, group_type=None):
group = get_group_or_404(acronym, group_type)
if not group.features.has_reviews:
raise Http404
open_review_requests = list(ReviewRequest.objects.filter(
team=group, state__in=("requested", "accepted")
).prefetch_related("reviewer", "type", "state").order_by("-time", "-id"))
open_review_requests += suggested_review_requests_for_team(group)
today = datetime.date.today()
for r in open_review_requests:
delta = today - r.deadline
r.due = max(0, delta.days)
closed_review_requests = ReviewRequest.objects.filter(
team=group,
).exclude(
state__in=("requested", "accepted")
).prefetch_related("reviewer", "type", "state", "doc").order_by("-time", "-id")
since_choices = [
(None, "1 month"),
("3m", "3 months"),
("6m", "6 months"),
("1y", "1 year"),
("2y", "2 years"),
("all", "All"),
]
since = request.GET.get("since", None)
if since not in [key for key, label in since_choices]:
since = None
if since != "all":
date_limit = {
None: datetime.timedelta(days=31),
"3m": datetime.timedelta(days=31 * 3),
"6m": datetime.timedelta(days=180),
"1y": datetime.timedelta(days=365),
"2y": datetime.timedelta(days=2 * 365),
}[since]
closed_review_requests = closed_review_requests.filter(time__gte=datetime.date.today() - date_limit)
return render(request, 'group/review_requests.html',
construct_group_menu_context(request, group, "review requests", group_type, {
"open_review_requests": open_review_requests,
"closed_review_requests": closed_review_requests,
"since_choices": since_choices,
"since": since,
"can_manage_review_requests": can_manage_review_requests_for_team(request.user, group)
}))
def materials(request, acronym, group_type=None):
group = get_group_or_404(acronym, group_type)
if not group.features.has_materials:

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(roles_for_group_type(self.group_type))
- set(strip_suffix(attr, "_roles") for attr in self.fields if attr.endswith("_roles")))
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":

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

@ -0,0 +1,222 @@
from django.shortcuts import render, redirect
from django.http import Http404, HttpResponseForbidden
from django.contrib.auth.decorators import login_required
from django import forms
from django.template.loader import render_to_string
from ietf.review.models import ReviewRequest
from ietf.review.utils import (can_manage_review_requests_for_team, close_review_request_states,
extract_revision_ordered_review_requests_for_documents,
assign_review_request_to_reviewer,
close_review_request,
setup_reviewer_field,
suggested_review_requests_for_team)
from ietf.group.utils import get_group_or_404
from ietf.person.fields import PersonEmailChoiceField
from ietf.utils.mail import send_mail_text
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)
close_initial = None
if review_req.pk is None:
if review_req.latest_reqs:
close_initial = "no-review-version"
else:
close_initial = "no-review-document"
elif review_req.reviewer:
close_initial = "no-response"
else:
close_initial = "overtaken"
if close_initial:
self.fields["close"].initial = close_initial
if review_req.pk is None:
self.fields["close"].queryset = self.fields["close"].queryset.filter(slug__in=["noreviewversion", "noreviewdocument"])
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):
group = get_group_or_404(acronym, group_type)
if not group.features.has_reviews:
raise Http404
if not can_manage_review_requests_for_team(request.user, group):
return HttpResponseForbidden("You do not have permission to perform this action")
review_requests = list(ReviewRequest.objects.filter(
team=group, state__in=("requested", "accepted")
).prefetch_related("reviewer", "type", "state").order_by("-time", "-id"))
review_requests += suggested_review_requests_for_team(group)
document_requests = extract_revision_ordered_review_requests_for_documents(
ReviewRequest.objects.filter(state__in=("part-completed", "completed"), 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:
l = []
# take all on the latest reviewed rev
for r in document_requests[req.doc_id]:
if l and l[0].reviewed_rev:
if r.doc_id == l[0].doc_id and r.reviewed_rev:
if int(r.reviewed_rev) > int(l[0].reviewed_rev):
l = [r]
elif int(r.reviewed_rev) == int(l[0].reviewed_rev):
l.append(r)
else:
l = [r]
req.latest_reqs = l
req.form = ManageReviewRequestForm(req, query_dict)
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":
return redirect(manage_review_requests, **kwargs)
else:
import ietf.group.views
return redirect(ietf.group.views.review_requests, **kwargs)
return render(request, 'group/manage_review_requests.html', {
'group': group,
'review_requests': review_requests,
'newly_closed': newly_closed,
'newly_opened': newly_opened,
'newly_assigned': newly_assigned,
'saving': saving,
})
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"))
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"])
kwargs = { "acronym": group.acronym }
if group_type:
kwargs["group_type"] = group_type
return redirect(manage_review_requests, **kwargs)
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,
})
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,
})

View file

@ -9,6 +9,7 @@ import sys
path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if not path in sys.path:
sys.path.insert(0, path)
print "!jojiojoisdjf", path
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")

View file

@ -3,7 +3,8 @@ from ietf.name.models import (GroupTypeName, GroupStateName, RoleName, StreamNam
DocRelationshipName, DocTypeName, DocTagName, StdLevelName, IntendedStdLevelName,
DocReminderTypeName, BallotPositionName, SessionStatusName, TimeSlotTypeName,
ConstraintName, NomineePositionStateName, FeedbackTypeName, DBTemplateTypeName,
DraftSubmissionStateName, RoomResourceName)
DraftSubmissionStateName, RoomResourceName,
ReviewRequestStateName, ReviewTypeName, ReviewResultName)
class NameAdmin(admin.ModelAdmin):
@ -39,3 +40,6 @@ admin.site.register(FeedbackTypeName, NameAdmin)
admin.site.register(DBTemplateTypeName, NameAdmin)
admin.site.register(DraftSubmissionStateName, NameAdmin)
admin.site.register(RoomResourceName, NameAdmin)
admin.site.register(ReviewRequestStateName, NameAdmin)
admin.site.register(ReviewTypeName, NameAdmin)
admin.site.register(ReviewResultName, 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', '0010_new_liaison_names'),
]
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,65 @@
# -*- 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)
ReviewTypeName.objects.get_or_create(slug="unknown", name="Unknown", order=4, used=False)
ReviewResultName = apps.get_model("name", "ReviewResultName")
ReviewResultName.objects.get_or_create(slug="serious-issues", name="Serious Issues", order=1)
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', '0011_reviewrequeststatename_reviewresultname_reviewtypename'),
('group', '0001_initial'),
('doc', '0001_initial'),
]
operations = [
migrations.RunPython(insert_initial_review_data, noop),
]

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

@ -139,3 +139,23 @@ class SearchableEmailField(SearchableEmailsField):
return super(SearchableEmailField, self).clean(value).first()
class PersonEmailChoiceField(forms.ModelChoiceField):
"""ModelChoiceField targeting Email and displaying choices with the
person name as well as the email address. Needs further
restrictions, e.g. on role, to useful."""
def __init__(self, *args, **kwargs):
if not "queryset" in kwargs:
kwargs["queryset"] = Email.objects.select_related("person")
self.label_with = kwargs.pop("label_with", None)
super(PersonEmailChoiceField, self).__init__(*args, **kwargs)
def label_from_instance(self, email):
if self.label_with == "person":
return unicode(email.person)
elif self.label_with == "email":
return email.address
else:
return u"{} <{}>".format(email.person, email.address)

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

View file

@ -0,0 +1,249 @@
#!/usr/bin/env python
import sys, os
# boilerplate
basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
sys.path = [ basedir ] + sys.path
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ietf.settings")
import django
django.setup()
# script
import datetime
from collections import namedtuple
from django.db import connections
from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewResultName
from ietf.review.models import ReviewRequestStateName, ReviewTypeName, ReviewTeamResult
from ietf.group.models import Group, Role, RoleName
from ietf.person.models import Person, Email, Alias
import argparse
from unidecode import unidecode
from collections import defaultdict
parser = argparse.ArgumentParser()
parser.add_argument("database", help="database must be included in settings")
parser.add_argument("team", help="team acronym, must exist")
args = parser.parse_args()
db_con = connections[args.database]
team = Group.objects.get(acronym=args.team)
def namedtuplefetchall(cursor):
"Return all rows from a cursor as a namedtuple"
desc = cursor.description
nt_result = namedtuple('Result', [col[0] for col in desc])
return (nt_result(*row) for row in cursor.fetchall())
def parse_timestamp(t):
if not t:
return None
return datetime.datetime.fromtimestamp(t)
# personnel
with db_con.cursor() as c:
c.execute("select distinct reviewer from reviews;")
known_reviewers = { row[0] for row in c.fetchall() }
with db_con.cursor() as c:
c.execute("select distinct who from doclog;")
docloggers = { row[0] for row in c.fetchall() }
with db_con.cursor() as c:
c.execute("select distinct login from members where permissions like '%secretary%';")
secretaries = { row[0] for row in c.fetchall() }
autopolicy_days = {
'weekly': 7,
'biweekly': 14,
'monthly': 30,
'bimonthly': 61,
'quarterly': 91,
}
known_personnel = {}
with db_con.cursor() as c:
c.execute("select * from members;")
needed_personnel = known_reviewers | docloggers | secretaries
for row in namedtuplefetchall(c):
if row.login not in needed_personnel:
continue
email = Email.objects.filter(address=row.email).select_related("person").first()
if not email:
person = Person.objects.filter(alias__name=row.name).first()
if not person:
person, created = Person.objects.get_or_create(name=row.name, ascii=unidecode(row.name))
if created:
print "created person", 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
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
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, created = ReviewerSettings.objects.get_or_create(
team=team,
person=email.person,
)
if reviewer:
print "created reviewer", reviewer.pk, unicode(reviewer).encode("utf-8")
if autopolicy_days.get(row.autopolicy):
reviewer.frequency = autopolicy_days.get(row.autopolicy)
reviewer.unavailable_until = parse_timestamp(row.available)
reviewer.filter_re = row.donotassign
try:
reviewer.skip_next = int(row.autopolicy)
except ValueError:
pass
reviewer.save()
# review requests
# check that we got the needed names
results = { n.name.lower(): n for n in ReviewResultName.objects.all() }
with db_con.cursor() as c:
c.execute("select distinct summary from reviews;")
summaries = [r[0].lower() for r in c.fetchall() if r[0]]
missing_result_names = set(summaries) - set(results.keys())
assert not missing_result_names, "missing result names: {} {}".format(missing_result_names, results.keys())
for s in summaries:
ReviewTeamResult.objects.get_or_create(team=team, result=results[s])
states = { n.slug: n for n in ReviewRequestStateName.objects.all() }
# map some names
states["assigned"] = states["requested"]
states["done"] = states["completed"]
states["noresponse"] = states["no-response"]
with db_con.cursor() as c:
c.execute("select distinct docstatus from reviews;")
docstates = [r[0] for r in c.fetchall() if r[0]]
missing_state_names = set(docstates) - set(states.keys())
assert not missing_state_names, "missing state names: {}".format(missing_state_names)
type_names = { n.slug: n for n in ReviewTypeName.objects.all() }
# extract relevant log entries
request_assigned = defaultdict(list)
with db_con.cursor() as c:
c.execute("select docname, time, who from doclog where text = 'AUTO UPDATED status TO working' order by time desc;")
for row in namedtuplefetchall(c):
request_assigned[row.docname].append((row.time, row.who))
# extract document request metadata
doc_metadata = {}
with db_con.cursor() as c:
c.execute("select docname, version, deadline, telechat, lcend, status from documents order by docname, version;")
for row in namedtuplefetchall(c):
doc_metadata[(row.docname, row.version)] = doc_metadata[row.docname] = (parse_timestamp(row.deadline), parse_timestamp(row.telechat), parse_timestamp(row.lcend), row.status)
system_person = Person.objects.get(name="(System)")
with db_con.cursor() as c:
c.execute("select * from reviews order by reviewid;")
for row in namedtuplefetchall(c):
meta = doc_metadata.get((row.docname, row.version))
if not meta:
meta = doc_metadata.get(row.docname)
deadline, telechat, lcend, status = meta or (None, None, None, None)
if not deadline:
deadline = parse_timestamp(row.timeout)
type_name = type_names["unknown"]
# FIXME: use lcend and telechat to try to deduce type
reviewed_rev = row.version if row.version and row.version != "99" else ""
if row.summary == "noresponse":
reviewed_rev = ""
assignment_logs = request_assigned.get(row.docname, [])
if assignment_logs:
time, who = assignment_logs.pop()
time = parse_timestamp(time)
else:
time = deadline
if not deadline:
# bogus row
print "SKIPPING WITH NO DEADLINE", time, row, meta
continue
if status == "done" and row.docstatus in ("assigned", "accepted"):
# filter out some apparently dead requests
print "SKIPPING MARKED DONE even if assigned/accepted", time, row
continue
req, _ = ReviewRequest.objects.get_or_create(
doc_id=row.docname,
team=team,
old_id=row.reviewid,
defaults={
"state": states["requested"],
"type": type_name,
"deadline": deadline.date(),
"requested_by": system_person,
}
)
req.reviewer = known_personnel[row.reviewer] if row.reviewer else None
req.result = results.get(row.summary.lower()) if row.summary else None
req.state = states.get(row.docstatus) if row.docstatus else None
req.type = type_name
req.time = time
req.reviewed_rev = reviewed_rev
req.deadline = deadline.date()
req.save()
# FIXME: add log entries
# FIXME: add review from reviewurl
# FIXME: do something about missing result
# adcomments IGNORED
# lccomments IGNORED
# nits IGNORED
# reviewurl review.external_url
#print meta and meta[0], telechat, lcend, req.type
if req.state_id == "requested" and req.doc.get_state_slug("draft-iesg") in ["approved", "ann", "rfcqueue", "pub"]:
req.state = states["overtaken"]
req.save()
print "imported review", row.reviewid, "as", req.pk, req.time, req.deadline, req.type, req.doc_id, req.state, req.doc.get_state_slug("draft-iesg")

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

@ -0,0 +1,95 @@
# 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")
res.append({
"from": 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"],
})
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,66 @@
# -*- 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='ReviewerSettings',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('frequency', models.IntegerField(default=30, help_text=b'Can review every N days', 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')])),
('unavailable_until', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)),
('filter_re', models.CharField(max_length=255, blank=True)),
('skip_next', models.IntegerField(default=0, help_text=b'Skip the next N review assignments')),
('person', models.ForeignKey(to='person.Person')),
('team', models.ForeignKey(to='group.Group')),
],
options={
},
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='review_request_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='ReviewTeamResult',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('result', models.ForeignKey(to='name.ReviewResultName')),
('team', models.ForeignKey(to='group.Group')),
],
options={
},
bases=(models.Model,),
),
]

View file

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

@ -0,0 +1,69 @@
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 the reviewer in the
particular team. There will be one record for each combination of
reviewer and team."""
team = models.ForeignKey(Group)
person = models.ForeignKey(Person)
FREQUENCIES = [
(7, "Once per week"),
(14, "Once per fortnight"),
(30, "Once per month"),
(61, "Once per two months"),
(91, "Once per quarter"),
]
frequency = models.IntegerField(default=30, help_text="Can review every N days", choices=FREQUENCIES)
unavailable_until = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again")
filter_re = models.CharField(max_length=255, blank=True)
skip_next = models.IntegerField(default=0, help_text="Skip the next N review assignments")
def __unicode__(self):
return u"{} in {}".format(self.person, self.team)
class ReviewTeamResult(models.Model):
"""Captures that a result name is valid for a given team for new
reviews. This also implicitly defines which teams are review
teams - if there are no possible review results valid for a given
team, it can't be a review team."""
team = models.ForeignKey(Group)
result = models.ForeignKey(ReviewResultName)
class ReviewRequest(models.Model):
"""Represents a request for a review and the process it goes through.
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='review_request_set')
team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewteamresult=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)

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

@ -0,0 +1,84 @@
# 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, ReviewTeamResult
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,
"frequency": ALL,
"unavailable_until": 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 ReviewTeamResultResource(ModelResource):
team = ToOneField(GroupResource, 'team')
result = ToOneField(ReviewResultNameResource, 'result')
class Meta:
queryset = ReviewTeamResult.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'reviewteamresult'
filtering = {
"id": ALL,
"team": ALL_WITH_RELATIONS,
"result": ALL_WITH_RELATIONS,
}
api.review.register(ReviewTeamResultResource())

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

@ -0,0 +1,400 @@
import datetime, re
from collections import defaultdict
from django.db import models
from django.core.urlresolvers import reverse as urlreverse
from ietf.group.models import Group, Role
from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent, DocumentAuthor, DocAlias
from ietf.iesg.models import TelechatDate
from ietf.person.models import Person, Email
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream
from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewTypeName, ReviewerSettings
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 ReviewTeamResult defined, it's a review team
return Group.objects.filter(state="active").exclude(reviewteamresult=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)
def can_manage_review_requests_for_team(user, team, allow_non_team_personnel=True):
if not user.is_authenticated():
return False
return (Role.objects.filter(name__in=["secr", "delegate"], person__user=user, group=team).exists()
or (allow_non_team_personnel and has_role(user, "Secretariat")))
def review_requests_to_list_for_doc(doc):
return extract_revision_ordered_review_requests_for_documents(
ReviewRequest.objects.filter(
state__in=["requested", "accepted", "part-completed", "completed"],
).prefetch_related("result"),
[doc.name]
).get(doc.pk, [])
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 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 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)
if notify_secretary:
extract_email_addresses(Role.objects.filter(name__in=["secr", "delegate"], 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, "doc/mail/review_request_changed.txt", {
"review_req_url": url,
"review_req": review_req,
"msg": msg,
})
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()
DocEvent.objects.create(
type="changed_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)",
),
)
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 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:
DocEvent.objects.create(
type="changed_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),
)
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 = {}
requested_state = ReviewRequestStateName.objects.get(slug="requested", used=True)
if True: # FIXME
# in Last Call
last_call_type = ReviewTypeName.objects.get(slug="lc")
last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True))
last_call_expires = { e.doc_id: e.expires for e in LastCallDocEvent.objects.order_by("time", "id") }
for doc in last_call_docs:
deadline = last_call_expires[doc.pk].date() if doc.pk in last_call_expires else datetime.date.today()
if deadline > seen_deadlines.get(doc.pk, datetime.date.max):
continue
requests[doc.pk] = ReviewRequest(
time=None,
type=last_call_type,
doc=doc,
team=team,
deadline=deadline,
requested_by=system_person,
state=requested_state,
)
seen_deadlines[doc.pk] = deadline
if True: # FIXME
# on Telechat Agenda
telechat_dates = list(TelechatDate.objects.active().order_by('date').values_list("date", flat=True)[:4])
telechat_type = ReviewTypeName.objects.get(slug="telechat")
telechat_deadline_delta = datetime.timedelta(days=2)
telechat_docs = Document.objects.filter(docevent__telechatdocevent__telechat_date__in=telechat_dates)
for doc in telechat_docs:
d = doc.telechat_date()
if d not in telechat_dates:
continue
deadline = d - telechat_deadline_delta
if deadline > seen_deadlines.get(doc.pk, datetime.date.max):
continue
requests[doc.pk] = ReviewRequest(
time=None,
type=telechat_type,
doc=doc,
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()):
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(review_request_queryset, names):
"""Extracts all review requests for document names (including replaced ancestors)."""
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)
aliases = DocAlias.objects.filter(document=doc).values_list("name", flat=True)
reviewers = { r.person_id: r for r in ReviewerSettings.objects.filter(team=team, person__in=[e.person_id for e in possible_emails]) }
# time since past assignment
latest_assignment_for_reviewer = dict(ReviewRequest.objects.filter(
reviewer__in=possible_emails,
).values_list("reviewer").annotate(models.Max("time")))
# previous review of document
has_reviewed_previous = ReviewRequest.objects.filter(
doc=doc,
reviewer__in=possible_emails,
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", flat=True))
# review indications
would_like_to_review = set() # FIXME: fill in
# connections
connections = {}
# examine the closest connections last to let them override
for e in Email.objects.filter(pk__in=possible_emails, person=doc.ad_id):
connections[e] = "is associated Area Director"
for r in Role.objects.filter(group=doc.group_id, email__in=possible_emails).select_related("name"):
connections[r.email_id] = "is group {}".format(r.name)
if doc.shepherd_id:
connections[doc.shepherd_id] = "is shepherd of document"
for e in DocumentAuthor.objects.filter(document=doc, author__in=possible_emails).values_list("author", flat=True):
connections[e] = "is author of document"
now = datetime.datetime.now()
def add_boolean_score(scores, direction, expr, explanation):
scores.append(int(bool(expr)) * direction)
if expr:
explanations.append(explanation)
ranking = []
for e in possible_emails:
reviewer = reviewers.get(e.person_id)
if not reviewer:
reviewer = ReviewerSettings()
days_past = None
latest = latest_assignment_for_reviewer.get(e.pk)
if latest is not None:
days_past = (now - latest).days - reviewer.frequency
if days_past is None:
ready_for = "first time"
else:
d = int(round(days_past))
if d > 0:
ready_for = "ready for {} {}".format(d, "day" if d == 1 else "days")
else:
d = -d
ready_for = "frequency exceeded, ready in {} {}".format(d, "day" if d == 1 else "days")
# we sort the reviewers by separate axes, listing the most
# important things first
scores = []
explanations = []
explanations.append(ready_for) # show ready for explanation first, but sort it after the other issues
add_boolean_score(scores, +1, e.pk in has_reviewed_previous, "reviewed document before")
add_boolean_score(scores, +1, e.pk in would_like_to_review, "wants to review document")
add_boolean_score(scores, -1, e.pk in connections, connections.get(e.pk)) # reviewer is somehow connected: bad
add_boolean_score(scores, -1, reviewer.filter_re and any(re.search(reviewer.filter_re, n) for n in aliases), "filter regexp matches")
add_boolean_score(scores, -1, reviewer.unavailable_until and reviewer.unavailable_until > now, "unavailable until {}".format((reviewer.unavailable_until or now).strftime("%Y-%m-%d %H:%M:%S")))
scores.append(100000 if days_past is None else days_past)
label = "{}: {}".format(e.person, "; ".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]

View file

@ -306,6 +306,7 @@ INSTALLED_APPS = (
'ietf.person',
'ietf.redirects',
'ietf.release',
'ietf.review',
'ietf.submit',
'ietf.sync',
'ietf.utils',
@ -448,6 +449,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

@ -470,6 +470,51 @@ 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;
}
.closed-review-filter {
margin-bottom: 1em;
}
form.review-requests .reviewer-controls, form.review-requests .close-controls {
display: none;
padding-right: 1em;
}
form.review-requests .assign-action, form.review-requests .close-action {
display: inline-block;
min-width: 11em;
}
form.review-requests .deadline {
padding-top: 0.45em;
}
form.review-requests label {
font-weight: normal;
padding-right: 0.3em;
}
form.email-open-review-assignments [name=body] {
height: 50em;
font-family: monospace;
}
/* === Photo pages ========================================================== */
.photo-name {

View file

@ -0,0 +1,131 @@
$(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.date);
row.data("url", msg.url);
row.data("content", msg.content);
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"));
});
// 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

@ -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("tr");
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("tr").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("tr");
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("tr");
row.find("[name$=\"-action\"]").val("close");
setControlDisplay(row);
});
form.find(".close-controls .undo").on("click", function () {
var row = $(this).closest("tr");
row.find("[name$=\"-action\"]").val("");
setControlDisplay(row);
});
form.find("[name$=\"-action\"]").each(function () {
var v = $(this).val();
if (!v)
return;
var row = $(this).closest("tr");
setControlDisplay(row);
});
updateSaveButtons();
});

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,116 @@
{% 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>
<tr>
<th></th>
<th>Review result</th>
<td class="edit"></td>
<td>{{ review_req.result.name }}</td>
</tr>
{% 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

@ -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,6 @@
{% autoescape off %}Review was partially completed by {{ by }}.
A new review request has been registered for completing the review:
{{ new_review_req_url }}
{% 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,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,80 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}Complete review of {{ review_req.doc.name }}{% endblock %}
{% block content %}
{% origin %}
<h1>Complete review<br><small>{{ review_req.doc.name }}</small></h1>
<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>
<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">Complete 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>
<div class="template" style="display:none">
<button type="button" class="mail-archive-search-result list-group-item">
<span class="date badge"></span>
<span class="subject"></span>
</button>
</div>
</form>
{% endblock %}
{% block js %}
<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_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> Complete review</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,15 @@
<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 %}:
{{ review_request.result.name }} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %}
- reviewer: {{ review_request.reviewer.person }}</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
{% if review_request.reviewer %}
- reviewer: {{ review_request.reviewer.person }}
{% endif %}
- due: {{ review_request.deadline|date:"Y-m-d" }}</a></i>
{% endif %}
</div>

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="{% url "ietf.group.views_review.manage_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="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,4 @@
{% 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 %}{% endautoescape %}

View file

@ -0,0 +1,142 @@
{% 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 open review requests for {{ group.acronym }}</h1>
<p>Other options:
<a href="{% url "ietf.group.views.review_requests" group_type=group.type_id acronym=group.acronym %}#closed-review-requests">Closed review requests</a>
- <a href="{% url "ietf.group.views_review.email_open_review_assignments" group_type=group.type_id acronym=group.acronym %}">Email open assignments summary</a>
</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 %}
<table class="table table-condensed table-striped materials">
<thead>
<tr>
<th>Document</th>
<th>Deadline</th>
<th style="min-width:65%">Action</th>
</tr>
</thead>
<tbody>
{% for r in review_requests %}
<tr>
<td>
<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 %}">{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a>
<div>
<small>
<a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{% if r.time %}Req: {{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %} - {{ r.type.name }}</a>
</small>
</div>
{% if r.latest_reqs %}
{% for rlatest in r.latest_reqs %}
<div>
<small>- prev. review of {% if rlatest.doc_id != r.doc_id %}{{ rlatest.doc_id }}{% endif %}-{{ rlatest.reviewed_rev }}:
<a href="{% url "ietf.doc.views_review.review_request" name=rlatest.doc_id request_id=rlatest.pk %}">{{ rlatest.result.name }}</a>
(<a href="{{ rfcdiff_base_url }}?url1={{ rlatest.doc.name }}-{{ rlatest.reviewed_rev }}&url2={{ r.doc.name }}-{{ r.doc.rev }}">diff</a>){% if not forloop.last %},{% endif %}
</small>
</div>
{% endfor %}
{% endif %}
{% if r.form.non_field_errors %}
<div class="alert alert-danger">
{% for e in r.form.non_field_errors %}
{{ e }}
{% endfor %}
</div>
{% endif %}
</td>
<td class="deadline">
{{ r.deadline|date:"Y-m-d" }}
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
</td>
<td>
<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-sm" title="Click to reassign reviewer">{{ r.reviewer.person }}{% if r.state_id == "accepted" %} <span class="label label-default">accp</span>{% endif %}</button>
{% else %}
<button type="button" class="btn btn-default btn-sm" 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 btn-sm 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-sm">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 btn-sm undo" title="Cancel closing">Cancel</button>
{% if r.form.close.errors %}
<br>
{{ r.form.close.errors }}
{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% buttons %}
<a href="{% url "ietf.group.views.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 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,112 @@
{% 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 %}
<h2>Open review requests</h2>
{% if open_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>
</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.type.name }}</td>
<td>{% if r.time %}{{ 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">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
</td>
<td>
{% if r.reviewer %}
{{ r.reviewer.person }} {% if r.state_id == "accepted" %}<span class="label label-default">Accepted</span>{% endif %}
{% elif r.pk != None %}
<em>not yet assigned</em>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>There are currently no open requests.</p>
{% endif %}
<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

@ -187,7 +187,7 @@ def encode_message(txt):
def send_mail_text(request, to, frm, subject, txt, cc=None, extra=None, toUser=False, bcc=None):
"""Send plain text message."""
msg = encode_message(txt)
send_mail_mime(request, to, frm, subject, msg, cc, extra, toUser, bcc)
return send_mail_mime(request, to, frm, subject, msg, cc, extra, toUser, bcc)
def condition_message(to, frm, subject, msg, cc, extra):
if isinstance(frm, tuple):
@ -284,6 +284,8 @@ def send_mail_mime(request, to, frm, subject, msg, cc=None, extra=None, toUser=F
build_warning_message(request, e)
send_error_email(e)
return msg
def parse_preformatted(preformatted, extra={}, override={}):
"""Parse preformatted string containing mail with From:, To:, ...,"""
msg = message_from_string(preformatted.encode("utf-8"))
@ -323,8 +325,8 @@ def send_mail_message(request, message, extra={}):
if message.reply_to:
e['Reply-to'] = message.reply_to
send_mail_text(request, message.to, message.frm, message.subject,
message.body, cc=message.cc, bcc=message.bcc, extra=e)
return send_mail_text(request, message.to, message.frm, message.subject,
message.body, cc=message.cc, bcc=message.bcc, extra=e)
def exception_components(e):
# See if it's a non-smtplib exception that we faked

View file

@ -6,7 +6,7 @@ from django.contrib.auth.models import User
import debug # pyflakes:ignore
from ietf.doc.models import Document, DocAlias, State, DocumentAuthor, BallotType, DocEvent, BallotDocEvent, RelatedDocument
from ietf.doc.models import Document, DocAlias, State, DocumentAuthor, BallotType, DocEvent, BallotDocEvent, RelatedDocument, NewRevisionDocEvent
from ietf.group.models import Group, GroupHistory, Role, RoleHistory
from ietf.iesg.models import TelechatDate
from ietf.ipr.models import HolderIprDisclosure, IprDocRel, IprDisclosureStateName, IprLicenseTypeName
@ -14,6 +14,7 @@ from ietf.meeting.models import Meeting
from ietf.name.models import StreamName, DocRelationshipName
from ietf.person.models import Person, Email
from ietf.group.utils import setup_default_community_list_for_group
from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewResultName, ReviewTeamResult
def create_person(group, role_name, name=None, username=None, email_address=None, password=None):
"""Add person/user/email and role."""
@ -285,11 +286,12 @@ def make_test_data():
desc="Started IESG process",
)
DocEvent.objects.create(
NewRevisionDocEvent.objects.create(
type="new_revision",
by=ad,
doc=draft,
desc="New revision available",
rev="01",
)
BallotDocEvent.objects.create(
@ -389,3 +391,32 @@ def make_test_data():
#other_doc_factory('recording','recording-42-mars-1-00')
return draft
def make_review_data(doc):
team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="dir", list_email="reviewteam@ietf.org", parent=Group.objects.get(acronym="farfut"))
for r in ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"]):
ReviewTeamResult.objects.create(team=team, result=r)
p = Person.objects.get(user__username="plain")
email = p.email_set.first()
Role.objects.create(name_id="reviewer", person=p, email=email, group=team)
ReviewerSettings.objects.create(team=team, person=p, frequency=14, skip_next=0)
review_req = ReviewRequest.objects.create(
doc=doc,
team=team,
type_id="early",
deadline=datetime.datetime.now() + datetime.timedelta(days=20),
state_id="accepted",
requested_by=p,
reviewer=email,
)
p = Person.objects.get(user__username="marschairman")
Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team)
p = Person.objects.get(user__username="secretary")
Role.objects.create(name_id="secr", person=p, email=p.email_set.first(), group=team)
return review_req

View file

@ -255,7 +255,7 @@ def canonicalize_sitemap(s):
def login_testing_unauthorized(test_case, username, url, password=None):
r = test_case.client.get(url)
test_case.assertTrue(r.status_code in (302, 403))
test_case.assertIn(r.status_code, (302, 403))
if r.status_code == 302:
test_case.assertTrue("/accounts/login" in r['Location'])
if not password:
@ -272,6 +272,17 @@ def unicontent(r):
encoding = 'utf-8'
return r.content.decode(encoding)
def reload_db_objects(*objects):
"""Rerequest the given arguments from the database so they're refreshed, to be used like
foo, bar = reload_objects(foo, bar)"""
t = tuple(o.__class__.objects.get(pk=o.pk) for o in objects)
if len(objects) == 1:
return t[0]
else:
return t
class ReverseLazyTest(django.test.TestCase):
def test_redirect_with_lazy_reverse(self):
response = self.client.get('/ipr/update/')

View file

@ -18,3 +18,15 @@ def xslugify(value):
value = re.sub('[^\w\s/-]', '', value).strip().lower()
return mark_safe(re.sub('[-\s/]+', '-', value))
xslugify = allow_lazy(xslugify, six.text_type)
def strip_prefix(text, prefix):
if text.startswith(prefix):
return text[len(prefix):]
else:
return text
def strip_suffix(text, suffix):
if text.endswith(suffix):
return text[:-len(suffix)]
else:
return text

View file

@ -1,18 +1,18 @@
import re
import django.forms
from django.core.exceptions import ValidationError
def get_cleaned_text_file_content(uploaded_file):
"""Read uploaded file, try to fix up encoding to UTF-8 and
transform line endings into Unix style, then return the content as
a UTF-8 string. Errors are reported as
django.forms.ValidationError exceptions."""
django.core.exceptions.ValidationError exceptions."""
if not uploaded_file:
return u""
if uploaded_file.size and uploaded_file.size > 10 * 1000 * 1000:
raise django.forms.ValidationError("Text file too large (size %s)." % uploaded_file.size)
raise ValidationError("Text file too large (size %s)." % uploaded_file.size)
content = "".join(uploaded_file.chunks())
@ -29,18 +29,18 @@ def get_cleaned_text_file_content(uploaded_file):
filetype = m.from_buffer(content)
if not filetype.startswith("text"):
raise django.forms.ValidationError("Uploaded file does not appear to be a text file.")
raise ValidationError("Uploaded file does not appear to be a text file.")
match = re.search("charset=([\w-]+)", filetype)
if not match:
raise django.forms.ValidationError("File has unknown encoding.")
raise ValidationError("File has unknown encoding.")
encoding = match.group(1)
if "ascii" not in encoding:
try:
content = content.decode(encoding)
except Exception as e:
raise django.forms.ValidationError("Error decoding file (%s). Try submitting with UTF-8 encoding or remove non-ASCII characters." % str(e))
raise ValidationError("Error decoding file (%s). Try submitting with UTF-8 encoding or remove non-ASCII characters." % str(e))
# turn line-endings into Unix style
content = content.replace("\r\n", "\n").replace("\r", "\n")