Create new branch from trunk@r11921, and merge review-tracker-r11360 into it
- Legacy-Id: 11923
This commit is contained in:
commit
74a02be9bf
|
@ -79,6 +79,8 @@ for _app in settings.INSTALLED_APPS:
|
|||
_root, _name = _app.split('.', 1)
|
||||
if _root == 'ietf':
|
||||
if not '.' in _name:
|
||||
if _name in _module_dict:
|
||||
continue
|
||||
|
||||
_api = Api(api_name=_name)
|
||||
_module_dict[_name] = _api
|
||||
|
|
|
@ -682,6 +682,10 @@ EVENT_TYPES = [
|
|||
("rfc_editor_received_announcement", "Announcement was received by RFC Editor"),
|
||||
("requested_publication", "Publication at RFC Editor requested"),
|
||||
("sync_from_rfc_editor", "Received updated information from RFC Editor"),
|
||||
|
||||
# review
|
||||
("requested_review", "Requested review"),
|
||||
("changed_review_request", "Changed review request"),
|
||||
]
|
||||
|
||||
class DocEvent(models.Model):
|
||||
|
|
540
ietf/doc/tests_review.py
Normal file
540
ietf/doc/tests_review.py
Normal file
|
@ -0,0 +1,540 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import datetime, os, shutil, json
|
||||
import tarfile, tempfile, mailbox
|
||||
import email.mime.multipart, email.mime.text, email.utils
|
||||
from StringIO import StringIO
|
||||
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
from django.conf import settings
|
||||
|
||||
from pyquery import PyQuery
|
||||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from ietf.review.models import ReviewRequest, ReviewTeamResult, ReviewerSettings
|
||||
import ietf.review.mailarch
|
||||
from ietf.person.models import Email, Person
|
||||
from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName, DocRelationshipName
|
||||
from ietf.doc.models import DocumentAuthor, Document, DocAlias, RelatedDocument, DocEvent
|
||||
from ietf.utils.test_utils import TestCase
|
||||
from ietf.utils.test_data import make_test_data, make_review_data
|
||||
from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects
|
||||
from ietf.utils.mail import outbox, empty_outbox
|
||||
|
||||
class ReviewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.review_dir = os.path.abspath("tmp-review-dir")
|
||||
if not os.path.exists(self.review_dir):
|
||||
os.mkdir(self.review_dir)
|
||||
|
||||
self.old_document_path_pattern = settings.DOCUMENT_PATH_PATTERN
|
||||
settings.DOCUMENT_PATH_PATTERN = self.review_dir + "/{doc.type_id}/"
|
||||
|
||||
self.review_subdir = os.path.join(self.review_dir, "review")
|
||||
if not os.path.exists(self.review_subdir):
|
||||
os.mkdir(self.review_subdir)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.review_dir)
|
||||
settings.DOCUMENT_PATH_PATTERN = self.old_document_path_pattern
|
||||
|
||||
def test_request_review(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_team = review_req.team
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.request_review', kwargs={ "name": doc.name })
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
# get
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
deadline = datetime.date.today() + datetime.timedelta(days=10)
|
||||
|
||||
# post request
|
||||
r = self.client.post(url, {
|
||||
"type": "early",
|
||||
"team": review_team.pk,
|
||||
"deadline": deadline.isoformat(),
|
||||
"requested_rev": "01",
|
||||
"requested_by": Person.objects.get(user__username="plain").pk,
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
req = ReviewRequest.objects.get(doc=doc, state="requested")
|
||||
self.assertEqual(req.deadline, deadline)
|
||||
self.assertEqual(req.team, review_team)
|
||||
self.assertEqual(req.requested_rev, "01")
|
||||
self.assertEqual(doc.latest_event().type, "requested_review")
|
||||
|
||||
def test_doc_page(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
|
||||
# move the review request to a doubly-replaced document to
|
||||
# check we can fish it out
|
||||
old_doc = Document.objects.get(name="draft-foo-mars-test")
|
||||
older_doc = Document.objects.create(name="draft-older")
|
||||
older_docalias = DocAlias.objects.create(name=older_doc.name, document=older_doc)
|
||||
RelatedDocument.objects.create(source=old_doc, target=older_docalias, relationship=DocRelationshipName.objects.get(slug='replaces'))
|
||||
review_req.doc = older_doc
|
||||
review_req.save()
|
||||
|
||||
url = urlreverse('doc_view', kwargs={ "name": doc.name })
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
content = unicontent(r)
|
||||
self.assertTrue("{} Review".format(review_req.type.name) in content)
|
||||
|
||||
def test_review_request(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req.team.acronym.upper() in unicontent(r))
|
||||
|
||||
def test_close_request(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
|
||||
close_url = urlreverse('ietf.doc.views_review.close_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
|
||||
|
||||
# follow link
|
||||
req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
r = self.client.get(req_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(close_url in unicontent(r))
|
||||
self.client.logout()
|
||||
|
||||
# get close page
|
||||
login_testing_unauthorized(self, "secretary", close_url)
|
||||
r = self.client.get(close_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# close
|
||||
empty_outbox()
|
||||
r = self.client.post(close_url, { "close_reason": "withdrawn" })
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "withdrawn")
|
||||
e = doc.latest_event()
|
||||
self.assertEqual(e.type, "changed_review_request")
|
||||
self.assertTrue("closed" in e.desc.lower())
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue("closed" in unicode(outbox[0]).lower())
|
||||
|
||||
def test_assign_reviewer(self):
|
||||
doc = make_test_data()
|
||||
|
||||
# set up some reviewer-suitability factors
|
||||
plain_email = Email.objects.filter(person__user__username="plain").first()
|
||||
DocumentAuthor.objects.create(
|
||||
author=plain_email,
|
||||
document=doc,
|
||||
)
|
||||
doc.rev = "10"
|
||||
doc.save_with_history([DocEvent.objects.create(doc=doc, type="changed_document", by=Person.objects.get(user__username="secretary"), desc="Test")])
|
||||
|
||||
# review to assign to
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
|
||||
review_req.reviewer = None
|
||||
review_req.save()
|
||||
|
||||
# previous review
|
||||
ReviewRequest.objects.create(
|
||||
time=datetime.datetime.now() - datetime.timedelta(days=100),
|
||||
requested_by=Person.objects.get(name="(System)"),
|
||||
doc=doc,
|
||||
type=ReviewTypeName.objects.get(slug="early"),
|
||||
team=review_req.team,
|
||||
state=ReviewRequestStateName.objects.get(slug="completed"),
|
||||
reviewed_rev="01",
|
||||
deadline=datetime.date.today() - datetime.timedelta(days=80),
|
||||
reviewer=plain_email,
|
||||
)
|
||||
|
||||
reviewer_settings = ReviewerSettings.objects.get(person__email=plain_email)
|
||||
reviewer_settings.filter_re = doc.name
|
||||
reviewer_settings.unavailable_until = datetime.datetime.now() + datetime.timedelta(days=10)
|
||||
reviewer_settings.save()
|
||||
|
||||
assign_url = urlreverse('ietf.doc.views_review.assign_reviewer', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
|
||||
|
||||
# follow link
|
||||
req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
r = self.client.get(req_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(assign_url in unicontent(r))
|
||||
self.client.logout()
|
||||
|
||||
# get assign page
|
||||
login_testing_unauthorized(self, "secretary", assign_url)
|
||||
r = self.client.get(assign_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
plain_label = q("option[value=\"{}\"]".format(plain_email.address)).text().lower()
|
||||
self.assertIn("ready for", plain_label)
|
||||
self.assertIn("reviewed document before", plain_label)
|
||||
self.assertIn("is author", plain_label)
|
||||
self.assertIn("regexp matches", plain_label)
|
||||
self.assertIn("unavailable until", plain_label)
|
||||
|
||||
# assign
|
||||
empty_outbox()
|
||||
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team).first()
|
||||
r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk })
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "requested")
|
||||
self.assertEqual(review_req.reviewer, reviewer)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue("assigned" in unicode(outbox[0]))
|
||||
|
||||
# re-assign
|
||||
empty_outbox()
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team).exclude(pk=reviewer.pk).first()
|
||||
r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk })
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "requested") # check that state is reset
|
||||
self.assertEqual(review_req.reviewer, reviewer)
|
||||
self.assertEqual(len(outbox), 2)
|
||||
self.assertTrue("cancelled your assignment" in unicode(outbox[0]))
|
||||
self.assertTrue("assigned" in unicode(outbox[1]))
|
||||
|
||||
def test_accept_reviewer_assignment(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
|
||||
review_req.save()
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
username = review_req.reviewer.person.user.username
|
||||
self.client.login(username=username, password=username + "+password")
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
self.assertTrue(q("[name=action][value=accept]"))
|
||||
|
||||
# accept
|
||||
r = self.client.post(url, { "action": "accept" })
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "accepted")
|
||||
|
||||
def test_reject_reviewer_assignment(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
|
||||
reject_url = urlreverse('ietf.doc.views_review.reject_reviewer_assignment', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
|
||||
|
||||
# follow link
|
||||
req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
r = self.client.get(req_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(reject_url in unicontent(r))
|
||||
self.client.logout()
|
||||
|
||||
# get reject page
|
||||
login_testing_unauthorized(self, "secretary", reject_url)
|
||||
r = self.client.get(reject_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r))
|
||||
|
||||
# reject
|
||||
empty_outbox()
|
||||
r = self.client.post(reject_url, { "action": "reject", "message_to_secretary": "Test message" })
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "rejected")
|
||||
e = doc.latest_event()
|
||||
self.assertEqual(e.type, "changed_review_request")
|
||||
self.assertTrue("rejected" in e.desc)
|
||||
self.assertEqual(ReviewRequest.objects.filter(doc=review_req.doc, team=review_req.team, state="requested").count(), 1)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue("Test message" in unicode(outbox[0]))
|
||||
|
||||
def make_test_mbox_tarball(self, review_req):
|
||||
mbox_path = os.path.join(self.review_dir, "testmbox.tar.gz")
|
||||
with tarfile.open(mbox_path, "w:gz") as tar:
|
||||
with tempfile.NamedTemporaryFile(dir=self.review_dir, suffix=".mbox") as tmp:
|
||||
mbox = mailbox.mbox(tmp.name)
|
||||
|
||||
# plain text
|
||||
msg = email.mime.text.MIMEText("Hello,\n\nI have reviewed the document and did not find any problems.\n\nJohn Doe")
|
||||
msg["From"] = "johndoe@example.com"
|
||||
msg["To"] = review_req.team.list_email
|
||||
msg["Subject"] = "Review of {}-01".format(review_req.doc.name)
|
||||
msg["Message-ID"] = email.utils.make_msgid()
|
||||
msg["Archived-At"] = "<https://www.example.com/testmessage>"
|
||||
msg["Date"] = email.utils.formatdate()
|
||||
|
||||
mbox.add(msg)
|
||||
|
||||
# plain text + HTML
|
||||
msg = email.mime.multipart.MIMEMultipart('alternative')
|
||||
msg["From"] = "johndoe2@example.com"
|
||||
msg["To"] = review_req.team.list_email
|
||||
msg["Subject"] = "Review of {}".format(review_req.doc.name)
|
||||
msg["Message-ID"] = email.utils.make_msgid()
|
||||
msg["Archived-At"] = "<https://www.example.com/testmessage2>"
|
||||
|
||||
msg.attach(email.mime.text.MIMEText("Hi!,\r\nLooks OK!\r\n-John", "plain"))
|
||||
msg.attach(email.mime.text.MIMEText("<html><body><p>Hi!,</p><p>Looks OK!</p><p>-John</p></body></html>", "html"))
|
||||
mbox.add(msg)
|
||||
|
||||
tmp.flush()
|
||||
|
||||
tar.add(os.path.relpath(tmp.name))
|
||||
|
||||
return mbox_path
|
||||
|
||||
def test_search_mail_archive(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
review_req.team.save()
|
||||
|
||||
# test URL construction
|
||||
query_urls = ietf.review.mailarch.construct_query_urls(review_req)
|
||||
self.assertTrue(review_req.doc.name in query_urls["query_data_url"])
|
||||
|
||||
# test parsing
|
||||
mbox_path = self.make_test_mbox_tarball(review_req)
|
||||
|
||||
try:
|
||||
# mock URL generator and point it to local file - for this
|
||||
# to work, the module (and not the function) must be
|
||||
# imported in the view
|
||||
real_fn = ietf.review.mailarch.construct_query_urls
|
||||
ietf.review.mailarch.construct_query_urls = lambda review_req, query=None: { "query_data_url": "file://" + os.path.abspath(mbox_path) }
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.search_mail_archive', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
messages = json.loads(r.content)["messages"]
|
||||
self.assertEqual(len(messages), 2)
|
||||
|
||||
self.assertEqual(messages[0]["url"], "https://www.example.com/testmessage")
|
||||
self.assertTrue("John Doe" in messages[0]["content"])
|
||||
self.assertEqual(messages[0]["subject"], "Review of {}-01".format(review_req.doc.name))
|
||||
|
||||
self.assertEqual(messages[1]["url"], "https://www.example.com/testmessage2")
|
||||
self.assertTrue("Looks OK" in messages[1]["content"])
|
||||
self.assertTrue("<html>" not in messages[1]["content"])
|
||||
self.assertEqual(messages[1]["subject"], "Review of {}".format(review_req.doc.name))
|
||||
finally:
|
||||
ietf.review.mailarch.construct_query_urls = real_fn
|
||||
|
||||
def setup_complete_review_test(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
for r in ReviewResultName.objects.filter(slug__in=("issues", "ready")):
|
||||
ReviewTeamResult.objects.get_or_create(team=review_req.team, result=r)
|
||||
review_req.team.save()
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
|
||||
return review_req, url
|
||||
|
||||
def test_complete_review_upload_content(self):
|
||||
review_req, url = self.setup_complete_review_test()
|
||||
|
||||
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
|
||||
|
||||
# get
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# faulty post
|
||||
r = self.client.post(url, data={
|
||||
"result": "ready",
|
||||
"state": "completed",
|
||||
"reviewed_rev": "abc",
|
||||
"review_submission": "upload",
|
||||
"review_content": "",
|
||||
"review_url": "",
|
||||
"review_file": "",
|
||||
})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
self.assertTrue(q("[name=reviewed_rev]").closest(".form-group").filter(".has-error"))
|
||||
self.assertTrue(q("[name=review_file]").closest(".form-group").filter(".has-error"))
|
||||
|
||||
# complete by uploading file
|
||||
empty_outbox()
|
||||
|
||||
test_file = StringIO("This is a review\nwith two lines")
|
||||
test_file.name = "unnamed"
|
||||
|
||||
r = self.client.post(url, data={
|
||||
"result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk,
|
||||
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
|
||||
"reviewed_rev": review_req.doc.rev,
|
||||
"review_submission": "upload",
|
||||
"review_content": "",
|
||||
"review_url": "",
|
||||
"review_file": test_file,
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "completed")
|
||||
self.assertEqual(review_req.result_id, "ready")
|
||||
self.assertEqual(review_req.reviewed_rev, review_req.doc.rev)
|
||||
self.assertTrue(review_req.team.acronym.lower() in review_req.review.name)
|
||||
self.assertTrue(review_req.doc.rev in review_req.review.name)
|
||||
|
||||
with open(os.path.join(self.review_subdir, review_req.review.name + ".txt")) as f:
|
||||
self.assertEqual(f.read(), "This is a review\nwith two lines")
|
||||
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(review_req.team.list_email in outbox[0]["To"])
|
||||
self.assertTrue("This is a review" in unicode(outbox[0]))
|
||||
|
||||
self.assertTrue(settings.MAILING_LIST_ARCHIVE_URL in review_req.review.external_url)
|
||||
|
||||
def test_complete_review_enter_content(self):
|
||||
review_req, url = self.setup_complete_review_test()
|
||||
|
||||
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
|
||||
|
||||
# complete by uploading file
|
||||
empty_outbox()
|
||||
|
||||
r = self.client.post(url, data={
|
||||
"result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk,
|
||||
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
|
||||
"reviewed_rev": review_req.doc.rev,
|
||||
"review_submission": "enter",
|
||||
"review_content": "This is a review\nwith two lines",
|
||||
"review_url": "",
|
||||
"review_file": "",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "completed")
|
||||
|
||||
with open(os.path.join(self.review_subdir, review_req.review.name + ".txt")) as f:
|
||||
self.assertEqual(f.read(), "This is a review\nwith two lines")
|
||||
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(review_req.team.list_email in outbox[0]["To"])
|
||||
self.assertTrue("This is a review" in unicode(outbox[0]))
|
||||
|
||||
self.assertTrue(settings.MAILING_LIST_ARCHIVE_URL in review_req.review.external_url)
|
||||
|
||||
def test_complete_review_link_to_mailing_list(self):
|
||||
review_req, url = self.setup_complete_review_test()
|
||||
|
||||
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
|
||||
|
||||
# complete by uploading file
|
||||
empty_outbox()
|
||||
|
||||
r = self.client.post(url, data={
|
||||
"result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk,
|
||||
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
|
||||
"reviewed_rev": review_req.doc.rev,
|
||||
"review_submission": "link",
|
||||
"review_content": "This is a review\nwith two lines",
|
||||
"review_url": "http://example.com/testreview/",
|
||||
"review_file": "",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "completed")
|
||||
|
||||
with open(os.path.join(self.review_subdir, review_req.review.name + ".txt")) as f:
|
||||
self.assertEqual(f.read(), "This is a review\nwith two lines")
|
||||
|
||||
self.assertEqual(len(outbox), 0)
|
||||
self.assertTrue("http://example.com" in review_req.review.external_url)
|
||||
|
||||
def test_partially_complete_review(self):
|
||||
review_req, url = self.setup_complete_review_test()
|
||||
|
||||
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
|
||||
|
||||
# partially complete
|
||||
empty_outbox()
|
||||
|
||||
r = self.client.post(url, data={
|
||||
"result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk,
|
||||
"state": ReviewRequestStateName.objects.get(slug="part-completed").pk,
|
||||
"reviewed_rev": review_req.doc.rev,
|
||||
"review_submission": "enter",
|
||||
"review_content": "This is a review\nwith two lines",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "part-completed")
|
||||
self.assertTrue(review_req.doc.rev in review_req.review.name)
|
||||
|
||||
self.assertEqual(len(outbox), 2)
|
||||
self.assertTrue("secretary" in outbox[0]["To"])
|
||||
self.assertTrue("partially" in outbox[0]["Subject"].lower())
|
||||
self.assertTrue("new review request" in unicode(outbox[0]))
|
||||
|
||||
self.assertTrue(review_req.team.list_email in outbox[1]["To"])
|
||||
self.assertTrue("partial review" in outbox[1]["Subject"].lower())
|
||||
self.assertTrue("This is a review" in unicode(outbox[1]))
|
||||
|
||||
first_review = review_req.review
|
||||
first_reviewer = review_req.reviewer
|
||||
|
||||
|
||||
# complete
|
||||
review_req = ReviewRequest.objects.get(state="requested", doc=review_req.doc, team=review_req.team)
|
||||
self.assertEqual(review_req.reviewer, None)
|
||||
review_req.reviewer = first_reviewer # same reviewer, so we can test uniquification
|
||||
review_req.save()
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": review_req.doc.name, "request_id": review_req.pk })
|
||||
|
||||
r = self.client.post(url, data={
|
||||
"result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk,
|
||||
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
|
||||
"reviewed_rev": review_req.doc.rev,
|
||||
"review_submission": "enter",
|
||||
"review_content": "This is another review\nwith\nthree lines",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "completed")
|
||||
self.assertTrue(review_req.doc.rev in review_req.review.name)
|
||||
second_review = review_req.review
|
||||
self.assertTrue(first_review.name != second_review.name)
|
||||
self.assertTrue(second_review.name.endswith("-2")) # uniquified
|
|
@ -74,6 +74,7 @@ urlpatterns = patterns('',
|
|||
url(r'^%(name)s/ballot/(?P<ballot_id>[0-9]+)/emailposition/$' % settings.URL_REGEXPS, views_ballot.send_ballot_comment, name='doc_send_ballot_comment'),
|
||||
(r'^%(name)s/(?:%(rev)s/)?doc.json$' % settings.URL_REGEXPS, views_doc.document_json),
|
||||
(r'^%(name)s/ballotpopup/(?P<ballot_id>[0-9]+)/$' % settings.URL_REGEXPS, views_doc.ballot_popup),
|
||||
url(r'^(?P<name>[A-Za-z0-9._+-]+)/reviewrequest/', include("ietf.doc.urls_review")),
|
||||
|
||||
url(r'^%(name)s/email-aliases/$' % settings.URL_REGEXPS, RedirectView.as_view(pattern_name='doc_email', permanent=False),name='doc_specific_email_aliases'),
|
||||
|
||||
|
|
13
ietf/doc/urls_review.py
Normal file
13
ietf/doc/urls_review.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from django.conf.urls import patterns, url
|
||||
from ietf.doc import views_review
|
||||
|
||||
urlpatterns = patterns('',
|
||||
url(r'^$', views_review.request_review),
|
||||
url(r'^(?P<request_id>[0-9]+)/$', views_review.review_request),
|
||||
url(r'^(?P<request_id>[0-9]+)/close/$', views_review.close_request),
|
||||
url(r'^(?P<request_id>[0-9]+)/assignreviewer/$', views_review.assign_reviewer),
|
||||
url(r'^(?P<request_id>[0-9]+)/rejectreviewerassignment/$', views_review.reject_reviewer_assignment),
|
||||
url(r'^(?P<request_id>[0-9]+)/complete/$', views_review.complete_review),
|
||||
url(r'^(?P<request_id>[0-9]+)/searchmailarchive/$', views_review.search_mail_archive),
|
||||
)
|
||||
|
|
@ -3,6 +3,7 @@ import re
|
|||
import urllib
|
||||
import math
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.query import EmptyQuerySet
|
||||
|
@ -109,7 +110,6 @@ def can_adopt_draft(user, doc):
|
|||
group__state="active",
|
||||
person__user=user).exists())
|
||||
|
||||
|
||||
def two_thirds_rule( recused=0 ):
|
||||
# For standards-track, need positions from 2/3 of the non-recused current IESG.
|
||||
active = Role.objects.filter(name="ad",group__type="area",group__state="active").count()
|
||||
|
@ -587,6 +587,39 @@ def uppercase_std_abbreviated_name(name):
|
|||
else:
|
||||
return name
|
||||
|
||||
def extract_complete_replaces_ancestor_mapping_for_docs(names):
|
||||
"""Return dict mapping all replaced by relationships of the
|
||||
replacement ancestors to docs. So if x is directly replaced by y
|
||||
and y is in names or replaced by something in names, x in
|
||||
replaces[y]."""
|
||||
|
||||
replaces = defaultdict(set)
|
||||
|
||||
checked = set()
|
||||
front = names
|
||||
while True:
|
||||
if not front:
|
||||
break
|
||||
|
||||
relations = RelatedDocument.objects.filter(
|
||||
source__in=front, relationship="replaces"
|
||||
).select_related("target").values_list("source", "target__document")
|
||||
|
||||
if not relations:
|
||||
break
|
||||
|
||||
checked.update(front)
|
||||
|
||||
front = []
|
||||
for source_doc, target_doc in relations:
|
||||
replaces[source_doc].add(target_doc)
|
||||
|
||||
if target_doc not in checked:
|
||||
front.append(target_doc)
|
||||
|
||||
return replaces
|
||||
|
||||
|
||||
def crawl_history(doc):
|
||||
# return document history data for inclusion in doc.json (used by timeline)
|
||||
def get_ancestors(doc):
|
||||
|
|
|
@ -344,14 +344,6 @@ class UploadForm(forms.Form):
|
|||
def clean_txt(self):
|
||||
return get_cleaned_text_file_content(self.cleaned_data["txt"])
|
||||
|
||||
def save(self, group, rev):
|
||||
filename = os.path.join(settings.CHARTER_PATH, '%s-%s.txt' % (group.charter.canonical_name(), rev))
|
||||
with open(filename, 'wb') as destination:
|
||||
if self.cleaned_data['txt']:
|
||||
destination.write(self.cleaned_data['txt'])
|
||||
else:
|
||||
destination.write(self.cleaned_data['content'].encode("utf-8"))
|
||||
|
||||
@login_required
|
||||
def submit(request, name, option=None):
|
||||
if not name.startswith('charter-'):
|
||||
|
@ -417,7 +409,12 @@ def submit(request, name, option=None):
|
|||
events.append(e)
|
||||
|
||||
# Save file on disk
|
||||
form.save(group, charter.rev)
|
||||
filename = os.path.join(settings.CHARTER_PATH, '%s-%s.txt' % (charter.canonical_name(), charter.rev))
|
||||
with open(filename, 'wb') as destination:
|
||||
if form.cleaned_data['txt']:
|
||||
destination.write(form.cleaned_data['txt'])
|
||||
else:
|
||||
destination.write(form.cleaned_data['content'].encode("utf-8"))
|
||||
|
||||
if option in ['initcharter','recharter'] and charter.ad == None:
|
||||
charter.ad = getattr(group.ad_role(),'person',None)
|
||||
|
|
|
@ -50,7 +50,7 @@ from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDo
|
|||
from ietf.doc.utils import ( add_links_in_new_revision_events, augment_events_with_revision,
|
||||
can_adopt_draft, get_chartering_type, get_document_content, get_tags_for_stream_id,
|
||||
needed_ballot_positions, nice_consensus, prettify_std_name, update_telechat, has_same_ballot,
|
||||
get_initial_notify, make_notify_changed_event, crawl_history, default_consensus)
|
||||
get_initial_notify, make_notify_changed_event, crawl_history, default_consensus )
|
||||
from ietf.community.utils import augment_docs_with_tracking_info
|
||||
from ietf.group.models import Role
|
||||
from ietf.group.utils import can_manage_group, can_manage_materials
|
||||
|
@ -59,10 +59,13 @@ from ietf.name.models import StreamName, BallotPositionName
|
|||
from ietf.person.models import Email
|
||||
from ietf.utils.history import find_history_active_at
|
||||
from ietf.doc.forms import TelechatForm, NotifyForm
|
||||
from ietf.doc.mails import email_comment
|
||||
from ietf.doc.mails import email_comment
|
||||
from ietf.mailtrigger.utils import gather_relevant_expansions
|
||||
from ietf.meeting.models import Session
|
||||
from ietf.meeting.utils import group_sessions, get_upcoming_manageable_sessions, sort_sessions
|
||||
from ietf.review.models import ReviewRequest
|
||||
from ietf.review.utils import can_request_review_of_doc, review_requests_to_list_for_doc
|
||||
from ietf.review.utils import no_review_from_teams_on_doc
|
||||
|
||||
def render_document_top(request, doc, tab, name):
|
||||
tabs = []
|
||||
|
@ -281,8 +284,8 @@ def document_main(request, name, rev=None):
|
|||
can_edit_stream_info = is_authorized_in_doc_stream(request.user, doc)
|
||||
can_edit_shepherd_writeup = can_edit_stream_info or user_is_person(request.user, doc.shepherd and doc.shepherd.person) or has_role(request.user, ["Area Director"])
|
||||
can_edit_notify = can_edit_shepherd_writeup
|
||||
can_edit_consensus = False
|
||||
|
||||
can_edit_consensus = False
|
||||
consensus = nice_consensus(default_consensus(doc))
|
||||
if doc.stream_id == "ietf" and iesg_state:
|
||||
show_in_states = set(IESG_BALLOT_ACTIVE_STATES)
|
||||
|
@ -296,6 +299,8 @@ def document_main(request, name, rev=None):
|
|||
e = doc.latest_event(ConsensusDocEvent, type="changed_consensus")
|
||||
consensus = nice_consensus(e and e.consensus)
|
||||
|
||||
can_request_review = can_request_review_of_doc(request.user, doc)
|
||||
|
||||
# mailing list search archive
|
||||
search_archive = "www.ietf.org/mail-archive/web/"
|
||||
if doc.stream_id == "ietf" and group.type_id == "wg" and group.list_archive:
|
||||
|
@ -355,6 +360,9 @@ def document_main(request, name, rev=None):
|
|||
published = doc.latest_event(type="published_rfc")
|
||||
started_iesg_process = doc.latest_event(type="started_iesg_process")
|
||||
|
||||
review_requests = review_requests_to_list_for_doc(doc)
|
||||
no_review_from_teams = no_review_from_teams_on_doc(doc, rev or doc.rev)
|
||||
|
||||
return render_to_response("doc/document_draft.html",
|
||||
dict(doc=doc,
|
||||
group=group,
|
||||
|
@ -376,6 +384,7 @@ def document_main(request, name, rev=None):
|
|||
can_edit_consensus=can_edit_consensus,
|
||||
can_edit_replaces=can_edit_replaces,
|
||||
can_view_possibly_replaces=can_view_possibly_replaces,
|
||||
can_request_review=can_request_review,
|
||||
|
||||
rfc_number=rfc_number,
|
||||
draft_name=draft_name,
|
||||
|
@ -414,6 +423,8 @@ def document_main(request, name, rev=None):
|
|||
search_archive=search_archive,
|
||||
actions=actions,
|
||||
presentations=presentations,
|
||||
review_requests=review_requests,
|
||||
no_review_from_teams=no_review_from_teams,
|
||||
),
|
||||
context_instance=RequestContext(request))
|
||||
|
||||
|
@ -565,6 +576,29 @@ def document_main(request, name, rev=None):
|
|||
),
|
||||
context_instance=RequestContext(request))
|
||||
|
||||
|
||||
if doc.type_id == "review":
|
||||
basename = "{}.txt".format(doc.name, doc.rev)
|
||||
pathname = os.path.join(doc.get_file_path(), basename)
|
||||
content = get_document_content(basename, pathname, split=False)
|
||||
|
||||
review_req = ReviewRequest.objects.filter(review=doc.name).first()
|
||||
|
||||
other_reviews = []
|
||||
if review_req:
|
||||
other_reviews = [r for r in review_requests_to_list_for_doc(review_req.doc) if r != review_req]
|
||||
|
||||
return render(request, "doc/document_review.html",
|
||||
dict(doc=doc,
|
||||
top=top,
|
||||
content=content,
|
||||
revisions=revisions,
|
||||
latest_rev=latest_rev,
|
||||
snapshot=snapshot,
|
||||
review_req=review_req,
|
||||
other_reviews=other_reviews,
|
||||
))
|
||||
|
||||
raise Http404
|
||||
|
||||
|
||||
|
|
533
ietf/doc/views_review.py
Normal file
533
ietf/doc/views_review.py
Normal file
|
@ -0,0 +1,533 @@
|
|||
import datetime, os, email.utils
|
||||
|
||||
from django.http import HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django import forms
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.html import mark_safe
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.template.loader import render_to_string
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
|
||||
from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias, LastCallDocEvent
|
||||
from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName
|
||||
from ietf.review.models import ReviewRequest
|
||||
from ietf.group.models import Group
|
||||
from ietf.person.fields import PersonEmailChoiceField, SearchablePersonField
|
||||
from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role
|
||||
from ietf.review.utils import (active_review_teams, assign_review_request_to_reviewer,
|
||||
can_request_review_of_doc, can_manage_review_requests_for_team,
|
||||
email_review_request_change, make_new_review_request_from_existing,
|
||||
close_review_request_states, close_review_request,
|
||||
setup_reviewer_field)
|
||||
from ietf.review import mailarch
|
||||
from ietf.utils.fields import DatepickerDateField
|
||||
from ietf.utils.text import strip_prefix
|
||||
from ietf.utils.textupload import get_cleaned_text_file_content
|
||||
from ietf.utils.mail import send_mail
|
||||
|
||||
def clean_doc_revision(doc, rev):
|
||||
if rev:
|
||||
rev = rev.rjust(2, "0")
|
||||
|
||||
if not NewRevisionDocEvent.objects.filter(doc=doc, rev=rev).exists():
|
||||
raise forms.ValidationError("Could not find revision \"{}\" of the document.".format(rev))
|
||||
|
||||
return rev
|
||||
|
||||
class RequestReviewForm(forms.ModelForm):
|
||||
team = forms.ModelMultipleChoiceField(queryset=Group.objects.all(), widget=forms.CheckboxSelectMultiple)
|
||||
deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" })
|
||||
|
||||
class Meta:
|
||||
model = ReviewRequest
|
||||
fields = ('requested_by', 'type', 'deadline', 'requested_rev')
|
||||
|
||||
def __init__(self, user, doc, *args, **kwargs):
|
||||
super(RequestReviewForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.doc = doc
|
||||
|
||||
self.fields['type'].queryset = self.fields['type'].queryset.filter(used=True)
|
||||
self.fields['type'].widget = forms.RadioSelect(choices=[t for t in self.fields['type'].choices if t[0]])
|
||||
|
||||
f = self.fields["team"]
|
||||
f.queryset = active_review_teams()
|
||||
if not is_authorized_in_doc_stream(user, doc): # user is a reviewer
|
||||
f.queryset = f.queryset.filter(role__name="reviewer", role__person__user=user)
|
||||
|
||||
f.initial = [group.pk for group in f.queryset if can_manage_review_requests_for_team(user, group, allow_non_team_personnel=False)]
|
||||
|
||||
self.fields["requested_rev"].label = "Document revision"
|
||||
|
||||
if has_role(user, "Secretariat"):
|
||||
self.fields["requested_by"] = SearchablePersonField()
|
||||
else:
|
||||
self.fields["requested_by"].widget = forms.HiddenInput()
|
||||
self.fields["requested_by"].initial = user.person.pk
|
||||
|
||||
def clean_deadline(self):
|
||||
v = self.cleaned_data.get('deadline')
|
||||
if v < datetime.date.today():
|
||||
raise forms.ValidationError("Select today or a date in the future.")
|
||||
return v
|
||||
|
||||
def clean_requested_rev(self):
|
||||
return clean_doc_revision(self.doc, self.cleaned_data.get("requested_rev"))
|
||||
|
||||
@login_required
|
||||
def request_review(request, name):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
|
||||
if not can_request_review_of_doc(request.user, doc):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
lc_ends = None
|
||||
e = doc.latest_event(LastCallDocEvent, type="sent_last_call")
|
||||
if e and e.expires >= now:
|
||||
lc_ends = e.expires
|
||||
|
||||
scheduled_for_telechat = doc.telechat_date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = RequestReviewForm(request.user, doc, request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
teams = form.cleaned_data["team"]
|
||||
for team in teams:
|
||||
review_req = form.save(commit=False)
|
||||
review_req.doc = doc
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="requested", used=True)
|
||||
review_req.team = team
|
||||
review_req.save()
|
||||
|
||||
DocEvent.objects.create(
|
||||
type="requested_review",
|
||||
doc=doc,
|
||||
by=request.user.person,
|
||||
desc="Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()),
|
||||
time=review_req.time,
|
||||
)
|
||||
|
||||
return redirect('doc_view', name=doc.name)
|
||||
|
||||
else:
|
||||
if lc_ends:
|
||||
review_type = "lc"
|
||||
elif scheduled_for_telechat:
|
||||
review_type = "telechat"
|
||||
else:
|
||||
review_type = "early"
|
||||
|
||||
form = RequestReviewForm(request.user, doc, initial={ "type": review_type })
|
||||
|
||||
return render(request, 'doc/review/request_review.html', {
|
||||
'doc': doc,
|
||||
'form': form,
|
||||
'lc_ends': lc_ends,
|
||||
'lc_ends_days': (lc_ends - now).days if lc_ends else None,
|
||||
'scheduled_for_telechat': scheduled_for_telechat,
|
||||
'scheduled_for_telechat_days': (scheduled_for_telechat - now.date()).days if scheduled_for_telechat else None,
|
||||
})
|
||||
|
||||
def review_request(request, name, request_id):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id)
|
||||
|
||||
is_reviewer = review_req.reviewer and user_is_person(request.user, review_req.reviewer.person)
|
||||
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
|
||||
|
||||
can_close_request = (review_req.state_id in ["requested", "accepted"]
|
||||
and (is_authorized_in_doc_stream(request.user, doc)
|
||||
or can_manage_request))
|
||||
|
||||
can_assign_reviewer = (review_req.state_id in ["requested", "accepted"]
|
||||
and can_manage_request)
|
||||
|
||||
can_accept_reviewer_assignment = (review_req.state_id == "requested"
|
||||
and review_req.reviewer
|
||||
and (is_reviewer or can_manage_request))
|
||||
|
||||
can_reject_reviewer_assignment = (review_req.state_id in ["requested", "accepted"]
|
||||
and review_req.reviewer
|
||||
and (is_reviewer or can_manage_request))
|
||||
|
||||
can_complete_review = (review_req.state_id in ["requested", "accepted"]
|
||||
and review_req.reviewer
|
||||
and (is_reviewer or can_manage_request))
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "accept" and can_accept_reviewer_assignment:
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
|
||||
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
|
||||
|
||||
return render(request, 'doc/review/review_request.html', {
|
||||
'doc': doc,
|
||||
'review_req': review_req,
|
||||
'can_close_request': can_close_request,
|
||||
'can_reject_reviewer_assignment': can_reject_reviewer_assignment,
|
||||
'can_assign_reviewer': can_assign_reviewer,
|
||||
'can_accept_reviewer_assignment': can_accept_reviewer_assignment,
|
||||
'can_complete_review': can_complete_review,
|
||||
})
|
||||
|
||||
|
||||
class CloseReviewRequestForm(forms.Form):
|
||||
close_reason = forms.ModelChoiceField(queryset=close_review_request_states(), widget=forms.RadioSelect, empty_label=None)
|
||||
|
||||
def __init__(self, can_manage_request, *args, **kwargs):
|
||||
super(CloseReviewRequestForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if not can_manage_request:
|
||||
self.fields["close_reason"].queryset = self.fields["close_reason"].queryset.filter(slug__in=["withdrawn"])
|
||||
|
||||
if len(self.fields["close_reason"].queryset) == 1:
|
||||
self.fields["close_reason"].initial = self.fields["close_reason"].queryset.first().pk
|
||||
self.fields["close_reason"].widget = forms.HiddenInput()
|
||||
|
||||
|
||||
@login_required
|
||||
def close_request(request, name, request_id):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
|
||||
|
||||
can_request = is_authorized_in_doc_stream(request.user, doc)
|
||||
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
|
||||
|
||||
if not (can_request or can_manage_request):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
if request.method == "POST":
|
||||
form = CloseReviewRequestForm(can_manage_request, request.POST)
|
||||
if form.is_valid():
|
||||
close_review_request(request, review_req, form.cleaned_data["close_reason"])
|
||||
|
||||
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
|
||||
else:
|
||||
form = CloseReviewRequestForm(can_manage_request)
|
||||
|
||||
return render(request, 'doc/review/close_request.html', {
|
||||
'doc': doc,
|
||||
'review_req': review_req,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
|
||||
class AssignReviewerForm(forms.Form):
|
||||
reviewer = PersonEmailChoiceField(empty_label="(None)", required=False)
|
||||
|
||||
def __init__(self, review_req, *args, **kwargs):
|
||||
super(AssignReviewerForm, self).__init__(*args, **kwargs)
|
||||
setup_reviewer_field(self.fields["reviewer"], review_req)
|
||||
|
||||
|
||||
@login_required
|
||||
def assign_reviewer(request, name, request_id):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
|
||||
|
||||
if not can_manage_review_requests_for_team(request.user, review_req.team):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "assign":
|
||||
form = AssignReviewerForm(review_req, request.POST)
|
||||
if form.is_valid():
|
||||
reviewer = form.cleaned_data["reviewer"]
|
||||
assign_review_request_to_reviewer(request, review_req, reviewer)
|
||||
|
||||
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
|
||||
else:
|
||||
form = AssignReviewerForm(review_req)
|
||||
|
||||
return render(request, 'doc/review/assign_reviewer.html', {
|
||||
'doc': doc,
|
||||
'review_req': review_req,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
class RejectReviewerAssignmentForm(forms.Form):
|
||||
message_to_secretary = forms.CharField(widget=forms.Textarea, required=False, help_text="Optional explanation of rejection, will be emailed to team secretary if filled in")
|
||||
|
||||
@login_required
|
||||
def reject_reviewer_assignment(request, name, request_id):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
|
||||
|
||||
if not review_req.reviewer:
|
||||
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
|
||||
|
||||
is_reviewer = user_is_person(request.user, review_req.reviewer.person)
|
||||
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
|
||||
|
||||
if not (is_reviewer or can_manage_request):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "reject":
|
||||
form = RejectReviewerAssignmentForm(request.POST)
|
||||
if form.is_valid():
|
||||
# reject the request
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="rejected")
|
||||
review_req.save()
|
||||
|
||||
DocEvent.objects.create(
|
||||
type="changed_review_request",
|
||||
doc=review_req.doc,
|
||||
by=request.user.person,
|
||||
desc="Assignment of request for {} review by {} to {} was rejected".format(
|
||||
review_req.type.name,
|
||||
review_req.team.acronym.upper(),
|
||||
review_req.reviewer.person,
|
||||
),
|
||||
)
|
||||
|
||||
# make a new unassigned review request
|
||||
new_review_req = make_new_review_request_from_existing(review_req)
|
||||
new_review_req.save()
|
||||
|
||||
msg = render_to_string("doc/mail/reviewer_assignment_rejected.txt", {
|
||||
"by": request.user.person,
|
||||
"message_to_secretary": form.cleaned_data.get("message_to_secretary")
|
||||
})
|
||||
|
||||
email_review_request_change(request, review_req, "Reviewer assignment rejected", msg, by=request.user.person, notify_secretary=True, notify_reviewer=True, notify_requested_by=False)
|
||||
|
||||
return redirect(review_request, name=new_review_req.doc.name, request_id=new_review_req.pk)
|
||||
else:
|
||||
form = RejectReviewerAssignmentForm()
|
||||
|
||||
return render(request, 'doc/review/reject_reviewer_assignment.html', {
|
||||
'doc': doc,
|
||||
'review_req': review_req,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
class CompleteReviewForm(forms.Form):
|
||||
state = forms.ModelChoiceField(queryset=ReviewRequestStateName.objects.filter(slug__in=("completed", "part-completed")).order_by("-order"), widget=forms.RadioSelect, initial="completed")
|
||||
reviewed_rev = forms.CharField(label="Reviewed revision", max_length=4)
|
||||
result = forms.ModelChoiceField(queryset=ReviewResultName.objects.filter(used=True), widget=forms.RadioSelect, empty_label=None)
|
||||
ACTIONS = [
|
||||
("enter", "Enter review content (automatically posts to {mailing_list})"),
|
||||
("upload", "Upload review content in text file (automatically posts to {mailing_list})"),
|
||||
("link", "Link to review message already sent to {mailing_list}"),
|
||||
]
|
||||
review_submission = forms.ChoiceField(choices=ACTIONS, widget=forms.RadioSelect)
|
||||
|
||||
review_url = forms.URLField(label="Link to message", required=False)
|
||||
review_file = forms.FileField(label="Text file to upload", required=False)
|
||||
review_content = forms.CharField(widget=forms.Textarea, required=False)
|
||||
cc = forms.CharField(required=False, help_text="Email addresses to send to in addition to the review team list")
|
||||
|
||||
def __init__(self, review_req, *args, **kwargs):
|
||||
self.review_req = review_req
|
||||
|
||||
super(CompleteReviewForm, self).__init__(*args, **kwargs)
|
||||
|
||||
doc = self.review_req.doc
|
||||
|
||||
known_revisions = NewRevisionDocEvent.objects.filter(doc=doc).order_by("time", "id").values_list("rev", flat=True)
|
||||
|
||||
self.fields["state"].choices = [
|
||||
(slug, "{} - extra reviewer is to be assigned".format(label)) if slug == "part-completed" else (slug, label)
|
||||
for slug, label in self.fields["state"].choices
|
||||
]
|
||||
|
||||
self.fields["reviewed_rev"].help_text = mark_safe(
|
||||
" ".join("<a class=\"rev label label-default\">{}</a>".format(r)
|
||||
for r in known_revisions))
|
||||
|
||||
self.fields["result"].queryset = self.fields["result"].queryset.filter(reviewteamresult__team=review_req.team)
|
||||
self.fields["review_submission"].choices = [
|
||||
(k, label.format(mailing_list=review_req.team.list_email or "[error: team has no mailing list set]"))
|
||||
for k, label in self.fields["review_submission"].choices
|
||||
]
|
||||
|
||||
def clean_reviewed_rev(self):
|
||||
return clean_doc_revision(self.review_req.doc, self.cleaned_data.get("reviewed_rev"))
|
||||
|
||||
def clean_review_content(self):
|
||||
return self.cleaned_data["review_content"].replace("\r", "")
|
||||
|
||||
def clean_review_file(self):
|
||||
return get_cleaned_text_file_content(self.cleaned_data["review_file"])
|
||||
|
||||
def clean(self):
|
||||
def require_field(f):
|
||||
if not self.cleaned_data.get(f):
|
||||
self.add_error(f, ValidationError("You must fill in this field."))
|
||||
|
||||
submission_method = self.cleaned_data.get("review_submission")
|
||||
if submission_method == "enter":
|
||||
require_field("review_content")
|
||||
elif submission_method == "upload":
|
||||
require_field("review_file")
|
||||
elif submission_method == "link":
|
||||
require_field("review_url")
|
||||
require_field("review_content")
|
||||
|
||||
@login_required
|
||||
def complete_review(request, name, request_id):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
|
||||
|
||||
if not review_req.reviewer:
|
||||
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
|
||||
|
||||
is_reviewer = user_is_person(request.user, review_req.reviewer.person)
|
||||
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
|
||||
|
||||
if not (is_reviewer or can_manage_request):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
if request.method == "POST":
|
||||
form = CompleteReviewForm(review_req, request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
review_submission = form.cleaned_data['review_submission']
|
||||
|
||||
# create review doc
|
||||
for i in range(1, 100):
|
||||
name_components = [
|
||||
"review",
|
||||
strip_prefix(review_req.doc.name, "draft-"),
|
||||
form.cleaned_data["reviewed_rev"],
|
||||
review_req.team.acronym,
|
||||
review_req.type.slug if review_req.type.slug != "unknown" else "",
|
||||
review_req.reviewer.person.ascii_parts()[3],
|
||||
datetime.date.today().isoformat(),
|
||||
]
|
||||
if i > 1:
|
||||
name_components.append(str(i))
|
||||
|
||||
name = "-".join(c for c in name_components if c).lower()
|
||||
if not Document.objects.filter(name=name).exists():
|
||||
review = Document.objects.create(name=name)
|
||||
break
|
||||
|
||||
e = NewRevisionDocEvent.objects.create(
|
||||
type="new_revision",
|
||||
doc=review,
|
||||
by=request.user.person,
|
||||
rev=review.rev,
|
||||
desc='New revision available',
|
||||
time=review.time,
|
||||
)
|
||||
|
||||
review.type = DocTypeName.objects.get(slug="review")
|
||||
review.rev = "00"
|
||||
review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, form.cleaned_data["reviewed_rev"])
|
||||
review.group = review_req.team
|
||||
if review_submission == "link":
|
||||
review.external_url = form.cleaned_data['review_url']
|
||||
review.save_with_history([e])
|
||||
review.set_state(State.objects.get(type="review", slug="active"))
|
||||
DocAlias.objects.create(document=review, name=review.name)
|
||||
|
||||
# save file on disk
|
||||
if review_submission == "upload":
|
||||
encoded_content = form.cleaned_data['review_file']
|
||||
else:
|
||||
encoded_content = form.cleaned_data['review_content'].encode("utf-8")
|
||||
|
||||
filename = os.path.join(review.get_file_path(), '{}.txt'.format(review.name, review.rev))
|
||||
with open(filename, 'wb') as destination:
|
||||
destination.write(encoded_content)
|
||||
|
||||
# close review request
|
||||
review_req.state = form.cleaned_data["state"]
|
||||
review_req.reviewed_rev = form.cleaned_data["reviewed_rev"]
|
||||
review_req.result = form.cleaned_data["result"]
|
||||
review_req.review = review
|
||||
review_req.save()
|
||||
|
||||
DocEvent.objects.create(
|
||||
type="changed_review_request",
|
||||
doc=review_req.doc,
|
||||
by=request.user.person,
|
||||
desc="Request for {} review by {} {}: {}. Reviewer: {}".format(
|
||||
review_req.type.name,
|
||||
review_req.team.acronym.upper(),
|
||||
review_req.state.name,
|
||||
review_req.result.name,
|
||||
review_req.reviewer,
|
||||
),
|
||||
)
|
||||
|
||||
if review_req.state_id == "part-completed":
|
||||
new_review_req = make_new_review_request_from_existing(review_req)
|
||||
new_review_req.save()
|
||||
|
||||
subject = "Review of {}-{} completed partially".format(review_req.doc.name, review_req.reviewed_rev)
|
||||
|
||||
url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": new_review_req.doc.name, "request_id": new_review_req.pk })
|
||||
url = request.build_absolute_uri(url)
|
||||
|
||||
msg = render_to_string("doc/mail/partially_completed_review.txt", {
|
||||
'new_review_req_url': url,
|
||||
"by": request.user.person,
|
||||
"new_review_req": new_review_req,
|
||||
})
|
||||
|
||||
email_review_request_change(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=False)
|
||||
|
||||
if review_submission != "link" and review_req.team.list_email:
|
||||
# email the review
|
||||
subject = "{} of {}-{}".format("Partial review" if review_req.state_id == "part-completed" else "Review", review_req.doc.name, review_req.reviewed_rev)
|
||||
msg = send_mail(request, [(review_req.team.name, review_req.team.list_email)], None,
|
||||
subject,
|
||||
"doc/mail/completed_review.txt", {
|
||||
"review_req": review_req,
|
||||
"content": encoded_content.decode("utf-8"),
|
||||
},
|
||||
cc=form.cleaned_data["cc"])
|
||||
|
||||
e = DocEvent.objects.create(
|
||||
type="changed_review_request",
|
||||
doc=review_req.doc,
|
||||
by=request.user.person,
|
||||
desc="Sent review to list.",
|
||||
)
|
||||
|
||||
list_name = mailarch.list_name_from_email(review_req.team.list_email)
|
||||
if list_name:
|
||||
review.external_url = mailarch.construct_message_url(list_name, email.utils.unquote(msg["Message-ID"]))
|
||||
review.save_with_history([e])
|
||||
|
||||
return redirect("doc_view", name=review_req.review.name)
|
||||
else:
|
||||
form = CompleteReviewForm(review_req)
|
||||
|
||||
mail_archive_query_urls = mailarch.construct_query_urls(review_req)
|
||||
|
||||
return render(request, 'doc/review/complete_review.html', {
|
||||
'doc': doc,
|
||||
'review_req': review_req,
|
||||
'form': form,
|
||||
'mail_archive_query_urls': mail_archive_query_urls,
|
||||
})
|
||||
|
||||
def search_mail_archive(request, name, request_id):
|
||||
#doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
|
||||
|
||||
is_reviewer = user_is_person(request.user, review_req.reviewer.person)
|
||||
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
|
||||
|
||||
if not (is_reviewer or can_manage_request):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
res = mailarch.construct_query_urls(review_req, query=request.GET.get("query"))
|
||||
if not res:
|
||||
return JsonResponse({ "error": "Couldn't do lookup in mail archive - don't know where to look"})
|
||||
|
||||
MAX_RESULTS = 30
|
||||
|
||||
try:
|
||||
res["messages"] = mailarch.retrieve_messages(res["query_data_url"])[:MAX_RESULTS]
|
||||
except Exception as e:
|
||||
res["error"] = "Retrieval from mail archive failed: {}".format(unicode(e))
|
||||
# raise # useful when debugging
|
||||
|
||||
return JsonResponse(res)
|
||||
|
|
@ -6,10 +6,12 @@ class GroupFeatures(object):
|
|||
has_chartering_process = False
|
||||
has_documents = False # i.e. drafts/RFCs
|
||||
has_materials = False
|
||||
has_reviews = False
|
||||
customize_workflow = False
|
||||
about_page = "group_about"
|
||||
default_tab = about_page
|
||||
material_types = ["slides"]
|
||||
admin_roles = ["chair"]
|
||||
|
||||
def __init__(self, group):
|
||||
if group.type_id in ("wg", "rg"):
|
||||
|
@ -24,3 +26,12 @@ class GroupFeatures(object):
|
|||
|
||||
if self.has_chartering_process:
|
||||
self.about_page = "group_charter"
|
||||
|
||||
from ietf.review.utils import active_review_teams
|
||||
if group in active_review_teams():
|
||||
self.has_reviews = True
|
||||
import ietf.group.views
|
||||
self.default_tab = ietf.group.views.review_requests
|
||||
|
||||
if group.type_id == "dir":
|
||||
self.admin_roles = ["chair", "secr"]
|
||||
|
|
|
@ -94,7 +94,7 @@ def edit_milestones(request, acronym, group_type=None, milestone_set="current"):
|
|||
|
||||
needs_review = False
|
||||
if not can_manage_group(request.user, group):
|
||||
if group.has_role(request.user, "chair"):
|
||||
if group.has_role(request.user, group.features.admin_roles):
|
||||
if milestone_set == "current":
|
||||
needs_review = True
|
||||
else:
|
||||
|
|
|
@ -29,8 +29,9 @@ from ietf.meeting.factories import SessionFactory
|
|||
from ietf.name.models import DocTagName, GroupStateName, GroupTypeName
|
||||
from ietf.person.models import Person, Email
|
||||
from ietf.utils.mail import outbox, empty_outbox
|
||||
from ietf.utils.test_data import make_test_data, create_person
|
||||
from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent
|
||||
from ietf.utils.test_data import make_test_data, create_person, make_review_data
|
||||
from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects
|
||||
import ietf.group.views
|
||||
|
||||
def group_urlreverse_list(group, viewname):
|
||||
return [
|
||||
|
@ -335,6 +336,31 @@ class GroupPagesTests(TestCase):
|
|||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(doc.title not in unicontent(r))
|
||||
|
||||
def test_review_requests(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
|
||||
group = review_req.team
|
||||
|
||||
for url in [ urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym }),
|
||||
urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym , 'group_type': group.type_id}),
|
||||
]:
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req.doc.name in unicontent(r))
|
||||
self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r))
|
||||
|
||||
url = urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym })
|
||||
|
||||
# close request, listed under closed
|
||||
review_req.state_id = "completed"
|
||||
review_req.result_id = "ready"
|
||||
review_req.save()
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req.doc.name in unicontent(r))
|
||||
|
||||
def test_history(self):
|
||||
draft = make_test_data()
|
||||
group = draft.group
|
||||
|
@ -575,10 +601,10 @@ class GroupEditTests(TestCase):
|
|||
parent=area.pk,
|
||||
ad=ad.pk,
|
||||
state=state.pk,
|
||||
chairs="aread@ietf.org, ad1@ietf.org",
|
||||
secretaries="aread@ietf.org, ad1@ietf.org, ad2@ietf.org",
|
||||
techadv="aread@ietf.org",
|
||||
delegates="ad2@ietf.org",
|
||||
chair_roles="aread@ietf.org, ad1@ietf.org",
|
||||
secr_roles="aread@ietf.org, ad1@ietf.org, ad2@ietf.org",
|
||||
techadv_roles="aread@ietf.org",
|
||||
delegate_roles="ad2@ietf.org",
|
||||
list_email="mars@mail",
|
||||
list_subscribe="subscribe.mars",
|
||||
list_archive="archive.mars",
|
||||
|
@ -604,6 +630,40 @@ class GroupEditTests(TestCase):
|
|||
for prefix in ['ad1','ad2','aread','marschairman','marsdelegate']:
|
||||
self.assertTrue(prefix+'@' in outbox[0]['To'])
|
||||
|
||||
def test_edit_reviewers(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
group = review_req.team
|
||||
|
||||
url = urlreverse('group_edit', kwargs=dict(group_type=group.type_id, acronym=group.acronym))
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
# normal get
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
self.assertEqual(len(q('form input[name=reviewer_roles]')), 1)
|
||||
|
||||
# set reviewers
|
||||
empty_outbox()
|
||||
r = self.client.post(url,
|
||||
dict(name=group.name,
|
||||
acronym=group.acronym,
|
||||
parent=group.parent_id,
|
||||
ad=Person.objects.get(name="Areað Irector").pk,
|
||||
state=group.state_id,
|
||||
reviewer_roles="ad2@ietf.org",
|
||||
list_email=group.list_email,
|
||||
list_subscribe=group.list_subscribe,
|
||||
list_archive=group.list_archive,
|
||||
urls=""
|
||||
))
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
group = reload_db_objects(group)
|
||||
self.assertEqual(list(group.role_set.filter(name="reviewer").values_list("email", flat=True)), ["ad2@ietf.org"])
|
||||
self.assertTrue('Personnel change' in outbox[0]['Subject'])
|
||||
|
||||
def test_conclude(self):
|
||||
make_test_data()
|
||||
|
||||
|
|
192
ietf/group/tests_review.py
Normal file
192
ietf/group/tests_review.py
Normal file
|
@ -0,0 +1,192 @@
|
|||
import datetime
|
||||
|
||||
#from pyquery import PyQuery
|
||||
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
|
||||
from ietf.utils.test_data import make_test_data, make_review_data
|
||||
from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects
|
||||
from ietf.review.models import ReviewRequest, ReviewRequestStateName
|
||||
from ietf.doc.models import TelechatDocEvent
|
||||
from ietf.iesg.models import TelechatDate
|
||||
from ietf.person.models import Email, Person
|
||||
from ietf.review.utils import suggested_review_requests_for_team
|
||||
import ietf.group.views_review
|
||||
from ietf.utils.mail import outbox, empty_outbox
|
||||
|
||||
class ReviewTests(TestCase):
|
||||
def test_suggested_review_requests(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
team = review_req.team
|
||||
|
||||
# put on telechat
|
||||
e = TelechatDocEvent.objects.create(
|
||||
type="scheduled_for_telechat",
|
||||
by=Person.objects.get(name="(System)"),
|
||||
doc=doc,
|
||||
telechat_date=TelechatDate.objects.all().first().date,
|
||||
)
|
||||
doc.rev = "10"
|
||||
doc.save_with_history([e])
|
||||
|
||||
prev_rev = "{:02}".format(int(doc.rev) - 1)
|
||||
|
||||
# blocked by existing request
|
||||
review_req.requested_rev = ""
|
||||
review_req.save()
|
||||
|
||||
self.assertEqual(len(suggested_review_requests_for_team(team)), 0)
|
||||
|
||||
# ... but not to previous version
|
||||
review_req.requested_rev = prev_rev
|
||||
review_req.save()
|
||||
suggestions = suggested_review_requests_for_team(team)
|
||||
self.assertEqual(len(suggestions), 1)
|
||||
self.assertEqual(suggestions[0].doc, doc)
|
||||
self.assertEqual(suggestions[0].team, team)
|
||||
|
||||
# blocked by non-versioned refusal
|
||||
review_req.requested_rev = ""
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="no-review-document")
|
||||
review_req.save()
|
||||
|
||||
self.assertEqual(list(suggested_review_requests_for_team(team)), [])
|
||||
|
||||
# blocked by versioned refusal
|
||||
review_req.reviewed_rev = doc.rev
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="no-review-document")
|
||||
review_req.save()
|
||||
|
||||
self.assertEqual(list(suggested_review_requests_for_team(team)), [])
|
||||
|
||||
# blocked by completion
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="completed")
|
||||
review_req.save()
|
||||
|
||||
self.assertEqual(list(suggested_review_requests_for_team(team)), [])
|
||||
|
||||
# ... but not to previous version
|
||||
review_req.reviewed_rev = prev_rev
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="completed")
|
||||
review_req.save()
|
||||
|
||||
self.assertEqual(len(suggested_review_requests_for_team(team)), 1)
|
||||
|
||||
|
||||
def test_manage_review_requests(self):
|
||||
doc = make_test_data()
|
||||
review_req1 = make_review_data(doc)
|
||||
|
||||
group = review_req1.team
|
||||
|
||||
url = urlreverse(ietf.group.views_review.manage_review_requests, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id })
|
||||
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
review_req2 = ReviewRequest.objects.create(
|
||||
doc=review_req1.doc,
|
||||
team=review_req1.team,
|
||||
type_id="early",
|
||||
deadline=datetime.date.today() + datetime.timedelta(days=30),
|
||||
state_id="accepted",
|
||||
reviewer=review_req1.reviewer,
|
||||
requested_by=Person.objects.get(user__username="plain"),
|
||||
)
|
||||
|
||||
review_req3 = ReviewRequest.objects.create(
|
||||
doc=review_req1.doc,
|
||||
team=review_req1.team,
|
||||
type_id="early",
|
||||
deadline=datetime.date.today() + datetime.timedelta(days=30),
|
||||
state_id="requested",
|
||||
requested_by=Person.objects.get(user__username="plain"),
|
||||
)
|
||||
|
||||
# get
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req1.doc.name in unicontent(r))
|
||||
|
||||
# can't save: conflict
|
||||
new_reviewer = Email.objects.get(role__name="reviewer", role__group=group, person__user__username="marschairman")
|
||||
# provoke conflict by posting bogus data
|
||||
r = self.client.post(url, {
|
||||
"reviewrequest": [str(review_req1.pk), str(review_req2.pk), str(123456)],
|
||||
|
||||
# close
|
||||
"r{}-existing_reviewer".format(review_req1.pk): "123456",
|
||||
"r{}-action".format(review_req1.pk): "close",
|
||||
"r{}-close".format(review_req1.pk): "no-response",
|
||||
|
||||
# assign
|
||||
"r{}-existing_reviewer".format(review_req2.pk): "123456",
|
||||
"r{}-action".format(review_req2.pk): "assign",
|
||||
"r{}-reviewer".format(review_req2.pk): new_reviewer.pk,
|
||||
|
||||
"action": "save-continue",
|
||||
})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
content = unicontent(r).lower()
|
||||
self.assertTrue("1 request closed" in content)
|
||||
self.assertTrue("1 request opened" in content)
|
||||
self.assertTrue("2 requests changed assignment" in content)
|
||||
|
||||
# close and assign
|
||||
new_reviewer = Email.objects.get(role__name="reviewer", role__group=group, person__user__username="marschairman")
|
||||
r = self.client.post(url, {
|
||||
"reviewrequest": [str(review_req1.pk), str(review_req2.pk), str(review_req3.pk)],
|
||||
|
||||
# close
|
||||
"r{}-existing_reviewer".format(review_req1.pk): review_req1.reviewer_id or "",
|
||||
"r{}-action".format(review_req1.pk): "close",
|
||||
"r{}-close".format(review_req1.pk): "no-response",
|
||||
|
||||
# assign
|
||||
"r{}-existing_reviewer".format(review_req2.pk): review_req2.reviewer_id or "",
|
||||
"r{}-action".format(review_req2.pk): "assign",
|
||||
"r{}-reviewer".format(review_req2.pk): new_reviewer.pk,
|
||||
|
||||
# no change
|
||||
"r{}-existing_reviewer".format(review_req3.pk): review_req3.reviewer_id or "",
|
||||
"r{}-action".format(review_req3.pk): "",
|
||||
"r{}-close".format(review_req3.pk): "no-response",
|
||||
"r{}-reviewer".format(review_req3.pk): "",
|
||||
|
||||
"action": "save",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req1, review_req2, review_req3 = reload_db_objects(review_req1, review_req2, review_req3)
|
||||
self.assertEqual(review_req1.state_id, "no-response")
|
||||
self.assertEqual(review_req2.state_id, "requested")
|
||||
self.assertEqual(review_req2.reviewer, new_reviewer)
|
||||
self.assertEqual(review_req3.state_id, "requested")
|
||||
|
||||
def test_email_open_review_assignments(self):
|
||||
doc = make_test_data()
|
||||
review_req1 = make_review_data(doc)
|
||||
|
||||
group = review_req1.team
|
||||
|
||||
url = urlreverse(ietf.group.views_review.email_open_review_assignments, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id })
|
||||
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req1.doc.name in unicontent(r))
|
||||
|
||||
empty_outbox()
|
||||
r = self.client.post(url, {
|
||||
"to": group.list_email,
|
||||
"subject": "Test subject",
|
||||
"body": "Test body",
|
||||
"action": "email",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(group.list_email in outbox[0]["To"])
|
||||
self.assertEqual(outbox[0]["subject"], "Test subject")
|
||||
self.assertTrue("Test body" in unicode(outbox[0]))
|
||||
|
|
@ -4,7 +4,7 @@ from django.conf.urls import patterns, include
|
|||
from django.views.generic import RedirectView
|
||||
from django.conf import settings
|
||||
|
||||
from ietf.group import views, views_edit
|
||||
from ietf.group import views, views_edit, views_review
|
||||
|
||||
urlpatterns = patterns('',
|
||||
(r'^$', views.active_groups),
|
||||
|
@ -21,5 +21,7 @@ urlpatterns = patterns('',
|
|||
(r'^email-aliases/$', 'ietf.group.views.email_aliases'),
|
||||
(r'^bofs/create/$', views_edit.edit, {'action': "create", }, "bof_create"),
|
||||
(r'^photos/$', views.chair_photos),
|
||||
(r'^reviews/$', views.review_requests),
|
||||
(r'^reviews/manage/$', views_review.manage_review_requests),
|
||||
(r'^%(acronym)s/' % settings.URL_REGEXPS, include('ietf.group.urls_info_details')),
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.conf.urls import patterns, url
|
||||
from django.views.generic import RedirectView
|
||||
import views
|
||||
from ietf.group import views, views_review
|
||||
|
||||
urlpatterns = patterns('',
|
||||
(r'^$', 'ietf.group.views.group_home', None, "group_home"),
|
||||
|
@ -30,5 +30,8 @@ urlpatterns = patterns('',
|
|||
(r'^materials/new/(?P<doc_type>[\w-]+)/$', 'ietf.doc.views_material.edit_material', { 'action': "new" }, "group_new_material"),
|
||||
(r'^archives/$', 'ietf.group.views.derived_archives'),
|
||||
(r'^photos/$', views.group_photos),
|
||||
(r'^reviews/$', views.review_requests),
|
||||
(r'^reviews/manage/$', views_review.manage_review_requests),
|
||||
(r'^reviews/email-assignments/$', views_review.email_open_review_assignments),
|
||||
url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'),
|
||||
)
|
||||
|
|
|
@ -69,6 +69,8 @@ from ietf.mailtrigger.utils import gather_relevant_expansions
|
|||
from ietf.ietfauth.utils import has_role
|
||||
from ietf.meeting.utils import group_sessions
|
||||
from ietf.meeting.helpers import get_meeting
|
||||
from ietf.review.models import ReviewRequest
|
||||
from ietf.review.utils import can_manage_review_requests_for_team, suggested_review_requests_for_team
|
||||
|
||||
def roles(group, role_name):
|
||||
return Role.objects.filter(group=group, name=role_name).select_related("email", "person")
|
||||
|
@ -347,6 +349,8 @@ def construct_group_menu_context(request, group, selected, group_type, others):
|
|||
entries.append(("About", urlreverse("group_about", kwargs=kwargs)))
|
||||
if group.features.has_materials and get_group_materials(group).exists():
|
||||
entries.append(("Materials", urlreverse("ietf.group.views.materials", kwargs=kwargs)))
|
||||
if group.features.has_reviews:
|
||||
entries.append(("Review requests", urlreverse(review_requests, kwargs=kwargs)))
|
||||
if group.type_id in ('rg','wg','team'):
|
||||
entries.append(("Meetings", urlreverse("ietf.group.views.meetings", kwargs=kwargs)))
|
||||
entries.append(("History", urlreverse("ietf.group.views.history", kwargs=kwargs)))
|
||||
|
@ -363,11 +367,11 @@ def construct_group_menu_context(request, group, selected, group_type, others):
|
|||
# actions
|
||||
actions = []
|
||||
|
||||
is_chair = group.has_role(request.user, "chair")
|
||||
is_admin = group.has_role(request.user, group.features.admin_roles)
|
||||
can_manage = can_manage_group(request.user, group)
|
||||
|
||||
if group.features.has_milestones:
|
||||
if group.state_id != "proposed" and (is_chair or can_manage):
|
||||
if group.state_id != "proposed" and (is_admin or can_manage):
|
||||
actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs)))
|
||||
|
||||
if group.features.has_documents:
|
||||
|
@ -379,10 +383,14 @@ def construct_group_menu_context(request, group, selected, group_type, others):
|
|||
if group.features.has_materials and can_manage_materials(request.user, group):
|
||||
actions.append((u"Upload material", urlreverse("ietf.doc.views_material.choose_material_type", kwargs=kwargs)))
|
||||
|
||||
if group.state_id != "conclude" and (is_chair or can_manage):
|
||||
if group.features.has_reviews:
|
||||
import ietf.group.views_review
|
||||
actions.append((u"Manage review requests", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=kwargs)))
|
||||
|
||||
if group.state_id != "conclude" and (is_admin or can_manage):
|
||||
actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs)))
|
||||
|
||||
if group.features.customize_workflow and (is_chair or can_manage):
|
||||
if group.features.customize_workflow and (is_admin or can_manage):
|
||||
actions.append((u"Customize workflow", urlreverse("ietf.group.views_edit.customize_workflow", kwargs=kwargs)))
|
||||
|
||||
if group.state_id in ("active", "dormant") and not group.type_id in ["sdo", "rfcedtyp", "isoc", ] and can_manage:
|
||||
|
@ -652,6 +660,60 @@ def history(request, acronym, group_type=None):
|
|||
"events": events,
|
||||
}))
|
||||
|
||||
def review_requests(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_reviews:
|
||||
raise Http404
|
||||
|
||||
open_review_requests = list(ReviewRequest.objects.filter(
|
||||
team=group, state__in=("requested", "accepted")
|
||||
).prefetch_related("reviewer", "type", "state").order_by("-time", "-id"))
|
||||
|
||||
open_review_requests += suggested_review_requests_for_team(group)
|
||||
|
||||
today = datetime.date.today()
|
||||
for r in open_review_requests:
|
||||
delta = today - r.deadline
|
||||
r.due = max(0, delta.days)
|
||||
|
||||
closed_review_requests = ReviewRequest.objects.filter(
|
||||
team=group,
|
||||
).exclude(
|
||||
state__in=("requested", "accepted")
|
||||
).prefetch_related("reviewer", "type", "state", "doc").order_by("-time", "-id")
|
||||
|
||||
since_choices = [
|
||||
(None, "1 month"),
|
||||
("3m", "3 months"),
|
||||
("6m", "6 months"),
|
||||
("1y", "1 year"),
|
||||
("2y", "2 years"),
|
||||
("all", "All"),
|
||||
]
|
||||
since = request.GET.get("since", None)
|
||||
if since not in [key for key, label in since_choices]:
|
||||
since = None
|
||||
|
||||
if since != "all":
|
||||
date_limit = {
|
||||
None: datetime.timedelta(days=31),
|
||||
"3m": datetime.timedelta(days=31 * 3),
|
||||
"6m": datetime.timedelta(days=180),
|
||||
"1y": datetime.timedelta(days=365),
|
||||
"2y": datetime.timedelta(days=2 * 365),
|
||||
}[since]
|
||||
|
||||
closed_review_requests = closed_review_requests.filter(time__gte=datetime.date.today() - date_limit)
|
||||
|
||||
return render(request, 'group/review_requests.html',
|
||||
construct_group_menu_context(request, group, "review requests", group_type, {
|
||||
"open_review_requests": open_review_requests,
|
||||
"closed_review_requests": closed_review_requests,
|
||||
"since_choices": since_choices,
|
||||
"since": since,
|
||||
"can_manage_review_requests": can_manage_review_requests_for_team(request.user, group)
|
||||
}))
|
||||
|
||||
def materials(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_materials:
|
||||
|
|
|
@ -23,19 +23,31 @@ from ietf.person.fields import SearchableEmailsField
|
|||
from ietf.person.models import Person, Email
|
||||
from ietf.group.mails import ( email_admin_re_charter, email_personnel_change)
|
||||
from ietf.utils.ordereddict import insert_after_in_ordered_dict
|
||||
from ietf.utils.text import strip_suffix
|
||||
|
||||
|
||||
MAX_GROUP_DELEGATES = 3
|
||||
|
||||
def roles_for_group_type(group_type):
|
||||
roles = ["chair", "secr", "techadv", "delegate"]
|
||||
if group_type == "dir":
|
||||
roles.append("reviewer")
|
||||
return roles
|
||||
|
||||
class GroupForm(forms.Form):
|
||||
name = forms.CharField(max_length=255, label="Name", required=True)
|
||||
acronym = forms.CharField(max_length=10, label="Acronym", required=True)
|
||||
state = forms.ModelChoiceField(GroupStateName.objects.all(), label="State", required=True)
|
||||
chairs = SearchableEmailsField(label="Chairs", required=False, only_users=True)
|
||||
secretaries = SearchableEmailsField(label="Secretaries", required=False, only_users=True)
|
||||
techadv = SearchableEmailsField(label="Technical Advisors", required=False, only_users=True)
|
||||
delegates = SearchableEmailsField(label="Delegates", required=False, only_users=True, max_entries=MAX_GROUP_DELEGATES,
|
||||
|
||||
# roles
|
||||
chair_roles = SearchableEmailsField(label="Chairs", required=False, only_users=True)
|
||||
secr_roles = SearchableEmailsField(label="Secretaries", required=False, only_users=True)
|
||||
techadv_roles = SearchableEmailsField(label="Technical Advisors", required=False, only_users=True)
|
||||
delegate_roles = SearchableEmailsField(label="Delegates", required=False, only_users=True, max_entries=MAX_GROUP_DELEGATES,
|
||||
help_text=mark_safe("Chairs can delegate the authority to update the state of group documents - at most %s persons at a given time." % MAX_GROUP_DELEGATES))
|
||||
reviewer_roles = SearchableEmailsField(label="Reviewers", required=False, only_users=True)
|
||||
ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active", role__group__type='area').order_by('name'), label="Shepherding AD", empty_label="(None)", required=False)
|
||||
|
||||
parent = forms.ModelChoiceField(Group.objects.filter(state="active").order_by('name'), empty_label="(None)", required=False)
|
||||
list_email = forms.CharField(max_length=64, required=False)
|
||||
list_subscribe = forms.CharField(max_length=255, required=False)
|
||||
|
@ -69,6 +81,11 @@ class GroupForm(forms.Form):
|
|||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(type="area")
|
||||
self.fields['parent'].label = "IETF Area"
|
||||
|
||||
role_fields_to_remove = (set(roles_for_group_type(self.group_type))
|
||||
- set(strip_suffix(attr, "_roles") for attr in self.fields if attr.endswith("_roles")))
|
||||
for r in role_fields_to_remove:
|
||||
del self.fields[r + "_roles"]
|
||||
|
||||
def clean_acronym(self):
|
||||
# Changing the acronym of an already existing group will cause 404s all
|
||||
# over the place, loose history, and generally muck up a lot of
|
||||
|
@ -219,7 +236,8 @@ def edit(request, group_type=None, acronym=None, action="edit"):
|
|||
group = get_group_or_404(acronym, group_type)
|
||||
if not group_type and group:
|
||||
group_type = group.type_id
|
||||
if not (can_manage_group(request.user, group) or group.has_role(request.user, "chair")):
|
||||
if not (can_manage_group(request.user, group)
|
||||
or group.has_role(request.user, group.features.admin_roles)):
|
||||
return HttpResponseForbidden("You don't have permission to access this view")
|
||||
|
||||
if request.method == 'POST':
|
||||
|
@ -283,10 +301,18 @@ def edit(request, group_type=None, acronym=None, action="edit"):
|
|||
personnel_change_text=""
|
||||
changed_personnel = set()
|
||||
# update roles
|
||||
for attr, slug, title in [('ad','ad','Shepherding AD'), ('chairs', 'chair', "Chairs"), ('secretaries', 'secr', "Secretaries"), ('techadv', 'techadv', "Tech Advisors"), ('delegates', 'delegate', "Delegates")]:
|
||||
for attr, f in form.fields.iteritems():
|
||||
if not (attr.endswith("_roles") or attr == "ad"):
|
||||
continue
|
||||
|
||||
slug = attr
|
||||
slug = strip_suffix(slug, "_roles")
|
||||
|
||||
title = f.label
|
||||
|
||||
new = clean[attr]
|
||||
if attr == 'ad':
|
||||
new = [ new.role_email('ad'),] if new else []
|
||||
new = [ new.role_email('ad') ] if new else []
|
||||
old = Email.objects.filter(role__group=group, role__name=slug).select_related("person")
|
||||
if set(new) != set(old):
|
||||
changes.append((attr, new, desc(title,
|
||||
|
@ -345,10 +371,6 @@ def edit(request, group_type=None, acronym=None, action="edit"):
|
|||
init = dict(name=group.name,
|
||||
acronym=group.acronym,
|
||||
state=group.state,
|
||||
chairs=Email.objects.filter(role__group=group, role__name="chair"),
|
||||
secretaries=Email.objects.filter(role__group=group, role__name="secr"),
|
||||
techadv=Email.objects.filter(role__group=group, role__name="techadv"),
|
||||
delegates=Email.objects.filter(role__group=group, role__name="delegate"),
|
||||
ad=ad_role and ad_role.person and ad_role.person.id,
|
||||
parent=group.parent.id if group.parent else None,
|
||||
list_email=group.list_email if group.list_email else None,
|
||||
|
@ -356,6 +378,9 @@ def edit(request, group_type=None, acronym=None, action="edit"):
|
|||
list_archive=group.list_archive if group.list_archive else None,
|
||||
urls=format_urls(group.groupurl_set.all()),
|
||||
)
|
||||
|
||||
for slug in roles_for_group_type(group_type):
|
||||
init[slug + "_roles"] = Email.objects.filter(role__group=group, role__name=slug)
|
||||
else:
|
||||
init = dict(ad=request.user.person.id if group_type == "wg" and has_role(request.user, "Area Director") else None,
|
||||
)
|
||||
|
@ -409,8 +434,8 @@ def customize_workflow(request, group_type=None, acronym=None):
|
|||
if not group.features.customize_workflow:
|
||||
raise Http404
|
||||
|
||||
if (not has_role(request.user, "Secretariat") and
|
||||
not group.role_set.filter(name="chair", person__user=request.user)):
|
||||
if not (can_manage_group(request.user, group)
|
||||
or group.has_role(request.user, group.features.admin_roles)):
|
||||
return HttpResponseForbidden("You don't have permission to access this view")
|
||||
|
||||
if group_type == "rg":
|
||||
|
|
222
ietf/group/views_review.py
Normal file
222
ietf/group/views_review.py
Normal file
|
@ -0,0 +1,222 @@
|
|||
from django.shortcuts import render, redirect
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django import forms
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from ietf.review.models import ReviewRequest
|
||||
from ietf.review.utils import (can_manage_review_requests_for_team, close_review_request_states,
|
||||
extract_revision_ordered_review_requests_for_documents,
|
||||
assign_review_request_to_reviewer,
|
||||
close_review_request,
|
||||
setup_reviewer_field,
|
||||
suggested_review_requests_for_team)
|
||||
from ietf.group.utils import get_group_or_404
|
||||
from ietf.person.fields import PersonEmailChoiceField
|
||||
from ietf.utils.mail import send_mail_text
|
||||
|
||||
|
||||
class ManageReviewRequestForm(forms.Form):
|
||||
ACTIONS = [
|
||||
("assign", "Assign"),
|
||||
("close", "Close"),
|
||||
]
|
||||
|
||||
action = forms.ChoiceField(choices=ACTIONS, widget=forms.HiddenInput, required=False)
|
||||
close = forms.ModelChoiceField(queryset=close_review_request_states(), required=False)
|
||||
reviewer = PersonEmailChoiceField(empty_label="(None)", required=False, label_with="person")
|
||||
|
||||
def __init__(self, review_req, *args, **kwargs):
|
||||
if not "prefix" in kwargs:
|
||||
if review_req.pk is None:
|
||||
kwargs["prefix"] = "r{}-{}".format(review_req.type_id, review_req.doc_id)
|
||||
else:
|
||||
kwargs["prefix"] = "r{}".format(review_req.pk)
|
||||
|
||||
super(ManageReviewRequestForm, self).__init__(*args, **kwargs)
|
||||
|
||||
close_initial = None
|
||||
if review_req.pk is None:
|
||||
if review_req.latest_reqs:
|
||||
close_initial = "no-review-version"
|
||||
else:
|
||||
close_initial = "no-review-document"
|
||||
elif review_req.reviewer:
|
||||
close_initial = "no-response"
|
||||
else:
|
||||
close_initial = "overtaken"
|
||||
|
||||
if close_initial:
|
||||
self.fields["close"].initial = close_initial
|
||||
|
||||
if review_req.pk is None:
|
||||
self.fields["close"].queryset = self.fields["close"].queryset.filter(slug__in=["noreviewversion", "noreviewdocument"])
|
||||
|
||||
self.fields["close"].widget.attrs["class"] = "form-control input-sm"
|
||||
|
||||
setup_reviewer_field(self.fields["reviewer"], review_req)
|
||||
self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm"
|
||||
|
||||
if self.is_bound:
|
||||
if self.data.get("action") == "close":
|
||||
self.fields["close"].required = True
|
||||
|
||||
|
||||
@login_required
|
||||
def manage_review_requests(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_reviews:
|
||||
raise Http404
|
||||
|
||||
if not can_manage_review_requests_for_team(request.user, group):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
review_requests = list(ReviewRequest.objects.filter(
|
||||
team=group, state__in=("requested", "accepted")
|
||||
).prefetch_related("reviewer", "type", "state").order_by("-time", "-id"))
|
||||
|
||||
review_requests += suggested_review_requests_for_team(group)
|
||||
|
||||
document_requests = extract_revision_ordered_review_requests_for_documents(
|
||||
ReviewRequest.objects.filter(state__in=("part-completed", "completed"), team=group).prefetch_related("result"),
|
||||
set(r.doc_id for r in review_requests),
|
||||
)
|
||||
|
||||
# we need a mutable query dict for resetting upon saving with
|
||||
# conflicts
|
||||
query_dict = request.POST.copy() if request.method == "POST" else None
|
||||
for req in review_requests:
|
||||
l = []
|
||||
# take all on the latest reviewed rev
|
||||
for r in document_requests[req.doc_id]:
|
||||
if l and l[0].reviewed_rev:
|
||||
if r.doc_id == l[0].doc_id and r.reviewed_rev:
|
||||
if int(r.reviewed_rev) > int(l[0].reviewed_rev):
|
||||
l = [r]
|
||||
elif int(r.reviewed_rev) == int(l[0].reviewed_rev):
|
||||
l.append(r)
|
||||
else:
|
||||
l = [r]
|
||||
|
||||
req.latest_reqs = l
|
||||
|
||||
req.form = ManageReviewRequestForm(req, query_dict)
|
||||
|
||||
saving = False
|
||||
newly_closed = newly_opened = newly_assigned = 0
|
||||
|
||||
if request.method == "POST":
|
||||
form_action = request.POST.get("action", "")
|
||||
saving = form_action.startswith("save")
|
||||
|
||||
# check for conflicts
|
||||
review_requests_dict = { unicode(r.pk): r for r in review_requests }
|
||||
posted_reqs = set(request.POST.getlist("reviewrequest", []))
|
||||
current_reqs = set(review_requests_dict.iterkeys())
|
||||
|
||||
closed_reqs = posted_reqs - current_reqs
|
||||
newly_closed += len(closed_reqs)
|
||||
|
||||
opened_reqs = current_reqs - posted_reqs
|
||||
newly_opened += len(opened_reqs)
|
||||
for r in opened_reqs:
|
||||
review_requests_dict[r].form.add_error(None, "New request.")
|
||||
|
||||
for req in review_requests:
|
||||
existing_reviewer = request.POST.get(req.form.prefix + "-existing_reviewer")
|
||||
if existing_reviewer is None:
|
||||
continue
|
||||
|
||||
if existing_reviewer != unicode(req.reviewer_id or ""):
|
||||
msg = "Assignment was changed."
|
||||
a = req.form["action"].value()
|
||||
if a == "assign":
|
||||
msg += " Didn't assign reviewer."
|
||||
elif a == "close":
|
||||
msg += " Didn't close request."
|
||||
req.form.add_error(None, msg)
|
||||
req.form.data[req.form.prefix + "-action"] = "" # cancel the action
|
||||
|
||||
newly_assigned += 1
|
||||
|
||||
form_results = []
|
||||
for req in review_requests:
|
||||
form_results.append(req.form.is_valid())
|
||||
|
||||
if saving and all(form_results) and not (newly_closed > 0 or newly_opened > 0 or newly_assigned > 0):
|
||||
for review_req in review_requests:
|
||||
action = review_req.form.cleaned_data.get("action")
|
||||
if action == "assign":
|
||||
assign_review_request_to_reviewer(request, review_req, review_req.form.cleaned_data["reviewer"])
|
||||
elif action == "close":
|
||||
close_review_request(request, review_req, review_req.form.cleaned_data["close"])
|
||||
|
||||
kwargs = { "acronym": group.acronym }
|
||||
if group_type:
|
||||
kwargs["group_type"] = group_type
|
||||
|
||||
if form_action == "save-continue":
|
||||
return redirect(manage_review_requests, **kwargs)
|
||||
else:
|
||||
import ietf.group.views
|
||||
return redirect(ietf.group.views.review_requests, **kwargs)
|
||||
|
||||
return render(request, 'group/manage_review_requests.html', {
|
||||
'group': group,
|
||||
'review_requests': review_requests,
|
||||
'newly_closed': newly_closed,
|
||||
'newly_opened': newly_opened,
|
||||
'newly_assigned': newly_assigned,
|
||||
'saving': saving,
|
||||
})
|
||||
|
||||
class EmailOpenAssignmentsForm(forms.Form):
|
||||
to = forms.EmailField(widget=forms.EmailInput(attrs={ "readonly": True }))
|
||||
subject = forms.CharField()
|
||||
body = forms.CharField(widget=forms.Textarea)
|
||||
|
||||
@login_required
|
||||
def email_open_review_assignments(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_reviews:
|
||||
raise Http404
|
||||
|
||||
if not can_manage_review_requests_for_team(request.user, group):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
review_requests = list(ReviewRequest.objects.filter(
|
||||
team=group,
|
||||
state__in=("requested", "accepted"),
|
||||
).exclude(
|
||||
reviewer=None,
|
||||
).prefetch_related("reviewer", "type", "state", "doc").distinct().order_by("deadline", "reviewer"))
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "email":
|
||||
form = EmailOpenAssignmentsForm(request.POST)
|
||||
if form.is_valid():
|
||||
send_mail_text(request, form.cleaned_data["to"], None, form.cleaned_data["subject"], form.cleaned_data["body"])
|
||||
|
||||
kwargs = { "acronym": group.acronym }
|
||||
if group_type:
|
||||
kwargs["group_type"] = group_type
|
||||
|
||||
return redirect(manage_review_requests, **kwargs)
|
||||
else:
|
||||
to = group.list_email
|
||||
subject = "Open review assignments in {}".format(group.acronym)
|
||||
body = render_to_string("group/email_open_review_assignments.txt", {
|
||||
"review_requests": review_requests,
|
||||
})
|
||||
|
||||
form = EmailOpenAssignmentsForm(initial={
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
})
|
||||
|
||||
return render(request, 'group/email_open_review_assignments.html', {
|
||||
'group': group,
|
||||
'review_requests': review_requests,
|
||||
'form': form,
|
||||
})
|
||||
|
|
@ -9,6 +9,7 @@ import sys
|
|||
path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if not path in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
print "!jojiojoisdjf", path
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
|
||||
|
|
|
@ -3,7 +3,8 @@ from ietf.name.models import (GroupTypeName, GroupStateName, RoleName, StreamNam
|
|||
DocRelationshipName, DocTypeName, DocTagName, StdLevelName, IntendedStdLevelName,
|
||||
DocReminderTypeName, BallotPositionName, SessionStatusName, TimeSlotTypeName,
|
||||
ConstraintName, NomineePositionStateName, FeedbackTypeName, DBTemplateTypeName,
|
||||
DraftSubmissionStateName, RoomResourceName)
|
||||
DraftSubmissionStateName, RoomResourceName,
|
||||
ReviewRequestStateName, ReviewTypeName, ReviewResultName)
|
||||
|
||||
|
||||
class NameAdmin(admin.ModelAdmin):
|
||||
|
@ -39,3 +40,6 @@ admin.site.register(FeedbackTypeName, NameAdmin)
|
|||
admin.site.register(DBTemplateTypeName, NameAdmin)
|
||||
admin.site.register(DraftSubmissionStateName, NameAdmin)
|
||||
admin.site.register(RoomResourceName, NameAdmin)
|
||||
admin.site.register(ReviewRequestStateName, NameAdmin)
|
||||
admin.site.register(ReviewTypeName, NameAdmin)
|
||||
admin.site.register(ReviewResultName, NameAdmin)
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/python
|
||||
|
||||
# simple script for exporting name related base data for the tests
|
||||
|
||||
# boiler plate
|
||||
import os, sys
|
||||
import django
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('group', '0008_auto_20160505_0523'),
|
||||
('name', '0010_new_liaison_names'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReviewRequestStateName',
|
||||
fields=[
|
||||
('slug', models.CharField(max_length=32, serialize=False, primary_key=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('desc', models.TextField(blank=True)),
|
||||
('used', models.BooleanField(default=True)),
|
||||
('order', models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReviewResultName',
|
||||
fields=[
|
||||
('slug', models.CharField(max_length=32, serialize=False, primary_key=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('desc', models.TextField(blank=True)),
|
||||
('used', models.BooleanField(default=True)),
|
||||
('order', models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReviewTypeName',
|
||||
fields=[
|
||||
('slug', models.CharField(max_length=32, serialize=False, primary_key=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('desc', models.TextField(blank=True)),
|
||||
('used', models.BooleanField(default=True)),
|
||||
('order', models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
]
|
65
ietf/name/migrations/0015_insert_review_name_data.py
Normal file
65
ietf/name/migrations/0015_insert_review_name_data.py
Normal file
|
@ -0,0 +1,65 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def insert_initial_review_data(apps, schema_editor):
|
||||
ReviewRequestStateName = apps.get_model("name", "ReviewRequestStateName")
|
||||
ReviewRequestStateName.objects.get_or_create(slug="requested", name="Requested", order=1)
|
||||
ReviewRequestStateName.objects.get_or_create(slug="accepted", name="Accepted", order=2)
|
||||
ReviewRequestStateName.objects.get_or_create(slug="rejected", name="Rejected", order=3)
|
||||
ReviewRequestStateName.objects.get_or_create(slug="withdrawn", name="Withdrawn", order=4)
|
||||
ReviewRequestStateName.objects.get_or_create(slug="overtaken", name="Overtaken By Events", order=5)
|
||||
ReviewRequestStateName.objects.get_or_create(slug="no-response", name="No Response", order=6)
|
||||
ReviewRequestStateName.objects.get_or_create(slug="no-review-version", name="Team Will not Review Version", order=7)
|
||||
ReviewRequestStateName.objects.get_or_create(slug="no-review-document", name="Team Will not Review Document", order=8)
|
||||
ReviewRequestStateName.objects.get_or_create(slug="part-completed", name="Partially Completed", order=9)
|
||||
ReviewRequestStateName.objects.get_or_create(slug="completed", name="Completed", order=10)
|
||||
|
||||
ReviewTypeName = apps.get_model("name", "ReviewTypeName")
|
||||
ReviewTypeName.objects.get_or_create(slug="early", name="Early", order=1)
|
||||
ReviewTypeName.objects.get_or_create(slug="lc", name="Last Call", order=2)
|
||||
ReviewTypeName.objects.get_or_create(slug="telechat", name="Telechat", order=3)
|
||||
ReviewTypeName.objects.get_or_create(slug="unknown", name="Unknown", order=4, used=False)
|
||||
|
||||
ReviewResultName = apps.get_model("name", "ReviewResultName")
|
||||
ReviewResultName.objects.get_or_create(slug="serious-issues", name="Serious Issues", order=1)
|
||||
ReviewResultName.objects.get_or_create(slug="issues", name="Has Issues", order=2)
|
||||
ReviewResultName.objects.get_or_create(slug="nits", name="Has Nits", order=3)
|
||||
|
||||
ReviewResultName.objects.get_or_create(slug="not-ready", name="Not Ready", order=4)
|
||||
ReviewResultName.objects.get_or_create(slug="right-track", name="On the Right Track", order=5)
|
||||
ReviewResultName.objects.get_or_create(slug="almost-ready", name="Almost Ready", order=6)
|
||||
|
||||
ReviewResultName.objects.get_or_create(slug="ready-issues", name="Ready with Issues", order=7)
|
||||
ReviewResultName.objects.get_or_create(slug="ready-nits", name="Ready with Nits", order=8)
|
||||
ReviewResultName.objects.get_or_create(slug="ready", name="Ready", order=9)
|
||||
|
||||
RoleName = apps.get_model("name", "RoleName")
|
||||
RoleName.objects.get_or_create(slug="reviewer", name="Reviewer", order=max(r.order for r in RoleName.objects.exclude(slug="reviewer")) + 1)
|
||||
|
||||
DocTypeName = apps.get_model("name", "DocTypeName")
|
||||
DocTypeName.objects.get_or_create(slug="review", name="Review")
|
||||
|
||||
StateType = apps.get_model("doc", "StateType")
|
||||
review_state_type, _ = StateType.objects.get_or_create(slug="review", label="Review")
|
||||
|
||||
State = apps.get_model("doc", "State")
|
||||
State.objects.get_or_create(type=review_state_type, slug="active", name="Active", order=1)
|
||||
State.objects.get_or_create(type=review_state_type, slug="deleted", name="Deleted", order=2)
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
pass
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('name', '0011_reviewrequeststatename_reviewresultname_reviewtypename'),
|
||||
('group', '0001_initial'),
|
||||
('doc', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(insert_initial_review_data, noop),
|
||||
]
|
|
@ -14,7 +14,7 @@ class NameModel(models.Model):
|
|||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ['order']
|
||||
ordering = ['order', 'name']
|
||||
|
||||
class GroupStateName(NameModel):
|
||||
"""BOF, Proposed, Active, Dormant, Concluded, Abandoned"""
|
||||
|
@ -88,3 +88,13 @@ class LiaisonStatementEventTypeName(NameModel):
|
|||
"Submitted, Modified, Approved, Posted, Killed, Resurrected, MsgIn, MsgOut, Comment"
|
||||
class LiaisonStatementTagName(NameModel):
|
||||
"Action Required, Action Taken"
|
||||
class ReviewRequestStateName(NameModel):
|
||||
"""Requested, Accepted, Rejected, Withdrawn, Overtaken By Events,
|
||||
No Response, No Review of Version, No Review of Document, Partially Completed, Completed"""
|
||||
class ReviewTypeName(NameModel):
|
||||
"""Early Review, Last Call, Telechat"""
|
||||
class ReviewResultName(NameModel):
|
||||
"""Almost ready, Has issues, Has nits, Not Ready,
|
||||
On the right track, Ready, Ready with issues,
|
||||
Ready with nits, Serious Issues"""
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ from ietf.name.models import (TimeSlotTypeName, GroupStateName, DocTagName, Inte
|
|||
IprEventTypeName, GroupMilestoneStateName, SessionStatusName, DocReminderTypeName,
|
||||
ConstraintName, MeetingTypeName, DocRelationshipName, RoomResourceName, IprLicenseTypeName,
|
||||
LiaisonStatementTagName, FeedbackTypeName, LiaisonStatementState, StreamName,
|
||||
BallotPositionName, DBTemplateTypeName, NomineePositionStateName)
|
||||
BallotPositionName, DBTemplateTypeName, NomineePositionStateName,
|
||||
ReviewRequestStateName, ReviewTypeName, ReviewResultName)
|
||||
|
||||
|
||||
class TimeSlotTypeNameResource(ModelResource):
|
||||
|
@ -413,3 +414,45 @@ class NomineePositionStateNameResource(ModelResource):
|
|||
}
|
||||
api.name.register(NomineePositionStateNameResource())
|
||||
|
||||
class ReviewRequestStateNameResource(ModelResource):
|
||||
class Meta:
|
||||
cache = SimpleCache()
|
||||
queryset = ReviewRequestStateName.objects.all()
|
||||
#resource_name = 'reviewrequeststatename'
|
||||
filtering = {
|
||||
"slug": ALL,
|
||||
"name": ALL,
|
||||
"desc": ALL,
|
||||
"used": ALL,
|
||||
"order": ALL,
|
||||
}
|
||||
api.name.register(ReviewRequestStateNameResource())
|
||||
|
||||
class ReviewTypeNameResource(ModelResource):
|
||||
class Meta:
|
||||
cache = SimpleCache()
|
||||
queryset = ReviewTypeName.objects.all()
|
||||
#resource_name = 'reviewtypename'
|
||||
filtering = {
|
||||
"slug": ALL,
|
||||
"name": ALL,
|
||||
"desc": ALL,
|
||||
"used": ALL,
|
||||
"order": ALL,
|
||||
}
|
||||
api.name.register(ReviewTypeNameResource())
|
||||
|
||||
class ReviewResultNameResource(ModelResource):
|
||||
class Meta:
|
||||
cache = SimpleCache()
|
||||
queryset = ReviewResultName.objects.all()
|
||||
#resource_name = 'reviewresultname'
|
||||
filtering = {
|
||||
"slug": ALL,
|
||||
"name": ALL,
|
||||
"desc": ALL,
|
||||
"used": ALL,
|
||||
"order": ALL,
|
||||
}
|
||||
api.name.register(ReviewResultNameResource())
|
||||
|
||||
|
|
|
@ -139,3 +139,23 @@ class SearchableEmailField(SearchableEmailsField):
|
|||
return super(SearchableEmailField, self).clean(value).first()
|
||||
|
||||
|
||||
class PersonEmailChoiceField(forms.ModelChoiceField):
|
||||
"""ModelChoiceField targeting Email and displaying choices with the
|
||||
person name as well as the email address. Needs further
|
||||
restrictions, e.g. on role, to useful."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
if not "queryset" in kwargs:
|
||||
kwargs["queryset"] = Email.objects.select_related("person")
|
||||
|
||||
self.label_with = kwargs.pop("label_with", None)
|
||||
|
||||
super(PersonEmailChoiceField, self).__init__(*args, **kwargs)
|
||||
|
||||
def label_from_instance(self, email):
|
||||
if self.label_with == "person":
|
||||
return unicode(email.person)
|
||||
elif self.label_with == "email":
|
||||
return email.address
|
||||
else:
|
||||
return u"{} <{}>".format(email.person, email.address)
|
||||
|
||||
|
|
0
ietf/review/__init__.py
Normal file
0
ietf/review/__init__.py
Normal file
249
ietf/review/import_from_review_tool.py
Executable file
249
ietf/review/import_from_review_tool.py
Executable file
|
@ -0,0 +1,249 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import sys, os
|
||||
|
||||
# boilerplate
|
||||
basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
|
||||
sys.path = [ basedir ] + sys.path
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ietf.settings")
|
||||
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
|
||||
# script
|
||||
|
||||
import datetime
|
||||
from collections import namedtuple
|
||||
from django.db import connections
|
||||
from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewResultName
|
||||
from ietf.review.models import ReviewRequestStateName, ReviewTypeName, ReviewTeamResult
|
||||
from ietf.group.models import Group, Role, RoleName
|
||||
from ietf.person.models import Person, Email, Alias
|
||||
import argparse
|
||||
from unidecode import unidecode
|
||||
from collections import defaultdict
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("database", help="database must be included in settings")
|
||||
parser.add_argument("team", help="team acronym, must exist")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_con = connections[args.database]
|
||||
team = Group.objects.get(acronym=args.team)
|
||||
|
||||
def namedtuplefetchall(cursor):
|
||||
"Return all rows from a cursor as a namedtuple"
|
||||
desc = cursor.description
|
||||
nt_result = namedtuple('Result', [col[0] for col in desc])
|
||||
return (nt_result(*row) for row in cursor.fetchall())
|
||||
|
||||
def parse_timestamp(t):
|
||||
if not t:
|
||||
return None
|
||||
return datetime.datetime.fromtimestamp(t)
|
||||
|
||||
# personnel
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select distinct reviewer from reviews;")
|
||||
known_reviewers = { row[0] for row in c.fetchall() }
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select distinct who from doclog;")
|
||||
docloggers = { row[0] for row in c.fetchall() }
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select distinct login from members where permissions like '%secretary%';")
|
||||
secretaries = { row[0] for row in c.fetchall() }
|
||||
|
||||
autopolicy_days = {
|
||||
'weekly': 7,
|
||||
'biweekly': 14,
|
||||
'monthly': 30,
|
||||
'bimonthly': 61,
|
||||
'quarterly': 91,
|
||||
}
|
||||
|
||||
known_personnel = {}
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select * from members;")
|
||||
|
||||
needed_personnel = known_reviewers | docloggers | secretaries
|
||||
|
||||
for row in namedtuplefetchall(c):
|
||||
if row.login not in needed_personnel:
|
||||
continue
|
||||
|
||||
email = Email.objects.filter(address=row.email).select_related("person").first()
|
||||
if not email:
|
||||
person = Person.objects.filter(alias__name=row.name).first()
|
||||
if not person:
|
||||
person, created = Person.objects.get_or_create(name=row.name, ascii=unidecode(row.name))
|
||||
if created:
|
||||
print "created person", unicode(person).encode("utf-8")
|
||||
existing_aliases = set(Alias.objects.filter(person=person).values_list("name", flat=True))
|
||||
curr_names = set(x for x in [person.name, person.ascii, person.ascii_short, person.plain_name(), ] if x)
|
||||
new_aliases = curr_names - existing_aliases
|
||||
for name in new_aliases:
|
||||
Alias.objects.create(person=person, name=name)
|
||||
|
||||
email, created = Email.objects.get_or_create(address=row.email, person=person)
|
||||
if created:
|
||||
print "created email", email
|
||||
|
||||
known_personnel[row.login] = email
|
||||
|
||||
if "secretary" in row.permissions:
|
||||
role, created = Role.objects.get_or_create(name=RoleName.objects.get(slug="secr"), person=email.person, email=email, group=team)
|
||||
if created:
|
||||
print "created role", unicode(role).encode("utf-8")
|
||||
|
||||
if row.login in known_reviewers:
|
||||
if row.comment != "Inactive" and row.available != 2145916800: # corresponds to 2038-01-01
|
||||
role, created = Role.objects.get_or_create(name=RoleName.objects.get(slug="reviewer"), person=email.person, email=email, group=team)
|
||||
|
||||
if created:
|
||||
print "created role", unicode(role).encode("utf-8")
|
||||
|
||||
reviewer, created = ReviewerSettings.objects.get_or_create(
|
||||
team=team,
|
||||
person=email.person,
|
||||
)
|
||||
if reviewer:
|
||||
print "created reviewer", reviewer.pk, unicode(reviewer).encode("utf-8")
|
||||
|
||||
if autopolicy_days.get(row.autopolicy):
|
||||
reviewer.frequency = autopolicy_days.get(row.autopolicy)
|
||||
reviewer.unavailable_until = parse_timestamp(row.available)
|
||||
reviewer.filter_re = row.donotassign
|
||||
try:
|
||||
reviewer.skip_next = int(row.autopolicy)
|
||||
except ValueError:
|
||||
pass
|
||||
reviewer.save()
|
||||
|
||||
# review requests
|
||||
|
||||
# check that we got the needed names
|
||||
results = { n.name.lower(): n for n in ReviewResultName.objects.all() }
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select distinct summary from reviews;")
|
||||
summaries = [r[0].lower() for r in c.fetchall() if r[0]]
|
||||
missing_result_names = set(summaries) - set(results.keys())
|
||||
assert not missing_result_names, "missing result names: {} {}".format(missing_result_names, results.keys())
|
||||
|
||||
for s in summaries:
|
||||
ReviewTeamResult.objects.get_or_create(team=team, result=results[s])
|
||||
|
||||
states = { n.slug: n for n in ReviewRequestStateName.objects.all() }
|
||||
# map some names
|
||||
states["assigned"] = states["requested"]
|
||||
states["done"] = states["completed"]
|
||||
states["noresponse"] = states["no-response"]
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select distinct docstatus from reviews;")
|
||||
docstates = [r[0] for r in c.fetchall() if r[0]]
|
||||
missing_state_names = set(docstates) - set(states.keys())
|
||||
assert not missing_state_names, "missing state names: {}".format(missing_state_names)
|
||||
|
||||
type_names = { n.slug: n for n in ReviewTypeName.objects.all() }
|
||||
|
||||
# extract relevant log entries
|
||||
|
||||
request_assigned = defaultdict(list)
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select docname, time, who from doclog where text = 'AUTO UPDATED status TO working' order by time desc;")
|
||||
for row in namedtuplefetchall(c):
|
||||
request_assigned[row.docname].append((row.time, row.who))
|
||||
|
||||
# extract document request metadata
|
||||
|
||||
doc_metadata = {}
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select docname, version, deadline, telechat, lcend, status from documents order by docname, version;")
|
||||
|
||||
for row in namedtuplefetchall(c):
|
||||
doc_metadata[(row.docname, row.version)] = doc_metadata[row.docname] = (parse_timestamp(row.deadline), parse_timestamp(row.telechat), parse_timestamp(row.lcend), row.status)
|
||||
|
||||
|
||||
system_person = Person.objects.get(name="(System)")
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select * from reviews order by reviewid;")
|
||||
|
||||
for row in namedtuplefetchall(c):
|
||||
meta = doc_metadata.get((row.docname, row.version))
|
||||
if not meta:
|
||||
meta = doc_metadata.get(row.docname)
|
||||
|
||||
deadline, telechat, lcend, status = meta or (None, None, None, None)
|
||||
|
||||
if not deadline:
|
||||
deadline = parse_timestamp(row.timeout)
|
||||
|
||||
type_name = type_names["unknown"]
|
||||
# FIXME: use lcend and telechat to try to deduce type
|
||||
|
||||
reviewed_rev = row.version if row.version and row.version != "99" else ""
|
||||
if row.summary == "noresponse":
|
||||
reviewed_rev = ""
|
||||
|
||||
assignment_logs = request_assigned.get(row.docname, [])
|
||||
if assignment_logs:
|
||||
time, who = assignment_logs.pop()
|
||||
|
||||
time = parse_timestamp(time)
|
||||
else:
|
||||
time = deadline
|
||||
|
||||
if not deadline:
|
||||
# bogus row
|
||||
print "SKIPPING WITH NO DEADLINE", time, row, meta
|
||||
continue
|
||||
|
||||
if status == "done" and row.docstatus in ("assigned", "accepted"):
|
||||
# filter out some apparently dead requests
|
||||
print "SKIPPING MARKED DONE even if assigned/accepted", time, row
|
||||
continue
|
||||
|
||||
req, _ = ReviewRequest.objects.get_or_create(
|
||||
doc_id=row.docname,
|
||||
team=team,
|
||||
old_id=row.reviewid,
|
||||
defaults={
|
||||
"state": states["requested"],
|
||||
"type": type_name,
|
||||
"deadline": deadline.date(),
|
||||
"requested_by": system_person,
|
||||
}
|
||||
)
|
||||
|
||||
req.reviewer = known_personnel[row.reviewer] if row.reviewer else None
|
||||
req.result = results.get(row.summary.lower()) if row.summary else None
|
||||
req.state = states.get(row.docstatus) if row.docstatus else None
|
||||
req.type = type_name
|
||||
req.time = time
|
||||
req.reviewed_rev = reviewed_rev
|
||||
req.deadline = deadline.date()
|
||||
req.save()
|
||||
|
||||
# FIXME: add log entries
|
||||
# FIXME: add review from reviewurl
|
||||
# FIXME: do something about missing result
|
||||
|
||||
# adcomments IGNORED
|
||||
# lccomments IGNORED
|
||||
# nits IGNORED
|
||||
# reviewurl review.external_url
|
||||
|
||||
#print meta and meta[0], telechat, lcend, req.type
|
||||
|
||||
if req.state_id == "requested" and req.doc.get_state_slug("draft-iesg") in ["approved", "ann", "rfcqueue", "pub"]:
|
||||
req.state = states["overtaken"]
|
||||
req.save()
|
||||
|
||||
print "imported review", row.reviewid, "as", req.pk, req.time, req.deadline, req.type, req.doc_id, req.state, req.doc.get_state_slug("draft-iesg")
|
95
ietf/review/mailarch.py
Normal file
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
|
66
ietf/review/migrations/0001_initial.py
Normal file
66
ietf/review/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import datetime
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('name', '0015_insert_review_name_data'),
|
||||
('group', '0008_auto_20160505_0523'),
|
||||
('person', '0014_auto_20160613_0751'),
|
||||
('doc', '0012_auto_20160207_0537'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReviewerSettings',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('frequency', models.IntegerField(default=30, help_text=b'Can review every N days', choices=[(7, b'Once per week'), (14, b'Once per fortnight'), (30, b'Once per month'), (61, b'Once per two months'), (91, b'Once per quarter')])),
|
||||
('unavailable_until', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)),
|
||||
('filter_re', models.CharField(max_length=255, blank=True)),
|
||||
('skip_next', models.IntegerField(default=0, help_text=b'Skip the next N review assignments')),
|
||||
('person', models.ForeignKey(to='person.Person')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReviewRequest',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('old_id', models.IntegerField(help_text=b'ID in previous review system', null=True, blank=True)),
|
||||
('time', models.DateTimeField(default=datetime.datetime.now)),
|
||||
('deadline', models.DateField()),
|
||||
('requested_rev', models.CharField(help_text=b'Fill in if a specific revision is to be reviewed, e.g. 02', max_length=16, verbose_name=b'requested revision', blank=True)),
|
||||
('reviewed_rev', models.CharField(max_length=16, verbose_name=b'reviewed revision', blank=True)),
|
||||
('doc', models.ForeignKey(related_name='review_request_set', to='doc.Document')),
|
||||
('requested_by', models.ForeignKey(to='person.Person')),
|
||||
('result', models.ForeignKey(blank=True, to='name.ReviewResultName', null=True)),
|
||||
('review', models.OneToOneField(null=True, blank=True, to='doc.Document')),
|
||||
('reviewer', models.ForeignKey(blank=True, to='person.Email', null=True)),
|
||||
('state', models.ForeignKey(to='name.ReviewRequestStateName')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
('type', models.ForeignKey(to='name.ReviewTypeName')),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReviewTeamResult',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('result', models.ForeignKey(to='name.ReviewResultName')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
]
|
0
ietf/review/migrations/__init__.py
Normal file
0
ietf/review/migrations/__init__.py
Normal file
69
ietf/review/models.py
Normal file
69
ietf/review/models.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
import datetime
|
||||
|
||||
from django.db import models
|
||||
|
||||
from ietf.doc.models import Document
|
||||
from ietf.group.models import Group
|
||||
from ietf.person.models import Person, Email
|
||||
from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName
|
||||
|
||||
class ReviewerSettings(models.Model):
|
||||
"""Keeps track of admin data associated with the reviewer in the
|
||||
particular team. There will be one record for each combination of
|
||||
reviewer and team."""
|
||||
team = models.ForeignKey(Group)
|
||||
person = models.ForeignKey(Person)
|
||||
FREQUENCIES = [
|
||||
(7, "Once per week"),
|
||||
(14, "Once per fortnight"),
|
||||
(30, "Once per month"),
|
||||
(61, "Once per two months"),
|
||||
(91, "Once per quarter"),
|
||||
]
|
||||
frequency = models.IntegerField(default=30, help_text="Can review every N days", choices=FREQUENCIES)
|
||||
unavailable_until = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again")
|
||||
filter_re = models.CharField(max_length=255, blank=True)
|
||||
skip_next = models.IntegerField(default=0, help_text="Skip the next N review assignments")
|
||||
|
||||
def __unicode__(self):
|
||||
return u"{} in {}".format(self.person, self.team)
|
||||
|
||||
class ReviewTeamResult(models.Model):
|
||||
"""Captures that a result name is valid for a given team for new
|
||||
reviews. This also implicitly defines which teams are review
|
||||
teams - if there are no possible review results valid for a given
|
||||
team, it can't be a review team."""
|
||||
team = models.ForeignKey(Group)
|
||||
result = models.ForeignKey(ReviewResultName)
|
||||
|
||||
class ReviewRequest(models.Model):
|
||||
"""Represents a request for a review and the process it goes through.
|
||||
There should be one ReviewRequest entered for each combination of
|
||||
document, rev, and reviewer."""
|
||||
state = models.ForeignKey(ReviewRequestStateName)
|
||||
|
||||
old_id = models.IntegerField(blank=True, null=True, help_text="ID in previous review system") # FIXME: remove this when everything has been migrated
|
||||
|
||||
# Fields filled in on the initial record creation - these
|
||||
# constitute the request part.
|
||||
time = models.DateTimeField(default=datetime.datetime.now)
|
||||
type = models.ForeignKey(ReviewTypeName)
|
||||
doc = models.ForeignKey(Document, related_name='review_request_set')
|
||||
team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewteamresult=None))
|
||||
deadline = models.DateField()
|
||||
requested_by = models.ForeignKey(Person)
|
||||
requested_rev = models.CharField(verbose_name="requested revision", max_length=16, blank=True, help_text="Fill in if a specific revision is to be reviewed, e.g. 02")
|
||||
|
||||
# Fields filled in as reviewer is assigned and as the review is
|
||||
# uploaded. Once these are filled in and we progress beyond being
|
||||
# requested/assigned, any changes to the assignment happens by
|
||||
# closing down the current request and making a new one, copying
|
||||
# the request-part fields above.
|
||||
reviewer = models.ForeignKey(Email, blank=True, null=True)
|
||||
|
||||
review = models.OneToOneField(Document, blank=True, null=True)
|
||||
reviewed_rev = models.CharField(verbose_name="reviewed revision", max_length=16, blank=True)
|
||||
result = models.ForeignKey(ReviewResultName, blank=True, null=True)
|
||||
|
||||
def __unicode__(self):
|
||||
return u"%s review on %s by %s %s" % (self.type, self.doc, self.team, self.state)
|
84
ietf/review/resources.py
Normal file
84
ietf/review/resources.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
# Autogenerated by the makeresources management command 2016-06-14 04:21 PDT
|
||||
from tastypie.resources import ModelResource
|
||||
from tastypie.fields import ToManyField # pyflakes:ignore
|
||||
from tastypie.constants import ALL, ALL_WITH_RELATIONS # pyflakes:ignore
|
||||
from tastypie.cache import SimpleCache
|
||||
|
||||
from ietf import api
|
||||
from ietf.api import ToOneField # pyflakes:ignore
|
||||
|
||||
from ietf.review.models import ReviewerSettings, ReviewRequest, ReviewTeamResult
|
||||
|
||||
|
||||
from ietf.person.resources import PersonResource
|
||||
from ietf.group.resources import GroupResource
|
||||
class ReviewerSettingsResource(ModelResource):
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
person = ToOneField(PersonResource, 'person')
|
||||
class Meta:
|
||||
queryset = ReviewerSettings.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'reviewer'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"frequency": ALL,
|
||||
"unavailable_until": ALL,
|
||||
"filter_re": ALL,
|
||||
"skip_next": ALL,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"person": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(ReviewerSettingsResource())
|
||||
|
||||
from ietf.doc.resources import DocumentResource
|
||||
from ietf.group.resources import RoleResource, GroupResource
|
||||
from ietf.name.resources import ReviewRequestStateNameResource, ReviewResultNameResource, ReviewTypeNameResource
|
||||
class ReviewRequestResource(ModelResource):
|
||||
state = ToOneField(ReviewRequestStateNameResource, 'state')
|
||||
type = ToOneField(ReviewTypeNameResource, 'type')
|
||||
doc = ToOneField(DocumentResource, 'doc')
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
reviewer = ToOneField(RoleResource, 'reviewer', null=True)
|
||||
review = ToOneField(DocumentResource, 'review', null=True)
|
||||
result = ToOneField(ReviewResultNameResource, 'result', null=True)
|
||||
class Meta:
|
||||
queryset = ReviewRequest.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'reviewrequest'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"time": ALL,
|
||||
"deadline": ALL,
|
||||
"requested_rev": ALL,
|
||||
"reviewed_rev": ALL,
|
||||
"state": ALL_WITH_RELATIONS,
|
||||
"type": ALL_WITH_RELATIONS,
|
||||
"doc": ALL_WITH_RELATIONS,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"reviewer": ALL_WITH_RELATIONS,
|
||||
"review": ALL_WITH_RELATIONS,
|
||||
"result": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(ReviewRequestResource())
|
||||
|
||||
|
||||
|
||||
from ietf.group.resources import GroupResource
|
||||
from ietf.name.resources import ReviewResultNameResource
|
||||
class ReviewTeamResultResource(ModelResource):
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
result = ToOneField(ReviewResultNameResource, 'result')
|
||||
class Meta:
|
||||
queryset = ReviewTeamResult.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'reviewteamresult'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"result": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(ReviewTeamResultResource())
|
||||
|
400
ietf/review/utils.py
Normal file
400
ietf/review/utils.py
Normal file
|
@ -0,0 +1,400 @@
|
|||
import datetime, re
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db import models
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
|
||||
from ietf.group.models import Group, Role
|
||||
from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent, DocumentAuthor, DocAlias
|
||||
from ietf.iesg.models import TelechatDate
|
||||
from ietf.person.models import Person, Email
|
||||
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream
|
||||
from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewTypeName, ReviewerSettings
|
||||
from ietf.utils.mail import send_mail
|
||||
from ietf.doc.utils import extract_complete_replaces_ancestor_mapping_for_docs
|
||||
|
||||
def active_review_teams():
|
||||
# if there's a ReviewTeamResult defined, it's a review team
|
||||
return Group.objects.filter(state="active").exclude(reviewteamresult=None)
|
||||
|
||||
def close_review_request_states():
|
||||
return ReviewRequestStateName.objects.filter(used=True).exclude(slug__in=["requested", "accepted", "rejected", "part-completed", "completed"])
|
||||
|
||||
def can_request_review_of_doc(user, doc):
|
||||
if not user.is_authenticated():
|
||||
return False
|
||||
|
||||
return is_authorized_in_doc_stream(user, doc)
|
||||
|
||||
def can_manage_review_requests_for_team(user, team, allow_non_team_personnel=True):
|
||||
if not user.is_authenticated():
|
||||
return False
|
||||
|
||||
return (Role.objects.filter(name__in=["secr", "delegate"], person__user=user, group=team).exists()
|
||||
or (allow_non_team_personnel and has_role(user, "Secretariat")))
|
||||
|
||||
def review_requests_to_list_for_doc(doc):
|
||||
return extract_revision_ordered_review_requests_for_documents(
|
||||
ReviewRequest.objects.filter(
|
||||
state__in=["requested", "accepted", "part-completed", "completed"],
|
||||
).prefetch_related("result"),
|
||||
[doc.name]
|
||||
).get(doc.pk, [])
|
||||
|
||||
def no_review_from_teams_on_doc(doc, rev):
|
||||
return Group.objects.filter(
|
||||
reviewrequest__doc=doc,
|
||||
reviewrequest__reviewed_rev=rev,
|
||||
reviewrequest__state="no-review-version",
|
||||
).distinct()
|
||||
|
||||
def make_new_review_request_from_existing(review_req):
|
||||
obj = ReviewRequest()
|
||||
obj.time = review_req.time
|
||||
obj.type = review_req.type
|
||||
obj.doc = review_req.doc
|
||||
obj.team = review_req.team
|
||||
obj.deadline = review_req.deadline
|
||||
obj.requested_rev = review_req.requested_rev
|
||||
obj.requested_by = review_req.requested_by
|
||||
obj.state = ReviewRequestStateName.objects.get(slug="requested")
|
||||
return obj
|
||||
|
||||
def email_review_request_change(request, review_req, subject, msg, by, notify_secretary, notify_reviewer, notify_requested_by):
|
||||
"""Notify possibly both secretary and reviewer about change, skipping
|
||||
a party if the change was done by that party."""
|
||||
|
||||
system_email = Person.objects.get(name="(System)").formatted_email()
|
||||
|
||||
to = []
|
||||
|
||||
def extract_email_addresses(objs):
|
||||
if any(o.person == by for o in objs if o):
|
||||
l = []
|
||||
else:
|
||||
l = []
|
||||
for o in objs:
|
||||
if o:
|
||||
e = o.formatted_email()
|
||||
if e != system_email:
|
||||
l.append(e)
|
||||
|
||||
for e in l:
|
||||
if e not in to:
|
||||
to.append(e)
|
||||
|
||||
if notify_secretary:
|
||||
extract_email_addresses(Role.objects.filter(name__in=["secr", "delegate"], group=review_req.team).distinct())
|
||||
if notify_reviewer:
|
||||
extract_email_addresses([review_req.reviewer])
|
||||
if notify_requested_by:
|
||||
extract_email_addresses([review_req.requested_by.email()])
|
||||
|
||||
if not to:
|
||||
return
|
||||
|
||||
url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": review_req.doc.name, "request_id": review_req.pk })
|
||||
url = request.build_absolute_uri(url)
|
||||
send_mail(request, to, None, subject, "doc/mail/review_request_changed.txt", {
|
||||
"review_req_url": url,
|
||||
"review_req": review_req,
|
||||
"msg": msg,
|
||||
})
|
||||
|
||||
def assign_review_request_to_reviewer(request, review_req, reviewer):
|
||||
assert review_req.state_id in ("requested", "accepted")
|
||||
|
||||
if reviewer == review_req.reviewer:
|
||||
return
|
||||
|
||||
if review_req.reviewer:
|
||||
email_review_request_change(
|
||||
request, review_req,
|
||||
"Unassigned from review of %s" % review_req.doc.name,
|
||||
"%s has cancelled your assignment to the review." % request.user.person,
|
||||
by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=False)
|
||||
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
|
||||
review_req.reviewer = reviewer
|
||||
review_req.save()
|
||||
|
||||
DocEvent.objects.create(
|
||||
type="changed_review_request",
|
||||
doc=review_req.doc,
|
||||
by=request.user.person,
|
||||
desc="Request for {} review by {} is assigned to {}".format(
|
||||
review_req.type.name,
|
||||
review_req.team.acronym.upper(),
|
||||
review_req.reviewer.person if review_req.reviewer else "(None)",
|
||||
),
|
||||
)
|
||||
|
||||
email_review_request_change(
|
||||
request, review_req,
|
||||
"Assigned to review %s" % review_req.doc.name,
|
||||
"%s has assigned you to review the document." % request.user.person,
|
||||
by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=False)
|
||||
|
||||
def close_review_request(request, review_req, close_state):
|
||||
suggested_req = review_req.pk is None
|
||||
|
||||
prev_state = review_req.state
|
||||
review_req.state = close_state
|
||||
if close_state.slug == "no-review-version":
|
||||
review_req.reviewed_rev = review_req.requested_rev or review_req.doc.rev # save rev for later reference
|
||||
review_req.save()
|
||||
|
||||
if not suggested_req:
|
||||
DocEvent.objects.create(
|
||||
type="changed_review_request",
|
||||
doc=review_req.doc,
|
||||
by=request.user.person,
|
||||
desc="Closed request for {} review by {} with state '{}'".format(
|
||||
review_req.type.name, review_req.team.acronym.upper(), close_state.name),
|
||||
)
|
||||
|
||||
if prev_state.slug != "requested":
|
||||
email_review_request_change(
|
||||
request, review_req,
|
||||
"Closed review request for {}: {}".format(review_req.doc.name, close_state.name),
|
||||
"Review request has been closed by {}.".format(request.user.person),
|
||||
by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=True)
|
||||
|
||||
def suggested_review_requests_for_team(team):
|
||||
system_person = Person.objects.get(name="(System)")
|
||||
|
||||
seen_deadlines = {}
|
||||
|
||||
requests = {}
|
||||
|
||||
requested_state = ReviewRequestStateName.objects.get(slug="requested", used=True)
|
||||
|
||||
if True: # FIXME
|
||||
# in Last Call
|
||||
last_call_type = ReviewTypeName.objects.get(slug="lc")
|
||||
last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True))
|
||||
last_call_expires = { e.doc_id: e.expires for e in LastCallDocEvent.objects.order_by("time", "id") }
|
||||
for doc in last_call_docs:
|
||||
deadline = last_call_expires[doc.pk].date() if doc.pk in last_call_expires else datetime.date.today()
|
||||
|
||||
if deadline > seen_deadlines.get(doc.pk, datetime.date.max):
|
||||
continue
|
||||
|
||||
requests[doc.pk] = ReviewRequest(
|
||||
time=None,
|
||||
type=last_call_type,
|
||||
doc=doc,
|
||||
team=team,
|
||||
deadline=deadline,
|
||||
requested_by=system_person,
|
||||
state=requested_state,
|
||||
)
|
||||
|
||||
seen_deadlines[doc.pk] = deadline
|
||||
|
||||
|
||||
if True: # FIXME
|
||||
# on Telechat Agenda
|
||||
telechat_dates = list(TelechatDate.objects.active().order_by('date').values_list("date", flat=True)[:4])
|
||||
|
||||
telechat_type = ReviewTypeName.objects.get(slug="telechat")
|
||||
telechat_deadline_delta = datetime.timedelta(days=2)
|
||||
telechat_docs = Document.objects.filter(docevent__telechatdocevent__telechat_date__in=telechat_dates)
|
||||
for doc in telechat_docs:
|
||||
d = doc.telechat_date()
|
||||
if d not in telechat_dates:
|
||||
continue
|
||||
|
||||
deadline = d - telechat_deadline_delta
|
||||
|
||||
if deadline > seen_deadlines.get(doc.pk, datetime.date.max):
|
||||
continue
|
||||
|
||||
requests[doc.pk] = ReviewRequest(
|
||||
time=None,
|
||||
type=telechat_type,
|
||||
doc=doc,
|
||||
team=team,
|
||||
deadline=deadline,
|
||||
requested_by=system_person,
|
||||
state=requested_state,
|
||||
)
|
||||
|
||||
seen_deadlines[doc.pk] = deadline
|
||||
|
||||
# filter those with existing requests
|
||||
existing_requests = defaultdict(list)
|
||||
for r in ReviewRequest.objects.filter(doc__in=requests.iterkeys()):
|
||||
existing_requests[r.doc_id].append(r)
|
||||
|
||||
def blocks(existing, request):
|
||||
if existing.doc_id != request.doc_id:
|
||||
return False
|
||||
|
||||
no_review_document = existing.state_id == "no-review-document"
|
||||
pending = (existing.state_id in ("requested", "accepted")
|
||||
and (not existing.requested_rev or existing.requested_rev == request.doc.rev))
|
||||
completed_or_closed = (existing.state_id not in ("part-completed", "rejected", "overtaken", "no-response")
|
||||
and existing.reviewed_rev == request.doc.rev)
|
||||
|
||||
return no_review_document or pending or completed_or_closed
|
||||
|
||||
res = [r for r in requests.itervalues()
|
||||
if not any(blocks(e, r) for e in existing_requests[r.doc_id])]
|
||||
res.sort(key=lambda r: (r.deadline, r.doc_id), reverse=True)
|
||||
return res
|
||||
|
||||
def extract_revision_ordered_review_requests_for_documents(review_request_queryset, names):
|
||||
"""Extracts all review requests for document names (including replaced ancestors)."""
|
||||
|
||||
names = set(names)
|
||||
|
||||
replaces = extract_complete_replaces_ancestor_mapping_for_docs(names)
|
||||
|
||||
requests_for_each_doc = defaultdict(list)
|
||||
for r in review_request_queryset.filter(doc__in=set(e for l in replaces.itervalues() for e in l) | names).order_by("-reviewed_rev", "-time", "-id").iterator():
|
||||
requests_for_each_doc[r.doc_id].append(r)
|
||||
|
||||
# now collect in breadth-first order to keep the revision order intact
|
||||
res = defaultdict(list)
|
||||
for name in names:
|
||||
front = replaces.get(name, [])
|
||||
res[name].extend(requests_for_each_doc.get(name, []))
|
||||
|
||||
seen = set()
|
||||
|
||||
while front:
|
||||
replaces_reqs = []
|
||||
next_front = []
|
||||
for replaces_name in front:
|
||||
if replaces_name in seen:
|
||||
continue
|
||||
|
||||
seen.add(replaces_name)
|
||||
|
||||
reqs = requests_for_each_doc.get(replaces_name, [])
|
||||
if reqs:
|
||||
replaces_reqs.append(reqs)
|
||||
|
||||
next_front.extend(replaces.get(replaces_name, []))
|
||||
|
||||
# in case there are multiple replaces, move the ones with
|
||||
# the latest reviews up front
|
||||
replaces_reqs.sort(key=lambda l: l[0].time, reverse=True)
|
||||
|
||||
for reqs in replaces_reqs:
|
||||
res[name].extend(reqs)
|
||||
|
||||
# move one level down
|
||||
front = next_front
|
||||
|
||||
return res
|
||||
|
||||
def setup_reviewer_field(field, review_req):
|
||||
field.queryset = field.queryset.filter(role__name="reviewer", role__group=review_req.team)
|
||||
if review_req.reviewer:
|
||||
field.initial = review_req.reviewer_id
|
||||
|
||||
choices = make_assignment_choices(field.queryset, review_req)
|
||||
if not field.required:
|
||||
choices = [("", field.empty_label)] + choices
|
||||
|
||||
field.choices = choices
|
||||
|
||||
def make_assignment_choices(email_queryset, review_req):
|
||||
doc = review_req.doc
|
||||
team = review_req.team
|
||||
|
||||
possible_emails = list(email_queryset)
|
||||
|
||||
aliases = DocAlias.objects.filter(document=doc).values_list("name", flat=True)
|
||||
|
||||
reviewers = { r.person_id: r for r in ReviewerSettings.objects.filter(team=team, person__in=[e.person_id for e in possible_emails]) }
|
||||
|
||||
# time since past assignment
|
||||
latest_assignment_for_reviewer = dict(ReviewRequest.objects.filter(
|
||||
reviewer__in=possible_emails,
|
||||
).values_list("reviewer").annotate(models.Max("time")))
|
||||
|
||||
# previous review of document
|
||||
has_reviewed_previous = ReviewRequest.objects.filter(
|
||||
doc=doc,
|
||||
reviewer__in=possible_emails,
|
||||
state="completed",
|
||||
)
|
||||
|
||||
if review_req.pk is not None:
|
||||
has_reviewed_previous = has_reviewed_previous.exclude(pk=review_req.pk)
|
||||
|
||||
has_reviewed_previous = set(has_reviewed_previous.values_list("reviewer", flat=True))
|
||||
|
||||
# review indications
|
||||
would_like_to_review = set() # FIXME: fill in
|
||||
|
||||
# connections
|
||||
connections = {}
|
||||
# examine the closest connections last to let them override
|
||||
for e in Email.objects.filter(pk__in=possible_emails, person=doc.ad_id):
|
||||
connections[e] = "is associated Area Director"
|
||||
for r in Role.objects.filter(group=doc.group_id, email__in=possible_emails).select_related("name"):
|
||||
connections[r.email_id] = "is group {}".format(r.name)
|
||||
if doc.shepherd_id:
|
||||
connections[doc.shepherd_id] = "is shepherd of document"
|
||||
for e in DocumentAuthor.objects.filter(document=doc, author__in=possible_emails).values_list("author", flat=True):
|
||||
connections[e] = "is author of document"
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
def add_boolean_score(scores, direction, expr, explanation):
|
||||
scores.append(int(bool(expr)) * direction)
|
||||
if expr:
|
||||
explanations.append(explanation)
|
||||
|
||||
ranking = []
|
||||
for e in possible_emails:
|
||||
reviewer = reviewers.get(e.person_id)
|
||||
if not reviewer:
|
||||
reviewer = ReviewerSettings()
|
||||
|
||||
days_past = None
|
||||
latest = latest_assignment_for_reviewer.get(e.pk)
|
||||
if latest is not None:
|
||||
days_past = (now - latest).days - reviewer.frequency
|
||||
|
||||
if days_past is None:
|
||||
ready_for = "first time"
|
||||
else:
|
||||
d = int(round(days_past))
|
||||
if d > 0:
|
||||
ready_for = "ready for {} {}".format(d, "day" if d == 1 else "days")
|
||||
else:
|
||||
d = -d
|
||||
ready_for = "frequency exceeded, ready in {} {}".format(d, "day" if d == 1 else "days")
|
||||
|
||||
|
||||
# we sort the reviewers by separate axes, listing the most
|
||||
# important things first
|
||||
scores = []
|
||||
explanations = []
|
||||
|
||||
explanations.append(ready_for) # show ready for explanation first, but sort it after the other issues
|
||||
|
||||
add_boolean_score(scores, +1, e.pk in has_reviewed_previous, "reviewed document before")
|
||||
add_boolean_score(scores, +1, e.pk in would_like_to_review, "wants to review document")
|
||||
add_boolean_score(scores, -1, e.pk in connections, connections.get(e.pk)) # reviewer is somehow connected: bad
|
||||
add_boolean_score(scores, -1, reviewer.filter_re and any(re.search(reviewer.filter_re, n) for n in aliases), "filter regexp matches")
|
||||
add_boolean_score(scores, -1, reviewer.unavailable_until and reviewer.unavailable_until > now, "unavailable until {}".format((reviewer.unavailable_until or now).strftime("%Y-%m-%d %H:%M:%S")))
|
||||
|
||||
scores.append(100000 if days_past is None else days_past)
|
||||
|
||||
label = "{}: {}".format(e.person, "; ".join(explanations))
|
||||
|
||||
ranking.append({
|
||||
"email": e,
|
||||
"scores": scores,
|
||||
"label": label,
|
||||
})
|
||||
|
||||
ranking.sort(key=lambda r: r["scores"], reverse=True)
|
||||
|
||||
return [(r["email"].pk, r["label"]) for r in ranking]
|
|
@ -306,6 +306,7 @@ INSTALLED_APPS = (
|
|||
'ietf.person',
|
||||
'ietf.redirects',
|
||||
'ietf.release',
|
||||
'ietf.review',
|
||||
'ietf.submit',
|
||||
'ietf.sync',
|
||||
'ietf.utils',
|
||||
|
@ -448,6 +449,7 @@ MEETING_RECORDINGS_DIR = '/a/www/audio'
|
|||
|
||||
# Mailing list info URL for lists hosted on the IETF servers
|
||||
MAILING_LIST_INFO_URL = "https://www.ietf.org/mailman/listinfo/%(list_addr)s"
|
||||
MAILING_LIST_ARCHIVE_URL = "https://mailarchive.ietf.org"
|
||||
|
||||
# Liaison Statement Tool settings (one is used in DOC_HREFS below)
|
||||
LIAISON_UNIVERSAL_FROM = 'Liaison Statement Management Tool <lsmt@' + IETF_DOMAIN + '>'
|
||||
|
|
|
@ -470,6 +470,51 @@ label#list-feeds {
|
|||
margin-left: 3em;
|
||||
}
|
||||
|
||||
/* === Review flow ========================================================== */
|
||||
|
||||
.reviewer-assignment-not-accepted {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
form.complete-review .mail-archive-search .query-input {
|
||||
width: 30em;
|
||||
}
|
||||
|
||||
form.complete-review .mail-archive-search .results .list-group {
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.closed-review-filter {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
form.review-requests .reviewer-controls, form.review-requests .close-controls {
|
||||
display: none;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
form.review-requests .assign-action, form.review-requests .close-action {
|
||||
display: inline-block;
|
||||
min-width: 11em;
|
||||
}
|
||||
|
||||
form.review-requests .deadline {
|
||||
padding-top: 0.45em;
|
||||
}
|
||||
|
||||
form.review-requests label {
|
||||
font-weight: normal;
|
||||
padding-right: 0.3em;
|
||||
}
|
||||
|
||||
form.email-open-review-assignments [name=body] {
|
||||
height: 50em;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* === Photo pages ========================================================== */
|
||||
|
||||
.photo-name {
|
||||
|
|
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"]', '[name="cc"]'],
|
||||
"upload": ['[name="review_file"]', '[name="cc"]'],
|
||||
"link": [".mail-archive-search", '[name="review_url"]', '[name="review_content"]']
|
||||
};
|
||||
|
||||
for (var v in shouldBeVisible) {
|
||||
for (var i in shouldBeVisible[v]) {
|
||||
var selector = shouldBeVisible[v][i];
|
||||
var row = form.find(selector);
|
||||
if (!row.is(".form-group"))
|
||||
row = row.closest(".form-group");
|
||||
|
||||
if ($.inArray(selector, shouldBeVisible[val]) != -1)
|
||||
row.show();
|
||||
else
|
||||
row.hide();
|
||||
}
|
||||
}
|
||||
|
||||
if (val == "link")
|
||||
searchMailArchive();
|
||||
}).trigger("change");
|
||||
});
|
101
ietf/static/ietf/js/manage-review-requests.js
Normal file
101
ietf/static/ietf/js/manage-review-requests.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
$(document).ready(function () {
|
||||
var form = $("form.review-requests");
|
||||
var saveButtons = form.find("[name=action][value^=\"save\"]");
|
||||
|
||||
function updateSaveButtons() {
|
||||
saveButtons.prop("disabled", form.find("[name$=\"-action\"][value][value!=\"\"]").length == 0);
|
||||
}
|
||||
|
||||
function setControlDisplay(row) {
|
||||
var action = row.find("[name$=\"-action\"]").val();
|
||||
if (action == "assign") {
|
||||
row.find(".reviewer-controls").show();
|
||||
row.find(".close-controls").hide();
|
||||
row.find(".assign-action,.close-action").hide();
|
||||
}
|
||||
else if (action == "close") {
|
||||
row.find(".reviewer-controls").hide();
|
||||
row.find(".close-controls").show();
|
||||
row.find(".assign-action,.close-action").hide();
|
||||
}
|
||||
else {
|
||||
row.find(".reviewer-controls,.close-controls").hide();
|
||||
row.find(".assign-action,.close-action").show();
|
||||
}
|
||||
|
||||
updateSaveButtons();
|
||||
}
|
||||
|
||||
form.find(".assign-action button").on("click", function () {
|
||||
var row = $(this).closest("tr");
|
||||
|
||||
var select = row.find(".reviewer-controls [name$=\"-reviewer\"]");
|
||||
if (!select.val()) {
|
||||
// collect reviewers already assigned in this session
|
||||
var reviewerAssigned = {};
|
||||
select.find("option").each(function () {
|
||||
if (this.value)
|
||||
reviewerAssigned[this.value] = 0;
|
||||
});
|
||||
|
||||
form.find("[name$=\"-action\"][value=\"assign\"]").each(function () {
|
||||
var v = $(this).closest("tr").find("[name$=\"-reviewer\"]").val();
|
||||
if (v)
|
||||
reviewerAssigned[v] += 1;
|
||||
});
|
||||
|
||||
// by default, the select box contains a sorted list, so
|
||||
// we should be able to select the first, unless that
|
||||
// person has already been assigned to review in this
|
||||
// session
|
||||
var found = null;
|
||||
var options = select.find("option").get();
|
||||
for (var round = 0; round < 100 && !found; ++round) {
|
||||
for (var i = 0; i < options.length && !found; ++i) {
|
||||
var v = options[i].value;
|
||||
if (!v)
|
||||
continue;
|
||||
|
||||
if (reviewerAssigned[v] == round)
|
||||
found = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (found)
|
||||
select.val(found);
|
||||
}
|
||||
|
||||
row.find("[name$=\"-action\"]").val("assign");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
form.find(".reviewer-controls .undo").on("click", function () {
|
||||
var row = $(this).closest("tr");
|
||||
row.find("[name$=\"-action\"]").val("");
|
||||
row.find("[name$=\"-reviewer\"]").val($(this).data("initial"));
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
form.find(".close-action button").on("click", function () {
|
||||
var row = $(this).closest("tr");
|
||||
row.find("[name$=\"-action\"]").val("close");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
form.find(".close-controls .undo").on("click", function () {
|
||||
var row = $(this).closest("tr");
|
||||
row.find("[name$=\"-action\"]").val("");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
form.find("[name$=\"-action\"]").each(function () {
|
||||
var v = $(this).val();
|
||||
if (!v)
|
||||
return;
|
||||
|
||||
var row = $(this).closest("tr");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
updateSaveButtons();
|
||||
});
|
|
@ -192,6 +192,33 @@
|
|||
</td>
|
||||
</tr>
|
||||
|
||||
{% if review_requests or can_request_review %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Reviews</th>
|
||||
<td class="edit"></td>
|
||||
<td>
|
||||
{% for review_request in review_requests %}
|
||||
{% include "doc/review_request_summary.html" with current_doc_name=doc.name current_rev=doc.rev %}
|
||||
{% endfor %}
|
||||
|
||||
{% if no_review_from_teams %}
|
||||
{% for team in no_review_from_teams %}
|
||||
{{ team.acronym.upper }}{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
will not review this version
|
||||
{% endif %}
|
||||
|
||||
{% if can_request_review %}
|
||||
<div>
|
||||
<a class="btn btn-default btn-xs" href="{% url "ietf.doc.views_review.request_review" doc.name %}"><span class="fa fa-check-circle-o"></span> Request review</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if conflict_reviews %}
|
||||
<tr>
|
||||
<th></th>
|
||||
|
|
116
ietf/templates/doc/document_review.html
Normal file
116
ietf/templates/doc/document_review.html
Normal file
|
@ -0,0 +1,116 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin %}
|
||||
{% load staticfiles %}
|
||||
{% load ietf_filters %}
|
||||
|
||||
{% block title %}{{ doc.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
{{ top|safe }}
|
||||
|
||||
{% include "doc/revisions_list.html" %}
|
||||
|
||||
<table class="table table-condensed">
|
||||
<thead id="message-row">
|
||||
<tr>
|
||||
{% if doc.rev != latest_rev %}
|
||||
<th colspan="4" class="alert-warning">The information below is for an old version of the document</th>
|
||||
{% else %}
|
||||
<th colspan="4"></th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="meta">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Team</th>
|
||||
<td class="edit"></td>
|
||||
<td>
|
||||
{{ doc.group.name }}
|
||||
<a href="{{ doc.group.about_url }}">({{ doc.group.acronym }})</a>
|
||||
|
||||
{% if snapshot %}
|
||||
<span class="label label-warning">Snapshot</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Title</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ doc.title }}</td>
|
||||
</tr>
|
||||
|
||||
{% if doc.get_state_slug != "active" %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>State</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ doc.get_state.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Request</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ review_req.type.name }} - <a href="{% url "ietf.doc.views_review.review_request" review_req.doc.name review_req.pk %}">requested {{ review_req.time|date:"Y-m-d" }}</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Reviewer</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ review_req.reviewer.person }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Review result</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ review_req.result.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if doc.external_url %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Posted at</th>
|
||||
<td class="edit"></td>
|
||||
<td><a href="{{ doc.external_url }}">{{ doc.external_url }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Last updated</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ doc.time|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
|
||||
{% if other_reviews %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Other reviews</th>
|
||||
<td class="edit"></td>
|
||||
<td>
|
||||
{% for review_request in other_reviews %}
|
||||
{% include "doc/review_request_summary.html" with current_doc_name=review_req.doc_id current_rev=review_req.reviewed_rev %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>{{ doc.type.name }}<br><small>{{ doc.name }}</small></h2>
|
||||
|
||||
{% if doc.rev and content != None %}
|
||||
{{ content|fill:"80"|safe|linebreaksbr|keep_spacing|sanitize_html|safe }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
7
ietf/templates/doc/mail/completed_review.txt
Normal file
7
ietf/templates/doc/mail/completed_review.txt
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% autoescape off %}{% filter wordwrap:70 %}{% if review_req.state_id == "part-completed" %}Review is partially done. Another review request has been registered for completing it.
|
||||
|
||||
{% endif %}Reviewer: {{ review_req.reviewer.person }}
|
||||
Review result: {{ review_req.result.name }}
|
||||
|
||||
{{ content }}
|
||||
{% endfilter %}{% endautoescape %}
|
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:
|
||||
|
||||
{{ new_review_req_url }}
|
||||
{% endautoescape %}
|
9
ietf/templates/doc/mail/review_request_changed.txt
Normal file
9
ietf/templates/doc/mail/review_request_changed.txt
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% autoescape off %}
|
||||
{{ review_req.type.name }} review of: {{ review_req.doc.name }} ({% if review_req.requested_rev %}rev. {{ review_req.requested_rev }}{% else %}no specific version{% endif %})
|
||||
Deadline: {{ review_req.deadline|date:"Y-m-d" }}
|
||||
|
||||
{{ review_req_url }}
|
||||
|
||||
{{ msg|wordwrap:72 }}
|
||||
|
||||
{% endautoescape %}
|
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 %}
|
22
ietf/templates/doc/review/assign_reviewer.html
Normal file
22
ietf/templates/doc/review/assign_reviewer.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Assign reviewer for {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Assign reviewer<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary" name="action" value="assign">Assign reviewer</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
24
ietf/templates/doc/review/close_request.html
Normal file
24
ietf/templates/doc/review/close_request.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Close review request for {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Close review request<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<p>Do you want to close the review request?</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Close request</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
80
ietf/templates/doc/review/complete_review.html
Normal file
80
ietf/templates/doc/review/complete_review.html
Normal file
|
@ -0,0 +1,80 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Complete review of {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Complete review<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<p>The review findings should be made available here and the review
|
||||
posted to the mailing list. If you enter the findings below, the
|
||||
system will post the review for you. If you already have posted
|
||||
the review, you can try to let the system find the link to the
|
||||
archive and retrieve the email body.</p>
|
||||
|
||||
<form class="complete-review form-horizontal" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{% bootstrap_form form layout="horizontal" %}
|
||||
|
||||
{% buttons %}
|
||||
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Complete review</button>
|
||||
{% endbuttons %}
|
||||
|
||||
<div class="template" style="display:none">
|
||||
{% if mail_archive_query_urls %}
|
||||
<div class="mail-archive-search form-group">
|
||||
<div class="col-md-offset-2 col-md-10">
|
||||
<p class="form-inline">
|
||||
Search mail archive subjects for:
|
||||
<input class="query-input form-control input-sm" value="{{ mail_archive_query_urls.query }}">
|
||||
<button type="button" class="search btn btn-default btn-sm">Search</button>
|
||||
</p>
|
||||
|
||||
<div class="retrieving hidden">
|
||||
<span class="fa fa-spin fa-circle-o-notch"></span>
|
||||
Searching...
|
||||
</div>
|
||||
|
||||
<div class="results hidden">
|
||||
<p>Select one of the following messages to automatically pre-fill link and content:</p>
|
||||
<div class="list-group">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error alert alert-warning hidden">
|
||||
<p>
|
||||
<span class="content"></span>
|
||||
<span class="hidden try-yourself">(searched for <a class="query-url" href="">"<span class="query"></span>"</a>, corresponding <a class="query-data-url" href="">export</a>).</span>
|
||||
You have to fill in link and content yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mail-archive-search">
|
||||
<small class="text-muted">Mailing list does not have a recognized ietf.org archive. Auto-searching disabled.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="template" style="display:none">
|
||||
<button type="button" class="mail-archive-search-result list-group-item">
|
||||
<span class="date badge"></span>
|
||||
<span class="subject"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
var searchMailArchiveUrl = "{% url "ietf.doc.views_review.search_mail_archive" name=review_req.doc.name request_id=review_req.pk %}";
|
||||
</script>
|
||||
<script src="{% static 'ietf/js/complete-review.js' %}"></script>
|
||||
{% endblock %}
|
24
ietf/templates/doc/review/reject_reviewer_assignment.html
Normal file
24
ietf/templates/doc/review/reject_reviewer_assignment.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Reject review assignment for {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Reject review assignment<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<p>{{ review_req.reviewer.person }} is currently assigned to do the review. Do you want to reject this assignment?</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary" name="action" value="reject">Reject assignment</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
50
ietf/templates/doc/review/request_review.html
Normal file
50
ietf/templates/doc/review/request_review.html
Normal file
|
@ -0,0 +1,50 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Request review of {{ doc.name }} {% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Request review<br><small>{{ doc.name }}</small></h1>
|
||||
|
||||
<p>Submit a request to have the document reviewed.</p>
|
||||
|
||||
<p>
|
||||
<div>Current revision of the document: <strong>{{ doc.rev }}</strong>.</div>
|
||||
|
||||
{% if lc_ends %}
|
||||
<div>Last Call ends: <strong>{{ lc_ends|date:"Y-m-d" }}</strong> (in {{ lc_ends_days }} day{{ lc_ends_days|pluralize }}).</div>
|
||||
{% endif %}
|
||||
|
||||
{% if scheduled_for_telechat %}
|
||||
<div>Scheduled for telechat: <strong>{{ scheduled_for_telechat|date:"Y-m-d" }}</strong> (in {{ scheduled_for_telechat_days }} day{{ scheduled_for_telechat_days|pluralize }}).</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<form class="form-horizontal" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_field form.requested_by layout="horizontal" %}
|
||||
{% bootstrap_field form.type layout="horizontal" %}
|
||||
{% bootstrap_field form.team layout="horizontal" %}
|
||||
{% bootstrap_field form.deadline layout="horizontal" %}
|
||||
{% bootstrap_field form.requested_rev layout="horizontal" %}
|
||||
|
||||
{% buttons %}
|
||||
<button type="submit" class="btn btn-primary">Request review</button>
|
||||
<a class="btn btn-default pull-right" href="{% url "doc_view" name=doc.canonical_name %}">Back</a>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
<script src="{% static 'select2/select2.min.js' %}"></script>
|
||||
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
|
||||
{% endblock %}
|
167
ietf/templates/doc/review/review_request.html
Normal file
167
ietf/templates/doc/review/review_request.html
Normal file
|
@ -0,0 +1,167 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Review request for {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Review request<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<table class="table table-condensed">
|
||||
<tbody class="meta">
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Review of</th>
|
||||
<td>
|
||||
{% if review_req.requested_rev %}
|
||||
<a href="{% url "doc_view" name=review_req.doc.name rev=review_req.requested_rev %}">{{ review_req.doc.name }}-{{ review_req.requested_rev }}</a>
|
||||
{% else %}
|
||||
<a href="{% url "doc_view" name=review_req.doc.name %}">{{ review_req.doc.name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Requested rev.</th>
|
||||
<td>
|
||||
{% if review_req.requested_rev %}
|
||||
{{ review_req.requested_rev }}
|
||||
{% else %}
|
||||
no specific revision
|
||||
{% endif %}
|
||||
{% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Type</th>
|
||||
<td>{{ review_req.type.name }} Review</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Team</th>
|
||||
<td><a href="{% url "ietf.group.views.review_requests" group_type=review_req.team.type_id acronym=review_req.team.acronym %}">{{ review_req.team.acronym|upper }}</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Deadline</th>
|
||||
<td>{{ review_req.deadline|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Requested</th>
|
||||
<td>{{ review_req.time|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
|
||||
{% if review_req.requested_by.name != "(System)" %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Requested by</th>
|
||||
<td>{{ review_req.requested_by }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
|
||||
<tbody class="meta">
|
||||
<tr>
|
||||
<th>Review</th>
|
||||
<th>State</th>
|
||||
<td>{{ review_req.state.name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Reviewer</th>
|
||||
<td>
|
||||
{% if review_req.reviewer %}
|
||||
{{ review_req.reviewer.person }}
|
||||
{% else %}
|
||||
None assigned yet
|
||||
{% endif %}
|
||||
|
||||
{% if can_assign_reviewer %}
|
||||
<a class="btn btn-default btn-xs" href="{% url "ietf.doc.views_review.assign_reviewer" name=doc.name request_id=review_req.pk %}"><span class="fa fa-user"></span> {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer</a>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req.reviewer %}
|
||||
{% if can_reject_reviewer_assignment or can_accept_reviewer_assignment %}
|
||||
<div class="reviewer-assignment-not-accepted">
|
||||
{% if review_req.state_id == "requested"%}
|
||||
<em>Assignment not accepted yet:</em>
|
||||
{% else %}
|
||||
<em>Assignment accepted:</em>
|
||||
{% endif %}
|
||||
|
||||
{% if can_reject_reviewer_assignment %}
|
||||
<a class="btn btn-danger btn-xs" href="{% url "ietf.doc.views_review.reject_reviewer_assignment" name=doc.name request_id=review_req.pk %}"><span class="fa fa-ban"></span> Reject</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_accept_reviewer_assignment %}
|
||||
<form style="display:inline" method="post" action="{% url "ietf.doc.views_review.review_request" name=doc.name request_id=review_req.pk %}">{% csrf_token %}<button class="btn btn-success btn-xs" type="submit" name="action" value="accept"><span class="fa fa-check"></span> Accept</button></form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Review</th>
|
||||
<td>
|
||||
{% if review_req.review %}
|
||||
<a href="{{ review_req.review.get_absolute_url }}">{{ review_req.review.name }}</a>
|
||||
{% elif review_req.state_id == "requested" or review_req.state_id == "accepted" %}
|
||||
Not completed yet
|
||||
{% else %}
|
||||
Not available
|
||||
{% endif %}
|
||||
|
||||
{% if can_complete_review %}
|
||||
<a class="btn btn-primary btn-xs" href="{% url "ietf.doc.views_review.complete_review" name=doc.name request_id=review_req.pk %}"><span class="fa fa-pencil-square-o"></span> Complete review</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% if review_req.review and review_req.review.external_url %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Posted at</th>
|
||||
<td>
|
||||
<a href="{{ review_req.review.external_url }}">{{ review_req.review.external_url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req.reviewed_rev %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Reviewed rev.</th>
|
||||
<td><a href="{% url "doc_view" name=review_req.doc.name rev=review_req.reviewed_rev %}">{{ review_req.reviewed_rev }}</a> {% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req.result %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Review result</th>
|
||||
<td>{{ review_req.result.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div>
|
||||
{% if can_close_request %}
|
||||
<a class="btn btn-danger btn-xs" href="{% url "ietf.doc.views_review.close_request" name=doc.name request_id=review_req.pk %}"><span class="fa fa-ban"></span> Close request</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
15
ietf/templates/doc/review_request_summary.html
Normal file
15
ietf/templates/doc/review_request_summary.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
<div class="review-request-summary">
|
||||
{% if review_request.state_id == "completed" or review_request.state_id == "part-completed" %}
|
||||
<a href="{% if review_request.review %}{% url "doc_view" review_request.review.name %}{% else %}{% url "ietf.doc.views_review.review_request" review_request.doc_id review_request.pk %}{% endif %}">
|
||||
{{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev or review_request.doc_id != current_doc_name %} (of {% if review_request.doc_id != current_doc_name %}{{ review_request.doc_id }}{% endif %}-{{ review_request.reviewed_rev }}){% endif %}:
|
||||
{{ review_request.result.name }} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %}
|
||||
- reviewer: {{ review_request.reviewer.person }}</a>
|
||||
{% else %}
|
||||
<i>
|
||||
<a href="{% url "ietf.doc.views_review.review_request" review_request.doc_id review_request.pk %}">{{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review
|
||||
{% if review_request.reviewer %}
|
||||
- reviewer: {{ review_request.reviewer.person }}
|
||||
{% endif %}
|
||||
- due: {{ review_request.deadline|date:"Y-m-d" }}</a></i>
|
||||
{% endif %}
|
||||
</div>
|
26
ietf/templates/group/email_open_review_assignments.html
Normal file
26
ietf/templates/group/email_open_review_assignments.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}Email summary of assigned review requests for {{ group.acronym }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
<h1>Email summary of assigned review requests for {{ group.acronym }}</h1>
|
||||
|
||||
{% if review_requests %}
|
||||
<form class="email-open-review-assignments" method="post">{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<a href="{% url "ietf.group.views_review.manage_review_requests" group_type=group.type_id acronym=group.acronym %}" class="btn btn-default pull-right">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="email">Send to team mailing list</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% else %}
|
||||
<p>There are currently no open requests.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
4
ietf/templates/group/email_open_review_assignments.txt
Normal file
4
ietf/templates/group/email_open_review_assignments.txt
Normal file
|
@ -0,0 +1,4 @@
|
|||
{% autoescape off %}
|
||||
Reviewer Deadline Draft
|
||||
{% for r in review_requests %}{{ r.reviewer.person.plain_name|ljust:"22" }} {{ r.deadline|date:"Y-m-d" }} {{ r.doc_id }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}
|
||||
{% endfor %}{% endautoescape %}
|
142
ietf/templates/group/manage_review_requests.html
Normal file
142
ietf/templates/group/manage_review_requests.html
Normal file
|
@ -0,0 +1,142 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}Manage open review requests for {{ group.acronym }}{% endblock %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static "jquery.tablesorter/css/theme.bootstrap.min.css" %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
<h1>Manage open review requests for {{ group.acronym }}</h1>
|
||||
|
||||
<p>Other options:
|
||||
<a href="{% url "ietf.group.views.review_requests" group_type=group.type_id acronym=group.acronym %}#closed-review-requests">Closed review requests</a>
|
||||
- <a href="{% url "ietf.group.views_review.email_open_review_assignments" group_type=group.type_id acronym=group.acronym %}">Email open assignments summary</a>
|
||||
</p>
|
||||
|
||||
{% if newly_closed > 0 or newly_opened > 0 or newly_assigned > 0 %}
|
||||
<p class="alert alert-danger">
|
||||
Changes since last refresh:
|
||||
{% if newly_closed %}{{ newly_closed }} request{{ newly_closed|pluralize }} closed.{% endif %}
|
||||
{% if newly_opened %}{{ newly_opened }} request{{ newly_opened|pluralize }} opened.{% endif %}
|
||||
{% if newly_assigned %}{{ newly_assigned }} request{{ newly_assigned|pluralize }} changed assignment.{% endif %}
|
||||
|
||||
{% if saving %}
|
||||
Check that you are happy with the results, then re-save.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if review_requests %}
|
||||
<form class="review-requests" method="post">{% csrf_token %}
|
||||
<table class="table table-condensed table-striped materials">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Document</th>
|
||||
<th>Deadline</th>
|
||||
<th style="min-width:65%">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in review_requests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% if r.requested_rev %}{% url "doc_view" name=r.doc.name rev=r.requested_rev %}{% else %}{% url "doc_view" name=r.doc.name %}{% endif %}">{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a>
|
||||
|
||||
<div>
|
||||
<small>
|
||||
<a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{% if r.time %}Req: {{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %} - {{ r.type.name }}</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{% if r.latest_reqs %}
|
||||
{% for rlatest in r.latest_reqs %}
|
||||
<div>
|
||||
<small>- prev. review of {% if rlatest.doc_id != r.doc_id %}{{ rlatest.doc_id }}{% endif %}-{{ rlatest.reviewed_rev }}:
|
||||
<a href="{% url "ietf.doc.views_review.review_request" name=rlatest.doc_id request_id=rlatest.pk %}">{{ rlatest.result.name }}</a>
|
||||
(<a href="{{ rfcdiff_base_url }}?url1={{ rlatest.doc.name }}-{{ rlatest.reviewed_rev }}&url2={{ r.doc.name }}-{{ r.doc.rev }}">diff</a>){% if not forloop.last %},{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if r.form.non_field_errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for e in r.form.non_field_errors %}
|
||||
{{ e }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="deadline">
|
||||
{{ r.deadline|date:"Y-m-d" }}
|
||||
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<input type="hidden" name="reviewrequest" value="{{ r.pk }}">
|
||||
<input type="hidden" name="{{ r.form.prefix }}-existing_reviewer" value="{{ r.reviewer_id|default:"" }}">
|
||||
|
||||
<span class="assign-action">
|
||||
{% if r.reviewer %}
|
||||
<button type="button" class="btn btn-default btn-sm" title="Click to reassign reviewer">{{ r.reviewer.person }}{% if r.state_id == "accepted" %} <span class="label label-default">accp</span>{% endif %}</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-default btn-sm" title="Click to assign reviewer"><em>not yet assigned</em></button>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{{ r.form.action }}
|
||||
|
||||
<span class="reviewer-controls form-inline">
|
||||
<label for="{{ r.form.reviewer.id_for_label }}">Assign:</label>
|
||||
{{ r.form.reviewer }}
|
||||
<button type="button" class="btn btn-default btn-sm undo" title="Cancel assignment" data-initial="{{ r.form.fields.reviewer.initial|default:"" }}">Cancel</button>
|
||||
{% if r.form.reviewer.errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for e in r.form.reviewer.errors %}
|
||||
{{ e }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<span class="close-action">
|
||||
<button type="button" class="btn btn-default btn-sm">Close...</button>
|
||||
</span>
|
||||
|
||||
<span class="close-controls form-inline">
|
||||
<label for="{{ r.form.reviewer.id_for_label }}">Close:</label>
|
||||
{{ r.form.close }}
|
||||
<button type="button" class="btn btn-default btn-sm undo" title="Cancel closing">Cancel</button>
|
||||
{% if r.form.close.errors %}
|
||||
<br>
|
||||
{{ r.form.close.errors }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% buttons %}
|
||||
<a href="{% url "ietf.group.views.review_requests" group_type=group.type_id acronym=group.acronym %}" class="btn btn-default pull-right">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="save">Save changes</button>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="save-continue">Save and continue editing</button>
|
||||
<button class="btn btn-default" type="submit" name="action" value="refresh">Refresh (keeping changes)</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% else %}
|
||||
<p>There are currently no open requests.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static "jquery.tablesorter/js/jquery.tablesorter.combined.min.js" %}"></script>
|
||||
<script src="{% static "ietf/js/manage-review-requests.js" %}"></script>
|
||||
{% endblock %}
|
112
ietf/templates/group/review_requests.html
Normal file
112
ietf/templates/group/review_requests.html
Normal file
|
@ -0,0 +1,112 @@
|
|||
{% extends "group/group_base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block group_subtitle %}Review requests{% endblock %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static "jquery.tablesorter/css/theme.bootstrap.min.css" %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block group_content %}
|
||||
{% origin %}
|
||||
|
||||
<h2>Open review requests</h2>
|
||||
|
||||
{% if open_review_requests %}
|
||||
<table class="table table-condensed table-striped materials tablesorter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Type</th>
|
||||
<th>Requested</th>
|
||||
<th>Deadline</th>
|
||||
<th>Reviewer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in open_review_requests %}
|
||||
<tr>
|
||||
<td><a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a></td>
|
||||
<td>{{ r.type.name }}</td>
|
||||
<td>{% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %}</td>
|
||||
<td>
|
||||
{{ r.deadline|date:"Y-m-d" }}
|
||||
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.reviewer %}
|
||||
{{ r.reviewer.person }} {% if r.state_id == "accepted" %}<span class="label label-default">Accepted</span>{% endif %}
|
||||
{% elif r.pk != None %}
|
||||
<em>not yet assigned</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<p>There are currently no open requests.</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 id="closed-review-requests">Closed review requests</h2>
|
||||
|
||||
<form class="closed-review-filter" action="#closed-review-requests">
|
||||
Past:
|
||||
<div class="btn-group" role="group">
|
||||
{% for key, label in since_choices %}
|
||||
<button class="btn btn-default {% if since == key %}active{% endif %}" {% if key %}name="since" value="{{ key }}"{% endif %} type="submit">{{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if closed_review_requests %}
|
||||
<table class="table table-condensed table-striped materials tablesorter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Type</th>
|
||||
<th>Requested</th>
|
||||
<th>Deadline</th>
|
||||
<th>Reviewer</th>
|
||||
<th>State</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in closed_review_requests %}
|
||||
<tr>
|
||||
<td><a href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}">{{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %}</a></td>
|
||||
<td>{{ r.type }}</td>
|
||||
<td>{{ r.time|date:"Y-m-d" }}</td>
|
||||
<td>{{ r.deadline|date:"Y-m-d" }}</td>
|
||||
<td>
|
||||
{% if r.reviewer %}
|
||||
{{ r.reviewer.person }}
|
||||
{% else %}
|
||||
<em>not yet assigned</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ r.state.name }}</td>
|
||||
<td>
|
||||
{% if r.result %}
|
||||
{{ r.result.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<p>No closed requests found.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static "jquery.tablesorter/js/jquery.tablesorter.combined.min.js" %}"></script>
|
||||
{% endblock %}
|
|
@ -187,7 +187,7 @@ def encode_message(txt):
|
|||
def send_mail_text(request, to, frm, subject, txt, cc=None, extra=None, toUser=False, bcc=None):
|
||||
"""Send plain text message."""
|
||||
msg = encode_message(txt)
|
||||
send_mail_mime(request, to, frm, subject, msg, cc, extra, toUser, bcc)
|
||||
return send_mail_mime(request, to, frm, subject, msg, cc, extra, toUser, bcc)
|
||||
|
||||
def condition_message(to, frm, subject, msg, cc, extra):
|
||||
if isinstance(frm, tuple):
|
||||
|
@ -284,6 +284,8 @@ def send_mail_mime(request, to, frm, subject, msg, cc=None, extra=None, toUser=F
|
|||
build_warning_message(request, e)
|
||||
send_error_email(e)
|
||||
|
||||
return msg
|
||||
|
||||
def parse_preformatted(preformatted, extra={}, override={}):
|
||||
"""Parse preformatted string containing mail with From:, To:, ...,"""
|
||||
msg = message_from_string(preformatted.encode("utf-8"))
|
||||
|
@ -323,8 +325,8 @@ def send_mail_message(request, message, extra={}):
|
|||
if message.reply_to:
|
||||
e['Reply-to'] = message.reply_to
|
||||
|
||||
send_mail_text(request, message.to, message.frm, message.subject,
|
||||
message.body, cc=message.cc, bcc=message.bcc, extra=e)
|
||||
return send_mail_text(request, message.to, message.frm, message.subject,
|
||||
message.body, cc=message.cc, bcc=message.bcc, extra=e)
|
||||
|
||||
def exception_components(e):
|
||||
# See if it's a non-smtplib exception that we faked
|
||||
|
|
|
@ -6,7 +6,7 @@ from django.contrib.auth.models import User
|
|||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from ietf.doc.models import Document, DocAlias, State, DocumentAuthor, BallotType, DocEvent, BallotDocEvent, RelatedDocument
|
||||
from ietf.doc.models import Document, DocAlias, State, DocumentAuthor, BallotType, DocEvent, BallotDocEvent, RelatedDocument, NewRevisionDocEvent
|
||||
from ietf.group.models import Group, GroupHistory, Role, RoleHistory
|
||||
from ietf.iesg.models import TelechatDate
|
||||
from ietf.ipr.models import HolderIprDisclosure, IprDocRel, IprDisclosureStateName, IprLicenseTypeName
|
||||
|
@ -14,6 +14,7 @@ from ietf.meeting.models import Meeting
|
|||
from ietf.name.models import StreamName, DocRelationshipName
|
||||
from ietf.person.models import Person, Email
|
||||
from ietf.group.utils import setup_default_community_list_for_group
|
||||
from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewResultName, ReviewTeamResult
|
||||
|
||||
def create_person(group, role_name, name=None, username=None, email_address=None, password=None):
|
||||
"""Add person/user/email and role."""
|
||||
|
@ -285,11 +286,12 @@ def make_test_data():
|
|||
desc="Started IESG process",
|
||||
)
|
||||
|
||||
DocEvent.objects.create(
|
||||
NewRevisionDocEvent.objects.create(
|
||||
type="new_revision",
|
||||
by=ad,
|
||||
doc=draft,
|
||||
desc="New revision available",
|
||||
rev="01",
|
||||
)
|
||||
|
||||
BallotDocEvent.objects.create(
|
||||
|
@ -389,3 +391,32 @@ def make_test_data():
|
|||
#other_doc_factory('recording','recording-42-mars-1-00')
|
||||
|
||||
return draft
|
||||
|
||||
def make_review_data(doc):
|
||||
team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="dir", list_email="reviewteam@ietf.org", parent=Group.objects.get(acronym="farfut"))
|
||||
for r in ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"]):
|
||||
ReviewTeamResult.objects.create(team=team, result=r)
|
||||
|
||||
p = Person.objects.get(user__username="plain")
|
||||
email = p.email_set.first()
|
||||
Role.objects.create(name_id="reviewer", person=p, email=email, group=team)
|
||||
ReviewerSettings.objects.create(team=team, person=p, frequency=14, skip_next=0)
|
||||
|
||||
review_req = ReviewRequest.objects.create(
|
||||
doc=doc,
|
||||
team=team,
|
||||
type_id="early",
|
||||
deadline=datetime.datetime.now() + datetime.timedelta(days=20),
|
||||
state_id="accepted",
|
||||
requested_by=p,
|
||||
reviewer=email,
|
||||
)
|
||||
|
||||
p = Person.objects.get(user__username="marschairman")
|
||||
Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team)
|
||||
|
||||
p = Person.objects.get(user__username="secretary")
|
||||
Role.objects.create(name_id="secr", person=p, email=p.email_set.first(), group=team)
|
||||
|
||||
return review_req
|
||||
|
||||
|
|
|
@ -255,7 +255,7 @@ def canonicalize_sitemap(s):
|
|||
|
||||
def login_testing_unauthorized(test_case, username, url, password=None):
|
||||
r = test_case.client.get(url)
|
||||
test_case.assertTrue(r.status_code in (302, 403))
|
||||
test_case.assertIn(r.status_code, (302, 403))
|
||||
if r.status_code == 302:
|
||||
test_case.assertTrue("/accounts/login" in r['Location'])
|
||||
if not password:
|
||||
|
@ -272,6 +272,17 @@ def unicontent(r):
|
|||
encoding = 'utf-8'
|
||||
return r.content.decode(encoding)
|
||||
|
||||
def reload_db_objects(*objects):
|
||||
"""Rerequest the given arguments from the database so they're refreshed, to be used like
|
||||
|
||||
foo, bar = reload_objects(foo, bar)"""
|
||||
|
||||
t = tuple(o.__class__.objects.get(pk=o.pk) for o in objects)
|
||||
if len(objects) == 1:
|
||||
return t[0]
|
||||
else:
|
||||
return t
|
||||
|
||||
class ReverseLazyTest(django.test.TestCase):
|
||||
def test_redirect_with_lazy_reverse(self):
|
||||
response = self.client.get('/ipr/update/')
|
||||
|
|
|
@ -18,3 +18,15 @@ def xslugify(value):
|
|||
value = re.sub('[^\w\s/-]', '', value).strip().lower()
|
||||
return mark_safe(re.sub('[-\s/]+', '-', value))
|
||||
xslugify = allow_lazy(xslugify, six.text_type)
|
||||
|
||||
def strip_prefix(text, prefix):
|
||||
if text.startswith(prefix):
|
||||
return text[len(prefix):]
|
||||
else:
|
||||
return text
|
||||
|
||||
def strip_suffix(text, suffix):
|
||||
if text.endswith(suffix):
|
||||
return text[:-len(suffix)]
|
||||
else:
|
||||
return text
|
||||
|
|
|
@ -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