Implement completing a review with tests. One can currently

enter/upload content or retrieve it from an IETF mailarch archive
through integrated searching support. Support for partial completion.
 - Legacy-Id: 11360
This commit is contained in:
Ole Laursen 2016-06-14 11:28:53 +00:00
parent b790781de9
commit 7cbe36fb62
20 changed files with 1695 additions and 688 deletions

View file

@ -1,14 +1,19 @@
# -*- coding: utf-8 -*-
import datetime
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
@ -17,7 +22,6 @@ 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"]))
@ -39,9 +43,28 @@ def make_review_data(doc):
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)
@ -229,3 +252,266 @@ class ReviewTests(TestCase):
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

@ -7,5 +7,7 @@ urlpatterns = patterns('',
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

@ -16,7 +16,7 @@ from ietf.doc.models import DocEvent, ConsensusDocEvent, BallotDocEvent, NewRevi
from ietf.doc.models import save_document_in_history
from ietf.name.models import DocReminderTypeName, DocRelationshipName
from ietf.group.models import Role
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream
from ietf.ietfauth.utils import has_role
from ietf.utils import draft, markup_txt
from ietf.utils.mail import send_mail
from ietf.mailtrigger.utils import gather_address_lists

View file

@ -1,19 +1,36 @@
import datetime
import datetime, os, email.utils
from django.http import HttpResponseForbidden
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
from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State
from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person
from ietf.name.models import ReviewRequestStateName
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)
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" })
@ -47,14 +64,7 @@ class RequestReviewForm(forms.ModelForm):
return v
def clean_requested_rev(self):
rev = self.cleaned_data.get("requested_rev")
if rev:
rev = rev.rjust(2, "0")
if not NewRevisionDocEvent.objects.filter(doc=self.doc, rev=rev).exists():
raise forms.ValidationError("Could not find revision '{}' of the document.".format(rev))
return rev
return clean_doc_revision(self.doc, self.cleaned_data.get("requested_rev"))
def clean(self):
deadline_date = self.cleaned_data.get('deadline_date')
@ -114,16 +124,20 @@ def review_request(request, name, request_id):
or can_manage_request))
can_assign_reviewer = (review_req.state_id in ["requested", "accepted"]
and is_authorized_in_doc_stream(request.user, doc))
and can_manage_request)
can_accept_reviewer_assignment = (review_req.state_id == "requested"
and review_req.reviewer_id is not None
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_id is not None
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()
@ -137,8 +151,10 @@ def review_request(request, name, request_id):
'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"])
@ -191,6 +207,7 @@ class AssignReviewerForm(forms.Form):
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"])
@ -217,8 +234,9 @@ def assign_reviewer(request, name, request_id):
})
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")
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"])
@ -251,21 +269,13 @@ def reject_reviewer_assignment(request, name, request_id):
)
# make a new unassigned review request
new_review_req = ReviewRequest.objects.create(
time=review_req.time,
type=review_req.type,
doc=review_req.doc,
team=review_req.team,
deadline=review_req.deadline,
requested_rev=review_req.requested_rev,
state=ReviewRequestStateName.objects.get(slug="requested"),
)
new_review_req = make_new_review_request_from_existing(review_req)
new_review_req.save()
msg = u"Reviewer assignment rejected by %s." % request.user.person
m = form.cleaned_data.get("message_to_secretary")
if m:
msg += "\n\n" + "Explanation:" + "\n" + m
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)
@ -278,3 +288,215 @@ def reject_reviewer_assignment(request, name, request_id):
'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.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"))
NewRevisionDocEvent.objects.create(
type="new_revision",
doc=review,
by=request.user.person,
rev=doc.rev,
desc='New revision available',
time=doc.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)

File diff suppressed because it is too large Load diff

View file

@ -15,25 +15,36 @@ def insert_initial_review_data(apps, schema_editor):
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="almost-ready", name="Almost Ready", order=1)
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="ready", name="Ready", order=6)
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="serious-issues", name="Serious Issues", order=9)
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.all()) + 1)
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
@ -43,6 +54,7 @@ class Migration(migrations.Migration):
dependencies = [
('name', '0011_reviewrequeststatename_reviewresultname_reviewtypename'),
('group', '0001_initial'),
('doc', '0001_initial'),
]
operations = [

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

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())

View file

@ -3,7 +3,7 @@ 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
from ietf.review.models import ReviewRequestStateName, ReviewRequest
from ietf.utils.mail import send_mail
def active_review_teams():
@ -22,6 +22,17 @@ def can_manage_review_requests_for_team(user, team):
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."""
@ -48,7 +59,6 @@ def email_about_review_request(request, review_req, subject, msg, by, notify_sec
"msg": msg,
})
def assign_review_request_to_reviewer(request, review_req, reviewer):
assert review_req.state_id in ("requested", "accepted")

View file

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

@ -459,3 +459,16 @@ label#list-feeds {
.email-subscription button[type=submit] {
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;
}

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

@ -200,7 +200,7 @@
<td>
{% for r in review_requests %}
<div>
<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>
<a href="{% url "ietf.doc.views_review.review_request" doc.name r.pk %}">{{ r.team.acronym|upper }} {{ r.type.name }} Review {% if r.reviewed_rev and r.reviewed_rev != doc.rev %}of rev. <b>{{ r.reviewed_rev }}</b>{% endif %} ({{ r.state.name }})</a>
</div>
{% endfor %}

View file

@ -0,0 +1,6 @@
{% 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 }}
{{ 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,6 @@
{% autoescape off %}Reviewer assignment rejected by {{ by }}.{% if message_to_secretary %}
Explanation:
{{ message_to_secretary }}
{% endif %}{% endautoescape %}

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

@ -19,6 +19,10 @@
{% 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>
@ -60,43 +64,51 @@
<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 %}
<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_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-default 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_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>
{% 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>
{% if review_req.review %}
<tr>
<th></th>
<th>Review</th>
<td><a href="{{ review_req.review.get_absolute_url }}">{{ review_req.review.name }}</a></td>
</tr>
{% endif %}
<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.reviewed_rev %}
<tr>
<th></th>
<th>Reviewed revision</th>
<td><a href="">{{ review_req.reviewed_rev }}</a></td>
<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 %}
@ -110,10 +122,4 @@
</tbody>
</table>
<div class="buttonlist">
{% if can_withdraw_request %}
<a class="btn btn-default 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 %}
</div>
{% endblock %}

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")