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:
parent
b790781de9
commit
7cbe36fb62
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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
95
ietf/review/mailarch.py
Normal 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
65
ietf/review/resources.py
Normal 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())
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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 + '>'
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
131
ietf/static/ietf/js/complete-review.js
Normal file
131
ietf/static/ietf/js/complete-review.js
Normal 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");
|
||||
});
|
|
@ -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 %}
|
||||
|
||||
|
|
6
ietf/templates/doc/mail/completed_review.txt
Normal file
6
ietf/templates/doc/mail/completed_review.txt
Normal 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 %}
|
6
ietf/templates/doc/mail/partially_completed_review.txt
Normal file
6
ietf/templates/doc/mail/partially_completed_review.txt
Normal 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 %}
|
6
ietf/templates/doc/mail/reviewer_assignment_rejected.txt
Normal file
6
ietf/templates/doc/mail/reviewer_assignment_rejected.txt
Normal file
|
@ -0,0 +1,6 @@
|
|||
{% autoescape off %}Reviewer assignment rejected by {{ by }}.{% if message_to_secretary %}
|
||||
|
||||
Explanation:
|
||||
|
||||
{{ message_to_secretary }}
|
||||
{% endif %}{% endautoescape %}
|
81
ietf/templates/doc/review/complete_review.html
Normal file
81
ietf/templates/doc/review/complete_review.html
Normal 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 %}
|
|
@ -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
11
ietf/utils/text.py
Normal 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
|
|
@ -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")
|
||||
|
|
Loading…
Reference in a new issue