Merge review-tracker branch with new branch from trunk

- Legacy-Id: 11364
This commit is contained in:
Ole Laursen 2016-06-14 13:57:20 +00:00
commit 7b95f46ecb
42 changed files with 3113 additions and 633 deletions

View file

@ -703,7 +703,11 @@ EVENT_TYPES = [
# RFC Editor
("rfc_editor_received_announcement", "Announcement was received by RFC Editor"),
("requested_publication", "Publication at RFC Editor requested")
("requested_publication", "Publication at RFC Editor requested"),
# review
("requested_review", "Requested review"),
("changed_review_request", "Changed review request"),
]
class DocEvent(models.Model):

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

@ -0,0 +1,517 @@
# -*- 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, Reviewer
import ietf.review.mailarch
from ietf.person.models import Person
from ietf.group.models import Group, Role
from ietf.name.models import ReviewResultName, ReviewRequestStateName
from ietf.utils.test_utils import TestCase
from ietf.utils.test_data import make_test_data
from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects
from ietf.utils.mail import outbox, empty_outbox
def make_review_data(doc):
team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team")
team.reviewresultname_set.add(ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"]))
p = Person.objects.get(user__username="plain")
role = Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team)
Reviewer.objects.create(team=team, person=p, frequency=14, skip_next=0)
review_req = ReviewRequest.objects.create(
doc=doc,
team=team,
type_id="early",
deadline=datetime.datetime.now() + datetime.timedelta(days=20),
state_id="ready",
reviewer=role,
reviewed_rev="01",
)
p = Person.objects.get(user__username="marschairman")
role = Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team)
p = Person.objects.get(user__username="secretary")
role = Role.objects.create(name_id="secretary", person=p, email=p.email_set.first(), group=team)
return review_req
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_date = datetime.date.today() + datetime.timedelta(days=10)
# post request
r = self.client.post(url, {
"type": "early",
"team": review_team.pk,
"deadline_date": deadline_date.isoformat(),
"requested_rev": "01"
})
self.assertEqual(r.status_code, 302)
req = ReviewRequest.objects.get(doc=doc, state="requested")
self.assertEqual(req.deadline.date(), deadline_date)
self.assertEqual(req.deadline.time(), datetime.time(23, 59, 59))
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):
# FIXME: fill in
pass
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_withdraw_request(self):
doc = make_test_data()
review_req = make_review_data(doc)
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
review_req.save()
withdraw_url = urlreverse('ietf.doc.views_review.withdraw_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(withdraw_url in unicontent(r))
self.client.logout()
# get withdraw page
login_testing_unauthorized(self, "secretary", withdraw_url)
r = self.client.get(withdraw_url)
self.assertEqual(r.status_code, 200)
# withdraw
empty_outbox()
r = self.client.post(withdraw_url, { "action": "withdraw" })
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("Withdrew" in e.desc)
self.assertEqual(len(outbox), 1)
self.assertTrue("withdrawn" in unicode(outbox[0]))
def test_assign_reviewer(self):
doc = make_test_data()
review_req = make_review_data(doc)
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
review_req.reviewer = None
review_req.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)
# assign
empty_outbox()
reviewer = Role.objects.filter(name="reviewer", 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 = Role.objects.filter(name="reviewer", 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.list_email = "{}@ietf.org".format(review_req.team.acronym)
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()
review_req.team.list_email = "{}@ietf.org".format(review_req.team.acronym)
for r in ReviewResultName.objects.filter(slug__in=("issues", "ready")):
review_req.team.reviewresultname_set.add(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(teams=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 + "-" + review_req.review.rev + ".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(teams=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 + "-" + review_req.review.rev + ".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(teams=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 + "-" + review_req.review.rev + ".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(teams=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(teams=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

@ -73,6 +73,7 @@ urlpatterns = patterns('',
url(r'^(?P<name>[A-Za-z0-9._+-]+)/ballot/$', views_doc.document_ballot, name="doc_ballot"),
(r'^(?P<name>[A-Za-z0-9._+-]+)/(?:(?P<rev>[0-9-]+)/)?doc.json$', views_doc.document_json),
(r'^(?P<name>[A-Za-z0-9._+-]+)/ballotpopup/(?P<ballot_id>[0-9]+)/$', views_doc.ballot_popup),
url(r'^(?P<name>[A-Za-z0-9._+-]+)/reviewrequest/', include("ietf.doc.urls_review")),
url(r'^(?P<name>[A-Za-z0-9._+-]+)/email-aliases/$', 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]+)/withdraw/$', views_review.withdraw_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

@ -89,7 +89,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()

View file

@ -343,14 +343,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=None, option=None):
if not name.startswith('charter-'):
@ -390,7 +382,12 @@ def submit(request, name=None, option=None):
e.save()
# 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

@ -48,7 +48,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
@ -57,10 +57,12 @@ 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
def render_document_top(request, doc, tab, name):
tabs = []
@ -279,8 +281,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)
@ -294,6 +296,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:
@ -353,6 +357,8 @@ 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 = ReviewRequest.objects.filter(doc=doc).exclude(state__in=["withdrawn", "rejected"])
return render_to_response("doc/document_draft.html",
dict(doc=doc,
group=group,
@ -374,6 +380,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,
@ -412,6 +419,7 @@ def document_main(request, name, rev=None):
search_archive=search_archive,
actions=actions,
presentations=presentations,
review_requests=review_requests,
),
context_instance=RequestContext(request))
@ -563,6 +571,24 @@ 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()
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,
))
raise Http404

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

@ -0,0 +1,503 @@
import datetime, os, email.utils
from django.contrib.sites.models import Site
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 ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias
from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person
from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName
from ietf.group.models import Role
from ietf.review.models import ReviewRequest
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_about_review_request, make_new_review_request_from_existing)
from ietf.review import mailarch
from ietf.utils.fields import DatepickerDateField
from ietf.utils.text import skip_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):
deadline_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" })
deadline_time = forms.TimeField(widget=forms.TextInput(attrs={ 'placeholder': "HH:MM" }), help_text="If time is not specified, end of day is assumed", required=False)
class Meta:
model = ReviewRequest
fields = ('type', 'team', 'deadline', 'requested_rev')
def __init__(self, user, doc, *args, **kwargs):
super(RequestReviewForm, self).__init__(*args, **kwargs)
self.doc = doc
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)
if len(f.queryset) < 6:
f.widget = forms.RadioSelect(choices=[t for t in f.choices if t[0]])
self.fields["deadline"].required = False
self.fields["requested_rev"].label = "Document revision"
def clean_deadline_date(self):
v = self.cleaned_data.get('deadline_date')
if v < datetime.date.today():
raise forms.ValidationError("Select a future date.")
return v
def clean_requested_rev(self):
return clean_doc_revision(self.doc, self.cleaned_data.get("requested_rev"))
def clean(self):
deadline_date = self.cleaned_data.get('deadline_date')
deadline_time = self.cleaned_data.get('deadline_time', None)
if deadline_date:
if deadline_time is None:
deadline_time = datetime.time(23, 59, 59)
self.cleaned_data["deadline"] = datetime.datetime.combine(deadline_date, deadline_time)
return self.cleaned_data
@login_required
def request_review(request, name):
doc = get_object_or_404(Document, name=name)
if not can_request_review_of_doc(request.user, doc):
return HttpResponseForbidden("You do not have permission to perform this action")
if request.method == "POST":
form = RequestReviewForm(request.user, doc, request.POST)
if form.is_valid():
review_req = form.save(commit=False)
review_req.doc = doc
review_req.state = ReviewRequestStateName.objects.get(slug="requested", used=True)
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:
form = RequestReviewForm(request.user, doc)
return render(request, 'doc/review/request_review.html', {
'doc': doc,
'form': form,
})
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_withdraw_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_withdraw_request': can_withdraw_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,
})
@login_required
def withdraw_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"])
if not is_authorized_in_doc_stream(request.user, doc):
return HttpResponseForbidden("You do not have permission to perform this action")
if request.method == "POST" and request.POST.get("action") == "withdraw":
prev_state = review_req.state
review_req.state = ReviewRequestStateName.objects.get(slug="withdrawn")
review_req.save()
DocEvent.objects.create(
type="changed_review_request",
doc=doc,
by=request.user.person,
desc="Withdrew request for {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()),
)
if prev_state.slug != "requested":
email_about_review_request(
request, review_req,
"Withdrew review request for %s" % review_req.doc.name,
"Review request has been withdrawn by %s." % request.user.person,
by=request.user.person, notify_secretary=False, notify_reviewer=True)
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
return render(request, 'doc/review/withdraw_request.html', {
'doc': doc,
'review_req': review_req,
})
class PersonEmailLabeledRoleModelChoiceField(forms.ModelChoiceField):
def __init__(self, *args, **kwargs):
if not "queryset" in kwargs:
kwargs["queryset"] = Role.objects.select_related("person", "email")
super(PersonEmailLabeledRoleModelChoiceField, self).__init__(*args, **kwargs)
def label_from_instance(self, role):
return u"{} <{}>".format(role.person.name, role.email.address)
class AssignReviewerForm(forms.Form):
reviewer = PersonEmailLabeledRoleModelChoiceField(widget=forms.RadioSelect, empty_label="(None)", required=False)
def __init__(self, review_req, *args, **kwargs):
super(AssignReviewerForm, self).__init__(*args, **kwargs)
f = self.fields["reviewer"]
f.queryset = f.queryset.filter(name="reviewer", group=review_req.team)
if review_req.reviewer:
f.initial = review_req.reviewer_id
@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"])
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
if not can_manage_request:
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_about_review_request(request, review_req, "Reviewer assignment rejected", msg, by=request.user.person, notify_secretary=True, notify_reviewer=True)
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)
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").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(teams=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",
review_req.team.acronym,
review_req.type.slug,
review_req.reviewer.person.ascii_parts()[3],
skip_prefix(review_req.doc.name, "draft-"),
form.cleaned_data["reviewed_rev"],
]
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
review.type = DocTypeName.objects.get(slug="review")
review.rev = "00"
review.title = "Review of {}-{}".format(review_req.doc.name, review_req.reviewed_rev)
review.group = review_req.team
if review_submission == "link":
review.external_url = form.cleaned_data['review_url']
review.save()
review.set_state(State.objects.get(type="review", slug="active"))
DocAlias.objects.create(document=review, name=review.name)
NewRevisionDocEvent.objects.create(
type="new_revision",
doc=review,
by=request.user.person,
rev=review.rev,
desc='New revision available',
time=review.time,
)
# 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 {} {}".format(
review_req.type.name,
review_req.team.acronym.upper(),
review_req.state.name,
),
)
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)
msg = render_to_string("doc/mail/partially_completed_review.txt", {
"domain": Site.objects.get_current().domain,
"by": request.user.person,
"new_review_req": new_review_req,
})
email_about_review_request(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=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"),
})
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()
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

@ -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):
@ -35,3 +36,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,61 @@
# -*- 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)),
('teams', models.ManyToManyField(help_text=b"Which teams this result can be set for. This also implicitly defines which teams are review teams - if there are no possible review results defined for a given team, it can't be a review team.", to='group.Group', blank=True)),
],
options={
'ordering': ['order'],
'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,62 @@
# -*- 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="noresponse", name="No Response", order=6)
ReviewRequestStateName.objects.get_or_create(slug="part-completed", name="Partially Completed", order=6)
ReviewRequestStateName.objects.get_or_create(slug="completed", name="Completed", order=8)
ReviewTypeName = apps.get_model("name", "ReviewTypeName")
ReviewTypeName.objects.get_or_create(slug="early", name="Early", order=1)
ReviewTypeName.objects.get_or_create(slug="lc", name="Last Call", order=2)
ReviewTypeName.objects.get_or_create(slug="telechat", name="Telechat", order=3)
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"""
@ -87,3 +87,14 @@ 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, 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"""
teams = models.ManyToManyField("group.Group", help_text="Which teams this result can be set for. This also implicitly defines which teams are review teams - if there are no possible review results defined for a given team, it can't be a review team.", blank=True)

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,46 @@ 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,
"teams": ALL_WITH_RELATIONS,
}
api.name.register(ReviewResultNameResource())

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

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,52 @@
# -*- 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', '0012_insert_review_name_data'),
('doc', '0012_auto_20160207_0537'),
('person', '0006_auto_20160503_0937'),
]
operations = [
migrations.CreateModel(
name='Reviewer',
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')),
('unavailable_until', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)),
('filter_re', models.CharField(max_length=255, blank=True)),
('skip_next', models.IntegerField(help_text=b'Skip the next N review assignments')),
('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)),
('time', models.DateTimeField(auto_now_add=True)),
('deadline', models.DateTimeField()),
('requested_rev', models.CharField(help_text=b'Fill in if a specific revision is to be reviewed, e.g. 02', max_length=16, verbose_name=b'requested revision', blank=True)),
('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')),
('result', models.ForeignKey(blank=True, to='name.ReviewResultName', null=True)),
('review', models.OneToOneField(null=True, blank=True, to='doc.Document')),
('reviewer', models.ForeignKey(blank=True, to='group.Role', null=True)),
('state', models.ForeignKey(to='name.ReviewRequestStateName')),
('team', models.ForeignKey(to='group.Group')),
('type', models.ForeignKey(to='name.ReviewTypeName')),
],
options={
},
bases=(models.Model,),
),
]

View file

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

@ -0,0 +1,46 @@
from django.db import models
from ietf.doc.models import Document
from ietf.group.models import Group, Role
from ietf.person.models import Person
from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName
class Reviewer(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)
frequency = models.IntegerField(help_text="Can review every N days", default=30)
unavailable_until = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again")
filter_re = models.CharField(max_length=255, blank=True)
skip_next = models.IntegerField(help_text="Skip the next N review assignments")
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)
# Fields filled in on the initial record creation - these
# constitute the request part.
time = models.DateTimeField(auto_now_add=True)
type = models.ForeignKey(ReviewTypeName)
doc = models.ForeignKey(Document, related_name='review_request_set')
team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewresultname=None))
deadline = models.DateTimeField()
requested_rev = models.CharField(verbose_name="requested revision", max_length=16, blank=True, help_text="Fill in if a specific revision is to be reviewed, e.g. 02")
# Fields filled in as reviewer is assigned and as the review is
# uploaded. Once these are filled in and we progress beyond the
# states requested/assigned, any changes to the assignment happens
# by closing down the current request and making a new one,
# copying the request-part fields above.
reviewer = models.ForeignKey(Role, blank=True, null=True)
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)

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

@ -0,0 +1,65 @@
# 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 * # pyflakes:ignore
from ietf.person.resources import PersonResource
from ietf.group.resources import GroupResource
class ReviewerResource(ModelResource):
team = ToOneField(GroupResource, 'team')
person = ToOneField(PersonResource, 'person')
class Meta:
queryset = Reviewer.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(ReviewerResource())
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())

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

@ -0,0 +1,94 @@
from django.contrib.sites.models import Site
from ietf.group.models import Group, Role
from ietf.doc.models import DocEvent
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream
from ietf.review.models import ReviewRequestStateName, ReviewRequest
from ietf.utils.mail import send_mail
def active_review_teams():
# if there's a ReviewResultName defined, it's a review team
return Group.objects.filter(state="active").exclude(reviewresultname=None)
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):
if not user.is_authenticated():
return False
return Role.objects.filter(name__in=["secretary", "delegate"], person__user=user, group=team).exists() or has_role(user, "Secretariat")
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.state = ReviewRequestStateName.objects.get(slug="requested")
return obj
def email_about_review_request(request, review_req, subject, msg, by, notify_secretary, notify_reviewer):
"""Notify possibly both secretary and reviewer about change, skipping
a party if the change was done by that party."""
def extract_email_addresses(roles):
if any(r.person == by for r in roles if r):
return []
else:
return [r.formatted_email() for r in roles if r]
to = []
if notify_secretary:
to += extract_email_addresses(Role.objects.filter(name__in=["secretary", "delegate"], group=review_req.team).distinct())
if notify_reviewer:
to += extract_email_addresses([review_req.reviewer])
if not to:
return
send_mail(request, list(set(to)), None, subject, "doc/mail/review_request_changed.txt", {
"domain": Site.objects.get_current().domain,
"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_about_review_request(
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)
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_about_review_request(
request, review_req,
"Assigned to review of %s" % review_req.doc.name,
"%s has assigned you to review the document." % request.user.person,
by=request.user.person, notify_secretary=False, notify_reviewer=True)

View file

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

@ -460,6 +460,18 @@ label#list-feeds {
margin-left: 3em;
}
/* Review flow */
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;
}
.photo-name {
height: 3em;
}

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"]'],
"upload": ['[name="review_file"]'],
"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

@ -192,6 +192,32 @@
</td>
</tr>
{% if review_requests or can_request_review %}
<tr>
<th></th>
<th>Reviews</th>
<td class="edit"></td>
<td>
{% for r in review_requests %}
<div>
{% if r.state_id == "completed" or r.state_id == "part-completed" %}
<a href="{% url "doc_view" r.review.name %}">{{ r.team.acronym|upper }} {{ r.type.name }} Review{% if r.reviewed_rev and r.reviewed_rev != doc.rev %} (of -{{ r.reviewed_rev }}){% endif %}: {{ r.result.name }}</a>
{% else %}
<a href="{% url "ietf.doc.views_review.review_request" doc.name r.pk %}">{{ r.team.acronym|upper }} {{ r.type.name }} Review ({{ r.state.name }})</a>
{% endif %}
</div>
{% endfor %}
{% 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,81 @@
{% 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="3" class="alert-warning">The information below is for an old version of the document</th>
{% else %}
<th colspan="3"></th>
{% endif %}
</tr>
</thead>
<tbody class="meta">
<tr>
<th class="col-md-1">Team</th>
<td class="edit col-md-1"></td>
<td class="col-md-10">
{{ 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>Title</th>
<td class="edit"></td>
<td>{{ doc.title }}</td>
</tr>
<tr>
<th>State</th>
<td class="edit"></td>
<td>{{ doc.get_state.name }}</td>
</tr>
{% if review_req %}
<tr>
<th>Review result</th>
<td class="edit"></td>
<td><a href="{% url "ietf.doc.views_review.review_request" review_req.doc.name review_req.pk %}">{{ review_req.result.name }}</a></td>
</tr>
{% endif %}
{% if doc.external_url %}
<tr>
<th>Posted at</th>
<td class="edit"></td>
<td><a href="{{ doc.external_url }}">{{ doc.external_url }}</a></td>
</tr>
{% endif %}
<tr>
<th>Last updated</th>
<td class="edit"></td>
<td>{{ doc.time|date:"Y-m-d" }}</td>
</tr>
</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:
https://{{ domain }}{% url "ietf.doc.views_review.review_request" name=new_review_req.doc.name request_id=new_review_req.pk %}
{% endautoescape %}

View file

@ -0,0 +1,7 @@
{% autoescape off %}
{{ review_req.type.name }} review of: {{ review_req.doc.name }}{% if review_req.requested_rev %}-{{ review_req.requested_rev }}{% endif %}
https://{{ domain }}{% url "ietf.doc.views_review.review_request" name=review_req.doc.name request_id=review_req.pk %}
{{ 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,81 @@
{% 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 a 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 possibleRevisions = {{ possible_revisions_as_json|safe }};
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,34 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block pagehead %}
<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>
<form class="form-horizontal" method="post">
{% csrf_token %}
{% bootstrap_field form.type layout="horizontal" %}
{% bootstrap_field form.team layout="horizontal" %}
{% bootstrap_field form.deadline_date layout="horizontal" %}
{% bootstrap_field form.deadline_time 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>
{% endblock %}

View file

@ -0,0 +1,135 @@
{% 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 }}-<b>{{ review_req.requested_rev }}</b></a>
{% else %}
<a href="{% url "doc_view" name=review_req.doc.name %}">{{ review_req.doc.name }}</a>
{% endif %}
{% if can_withdraw_request %}
<a class="btn btn-danger btn-xs" href="{% url "ietf.doc.views_review.withdraw_request" name=doc.name request_id=review_req.pk %}"><span class="fa fa-ban"></span> Withdraw request</a>
{% endif %}
</td>
</tr>
<tr>
<th></th>
<th>Type</th>
<td>{{ review_req.type.name }} Review</td>
</tr>
<tr>
<th></th>
<th>Team</th>
<td>{{ review_req.team.acronym|upper }}</td>
</tr>
<tr>
<th></th>
<th>Deadline</th>
<td>
{% if review_req.deadline|date:"H:i" != "23:59" %}
{{ review_req.deadline|date:"Y-m-d H:i" }}
{% else %}
{{ review_req.deadline|date:"Y-m-d" }}
{% endif %}
</td>
</tr>
<tr>
<th></th>
<th>Requested</th>
<td>{{ review_req.time|date:"Y-m-d" }}</td>
</tr>
</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_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-default btn-xs" type="submit" name="action" value="accept"><span class="fa fa-check"></span> Accept</button></form>
{% endif %}
{% if can_reject_reviewer_assignment %}
<a class="btn btn-warning 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_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 %}
</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>
{% else %}
Not completed yet
{% 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 %}(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>
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}Withdraw review request for {{ review_req.doc.name }}{% endblock %}
{% block content %}
{% origin %}
<h1>Withdraw review request<br><small>{{ review_req.doc.name }}</small></h1>
<p>Do you want to withdraw the review request?</p>
<form method="post">
{% csrf_token %}
{% 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="withdraw">Withdraw request</button>
{% endbuttons %}
</form>
{% 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
@ -247,11 +247,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(

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/')

11
ietf/utils/text.py Normal file
View file

@ -0,0 +1,11 @@
def skip_prefix(text, prefix):
if text.startswith(prefix):
return text[len(prefix):]
else:
return text
def skip_suffix(text, prefix):
if text.endswith(prefix):
return text[:-len(prefix)]
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")