From 7cbe36fb6248e7cbafe5aadf292e5f84c924ebfd Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 14 Jun 2016 11:28:53 +0000 Subject: [PATCH] Implement completing a review with tests. One can currently enter/upload content or retrieve it from an IETF mailarch archive through integrated searching support. Support for partial completion. - Legacy-Id: 11360 --- ietf/doc/tests_review.py | 290 +++- ietf/doc/urls_review.py | 2 + ietf/doc/utils.py | 2 +- ietf/doc/views_review.py | 284 +++- ietf/name/fixtures/names.json | 1266 +++++++++-------- .../0012_insert_review_name_data.py | 22 +- ietf/review/mailarch.py | 95 ++ ietf/review/resources.py | 65 + ietf/review/utils.py | 14 +- ietf/settings.py | 1 + ietf/static/ietf/css/ietf.css | 13 + ietf/static/ietf/js/complete-review.js | 131 ++ ietf/templates/doc/document_draft.html | 2 +- ietf/templates/doc/mail/completed_review.txt | 6 + .../doc/mail/partially_completed_review.txt | 6 + .../doc/mail/reviewer_assignment_rejected.txt | 6 + .../templates/doc/review/complete_review.html | 81 ++ ietf/templates/doc/review/review_request.html | 74 +- ietf/utils/text.py | 11 + ietf/utils/textupload.py | 12 +- 20 files changed, 1695 insertions(+), 688 deletions(-) create mode 100644 ietf/review/mailarch.py create mode 100644 ietf/review/resources.py create mode 100644 ietf/static/ietf/js/complete-review.js create mode 100644 ietf/templates/doc/mail/completed_review.txt create mode 100644 ietf/templates/doc/mail/partially_completed_review.txt create mode 100644 ietf/templates/doc/mail/reviewer_assignment_rejected.txt create mode 100644 ietf/templates/doc/review/complete_review.html create mode 100644 ietf/utils/text.py diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index b5d6787e7..e937bd27e 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -1,14 +1,19 @@ # -*- coding: utf-8 -*- -import datetime +import datetime, os, shutil, json +import tarfile, tempfile, mailbox +import email.mime.multipart, email.mime.text, email.utils +from StringIO import StringIO from django.core.urlresolvers import reverse as urlreverse +from django.conf import settings from pyquery import PyQuery import debug # pyflakes:ignore from ietf.review.models import ReviewRequest, Reviewer +import ietf.review.mailarch from ietf.person.models import Person from ietf.group.models import Group, Role from ietf.name.models import ReviewResultName, ReviewRequestStateName @@ -17,7 +22,6 @@ from ietf.utils.test_data import make_test_data from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects from ietf.utils.mail import outbox, empty_outbox - def make_review_data(doc): team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team") team.reviewresultname_set.add(ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"])) @@ -39,9 +43,28 @@ def make_review_data(doc): p = Person.objects.get(user__username="marschairman") role = Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team) + p = Person.objects.get(user__username="secretary") + role = Role.objects.create(name_id="secretary", person=p, email=p.email_set.first(), group=team) + return review_req class ReviewTests(TestCase): + def setUp(self): + self.review_dir = os.path.abspath("tmp-review-dir") + if not os.path.exists(self.review_dir): + os.mkdir(self.review_dir) + + self.old_document_path_pattern = settings.DOCUMENT_PATH_PATTERN + settings.DOCUMENT_PATH_PATTERN = self.review_dir + "/{doc.type_id}/" + + self.review_subdir = os.path.join(self.review_dir, "review") + if not os.path.exists(self.review_subdir): + os.mkdir(self.review_subdir) + + def tearDown(self): + shutil.rmtree(self.review_dir) + settings.DOCUMENT_PATH_PATTERN = self.old_document_path_pattern + def test_request_review(self): doc = make_test_data() review_req = make_review_data(doc) @@ -229,3 +252,266 @@ class ReviewTests(TestCase): self.assertEqual(len(outbox), 1) self.assertTrue("Test message" in unicode(outbox[0])) + def make_test_mbox_tarball(self, review_req): + mbox_path = os.path.join(self.review_dir, "testmbox.tar.gz") + with tarfile.open(mbox_path, "w:gz") as tar: + with tempfile.NamedTemporaryFile(dir=self.review_dir, suffix=".mbox") as tmp: + mbox = mailbox.mbox(tmp.name) + + # plain text + msg = email.mime.text.MIMEText("Hello,\n\nI have reviewed the document and did not find any problems.\n\nJohn Doe") + msg["From"] = "johndoe@example.com" + msg["To"] = review_req.team.list_email + msg["Subject"] = "Review of {}-01".format(review_req.doc.name) + msg["Message-ID"] = email.utils.make_msgid() + msg["Archived-At"] = "" + 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"] = "" + + msg.attach(email.mime.text.MIMEText("Hi!,\r\nLooks OK!\r\n-John", "plain")) + msg.attach(email.mime.text.MIMEText("

Hi!,

Looks OK!

-John

", "html")) + mbox.add(msg) + + tmp.flush() + + tar.add(os.path.relpath(tmp.name)) + + return mbox_path + + def test_search_mail_archive(self): + doc = make_test_data() + review_req = make_review_data(doc) + review_req.state = ReviewRequestStateName.objects.get(slug="accepted") + review_req.save() + review_req.team.list_email = "{}@ietf.org".format(review_req.team.acronym) + review_req.team.save() + + # test URL construction + query_urls = ietf.review.mailarch.construct_query_urls(review_req) + self.assertTrue(review_req.doc.name in query_urls["query_data_url"]) + + # test parsing + mbox_path = self.make_test_mbox_tarball(review_req) + + try: + # mock URL generator and point it to local file - for this + # to work, the module (and not the function) must be + # imported in the view + real_fn = ietf.review.mailarch.construct_query_urls + ietf.review.mailarch.construct_query_urls = lambda review_req, query=None: { "query_data_url": "file://" + os.path.abspath(mbox_path) } + + url = urlreverse('ietf.doc.views_review.search_mail_archive', kwargs={ "name": doc.name, "request_id": review_req.pk }) + login_testing_unauthorized(self, "secretary", url) + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + messages = json.loads(r.content)["messages"] + self.assertEqual(len(messages), 2) + + self.assertEqual(messages[0]["url"], "https://www.example.com/testmessage") + self.assertTrue("John Doe" in messages[0]["content"]) + self.assertEqual(messages[0]["subject"], "Review of {}-01".format(review_req.doc.name)) + + self.assertEqual(messages[1]["url"], "https://www.example.com/testmessage2") + self.assertTrue("Looks OK" in messages[1]["content"]) + self.assertTrue("" not in messages[1]["content"]) + self.assertEqual(messages[1]["subject"], "Review of {}".format(review_req.doc.name)) + finally: + ietf.review.mailarch.construct_query_urls = real_fn + + def setup_complete_review_test(self): + doc = make_test_data() + review_req = make_review_data(doc) + review_req.state = ReviewRequestStateName.objects.get(slug="accepted") + review_req.save() + review_req.team.list_email = "{}@ietf.org".format(review_req.team.acronym) + for r in ReviewResultName.objects.filter(slug__in=("issues", "ready")): + review_req.team.reviewresultname_set.add(r) + review_req.team.save() + + url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": doc.name, "request_id": review_req.pk }) + + return review_req, url + + def test_complete_review_upload_content(self): + review_req, url = self.setup_complete_review_test() + + login_testing_unauthorized(self, review_req.reviewer.person.user.username, url) + + # get + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + # faulty post + r = self.client.post(url, data={ + "result": "ready", + "state": "completed", + "reviewed_rev": "abc", + "review_submission": "upload", + "review_content": "", + "review_url": "", + "review_file": "", + }) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertTrue(q("[name=reviewed_rev]").closest(".form-group").filter(".has-error")) + self.assertTrue(q("[name=review_file]").closest(".form-group").filter(".has-error")) + + # complete by uploading file + empty_outbox() + + test_file = StringIO("This is a review\nwith two lines") + test_file.name = "unnamed" + + r = self.client.post(url, data={ + "result": ReviewResultName.objects.get(teams=review_req.team, slug="ready").pk, + "state": ReviewRequestStateName.objects.get(slug="completed").pk, + "reviewed_rev": review_req.doc.rev, + "review_submission": "upload", + "review_content": "", + "review_url": "", + "review_file": test_file, + }) + self.assertEqual(r.status_code, 302) + + review_req = reload_db_objects(review_req) + self.assertEqual(review_req.state_id, "completed") + self.assertEqual(review_req.result_id, "ready") + self.assertEqual(review_req.reviewed_rev, review_req.doc.rev) + self.assertTrue(review_req.team.acronym.lower() in review_req.review.name) + self.assertTrue(review_req.doc.rev in review_req.review.name) + + with open(os.path.join(self.review_subdir, review_req.review.name + "-" + review_req.review.rev + ".txt")) as f: + self.assertEqual(f.read(), "This is a review\nwith two lines") + + self.assertEqual(len(outbox), 1) + self.assertTrue(review_req.team.list_email in outbox[0]["To"]) + self.assertTrue("This is a review" in unicode(outbox[0])) + + self.assertTrue(settings.MAILING_LIST_ARCHIVE_URL in review_req.review.external_url) + + def test_complete_review_enter_content(self): + review_req, url = self.setup_complete_review_test() + + login_testing_unauthorized(self, review_req.reviewer.person.user.username, url) + + # complete by uploading file + empty_outbox() + + r = self.client.post(url, data={ + "result": ReviewResultName.objects.get(teams=review_req.team, slug="ready").pk, + "state": ReviewRequestStateName.objects.get(slug="completed").pk, + "reviewed_rev": review_req.doc.rev, + "review_submission": "enter", + "review_content": "This is a review\nwith two lines", + "review_url": "", + "review_file": "", + }) + self.assertEqual(r.status_code, 302) + + review_req = reload_db_objects(review_req) + self.assertEqual(review_req.state_id, "completed") + + with open(os.path.join(self.review_subdir, review_req.review.name + "-" + review_req.review.rev + ".txt")) as f: + self.assertEqual(f.read(), "This is a review\nwith two lines") + + self.assertEqual(len(outbox), 1) + self.assertTrue(review_req.team.list_email in outbox[0]["To"]) + self.assertTrue("This is a review" in unicode(outbox[0])) + + self.assertTrue(settings.MAILING_LIST_ARCHIVE_URL in review_req.review.external_url) + + def test_complete_review_link_to_mailing_list(self): + review_req, url = self.setup_complete_review_test() + + login_testing_unauthorized(self, review_req.reviewer.person.user.username, url) + + # complete by uploading file + empty_outbox() + + r = self.client.post(url, data={ + "result": ReviewResultName.objects.get(teams=review_req.team, slug="ready").pk, + "state": ReviewRequestStateName.objects.get(slug="completed").pk, + "reviewed_rev": review_req.doc.rev, + "review_submission": "link", + "review_content": "This is a review\nwith two lines", + "review_url": "http://example.com/testreview/", + "review_file": "", + }) + self.assertEqual(r.status_code, 302) + + review_req = reload_db_objects(review_req) + self.assertEqual(review_req.state_id, "completed") + + with open(os.path.join(self.review_subdir, review_req.review.name + "-" + review_req.review.rev + ".txt")) as f: + self.assertEqual(f.read(), "This is a review\nwith two lines") + + self.assertEqual(len(outbox), 0) + self.assertTrue("http://example.com" in review_req.review.external_url) + + def test_partially_complete_review(self): + review_req, url = self.setup_complete_review_test() + + login_testing_unauthorized(self, review_req.reviewer.person.user.username, url) + + # partially complete + empty_outbox() + + r = self.client.post(url, data={ + "result": ReviewResultName.objects.get(teams=review_req.team, slug="ready").pk, + "state": ReviewRequestStateName.objects.get(slug="part-completed").pk, + "reviewed_rev": review_req.doc.rev, + "review_submission": "enter", + "review_content": "This is a review\nwith two lines", + }) + self.assertEqual(r.status_code, 302) + + review_req = reload_db_objects(review_req) + self.assertEqual(review_req.state_id, "part-completed") + self.assertTrue(review_req.doc.rev in review_req.review.name) + + self.assertEqual(len(outbox), 2) + self.assertTrue("secretary" in outbox[0]["To"]) + self.assertTrue("partially" in outbox[0]["Subject"].lower()) + self.assertTrue("new review request" in unicode(outbox[0])) + + self.assertTrue(review_req.team.list_email in outbox[1]["To"]) + self.assertTrue("partial review" in outbox[1]["Subject"].lower()) + self.assertTrue("This is a review" in unicode(outbox[1])) + + first_review = review_req.review + first_reviewer = review_req.reviewer + + + # complete + review_req = ReviewRequest.objects.get(state="requested", doc=review_req.doc, team=review_req.team) + self.assertEqual(review_req.reviewer, None) + review_req.reviewer = first_reviewer # same reviewer, so we can test uniquification + review_req.save() + + url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": review_req.doc.name, "request_id": review_req.pk }) + + r = self.client.post(url, data={ + "result": ReviewResultName.objects.get(teams=review_req.team, slug="ready").pk, + "state": ReviewRequestStateName.objects.get(slug="completed").pk, + "reviewed_rev": review_req.doc.rev, + "review_submission": "enter", + "review_content": "This is another review\nwith\nthree lines", + }) + self.assertEqual(r.status_code, 302) + + review_req = reload_db_objects(review_req) + self.assertEqual(review_req.state_id, "completed") + self.assertTrue(review_req.doc.rev in review_req.review.name) + second_review = review_req.review + self.assertTrue(first_review.name != second_review.name) + self.assertTrue(second_review.name.endswith("-2")) # uniquified diff --git a/ietf/doc/urls_review.py b/ietf/doc/urls_review.py index 527833c0a..89be3f732 100644 --- a/ietf/doc/urls_review.py +++ b/ietf/doc/urls_review.py @@ -7,5 +7,7 @@ urlpatterns = patterns('', url(r'^(?P[0-9]+)/withdraw/$', views_review.withdraw_request), url(r'^(?P[0-9]+)/assignreviewer/$', views_review.assign_reviewer), url(r'^(?P[0-9]+)/rejectreviewerassignment/$', views_review.reject_reviewer_assignment), + url(r'^(?P[0-9]+)/complete/$', views_review.complete_review), + url(r'^(?P[0-9]+)/searchmailarchive/$', views_review.search_mail_archive), ) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 500b283bf..5d7f02f93 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -16,7 +16,7 @@ from ietf.doc.models import DocEvent, ConsensusDocEvent, BallotDocEvent, NewRevi from ietf.doc.models import save_document_in_history from ietf.name.models import DocReminderTypeName, DocRelationshipName from ietf.group.models import Role -from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream +from ietf.ietfauth.utils import has_role from ietf.utils import draft, markup_txt from ietf.utils.mail import send_mail from ietf.mailtrigger.utils import gather_address_lists diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 2c618a8bd..f5c3226b8 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -1,19 +1,36 @@ -import datetime +import datetime, os, email.utils -from django.http import HttpResponseForbidden +from django.contrib.sites.models import Site +from django.http import HttpResponseForbidden, JsonResponse from django.shortcuts import render, get_object_or_404, redirect from django import forms from django.contrib.auth.decorators import login_required +from django.utils.html import mark_safe +from django.core.exceptions import ValidationError +from django.template.loader import render_to_string -from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent +from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person -from ietf.name.models import ReviewRequestStateName +from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName from ietf.group.models import Role from ietf.review.models import ReviewRequest from ietf.review.utils import (active_review_teams, assign_review_request_to_reviewer, can_request_review_of_doc, can_manage_review_requests_for_team, - email_about_review_request) + email_about_review_request, make_new_review_request_from_existing) +from ietf.review import mailarch from ietf.utils.fields import DatepickerDateField +from ietf.utils.text import skip_prefix +from ietf.utils.textupload import get_cleaned_text_file_content +from ietf.utils.mail import send_mail + +def clean_doc_revision(doc, rev): + if rev: + rev = rev.rjust(2, "0") + + if not NewRevisionDocEvent.objects.filter(doc=doc, rev=rev).exists(): + raise forms.ValidationError("Could not find revision \"{}\" of the document.".format(rev)) + + return rev class RequestReviewForm(forms.ModelForm): deadline_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" }) @@ -47,14 +64,7 @@ class RequestReviewForm(forms.ModelForm): return v def clean_requested_rev(self): - rev = self.cleaned_data.get("requested_rev") - if rev: - rev = rev.rjust(2, "0") - - if not NewRevisionDocEvent.objects.filter(doc=self.doc, rev=rev).exists(): - raise forms.ValidationError("Could not find revision '{}' of the document.".format(rev)) - - return rev + return clean_doc_revision(self.doc, self.cleaned_data.get("requested_rev")) def clean(self): deadline_date = self.cleaned_data.get('deadline_date') @@ -114,16 +124,20 @@ def review_request(request, name, request_id): or can_manage_request)) can_assign_reviewer = (review_req.state_id in ["requested", "accepted"] - and is_authorized_in_doc_stream(request.user, doc)) + and can_manage_request) can_accept_reviewer_assignment = (review_req.state_id == "requested" - and review_req.reviewer_id is not None + and review_req.reviewer and (is_reviewer or can_manage_request)) can_reject_reviewer_assignment = (review_req.state_id in ["requested", "accepted"] - and review_req.reviewer_id is not None + and review_req.reviewer and (is_reviewer or can_manage_request)) + can_complete_review = (review_req.state_id in ["requested", "accepted"] + and review_req.reviewer + and (is_reviewer or can_manage_request)) + if request.method == "POST" and request.POST.get("action") == "accept" and can_accept_reviewer_assignment: review_req.state = ReviewRequestStateName.objects.get(slug="accepted") review_req.save() @@ -137,8 +151,10 @@ def review_request(request, name, request_id): 'can_reject_reviewer_assignment': can_reject_reviewer_assignment, 'can_assign_reviewer': can_assign_reviewer, 'can_accept_reviewer_assignment': can_accept_reviewer_assignment, + 'can_complete_review': can_complete_review, }) +@login_required def withdraw_request(request, name, request_id): doc = get_object_or_404(Document, name=name) review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"]) @@ -191,6 +207,7 @@ class AssignReviewerForm(forms.Form): if review_req.reviewer: f.initial = review_req.reviewer_id +@login_required def assign_reviewer(request, name, request_id): doc = get_object_or_404(Document, name=name) review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"]) @@ -217,8 +234,9 @@ def assign_reviewer(request, name, request_id): }) class RejectReviewerAssignmentForm(forms.Form): - message_to_secretary = forms.CharField(widget=forms.Textarea, required=False, help_text="Optional explanation of rejection, will be emailed to team secretary") + message_to_secretary = forms.CharField(widget=forms.Textarea, required=False, help_text="Optional explanation of rejection, will be emailed to team secretary if filled in") +@login_required def reject_reviewer_assignment(request, name, request_id): doc = get_object_or_404(Document, name=name) review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"]) @@ -251,21 +269,13 @@ def reject_reviewer_assignment(request, name, request_id): ) # make a new unassigned review request - new_review_req = ReviewRequest.objects.create( - time=review_req.time, - type=review_req.type, - doc=review_req.doc, - team=review_req.team, - deadline=review_req.deadline, - requested_rev=review_req.requested_rev, - state=ReviewRequestStateName.objects.get(slug="requested"), - ) + new_review_req = make_new_review_request_from_existing(review_req) + new_review_req.save() - msg = u"Reviewer assignment rejected by %s." % request.user.person - - m = form.cleaned_data.get("message_to_secretary") - if m: - msg += "\n\n" + "Explanation:" + "\n" + m + msg = render_to_string("doc/mail/reviewer_assignment_rejected.txt", { + "by": request.user.person, + "message_to_secretary": form.cleaned_data.get("message_to_secretary") + }) email_about_review_request(request, review_req, "Reviewer assignment rejected", msg, by=request.user.person, notify_secretary=True, notify_reviewer=True) @@ -278,3 +288,215 @@ def reject_reviewer_assignment(request, name, request_id): 'review_req': review_req, 'form': form, }) + +class CompleteReviewForm(forms.Form): + state = forms.ModelChoiceField(queryset=ReviewRequestStateName.objects.filter(slug__in=("completed", "part-completed")).order_by("-order"), widget=forms.RadioSelect, initial="completed") + reviewed_rev = forms.CharField(label="Reviewed revision", max_length=4) + result = forms.ModelChoiceField(queryset=ReviewResultName.objects.filter(used=True), widget=forms.RadioSelect, empty_label=None) + ACTIONS = [ + ("enter", "Enter review content (automatically posts to {mailing_list})"), + ("upload", "Upload review content in text file (automatically posts to {mailing_list})"), + ("link", "Link to review message already sent to {mailing_list}"), + ] + review_submission = forms.ChoiceField(choices=ACTIONS, widget=forms.RadioSelect) + + review_url = forms.URLField(label="Link to message", required=False) + review_file = forms.FileField(label="Text file to upload", required=False) + review_content = forms.CharField(widget=forms.Textarea, required=False) + + def __init__(self, review_req, *args, **kwargs): + self.review_req = review_req + + super(CompleteReviewForm, self).__init__(*args, **kwargs) + + doc = self.review_req.doc + + known_revisions = NewRevisionDocEvent.objects.filter(doc=doc).order_by("-time").values_list("rev", flat=True) + + self.fields["state"].choices = [ + (slug, "{} - extra reviewer is to be assigned".format(label)) if slug == "part-completed" else (slug, label) + for slug, label in self.fields["state"].choices + ] + + self.fields["reviewed_rev"].help_text = mark_safe( + " ".join("{}".format(r) + for r in known_revisions)) + + self.fields["result"].queryset = self.fields["result"].queryset.filter(teams=review_req.team) + self.fields["review_submission"].choices = [ + (k, label.format(mailing_list=review_req.team.list_email or "[error: team has no mailing list set]")) + for k, label in self.fields["review_submission"].choices + ] + + def clean_reviewed_rev(self): + return clean_doc_revision(self.review_req.doc, self.cleaned_data.get("reviewed_rev")) + + def clean_review_content(self): + return self.cleaned_data["review_content"].replace("\r", "") + + def clean_review_file(self): + return get_cleaned_text_file_content(self.cleaned_data["review_file"]) + + def clean(self): + def require_field(f): + if not self.cleaned_data.get(f): + self.add_error(f, ValidationError("You must fill in this field.")) + + submission_method = self.cleaned_data.get("review_submission") + if submission_method == "enter": + require_field("review_content") + elif submission_method == "upload": + require_field("review_file") + elif submission_method == "link": + require_field("review_url") + require_field("review_content") + +@login_required +def complete_review(request, name, request_id): + doc = get_object_or_404(Document, name=name) + review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"]) + + if not review_req.reviewer: + return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) + + is_reviewer = user_is_person(request.user, review_req.reviewer.person) + can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) + + if not (is_reviewer or can_manage_request): + return HttpResponseForbidden("You do not have permission to perform this action") + + if request.method == "POST": + form = CompleteReviewForm(review_req, request.POST, request.FILES) + if form.is_valid(): + review_submission = form.cleaned_data['review_submission'] + + # create review doc + for i in range(1, 100): + name_components = [ + "review", + review_req.team.acronym, + review_req.type.slug, + review_req.reviewer.person.ascii_parts()[3], + skip_prefix(review_req.doc.name, "draft-"), + form.cleaned_data["reviewed_rev"], + ] + if i > 1: + name_components.append(str(i)) + + name = "-".join(c for c in name_components if c).lower() + if not Document.objects.filter(name=name).exists(): + review = Document.objects.create(name=name) + break + + review.type = DocTypeName.objects.get(slug="review") + review.rev = "00" + review.title = "Review of {}".format(review_req.doc.name) + review.group = review_req.team + if review_submission == "link": + review.external_url = form.cleaned_data['review_url'] + review.save() + review.set_state(State.objects.get(type="review", slug="active")) + + NewRevisionDocEvent.objects.create( + type="new_revision", + doc=review, + by=request.user.person, + rev=doc.rev, + desc='New revision available', + time=doc.time, + ) + + # save file on disk + if review_submission == "upload": + encoded_content = form.cleaned_data['review_file'] + else: + encoded_content = form.cleaned_data['review_content'].encode("utf-8") + + filename = os.path.join(review.get_file_path(), '{}-{}.txt'.format(review.name, review.rev)) + with open(filename, 'wb') as destination: + destination.write(encoded_content) + + # close review request + review_req.state = form.cleaned_data["state"] + review_req.reviewed_rev = form.cleaned_data["reviewed_rev"] + review_req.result = form.cleaned_data["result"] + review_req.review = review + review_req.save() + + DocEvent.objects.create( + type="changed_review_request", + doc=review_req.doc, + by=request.user.person, + desc="Request for {} review by {} {}".format( + review_req.type.name, + review_req.team.acronym.upper(), + review_req.state.name, + ), + ) + + if review_req.state_id == "part-completed": + new_review_req = make_new_review_request_from_existing(review_req) + new_review_req.save() + + subject = "Review of {}-{} completed partially".format(review_req.doc.name, review_req.reviewed_rev) + + msg = render_to_string("doc/mail/partially_completed_review.txt", { + "domain": Site.objects.get_current().domain, + "by": request.user.person, + "new_review_req": new_review_req, + }) + + email_about_review_request(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False) + + if review_submission != "link" and review_req.team.list_email: + # email the review + subject = "{} of {}-{}".format("Partial review" if review_req.state_id == "part-completed" else "Review", review_req.doc.name, review_req.reviewed_rev) + msg = send_mail(request, [(review_req.team.name, review_req.team.list_email)], None, + subject, + "doc/mail/completed_review.txt", { + "review_req": review_req, + "content": encoded_content.decode("utf-8"), + }) + + list_name = mailarch.list_name_from_email(review_req.team.list_email) + if list_name: + review.external_url = mailarch.construct_message_url(list_name, email.utils.unquote(msg["Message-ID"])) + review.save() + + return redirect("doc_view", name=review_req.review.name) + else: + form = CompleteReviewForm(review_req) + + mail_archive_query_urls = mailarch.construct_query_urls(review_req) + + return render(request, 'doc/review/complete_review.html', { + 'doc': doc, + 'review_req': review_req, + 'form': form, + 'mail_archive_query_urls': mail_archive_query_urls, + }) + +def search_mail_archive(request, name, request_id): + #doc = get_object_or_404(Document, name=name) + review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"]) + + is_reviewer = user_is_person(request.user, review_req.reviewer.person) + can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) + + if not (is_reviewer or can_manage_request): + return HttpResponseForbidden("You do not have permission to perform this action") + + res = mailarch.construct_query_urls(review_req, query=request.GET.get("query")) + if not res: + return JsonResponse({ "error": "Couldn't do lookup in mail archive - don't know where to look"}) + + MAX_RESULTS = 30 + + try: + res["messages"] = mailarch.retrieve_messages(res["query_data_url"])[:MAX_RESULTS] + except Exception as e: + res["error"] = "Retrieval from mail archive failed: {}".format(unicode(e)) + # raise # useful when debugging + + return JsonResponse(res) + diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 5d9e8def4..72aeb7b2f 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -21,17 +21,6 @@ "model": "name.ballotpositionname", "pk": "noobj" }, -{ - "fields": { - "order": 3, - "used": true, - "name": "Discuss", - "blocking": true, - "desc": "" - }, - "model": "name.ballotpositionname", - "pk": "discuss" -}, { "fields": { "order": 3, @@ -43,6 +32,17 @@ "model": "name.ballotpositionname", "pk": "block" }, +{ + "fields": { + "order": 3, + "used": true, + "name": "Discuss", + "blocking": true, + "desc": "" + }, + "model": "name.ballotpositionname", + "pk": "discuss" +}, { "fields": { "order": 4, @@ -124,11 +124,11 @@ "fields": { "order": 0, "used": true, - "name": "reStructuredText", + "name": "Django", "desc": "" }, "model": "name.dbtemplatetypename", - "pk": "rst" + "pk": "django" }, { "fields": { @@ -144,44 +144,11 @@ "fields": { "order": 0, "used": true, - "name": "Django", + "name": "reStructuredText", "desc": "" }, "model": "name.dbtemplatetypename", - "pk": "django" -}, -{ - "fields": { - "order": 0, - "revname": "Obsoleted by", - "used": true, - "name": "Obsoletes", - "desc": "" - }, - "model": "name.docrelationshipname", - "pk": "obs" -}, -{ - "fields": { - "order": 0, - "revname": "Updated by", - "used": true, - "name": "Updates", - "desc": "" - }, - "model": "name.docrelationshipname", - "pk": "updates" -}, -{ - "fields": { - "order": 0, - "revname": "Replaced by", - "used": true, - "name": "Replaces", - "desc": "" - }, - "model": "name.docrelationshipname", - "pk": "replaces" + "pk": "rst" }, { "fields": { @@ -194,28 +161,6 @@ "model": "name.docrelationshipname", "pk": "conflrev" }, -{ - "fields": { - "order": 0, - "revname": "Normatively Referenced by", - "used": true, - "name": "Normative Reference", - "desc": "Normative Reference" - }, - "model": "name.docrelationshipname", - "pk": "refnorm" -}, -{ - "fields": { - "order": 0, - "revname": "Referenced by", - "used": true, - "name": "Reference", - "desc": "A reference found in a document which does not have split normative/informative reference sections." - }, - "model": "name.docrelationshipname", - "pk": "refold" -}, { "fields": { "order": 0, @@ -227,50 +172,6 @@ "model": "name.docrelationshipname", "pk": "refinfo" }, -{ - "fields": { - "order": 0, - "revname": "Moved to Proposed Standard by", - "used": true, - "name": "Moves to Proposed Standard", - "desc": "" - }, - "model": "name.docrelationshipname", - "pk": "tops" -}, -{ - "fields": { - "order": 0, - "revname": "Moved to Internet Standard by", - "used": true, - "name": "Moves to Internet Standard", - "desc": "" - }, - "model": "name.docrelationshipname", - "pk": "tois" -}, -{ - "fields": { - "order": 0, - "revname": "Moved to Historic by", - "used": true, - "name": "Moves to Historic", - "desc": "" - }, - "model": "name.docrelationshipname", - "pk": "tohist" -}, -{ - "fields": { - "order": 0, - "revname": "Moved to Informational by", - "used": true, - "name": "Moves to Informational", - "desc": "" - }, - "model": "name.docrelationshipname", - "pk": "toinf" -}, { "fields": { "order": 0, @@ -293,6 +194,72 @@ "model": "name.docrelationshipname", "pk": "toexp" }, +{ + "fields": { + "order": 0, + "revname": "Moved to Historic by", + "used": true, + "name": "Moves to Historic", + "desc": "" + }, + "model": "name.docrelationshipname", + "pk": "tohist" +}, +{ + "fields": { + "order": 0, + "revname": "Moved to Informational by", + "used": true, + "name": "Moves to Informational", + "desc": "" + }, + "model": "name.docrelationshipname", + "pk": "toinf" +}, +{ + "fields": { + "order": 0, + "revname": "Moved to Internet Standard by", + "used": true, + "name": "Moves to Internet Standard", + "desc": "" + }, + "model": "name.docrelationshipname", + "pk": "tois" +}, +{ + "fields": { + "order": 0, + "revname": "Moved to Proposed Standard by", + "used": true, + "name": "Moves to Proposed Standard", + "desc": "" + }, + "model": "name.docrelationshipname", + "pk": "tops" +}, +{ + "fields": { + "order": 0, + "revname": "Normatively Referenced by", + "used": true, + "name": "Normative Reference", + "desc": "Normative Reference" + }, + "model": "name.docrelationshipname", + "pk": "refnorm" +}, +{ + "fields": { + "order": 0, + "revname": "Obsoleted by", + "used": true, + "name": "Obsoletes", + "desc": "" + }, + "model": "name.docrelationshipname", + "pk": "obs" +}, { "fields": { "order": 0, @@ -304,6 +271,39 @@ "model": "name.docrelationshipname", "pk": "possibly-replaces" }, +{ + "fields": { + "order": 0, + "revname": "Referenced by", + "used": true, + "name": "Reference", + "desc": "A reference found in a document which does not have split normative/informative reference sections." + }, + "model": "name.docrelationshipname", + "pk": "refold" +}, +{ + "fields": { + "order": 0, + "revname": "Replaced by", + "used": true, + "name": "Replaces", + "desc": "" + }, + "model": "name.docrelationshipname", + "pk": "replaces" +}, +{ + "fields": { + "order": 0, + "revname": "Updated by", + "used": true, + "name": "Updates", + "desc": "" + }, + "model": "name.docrelationshipname", + "pk": "updates" +}, { "fields": { "order": 3, @@ -329,31 +329,11 @@ "fields": { "order": 0, "used": true, - "name": "IANA coordination", - "desc": "RFC-Editor/IANA Registration Coordination" + "name": "Approved in minutes", + "desc": "" }, "model": "name.doctagname", - "pk": "iana-crd" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Holding for references", - "desc": "Holding for normative reference" - }, - "model": "name.doctagname", - "pk": "ref" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Missing references", - "desc": "Awaiting missing normative reference" - }, - "model": "name.doctagname", - "pk": "missref" + "pk": "app-min" }, { "fields": { @@ -369,61 +349,11 @@ "fields": { "order": 0, "used": true, - "name": "Review by RFC Editor", - "desc": "" + "name": "Holding for references", + "desc": "Holding for normative reference" }, "model": "name.doctagname", - "pk": "rfc-rev" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Via RFC Editor", - "desc": "" - }, - "model": "name.doctagname", - "pk": "via-rfc" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Approved in minutes", - "desc": "" - }, - "model": "name.doctagname", - "pk": "app-min" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Shepherd Needed", - "desc": "" - }, - "model": "name.doctagname", - "pk": "need-sh" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Waiting for Dependency on Other Document", - "desc": "" - }, - "model": "name.doctagname", - "pk": "w-dep" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "IESG Review Completed", - "desc": "" - }, - "model": "name.doctagname", - "pk": "iesg-com" + "pk": "ref" }, { "fields": { @@ -439,11 +369,31 @@ "fields": { "order": 0, "used": true, - "name": "Revised I-D Needed - Issue raised by WG", + "name": "IANA coordination", + "desc": "RFC-Editor/IANA Registration Coordination" + }, + "model": "name.doctagname", + "pk": "iana-crd" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "IESG Review Completed", "desc": "" }, "model": "name.doctagname", - "pk": "rev-wg" + "pk": "iesg-com" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Missing references", + "desc": "Awaiting missing normative reference" + }, + "model": "name.doctagname", + "pk": "missref" }, { "fields": { @@ -457,13 +407,53 @@ }, { "fields": { - "order": 1, + "order": 0, "used": true, - "name": "Point Raised - writeup needed", - "desc": "IESG discussions on the document have raised some issues that need to be brought to the attention of the authors/WG, but those issues have not been written down yet. (It is common for discussions during a telechat to result in such situations. An AD may raise a possible issue during a telechat and only decide as a result of that discussion whether the issue is worth formally writing up and bringing to the attention of the authors/WG). A document stays in the \"Point Raised - Writeup Needed\" state until *ALL* IESG comments that have been raised have been documented." + "name": "Review by RFC Editor", + "desc": "" }, "model": "name.doctagname", - "pk": "point" + "pk": "rfc-rev" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Revised I-D Needed - Issue raised by WG", + "desc": "" + }, + "model": "name.doctagname", + "pk": "rev-wg" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Shepherd Needed", + "desc": "" + }, + "model": "name.doctagname", + "pk": "need-sh" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Via RFC Editor", + "desc": "" + }, + "model": "name.doctagname", + "pk": "via-rfc" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Waiting for Dependency on Other Document", + "desc": "" + }, + "model": "name.doctagname", + "pk": "w-dep" }, { "fields": { @@ -485,6 +475,16 @@ "model": "name.doctagname", "pk": "need-ed" }, +{ + "fields": { + "order": 1, + "used": true, + "name": "Point Raised - writeup needed", + "desc": "IESG discussions on the document have raised some issues that need to be brought to the attention of the authors/WG, but those issues have not been written down yet. (It is common for discussions during a telechat to result in such situations. An AD may raise a possible issue during a telechat and only decide as a result of that discussion whether the issue is worth formally writing up and bringing to the attention of the authors/WG). A document stays in the \"Point Raised - Writeup Needed\" state until *ALL* IESG comments that have been raised have been documented." + }, + "model": "name.doctagname", + "pk": "point" +}, { "fields": { "order": 2, @@ -515,16 +515,6 @@ "model": "name.doctagname", "pk": "w-part" }, -{ - "fields": { - "order": 3, - "used": true, - "name": "External Party", - "desc": "The document is awaiting review or input from an external party (i.e, someone other than the shepherding AD, the authors, or the WG). See the \"note\" field for more details on who has the action." - }, - "model": "name.doctagname", - "pk": "extpty" -}, { "fields": { "order": 3, @@ -545,6 +535,16 @@ "model": "name.doctagname", "pk": "w-review" }, +{ + "fields": { + "order": 3, + "used": true, + "name": "External Party", + "desc": "The document is awaiting review or input from an external party (i.e, someone other than the shepherding AD, the authors, or the WG). See the \"note\" field for more details on who has the action." + }, + "model": "name.doctagname", + "pk": "extpty" +}, { "fields": { "order": 4, @@ -645,17 +645,6 @@ "model": "name.doctagname", "pk": "other" }, -{ - "fields": { - "order": 0, - "prefix": "charter", - "used": true, - "name": "Charter", - "desc": "" - }, - "model": "name.doctypename", - "pk": "charter" -}, { "fields": { "order": 0, @@ -670,46 +659,24 @@ { "fields": { "order": 0, - "prefix": "minutes", + "prefix": "bluesheets", "used": true, - "name": "Minutes", + "name": "Bluesheets", "desc": "" }, "model": "name.doctypename", - "pk": "minutes" + "pk": "bluesheets" }, { "fields": { "order": 0, - "prefix": "slides", + "prefix": "charter", "used": true, - "name": "Slides", + "name": "Charter", "desc": "" }, "model": "name.doctypename", - "pk": "slides" -}, -{ - "fields": { - "order": 0, - "prefix": "draft", - "used": true, - "name": "Draft", - "desc": "" - }, - "model": "name.doctypename", - "pk": "draft" -}, -{ - "fields": { - "order": 0, - "prefix": "liai-att", - "used": true, - "name": "Liaison Attachment", - "desc": "" - }, - "model": "name.doctypename", - "pk": "liai-att" + "pk": "charter" }, { "fields": { @@ -725,24 +692,13 @@ { "fields": { "order": 0, - "prefix": "status-change", + "prefix": "draft", "used": true, - "name": "Status Change", + "name": "Draft", "desc": "" }, "model": "name.doctypename", - "pk": "statchg" -}, -{ - "fields": { - "order": 0, - "prefix": "", - "used": false, - "name": "Shepherd's writeup", - "desc": "" - }, - "model": "name.doctypename", - "pk": "shepwrit" + "pk": "draft" }, { "fields": { @@ -755,6 +711,28 @@ "model": "name.doctypename", "pk": "liaison" }, +{ + "fields": { + "order": 0, + "prefix": "liai-att", + "used": true, + "name": "Liaison Attachment", + "desc": "" + }, + "model": "name.doctypename", + "pk": "liai-att" +}, +{ + "fields": { + "order": 0, + "prefix": "minutes", + "used": true, + "name": "Minutes", + "desc": "" + }, + "model": "name.doctypename", + "pk": "minutes" +}, { "fields": { "order": 0, @@ -769,13 +747,46 @@ { "fields": { "order": 0, - "prefix": "bluesheets", + "prefix": "", "used": true, - "name": "Bluesheets", + "name": "Review", "desc": "" }, "model": "name.doctypename", - "pk": "bluesheets" + "pk": "review" +}, +{ + "fields": { + "order": 0, + "prefix": "", + "used": false, + "name": "Shepherd's writeup", + "desc": "" + }, + "model": "name.doctypename", + "pk": "shepwrit" +}, +{ + "fields": { + "order": 0, + "prefix": "slides", + "used": true, + "name": "Slides", + "desc": "" + }, + "model": "name.doctypename", + "pk": "slides" +}, +{ + "fields": { + "order": 0, + "prefix": "status-change", + "used": true, + "name": "Status Change", + "desc": "" + }, + "model": "name.doctypename", + "pk": "statchg" }, { "fields": { @@ -886,11 +897,11 @@ "fields": { "order": 0, "used": true, - "name": "Questionnaire response", + "name": "Junk", "desc": "" }, "model": "name.feedbacktypename", - "pk": "questio" + "pk": "junk" }, { "fields": { @@ -906,11 +917,11 @@ "fields": { "order": 0, "used": true, - "name": "Junk", + "name": "Questionnaire response", "desc": "" }, "model": "name.feedbacktypename", - "pk": "junk" + "pk": "questio" }, { "fields": { @@ -956,21 +967,11 @@ "fields": { "order": 0, "used": true, - "name": "BOF", - "desc": "" + "name": "Abandonded", + "desc": "Formation of the group (most likely a BoF or Proposed WG) was abandoned" }, "model": "name.groupstatename", - "pk": "bof" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Proposed", - "desc": "" - }, - "model": "name.groupstatename", - "pk": "proposed" + "pk": "abandon" }, { "fields": { @@ -986,41 +987,11 @@ "fields": { "order": 0, "used": true, - "name": "Dormant", + "name": "BOF", "desc": "" }, "model": "name.groupstatename", - "pk": "dormant" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Concluded", - "desc": "" - }, - "model": "name.groupstatename", - "pk": "conclude" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Unknown", - "desc": "" - }, - "model": "name.groupstatename", - "pk": "unknown" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Abandonded", - "desc": "Formation of the group (most likely a BoF or Proposed WG) was abandoned" - }, - "model": "name.groupstatename", - "pk": "abandon" + "pk": "bof" }, { "fields": { @@ -1032,6 +1003,36 @@ "model": "name.groupstatename", "pk": "bof-conc" }, +{ + "fields": { + "order": 0, + "used": true, + "name": "Concluded", + "desc": "" + }, + "model": "name.groupstatename", + "pk": "conclude" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Dormant", + "desc": "" + }, + "model": "name.groupstatename", + "pk": "dormant" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Proposed", + "desc": "" + }, + "model": "name.groupstatename", + "pk": "proposed" +}, { "fields": { "order": 0, @@ -1046,21 +1047,11 @@ "fields": { "order": 0, "used": true, - "name": "IETF", + "name": "Unknown", "desc": "" }, - "model": "name.grouptypename", - "pk": "ietf" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Area", - "desc": "" - }, - "model": "name.grouptypename", - "pk": "area" + "model": "name.groupstatename", + "pk": "unknown" }, { "fields": { @@ -1076,81 +1067,21 @@ "fields": { "order": 0, "used": true, - "name": "WG", - "desc": "Working group" - }, - "model": "name.grouptypename", - "pk": "wg" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "RG", - "desc": "Research group" - }, - "model": "name.grouptypename", - "pk": "rg" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Team", + "name": "Area", "desc": "" }, "model": "name.grouptypename", - "pk": "team" + "pk": "area" }, { "fields": { "order": 0, "used": true, - "name": "Individual", - "desc": "" + "name": "Directorate", + "desc": "In many areas, the Area Directors have formed an advisory group or directorate. These comprise experienced members of the IETF and the technical community represented by the area. The specific name and the details of the role for each group differ from area to area, but the primary intent is that these groups assist the Area Director(s), e.g., with the review of specifications produced in the area." }, "model": "name.grouptypename", - "pk": "individ" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "SDO", - "desc": "Standards organization" - }, - "model": "name.grouptypename", - "pk": "sdo" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "IRTF", - "desc": "" - }, - "model": "name.grouptypename", - "pk": "irtf" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "RFC Editor", - "desc": "" - }, - "model": "name.grouptypename", - "pk": "rfcedtyp" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Nomcom", - "desc": "An IETF/IAB Nominating Committee. Use 'SDO' for external nominating committees." - }, - "model": "name.grouptypename", - "pk": "nomcom" + "pk": "dir" }, { "fields": { @@ -1162,6 +1093,36 @@ "model": "name.grouptypename", "pk": "iab" }, +{ + "fields": { + "order": 0, + "used": true, + "name": "IETF", + "desc": "" + }, + "model": "name.grouptypename", + "pk": "ietf" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Individual", + "desc": "" + }, + "model": "name.grouptypename", + "pk": "individ" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "IRTF", + "desc": "" + }, + "model": "name.grouptypename", + "pk": "irtf" +}, { "fields": { "order": 0, @@ -1176,11 +1137,61 @@ "fields": { "order": 0, "used": true, - "name": "Directorate", - "desc": "In many areas, the Area Directors have formed an advisory group or directorate. These comprise experienced members of the IETF and the technical community represented by the area. The specific name and the details of the role for each group differ from area to area, but the primary intent is that these groups assist the Area Director(s), e.g., with the review of specifications produced in the area." + "name": "Nomcom", + "desc": "An IETF/IAB Nominating Committee. Use 'SDO' for external nominating committees." }, "model": "name.grouptypename", - "pk": "dir" + "pk": "nomcom" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "RFC Editor", + "desc": "" + }, + "model": "name.grouptypename", + "pk": "rfcedtyp" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "RG", + "desc": "Research group" + }, + "model": "name.grouptypename", + "pk": "rg" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "SDO", + "desc": "Standards organization" + }, + "model": "name.grouptypename", + "pk": "sdo" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Team", + "desc": "" + }, + "model": "name.grouptypename", + "pk": "team" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "WG", + "desc": "Working group" + }, + "model": "name.grouptypename", + "pk": "wg" }, { "fields": { @@ -1306,61 +1317,31 @@ "fields": { "order": 0, "used": true, - "name": "Submitted", + "name": "Changed disclosure metadata", "desc": "" }, "model": "name.ipreventtypename", - "pk": "submitted" + "pk": "changed_disclosure" }, { "fields": { "order": 0, "used": true, - "name": "Posted", + "name": "Comment", "desc": "" }, "model": "name.ipreventtypename", - "pk": "posted" + "pk": "comment" }, { "fields": { "order": 0, "used": true, - "name": "Removed", + "name": "Legacy", "desc": "" }, "model": "name.ipreventtypename", - "pk": "removed" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Rejected", - "desc": "" - }, - "model": "name.ipreventtypename", - "pk": "rejected" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Pending", - "desc": "" - }, - "model": "name.ipreventtypename", - "pk": "pending" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Parked", - "desc": "" - }, - "model": "name.ipreventtypename", - "pk": "parked" + "pk": "legacy" }, { "fields": { @@ -1386,11 +1367,31 @@ "fields": { "order": 0, "used": true, - "name": "Comment", + "name": "Parked", "desc": "" }, "model": "name.ipreventtypename", - "pk": "comment" + "pk": "parked" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Pending", + "desc": "" + }, + "model": "name.ipreventtypename", + "pk": "pending" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Posted", + "desc": "" + }, + "model": "name.ipreventtypename", + "pk": "posted" }, { "fields": { @@ -1406,11 +1407,31 @@ "fields": { "order": 0, "used": true, - "name": "Legacy", + "name": "Rejected", "desc": "" }, "model": "name.ipreventtypename", - "pk": "legacy" + "pk": "rejected" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Removed", + "desc": "" + }, + "model": "name.ipreventtypename", + "pk": "removed" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Submitted", + "desc": "" + }, + "model": "name.ipreventtypename", + "pk": "submitted" }, { "fields": { @@ -1422,16 +1443,6 @@ "model": "name.ipreventtypename", "pk": "update_notify" }, -{ - "fields": { - "order": 0, - "used": true, - "name": "Changed disclosure metadata", - "desc": "" - }, - "model": "name.ipreventtypename", - "pk": "changed_disclosure" -}, { "fields": { "order": 0, @@ -1722,16 +1733,6 @@ "model": "name.meetingtypename", "pk": "interim" }, -{ - "fields": { - "order": 0, - "used": true, - "name": "Nominated, pending response", - "desc": "" - }, - "model": "name.nomineepositionstatename", - "pk": "pending" -}, { "fields": { "order": 0, @@ -1752,6 +1753,16 @@ "model": "name.nomineepositionstatename", "pk": "declined" }, +{ + "fields": { + "order": 0, + "used": true, + "name": "Nominated, pending response", + "desc": "" + }, + "model": "name.nomineepositionstatename", + "pk": "pending" +}, { "fields": { "order": 1, @@ -1814,7 +1825,17 @@ }, { "fields": { - "order": 7, + "order": 6, + "used": true, + "name": "Partially Completed", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "part-completed" +}, +{ + "fields": { + "order": 8, "used": true, "name": "Completed", "desc": "" @@ -1827,11 +1848,11 @@ "order": 1, "used": true, "teams": [], - "name": "Almost Ready", + "name": "Serious Issues", "desc": "" }, "model": "name.reviewresultname", - "pk": "almost-ready" + "pk": "serious-issues" }, { "fields": { @@ -1882,11 +1903,11 @@ "order": 6, "used": true, "teams": [], - "name": "Ready", + "name": "Almost Ready", "desc": "" }, "model": "name.reviewresultname", - "pk": "ready" + "pk": "almost-ready" }, { "fields": { @@ -1915,11 +1936,11 @@ "order": 9, "used": true, "teams": [], - "name": "Serious Issues", + "name": "Ready", "desc": "" }, "model": "name.reviewresultname", - "pk": "serious-issues" + "pk": "ready" }, { "fields": { @@ -2001,16 +2022,6 @@ "model": "name.rolename", "pk": "execdir" }, -{ - "fields": { - "order": 3, - "used": true, - "name": "Incoming Area Director", - "desc": "" - }, - "model": "name.rolename", - "pk": "pre-ad" -}, { "fields": { "order": 3, @@ -2023,13 +2034,23 @@ }, { "fields": { - "order": 4, + "order": 3, "used": true, - "name": "Tech Advisor", + "name": "Incoming Area Director", "desc": "" }, "model": "name.rolename", - "pk": "techadv" + "pk": "pre-ad" +}, +{ + "fields": { + "order": 4, + "used": true, + "name": "Advisor", + "desc": "Advisor in a group that has explicit membership, such as the NomCom" + }, + "model": "name.rolename", + "pk": "advisor" }, { "fields": { @@ -2045,21 +2066,11 @@ "fields": { "order": 4, "used": true, - "name": "Advisor", - "desc": "Advisor in a group that has explicit membership, such as the NomCom" - }, - "model": "name.rolename", - "pk": "advisor" -}, -{ - "fields": { - "order": 5, - "used": true, - "name": "Editor", + "name": "Tech Advisor", "desc": "" }, "model": "name.rolename", - "pk": "editor" + "pk": "techadv" }, { "fields": { @@ -2073,13 +2084,13 @@ }, { "fields": { - "order": 6, + "order": 5, "used": true, - "name": "Secretary", + "name": "Editor", "desc": "" }, "model": "name.rolename", - "pk": "secr" + "pk": "editor" }, { "fields": { @@ -2091,6 +2102,16 @@ "model": "name.rolename", "pk": "delegate" }, +{ + "fields": { + "order": 6, + "used": true, + "name": "Secretary", + "desc": "" + }, + "model": "name.rolename", + "pk": "secr" +}, { "fields": { "order": 7, @@ -2155,21 +2176,21 @@ "fields": { "order": 0, "used": true, - "name": "LCD projector", - "desc": "The room will have a computer projector" + "name": "Boardroom Layout", + "desc": "Experimental room setup (boardroom and classroom) subject to availability" }, "model": "name.roomresourcename", - "pk": "project" + "pk": "boardroom" }, { "fields": { "order": 0, "used": true, - "name": "second LCD projector", - "desc": "The room will have a second computer projector" + "name": "LCD projector", + "desc": "The room will have a computer projector" }, "model": "name.roomresourcename", - "pk": "proj2" + "pk": "project" }, { "fields": { @@ -2185,31 +2206,11 @@ "fields": { "order": 0, "used": true, - "name": "Boardroom Layout", - "desc": "Experimental room setup (boardroom and classroom) subject to availability" + "name": "second LCD projector", + "desc": "The room will have a second computer projector" }, "model": "name.roomresourcename", - "pk": "boardroom" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Waiting for Scheduling", - "desc": "" - }, - "model": "name.sessionstatusname", - "pk": "schedw" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Waiting for Approval", - "desc": "" - }, - "model": "name.sessionstatusname", - "pk": "apprw" + "pk": "proj2" }, { "fields": { @@ -2221,16 +2222,6 @@ "model": "name.sessionstatusname", "pk": "appr" }, -{ - "fields": { - "order": 0, - "used": true, - "name": "Scheduled", - "desc": "" - }, - "model": "name.sessionstatusname", - "pk": "sched" -}, { "fields": { "order": 0, @@ -2241,6 +2232,16 @@ "model": "name.sessionstatusname", "pk": "canceled" }, +{ + "fields": { + "order": 0, + "used": true, + "name": "Deleted", + "desc": "" + }, + "model": "name.sessionstatusname", + "pk": "deleted" +}, { "fields": { "order": 0, @@ -2265,21 +2266,41 @@ "fields": { "order": 0, "used": true, - "name": "Deleted", + "name": "Scheduled", "desc": "" }, "model": "name.sessionstatusname", - "pk": "deleted" + "pk": "sched" }, { "fields": { "order": 0, "used": true, - "name": "Internet Standard", + "name": "Waiting for Approval", + "desc": "" + }, + "model": "name.sessionstatusname", + "pk": "apprw" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Waiting for Scheduling", + "desc": "" + }, + "model": "name.sessionstatusname", + "pk": "schedw" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Best Current Practice", "desc": "" }, "model": "name.stdlevelname", - "pk": "std" + "pk": "bcp" }, { "fields": { @@ -2295,11 +2316,21 @@ "fields": { "order": 0, "used": true, - "name": "Proposed Standard", + "name": "Experimental", "desc": "" }, "model": "name.stdlevelname", - "pk": "ps" + "pk": "exp" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Historic", + "desc": "" + }, + "model": "name.stdlevelname", + "pk": "hist" }, { "fields": { @@ -2315,31 +2346,21 @@ "fields": { "order": 0, "used": true, - "name": "Experimental", + "name": "Internet Standard", "desc": "" }, "model": "name.stdlevelname", - "pk": "exp" + "pk": "std" }, { "fields": { "order": 0, "used": true, - "name": "Best Current Practice", + "name": "Proposed Standard", "desc": "" }, "model": "name.stdlevelname", - "pk": "bcp" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Historic", - "desc": "" - }, - "model": "name.stdlevelname", - "pk": "hist" + "pk": "ps" }, { "fields": { @@ -2401,26 +2422,6 @@ "model": "name.streamname", "pk": "legacy" }, -{ - "fields": { - "order": 0, - "used": true, - "name": "Other", - "desc": "" - }, - "model": "name.timeslottypename", - "pk": "other" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Session", - "desc": "" - }, - "model": "name.timeslottypename", - "pk": "session" -}, { "fields": { "order": 0, @@ -2431,46 +2432,6 @@ "model": "name.timeslottypename", "pk": "break" }, -{ - "fields": { - "order": 0, - "used": true, - "name": "Registration", - "desc": "" - }, - "model": "name.timeslottypename", - "pk": "reg" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Plenary", - "desc": "" - }, - "model": "name.timeslottypename", - "pk": "plenary" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Room Unavailable", - "desc": "A room was not booked for the timeslot indicated" - }, - "model": "name.timeslottypename", - "pk": "unavail" -}, -{ - "fields": { - "order": 0, - "used": true, - "name": "Room Reserved", - "desc": "A room has been reserved for use by another body the timeslot indicated" - }, - "model": "name.timeslottypename", - "pk": "reserved" -}, { "fields": { "order": 0, @@ -2491,6 +2452,66 @@ "model": "name.timeslottypename", "pk": "offagenda" }, +{ + "fields": { + "order": 0, + "used": true, + "name": "Other", + "desc": "" + }, + "model": "name.timeslottypename", + "pk": "other" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Plenary", + "desc": "" + }, + "model": "name.timeslottypename", + "pk": "plenary" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Registration", + "desc": "" + }, + "model": "name.timeslottypename", + "pk": "reg" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Room Reserved", + "desc": "A room has been reserved for use by another body the timeslot indicated" + }, + "model": "name.timeslottypename", + "pk": "reserved" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Room Unavailable", + "desc": "A room was not booked for the timeslot indicated" + }, + "model": "name.timeslottypename", + "pk": "unavail" +}, +{ + "fields": { + "order": 0, + "used": true, + "name": "Session", + "desc": "" + }, + "model": "name.timeslottypename", + "pk": "session" +}, { "fields": { "label": "State" @@ -2631,6 +2652,13 @@ "model": "doc.statetype", "pk": "reuse_policy" }, +{ + "fields": { + "label": "Review" + }, + "model": "doc.statetype", + "pk": "review" +}, { "fields": { "used": true, @@ -4378,6 +4406,32 @@ "model": "doc.state", "pk": 142 }, +{ + "fields": { + "used": true, + "name": "Active", + "next_states": [], + "slug": "active", + "type": "review", + "order": 1, + "desc": "" + }, + "model": "doc.state", + "pk": 143 +}, +{ + "fields": { + "used": true, + "name": "Deleted", + "next_states": [], + "slug": "deleted", + "type": "review", + "order": 2, + "desc": "" + }, + "model": "doc.state", + "pk": 144 +}, { "fields": { "used": true, diff --git a/ietf/name/migrations/0012_insert_review_name_data.py b/ietf/name/migrations/0012_insert_review_name_data.py index 1ebbba69f..7cc1a7d52 100644 --- a/ietf/name/migrations/0012_insert_review_name_data.py +++ b/ietf/name/migrations/0012_insert_review_name_data.py @@ -15,25 +15,36 @@ def insert_initial_review_data(apps, schema_editor): ReviewRequestStateName.objects.get_or_create(slug="part-completed", name="Partially Completed", order=6) ReviewRequestStateName.objects.get_or_create(slug="completed", name="Completed", order=8) - ReviewTypeName = apps.get_model("name", "ReviewTypeName") ReviewTypeName.objects.get_or_create(slug="early", name="Early", order=1) ReviewTypeName.objects.get_or_create(slug="lc", name="Last Call", order=2) ReviewTypeName.objects.get_or_create(slug="telechat", name="Telechat", order=3) ReviewResultName = apps.get_model("name", "ReviewResultName") - ReviewResultName.objects.get_or_create(slug="almost-ready", name="Almost Ready", order=1) + ReviewResultName.objects.get_or_create(slug="serious-issues", name="Serious Issues", order=1) ReviewResultName.objects.get_or_create(slug="issues", name="Has Issues", order=2) ReviewResultName.objects.get_or_create(slug="nits", name="Has Nits", order=3) + ReviewResultName.objects.get_or_create(slug="not-ready", name="Not Ready", order=4) ReviewResultName.objects.get_or_create(slug="right-track", name="On the Right Track", order=5) - ReviewResultName.objects.get_or_create(slug="ready", name="Ready", order=6) + ReviewResultName.objects.get_or_create(slug="almost-ready", name="Almost Ready", order=6) + ReviewResultName.objects.get_or_create(slug="ready-issues", name="Ready with Issues", order=7) ReviewResultName.objects.get_or_create(slug="ready-nits", name="Ready with Nits", order=8) - ReviewResultName.objects.get_or_create(slug="serious-issues", name="Serious Issues", order=9) + ReviewResultName.objects.get_or_create(slug="ready", name="Ready", order=9) RoleName = apps.get_model("name", "RoleName") - RoleName.objects.get_or_create(slug="reviewer", name="Reviewer", order=max(r.order for r in RoleName.objects.all()) + 1) + RoleName.objects.get_or_create(slug="reviewer", name="Reviewer", order=max(r.order for r in RoleName.objects.exclude(slug="reviewer")) + 1) + + DocTypeName = apps.get_model("name", "DocTypeName") + DocTypeName.objects.get_or_create(slug="review", name="Review") + + StateType = apps.get_model("doc", "StateType") + review_state_type, _ = StateType.objects.get_or_create(slug="review", label="Review") + + State = apps.get_model("doc", "State") + State.objects.get_or_create(type=review_state_type, slug="active", name="Active", order=1) + State.objects.get_or_create(type=review_state_type, slug="deleted", name="Deleted", order=2) def noop(apps, schema_editor): pass @@ -43,6 +54,7 @@ class Migration(migrations.Migration): dependencies = [ ('name', '0011_reviewrequeststatename_reviewresultname_reviewtypename'), ('group', '0001_initial'), + ('doc', '0001_initial'), ] operations = [ diff --git a/ietf/review/mailarch.py b/ietf/review/mailarch.py new file mode 100644 index 000000000..51f419fda --- /dev/null +++ b/ietf/review/mailarch.py @@ -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 diff --git a/ietf/review/resources.py b/ietf/review/resources.py new file mode 100644 index 000000000..dff2da8ae --- /dev/null +++ b/ietf/review/resources.py @@ -0,0 +1,65 @@ +# Autogenerated by the makeresources management command 2016-06-14 04:21 PDT +from tastypie.resources import ModelResource +from tastypie.fields import ToManyField # pyflakes:ignore +from tastypie.constants import ALL, ALL_WITH_RELATIONS # pyflakes:ignore +from tastypie.cache import SimpleCache + +from ietf import api +from ietf.api import ToOneField # pyflakes:ignore + +from ietf.review.models import * # pyflakes:ignore + + +from ietf.person.resources import PersonResource +from ietf.group.resources import GroupResource +class ReviewerResource(ModelResource): + team = ToOneField(GroupResource, 'team') + person = ToOneField(PersonResource, 'person') + class Meta: + queryset = Reviewer.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'reviewer' + filtering = { + "id": ALL, + "frequency": ALL, + "unavailable_until": ALL, + "filter_re": ALL, + "skip_next": ALL, + "team": ALL_WITH_RELATIONS, + "person": ALL_WITH_RELATIONS, + } +api.review.register(ReviewerResource()) + +from ietf.doc.resources import DocumentResource +from ietf.group.resources import RoleResource, GroupResource +from ietf.name.resources import ReviewRequestStateNameResource, ReviewResultNameResource, ReviewTypeNameResource +class ReviewRequestResource(ModelResource): + state = ToOneField(ReviewRequestStateNameResource, 'state') + type = ToOneField(ReviewTypeNameResource, 'type') + doc = ToOneField(DocumentResource, 'doc') + team = ToOneField(GroupResource, 'team') + reviewer = ToOneField(RoleResource, 'reviewer', null=True) + review = ToOneField(DocumentResource, 'review', null=True) + result = ToOneField(ReviewResultNameResource, 'result', null=True) + class Meta: + queryset = ReviewRequest.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'reviewrequest' + filtering = { + "id": ALL, + "time": ALL, + "deadline": ALL, + "requested_rev": ALL, + "reviewed_rev": ALL, + "state": ALL_WITH_RELATIONS, + "type": ALL_WITH_RELATIONS, + "doc": ALL_WITH_RELATIONS, + "team": ALL_WITH_RELATIONS, + "reviewer": ALL_WITH_RELATIONS, + "review": ALL_WITH_RELATIONS, + "result": ALL_WITH_RELATIONS, + } +api.review.register(ReviewRequestResource()) + diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 8cc692d35..7da5e1b6b 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -3,7 +3,7 @@ from django.contrib.sites.models import Site from ietf.group.models import Group, Role from ietf.doc.models import DocEvent from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream -from ietf.review.models import ReviewRequestStateName +from ietf.review.models import ReviewRequestStateName, ReviewRequest from ietf.utils.mail import send_mail def active_review_teams(): @@ -22,6 +22,17 @@ def can_manage_review_requests_for_team(user, team): return Role.objects.filter(name__in=["secretary", "delegate"], person__user=user, group=team).exists() or has_role(user, "Secretariat") +def make_new_review_request_from_existing(review_req): + obj = ReviewRequest() + obj.time = review_req.time + obj.type = review_req.type + obj.doc = review_req.doc + obj.team = review_req.team + obj.deadline = review_req.deadline + obj.requested_rev = review_req.requested_rev + obj.state = ReviewRequestStateName.objects.get(slug="requested") + return obj + def email_about_review_request(request, review_req, subject, msg, by, notify_secretary, notify_reviewer): """Notify possibly both secretary and reviewer about change, skipping a party if the change was done by that party.""" @@ -48,7 +59,6 @@ def email_about_review_request(request, review_req, subject, msg, by, notify_sec "msg": msg, }) - def assign_review_request_to_reviewer(request, review_req, reviewer): assert review_req.state_id in ("requested", "accepted") diff --git a/ietf/settings.py b/ietf/settings.py index ec91d6fb0..5e21a2873 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -404,6 +404,7 @@ MEETING_RECORDINGS_DIR = '/a/www/audio' # Mailing list info URL for lists hosted on the IETF servers MAILING_LIST_INFO_URL = "https://www.ietf.org/mailman/listinfo/%(list_addr)s" +MAILING_LIST_ARCHIVE_URL = "https://mailarchive.ietf.org" # Liaison Statement Tool settings (one is used in DOC_HREFS below) LIAISON_UNIVERSAL_FROM = 'Liaison Statement Management Tool ' diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 2aacf362b..cc433f0b2 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -459,3 +459,16 @@ label#list-feeds { .email-subscription button[type=submit] { margin-left: 3em; } + +/* Review flow */ + +form.complete-review .mail-archive-search .query-input { + width: 30em; +} + +form.complete-review .mail-archive-search .results .list-group { + margin-left: 1em; + margin-right: 1em; + margin-bottom: 0.5em; +} + diff --git a/ietf/static/ietf/js/complete-review.js b/ietf/static/ietf/js/complete-review.js new file mode 100644 index 000000000..e810f40a2 --- /dev/null +++ b/ietf/static/ietf/js/complete-review.js @@ -0,0 +1,131 @@ +$(document).ready(function () { + var form = $("form.complete-review"); + + var reviewedRev = form.find("[name=reviewed_rev]"); + reviewedRev.closest(".form-group").find("a.rev").on("click", function (e) { + e.preventDefault(); + reviewedRev.val($(this).text()); + }); + + // mail archive search functionality + var mailArchiveSearchTemplate = form.find(".template .mail-archive-search").parent().html(); + var mailArchiveSearchResultTemplate = form.find(".template .mail-archive-search-result").parent().html(); + + form.find("[name=review_url]").closest(".form-group").before(mailArchiveSearchTemplate); + + var mailArchiveSearch = form.find(".mail-archive-search"); + + var retrievingData = null; + + function searchMailArchive() { + if (retrievingData) + return; + + var queryInput = mailArchiveSearch.find(".query-input"); + if (queryInput.length == 0 || !$.trim(queryInput.val())) + return; + + mailArchiveSearch.find(".search").prop("disabled", true); + mailArchiveSearch.find(".error").addClass("hidden"); + mailArchiveSearch.find(".retrieving").removeClass("hidden"); + mailArchiveSearch.find(".results").addClass("hidden"); + + retrievingData = $.ajax({ + url: searchMailArchiveUrl, + method: "GET", + data: { + query: queryInput.val() + }, + dataType: "json", + timeout: 20 * 1000 + }).then(function (data) { + retrievingData = null; + mailArchiveSearch.find(".search").prop("disabled", false); + mailArchiveSearch.find(".retrieving").addClass("hidden"); + + var err = data.error; + if (!err && (!data.messages || !data.messages.length)) + err = "No messages matching document name found in archive"; + + if (err) { + var errorDiv = mailArchiveSearch.find(".error"); + errorDiv.removeClass("hidden"); + errorDiv.find(".content").text(err); + if (data.query && data.query_url && data.query_data_url) { + errorDiv.find(".try-yourself .query").text(data.query); + errorDiv.find(".try-yourself .query-url").prop("href", data.query_url); + errorDiv.find(".try-yourself .query-data-url").prop("href", data.query_data_url); + errorDiv.find(".try-yourself").removeClass("hidden"); + } + } + else { + mailArchiveSearch.find(".results").removeClass("hidden"); + + var results = mailArchiveSearch.find(".results .list-group"); + results.children().remove(); + + for (var i = 0; i < data.messages.length; ++i) { + var msg = data.messages[i]; + var row = $(mailArchiveSearchResultTemplate).attr("title", "Click to fill in link and content from this message"); + row.find(".subject").text(msg.subject); + row.find(".date").text(msg.date); + row.data("url", msg.url); + row.data("content", msg.content); + results.append(row); + } + } + }, function () { + retrievingData = null; + mailArchiveSearch.find(".search").prop("disabled", false); + mailArchiveSearch.find(".retrieving").addClass("hidden"); + + var errorDiv = mailArchiveSearch.find(".error"); + errorDiv.removeClass("hidden"); + errorDiv.find(".content").text("Error trying to retrieve data from mailing list archive."); + }); + } + + mailArchiveSearch.find(".search").on("click", function () { + searchMailArchive(); + }); + + mailArchiveSearch.find(".results").on("click", ".mail-archive-search-result", function (e) { + e.preventDefault(); + + var row = $(this); + if (!row.is(".mail-archive-search-result")) + row = row.closest(".mail-archive-search-result"); + + form.find("[name=review_url]").val(row.data("url")); + form.find("[name=review_content]").val(row.data("content")); + }); + + + // review submission selection + form.find("[name=review_submission]").on("click change", function () { + var val = form.find("[name=review_submission]:checked").val(); + + var shouldBeVisible = { + "enter": ['[name="review_content"]'], + "upload": ['[name="review_file"]'], + "link": [".mail-archive-search", '[name="review_url"]', '[name="review_content"]'] + }; + + for (var v in shouldBeVisible) { + for (var i in shouldBeVisible[v]) { + var selector = shouldBeVisible[v][i]; + var row = form.find(selector); + if (!row.is(".form-group")) + row = row.closest(".form-group"); + + if ($.inArray(selector, shouldBeVisible[val]) != -1) + row.show(); + else + row.hide(); + } + } + + if (val == "link") + searchMailArchive(); + }).trigger("change"); +}); diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 853d6fcc6..d05e60555 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -200,7 +200,7 @@ {% for r in review_requests %} {% endfor %} diff --git a/ietf/templates/doc/mail/completed_review.txt b/ietf/templates/doc/mail/completed_review.txt new file mode 100644 index 000000000..b3ca9d2f4 --- /dev/null +++ b/ietf/templates/doc/mail/completed_review.txt @@ -0,0 +1,6 @@ +{% autoescape off %}{% filter wordwrap:70 %}{% if review_req.state_id == "part-completed" %}Review is partially done. Another review request has been registered for completing it. + +{% endif %}Reviewer: {{ review_req.reviewer.person }} + +{{ content }} +{% endfilter %}{% endautoescape %} diff --git a/ietf/templates/doc/mail/partially_completed_review.txt b/ietf/templates/doc/mail/partially_completed_review.txt new file mode 100644 index 000000000..3e1661e55 --- /dev/null +++ b/ietf/templates/doc/mail/partially_completed_review.txt @@ -0,0 +1,6 @@ +{% autoescape off %}Review was partially completed by {{ by }}. + +A new review request has been registered for completing the review: + +https://{{ domain }}{% url "ietf.doc.views_review.review_request" name=new_review_req.doc.name request_id=new_review_req.pk %} +{% endautoescape %} diff --git a/ietf/templates/doc/mail/reviewer_assignment_rejected.txt b/ietf/templates/doc/mail/reviewer_assignment_rejected.txt new file mode 100644 index 000000000..001de69b3 --- /dev/null +++ b/ietf/templates/doc/mail/reviewer_assignment_rejected.txt @@ -0,0 +1,6 @@ +{% autoescape off %}Reviewer assignment rejected by {{ by }}.{% if message_to_secretary %} + +Explanation: + +{{ message_to_secretary }} +{% endif %}{% endautoescape %} diff --git a/ietf/templates/doc/review/complete_review.html b/ietf/templates/doc/review/complete_review.html new file mode 100644 index 000000000..3dca99e6a --- /dev/null +++ b/ietf/templates/doc/review/complete_review.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2016, All Rights Reserved #} +{% load origin bootstrap3 static %} + +{% block title %}Complete review of {{ review_req.doc.name }}{% endblock %} + +{% block content %} + {% origin %} +

Complete review
{{ review_req.doc.name }}

+ +

The review findings should be made available here and the review + posted to the mailing list. If you enter the findings below, the + system will post the review for you. If you already have posted + the review, you can try to let the system find a link to the + archive and retrieve the email body.

+ +
+ {% csrf_token %} + + {% bootstrap_form form layout="horizontal" %} + + {% buttons %} + Cancel + + {% endbuttons %} + + + + + +
+ +{% endblock %} + +{% block js %} + + +{% endblock %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index aec78a6c1..e2c137dd4 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -19,6 +19,10 @@ {% else %} {{ review_req.doc.name }} {% endif %} + + {% if can_withdraw_request %} + Withdraw request + {% endif %} @@ -60,43 +64,51 @@ {{ review_req.state.name }} - - - Reviewer - - {% if review_req.reviewer %} - {{ review_req.reviewer.person }} - {% else %} - None assigned yet - {% endif %} + + + Reviewer + + {% if review_req.reviewer %} + {{ review_req.reviewer.person }} + {% else %} + None assigned yet + {% endif %} - {% if can_accept_reviewer_assignment %} -
{% csrf_token %}
- {% endif %} + {% if can_accept_reviewer_assignment %} +
{% csrf_token %}
+ {% endif %} - {% if can_reject_reviewer_assignment %} - Reject - {% endif %} + {% if can_reject_reviewer_assignment %} + Reject + {% endif %} - {% if can_assign_reviewer %} - {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer - {% endif %} - - + {% if can_assign_reviewer %} + {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer + {% endif %} + + - {% if review_req.review %} - - - Review - {{ review_req.review.name }} - - {% endif %} + + + Review + + {% if review_req.review %} + {{ review_req.review.name }} + {% else %} + Not completed yet + {% endif %} + + {% if can_complete_review %} + Complete review + {% endif %} + + {% if review_req.reviewed_rev %} Reviewed revision - {{ review_req.reviewed_rev }} + {{ review_req.reviewed_rev }} {% if review_req.reviewed_rev != review_req.doc.rev %}(currently at {{ review_req.doc.rev }}){% endif %} {% endif %} @@ -110,10 +122,4 @@ -
- {% if can_withdraw_request %} - Withdraw request - {% endif %} -
- {% endblock %} diff --git a/ietf/utils/text.py b/ietf/utils/text.py new file mode 100644 index 000000000..39df9a136 --- /dev/null +++ b/ietf/utils/text.py @@ -0,0 +1,11 @@ +def skip_prefix(text, prefix): + if text.startswith(prefix): + return text[len(prefix):] + else: + return text + +def skip_suffix(text, prefix): + if text.endswith(prefix): + return text[:-len(prefix)] + else: + return text diff --git a/ietf/utils/textupload.py b/ietf/utils/textupload.py index 1a4dbe705..7456825a1 100644 --- a/ietf/utils/textupload.py +++ b/ietf/utils/textupload.py @@ -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")