diff --git a/ietf/api/__init__.py b/ietf/api/__init__.py index 5ac6ef145..bee755ec3 100644 --- a/ietf/api/__init__.py +++ b/ietf/api/__init__.py @@ -94,7 +94,6 @@ for _app in settings.INSTALLED_APPS: _root, _name = _app.split('.', 1) if _root == 'ietf': if not '.' in _name: - _api = Api(api_name=_name) _module_dict[_name] = _api _api_list.append((_name, _api)) diff --git a/ietf/bin/send-reviewer-reminders b/ietf/bin/send-reviewer-reminders new file mode 100755 index 000000000..07d7f3e46 --- /dev/null +++ b/ietf/bin/send-reviewer-reminders @@ -0,0 +1,25 @@ +#!/usr/bin/env python + +import os, sys +import syslog + +# boilerplate +basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +sys.path = [ basedir ] + sys.path +os.environ["DJANGO_SETTINGS_MODULE"] = "ietf.settings" + +virtualenv_activation = os.path.join(basedir, "bin", "activate_this.py") +if os.path.exists(virtualenv_activation): + execfile(virtualenv_activation, dict(__file__=virtualenv_activation)) + +syslog.openlog(os.path.basename(__file__), syslog.LOG_PID, syslog.LOG_USER) + +import django +django.setup() + +import datetime +from ietf.review.utils import review_requests_needing_reviewer_reminder, email_reviewer_reminder + +for review_req in review_requests_needing_reviewer_reminder(datetime.date.today()): + email_reviewer_reminder(review_req) + print("Emailed reminder to {} for review of {} in {} (req. id {})".format(review_req.reviewer.address, review_req.doc_id, review_req.team.acronym, review_req.pk)) diff --git a/ietf/community/models.py b/ietf/community/models.py index ed7238a6c..6839801ce 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -98,6 +98,9 @@ def notify_events(sender, instance, **kwargs): if instance.doc.type_id != 'draft': return + if getattr(instance, "skip_community_list_notification", False): + return + from ietf.community.utils import notify_event_to_subscribers notify_event_to_subscribers(instance) diff --git a/ietf/doc/migrations/0015_auto_20160927_0713.py b/ietf/doc/migrations/0015_auto_20160927_0713.py new file mode 100644 index 000000000..fa127a6d2 --- /dev/null +++ b/ietf/doc/migrations/0015_auto_20160927_0713.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0015_insert_review_name_data'), + ('review', '0001_initial'), + ('doc', '0014_auto_20160824_2218'), + ] + + operations = [ + migrations.CreateModel( + name='ReviewRequestDocEvent', + fields=[ + ('docevent_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='doc.DocEvent')), + ('review_request', models.ForeignKey(to='review.ReviewRequest')), + ('state', models.ForeignKey(blank=True, to='name.ReviewRequestStateName', null=True)), + ], + options={ + }, + bases=('doc.docevent',), + ), + migrations.AlterField( + model_name='docevent', + name='type', + field=models.CharField(max_length=50, choices=[(b'new_revision', b'Added new revision'), (b'changed_document', b'Changed document metadata'), (b'added_comment', b'Added comment'), (b'deleted', b'Deleted document'), (b'changed_state', b'Changed state'), (b'changed_stream', b'Changed document stream'), (b'expired_document', b'Expired document'), (b'extended_expiry', b'Extended expiry of document'), (b'requested_resurrect', b'Requested resurrect'), (b'completed_resurrect', b'Completed resurrect'), (b'changed_consensus', b'Changed consensus'), (b'published_rfc', b'Published RFC'), (b'added_suggested_replaces', b'Added suggested replacement relationships'), (b'reviewed_suggested_replaces', b'Reviewed suggested replacement relationships'), (b'changed_group', b'Changed group'), (b'changed_protocol_writeup', b'Changed protocol writeup'), (b'changed_charter_milestone', b'Changed charter milestone'), (b'initial_review', b'Set initial review time'), (b'changed_review_announcement', b'Changed WG Review text'), (b'changed_action_announcement', b'Changed WG Action text'), (b'started_iesg_process', b'Started IESG process on document'), (b'created_ballot', b'Created ballot'), (b'closed_ballot', b'Closed ballot'), (b'sent_ballot_announcement', b'Sent ballot announcement'), (b'changed_ballot_position', b'Changed ballot position'), (b'changed_ballot_approval_text', b'Changed ballot approval text'), (b'changed_ballot_writeup_text', b'Changed ballot writeup text'), (b'changed_rfc_editor_note_text', b'Changed RFC Editor Note text'), (b'changed_last_call_text', b'Changed last call text'), (b'requested_last_call', b'Requested last call'), (b'sent_last_call', b'Sent last call'), (b'scheduled_for_telechat', b'Scheduled for telechat'), (b'iesg_approved', b'IESG approved document (no problem)'), (b'iesg_disapproved', b'IESG disapproved document (do not publish)'), (b'approved_in_minute', b'Approved in minute'), (b'iana_review', b'IANA review comment'), (b'rfc_in_iana_registry', b'RFC is in IANA registry'), (b'rfc_editor_received_announcement', b'Announcement was received by RFC Editor'), (b'requested_publication', b'Publication at RFC Editor requested'), (b'sync_from_rfc_editor', b'Received updated information from RFC Editor'), (b'requested_review', b'Requested review'), (b'assigned_review_request', b'Assigned review request'), (b'closed_review_request', b'Closed review request')]), + preserve_default=True, + ), + ] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 6d8e2dc09..08de4c47a 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -14,7 +14,7 @@ import debug # pyflakes:ignore from ietf.group.models import Group from ietf.name.models import ( DocTypeName, DocTagName, StreamName, IntendedStdLevelName, StdLevelName, - DocRelationshipName, DocReminderTypeName, BallotPositionName ) + DocRelationshipName, DocReminderTypeName, BallotPositionName, ReviewRequestStateName ) from ietf.person.models import Email, Person from ietf.utils.admin import admin_link @@ -697,6 +697,11 @@ 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"), + ("assigned_review_request", "Assigned review request"), + ("closed_review_request", "Closed review request"), ] class DocEvent(models.Model): @@ -827,11 +832,14 @@ class TelechatDocEvent(DocEvent): telechat_date = models.DateField(blank=True, null=True) returning_item = models.BooleanField(default=False) +class ReviewRequestDocEvent(DocEvent): + review_request = models.ForeignKey('review.ReviewRequest') + state = models.ForeignKey(ReviewRequestStateName, blank=True, null=True) + # charter events class InitialReviewDocEvent(DocEvent): expires = models.DateTimeField(blank=True, null=True) - class AddedMessageEvent(DocEvent): import ietf.message.models message = models.ForeignKey(ietf.message.models.Message, null=True, blank=True,related_name='doc_manualevents') diff --git a/ietf/doc/resources.py b/ietf/doc/resources.py index 3df4ebd8d..fc603fd98 100644 --- a/ietf/doc/resources.py +++ b/ietf/doc/resources.py @@ -11,7 +11,7 @@ from ietf.doc.models import (BallotType, DeletedEvent, StateType, State, Documen DocumentAuthor, DocEvent, StateDocEvent, DocHistory, ConsensusDocEvent, DocAlias, TelechatDocEvent, DocReminder, LastCallDocEvent, NewRevisionDocEvent, WriteupDocEvent, InitialReviewDocEvent, DocHistoryAuthor, BallotDocEvent, RelatedDocument, - RelatedDocHistory, BallotPositionDocEvent, AddedMessageEvent) + RelatedDocHistory, BallotPositionDocEvent, AddedMessageEvent, ReviewRequestDocEvent) from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource @@ -513,8 +513,6 @@ class BallotPositionDocEventResource(ModelResource): } api.doc.register(BallotPositionDocEventResource()) - - from ietf.person.resources import PersonResource from ietf.message.resources import MessageResource class AddedMessageEventResource(ModelResource): @@ -542,3 +540,28 @@ class AddedMessageEventResource(ModelResource): } api.doc.register(AddedMessageEventResource()) +from ietf.person.resources import PersonResource +from ietf.name.resources import ReviewRequestStateNameResource +class ReviewRequestDocEventResource(ModelResource): + by = ToOneField(PersonResource, 'by') + doc = ToOneField(DocumentResource, 'doc') + docevent_ptr = ToOneField(DocEventResource, 'docevent_ptr') + review_request = ToOneField('review.ReviewRequestResource', 'review_request') + state = ToOneField(ReviewRequestStateNameResource, 'state', null=True) + class Meta: + queryset = ReviewRequestDocEvent.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'reviewrequestdocevent' + filtering = { + "id": ALL, + "time": ALL, + "type": ALL, + "desc": ALL, + "by": ALL_WITH_RELATIONS, + "doc": ALL_WITH_RELATIONS, + "docevent_ptr": ALL_WITH_RELATIONS, + "review_request": ALL_WITH_RELATIONS, + "state": ALL_WITH_RELATIONS, + } +api.doc.register(ReviewRequestDocEventResource()) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py new file mode 100644 index 000000000..fff8049be --- /dev/null +++ b/ietf/doc/tests_review.py @@ -0,0 +1,661 @@ +# -*- 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, ResultUsedInReviewTeam, ReviewerSettings, + ReviewWish, UnavailablePeriod, NextReviewerInTeam) +from ietf.review.utils import reviewer_rotation_list, possibly_advance_next_reviewer_for_team +import ietf.review.mailarch +from ietf.person.models import Email, Person +from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName, DocRelationshipName +from ietf.group.models import Group +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, create_person +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, "closed_review_request") + self.assertTrue("closed" in e.desc.lower()) + self.assertEqual(len(outbox), 1) + self.assertTrue("closed" in unicode(outbox[0]).lower()) + + def make_data_for_rotation_tests(self, doc): + team = Group.objects.create(state_id="active", acronym="rotationteam", name="Review Team", type_id="dir", + list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut")) + + # make a bunch of reviewers + reviewers = [ + create_person(team, "reviewer", name="Test Reviewer{}".format(i), username="testreviewer{}".format(i)) + for i in range(5) + ] + + self.assertEqual(reviewers, reviewer_rotation_list(team)) + + return team, reviewers + + def test_possibly_advance_next_reviewer_for_team(self): + doc = make_test_data() + + team, reviewers = self.make_data_for_rotation_tests(doc) + + def get_skip_next(person): + settings = (ReviewerSettings.objects.filter(team=team, person=person).first() + or ReviewerSettings(team=team)) + return settings.skip_next + + possibly_advance_next_reviewer_for_team(team, reviewers[0].pk) + self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[1]) + self.assertEqual(get_skip_next(reviewers[0]), 0) + self.assertEqual(get_skip_next(reviewers[1]), 0) + + possibly_advance_next_reviewer_for_team(team, reviewers[1].pk) + self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2]) + + # skip reviewer 2 + possibly_advance_next_reviewer_for_team(team, reviewers[3].pk) + self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2]) + self.assertEqual(get_skip_next(reviewers[0]), 0) + self.assertEqual(get_skip_next(reviewers[1]), 0) + self.assertEqual(get_skip_next(reviewers[2]), 0) + self.assertEqual(get_skip_next(reviewers[3]), 1) + + # pick reviewer 2, use up reviewer 3's skip_next + possibly_advance_next_reviewer_for_team(team, reviewers[2].pk) + self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[4]) + self.assertEqual(get_skip_next(reviewers[0]), 0) + self.assertEqual(get_skip_next(reviewers[1]), 0) + self.assertEqual(get_skip_next(reviewers[2]), 0) + self.assertEqual(get_skip_next(reviewers[3]), 0) + self.assertEqual(get_skip_next(reviewers[4]), 0) + + # check wrap-around + possibly_advance_next_reviewer_for_team(team, reviewers[4].pk) + self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[0]) + self.assertEqual(get_skip_next(reviewers[0]), 0) + self.assertEqual(get_skip_next(reviewers[1]), 0) + self.assertEqual(get_skip_next(reviewers[2]), 0) + self.assertEqual(get_skip_next(reviewers[3]), 0) + self.assertEqual(get_skip_next(reviewers[4]), 0) + + # unavailable + today = datetime.date.today() + UnavailablePeriod.objects.create(team=team, person=reviewers[1], start_date=today, end_date=today, availability="unavailable") + possibly_advance_next_reviewer_for_team(team, reviewers[0].pk) + self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2]) + self.assertEqual(get_skip_next(reviewers[0]), 0) + self.assertEqual(get_skip_next(reviewers[1]), 0) + self.assertEqual(get_skip_next(reviewers[2]), 0) + self.assertEqual(get_skip_next(reviewers[3]), 0) + self.assertEqual(get_skip_next(reviewers[4]), 0) + + # pick unavailable anyway + possibly_advance_next_reviewer_for_team(team, reviewers[1].pk) + self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2]) + self.assertEqual(get_skip_next(reviewers[0]), 0) + self.assertEqual(get_skip_next(reviewers[1]), 1) + self.assertEqual(get_skip_next(reviewers[2]), 0) + self.assertEqual(get_skip_next(reviewers[3]), 0) + self.assertEqual(get_skip_next(reviewers[4]), 0) + + # not through min_interval + ReviewRequest.objects.create(team=team, doc=doc, type_id="early", state_id="accepted", deadline=today, requested_by=reviewers[0], reviewer=reviewers[2].email_set.first()) + possibly_advance_next_reviewer_for_team(team, reviewers[3].pk) + self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[4]) + self.assertEqual(get_skip_next(reviewers[0]), 0) + self.assertEqual(get_skip_next(reviewers[1]), 1) + self.assertEqual(get_skip_next(reviewers[2]), 0) + self.assertEqual(get_skip_next(reviewers[3]), 0) + self.assertEqual(get_skip_next(reviewers[4]), 0) + + 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, team=review_req.team) + reviewer_settings.filter_re = doc.name + reviewer_settings.skip_next = 1 + reviewer_settings.save() + + UnavailablePeriod.objects.create( + team=review_req.team, + person=plain_email.person, + start_date=datetime.date.today() - datetime.timedelta(days=10), + availability="unavailable", + ) + + ReviewWish.objects.create(person=plain_email.person, team=review_req.team, doc=doc) + + # pick a non-existing reviewer as next to see that we can + # handle reviewers who have left + NextReviewerInTeam.objects.filter(team=review_req.team).delete() + NextReviewerInTeam.objects.create( + team=review_req.team, + next_reviewer=Person.objects.exclude(pk=plain_email.person_id).first(), + ) + + 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("reviewed document before", plain_label) + self.assertIn("wishes to review", plain_label) + self.assertIn("is author", plain_label) + self.assertIn("regexp matches", plain_label) + self.assertIn("unavailable indefinitely", plain_label) + self.assertIn("skip next 1", plain_label) + self.assertIn("#1", plain_label) + + # assign + empty_outbox() + rotation_list = reviewer_rotation_list(review_req.team) + reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team, person=rotation_list[0]).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])) + self.assertEqual(NextReviewerInTeam.objects.get(team=review_req.team).next_reviewer, rotation_list[1]) + + # 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, person=rotation_list[1]).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, "closed_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"] = "" + 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.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() + for r in ReviewResultName.objects.filter(slug__in=("issues", "ready")): + ResultUsedInReviewTeam.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(resultusedinreviewteam__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) + + # check the review document page + url = urlreverse('doc_view', kwargs={ "name": review_req.review.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) + self.assertTrue("This is a review" in content) + + + 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(resultusedinreviewteam__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(resultusedinreviewteam__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(resultusedinreviewteam__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(resultusedinreviewteam__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 diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index 90ea9f12d..c8e7a503b 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -77,6 +77,7 @@ urlpatterns = patterns('', url(r'^%(name)s/ballot/(?P[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[0-9]+)/$' % settings.URL_REGEXPS, views_doc.ballot_popup), + url(r'^(?P[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'), diff --git a/ietf/doc/urls_review.py b/ietf/doc/urls_review.py new file mode 100644 index 000000000..0085d6add --- /dev/null +++ b/ietf/doc/urls_review.py @@ -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[0-9]+)/$', views_review.review_request), + url(r'^(?P[0-9]+)/close/$', views_review.close_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 626a14dd2..d6dc2a8c7 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -5,6 +5,7 @@ import math import datetime import hashlib import json +from collections import defaultdict from django.conf import settings from django.forms import ValidationError @@ -110,7 +111,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() @@ -598,6 +598,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): diff --git a/ietf/doc/utils_search.py b/ietf/doc/utils_search.py index 7b9e3c159..c7345596d 100644 --- a/ietf/doc/utils_search.py +++ b/ietf/doc/utils_search.py @@ -60,8 +60,9 @@ def fill_in_document_table_attributes(docs): d.expirable = expirable_draft(d) if d.get_state_slug() != "rfc": - d.milestones = d.groupmilestone_set.filter(state="active").order_by("time").select_related("group") + d.milestones = sorted((m for m in d.groupmilestone_set.all() if m.state_id == "active"), key=lambda m: m.time) + d.reviewed_by_teams = sorted(set(r.team for r in d.reviewrequest_set.all()), key=lambda g: g.acronym) # RFCs @@ -101,7 +102,7 @@ def prepare_document_table(request, docs, query=None, max_results=500): # evaluate and fill in attribute results immediately to decrease # the number of queries docs = docs.select_related("ad", "ad__person", "std_level", "intended_std_level", "group", "stream") - docs = docs.prefetch_related("states__type", "tags") + docs = docs.prefetch_related("states__type", "tags", "groupmilestone_set__group", "reviewrequest_set__team") docs = list(docs[:max_results]) diff --git a/ietf/doc/views_charter.py b/ietf/doc/views_charter.py index ef9d4556e..a26432a4d 100644 --- a/ietf/doc/views_charter.py +++ b/ietf/doc/views_charter.py @@ -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) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index a297c107a..5d365e656 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -60,10 +60,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_docs +from ietf.review.utils import no_review_from_teams_on_doc def render_document_top(request, doc, tab, name): tabs = [] @@ -282,8 +285,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) @@ -297,6 +300,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: @@ -357,6 +362,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_docs([doc]).get(doc.pk, []) + 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, @@ -378,6 +386,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, @@ -416,6 +425,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)) @@ -567,6 +578,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_docs([review_req.doc]).get(doc.pk, []) 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 diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index 642df545c..55a176f7b 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -792,11 +792,15 @@ def resurrect(request, name): if doc.get_state_slug() != "expired": raise Http404 + resurrect_requested_by = None + e = doc.latest_event(type__in=('requested_resurrect', "completed_resurrect")) + if e.type == 'requested_resurrect': + resurrect_requested_by = e.by + if request.method == 'POST': - e = doc.latest_event(type__in=('requested_resurrect', "completed_resurrect")) - if e and e.type == 'requested_resurrect': - email_resurrection_completed(request, doc, requester=e.by) - + if resurrect_requested_by: + email_resurrection_completed(request, doc, requester=resurrect_requested_by) + events = [] e = DocEvent(doc=doc, by=request.user.person) e.type = "completed_resurrect" @@ -812,6 +816,7 @@ def resurrect(request, name): return render_to_response('doc/draft/resurrect.html', dict(doc=doc, + resurrect_requested_by=resurrect_requested_by, back_url=doc.get_absolute_url()), context_instance=RequestContext(request)) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py new file mode 100644 index 000000000..6ba6a81ff --- /dev/null +++ b/ietf/doc/views_review.py @@ -0,0 +1,556 @@ +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, State, DocAlias, + LastCallDocEvent, ReviewRequestDocEvent) +from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName +from ietf.review.models import ReviewRequest, TypeUsedInReviewTeam +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 + + f = self.fields["team"] + f.queryset = active_review_teams() + 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['type'].queryset = self.fields['type'].queryset.filter(used=True, typeusedinreviewteam__team__in=self.fields["team"].queryset).distinct() + self.fields['type'].widget = forms.RadioSelect(choices=[t for t in self.fields['type'].choices if t[0]]) + + 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")) + + def clean(self): + chosen_type = self.cleaned_data.get("type") + chosen_teams = self.cleaned_data.get("team") + + if chosen_type and chosen_teams: + for t in chosen_teams: + if not TypeUsedInReviewTeam.objects.filter(type=chosen_type, team=t).exists(): + self.add_error("type", "{} does not use the review type {}.".format(t.name, chosen_type.name)) + + return self.cleaned_data + +@login_required +def request_review(request, name): + doc = get_object_or_404(Document, name=name) + + if not can_request_review_of_doc(request.user, doc): + return HttpResponseForbidden("You do not have permission to perform this action") + + 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() + + ReviewRequestDocEvent.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, + review_request=review_req, + state=None, + ) + + 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() + + ReviewRequestDocEvent.objects.create( + type="closed_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, + ), + review_request=review_req, + state=review_req.state, + ) + + # 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("review/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("{}".format(r) + for r in known_revisions)) + + self.fields["result"].queryset = self.fields["result"].queryset.filter(resultusedinreviewteam__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): + if "@" in self.review_req.reviewer.person.ascii: + raise forms.ValidationError("Reviewer name must be filled in (the ASCII version is currently \"{}\" - since it contains an @ sign the name is probably still the original email address).".format(self.review_req.reviewer.person.ascii)) + + 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, + 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) + DocAlias.objects.create(document=review, name=review.name) + break + + 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'] + + e = NewRevisionDocEvent.objects.create( + type="new_revision", + doc=review, + by=request.user.person, + rev=review.rev, + desc='New revision available', + time=review.time, + ) + + review.set_state(State.objects.get(type="review", slug="active")) + + review.save_with_history([e]) + + # 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() + + need_to_email_review = review_submission != "link" and review_req.team.list_email + + 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.person, + ) + if need_to_email_review: + desc += " " + "Sent review to list." + + close_event = ReviewRequestDocEvent.objects.create( + type="closed_review_request", + doc=review_req.doc, + by=request.user.person, + desc=desc, + review_request=review_req, + state=review_req.state, + ) + + if review_req.state_id == "part-completed": + existing_open_reqs = ReviewRequest.objects.filter(doc=review_req.doc, team=review_req.team, state__in=("requested", "accepted")) + + new_review_req_url = new_review_req = None + if not existing_open_reqs: + new_review_req = make_new_review_request_from_existing(review_req) + new_review_req.save() + + new_review_req_url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": new_review_req.doc.name, "request_id": new_review_req.pk }) + new_review_req_url = request.build_absolute_uri(new_review_req_url) + + subject = "Review of {}-{} completed partially".format(review_req.doc.name, review_req.reviewed_rev) + + msg = render_to_string("review/partially_completed_review.txt", { + "new_review_req_url": new_review_req_url, + "existing_open_reqs": existing_open_reqs, + "by": request.user.person, + }) + + email_review_request_change(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=False) + + if need_to_email_review: + # 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, + "review/completed_review.txt", { + "review_req": review_req, + "content": encoded_content.decode("utf-8"), + }, + cc=form.cleaned_data["cc"]) + + 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([close_event]) + + 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/externals/static/flot/jquery.flot.min.js b/ietf/externals/static/flot/jquery.flot.min.js new file mode 100644 index 000000000..968d3ebd9 --- /dev/null +++ b/ietf/externals/static/flot/jquery.flot.min.js @@ -0,0 +1,8 @@ +/* Javascript plotting library for jQuery, version 0.8.3. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +*/ +(function($){$.color={};$.color.make=function(r,g,b,a){var o={};o.r=r||0;o.g=g||0;o.b=b||0;o.a=a!=null?a:1;o.add=function(c,d){for(var i=0;i=1){return"rgb("+[o.r,o.g,o.b].join(",")+")"}else{return"rgba("+[o.r,o.g,o.b,o.a].join(",")+")"}};o.normalize=function(){function clamp(min,value,max){return valuemax?max:value}o.r=clamp(0,parseInt(o.r),255);o.g=clamp(0,parseInt(o.g),255);o.b=clamp(0,parseInt(o.b),255);o.a=clamp(0,o.a,1);return o};o.clone=function(){return $.color.make(o.r,o.b,o.g,o.a)};return o.normalize()};$.color.extract=function(elem,css){var c;do{c=elem.css(css).toLowerCase();if(c!=""&&c!="transparent")break;elem=elem.parent()}while(elem.length&&!$.nodeName(elem.get(0),"body"));if(c=="rgba(0, 0, 0, 0)")c="transparent";return $.color.parse(c)};$.color.parse=function(str){var res,m=$.color.make;if(res=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10));if(res=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10),parseFloat(res[4]));if(res=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55);if(res=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55,parseFloat(res[4]));if(res=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str))return m(parseInt(res[1],16),parseInt(res[2],16),parseInt(res[3],16));if(res=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str))return m(parseInt(res[1]+res[1],16),parseInt(res[2]+res[2],16),parseInt(res[3]+res[3],16));var name=$.trim(str).toLowerCase();if(name=="transparent")return m(255,255,255,0);else{res=lookupColors[name]||[0,0,0];return m(res[0],res[1],res[2])}};var lookupColors={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery);(function($){var hasOwnProperty=Object.prototype.hasOwnProperty;if(!$.fn.detach){$.fn.detach=function(){return this.each(function(){if(this.parentNode){this.parentNode.removeChild(this)}})}}function Canvas(cls,container){var element=container.children("."+cls)[0];if(element==null){element=document.createElement("canvas");element.className=cls;$(element).css({direction:"ltr",position:"absolute",left:0,top:0}).appendTo(container);if(!element.getContext){if(window.G_vmlCanvasManager){element=window.G_vmlCanvasManager.initElement(element)}else{throw new Error("Canvas is not available. If you're using IE with a fall-back such as Excanvas, then there's either a mistake in your conditional include, or the page has no DOCTYPE and is rendering in Quirks Mode.")}}}this.element=element;var context=this.context=element.getContext("2d");var devicePixelRatio=window.devicePixelRatio||1,backingStoreRatio=context.webkitBackingStorePixelRatio||context.mozBackingStorePixelRatio||context.msBackingStorePixelRatio||context.oBackingStorePixelRatio||context.backingStorePixelRatio||1;this.pixelRatio=devicePixelRatio/backingStoreRatio;this.resize(container.width(),container.height());this.textContainer=null;this.text={};this._textCache={}}Canvas.prototype.resize=function(width,height){if(width<=0||height<=0){throw new Error("Invalid dimensions for plot, width = "+width+", height = "+height)}var element=this.element,context=this.context,pixelRatio=this.pixelRatio;if(this.width!=width){element.width=width*pixelRatio;element.style.width=width+"px";this.width=width}if(this.height!=height){element.height=height*pixelRatio;element.style.height=height+"px";this.height=height}context.restore();context.save();context.scale(pixelRatio,pixelRatio)};Canvas.prototype.clear=function(){this.context.clearRect(0,0,this.width,this.height)};Canvas.prototype.render=function(){var cache=this._textCache;for(var layerKey in cache){if(hasOwnProperty.call(cache,layerKey)){var layer=this.getTextLayer(layerKey),layerCache=cache[layerKey];layer.hide();for(var styleKey in layerCache){if(hasOwnProperty.call(layerCache,styleKey)){var styleCache=layerCache[styleKey];for(var key in styleCache){if(hasOwnProperty.call(styleCache,key)){var positions=styleCache[key].positions;for(var i=0,position;position=positions[i];i++){if(position.active){if(!position.rendered){layer.append(position.element);position.rendered=true}}else{positions.splice(i--,1);if(position.rendered){position.element.detach()}}}if(positions.length==0){delete styleCache[key]}}}}}layer.show()}}};Canvas.prototype.getTextLayer=function(classes){var layer=this.text[classes];if(layer==null){if(this.textContainer==null){this.textContainer=$("
").css({position:"absolute",top:0,left:0,bottom:0,right:0,"font-size":"smaller",color:"#545454"}).insertAfter(this.element)}layer=this.text[classes]=$("
").addClass(classes).css({position:"absolute",top:0,left:0,bottom:0,right:0}).appendTo(this.textContainer)}return layer};Canvas.prototype.getTextInfo=function(layer,text,font,angle,width){var textStyle,layerCache,styleCache,info;text=""+text;if(typeof font==="object"){textStyle=font.style+" "+font.variant+" "+font.weight+" "+font.size+"px/"+font.lineHeight+"px "+font.family}else{textStyle=font}layerCache=this._textCache[layer];if(layerCache==null){layerCache=this._textCache[layer]={}}styleCache=layerCache[textStyle];if(styleCache==null){styleCache=layerCache[textStyle]={}}info=styleCache[text];if(info==null){var element=$("
").html(text).css({position:"absolute","max-width":width,top:-9999}).appendTo(this.getTextLayer(layer));if(typeof font==="object"){element.css({font:textStyle,color:font.color})}else if(typeof font==="string"){element.addClass(font)}info=styleCache[text]={width:element.outerWidth(true),height:element.outerHeight(true),element:element,positions:[]};element.detach()}return info};Canvas.prototype.addText=function(layer,x,y,text,font,angle,width,halign,valign){var info=this.getTextInfo(layer,text,font,angle,width),positions=info.positions;if(halign=="center"){x-=info.width/2}else if(halign=="right"){x-=info.width}if(valign=="middle"){y-=info.height/2}else if(valign=="bottom"){y-=info.height}for(var i=0,position;position=positions[i];i++){if(position.x==x&&position.y==y){position.active=true;return}}position={active:true,rendered:false,element:positions.length?info.element.clone():info.element,x:x,y:y};positions.push(position);position.element.css({top:Math.round(y),left:Math.round(x),"text-align":halign})};Canvas.prototype.removeText=function(layer,x,y,text,font,angle){if(text==null){var layerCache=this._textCache[layer];if(layerCache!=null){for(var styleKey in layerCache){if(hasOwnProperty.call(layerCache,styleKey)){var styleCache=layerCache[styleKey];for(var key in styleCache){if(hasOwnProperty.call(styleCache,key)){var positions=styleCache[key].positions;for(var i=0,position;position=positions[i];i++){position.active=false}}}}}}}else{var positions=this.getTextInfo(layer,text,font,angle).positions;for(var i=0,position;position=positions[i];i++){if(position.x==x&&position.y==y){position.active=false}}}};function Plot(placeholder,data_,options_,plugins){var series=[],options={colors:["#edc240","#afd8f8","#cb4b4b","#4da74d","#9440ed"],legend:{show:true,noColumns:1,labelFormatter:null,labelBoxBorderColor:"#ccc",container:null,position:"ne",margin:5,backgroundColor:null,backgroundOpacity:.85,sorted:null},xaxis:{show:null,position:"bottom",mode:null,font:null,color:null,tickColor:null,transform:null,inverseTransform:null,min:null,max:null,autoscaleMargin:null,ticks:null,tickFormatter:null,labelWidth:null,labelHeight:null,reserveSpace:null,tickLength:null,alignTicksWithAxis:null,tickDecimals:null,tickSize:null,minTickSize:null},yaxis:{autoscaleMargin:.02,position:"left"},xaxes:[],yaxes:[],series:{points:{show:false,radius:3,lineWidth:2,fill:true,fillColor:"#ffffff",symbol:"circle"},lines:{lineWidth:2,fill:false,fillColor:null,steps:false},bars:{show:false,lineWidth:2,barWidth:1,fill:true,fillColor:null,align:"left",horizontal:false,zero:true},shadowSize:3,highlightColor:null},grid:{show:true,aboveData:false,color:"#545454",backgroundColor:null,borderColor:null,tickColor:null,margin:0,labelMargin:5,axisMargin:8,borderWidth:2,minBorderMargin:null,markings:null,markingsColor:"#f4f4f4",markingsLineWidth:2,clickable:false,hoverable:false,autoHighlight:true,mouseActiveRadius:10},interaction:{redrawOverlayInterval:1e3/60},hooks:{}},surface=null,overlay=null,eventHolder=null,ctx=null,octx=null,xaxes=[],yaxes=[],plotOffset={left:0,right:0,top:0,bottom:0},plotWidth=0,plotHeight=0,hooks={processOptions:[],processRawData:[],processDatapoints:[],processOffset:[],drawBackground:[],drawSeries:[],draw:[],bindEvents:[],drawOverlay:[],shutdown:[]},plot=this;plot.setData=setData;plot.setupGrid=setupGrid;plot.draw=draw;plot.getPlaceholder=function(){return placeholder};plot.getCanvas=function(){return surface.element};plot.getPlotOffset=function(){return plotOffset};plot.width=function(){return plotWidth};plot.height=function(){return plotHeight};plot.offset=function(){var o=eventHolder.offset();o.left+=plotOffset.left;o.top+=plotOffset.top;return o};plot.getData=function(){return series};plot.getAxes=function(){var res={},i;$.each(xaxes.concat(yaxes),function(_,axis){if(axis)res[axis.direction+(axis.n!=1?axis.n:"")+"axis"]=axis});return res};plot.getXAxes=function(){return xaxes};plot.getYAxes=function(){return yaxes};plot.c2p=canvasToAxisCoords;plot.p2c=axisToCanvasCoords;plot.getOptions=function(){return options};plot.highlight=highlight;plot.unhighlight=unhighlight;plot.triggerRedrawOverlay=triggerRedrawOverlay;plot.pointOffset=function(point){return{left:parseInt(xaxes[axisNumber(point,"x")-1].p2c(+point.x)+plotOffset.left,10),top:parseInt(yaxes[axisNumber(point,"y")-1].p2c(+point.y)+plotOffset.top,10)}};plot.shutdown=shutdown;plot.destroy=function(){shutdown();placeholder.removeData("plot").empty();series=[];options=null;surface=null;overlay=null;eventHolder=null;ctx=null;octx=null;xaxes=[];yaxes=[];hooks=null;highlights=[];plot=null};plot.resize=function(){var width=placeholder.width(),height=placeholder.height();surface.resize(width,height);overlay.resize(width,height)};plot.hooks=hooks;initPlugins(plot);parseOptions(options_);setupCanvases();setData(data_);setupGrid();draw();bindEvents();function executeHooks(hook,args){args=[plot].concat(args);for(var i=0;imaxIndex){maxIndex=sc}}}if(neededColors<=maxIndex){neededColors=maxIndex+1}var c,colors=[],colorPool=options.colors,colorPoolSize=colorPool.length,variation=0;for(i=0;i=0){if(variation<.5){variation=-variation-.2}else variation=0}else variation=-variation}colors[i]=c.scale("rgb",1+variation)}var colori=0,s;for(i=0;iaxis.datamax&&max!=fakeInfinity)axis.datamax=max}$.each(allAxes(),function(_,axis){axis.datamin=topSentry;axis.datamax=bottomSentry;axis.used=false});for(i=0;i0&&points[k-ps]!=null&&points[k-ps]!=points[k]&&points[k-ps+1]!=points[k+1]){for(m=0;mxmax)xmax=val}if(f.y){if(valymax)ymax=val}}}if(s.bars.show){var delta;switch(s.bars.align){case"left":delta=0;break;case"right":delta=-s.bars.barWidth;break;default:delta=-s.bars.barWidth/2}if(s.bars.horizontal){ymin+=delta;ymax+=delta+s.bars.barWidth}else{xmin+=delta;xmax+=delta+s.bars.barWidth}}updateAxis(s.xaxis,xmin,xmax);updateAxis(s.yaxis,ymin,ymax)}$.each(allAxes(),function(_,axis){if(axis.datamin==topSentry)axis.datamin=null;if(axis.datamax==bottomSentry)axis.datamax=null})}function setupCanvases(){placeholder.css("padding",0).children().filter(function(){return!$(this).hasClass("flot-overlay")&&!$(this).hasClass("flot-base")}).remove();if(placeholder.css("position")=="static")placeholder.css("position","relative");surface=new Canvas("flot-base",placeholder);overlay=new Canvas("flot-overlay",placeholder);ctx=surface.context;octx=overlay.context;eventHolder=$(overlay.element).unbind();var existing=placeholder.data("plot");if(existing){existing.shutdown();overlay.clear()}placeholder.data("plot",plot)}function bindEvents(){if(options.grid.hoverable){eventHolder.mousemove(onMouseMove);eventHolder.bind("mouseleave",onMouseLeave)}if(options.grid.clickable)eventHolder.click(onClick);executeHooks(hooks.bindEvents,[eventHolder])}function shutdown(){if(redrawTimeout)clearTimeout(redrawTimeout);eventHolder.unbind("mousemove",onMouseMove);eventHolder.unbind("mouseleave",onMouseLeave);eventHolder.unbind("click",onClick);executeHooks(hooks.shutdown,[eventHolder])}function setTransformationHelpers(axis){function identity(x){return x}var s,m,t=axis.options.transform||identity,it=axis.options.inverseTransform;if(axis.direction=="x"){s=axis.scale=plotWidth/Math.abs(t(axis.max)-t(axis.min));m=Math.min(t(axis.max),t(axis.min))}else{s=axis.scale=plotHeight/Math.abs(t(axis.max)-t(axis.min));s=-s;m=Math.max(t(axis.max),t(axis.min))}if(t==identity)axis.p2c=function(p){return(p-m)*s};else axis.p2c=function(p){return(t(p)-m)*s};if(!it)axis.c2p=function(c){return m+c/s};else axis.c2p=function(c){return it(m+c/s)}}function measureTickLabels(axis){var opts=axis.options,ticks=axis.ticks||[],labelWidth=opts.labelWidth||0,labelHeight=opts.labelHeight||0,maxWidth=labelWidth||(axis.direction=="x"?Math.floor(surface.width/(ticks.length||1)):null),legacyStyles=axis.direction+"Axis "+axis.direction+axis.n+"Axis",layer="flot-"+axis.direction+"-axis flot-"+axis.direction+axis.n+"-axis "+legacyStyles,font=opts.font||"flot-tick-label tickLabel";for(var i=0;i=0;--i)allocateAxisBoxFirstPhase(allocatedAxes[i]);adjustLayoutForThingsStickingOut();$.each(allocatedAxes,function(_,axis){allocateAxisBoxSecondPhase(axis)})}plotWidth=surface.width-plotOffset.left-plotOffset.right;plotHeight=surface.height-plotOffset.bottom-plotOffset.top;$.each(axes,function(_,axis){setTransformationHelpers(axis)});if(showGrid){drawAxisLabels()}insertLegend()}function setRange(axis){var opts=axis.options,min=+(opts.min!=null?opts.min:axis.datamin),max=+(opts.max!=null?opts.max:axis.datamax),delta=max-min;if(delta==0){var widen=max==0?1:.01;if(opts.min==null)min-=widen;if(opts.max==null||opts.min!=null)max+=widen}else{var margin=opts.autoscaleMargin;if(margin!=null){if(opts.min==null){min-=delta*margin;if(min<0&&axis.datamin!=null&&axis.datamin>=0)min=0}if(opts.max==null){max+=delta*margin;if(max>0&&axis.datamax!=null&&axis.datamax<=0)max=0}}}axis.min=min;axis.max=max}function setupTickGeneration(axis){var opts=axis.options;var noTicks;if(typeof opts.ticks=="number"&&opts.ticks>0)noTicks=opts.ticks;else noTicks=.3*Math.sqrt(axis.direction=="x"?surface.width:surface.height);var delta=(axis.max-axis.min)/noTicks,dec=-Math.floor(Math.log(delta)/Math.LN10),maxDec=opts.tickDecimals;if(maxDec!=null&&dec>maxDec){dec=maxDec}var magn=Math.pow(10,-dec),norm=delta/magn,size;if(norm<1.5){size=1}else if(norm<3){size=2;if(norm>2.25&&(maxDec==null||dec+1<=maxDec)){size=2.5;++dec}}else if(norm<7.5){size=5}else{size=10}size*=magn;if(opts.minTickSize!=null&&size0){if(opts.min==null)axis.min=Math.min(axis.min,niceTicks[0]);if(opts.max==null&&niceTicks.length>1)axis.max=Math.max(axis.max,niceTicks[niceTicks.length-1])}axis.tickGenerator=function(axis){var ticks=[],v,i;for(i=0;i1&&/\..*0$/.test((ts[1]-ts[0]).toFixed(extraDec))))axis.tickDecimals=extraDec}}}}function setTicks(axis){var oticks=axis.options.ticks,ticks=[];if(oticks==null||typeof oticks=="number"&&oticks>0)ticks=axis.tickGenerator(axis);else if(oticks){if($.isFunction(oticks))ticks=oticks(axis);else ticks=oticks}var i,v;axis.ticks=[];for(i=0;i1)label=t[1]}else v=+t;if(label==null)label=axis.tickFormatter(v,axis);if(!isNaN(v))axis.ticks.push({v:v,label:label})}}function snapRangeToTicks(axis,ticks){if(axis.options.autoscaleMargin&&ticks.length>0){if(axis.options.min==null)axis.min=Math.min(axis.min,ticks[0].v);if(axis.options.max==null&&ticks.length>1)axis.max=Math.max(axis.max,ticks[ticks.length-1].v)}}function draw(){surface.clear();executeHooks(hooks.drawBackground,[ctx]);var grid=options.grid;if(grid.show&&grid.backgroundColor)drawBackground();if(grid.show&&!grid.aboveData){drawGrid()}for(var i=0;ito){var tmp=from;from=to;to=tmp}return{from:from,to:to,axis:axis}}function drawBackground(){ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.fillStyle=getColorOrGradient(options.grid.backgroundColor,plotHeight,0,"rgba(255, 255, 255, 0)");ctx.fillRect(0,0,plotWidth,plotHeight);ctx.restore()}function drawGrid(){var i,axes,bw,bc;ctx.save();ctx.translate(plotOffset.left,plotOffset.top);var markings=options.grid.markings;if(markings){if($.isFunction(markings)){axes=plot.getAxes();axes.xmin=axes.xaxis.min;axes.xmax=axes.xaxis.max;axes.ymin=axes.yaxis.min;axes.ymax=axes.yaxis.max;markings=markings(axes)}for(i=0;ixrange.axis.max||yrange.toyrange.axis.max)continue;xrange.from=Math.max(xrange.from,xrange.axis.min);xrange.to=Math.min(xrange.to,xrange.axis.max);yrange.from=Math.max(yrange.from,yrange.axis.min);yrange.to=Math.min(yrange.to,yrange.axis.max);var xequal=xrange.from===xrange.to,yequal=yrange.from===yrange.to;if(xequal&&yequal){continue}xrange.from=Math.floor(xrange.axis.p2c(xrange.from));xrange.to=Math.floor(xrange.axis.p2c(xrange.to));yrange.from=Math.floor(yrange.axis.p2c(yrange.from));yrange.to=Math.floor(yrange.axis.p2c(yrange.to));if(xequal||yequal){var lineWidth=m.lineWidth||options.grid.markingsLineWidth,subPixel=lineWidth%2?.5:0;ctx.beginPath();ctx.strokeStyle=m.color||options.grid.markingsColor;ctx.lineWidth=lineWidth;if(xequal){ctx.moveTo(xrange.to+subPixel,yrange.from);ctx.lineTo(xrange.to+subPixel,yrange.to)}else{ctx.moveTo(xrange.from,yrange.to+subPixel);ctx.lineTo(xrange.to,yrange.to+subPixel)}ctx.stroke()}else{ctx.fillStyle=m.color||options.grid.markingsColor;ctx.fillRect(xrange.from,yrange.to,xrange.to-xrange.from,yrange.from-yrange.to)}}}axes=allAxes();bw=options.grid.borderWidth;for(var j=0;jaxis.max||t=="full"&&(typeof bw=="object"&&bw[axis.position]>0||bw>0)&&(v==axis.min||v==axis.max))continue;if(axis.direction=="x"){x=axis.p2c(v);yoff=t=="full"?-plotHeight:t;if(axis.position=="top")yoff=-yoff}else{y=axis.p2c(v);xoff=t=="full"?-plotWidth:t;if(axis.position=="left")xoff=-xoff}if(ctx.lineWidth==1){if(axis.direction=="x")x=Math.floor(x)+.5;else y=Math.floor(y)+.5}ctx.moveTo(x,y);ctx.lineTo(x+xoff,y+yoff)}ctx.stroke()}if(bw){bc=options.grid.borderColor;if(typeof bw=="object"||typeof bc=="object"){if(typeof bw!=="object"){bw={top:bw,right:bw,bottom:bw,left:bw}}if(typeof bc!=="object"){bc={top:bc,right:bc,bottom:bc,left:bc}}if(bw.top>0){ctx.strokeStyle=bc.top;ctx.lineWidth=bw.top;ctx.beginPath();ctx.moveTo(0-bw.left,0-bw.top/2);ctx.lineTo(plotWidth,0-bw.top/2);ctx.stroke()}if(bw.right>0){ctx.strokeStyle=bc.right;ctx.lineWidth=bw.right;ctx.beginPath();ctx.moveTo(plotWidth+bw.right/2,0-bw.top);ctx.lineTo(plotWidth+bw.right/2,plotHeight);ctx.stroke()}if(bw.bottom>0){ctx.strokeStyle=bc.bottom;ctx.lineWidth=bw.bottom;ctx.beginPath();ctx.moveTo(plotWidth+bw.right,plotHeight+bw.bottom/2);ctx.lineTo(0,plotHeight+bw.bottom/2);ctx.stroke()}if(bw.left>0){ctx.strokeStyle=bc.left;ctx.lineWidth=bw.left;ctx.beginPath();ctx.moveTo(0-bw.left/2,plotHeight+bw.bottom);ctx.lineTo(0-bw.left/2,0);ctx.stroke()}}else{ctx.lineWidth=bw;ctx.strokeStyle=options.grid.borderColor;ctx.strokeRect(-bw/2,-bw/2,plotWidth+bw,plotHeight+bw)}}ctx.restore()}function drawAxisLabels(){$.each(allAxes(),function(_,axis){var box=axis.box,legacyStyles=axis.direction+"Axis "+axis.direction+axis.n+"Axis",layer="flot-"+axis.direction+"-axis flot-"+axis.direction+axis.n+"-axis "+legacyStyles,font=axis.options.font||"flot-tick-label tickLabel",tick,x,y,halign,valign;surface.removeText(layer);if(!axis.show||axis.ticks.length==0)return;for(var i=0;iaxis.max)continue;if(axis.direction=="x"){halign="center";x=plotOffset.left+axis.p2c(tick.v);if(axis.position=="bottom"){y=box.top+box.padding}else{y=box.top+box.height-box.padding;valign="bottom"}}else{valign="middle";y=plotOffset.top+axis.p2c(tick.v);if(axis.position=="left"){x=box.left+box.width-box.padding;halign="right"}else{x=box.left+box.padding}}surface.addText(layer,x,y,tick.label,font,null,null,halign,valign)}})}function drawSeries(series){if(series.lines.show)drawSeriesLines(series);if(series.bars.show)drawSeriesBars(series);if(series.points.show)drawSeriesPoints(series)}function drawSeriesLines(series){function plotLine(datapoints,xoffset,yoffset,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize,prevx=null,prevy=null;ctx.beginPath();for(var i=ps;i=y2&&y1>axisy.max){if(y2>axisy.max)continue;x1=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.max}else if(y2>=y1&&y2>axisy.max){if(y1>axisy.max)continue;x2=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.max}if(x1<=x2&&x1=x2&&x1>axisx.max){if(x2>axisx.max)continue;y1=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.max}else if(x2>=x1&&x2>axisx.max){if(x1>axisx.max)continue;y2=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.max}if(x1!=prevx||y1!=prevy)ctx.moveTo(axisx.p2c(x1)+xoffset,axisy.p2c(y1)+yoffset);prevx=x2;prevy=y2;ctx.lineTo(axisx.p2c(x2)+xoffset,axisy.p2c(y2)+yoffset)}ctx.stroke()}function plotLineArea(datapoints,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize,bottom=Math.min(Math.max(0,axisy.min),axisy.max),i=0,top,areaOpen=false,ypos=1,segmentStart=0,segmentEnd=0;while(true){if(ps>0&&i>points.length+ps)break;i+=ps;var x1=points[i-ps],y1=points[i-ps+ypos],x2=points[i],y2=points[i+ypos];if(areaOpen){if(ps>0&&x1!=null&&x2==null){segmentEnd=i;ps=-ps;ypos=2;continue}if(ps<0&&i==segmentStart+ps){ctx.fill();areaOpen=false;ps=-ps;ypos=1;i=segmentStart=segmentEnd+ps;continue}}if(x1==null||x2==null)continue;if(x1<=x2&&x1=x2&&x1>axisx.max){if(x2>axisx.max)continue;y1=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.max}else if(x2>=x1&&x2>axisx.max){if(x1>axisx.max)continue;y2=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.max}if(!areaOpen){ctx.beginPath();ctx.moveTo(axisx.p2c(x1),axisy.p2c(bottom));areaOpen=true}if(y1>=axisy.max&&y2>=axisy.max){ctx.lineTo(axisx.p2c(x1),axisy.p2c(axisy.max));ctx.lineTo(axisx.p2c(x2),axisy.p2c(axisy.max));continue}else if(y1<=axisy.min&&y2<=axisy.min){ctx.lineTo(axisx.p2c(x1),axisy.p2c(axisy.min));ctx.lineTo(axisx.p2c(x2),axisy.p2c(axisy.min));continue}var x1old=x1,x2old=x2;if(y1<=y2&&y1=axisy.min){x1=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.min}else if(y2<=y1&&y2=axisy.min){x2=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.min}if(y1>=y2&&y1>axisy.max&&y2<=axisy.max){x1=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.max}else if(y2>=y1&&y2>axisy.max&&y1<=axisy.max){x2=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.max}if(x1!=x1old){ctx.lineTo(axisx.p2c(x1old),axisy.p2c(y1))}ctx.lineTo(axisx.p2c(x1),axisy.p2c(y1));ctx.lineTo(axisx.p2c(x2),axisy.p2c(y2));if(x2!=x2old){ctx.lineTo(axisx.p2c(x2),axisy.p2c(y2));ctx.lineTo(axisx.p2c(x2old),axisy.p2c(y2))}}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.lineJoin="round";var lw=series.lines.lineWidth,sw=series.shadowSize;if(lw>0&&sw>0){ctx.lineWidth=sw;ctx.strokeStyle="rgba(0,0,0,0.1)";var angle=Math.PI/18;plotLine(series.datapoints,Math.sin(angle)*(lw/2+sw/2),Math.cos(angle)*(lw/2+sw/2),series.xaxis,series.yaxis);ctx.lineWidth=sw/2;plotLine(series.datapoints,Math.sin(angle)*(lw/2+sw/4),Math.cos(angle)*(lw/2+sw/4),series.xaxis,series.yaxis)}ctx.lineWidth=lw;ctx.strokeStyle=series.color;var fillStyle=getFillStyle(series.lines,series.color,0,plotHeight);if(fillStyle){ctx.fillStyle=fillStyle;plotLineArea(series.datapoints,series.xaxis,series.yaxis)}if(lw>0)plotLine(series.datapoints,0,0,series.xaxis,series.yaxis);ctx.restore()}function drawSeriesPoints(series){function plotPoints(datapoints,radius,fillStyle,offset,shadow,axisx,axisy,symbol){var points=datapoints.points,ps=datapoints.pointsize;for(var i=0;iaxisx.max||yaxisy.max)continue;ctx.beginPath();x=axisx.p2c(x);y=axisy.p2c(y)+offset;if(symbol=="circle")ctx.arc(x,y,radius,0,shadow?Math.PI:Math.PI*2,false);else symbol(ctx,x,y,radius,shadow);ctx.closePath();if(fillStyle){ctx.fillStyle=fillStyle;ctx.fill()}ctx.stroke()}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);var lw=series.points.lineWidth,sw=series.shadowSize,radius=series.points.radius,symbol=series.points.symbol;if(lw==0)lw=1e-4;if(lw>0&&sw>0){var w=sw/2;ctx.lineWidth=w;ctx.strokeStyle="rgba(0,0,0,0.1)";plotPoints(series.datapoints,radius,null,w+w/2,true,series.xaxis,series.yaxis,symbol);ctx.strokeStyle="rgba(0,0,0,0.2)";plotPoints(series.datapoints,radius,null,w/2,true,series.xaxis,series.yaxis,symbol)}ctx.lineWidth=lw;ctx.strokeStyle=series.color;plotPoints(series.datapoints,radius,getFillStyle(series.points,series.color),0,false,series.xaxis,series.yaxis,symbol);ctx.restore()}function drawBar(x,y,b,barLeft,barRight,fillStyleCallback,axisx,axisy,c,horizontal,lineWidth){var left,right,bottom,top,drawLeft,drawRight,drawTop,drawBottom,tmp;if(horizontal){drawBottom=drawRight=drawTop=true;drawLeft=false;left=b;right=x;top=y+barLeft;bottom=y+barRight;if(rightaxisx.max||topaxisy.max)return;if(leftaxisx.max){right=axisx.max;drawRight=false}if(bottomaxisy.max){top=axisy.max;drawTop=false}left=axisx.p2c(left);bottom=axisy.p2c(bottom);right=axisx.p2c(right);top=axisy.p2c(top);if(fillStyleCallback){c.fillStyle=fillStyleCallback(bottom,top);c.fillRect(left,top,right-left,bottom-top)}if(lineWidth>0&&(drawLeft||drawRight||drawTop||drawBottom)){c.beginPath();c.moveTo(left,bottom);if(drawLeft)c.lineTo(left,top);else c.moveTo(left,top);if(drawTop)c.lineTo(right,top);else c.moveTo(right,top);if(drawRight)c.lineTo(right,bottom);else c.moveTo(right,bottom);if(drawBottom)c.lineTo(left,bottom);else c.moveTo(left,bottom);c.stroke()}}function drawSeriesBars(series){function plotBars(datapoints,barLeft,barRight,fillStyleCallback,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize;for(var i=0;i");fragments.push("");rowStarted=true}fragments.push('
'+''+entry.label+"")}if(rowStarted)fragments.push("");if(fragments.length==0)return;var table=''+fragments.join("")+"
";if(options.legend.container!=null)$(options.legend.container).html(table);else{var pos="",p=options.legend.position,m=options.legend.margin;if(m[0]==null)m=[m,m];if(p.charAt(0)=="n")pos+="top:"+(m[1]+plotOffset.top)+"px;";else if(p.charAt(0)=="s")pos+="bottom:"+(m[1]+plotOffset.bottom)+"px;";if(p.charAt(1)=="e")pos+="right:"+(m[0]+plotOffset.right)+"px;";else if(p.charAt(1)=="w")pos+="left:"+(m[0]+plotOffset.left)+"px;";var legend=$('
'+table.replace('style="','style="position:absolute;'+pos+";")+"
").appendTo(placeholder);if(options.legend.backgroundOpacity!=0){var c=options.legend.backgroundColor;if(c==null){c=options.grid.backgroundColor;if(c&&typeof c=="string")c=$.color.parse(c);else c=$.color.extract(legend,"background-color");c.a=1;c=c.toString()}var div=legend.children();$('
').prependTo(legend).css("opacity",options.legend.backgroundOpacity)}}}var highlights=[],redrawTimeout=null;function findNearbyItem(mouseX,mouseY,seriesFilter){var maxDistance=options.grid.mouseActiveRadius,smallestDistance=maxDistance*maxDistance+1,item=null,foundPoint=false,i,j,ps;for(i=series.length-1;i>=0;--i){if(!seriesFilter(series[i]))continue;var s=series[i],axisx=s.xaxis,axisy=s.yaxis,points=s.datapoints.points,mx=axisx.c2p(mouseX),my=axisy.c2p(mouseY),maxx=maxDistance/axisx.scale,maxy=maxDistance/axisy.scale;ps=s.datapoints.pointsize;if(axisx.options.inverseTransform)maxx=Number.MAX_VALUE;if(axisy.options.inverseTransform)maxy=Number.MAX_VALUE;if(s.lines.show||s.points.show){for(j=0;jmaxx||x-mx<-maxx||y-my>maxy||y-my<-maxy)continue;var dx=Math.abs(axisx.p2c(x)-mouseX),dy=Math.abs(axisy.p2c(y)-mouseY),dist=dx*dx+dy*dy;if(dist=Math.min(b,x)&&my>=y+barLeft&&my<=y+barRight:mx>=x+barLeft&&mx<=x+barRight&&my>=Math.min(b,y)&&my<=Math.max(b,y))item=[i,j/ps]}}}if(item){i=item[0];j=item[1];ps=series[i].datapoints.pointsize;return{datapoint:series[i].datapoints.points.slice(j*ps,(j+1)*ps),dataIndex:j,series:series[i],seriesIndex:i}}return null}function onMouseMove(e){if(options.grid.hoverable)triggerClickHoverEvent("plothover",e,function(s){return s["hoverable"]!=false})}function onMouseLeave(e){if(options.grid.hoverable)triggerClickHoverEvent("plothover",e,function(s){return false})}function onClick(e){triggerClickHoverEvent("plotclick",e,function(s){return s["clickable"]!=false})}function triggerClickHoverEvent(eventname,event,seriesFilter){var offset=eventHolder.offset(),canvasX=event.pageX-offset.left-plotOffset.left,canvasY=event.pageY-offset.top-plotOffset.top,pos=canvasToAxisCoords({left:canvasX,top:canvasY});pos.pageX=event.pageX;pos.pageY=event.pageY;var item=findNearbyItem(canvasX,canvasY,seriesFilter);if(item){item.pageX=parseInt(item.series.xaxis.p2c(item.datapoint[0])+offset.left+plotOffset.left,10);item.pageY=parseInt(item.series.yaxis.p2c(item.datapoint[1])+offset.top+plotOffset.top,10)}if(options.grid.autoHighlight){for(var i=0;iaxisx.max||yaxisy.max)return;var pointRadius=series.points.radius+series.points.lineWidth/2;octx.lineWidth=pointRadius;octx.strokeStyle=highlightColor;var radius=1.5*pointRadius;x=axisx.p2c(x);y=axisy.p2c(y);octx.beginPath();if(series.points.symbol=="circle")octx.arc(x,y,radius,0,2*Math.PI,false);else series.points.symbol(octx,x,y,radius,false);octx.closePath();octx.stroke()}function drawBarHighlight(series,point){var highlightColor=typeof series.highlightColor==="string"?series.highlightColor:$.color.parse(series.color).scale("a",.5).toString(),fillStyle=highlightColor,barLeft;switch(series.bars.align){case"left":barLeft=0;break;case"right":barLeft=-series.bars.barWidth;break;default:barLeft=-series.bars.barWidth/2}octx.lineWidth=series.bars.lineWidth;octx.strokeStyle=highlightColor;drawBar(point[0],point[1],point[2]||0,barLeft,barLeft+series.bars.barWidth,function(){return fillStyle},series.xaxis,series.yaxis,octx,series.bars.horizontal,series.bars.lineWidth)}function getColorOrGradient(spec,bottom,top,defaultColor){if(typeof spec=="string")return spec;else{var gradient=ctx.createLinearGradient(0,top,0,bottom);for(var i=0,l=spec.colors.length;i12){hours12=hours-12}else if(hours==0){hours12=12}else{hours12=hours}for(var i=0;i=minSize){break}}var size=spec[i][0];var unit=spec[i][1];if(unit=="year"){if(opts.minTickSize!=null&&opts.minTickSize[1]=="year"){size=Math.floor(opts.minTickSize[0])}else{var magn=Math.pow(10,Math.floor(Math.log(axis.delta/timeUnitSize.year)/Math.LN10));var norm=axis.delta/timeUnitSize.year/magn;if(norm<1.5){size=1}else if(norm<3){size=2}else if(norm<7.5){size=5}else{size=10}size*=magn}if(size<1){size=1}}axis.tickSize=opts.tickSize||[size,unit];var tickSize=axis.tickSize[0];unit=axis.tickSize[1];var step=tickSize*timeUnitSize[unit];if(unit=="second"){d.setSeconds(floorInBase(d.getSeconds(),tickSize))}else if(unit=="minute"){d.setMinutes(floorInBase(d.getMinutes(),tickSize))}else if(unit=="hour"){d.setHours(floorInBase(d.getHours(),tickSize))}else if(unit=="month"){d.setMonth(floorInBase(d.getMonth(),tickSize))}else if(unit=="quarter"){d.setMonth(3*floorInBase(d.getMonth()/3,tickSize))}else if(unit=="year"){d.setFullYear(floorInBase(d.getFullYear(),tickSize))}d.setMilliseconds(0);if(step>=timeUnitSize.minute){d.setSeconds(0)}if(step>=timeUnitSize.hour){d.setMinutes(0)}if(step>=timeUnitSize.day){d.setHours(0)}if(step>=timeUnitSize.day*4){d.setDate(1)}if(step>=timeUnitSize.month*2){d.setMonth(floorInBase(d.getMonth(),3))}if(step>=timeUnitSize.quarter*2){d.setMonth(floorInBase(d.getMonth(),6))}if(step>=timeUnitSize.year){d.setMonth(0)}var carry=0;var v=Number.NaN;var prev;do{prev=v;v=d.getTime();ticks.push(v);if(unit=="month"||unit=="quarter"){if(tickSize<1){d.setDate(1);var start=d.getTime();d.setMonth(d.getMonth()+(unit=="quarter"?3:1));var end=d.getTime();d.setTime(v+carry*timeUnitSize.hour+(end-start)*tickSize);carry=d.getHours();d.setHours(0)}else{d.setMonth(d.getMonth()+tickSize*(unit=="quarter"?3:1))}}else if(unit=="year"){d.setFullYear(d.getFullYear()+tickSize)}else{d.setTime(v+step)}}while(v[\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.review_requests), + (r'^reviews/manage/$', views_review.manage_review_requests), + (r'^reviews/email-assignments/$', views_review.email_open_review_assignments), + (r'^reviewers/$', views_review.reviewer_overview), + (r'^reviewers/(?P[\w%+-.@]+)/settings/$', views_review.change_reviewer_settings), url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'), ) diff --git a/ietf/group/utils.py b/ietf/group/utils.py index 1287f826e..6f998da0b 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -1,6 +1,8 @@ import os from django.shortcuts import get_object_or_404 +from django.utils.safestring import mark_safe +from django.core.urlresolvers import reverse as urlreverse import debug # pyflakes:ignore @@ -9,8 +11,8 @@ from ietf.person.models import Email from ietf.utils.history import get_history_object_for, copy_many_to_many_for_history from ietf.ietfauth.utils import has_role from ietf.community.models import CommunityList, SearchRule -from ietf.community.utils import reset_name_contains_index_for_rule -from ietf.doc.models import State +from ietf.community.utils import reset_name_contains_index_for_rule, can_manage_community_list +from ietf.doc.models import Document, State def save_group_in_history(group): @@ -149,3 +151,86 @@ def setup_default_community_list_for_group(group): state=State.objects.get(slug="active", type="draft"), ) reset_name_contains_index_for_rule(related_docs_rule) + +def get_group_materials(group): + return Document.objects.filter( + group=group, + type__in=group.features.material_types + ).exclude(states__slug__in=['deleted','archived']) + +def construct_group_menu_context(request, group, selected, group_type, others): + """Return context with info for the group menu filled in.""" + kwargs = dict(acronym=group.acronym) + if group_type: + kwargs["group_type"] = group_type + + # menu entries + entries = [] + if group.features.has_documents: + entries.append(("Documents", urlreverse("ietf.group.views.group_documents", kwargs=kwargs))) + if group.features.has_chartering_process: + entries.append(("Charter", urlreverse("group_charter", kwargs=kwargs))) + else: + 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: + import ietf.group.views_review + entries.append(("Review requests", urlreverse(ietf.group.views_review.review_requests, kwargs=kwargs))) + entries.append(("Reviewers", urlreverse(ietf.group.views_review.reviewer_overview, 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))) + entries.append(("Photos", urlreverse("ietf.group.views.group_photos", kwargs=kwargs))) + entries.append(("Email expansions", urlreverse("ietf.group.views.email", kwargs=kwargs))) + if group.list_archive.startswith("http:") or group.list_archive.startswith("https:") or group.list_archive.startswith("ftp:"): + if 'mailarchive.ietf.org' in group.list_archive: + entries.append(("List archive", urlreverse("ietf.group.views.derived_archives", kwargs=kwargs))) + else: + entries.append((mark_safe("List archive »"), group.list_archive)) + if group.has_tools_page(): + entries.append((mark_safe("Tools »"), "https://tools.ietf.org/%s/%s/" % (group.type_id, group.acronym))) + + # actions + actions = [] + + 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_admin or can_manage): + actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs))) + + if group.features.has_documents: + clist = CommunityList.objects.filter(group=group).first() + if clist and can_manage_community_list(request.user, clist): + import ietf.community.views + actions.append((u'Manage document list', urlreverse(ietf.community.views.manage_list, kwargs=kwargs))) + + 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.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_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: + actions.append((u"Request closing group", urlreverse("ietf.group.views_edit.conclude", kwargs=kwargs))) + + d = { + "group": group, + "selected_menu_entry": selected, + "menu_entries": entries, + "menu_actions": actions, + "group_type": group_type, + } + + d.update(others) + + return d diff --git a/ietf/group/views.py b/ietf/group/views.py index b26ac7194..d24aeb24e 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -49,18 +49,19 @@ from django.conf import settings from django.core.urlresolvers import reverse as urlreverse from django.views.decorators.cache import cache_page from django.db.models import Q -from django.utils.safestring import mark_safe -from ietf.doc.models import Document, State, DocAlias, RelatedDocument +from ietf.doc.models import State, DocAlias, RelatedDocument from ietf.doc.utils import get_chartering_type from ietf.doc.templatetags.ietf_filters import clean_whitespace from ietf.doc.utils_search import prepare_document_table from ietf.doc.utils_charter import charter_name_for_group from ietf.group.models import Group, Role, ChangeStateGroupEvent from ietf.name.models import GroupTypeName -from ietf.group.utils import get_charter_text, can_manage_group_type, can_manage_group, milestone_reviewer_for_group_type, can_provide_status_update -from ietf.group.utils import can_manage_materials, get_group_or_404 -from ietf.community.utils import docs_tracked_by_community_list, can_manage_community_list +from ietf.group.utils import (get_charter_text, can_manage_group_type, can_manage_group, + milestone_reviewer_for_group_type, can_provide_status_update, + can_manage_materials, get_group_or_404, + construct_group_menu_context, get_group_materials) +from ietf.community.utils import docs_tracked_by_community_list from ietf.community.models import CommunityList, EmailSubscription from ietf.utils.pipe import pipe from ietf.utils.textupload import get_cleaned_text_file_content @@ -70,6 +71,7 @@ from ietf.ietfauth.utils import has_role from ietf.meeting.utils import group_sessions from ietf.meeting.helpers import get_meeting + def roles(group, role_name): return Role.objects.filter(group=group, name=role_name).select_related("email", "person") @@ -327,79 +329,6 @@ def concluded_groups(request): return render(request, 'group/concluded_groups.html', dict(sections=sections)) -def get_group_materials(group): -# return Document.objects.filter(group=group, type__in=group.features.material_types, session=None).exclude(states__slug="deleted") - return Document.objects.filter(group=group, type__in=group.features.material_types).exclude(states__slug__in=['deleted','archived']) - -def construct_group_menu_context(request, group, selected, group_type, others): - """Return context with info for the group menu filled in.""" - kwargs = dict(acronym=group.acronym) - if group_type: - kwargs["group_type"] = group_type - - # menu entries - entries = [] - if group.features.has_documents: - entries.append(("Documents", urlreverse("ietf.group.views.group_documents", kwargs=kwargs))) - if group.features.has_chartering_process: - entries.append(("Charter", urlreverse("group_charter", kwargs=kwargs))) - else: - 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.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))) - entries.append(("Photos", urlreverse("ietf.group.views.group_photos", kwargs=kwargs))) - entries.append(("Email expansions", urlreverse("ietf.group.views.email", kwargs=kwargs))) - if group.list_archive.startswith("http:") or group.list_archive.startswith("https:") or group.list_archive.startswith("ftp:"): - if 'mailarchive.ietf.org' in group.list_archive: - entries.append(("List archive", urlreverse("ietf.group.views.derived_archives", kwargs=kwargs))) - else: - entries.append((mark_safe("List archive »"), group.list_archive)) - if group.has_tools_page(): - entries.append((mark_safe("Tools »"), "https://tools.ietf.org/%s/%s/" % (group.type_id, group.acronym))) - - # actions - actions = [] - - is_chair = group.has_role(request.user, "chair") - can_manage = can_manage_group(request.user, group) - - if group.features.has_milestones: - if group.state_id != "proposed" and (is_chair or can_manage): - actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs))) - - if group.features.has_documents: - clist = CommunityList.objects.filter(group=group).first() - if clist and can_manage_community_list(request.user, clist): - import ietf.community.views - actions.append((u'Manage document list', urlreverse(ietf.community.views.manage_list, kwargs=kwargs))) - - 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): - actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs))) - - if group.features.customize_workflow and (is_chair 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: - actions.append((u"Request closing group", urlreverse("ietf.group.views_edit.conclude", kwargs=kwargs))) - - d = { - "group": group, - "selected_menu_entry": selected, - "menu_entries": entries, - "menu_actions": actions, - "group_type": group_type, - } - - d.update(others) - - return d - def prepare_group_documents(request, group, clist): found_docs, meta = prepare_document_table(request, docs_tracked_by_community_list(clist), request.GET) diff --git a/ietf/group/views_edit.py b/ietf/group/views_edit.py index bbb15b7e3..f261d31a1 100644 --- a/ietf/group/views_edit.py +++ b/ietf/group/views_edit.py @@ -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": diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py new file mode 100644 index 000000000..6d74dfb51 --- /dev/null +++ b/ietf/group/views_review.py @@ -0,0 +1,532 @@ +import datetime, math, itertools +from collections import defaultdict + +from django.shortcuts import render, redirect, get_object_or_404 +from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect +from django.contrib.auth.decorators import login_required +from django.core.urlresolvers import reverse as urlreverse +from django import forms +from django.template.loader import render_to_string + +from ietf.review.models import ReviewRequest, ReviewerSettings, UnavailablePeriod +from ietf.review.utils import (can_manage_review_requests_for_team, close_review_request_states, + extract_revision_ordered_review_requests_for_documents_and_replaced, + assign_review_request_to_reviewer, + close_review_request, + setup_reviewer_field, + suggested_review_requests_for_team, + unavailable_periods_to_list, + current_unavailable_periods_for_reviewers, + email_reviewer_availability_change, + reviewer_rotation_list, + extract_review_request_data) +from ietf.group.models import Role +from ietf.group.utils import get_group_or_404, construct_group_menu_context +from ietf.person.fields import PersonEmailChoiceField +from ietf.name.models import ReviewRequestStateName +from ietf.utils.mail import send_mail_text +from ietf.utils.fields import DatepickerDateField +from ietf.ietfauth.utils import user_is_person + + +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")) + + unavailable_periods = current_unavailable_periods_for_reviewers(group) + for review_req in open_review_requests: + if review_req.reviewer: + review_req.reviewer_unavailable = any(p.availability == "unavailable" + for p in unavailable_periods.get(review_req.reviewer.person_id, [])) + + open_review_requests = suggested_review_requests_for_team(group) + open_review_requests + + today = datetime.date.today() + for r in open_review_requests: + r.due = max(0, (today - r.deadline).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 reviewer_overview(request, acronym, group_type=None): + group = get_group_or_404(acronym, group_type) + if not group.features.has_reviews: + raise Http404 + + can_manage = can_manage_review_requests_for_team(request.user, group) + + reviewers = reviewer_rotation_list(group) + + reviewer_settings = { s.person_id: s for s in ReviewerSettings.objects.filter(team=group) } + unavailable_periods = defaultdict(list) + for p in unavailable_periods_to_list().filter(team=group): + unavailable_periods[p.person_id].append(p) + reviewer_roles = { r.person_id: r for r in Role.objects.filter(group=group, name="reviewer").select_related("email") } + + today = datetime.date.today() + + extracted_data = extract_review_request_data(teams=[group], time_from=today - datetime.timedelta(days=365), ordering=["reviewer"]) + req_data_for_reviewer = {} + for reviewer, req_data_items in itertools.groupby(extracted_data, key=lambda data: data.reviewer): + l = list(req_data_items) + l.reverse() + req_data_for_reviewer[reviewer] = l + review_state_by_slug = { n.slug: n for n in ReviewRequestStateName.objects.all() } + + for person in reviewers: + person.settings = reviewer_settings.get(person.pk) or ReviewerSettings(team=group, person=person) + person.settings_url = None + person.role = reviewer_roles.get(person.pk) + if person.role and (can_manage or user_is_person(request.user, person)): + person.settings_url = urlreverse("ietf.group.views_review.change_reviewer_settings", kwargs={ "group_type": group_type, "acronym": group.acronym, "reviewer_email": person.role.email.address }) + person.unavailable_periods = unavailable_periods.get(person.pk, []) + person.completely_unavailable = any(p.availability == "unavailable" + and p.start_date <= today and (p.end_date is None or today <= p.end_date) + for p in person.unavailable_periods) + + MAX_REQS = 5 + req_data = req_data_for_reviewer.get(person.pk, []) + open_reqs = sum(1 for d in req_data if d.state in ("requested", "accepted")) + latest_reqs = [] + for d in req_data: + # any open requests pushes the others out + if ((d.state in ("requested", "accepted") and len(latest_reqs) < MAX_REQS) or (len(latest_reqs) + open_reqs < MAX_REQS)): + latest_reqs.append((d.req_pk, d.doc, d.deadline, + review_state_by_slug.get(d.state), + int(math.ceil(d.assignment_to_closure_days)) if d.assignment_to_closure_days is not None else None)) + person.latest_reqs = latest_reqs + + return render(request, 'group/reviewer_overview.html', + construct_group_menu_context(request, group, "reviewers", group_type, { + "reviewers": reviewers, + })) + +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) + + if review_req.pk is None: + self.fields["close"].queryset = self.fields["close"].queryset.filter(slug__in=["no-review-version", "no-review-document"]) + + close_initial = None + if review_req.pk is None: + close_initial = "no-review-version" + elif review_req.reviewer: + close_initial = "no-response" + else: + close_initial = "overtaken" + + if close_initial: + self.fields["close"].initial = close_initial + + 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") + + unavailable_periods = current_unavailable_periods_for_reviewers(group) + + open_review_requests = list(ReviewRequest.objects.filter( + team=group, state__in=("requested", "accepted") + ).prefetch_related("reviewer", "type", "state").order_by("-time", "-id")) + + for review_req in open_review_requests: + if review_req.reviewer: + review_req.reviewer_unavailable = any(p.availability == "unavailable" + for p in unavailable_periods.get(review_req.reviewer.person_id, [])) + + review_requests = suggested_review_requests_for_team(group) + open_review_requests + + document_requests = extract_revision_ordered_review_requests_for_documents_and_replaced( + 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.get(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_review + return redirect(ietf.group.views_review.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, + "rotation_list": reviewer_rotation_list(group)[:10], + }) + + 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, + }) + + +class ReviewerSettingsForm(forms.ModelForm): + class Meta: + model = ReviewerSettings + fields = ['min_interval', 'filter_re', 'skip_next', 'remind_days_before_deadline'] + +class AddUnavailablePeriodForm(forms.ModelForm): + class Meta: + model = UnavailablePeriod + fields = ['start_date', 'end_date', 'availability'] + + def __init__(self, *args, **kwargs): + super(AddUnavailablePeriodForm, self).__init__(*args, **kwargs) + + self.fields["start_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label=self.fields["start_date"].label, help_text=self.fields["start_date"].help_text, required=self.fields["start_date"].required) + self.fields["end_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label=self.fields["end_date"].label, help_text=self.fields["end_date"].help_text, required=self.fields["end_date"].required) + + self.fields['availability'].widget = forms.RadioSelect(choices=UnavailablePeriod.LONG_AVAILABILITY_CHOICES) + + def clean(self): + start = self.cleaned_data.get("start_date") + end = self.cleaned_data.get("end_date") + if start and end and start > end: + self.add_error("start_date", "Start date must be before or equal to end date.") + return self.cleaned_data + +class EndUnavailablePeriodForm(forms.Form): + def __init__(self, start_date, *args, **kwargs): + super(EndUnavailablePeriodForm, self).__init__(*args, **kwargs) + + self.fields["end_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1", "start-date": start_date.isoformat() }) + + self.start_date = start_date + + def clean_end_date(self): + end = self.cleaned_data["end_date"] + if end < self.start_date: + raise forms.ValidationError("End date must be equal to or come after start date.") + return end + + +@login_required +def change_reviewer_settings(request, acronym, reviewer_email, group_type=None): + group = get_group_or_404(acronym, group_type) + if not group.features.has_reviews: + raise Http404 + + reviewer_role = get_object_or_404(Role, name="reviewer", group=group, email=reviewer_email) + reviewer = reviewer_role.person + + if not (user_is_person(request.user, reviewer) + or can_manage_review_requests_for_team(request.user, group)): + return HttpResponseForbidden("You do not have permission to perform this action") + + settings = (ReviewerSettings.objects.filter(person=reviewer, team=group).first() + or ReviewerSettings(person=reviewer, team=group)) + + back_url = request.GET.get("next") + if not back_url: + import ietf.group.views_review + back_url = urlreverse(ietf.group.views_review.reviewer_overview, kwargs={ "group_type": group.type_id, "acronym": group.acronym}) + + # settings + if request.method == "POST" and request.POST.get("action") == "change_settings": + prev_min_interval = settings.get_min_interval_display() + prev_skip_next = settings.skip_next + settings_form = ReviewerSettingsForm(request.POST, instance=settings) + if settings_form.is_valid(): + settings = settings_form.save() + + changes = [] + if settings.get_min_interval_display() != prev_min_interval: + changes.append("Frequency changed to \"{}\" from \"{}\".".format(settings.get_min_interval_display(), prev_min_interval)) + if settings.skip_next != prev_skip_next: + changes.append("Skip next assignments changed to {} from {}.".format(settings.skip_next, prev_skip_next)) + + if changes: + email_reviewer_availability_change(request, group, reviewer_role, "\n\n".join(changes), request.user.person) + + return HttpResponseRedirect(back_url) + else: + settings_form = ReviewerSettingsForm(instance=settings) + + # periods + unavailable_periods = unavailable_periods_to_list().filter(person=reviewer, team=group) + + if request.method == "POST" and request.POST.get("action") == "add_period": + period_form = AddUnavailablePeriodForm(request.POST) + if period_form.is_valid(): + period = period_form.save(commit=False) + period.team = group + period.person = reviewer + period.save() + + today = datetime.date.today() + + in_the_past = period.end_date and period.end_date < today + + if not in_the_past: + msg = "Unavailable for review: {} - {} ({})".format( + period.start_date.isoformat(), + period.end_date.isoformat() if period.end_date else "indefinite", + period.get_availability_display(), + ) + + if period.availability == "unavailable": + # the secretary might need to reassign + # assignments, so mention the current ones + + review_reqs = ReviewRequest.objects.filter(state__in=["requested", "accepted"], reviewer=reviewer_role.email, team=group) + msg += "\n\n" + + if review_reqs: + msg += "{} is currently assigned to review:".format(reviewer_role.person) + for r in review_reqs: + msg += "\n\n" + msg += "{} (deadline: {})".format(r.doc_id, r.deadline.isoformat()) + else: + msg += "{} does not have any assignments currently.".format(reviewer_role.person) + + email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person) + + return HttpResponseRedirect(request.get_full_path()) + else: + period_form = AddUnavailablePeriodForm() + + if request.method == "POST" and request.POST.get("action") == "delete_period": + period_id = request.POST.get("period_id") + if period_id is not None: + for period in unavailable_periods: + if str(period.pk) == period_id: + period.delete() + + today = datetime.date.today() + + in_the_past = period.end_date and period.end_date < today + + if not in_the_past: + msg = "Removed unavailable period: {} - {} ({})".format( + period.start_date.isoformat(), + period.end_date.isoformat() if period.end_date else "indefinite", + period.get_availability_display(), + ) + + email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person) + + return HttpResponseRedirect(request.get_full_path()) + + for p in unavailable_periods: + if not p.end_date: + p.end_form = EndUnavailablePeriodForm(p.start_date, request.POST if request.method == "POST" and request.POST.get("action") == "end_period" else None) + + if request.method == "POST" and request.POST.get("action") == "end_period": + period_id = request.POST.get("period_id") + for period in unavailable_periods: + if str(period.pk) == period_id: + if not period.end_date and period.end_form.is_valid(): + period.end_date = period.end_form.cleaned_data["end_date"] + period.save() + + msg = "Set end date of unavailable period: {} - {} ({})".format( + period.start_date.isoformat(), + period.end_date.isoformat() if period.end_date else "indefinite", + period.get_availability_display(), + ) + + email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person) + + return HttpResponseRedirect(request.get_full_path()) + + + return render(request, 'group/change_reviewer_settings.html', { + 'group': group, + 'reviewer_email': reviewer_email, + 'back_url': back_url, + 'settings_form': settings_form, + 'period_form': period_form, + 'unavailable_periods': unavailable_periods, + }) diff --git a/ietf/iesg/agenda.py b/ietf/iesg/agenda.py index 3b34b1a18..1a0f00a05 100644 --- a/ietf/iesg/agenda.py +++ b/ietf/iesg/agenda.py @@ -9,7 +9,7 @@ from django.http import Http404 from ietf.doc.models import Document, TelechatDocEvent, LastCallDocEvent, ConsensusDocEvent from ietf.iesg.models import TelechatDate, TelechatAgendaItem - +from ietf.review.utils import review_requests_to_list_for_docs def get_agenda_date(date=None): if not date: @@ -152,6 +152,8 @@ def fill_in_agenda_docs(date, sections, matches=None): matches = Document.objects.filter(docevent__telechatdocevent__telechat_date=date) matches = matches.select_related("stream", "group").distinct() + review_requests_for_docs = review_requests_to_list_for_docs(matches) + for doc in matches: if doc.latest_event(TelechatDocEvent, type="scheduled_for_telechat").telechat_date != date: continue @@ -174,6 +176,8 @@ def fill_in_agenda_docs(date, sections, matches=None): e = doc.latest_event(ConsensusDocEvent, type="changed_consensus") if e and (e.consensus != None): doc.consensus = "Yes" if e.consensus else "No" + + doc.review_requests = review_requests_for_docs.get(doc.pk, []) elif doc.type_id == "conflrev": doc.conflictdoc = doc.relateddocument_set.get(relationship__slug='conflrev').target.document elif doc.type_id == "charter": diff --git a/ietf/ietfauth/forms.py b/ietf/ietfauth/forms.py index a7d2e48ce..08b0ee9d4 100644 --- a/ietf/ietfauth/forms.py +++ b/ietf/ietfauth/forms.py @@ -3,7 +3,6 @@ import re from django import forms from django.conf import settings from django.core.exceptions import ValidationError -from django.forms import ModelForm from django.db import models from django.contrib.auth.models import User from django.utils.html import mark_safe @@ -68,7 +67,7 @@ def get_person_form(*args, **kwargs): if not roles: exclude_list += ['biography', 'photo', ] - class PersonForm(ModelForm): + class PersonForm(forms.ModelForm): class Meta: model = Person exclude = exclude_list @@ -161,7 +160,7 @@ class ResetPasswordForm(forms.Form): class TestEmailForm(forms.Form): email = forms.EmailField(required=False) -class WhitelistForm(ModelForm): +class WhitelistForm(forms.ModelForm): class Meta: model = Whitelisted exclude = ['by', 'time' ] diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index 335a5f489..b3ede910c 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import os, shutil, time +import os, shutil, time, datetime from urlparse import urlsplit from pyquery import PyQuery from unittest import skipIf @@ -12,12 +12,13 @@ from django.contrib.auth.models import User from django.conf import settings from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent -from ietf.utils.test_data import make_test_data +from ietf.utils.test_data import make_test_data, make_review_data from ietf.utils.mail import outbox, empty_outbox from ietf.person.models import Person, Email from ietf.group.models import Group, Role, RoleName from ietf.ietfauth.htpasswd import update_htpasswd_file from ietf.mailinglists.models import Subscribed +from ietf.review.models import ReviewWish, UnavailablePeriod from ietf.utils.decorators import skip_coverage import ietf.ietfauth.views @@ -338,6 +339,48 @@ class IetfAuthTests(TestCase): self.assertEqual(len(q("form .has-error")), 0) self.assertTrue(self.username_in_htpasswd_file(user.username)) + def test_review_overview(self): + doc = make_test_data() + + reviewer = Person.objects.get(name="Plain Man") + + review_req = make_review_data(doc) + review_req.reviewer = reviewer.email_set.first() + review_req.save() + + UnavailablePeriod.objects.create( + team=review_req.team, + person=reviewer, + start_date=datetime.date.today() - datetime.timedelta(days=10), + availability="unavailable", + ) + + url = urlreverse(ietf.ietfauth.views.review_overview) + + login_testing_unauthorized(self, reviewer.user.username, url) + + # get + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(review_req.doc.name in unicontent(r)) + + # wish to review + r = self.client.post(url, { + "action": "add_wish", + 'doc': doc.pk, + "team": review_req.team_id, + }) + self.assertEqual(r.status_code, 302) + self.assertEqual(ReviewWish.objects.filter(doc=doc, team=review_req.team).count(), 1) + + # delete wish + r = self.client.post(url, { + "action": "delete_wish", + 'wish_id': ReviewWish.objects.get(doc=doc, team=review_req.team).pk, + }) + self.assertEqual(r.status_code, 302) + self.assertEqual(ReviewWish.objects.filter(doc=doc, team=review_req.team).count(), 0) + def test_htpasswd_file_with_python(self): # make sure we test both Python and call-out to binary settings.USE_PYTHON_HTDIGEST = True diff --git a/ietf/ietfauth/urls.py b/ietf/ietfauth/urls.py index e7c4f0ed0..bb40446a5 100644 --- a/ietf/ietfauth/urls.py +++ b/ietf/ietfauth/urls.py @@ -21,4 +21,5 @@ urlpatterns = patterns('ietf.ietfauth.views', url(r'^reset/confirm/(?P[^/]+)/$', 'confirm_password_reset'), url(r'^confirmnewemail/(?P[^/]+)/$', 'confirm_new_email'), (r'whitelist/add/?$', add_account_whitelist), + url(r'^review/$', 'review_overview'), ) diff --git a/ietf/ietfauth/utils.py b/ietf/ietfauth/utils.py index 0680762e2..98749c21d 100644 --- a/ietf/ietfauth/utils.py +++ b/ietf/ietfauth/utils.py @@ -69,13 +69,14 @@ def has_role(user, role_names, *args, **kwargs): "Nomcom": Q(person=person, group__type="nomcom", group__acronym__icontains=kwargs.get('year', '0000')), "Liaison Manager": Q(person=person,name="liaiman",group__type="sdo",group__state="active", ), "Authorized Individual": Q(person=person,name="auth",group__type="sdo",group__state="active", ), + "Reviewer": Q(person=person, name="reviewer", group__state="active"), } filter_expr = Q() for r in role_names: filter_expr |= role_qs[r] - user.roles_check_cache[key] = bool(Role.objects.filter(filter_expr)[:1]) + user.roles_check_cache[key] = bool(Role.objects.filter(filter_expr).exists()) return user.roles_check_cache[key] diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index f3707b9bb..3449814dd 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -32,7 +32,8 @@ # Copyright The IETF Trust 2007, All Rights Reserved -from datetime import datetime as DateTime, timedelta as TimeDelta +from datetime import datetime as DateTime, timedelta as TimeDelta, date as Date +from collections import defaultdict from django.conf import settings from django.http import Http404 #, HttpResponse, HttpResponseRedirect @@ -43,17 +44,21 @@ from django.contrib.auth.decorators import login_required import django.core.signing from django.contrib.sites.models import Site from django.contrib.auth.models import User +from django import forms import debug # pyflakes:ignore -from ietf.group.models import Role +from ietf.group.models import Role, Group from ietf.ietfauth.forms import RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm, WhitelistForm from ietf.ietfauth.forms import get_person_form, RoleEmailForm, NewEmailForm from ietf.ietfauth.htpasswd import update_htpasswd_file from ietf.ietfauth.utils import role_required from ietf.mailinglists.models import Subscribed, Whitelisted from ietf.person.models import Person, Email, Alias +from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewWish +from ietf.review.utils import unavailable_periods_to_list from ietf.utils.mail import send_mail +from ietf.doc.fields import SearchableDocumentField def index(request): return render(request, 'registration/index.html') @@ -389,3 +394,74 @@ def add_account_whitelist(request): 'success': success, }) +class AddReviewWishForm(forms.Form): + doc = SearchableDocumentField(label="Document", doc_type="draft") + team = forms.ModelChoiceField(queryset=Group.objects.all(), empty_label="(Choose review team)") + + def __init__(self, teams, *args, **kwargs): + super(AddReviewWishForm, self).__init__(*args, **kwargs) + + f = self.fields["team"] + f.queryset = teams + if len(f.queryset) == 1: + f.initial = f.queryset[0].pk + f.widget = forms.HiddenInput() + +@login_required +def review_overview(request): + open_review_requests = ReviewRequest.objects.filter( + reviewer__person__user=request.user, + state__in=["requested", "accepted"], + ) + today = Date.today() + for r in open_review_requests: + r.due = max(0, (today - r.deadline).days) + + closed_review_requests = ReviewRequest.objects.filter( + reviewer__person__user=request.user, + state__in=["no-response", "part-completed", "completed"], + ).order_by("-time")[:20] + + teams = Group.objects.filter(role__name="reviewer", role__person__user=request.user, state="active") + + settings = { o.team_id: o for o in ReviewerSettings.objects.filter(person__user=request.user, team__in=teams) } + + unavailable_periods = defaultdict(list) + for o in unavailable_periods_to_list().filter(person__user=request.user, team__in=teams): + unavailable_periods[o.team_id].append(o) + + roles = { o.group_id: o for o in Role.objects.filter(name="reviewer", person__user=request.user, group__in=teams) } + + for t in teams: + t.reviewer_settings = settings.get(t.pk) or ReviewerSettings(team=t) + t.unavailable_periods = unavailable_periods.get(t.pk, []) + t.role = roles.get(t.pk) + + if request.method == "POST" and request.POST.get("action") == "add_wish": + review_wish_form = AddReviewWishForm(teams, request.POST) + if review_wish_form.is_valid(): + ReviewWish.objects.get_or_create( + person=request.user.person, + doc=review_wish_form.cleaned_data["doc"], + team=review_wish_form.cleaned_data["team"], + ) + + return redirect(review_overview) + else: + review_wish_form = AddReviewWishForm(teams) + + if request.method == "POST" and request.POST.get("action") == "delete_wish": + wish_id = request.POST.get("wish_id") + if wish_id is not None: + ReviewWish.objects.filter(pk=wish_id, person=request.user.person).delete() + return redirect(review_overview) + + review_wishes = ReviewWish.objects.filter(person__user=request.user).prefetch_related("team") + + return render(request, 'ietfauth/review_overview.html', { + 'open_review_requests': open_review_requests, + 'closed_review_requests': closed_review_requests, + 'teams': teams, + 'review_wishes': review_wishes, + 'review_wish_form': review_wish_form, + }) diff --git a/ietf/name/admin.py b/ietf/name/admin.py index b5e05fa84..c5902c309 100644 --- a/ietf/name/admin.py +++ b/ietf/name/admin.py @@ -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) diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index e46819637..730545b32 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -777,6 +777,17 @@ "model": "name.doctypename", "pk": "bluesheets" }, +{ + "fields": { + "order": 0, + "prefix": "", + "used": true, + "name": "Review", + "desc": "" + }, + "model": "name.doctypename", + "pk": "review" +}, { "fields": { "order": 1, @@ -1780,6 +1791,226 @@ "model": "name.nomineepositionstatename", "pk": "declined" }, +{ + "fields": { + "order": 1, + "used": true, + "name": "Requested", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "requested" +}, +{ + "fields": { + "order": 2, + "used": true, + "name": "Accepted", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "accepted" +}, +{ + "fields": { + "order": 3, + "used": true, + "name": "Rejected", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "rejected" +}, +{ + "fields": { + "order": 4, + "used": true, + "name": "Withdrawn", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "withdrawn" +}, +{ + "fields": { + "order": 5, + "used": true, + "name": "Overtaken by Events", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "overtaken" +}, +{ + "fields": { + "order": 6, + "used": true, + "name": "No Response", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "no-response" +}, +{ + "fields": { + "order": 7, + "used": true, + "name": "Team Will not Review Version", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "no-review-version" +}, +{ + "fields": { + "order": 8, + "used": true, + "name": "Team Will not Review Document", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "no-review-document" +}, +{ + "fields": { + "order": 9, + "used": true, + "name": "Partially Completed", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "part-completed" +}, +{ + "fields": { + "order": 10, + "used": true, + "name": "Completed", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "completed" +}, +{ + "fields": { + "order": 1, + "used": true, + "name": "Serious Issues", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "serious-issues" +}, +{ + "fields": { + "order": 2, + "used": true, + "name": "Has Issues", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "issues" +}, +{ + "fields": { + "order": 3, + "used": true, + "name": "Has Nits", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "nits" +}, +{ + "fields": { + "order": 4, + "used": true, + "name": "Not Ready", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "not-ready" +}, +{ + "fields": { + "order": 5, + "used": true, + "name": "On the Right Track", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "right-track" +}, +{ + "fields": { + "order": 6, + "used": true, + "name": "Almost Ready", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "almost-ready" +}, +{ + "fields": { + "order": 7, + "used": true, + "name": "Ready with Issues", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "ready-issues" +}, +{ + "fields": { + "order": 8, + "used": true, + "name": "Ready with Nits", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "ready-nits" +}, +{ + "fields": { + "order": 9, + "used": true, + "name": "Ready", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "ready" +}, +{ + "fields": { + "order": 1, + "used": true, + "name": "Early", + "desc": "" + }, + "model": "name.reviewtypename", + "pk": "early" +}, +{ + "fields": { + "order": 2, + "used": true, + "name": "Last Call", + "desc": "" + }, + "model": "name.reviewtypename", + "pk": "lc" +}, +{ + "fields": { + "order": 3, + "used": true, + "name": "Telechat", + "desc": "" + }, + "model": "name.reviewtypename", + "pk": "telechat" +}, { "fields": { "order": 0, @@ -1970,6 +2201,16 @@ "model": "name.rolename", "pk": "matman" }, +{ + "fields": { + "order": 14, + "used": true, + "name": "Reviewer", + "desc": "" + }, + "model": "name.rolename", + "pk": "reviewer" +}, { "fields": { "order": 0, @@ -2480,6 +2721,13 @@ "model": "doc.statetype", "pk": "reuse_policy" }, +{ + "fields": { + "label": "Review" + }, + "model": "doc.statetype", + "pk": "review" +}, { "fields": { "used": true, @@ -4227,6 +4475,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, @@ -5253,6 +5527,19 @@ "model": "mailtrigger.mailtrigger", "pk": "charter_internal_review" }, +{ + "fields": { + "cc": [ + "group_mail_list" + ], + "to": [ + "ietf_announce" + ], + "desc": "Recipients when an interim meeting is announced" + }, + "model": "mailtrigger.mailtrigger", + "pk": "interim_announced" +}, { "fields": { "cc": [], @@ -5560,19 +5847,6 @@ "model": "mailtrigger.mailtrigger", "pk": "group_personnel_change" }, -{ - "fields": { - "cc": [ - "group_mail_list" - ], - "to": [ - "ietf_announce" - ], - "desc": "Recipients when an interim meeting is announced" - }, - "model": "mailtrigger.mailtrigger", - "pk": "interim_announced" -}, { "fields": { "cc": [], diff --git a/ietf/name/generate_fixtures.py b/ietf/name/generate_fixtures.py index a413cbd13..753f6b8ad 100644 --- a/ietf/name/generate_fixtures.py +++ b/ietf/name/generate_fixtures.py @@ -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 diff --git a/ietf/name/migrations/0014_reviewrequeststatename_reviewresultname_reviewtypename.py b/ietf/name/migrations/0014_reviewrequeststatename_reviewresultname_reviewtypename.py new file mode 100644 index 000000000..abef1e015 --- /dev/null +++ b/ietf/name/migrations/0014_reviewrequeststatename_reviewresultname_reviewtypename.py @@ -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', '0013_add_group_type_verbose_name_data'), + ] + + 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,), + ), + ] diff --git a/ietf/name/migrations/0015_insert_review_name_data.py b/ietf/name/migrations/0015_insert_review_name_data.py new file mode 100644 index 000000000..dac0d287d --- /dev/null +++ b/ietf/name/migrations/0015_insert_review_name_data.py @@ -0,0 +1,64 @@ +# -*- 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) + + 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', '0014_reviewrequeststatename_reviewresultname_reviewtypename'), + ('group', '0001_initial'), + ('doc', '0001_initial'), + ] + + operations = [ + migrations.RunPython(insert_initial_review_data, noop), + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index 75a35898e..c1c69b4c6 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -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""" + diff --git a/ietf/name/resources.py b/ietf/name/resources.py index 801fd9f29..15bc6d378 100644 --- a/ietf/name/resources.py +++ b/ietf/name/resources.py @@ -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()) + diff --git a/ietf/person/fields.py b/ietf/person/fields.py index 73bf23f5b..d7ba971b3 100644 --- a/ietf/person/fields.py +++ b/ietf/person/fields.py @@ -52,7 +52,7 @@ class SearchablePersonsField(forms.CharField): model=Person, # or Email hint_text="Type in name to search for person.", *args, **kwargs): - kwargs["max_length"] = 1000 + kwargs["max_length"] = 10000 self.max_entries = max_entries self.only_users = only_users self.all_emails = all_emails @@ -147,3 +147,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) + diff --git a/ietf/person/tests.py b/ietf/person/tests.py index 0f5900a66..027884642 100644 --- a/ietf/person/tests.py +++ b/ietf/person/tests.py @@ -8,8 +8,10 @@ from django.core.urlresolvers import reverse as urlreverse import debug # pyflakes:ignore from ietf.person.factories import EmailFactory,PersonFactory +from ietf.person.models import Person from ietf.utils.test_utils import TestCase from ietf.utils.test_data import make_test_data +from ietf.utils.mail import outbox, empty_outbox class PersonTests(TestCase): @@ -68,3 +70,8 @@ class PersonTests(TestCase): # Maybe use ietf.person.cjk.* self.assertEqual(person.ascii_name(), u"Wu Jian Ping") + def test_duplicate_person_name(self): + empty_outbox() + Person.objects.create(name="Duplicate Test") + Person.objects.create(name="Duplicate Test") + self.assertTrue("possible duplicate" in outbox[0]["Subject"].lower()) diff --git a/ietf/review/__init__.py b/ietf/review/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ietf/review/admin.py b/ietf/review/admin.py new file mode 100644 index 000000000..8ad3733a8 --- /dev/null +++ b/ietf/review/admin.py @@ -0,0 +1,71 @@ +from django.contrib import admin + +from ietf.review.models import (ReviewerSettings, UnavailablePeriod, ReviewWish, + ResultUsedInReviewTeam, TypeUsedInReviewTeam, NextReviewerInTeam, + ReviewRequest) + +class ReviewerSettingsAdmin(admin.ModelAdmin): + list_filter = ["team"] + search_fields = ["person__name"] + ordering = ["-id"] + raw_id_fields = ["team", "person"] + +admin.site.register(ReviewerSettings, ReviewerSettingsAdmin) + +class UnavailablePeriodAdmin(admin.ModelAdmin): + list_display = ["person", "team", "start_date", "end_date", "availability"] + list_display_links = ["person"] + list_filter = ["team"] + date_hierarchy = "start_date" + search_fields = ["person__name"] + ordering = ["-id"] + raw_id_fields = ["team", "person"] + +admin.site.register(UnavailablePeriod, UnavailablePeriodAdmin) + +class ReviewWishAdmin(admin.ModelAdmin): + list_display = ["person", "team", "doc"] + list_display_links = ["person"] + list_filter = ["team"] + search_fields = ["person__name"] + ordering = ["-id"] + raw_id_fields = ["team", "person", "doc"] + +admin.site.register(ReviewWish, ReviewWishAdmin) + +class ResultUsedInReviewTeamAdmin(admin.ModelAdmin): + list_display = ["team", "result"] + list_display_links = ["team"] + list_filter = ["team"] + ordering = ["team", "result__order"] + raw_id_fields = ["team"] + +admin.site.register(ResultUsedInReviewTeam, ResultUsedInReviewTeamAdmin) + +class TypeUsedInReviewTeamAdmin(admin.ModelAdmin): + list_display = ["team", "type"] + list_display_links = ["team"] + list_filter = ["team"] + ordering = ["team", "type__order"] + raw_id_fields = ["team"] + +admin.site.register(TypeUsedInReviewTeam, TypeUsedInReviewTeamAdmin) + +class NextReviewerInTeamAdmin(admin.ModelAdmin): + list_display = ["team", "next_reviewer"] + list_display_links = ["team"] + ordering = ["team"] + raw_id_fields = ["team", "next_reviewer"] + +admin.site.register(NextReviewerInTeam, NextReviewerInTeamAdmin) + +class ReviewRequestAdmin(admin.ModelAdmin): + list_display = ["doc", "time", "type", "team", "deadline"] + list_display_links = ["doc"] + list_filter = ["team", "type", "state", "result"] + ordering = ["-id"] + raw_id_fields = ["doc", "team", "requested_by", "reviewer", "review"] + date_hierarchy = "time" + search_fields = ["doc__name", "reviewer__person__name"] + +admin.site.register(ReviewRequest, ReviewRequestAdmin) diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py new file mode 100755 index 000000000..c1ee35629 --- /dev/null +++ b/ietf/review/import_from_review_tool.py @@ -0,0 +1,622 @@ +#!/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, re, itertools +from collections import namedtuple +from django.db import connections +from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, ResultUsedInReviewTeam, + ReviewRequestStateName, ReviewTypeName, TypeUsedInReviewTeam, + UnavailablePeriod, NextReviewerInTeam) +from ietf.group.models import Group, Role, RoleName +from ietf.person.models import Person, Email, Alias +from ietf.doc.models import Document, DocAlias, ReviewRequestDocEvent, NewRevisionDocEvent, DocTypeName, State +from ietf.utils.text import strip_prefix +import argparse +from unidecode import unidecode + +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 + + if "@" in email.person.name and row.name: + old_name = email.person.name + email.person.name = row.name + email.person.ascii = row.name + email.person.save() + print "fixed name of", email, old_name, "->", row.name + + 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 created: + print "created reviewer", reviewer.pk, unicode(reviewer).encode("utf-8") + + if autopolicy_days.get(row.autopolicy): + reviewer.min_interval = autopolicy_days.get(row.autopolicy) + + reviewer.filter_re = row.donotassign + try: + reviewer.skip_next = int(row.autopolicy) + except ValueError: + pass + reviewer.save() + + unavailable_until = parse_timestamp(row.available) + if unavailable_until: + today = datetime.date.today() + end_date = unavailable_until.date() + if end_date >= today: + if end_date >= datetime.date(2020, 1, 1): + # convert hacked end dates to indefinite + end_date = None + + UnavailablePeriod.objects.filter(person=email.person, team=team).delete() + + UnavailablePeriod.objects.create( + team=team, + person=email.person, + start_date=today, + end_date=end_date, + availability="unavailable", + ) + +# 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()) + + +# configuration options +with db_con.cursor() as c: + c.execute("select * from config;") + + for row in namedtuplefetchall(c): + if row.name == "next": # next reviewer + NextReviewerInTeam.objects.filter(team=team).delete() + NextReviewerInTeam.objects.create(team=team, next_reviewer=known_personnel[row.value].person) + + if row.name == "summary-list": # review results used in team + summaries = [v.strip().lower() for v in row.value.split(";") if v.strip()] + + for s in summaries: + ResultUsedInReviewTeam.objects.get_or_create(team=team, result=results[s]) + +for t in ReviewTypeName.objects.filter(slug__in=["early", "lc", "telechat"]): + TypeUsedInReviewTeam.objects.get_or_create(team=team, type=t) + +# review requests + +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 + +document_history = {} + +requested_re = re.compile("(?:ADDED docname =>|Created: remote=|AUTO UPDATED status TO new|CHANGED status FROM [^ ]+ => new|CHANGE status done => working)") + +add_docstatus_re = re.compile('([a-zA-Z-_@.]+) ADD docstatus => (\w+)') +update_docstatus_re = re.compile('([a-zA-Z-_@.]+) (?:UPDATE|CHANGE) docstatus \w+ => (\w+)') +iesgstatus_re = re.compile('(?:ADD|ADDED|CHANGED) iesgstatus (?:FROM )?(?:[^ ]+ )?=> ([a-zA-Z-_]+)?') + +deadline_re = re.compile('(?:ADD|ADDED|CHANGED) deadline (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)') +telechat_re = re.compile('(?:ADD|ADDED|CHANGED) telechat (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)') +lcend_re = re.compile('(?:ADD|ADDED|CHANGED) lcend (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)') + +close_states = ["done", "rejected", "withdrawn", "noresponse"] + +document_blacklist = set([(u"tsvdir", u"draft-arkko-ipv6-transition-guidelines-09 ")]) + +with db_con.cursor() as c: + c.execute("""select docname, time, who, text from doclog where + text like 'Created: remote=%' + or text like '%ADDED docname => %' + or text like '%AUTO UPDATED status TO new%' + or text like '%CHANGED status FROM % => new%' + or text like '%CHANGE status done => working%' + + or text like '% ADD docstatus => %' + or text like '% UPDATE docstatus % => %' + or text like '% CHANGE docstatus % => %' + or text like '%CHANGE status working => done%' + + or text like '%ADDED iesgstatus => %' + or text like '%ADD iesgstatus => %' + or text like '%CHANGED iesgstatus % => %' + order by docname, time asc;""") + for docname, rows in itertools.groupby(namedtuplefetchall(c), lambda row: row.docname): + if (team.acronym, docname) in document_blacklist: + continue # ignore + + branches = {} + + latest_requested = None + non_specific_close = None + latest_iesg_status = None + + for row in rows: + state = None + used = False + + if requested_re.search(row.text): + if "Created: remote" in row.text: + latest_iesg_status = "early" + state = "requested" + membername = None + used = True + else: + if "ADD docstatus" in row.text: + m = add_docstatus_re.match(row.text) + assert m, 'row.text "{}" does not match add regexp {}'.format(row.text, docname) + membername, state = m.groups() + used = True + elif "UPDATE docstatus" in row.text or "CHANGE docstatus" in row.text: + m = update_docstatus_re.match(row.text) + assert m, 'row.text "{}" does not match update regexp {}'.format(row.text, docname) + membername, state = m.groups() + used = True + + if telechat_re.search(row.text) and not lcend_re.search(row.text): + latest_iesg_status = "telechat" + used = True + elif ((not telechat_re.search(row.text) and lcend_re.search(row.text)) + or (deadline_re.search(row.text) and not telechat_re.search(row.text) and not lcend_re.search(row.text))): + latest_iesg_status = "lc" + used = True + elif (deadline_re.search(row.text) and telechat_re.search(row.text) and lcend_re.search(row.text)): + # if we got both, assume it was a Last Call + latest_iesg_status = "lc" + used = True + + if iesgstatus_re.search(row.text): + m = iesgstatus_re.search(row.text) + if m.groups(): + literal_iesg_status = m.groups()[0] + if literal_iesg_status == "IESG_Evaluation": + latest_iesg_status = "telechat" + elif literal_iesg_status == "In_Last_Call": + latest_iesg_status = "lc" + used = True + + if "CHANGE status working => done" in row.text: + non_specific_close = (row, latest_iesg_status) + used = True + + if not used: + raise Exception("Unknown text {}".format(row.text)) + + if not state: + continue + + if state == "working": + state = "assigned" + + if state == "requested": + latest_requested = (row.time, row.who, membername, state, latest_iesg_status) + else: + if membername not in branches: + branches[membername] = [] + + latest = branches[membername][-1] if branches[membername] else None + + if not latest or ((state == "assigned" and ("assigned" in latest or "closed" in latest)) + or (state not in ("assigned", "accepted") and "closed" in latest)): + # open new + branches[membername].append({}) + latest = branches[membername][-1] + if latest_requested: + latest["requested"] = latest_requested + latest_requested = None + + if state in ("assigned", 'accepted'): + latest[state] = (row.time, row.who, membername, state, latest_iesg_status) + else: + latest["closed"] = (row.time, row.who, membername, state, latest_iesg_status) + + + if branches: + if non_specific_close: + # find any open branches + for m, hs in branches.iteritems(): + latest = hs[-1] + if "assigned" in latest and "closed" not in latest: + #print "closing with non specific", docname + close_row, iesg_status = non_specific_close + latest["closed"] = (close_row.time, close_row.who, m, "done", iesg_status) + + document_history[docname] = branches + + # if docname in document_history: + # print docname, document_history[docname] + +# 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): + if (team.acronym, row.docname) in document_blacklist: + continue # ignore + + 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) + + reviewed_rev = row.version if row.version and row.version != "99" else "" + if row.summary == "noresponse": + reviewed_rev = "" + + event_collection = {} + branches = document_history.get(row.docname) + if not branches: + print "WARNING: no history for", row.docname + else: + history = branches.get(row.reviewer) + if not history: + print "WARNING: reviewer {} not found in history for".format(row.reviewer), row.docname + else: + event_collection = history.pop(0) + if "requested" not in event_collection: + print "WARNING: no requested log entry for", row.docname, [event_collection] + history + + if "assigned" not in event_collection: + print "WARNING: no assigned log entry for", row.docname, [event_collection] + history + + if "closed" not in event_collection and row.docstatus in close_states: + print "WARNING: no {} log entry for".format("/".join(close_states)), row.docname, [event_collection] + history + + def day_delta(time_from, time_to): + if time_from is None or time_to is None: + return None + + return float(time_to[0] - time_from[0]) / (24 * 60 * 60) + + requested_assigned_days = day_delta(event_collection.get("requested"), event_collection.get("assigned")) + if requested_assigned_days is not None and requested_assigned_days < 0: + print "WARNING: assignment before request", requested_assigned_days, row.docname + at_most_days = 20 + if requested_assigned_days is not None and requested_assigned_days > at_most_days: + print "WARNING: more than {} days between request and assignment".format(at_most_days), round(requested_assigned_days), event_collection, row.docname + + if "closed" in event_collection: + assigned_closed_days = day_delta(event_collection.get("assigned"), event_collection.get("closed")) + if assigned_closed_days is not None and assigned_closed_days < 0: + print "WARNING: closure before assignment", assigned_closed_days, row.docname + at_most_days = 60 + if assigned_closed_days is not None and assigned_closed_days > at_most_days and event_collection.get("closed")[3] not in ("noresponse", "withdrawn"): + print "WARNING: more than {} days between assignment and completion".format(at_most_days), round(assigned_closed_days), event_collection, row.docname + + if event_collection: + time = None + if "requested" in event_collection: + time = parse_timestamp(event_collection["requested"][0]) + elif "assigned" in event_collection: + time = parse_timestamp(event_collection["assigned"][0]) + elif "closed" in event_collection: + time = parse_timestamp(event_collection["closed"][0]) + else: + time = deadline + + if not deadline and "closed" in event_collection: + deadline = parse_timestamp(event_collection["closed"][0]) + + if not deadline: + print "SKIPPING WITH NO DEADLINE", row.reviewid, row.docname, meta, event_collection + continue + + type_slug = None + if "assigned" in event_collection: + type_slug = event_collection["assigned"][4] + if type_slug: + print "deduced type slug at assignment", type_slug, row.docname + + if not type_slug and "requested" in event_collection: + type_slug = event_collection["requested"][4] + if type_slug: + print "deduced type slug at request", type_slug, row.docname + + if not type_slug and "closed" in event_collection and "assigned" not in event_collection: + type_slug = event_collection["closed"][4] + if type_slug: + print "deduced type slug at closure", type_slug, row.docname + + if not type_slug: + print "did not deduce type slug, assuming early", row.docname, deadline + type_slug = "early" + + type_name = type_names[type_slug] + + def fix_docname(docname): + if docname == "draft-fenner-obsolete": + docname = "draft-fenner-obsolete-1264" + return docname + + review_req, _ = ReviewRequest.objects.get_or_create( + doc_id=fix_docname(row.docname), + team=team, + old_id=row.reviewid, + defaults={ + "state": states["requested"], + "type": type_name, + "deadline": deadline.date(), + "requested_by": system_person, + } + ) + + review_req.reviewer = known_personnel[row.reviewer] if row.reviewer else None + review_req.result = results.get(row.summary.lower()) if row.summary else None + review_req.state = states.get(row.docstatus) if row.docstatus else None + review_req.type = type_name + review_req.time = time + review_req.reviewed_rev = reviewed_rev if review_req.state_id not in ("requested", "accepted") else "" + review_req.deadline = deadline.date() + review_req.save() + + completion_event = None + + # review request events + for key, data in event_collection.iteritems(): + timestamp, who_did_it, reviewer, state, latest_iesg_status = data + + if who_did_it in known_personnel: + by = known_personnel[who_did_it].person + else: + by = system_person + + if key == "requested": + if "assigned" in event_collection: + continue # skip requested unless there's no assigned event + + e = ReviewRequestDocEvent.objects.filter(type="requested_review", doc=review_req.doc, review_request=review_req).first() + if not e: + e = ReviewRequestDocEvent(type="requested_review", doc=review_req.doc, review_request=review_req) + e.time = time + e.by = by + e.desc = "Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()) + e.state = None + e.skip_community_list_notification = True + e.save() + print "imported event requested_review", e.desc, e.doc_id + + elif key == "assigned": + e = ReviewRequestDocEvent.objects.filter(type="assigned_review_request", doc=review_req.doc, review_request=review_req).first() + if not e: + e = ReviewRequestDocEvent(type="assigned_review_request", doc=review_req.doc, review_request=review_req) + e.time = parse_timestamp(timestamp) + e.by = by + e.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)", + ) + e.state = None + e.skip_community_list_notification = True + e.save() + print "imported event assigned_review_request", e.pk, e.desc, e.doc_id + + elif key == "closed" and review_req.state_id not in ("requested", "accepted"): + e = ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc, review_request=review_req).first() + if not e: + e = ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc, review_request=review_req) + e.time = parse_timestamp(timestamp) + e.by = by + e.state = states.get(state) if state else None + if e.state_id == "rejected": + e.desc = "Assignment of request for {} review by {} to {} was rejected".format( + review_req.type.name, + review_req.team.acronym.upper(), + review_req.reviewer.person, + ) + elif e.state_id == "completed": + e.desc = "Request for {} review by {} {}{}. Reviewer: {}.".format( + review_req.type.name, + review_req.team.acronym.upper(), + review_req.state.name, + ": {}".format(review_req.result.name) if review_req.result else "", + review_req.reviewer.person, + ) + else: + e.desc = "Closed request for {} review by {} with state '{}'".format(review_req.type.name, review_req.team.acronym.upper(), e.state.name) + e.skip_community_list_notification = True + e.save() + completion_event = e + print "imported event closed_review_request", e.desc, e.doc_id + + if review_req.state_id == "completed": + if not row.reviewurl: # don't have anything to store, so skip + continue + + if completion_event: + completion_time = completion_event.time + completion_by = completion_event.by + else: + completion_time = deadline + completion_by = system_person + + # create the review document + if review_req.review: + review = review_req.review + else: + for i in range(1, 100): + name_components = [ + "review", + strip_prefix(review_req.doc_id, "draft-"), + review_req.reviewed_rev, + review_req.team.acronym, + review_req.type_id, + review_req.reviewer.person.ascii_parts()[3], + completion_time.date().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) + DocAlias.objects.create(document=review, name=review.name) + break + + review.set_state(State.objects.get(type="review", slug="active")) + + review.time = completion_time + review.type = DocTypeName.objects.get(slug="review") + review.rev = "00" + review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, review_req.reviewed_rev) + review.group = review_req.team + review.external_url = row.reviewurl + + existing = NewRevisionDocEvent.objects.filter(doc=review).first() or NewRevisionDocEvent(doc=review) + e.type = "new_revision" + e.by = completion_by + e.rev = review.rev + e.desc = 'New revision available' + e.time = completion_time + e.skip_community_list_notification = True + + review.save_with_history([e]) + + review_req.review = review + review_req.save() + + print "imported review document", review_req.doc, review.name + + if review_req.state_id in ("requested", "accepted") and review_req.doc.get_state_slug("draft-iesg") in ["approved", "ann", "rfcqueue", "pub"]: + review_req.state = states["overtaken"] + review_req.save() + + if "closed" not in event_collection and "assigned" in event_collection: + e = ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc, review_request=review_req).first() + if not e: + e = ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc, review_request=review_req) + e.time = datetime.datetime.now() + e.by = by + e.state = review_req.state + e.desc = "Closed request for {} review by {} with state '{}'".format(review_req.type.name, review_req.team.acronym.upper(), e.state.name) + e.skip_community_list_notification = True + e.save() + completion_event = e + print "imported event closed_review_request (generated upon closing)", e.desc, e.doc_id + + + print "imported review request", row.reviewid, "as", review_req.pk, review_req.time, review_req.deadline, review_req.type, review_req.doc_id, review_req.state, review_req.doc.get_state_slug("draft-iesg") 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/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py new file mode 100644 index 000000000..7f618abe1 --- /dev/null +++ b/ietf/review/migrations/0001_initial.py @@ -0,0 +1,115 @@ +# -*- 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='NextReviewerInTeam', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('next_reviewer', models.ForeignKey(to='person.Person')), + ('team', models.ForeignKey(to='group.Group')), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='ResultUsedInReviewTeam', + 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,), + ), + migrations.CreateModel( + name='ReviewerSettings', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('min_interval', models.IntegerField(default=30, verbose_name=b'Can review at most', 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')])), + ('filter_re', models.CharField(help_text=b'Draft names matching regular expression should not be assigned', max_length=255, verbose_name=b'Filter regexp', blank=True)), + ('skip_next', models.IntegerField(default=0, verbose_name=b'Skip next assignments')), + ('remind_days_before_deadline', models.IntegerField(null=True, blank=True)), + ('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='reviewrequest_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='ReviewWish', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('time', models.DateTimeField(default=datetime.datetime.now)), + ('doc', models.ForeignKey(to='doc.Document')), + ('person', models.ForeignKey(to='person.Person')), + ('team', models.ForeignKey(to='group.Group')), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='TypeUsedInReviewTeam', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('team', models.ForeignKey(to='group.Group')), + ('type', models.ForeignKey(to='name.ReviewTypeName')), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='UnavailablePeriod', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('start_date', models.DateField(default=datetime.date.today, help_text=b"Choose the start date so that you can still do a review if it's assigned just before the start date - this usually means you should mark yourself unavailable for assignment some time before you are actually away.")), + ('end_date', models.DateField(help_text=b'Leaving the end date blank means that the period continues indefinitely. You can end it later.', null=True, blank=True)), + ('availability', models.CharField(max_length=30, choices=[(b'canfinish', b'Can do follow-ups'), (b'unavailable', b'Completely unavailable')])), + ('person', models.ForeignKey(to='person.Person')), + ('team', models.ForeignKey(to='group.Group')), + ], + options={ + }, + bases=(models.Model,), + ), + ] diff --git a/ietf/review/migrations/__init__.py b/ietf/review/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ietf/review/models.py b/ietf/review/models.py new file mode 100644 index 000000000..808bee3c7 --- /dev/null +++ b/ietf/review/models.py @@ -0,0 +1,145 @@ +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, limit_choices_to=~models.Q(resultusedinreviewteam=None)) + person = models.ForeignKey(Person) + INTERVALS = [ + (7, "Once per week"), + (14, "Once per fortnight"), + (30, "Once per month"), + (61, "Once per two months"), + (91, "Once per quarter"), + ] + min_interval = models.IntegerField(default=30, verbose_name="Can review at most", choices=INTERVALS) + filter_re = models.CharField(max_length=255, verbose_name="Filter regexp", blank=True, help_text="Draft names matching regular expression should not be assigned") + skip_next = models.IntegerField(default=0, verbose_name="Skip next assignments") + remind_days_before_deadline = models.IntegerField(null=True, blank=True, help_text="To get an email reminder in case you forget to do an assigned review, enter the number of days before a review deadline you want to receive it. Clear the field if you don't want a reminder.") + + def __unicode__(self): + return u"{} in {}".format(self.person, self.team) + + class Meta: + verbose_name_plural = "reviewer settings" + +class UnavailablePeriod(models.Model): + team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None)) + person = models.ForeignKey(Person) + start_date = models.DateField(default=datetime.date.today, help_text="Choose the start date so that you can still do a review if it's assigned just before the start date - this usually means you should mark yourself unavailable for assignment some time before you are actually away.") + end_date = models.DateField(blank=True, null=True, help_text="Leaving the end date blank means that the period continues indefinitely. You can end it later.") + AVAILABILITY_CHOICES = [ + ("canfinish", "Can do follow-ups"), + ("unavailable", "Completely unavailable"), + ] + LONG_AVAILABILITY_CHOICES = [ + ("canfinish", "Can do follow-up reviews and finish outstanding reviews"), + ("unavailable", "Completely unavailable - reassign any outstanding reviews"), + ] + availability = models.CharField(max_length=30, choices=AVAILABILITY_CHOICES) + + def state(self): + import datetime + today = datetime.date.today() + if self.start_date <= today: + if not self.end_date or today <= self.end_date: + return "active" + else: + return "past" + else: + return "future" + + def __unicode__(self): + return u"{} is unavailable in {} {} - {}".format(self.person, self.team.acronym, self.start_date, self.end_date or "") + +class ReviewWish(models.Model): + """Reviewer wishes to review a document when it becomes available for review.""" + time = models.DateTimeField(default=datetime.datetime.now) + team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None)) + person = models.ForeignKey(Person) + doc = models.ForeignKey(Document) + + def __unicode__(self): + return u"{} wishes to review {} in {}".format(self.person, self.doc.name, self.team.acronym) + + class Meta: + verbose_name_plural = "review wishes" + +class ResultUsedInReviewTeam(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, limit_choices_to=~models.Q(resultusedinreviewteam=None)) + result = models.ForeignKey(ReviewResultName) + + def __unicode__(self): + return u"{} in {}".format(self.result.name, self.team.acronym) + + class Meta: + verbose_name = "review result used in review team setting" + verbose_name_plural = "review result used in review team settings" + +class TypeUsedInReviewTeam(models.Model): + """Captures that a type name is valid for a given team for new + reviews. """ + team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None)) + type = models.ForeignKey(ReviewTypeName) + + def __unicode__(self): + return u"{} in {}".format(self.type.name, self.team.acronym) + + class Meta: + verbose_name = "review type used in review team setting" + verbose_name_plural = "review type used in review team settings" + +class NextReviewerInTeam(models.Model): + team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None)) + next_reviewer = models.ForeignKey(Person) + + def __unicode__(self): + return u"{} next in {}".format(self.next_reviewer, self.team) + + class Meta: + verbose_name = "next reviewer in team setting" + verbose_name_plural = "next reviewer in team settings" + +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='reviewrequest_set') + team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=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) diff --git a/ietf/review/resources.py b/ietf/review/resources.py new file mode 100644 index 000000000..2dffd8677 --- /dev/null +++ b/ietf/review/resources.py @@ -0,0 +1,166 @@ +# 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, + ResultUsedInReviewTeam, TypeUsedInReviewTeam, + UnavailablePeriod, ReviewWish, NextReviewerInTeam) + + +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, + "min_interval": 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 ResultUsedInReviewTeamResource(ModelResource): + team = ToOneField(GroupResource, 'team') + result = ToOneField(ReviewResultNameResource, 'result') + class Meta: + queryset = ResultUsedInReviewTeam.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'resultusedinreviewteam' + filtering = { + "id": ALL, + "team": ALL_WITH_RELATIONS, + "result": ALL_WITH_RELATIONS, + } +api.review.register(ResultUsedInReviewTeamResource()) + + + +from ietf.person.resources import PersonResource +from ietf.group.resources import GroupResource +class UnavailablePeriodResource(ModelResource): + team = ToOneField(GroupResource, 'team') + person = ToOneField(PersonResource, 'person') + class Meta: + queryset = UnavailablePeriod.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'unavailableperiod' + filtering = { + "id": ALL, + "start_date": ALL, + "end_date": ALL, + "availability": ALL, + "team": ALL_WITH_RELATIONS, + "person": ALL_WITH_RELATIONS, + } +api.review.register(UnavailablePeriodResource()) + +from ietf.person.resources import PersonResource +from ietf.group.resources import GroupResource +from ietf.doc.resources import DocumentResource +class ReviewWishResource(ModelResource): + team = ToOneField(GroupResource, 'team') + person = ToOneField(PersonResource, 'person') + doc = ToOneField(DocumentResource, 'doc') + class Meta: + queryset = ReviewWish.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'reviewwish' + filtering = { + "id": ALL, + "time": ALL, + "team": ALL_WITH_RELATIONS, + "person": ALL_WITH_RELATIONS, + "doc": ALL_WITH_RELATIONS, + } +api.review.register(ReviewWishResource()) + + + +from ietf.person.resources import PersonResource +from ietf.group.resources import GroupResource +class NextReviewerInTeamResource(ModelResource): + team = ToOneField(GroupResource, 'team') + next_reviewer = ToOneField(PersonResource, 'next_reviewer') + class Meta: + queryset = NextReviewerInTeam.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'nextreviewerinteam' + filtering = { + "id": ALL, + "team": ALL_WITH_RELATIONS, + "next_reviewer": ALL_WITH_RELATIONS, + } +api.review.register(NextReviewerInTeamResource()) + + + +from ietf.group.resources import GroupResource +from ietf.name.resources import ReviewTypeNameResource +class TypeUsedInReviewTeamResource(ModelResource): + team = ToOneField(GroupResource, 'team') + type = ToOneField(ReviewTypeNameResource, 'type') + class Meta: + queryset = TypeUsedInReviewTeam.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'typeusedinreviewteam' + filtering = { + "id": ALL, + "team": ALL_WITH_RELATIONS, + "type": ALL_WITH_RELATIONS, + } +api.review.register(TypeUsedInReviewTeamResource()) + diff --git a/ietf/review/utils.py b/ietf/review/utils.py new file mode 100644 index 000000000..966cfe804 --- /dev/null +++ b/ietf/review/utils.py @@ -0,0 +1,773 @@ +import datetime, re, itertools +from collections import defaultdict, namedtuple + +from django.db.models import Q, Max, F +from django.core.urlresolvers import reverse as urlreverse +from django.contrib.sites.models import Site + +from ietf.group.models import Group, Role +from ietf.doc.models import (Document, ReviewRequestDocEvent, 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, TypeUsedInReviewTeam, + ReviewerSettings, UnavailablePeriod, ReviewWish, NextReviewerInTeam) +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 ResultUsedInReviewTeam defined, it's a review team + return Group.objects.filter(state="active").exclude(resultusedinreviewteam=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_docs(docs): + request_qs = ReviewRequest.objects.filter( + state__in=["requested", "accepted", "part-completed", "completed"], + ).prefetch_related("result") + + doc_names = [d.name for d in docs] + + return extract_revision_ordered_review_requests_for_documents_and_replaced(request_qs, doc_names) + +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 unavailable_periods_to_list(past_days=14): + return UnavailablePeriod.objects.filter( + Q(end_date=None) | Q(end_date__gte=datetime.date.today() - datetime.timedelta(days=past_days)), + ).order_by("start_date") + +def current_unavailable_periods_for_reviewers(team): + """Return dict with currently active unavailable periods for reviewers.""" + today = datetime.date.today() + + unavailable_period_qs = UnavailablePeriod.objects.filter( + Q(end_date__gte=today) | Q(end_date=None), + start_date__lte=today, + team=team, + ).order_by("end_date") + + res = defaultdict(list) + for period in unavailable_period_qs: + res[period.person_id].append(period) + + return res + +def reviewer_rotation_list(team, skip_unavailable=False, dont_skip=[]): + """Returns person id -> index in rotation (next reviewer has index 0).""" + reviewers = list(Person.objects.filter(role__name="reviewer", role__group=team)) + reviewers.sort(key=lambda p: p.last_name()) + + next_reviewer_index = 0 + + # now to figure out where the rotation is currently at + saved_reviewer = NextReviewerInTeam.objects.filter(team=team).select_related("next_reviewer").first() + if saved_reviewer: + n = saved_reviewer.next_reviewer + + if n not in reviewers: + # saved reviewer might not still be here, if not just + # insert and use that position (Python will wrap around, + # so no harm done by using the index on the original list + # afterwards) + reviewers_with_next = reviewers[:] + [n] + reviewers_with_next.sort(key=lambda p: p.last_name()) + next_reviewer_index = reviewers_with_next.index(n) + else: + next_reviewer_index = reviewers.index(n) + + rotation_list = reviewers[next_reviewer_index:] + reviewers[:next_reviewer_index] + + if skip_unavailable: + # prune reviewers not in the rotation (but not the assigned + # reviewer who must have been available for assignment anyway) + reviewers_to_skip = set() + + unavailable_periods = current_unavailable_periods_for_reviewers(team) + for person_id, periods in unavailable_periods.iteritems(): + if periods and person_id not in dont_skip: + reviewers_to_skip.add(person_id) + + days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(team) + for person_id, days_needed in days_needed_for_reviewers.iteritems(): + if days_needed > 0 and person_id not in dont_skip: + reviewers_to_skip.add(person_id) + + rotation_list = [p.pk for p in rotation_list if p.pk not in reviewers_to_skip] + + return rotation_list + +def days_needed_to_fulfill_min_interval_for_reviewers(team): + """Returns person_id -> days needed until min_interval is fulfilled for + reviewer.""" + latest_assignments = dict(ReviewRequest.objects.filter( + team=team, + ).values_list("reviewer__person").annotate(Max("time"))) + + min_intervals = dict(ReviewerSettings.objects.filter(team=team).values_list("person_id", "min_interval")) + + default_min_interval = ReviewerSettings(team=team).min_interval + + now = datetime.datetime.now() + + res = {} + for person_id, latest_assignment_time in latest_assignments.iteritems(): + if latest_assignment_time is not None: + min_interval = min_intervals.get(person_id, default_min_interval) + + days_needed = max(0, min_interval - (now - latest_assignment_time).days) + + if days_needed > 0: + res[person_id] = days_needed + + return res + +ReviewRequestData = namedtuple("ReviewRequestData", [ + "req_pk", "doc", "doc_pages", "req_time", "state", "deadline", "result", "team", "reviewer", + "late_days", + "request_to_assignment_days", "assignment_to_closure_days", "request_to_closure_days"]) + + +def extract_review_request_data(teams=None, reviewers=None, time_from=None, time_to=None, ordering=[]): + """Yield data on each review request, sorted by (*ordering, time) + for easy use with itertools.groupby. Valid entries in *ordering are "team" and "reviewer".""" + + filters = Q() + + if teams: + filters &= Q(team__in=teams) + + if reviewers: + filters &= Q(reviewer__person__in=reviewers) + + if time_from: + filters &= Q(time__gte=time_from) + + if time_to: + filters &= Q(time__lte=time_to) + + # we may be dealing with a big bunch of data, so treat it carefully + event_qs = ReviewRequest.objects.filter(filters) + + # left outer join with RequestRequestDocEvent for request/assign/close time + event_qs = event_qs.values_list( + "pk", "doc", "doc__pages", "time", "state", "deadline", "result", "team", + "reviewer__person", "reviewrequestdocevent__time", "reviewrequestdocevent__type" + ) + + event_qs = event_qs.order_by(*[o.replace("reviewer", "reviewer__person") for o in ordering] + ["time", "pk", "-reviewrequestdocevent__time"]) + + def positive_days(time_from, time_to): + if time_from is None or time_to is None: + return None + + delta = time_to - time_from + seconds = delta.total_seconds() + if seconds > 0: + return seconds / float(24 * 60 * 60) + else: + return 0.0 + + for _, events in itertools.groupby(event_qs.iterator(), lambda t: t[0]): + requested_time = assigned_time = closed_time = None + + for e in events: + req_pk, doc, doc_pages, req_time, state, deadline, result, team, reviewer, event_time, event_type = e + + if event_type == "requested_review" and requested_time is None: + requested_time = event_time + elif event_type == "assigned_review_request" and assigned_time is None: + assigned_time = event_time + elif event_type == "closed_review_request" and closed_time is None: + closed_time = event_time + + late_days = positive_days(datetime.datetime.combine(deadline, datetime.time.max), closed_time) + request_to_assignment_days = positive_days(requested_time, assigned_time) + assignment_to_closure_days = positive_days(assigned_time, closed_time) + request_to_closure_days = positive_days(requested_time, closed_time) + + d = ReviewRequestData(req_pk, doc, doc_pages, req_time, state, deadline, result, team, reviewer, + late_days, request_to_assignment_days, assignment_to_closure_days, + request_to_closure_days) + + yield d + +def aggregate_review_request_stats(review_request_data, count=None): + """Take a sequence of review request data from + extract_review_request_data and compute aggregated statistics.""" + + state_dict = defaultdict(int) + late_state_dict = defaultdict(int) + result_dict = defaultdict(int) + assignment_to_closure_days_list = [] + assignment_to_closure_days_count = 0 + + for (req_pk, doc, doc_pages, req_time, state, deadline, result, team, reviewer, + late_days, request_to_assignment_days, assignment_to_closure_days, request_to_closure_days) in review_request_data: + if count == "pages": + c = doc_pages + else: + c = 1 + + state_dict[state] += c + + if late_days is not None and late_days > 0: + late_state_dict[state] += c + + if state in ("completed", "part-completed"): + result_dict[result] += c + if assignment_to_closure_days is not None: + assignment_to_closure_days_list.append(assignment_to_closure_days) + assignment_to_closure_days_count += c + + res = {} + res["state"] = state_dict + res["result"] = result_dict + + res["open"] = sum(state_dict.get(s, 0) for s in ("requested", "accepted")) + res["completed"] = sum(state_dict.get(s, 0) for s in ("completed", "part-completed")) + res["not_completed"] = sum(state_dict.get(s, 0) for s in state_dict if s in ("rejected", "withdrawn", "overtaken", "no-response")) + + res["open_late"] = sum(late_state_dict.get(s, 0) for s in ("requested", "accepted")) + res["open_in_time"] = res["open"] - res["open_late"] + res["completed_late"] = sum(late_state_dict.get(s, 0) for s in ("completed", "part-completed")) + res["completed_in_time"] = res["completed"] - res["completed_late"] + + res["average_assignment_to_closure_days"] = float(sum(assignment_to_closure_days_list)) / (assignment_to_closure_days_count or 1) if assignment_to_closure_days_list else None + + return res + + +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 stakeholders 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="secr", 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, "review/review_request_changed.txt", { + "review_req_url": url, + "review_req": review_req, + "msg": msg, + }) + +def email_reviewer_availability_change(request, team, reviewer_role, msg, 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) + + extract_email_addresses(Role.objects.filter(name="secr", group=team).distinct()) + + extract_email_addresses([reviewer_role]) + + if not to: + return + + subject = "Reviewer availability of {} changed in {}".format(reviewer_role.person, team.acronym) + + url = urlreverse("ietf.group.views_review.reviewer_overview", kwargs={ "group_type": team.type_id, "acronym": team.acronym }) + url = request.build_absolute_uri(url) + send_mail(request, to, None, subject, "review/reviewer_availability_changed.txt", { + "reviewer_overview_url": url, + "reviewer": reviewer_role.person, + "team": team, + "msg": msg, + "by": by, + }) + +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() + + if review_req.reviewer: + possibly_advance_next_reviewer_for_team(review_req.team, review_req.reviewer.person_id) + + ReviewRequestDocEvent.objects.create( + type="assigned_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)", + ), + review_request=review_req, + state=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 possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id): + assert assigned_review_to_person_id is not None + + rotation_list = reviewer_rotation_list(team, skip_unavailable=True, dont_skip=[assigned_review_to_person_id]) + + def reviewer_at_index(i): + if not rotation_list: + return None + + return rotation_list[i % len(rotation_list)] + + def reviewer_settings_for(person_id): + return (ReviewerSettings.objects.filter(team=team, person=person_id).first() + or ReviewerSettings(team=team, person_id=person_id)) + + current_i = 0 + + if assigned_review_to_person_id == reviewer_at_index(current_i): + # move 1 ahead + current_i += 1 + else: + settings = reviewer_settings_for(assigned_review_to_person_id) + settings.skip_next += 1 + settings.save() + + if not rotation_list: + return + + while True: + # as a clean-up step go through any with a skip next > 0 + current_reviewer_person_id = reviewer_at_index(current_i) + settings = reviewer_settings_for(current_reviewer_person_id) + if settings.skip_next > 0: + settings.skip_next -= 1 + settings.save() + + current_i += 1 + else: + nr = NextReviewerInTeam.objects.filter(team=team).first() or NextReviewerInTeam(team=team) + nr.next_reviewer_id = current_reviewer_person_id + nr.save() + + break + +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: + ReviewRequestDocEvent.objects.create( + type="closed_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), + review_request=review_req, + state=review_req.state, + ) + + 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 = {} + + now = datetime.datetime.now() + + requested_state = ReviewRequestStateName.objects.get(slug="requested", used=True) + + last_call_type = ReviewTypeName.objects.get(slug="lc") + if TypeUsedInReviewTeam.objects.filter(team=team, type=last_call_type).exists(): + # in Last Call + last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True)) + last_call_expiry_events = { e.doc_id: e for e in LastCallDocEvent.objects.order_by("time", "id") } + for doc in last_call_docs: + e = last_call_expiry_events[doc.pk] if doc.pk in last_call_expiry_events else LastCallDocEvent(expires=now.date(), time=now) + + deadline = e.expires.date() + + if deadline > seen_deadlines.get(doc.pk, datetime.date.max) or deadline < now.date(): + continue + + requests[doc.pk] = ReviewRequest( + time=e.time, + type=last_call_type, + doc=doc, + team=team, + deadline=deadline, + requested_by=system_person, + state=requested_state, + ) + + seen_deadlines[doc.pk] = deadline + + + telechat_type = ReviewTypeName.objects.get(slug="telechat") + if TypeUsedInReviewTeam.objects.filter(team=team, type=telechat_type).exists(): + # on Telechat Agenda + telechat_dates = list(TelechatDate.objects.active().order_by('date').values_list("date", flat=True)[:4]) + + telechat_deadline_delta = datetime.timedelta(days=2) + + telechat_docs = Document.objects.filter( + docevent__telechatdocevent__telechat_date__in=telechat_dates + ).values_list( + "pk", "docevent__telechatdocevent__time", "docevent__telechatdocevent__telechat_date" + ).order_by("pk", "docevent__telechatdocevent__telechat_date") + for doc_pk, events in itertools.groupby(telechat_docs, lambda t: t[0]): + event_time = deadline = None + for _, event_time, event_telechat_date in events: + if event_telechat_date in telechat_dates: + deadline = event_telechat_date - telechat_deadline_delta + break + + if not deadline or deadline > seen_deadlines.get(doc_pk, datetime.date.max): + continue + + requests[doc_pk] = ReviewRequest( + time=event_time, + type=telechat_type, + doc_id=doc_pk, + 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_and_replaced(review_request_queryset, names): + """Extracts all review requests for document names (including replaced ancestors), return them neatly sorted.""" + + 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) + possible_person_ids = [e.person_id for e in possible_emails] + + aliases = DocAlias.objects.filter(document=doc).values_list("name", flat=True) + + # settings + reviewer_settings = { + r.person_id: r + for r in ReviewerSettings.objects.filter(team=team, person__in=possible_person_ids) + } + + for p in possible_person_ids: + if p not in reviewer_settings: + reviewer_settings[p] = ReviewerSettings(team=team) + + # frequency + days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(team) + + # rotation + rotation_index = { p.pk: i for i, p in enumerate(reviewer_rotation_list(team)) } + + # 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 wishes + wish_to_review = set(ReviewWish.objects.filter(team=team, person__in=possible_person_ids, doc=doc).values_list("person", flat=True)) + + # 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" + + # unavailable periods + unavailable_periods = current_unavailable_periods_for_reviewers(team) + + ranking = [] + for e in possible_emails: + settings = reviewer_settings.get(e.person_id) + + # we sort the reviewers by separate axes, listing the most + # important things first + scores = [] + explanations = [] + + def add_boolean_score(direction, expr, explanation=None): + scores.append(direction if expr else -direction) + if expr and explanation: + explanations.append(explanation) + + # unavailable for review periods + periods = unavailable_periods.get(e.person_id, []) + unavailable_at_the_moment = periods and not (e.pk in has_reviewed_previous and all(p.availability == "canfinish" for p in periods)) + add_boolean_score(-1, unavailable_at_the_moment) + + def format_period(p): + if p.end_date: + res = "unavailable until {}".format(p.end_date.isoformat()) + else: + res = "unavailable indefinitely" + return "{} ({})".format(res, p.get_availability_display()) + + if periods: + explanations.append(", ".join(format_period(p) for p in periods)) + + # minimum interval between reviews + days_needed = days_needed_for_reviewers.get(e.person_id, 0) + scores.append(-days_needed) + if days_needed > 0: + explanations.append("max frequency exceeded, ready in {} {}".format(days_needed, "day" if days_needed == 1 else "days")) + + # misc + add_boolean_score(+1, e.pk in has_reviewed_previous, "reviewed document before") + add_boolean_score(+1, e.person_id in wish_to_review, "wishes to review document") + add_boolean_score(-1, e.pk in connections, connections.get(e.pk)) # reviewer is somehow connected: bad + add_boolean_score(-1, settings.filter_re and any(re.search(settings.filter_re, n) for n in aliases), "filter regexp matches") + + # skip next + scores.append(-settings.skip_next) + if settings.skip_next > 0: + explanations.append("skip next {}".format(settings.skip_next)) + + index = rotation_index.get(e.person_id, 0) + scores.append(-index) + explanations.append("#{}".format(index + 1)) + + label = unicode(e.person) + if explanations: + label = u"{}: {}".format(label, u"; ".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] + +def review_requests_needing_reviewer_reminder(remind_date): + reqs_qs = ReviewRequest.objects.filter( + state__in=("requested", "accepted"), + reviewer__person__reviewersettings__remind_days_before_deadline__isnull=False, + reviewer__person__reviewersettings__team=F("team"), + ).exclude( + reviewer=None + ).values_list("pk", "deadline", "reviewer__person__reviewersettings__remind_days_before_deadline").distinct() + + req_pks = [] + for r_pk, deadline, remind_days in reqs_qs: + if (deadline - remind_date).days == remind_days: + req_pks.append(r_pk) + + return ReviewRequest.objects.filter(pk__in=req_pks).select_related("reviewer", "reviewer__person", "state", "team") + +def email_reviewer_reminder(review_request): + team = review_request.team + + deadline_days = (review_request.deadline - datetime.date.today()).days + + subject = "Reminder: deadline for review of {} in {} is {}".format(review_request.doc_id, team.acronym, review_request.deadline.isoformat()) + + overview_url = urlreverse("ietf.ietfauth.views.review_overview") + request_url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": review_request.doc_id, "request_id": review_request.pk }) + + domain = Site.objects.get_current().domain + + settings = ReviewerSettings.objects.filter(person=review_request.reviewer.person, team=team).first() + remind_days = settings.remind_days_before_deadline if settings else 0 + + send_mail(None, [review_request.reviewer.formatted_email()], None, subject, "review/reviewer_reminder.txt", { + "reviewer_overview_url": "https://{}{}".format(domain, overview_url), + "review_request_url": "https://{}{}".format(domain, request_url), + "review_request": review_request, + "deadline_days": deadline_days, + "remind_days": remind_days, + }) diff --git a/ietf/settings.py b/ietf/settings.py index 88717165b..5d0c177cf 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -304,6 +304,7 @@ INSTALLED_APPS = ( 'ietf.person', 'ietf.redirects', 'ietf.release', + 'ietf.review', 'ietf.submit', 'ietf.sync', 'ietf.utils', @@ -447,6 +448,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 ab81ad8f7..bfce8c59b 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -470,6 +470,108 @@ 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; +} + +table.simple-table td { + padding-right: 0.5em; +} + +table.simple-table td:last-child { + padding-right: 0; +} + +.unavailable-period-past { + color: #777; +} + +.unavailable-period-active { + font-weight: bold; +} + +.reviewer-overview .completely-unavailable { + opacity: 0.6; +} + +/* === Statistics =========================================================== */ + +.stats-options > * { + margin-bottom: 1em; +} + +.stats-options > *:last-child { + margin-bottom: 0; +} + +.stats-options .date-range input.form-control { + display: inline-block; + width: 7em; +} + +.stats-time-graph { + height: 15em; +} + +.review-stats th:first-child, .review-stats td:first-child { + text-align: left; +} + +.review-stats th, .review-stats td { + text-align: center; +} + +.review-stats-teams { + -moz-column-width: 18em; + -webkit-column-width: 18em; + column-width: 18em; +} + +.review-stats-teams a { + display: block; +} + /* === Photo pages ========================================================== */ .photo-name { diff --git a/ietf/static/ietf/js/complete-review.js b/ietf/static/ietf/js/complete-review.js new file mode 100644 index 000000000..c90b9fa3d --- /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"]', '[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"); +}); diff --git a/ietf/static/ietf/js/ietf.js b/ietf/static/ietf/js/ietf.js index 709ec3374..a239de0c0 100644 --- a/ietf/static/ietf/js/ietf.js +++ b/ietf/static/ietf/js/ietf.js @@ -194,7 +194,7 @@ $(".snippet .show-all").click(function () { // }); // Use the Bootstrap3 tooltip plugin for all elements with a title attribute -$('[title][title!=""]').tooltip(); +$('[title][title!=""]').not("th").tooltip(); $(document).ready(function () { // add a required class on labels on forms that should have diff --git a/ietf/static/ietf/js/manage-review-requests.js b/ietf/static/ietf/js/manage-review-requests.js new file mode 100644 index 000000000..cf9cbe8d7 --- /dev/null +++ b/ietf/static/ietf/js/manage-review-requests.js @@ -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(); +}); diff --git a/ietf/static/ietf/js/review-stats.js b/ietf/static/ietf/js/review-stats.js new file mode 100644 index 000000000..385eab982 --- /dev/null +++ b/ietf/static/ietf/js/review-stats.js @@ -0,0 +1,8 @@ +$(document).ready(function () { + if (window.timeSeriesData && window.timeSeriesOptions) { + var placeholder = $(".stats-time-graph"); + placeholder.height(Math.round(placeholder.width() * 1 / 3)); + + $.plot(placeholder, window.timeSeriesData, window.timeSeriesOptions); + } +}); diff --git a/ietf/stats/__init__.py b/ietf/stats/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/ietf/stats/__init__.py @@ -0,0 +1 @@ + diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py new file mode 100644 index 000000000..bd66946dd --- /dev/null +++ b/ietf/stats/tests.py @@ -0,0 +1,50 @@ +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 +import ietf.stats.views + +class StatisticsTests(TestCase): + def test_stats_index(self): + url = urlreverse(ietf.stats.views.stats_index) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + def test_review_stats(self): + doc = make_test_data() + review_req = make_review_data(doc) + + # check redirect + url = urlreverse(ietf.stats.views.review_stats) + + login_testing_unauthorized(self, "secretary", url) + + r = self.client.get(url) + self.assertEqual(r.status_code, 302) + self.assertTrue(urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion" }) in r["Location"]) + + # check tabular + for stats_type in ["completion", "results", "states"]: + url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": stats_type }) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + if stats_type != "results": + self.assertTrue(q('.review-stats td:contains("1")')) + + # check chart + url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "time" }) + url += "?team={}".format(review_req.team.acronym) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertTrue(q('.stats-time-graph')) + + # check reviewer level + url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion", "acronym": review_req.team.acronym }) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertTrue(q('.review-stats td:contains("1")')) diff --git a/ietf/stats/urls.py b/ietf/stats/urls.py new file mode 100644 index 000000000..8a05f9659 --- /dev/null +++ b/ietf/stats/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import patterns, url +from django.conf import settings + +import ietf.stats.views + +urlpatterns = patterns('', + url("^$", ietf.stats.views.stats_index), + url("^review/(?:(?Pcompletion|results|states|time)/)?(?:%(acronym)s/)?$" % settings.URL_REGEXPS, ietf.stats.views.review_stats), +) diff --git a/ietf/stats/views.py b/ietf/stats/views.py new file mode 100644 index 000000000..348ffc496 --- /dev/null +++ b/ietf/stats/views.py @@ -0,0 +1,320 @@ +import datetime, itertools, json, calendar + +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.core.urlresolvers import reverse as urlreverse +from django.http import HttpResponseRedirect + +import dateutil.relativedelta + +from ietf.review.utils import extract_review_request_data, aggregate_review_request_stats, ReviewRequestData +from ietf.group.models import Role, Group +from ietf.person.models import Person +from ietf.name.models import ReviewRequestStateName, ReviewResultName +from ietf.ietfauth.utils import has_role + +def stats_index(request): + return render(request, "stats/index.html") + +@login_required +def review_stats(request, stats_type=None, acronym=None): + # This view is a bit complex because we want to show a bunch of + # tables with various filtering options, and both a team overview + # and a reviewers-within-team overview - and a time series chart. + # And in order to make the UI quick to navigate, we're not using + # one big form but instead presenting a bunch of immediate + # actions, with a URL scheme where the most common options (level + # and statistics type) are incorporated directly into the URL to + # be a bit nicer. + + def build_review_stats_url(stats_type_override=Ellipsis, acronym_override=Ellipsis, get_overrides={}): + kwargs = { + "stats_type": stats_type if stats_type_override is Ellipsis else stats_type_override, + } + acr = acronym if acronym_override is Ellipsis else acronym_override + if acr: + kwargs["acronym"] = acr + + base_url = urlreverse(review_stats, kwargs=kwargs) + query_part = u"" + + if request.GET or get_overrides: + d = request.GET.copy() + for k, v in get_overrides.iteritems(): + if type(v) in (list, tuple): + if not v: + if k in d: + del d[k] + else: + d.setlist(k, v) + else: + if v is None or v == u"": + if k in d: + del d[k] + else: + d[k] = v + + if d: + query_part = u"?" + d.urlencode() + + return base_url + query_part + + def get_from_selection(get_parameter, possible_choices): + val = request.GET.get(get_parameter) + for slug, label, url in possible_choices: + if slug == val: + return slug + return None + + # which overview - team or reviewer + if acronym: + level = "reviewer" + else: + level = "team" + + # statistics type - one of the tables or the chart + possible_stats_types = [ + ("completion", "Completion status"), + ("results", "Review results"), + ("states", "Request states"), + ] + + if level == "team": + possible_stats_types.append(("time", "Changes over time")) + + possible_stats_types = [ (slug, label, build_review_stats_url(stats_type_override=slug)) + for slug, label in possible_stats_types ] + + if not stats_type: + return HttpResponseRedirect(build_review_stats_url(stats_type_override=possible_stats_types[0][0])) + + # what to count + possible_count_choices = [ + ("", "Review requests"), + ("pages", "Reviewed pages"), + ] + + possible_count_choices = [ (slug, label, build_review_stats_url(get_overrides={ "count": slug })) for slug, label in possible_count_choices ] + + count = get_from_selection("count", possible_count_choices) or "" + + # time range + def parse_date(s): + if not s: + return None + try: + return datetime.datetime.strptime(s.strip(), "%Y-%m-%d").date() + except ValueError: + return None + + today = datetime.date.today() + from_date = parse_date(request.GET.get("from")) or today - dateutil.relativedelta.relativedelta(years=1) + to_date = parse_date(request.GET.get("to")) or today + + from_time = datetime.datetime.combine(from_date, datetime.time.min) + to_time = datetime.datetime.combine(to_date, datetime.time.max) + + # teams/reviewers + teams = list(Group.objects.exclude(reviewrequest=None).distinct().order_by("name")) + + reviewer_filter_args = {} + + # - interlude: access control + if has_role(request.user, ["Secretariat", "Area Director"]): + pass + else: + secr_access = set() + reviewer_only_access = set() + + for r in Role.objects.filter(person__user=request.user, name__in=["secr", "reviewer"], group__in=teams).distinct(): + if r.name_id == "secr": + secr_access.add(r.group_id) + reviewer_only_access.discard(r.group_id) + elif r.name_id == "reviewer": + if not r.group_id in secr_access: + reviewer_only_access.add(r.group_id) + + teams = [t for t in teams if t.pk in secr_access or t.pk in reviewer_only_access] + + for t in reviewer_only_access: + reviewer_filter_args[t] = { "user": request.user } + + reviewers_for_team = None + + if level == "team": + for t in teams: + t.reviewer_stats_url = build_review_stats_url(acronym_override=t.acronym) + + query_teams = teams + query_reviewers = None + + group_by_objs = { t.pk: t for t in query_teams } + group_by_index = ReviewRequestData._fields.index("team") + + elif level == "reviewer": + for t in teams: + if t.acronym == acronym: + reviewers_for_team = t + break + else: + return HttpResponseRedirect(urlreverse(review_stats)) + + query_reviewers = list(Person.objects.filter( + email__reviewrequest__time__gte=from_time, + email__reviewrequest__time__lte=to_time, + email__reviewrequest__team=reviewers_for_team, + **reviewer_filter_args.get(t.pk, {}) + ).distinct()) + query_reviewers.sort(key=lambda p: p.last_name()) + + query_teams = [t] + + group_by_objs = { r.pk: r for r in query_reviewers } + group_by_index = ReviewRequestData._fields.index("reviewer") + + # now aggregate the data + possible_teams = possible_completion_types = possible_results = possible_states = None + selected_team = selected_completion_type = selected_result = selected_state = None + + if stats_type == "time": + possible_teams = [(t.acronym, t.acronym, build_review_stats_url(get_overrides={ "team": t.acronym })) for t in teams] + selected_team = get_from_selection("team", possible_teams) + query_teams = [t for t in query_teams if t.acronym == selected_team] + + extracted_data = extract_review_request_data(query_teams, query_reviewers, from_time, to_time, ordering=[level]) + + if stats_type == "time": + req_time_index = ReviewRequestData._fields.index("req_time") + + def time_key_fn(t): + d = t[req_time_index].date() + #d -= datetime.timedelta(days=d.weekday()) + d -= datetime.timedelta(days=d.day) + return (t[group_by_index], d) + + found_results = set() + found_states = set() + aggrs = [] + for (group_pk, d), request_data_items in itertools.groupby(extracted_data, key=time_key_fn): + aggr = aggregate_review_request_stats(request_data_items, count=count) + + aggrs.append((d, aggr)) + + for slug in aggr["result"]: + found_results.add(slug) + for slug in aggr["state"]: + found_states.add(slug) + + results = ReviewResultName.objects.filter(slug__in=found_results) + states = ReviewRequestStateName.objects.filter(slug__in=found_states) + + # choice + + possible_completion_types = [ + ("completed_in_time", "Completed in time"), + ("completed_late", "Completed late"), + ("not_completed", "Not completed"), + ("average_assignment_to_closure_days", "Avg. compl. days"), + ] + + possible_completion_types = [ + (slug, label, build_review_stats_url(get_overrides={ "completion": slug, "result": None, "state": None })) + for slug, label in possible_completion_types + ] + + selected_completion_type = get_from_selection("completion", possible_completion_types) + + possible_results = [ + (r.slug, r.name, build_review_stats_url(get_overrides={ "completion": None, "result": r.slug, "state": None })) + for r in results + ] + + selected_result = get_from_selection("result", possible_results) + + possible_states = [ + (s.slug, s.name, build_review_stats_url(get_overrides={ "completion": None, "result": None, "state": s.slug })) + for s in states + ] + + selected_state = get_from_selection("state", possible_states) + + if not selected_completion_type and not selected_result and not selected_state: + selected_completion_type = "completed_in_time" + + series_data = [] + for d, aggr in aggrs: + v = 0 + if selected_completion_type is not None: + v = aggr[selected_completion_type] + elif selected_result is not None: + v = aggr["result"][selected_result] + elif selected_state is not None: + v = aggr["state"][selected_state] + + series_data.append((calendar.timegm(d.timetuple()) * 1000, v)) + + data = json.dumps([{ + "data": series_data + }]) + + else: # tabular data + + data = [] + + found_results = set() + found_states = set() + for group_pk, request_data_items in itertools.groupby(extracted_data, key=lambda t: t[group_by_index]): + aggr = aggregate_review_request_stats(request_data_items, count=count) + + # skip zero-valued rows + if aggr["open"] == 0 and aggr["completed"] == 0 and aggr["not_completed"] == 0: + continue + + aggr["obj"] = group_by_objs.get(group_pk) + + for slug in aggr["result"]: + found_results.add(slug) + for slug in aggr["state"]: + found_states.add(slug) + + data.append(aggr) + + results = ReviewResultName.objects.filter(slug__in=found_results) + states = ReviewRequestStateName.objects.filter(slug__in=found_states) + + # massage states/results breakdowns for template rendering + for aggr in data: + aggr["state_list"] = [aggr["state"].get(x.slug, 0) for x in states] + aggr["result_list"] = [aggr["result"].get(x.slug, 0) for x in results] + + + return render(request, 'stats/review_stats.html', { + "team_level_url": build_review_stats_url(acronym_override=None), + "level": level, + "reviewers_for_team": reviewers_for_team, + "teams": teams, + "data": data, + "states": states, + "results": results, + + # options + "possible_stats_types": possible_stats_types, + "stats_type": stats_type, + + "possible_count_choices": possible_count_choices, + "count": count, + + "from_date": from_date, + "to_date": to_date, + "today": today, + + # time options + "possible_teams": possible_teams, + "selected_team": selected_team, + "possible_completion_types": possible_completion_types, + "selected_completion_type": selected_completion_type, + "possible_results": possible_results, + "selected_result": selected_result, + "possible_states": possible_states, + "selected_state": selected_state, + }) diff --git a/ietf/templates/base/menu.html b/ietf/templates/base/menu.html index 10828bb83..443d34a52 100644 --- a/ietf/templates/base/menu.html +++ b/ietf/templates/base/menu.html @@ -102,6 +102,7 @@
  • IPR disclosures
  • Liaison statements
  • IESG agenda
  • +
  • Statistics
  • Tutorials
  • {% if flavor == "top" %}{% endif %}
  • Report a bug
  • diff --git a/ietf/templates/base/menu_user.html b/ietf/templates/base/menu_user.html index 106f74ec7..0814c517d 100644 --- a/ietf/templates/base/menu_user.html +++ b/ietf/templates/base/menu_user.html @@ -25,6 +25,10 @@
  • {% if request.user.is_authenticated %}Manage account{% else %}New account{% endif %}
  • Preferences
  • + {% if user|has_role:"Reviewer" %} +
  • My reviews
  • + {% endif %} + {% if user|has_role:"Area Director" %} {% if flavor == "top" %}{% endif %}
  • AD dashboard
  • diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index de9c06767..bed55b798 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -192,6 +192,33 @@ + {% if review_requests or can_request_review %} + + + Reviews + + + {% 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 %} + + {% endif %} + + + {% endif %} + + {% if conflict_reviews %} diff --git a/ietf/templates/doc/document_review.html b/ietf/templates/doc/document_review.html new file mode 100644 index 000000000..3d98cc0d9 --- /dev/null +++ b/ietf/templates/doc/document_review.html @@ -0,0 +1,118 @@ +{% 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" %} + + + + + {% if doc.rev != latest_rev %} + + {% else %} + + {% endif %} + + + + + + + + + + + + + + + + + + + {% if doc.get_state_slug != "active" %} + + + + + + + {% endif %} + + {% if review_req %} + + + + + + + + + + + + + + + {% if review_req.result %} + + + + + + + {% endif %} + {% endif %} + + {% if doc.external_url %} + + + + + + + {% endif %} + + + + + + + + + {% if other_reviews %} + + + + + + + {% endif %} + +
    The information below is for an old version of the document
    Team + {{ doc.group.name }} + ({{ doc.group.acronym }}) + + {% if snapshot %} + Snapshot + {% endif %} +
    Title{{ doc.title }}
    State{{ doc.get_state.name }}
    Request{{ review_req.type.name }} - requested {{ review_req.time|date:"Y-m-d" }}
    Reviewer{{ review_req.reviewer.person }}
    Review result{{ review_req.result.name }}
    Posted at{{ doc.external_url }}
    Last updated{{ doc.time|date:"Y-m-d" }}
    Other reviews + {% 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 %} +
    + +

    {{ doc.type.name }}
    {{ doc.name }}

    + + {% if doc.rev and content != None %} + {{ content|fill:"80"|safe|linebreaksbr|keep_spacing|sanitize_html|safe }} + {% endif %} +{% endblock %} diff --git a/ietf/templates/doc/draft/resurrect.html b/ietf/templates/doc/draft/resurrect.html index 04c803e41..472a7f369 100644 --- a/ietf/templates/doc/draft/resurrect.html +++ b/ietf/templates/doc/draft/resurrect.html @@ -14,7 +14,7 @@

    Resurrect {{ doc }}?

    - This will change the status to Active {% if doc.idinternal.resurrect_requested_by %} and email a notice to {{ doc.idinternal.resurrect_requested_by }}{% endif %}. + This will change the status to Active{% if resurrect_requested_by %} and email a notice to {{ resurrect_requested_by }}{% endif %}.

    diff --git a/ietf/templates/doc/review/assign_reviewer.html b/ietf/templates/doc/review/assign_reviewer.html new file mode 100644 index 000000000..0e7059b03 --- /dev/null +++ b/ietf/templates/doc/review/assign_reviewer.html @@ -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 %} +

    Assign reviewer
    {{ review_req.doc.name }}

    + + + {% csrf_token %} + + {% bootstrap_form form %} + + {% buttons %} + Cancel + + {% endbuttons %} +
    + +{% endblock %} diff --git a/ietf/templates/doc/review/close_request.html b/ietf/templates/doc/review/close_request.html new file mode 100644 index 000000000..2413cf22f --- /dev/null +++ b/ietf/templates/doc/review/close_request.html @@ -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 %} +

    Close review request
    {{ review_req.doc.name }}

    + +

    Do you want to close the review request?

    + +
    + {% csrf_token %} + + {% bootstrap_form form %} + + {% buttons %} + Cancel + + {% endbuttons %} +
    + +{% endblock %} diff --git a/ietf/templates/doc/review/complete_review.html b/ietf/templates/doc/review/complete_review.html new file mode 100644 index 000000000..12112fc98 --- /dev/null +++ b/ietf/templates/doc/review/complete_review.html @@ -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 %} +

    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 the 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/reject_reviewer_assignment.html b/ietf/templates/doc/review/reject_reviewer_assignment.html new file mode 100644 index 000000000..26097da19 --- /dev/null +++ b/ietf/templates/doc/review/reject_reviewer_assignment.html @@ -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 %} +

    Reject review assignment
    {{ review_req.doc.name }}

    + +

    {{ review_req.reviewer.person }} is currently assigned to do the review. Do you want to reject this assignment?

    + +
    + {% csrf_token %} + + {% bootstrap_form form %} + + {% buttons %} + Cancel + + {% endbuttons %} +
    + +{% endblock %} diff --git a/ietf/templates/doc/review/request_review.html b/ietf/templates/doc/review/request_review.html new file mode 100644 index 000000000..39625ca64 --- /dev/null +++ b/ietf/templates/doc/review/request_review.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2016, All Rights Reserved #} +{% load origin bootstrap3 static %} + +{% block pagehead %} + + + +{% endblock %} + +{% block title %}Request review of {{ doc.name }} {% endblock %} + +{% block content %} + {% origin %} +

    Request review
    {{ doc.name }}

    + +

    Submit a request to have the document reviewed.

    + +

    +

    Current revision of the document: {{ doc.rev }}.
    + + {% if lc_ends %} +
    Last Call ends: {{ lc_ends|date:"Y-m-d" }} (in {{ lc_ends_days }} day{{ lc_ends_days|pluralize }}).
    + {% endif %} + + {% if scheduled_for_telechat %} +
    Scheduled for telechat: {{ scheduled_for_telechat|date:"Y-m-d" }} (in {{ scheduled_for_telechat_days }} day{{ scheduled_for_telechat_days|pluralize }}).
    + {% endif %} +

    + +
    + {% 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 %} + + Back + {% endbuttons %} +
    +{% endblock %} + +{% block js %} + + + +{% endblock %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html new file mode 100644 index 000000000..6cfbb90d9 --- /dev/null +++ b/ietf/templates/doc/review/review_request.html @@ -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 %} +

    Review request
    {{ review_req.doc.name }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if review_req.requested_by.name != "(System)" %} + + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + {% if review_req.review and review_req.review.external_url %} + + + + + + {% endif %} + + {% if review_req.reviewed_rev %} + + + + + + {% endif %} + + {% if review_req.result %} + + + + + + {% endif %} + +
    RequestReview of + {% if review_req.requested_rev %} + {{ review_req.doc.name }}-{{ review_req.requested_rev }} + {% else %} + {{ review_req.doc.name }} + {% endif %} +
    Requested rev. + {% 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 %} +
    Type{{ review_req.type.name }} Review
    Team{{ review_req.team.acronym|upper }}
    Deadline{{ review_req.deadline|date:"Y-m-d" }}
    Requested{{ review_req.time|date:"Y-m-d" }}
    Requested by{{ review_req.requested_by }}
    ReviewState{{ review_req.state.name }}
    Reviewer + {% if review_req.reviewer %} + {{ review_req.reviewer.person }} + {% else %} + None assigned yet + {% endif %} + + {% if can_assign_reviewer %} + {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer + {% endif %} + + {% if review_req.reviewer %} + {% if can_reject_reviewer_assignment or can_accept_reviewer_assignment %} +
    + {% if review_req.state_id == "requested"%} + Assignment not accepted yet: + {% else %} + Assignment accepted: + {% endif %} + + {% if can_reject_reviewer_assignment %} + Reject + {% endif %} + + {% if can_accept_reviewer_assignment %} +
    {% csrf_token %}
    + {% endif %} +
    + {% endif %} + {% endif %} +
    Review + {% if review_req.review %} + {{ review_req.review.name }} + {% elif review_req.state_id == "requested" or review_req.state_id == "accepted" %} + Not completed yet + {% else %} + Not available + {% endif %} + + {% if can_complete_review %} + Complete review + {% endif %} +
    Posted at + {{ review_req.review.external_url }} +
    Reviewed rev.{{ review_req.reviewed_rev }} {% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %}
    Review result{{ review_req.result.name }}
    + +
    + {% if can_close_request %} + Close request + {% endif %} +
    + + +{% endblock %} diff --git a/ietf/templates/doc/review_request_summary.html b/ietf/templates/doc/review_request_summary.html new file mode 100644 index 000000000..7f1faa9c6 --- /dev/null +++ b/ietf/templates/doc/review_request_summary.html @@ -0,0 +1,12 @@ + diff --git a/ietf/templates/doc/search/status_columns.html b/ietf/templates/doc/search/status_columns.html index 6b799e1e8..f5ac41e80 100644 --- a/ietf/templates/doc/search/status_columns.html +++ b/ietf/templates/doc/search/status_columns.html @@ -42,9 +42,16 @@ {{ doc.intended_std_level }} {% endif %} + {% if doc.reviewed_by_teams %} +
    Reviewed by: + {% for g in doc.reviewed_by_teams %} + {{ g.acronym }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% endif %} + {% for m in doc.milestones %} - {% if forloop.first %}
    {% else %}, {% endif %} - {{ m.due|date:"M Y" }} + {% if forloop.first %}
    {% endif %} + {{ m.due|date:"M Y" }}{% if not forloop.last %}, {% endif %} {% endfor %} {% else %}{# RFC #} diff --git a/ietf/templates/group/change_reviewer_settings.html b/ietf/templates/group/change_reviewer_settings.html new file mode 100644 index 000000000..3c5ae3b6c --- /dev/null +++ b/ietf/templates/group/change_reviewer_settings.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block pagehead %} + +{% endblock %} + +{% block title %}Change reviewer settings for {{ group.acronym }} for {{ reviewer_email }}{% endblock %} + +{% block content %} + {% origin %} + +

    Change reviewer settings for {{ group.acronym }} for {{ reviewer_email }}

    + +

    Settings

    + +
    {% csrf_token %} + {% bootstrap_form settings_form %} + + {% buttons %} + Cancel + + {% endbuttons %} +
    + +

    Unavailable periods

    + +

    You can register periods where reviews should not be assigned.

    + + {% if unavailable_periods %} + + {% for o in unavailable_periods %} + + + + + + + {% endfor %} +
    + {{ o.start_date }} - {{ o.end_date|default:"indefinite" }} + {{ o.get_availability_display }} + {% if not o.end_date %} +
    + {% csrf_token %} + + {% bootstrap_form o.end_form layout="inline" %} + +
    + {% endif %} +
    +
    + {% csrf_token %} + + +
    +
    + {% else %} +

    No periods found.

    + {% endif %} + + + +
    +

    Add a new period

    + +
    + {% csrf_token %} + {% bootstrap_form period_form %} + + {% buttons %} + + {% endbuttons %} +
    +
    + +

    + Back +

    +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/ietf/templates/group/email_open_review_assignments.html b/ietf/templates/group/email_open_review_assignments.html new file mode 100644 index 000000000..c5883eb6c --- /dev/null +++ b/ietf/templates/group/email_open_review_assignments.html @@ -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 %} + +

    Email summary of assigned review requests for {{ group.acronym }}

    + + {% if review_requests %} + + {% else %} +

    There are currently no open requests.

    + {% endif %} +{% endblock %} diff --git a/ietf/templates/group/email_open_review_assignments.txt b/ietf/templates/group/email_open_review_assignments.txt new file mode 100644 index 000000000..ef6b3f692 --- /dev/null +++ b/ietf/templates/group/email_open_review_assignments.txt @@ -0,0 +1,8 @@ +{% 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 %} +{% if rotation_list %}Next in the reviewer rotation: + +{% for p in rotation_list %} {{ p }} +{% endfor %}{% endif %}{% endautoescape %} diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html new file mode 100644 index 000000000..e8ff4e8d1 --- /dev/null +++ b/ietf/templates/group/manage_review_requests.html @@ -0,0 +1,146 @@ +{% 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 %} + +{% endblock %} + +{% block content %} + {% origin %} + +

    Manage open review requests for {{ group.acronym }}

    + +

    Other options: + Reviewers in team + - Email open assignments summary +

    + + {% if newly_closed > 0 or newly_opened > 0 or newly_assigned > 0 %} +

    + 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 %} +

    + {% endif %} + + {% if review_requests %} +
    {% csrf_token %} + + + + + + + + + + {% for r in review_requests %} + + + + + + {% endfor %} + +
    DocumentDeadlineAction
    + {{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %} + + + + {% if r.latest_reqs %} + {% for rlatest in r.latest_reqs %} +
    + - prev. review of {% if rlatest.doc_id != r.doc_id %}{{ rlatest.doc_id }}{% endif %}-{{ rlatest.reviewed_rev }}: + {% if rlatest.result %}{{ rlatest.result.name }}{% else %}result unavail.{% endif %} + (diff){% if not forloop.last %},{% endif %} + +
    + {% endfor %} + {% endif %} + + {% if r.form.non_field_errors %} +
    + {% for e in r.form.non_field_errors %} + {{ e }} + {% endfor %} +
    + {% endif %} +
    + {{ r.deadline|date:"Y-m-d" }} + {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} + + + + + + {% if r.reviewer %} + + {% else %} + + {% endif %} + + + {{ r.form.action }} + + + + {{ r.form.reviewer }} + + {% if r.form.reviewer.errors %} +
    + {% for e in r.form.reviewer.errors %} + {{ e }} + {% endfor %} +
    + {% endif %} +
    + + + + + + + + {{ r.form.close }} + + {% if r.form.close.errors %} +
    + {{ r.form.close.errors }} + {% endif %} +
    +
    + + {% buttons %} + Cancel + + + + {% endbuttons %} +
    + {% else %} +

    There are currently no open requests.

    + {% endif %} +{% endblock %} + +{% block js %} + + +{% endblock %} diff --git a/ietf/templates/group/review_requests.html b/ietf/templates/group/review_requests.html new file mode 100644 index 000000000..d746fbc41 --- /dev/null +++ b/ietf/templates/group/review_requests.html @@ -0,0 +1,114 @@ +{% 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 %} + +{% endblock %} + +{% block group_content %} + {% origin %} + +

    Open review requests

    + + {% if open_review_requests %} + + + + + + + + + + + + {% for r in open_review_requests %} + + + + + + + + {% endfor %} + +
    RequestTypeRequestedDeadlineReviewer
    {{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}{{ r.type.name }}{% if r.pk %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} + {{ r.deadline|date:"Y-m-d" }} + {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} + + {% if r.reviewer %} + {{ r.reviewer.person }} + {% if r.state_id == "accepted" %}Accepted{% endif %} + {% if r.reviewer_unavailable %}Unavailable{% endif %} + {% elif r.pk != None %} + not yet assigned + {% endif %} +
    + + {% else %} +

    There are currently no open requests.

    + {% endif %} + +

    Closed review requests

    + +
    + Past: +
    + {% for key, label in since_choices %} + + {% endfor %} +
    +
    + + {% if closed_review_requests %} + + + + + + + + + + + + + + {% for r in closed_review_requests %} + + + + + + + + + + {% endfor %} + +
    RequestTypeRequestedDeadlineReviewerStateResult
    {{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %}{{ r.type }}{{ r.time|date:"Y-m-d" }}{{ r.deadline|date:"Y-m-d" }} + {% if r.reviewer %} + {{ r.reviewer.person }} + {% else %} + not yet assigned + {% endif %} + {{ r.state.name }} + {% if r.result %} + {{ r.result.name }} + {% endif %} +
    + + {% else %} +

    No closed requests found.

    + {% endif %} + +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/ietf/templates/group/reviewer_overview.html b/ietf/templates/group/reviewer_overview.html new file mode 100644 index 000000000..9a25c8f3b --- /dev/null +++ b/ietf/templates/group/reviewer_overview.html @@ -0,0 +1,60 @@ +{% extends "group/group_base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block group_subtitle %}Reviewers{% endblock %} + +{% block group_content %} + {% origin %} + +

    Reviewers

    + + {% if reviewers %} + + + + + + + + + + {% for person in reviewers %} + + + + + + {% endfor %} + +
    ReviewerDeadline/state/time between assignment and closure for latest assignmentsSettings
    {% if person.settings_url %}{% endif %}{{ person }}{% if person.settings_url %}{% endif %} + + {% for req_pk, doc_name, deadline, state, assignment_to_closure_days in person.latest_reqs %} + + + + + + + {% endfor %} +
    {{ deadline|date }} + {{ state.name }} + + {% if assignment_to_closure_days != None %}{{ assignment_to_closure_days }} day{{ assignment_to_closure_days|pluralize }}{% endif %} + {{ doc_name }}
    +
    + {{ person.settings.get_min_interval_display }} {% if person.settings.skip_next %}(skip: {{ person.settings.skip_next }}){% endif %}
    + {% if person.settings.filter_re %}Filter: {{ person.settings.filter_re|truncatechars:15 }}
    {% endif %} + + {% if person.unavailable_periods %} + {% include "review/unavailable_table.html" with unavailable_periods=person.unavailable_periods %} + {% endif %} +
    + + {% else %} +

    No reviewers found.

    + {% endif %} + +{% endblock %} diff --git a/ietf/templates/iesg/agenda_doc.html b/ietf/templates/iesg/agenda_doc.html index 31f5c18ab..9f0796be9 100644 --- a/ietf/templates/iesg/agenda_doc.html +++ b/ietf/templates/iesg/agenda_doc.html @@ -47,6 +47,15 @@
    Consensus
    {{ doc.consensus }}
    {% endif %} + {% if doc.review_requests %} +
    Reviews
    +
    + {% for review_request in doc.review_requests %} + {% include "doc/review_request_summary.html" with current_doc_name=doc.name current_rev=doc.rev %} + {% endfor %} +
    + {% endif %} + {% if doc.lastcall_expires %}
    Last call expires
    {{ doc.lastcall_expires|date:"Y-m-d" }}
    {% endif %} diff --git a/ietf/templates/iesg/agenda_doc.txt b/ietf/templates/iesg/agenda_doc.txt index 655736628..49ed89043 100644 --- a/ietf/templates/iesg/agenda_doc.txt +++ b/ietf/templates/iesg/agenda_doc.txt @@ -5,5 +5,7 @@ {% endif %} Token: {{ doc.ad }}{% if doc.iana_review_state %} IANA Review: {{ doc.iana_review_state }}{% endif %}{% if doc.consensus %} Consensus: {{ doc.consensus }}{% endif %}{% if doc.lastcall_expires %} - Last call expires: {{ doc.lastcall_expires|date:"Y-m-d" }}{% endif %} + Last call expires: {{ doc.lastcall_expires|date:"Y-m-d" }}{% endif %}{% if doc.review_requests %} + Reviews: {% for review_request in doc.review_requests %}{% with current_doc_name=doc.name current_rev=doc.rev %}{% if not forloop.first %} {% endif %}{{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.state_id == "completed" or review_request.state_id == "part-completed" %}{% 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 %}{% if review_request.result %}: {{ review_request.result.name }}{% endif %} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %}{% else %} - due: {{ review_request.deadline|date:"Y-m-d" }}{% endif %}{% endwith %} +{% endfor %}{% endif %} {% with doc.active_defer_event as defer %}{% if defer %} Was deferred by {{defer.by}} on {{defer.time|date:"Y-m-d"}}{% endif %}{% endwith %} diff --git a/ietf/templates/ietfauth/review_overview.html b/ietf/templates/ietfauth/review_overview.html new file mode 100644 index 000000000..edf39f11f --- /dev/null +++ b/ietf/templates/ietfauth/review_overview.html @@ -0,0 +1,170 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %} + +{% load bootstrap3 static %} + +{% block pagehead %} + + +{% endblock %} + +{% block title %}Review overview for {{ request.user }}{% endblock %} + +{% block content %} + {% origin %} +

    Review overview for {{ request.user }}

    + +

    Assigned reviews

    + + {% if open_review_requests %} + + + + + + + + + + + {% for r in open_review_requests %} + + + + + + + {% endfor %} + +
    RequestTeamTypeDeadline
    {{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}{{ r.team.acronym }}{{ r.type.name }} + {{ r.deadline|date:"Y-m-d" }} + {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} +
    + {% else %} +

    You do not have any open review requests assigned.

    + {% endif %} + + +

    Latest closed review requests

    + + {% if closed_review_requests %} + + + + + + + + + + + + + {% for r in closed_review_requests %} + + + + + + + + + {% endfor %} + +
    RequestTeamTypeDeadlineStateResult
    {{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}{{ r.team.acronym }}{{ r.type.name }} + {{ r.deadline|date:"Y-m-d" }} + {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} + {{ r.state.name }}{% if r.result %}{{ r.result.name }}{% endif %}
    + {% else %} +

    Did not find any closed review requests assigned to you.

    + {% endif %} + + +

    Review wishes

    + + {% if review_wishes %} +

    You have indicated that you would like to review:

    + + + {% for w in review_wishes %} + + + + + + {% endfor %} +
    {{ w.doc_id }}{{ w.team.acronym }} +
    + {% csrf_token %} + + + +
    +
    + {% else %} +

    You do not have any review wishes.

    + {% endif %} + + {% if teams %} +

    Add a draft that you would like to review when it becomes available for review:

    + +
    + {% csrf_token %} + {% bootstrap_form review_wish_form layout="inline" %} + + {% buttons %} + + {% endbuttons %} +
    + {% endif %} + + + {% for t in teams %} +

    Settings for {{ t }}

    + + + + + + + + + + + + + + + + + + + + + +
    Can review{{ t.reviewer_settings.get_min_interval_display }}
    Skip next assignments{{ t.reviewer_settings.skip_next }}
    Filter regexp{% if t.reviewer_settings.filter_re %}{{ t.reviewer_settings.filter_re }}{% else %}(None){% endif %}
    Remind days before deadline{{ t.reviewer_settings.remind_days_before_deadline|default:"(Do not remind)" }}
    Unavailable periods + {% if t.unavailable_periods %} + {% include "review/unavailable_table.html" with unavailable_periods=t.unavailable_periods %} + {% else %} + (No periods) + {% endif %} +
    + + + + + {% empty %} +

    Settings

    + +

    It looks like you are not a reviewer in any active review team.

    + {% endfor %} + +{% endblock %} + +{% block js %} + + +{% endblock %} diff --git a/ietf/templates/ietfauth/testemail.html b/ietf/templates/ietfauth/testemail.html index 9d6301d8e..1d21ac88f 100644 --- a/ietf/templates/ietfauth/testemail.html +++ b/ietf/templates/ietfauth/testemail.html @@ -19,7 +19,7 @@

    Value of testmailcc: {{ cookie }}

    -
    + {% csrf_token %} {% bootstrap_form form %} diff --git a/ietf/templates/ietfauth/whitelist_form.html b/ietf/templates/ietfauth/whitelist_form.html index a3f62d45c..44113d005 100644 --- a/ietf/templates/ietfauth/whitelist_form.html +++ b/ietf/templates/ietfauth/whitelist_form.html @@ -81,7 +81,7 @@

    - + {% csrf_token %} {% bootstrap_form form %} diff --git a/ietf/templates/review/completed_review.txt b/ietf/templates/review/completed_review.txt new file mode 100644 index 000000000..7d81d628f --- /dev/null +++ b/ietf/templates/review/completed_review.txt @@ -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 %} diff --git a/ietf/templates/review/partially_completed_review.txt b/ietf/templates/review/partially_completed_review.txt new file mode 100644 index 000000000..6d518357c --- /dev/null +++ b/ietf/templates/review/partially_completed_review.txt @@ -0,0 +1,10 @@ +{% autoescape off %}Review was partially completed by {{ by }}. + +{% if new_review_req_url %} +A new review request has been added for completing the review: + +{{ new_review_req_url }} +{% else %} +Found {{ existing_open_reqs|length }} open review request{{ existing_open_reqs|pluralize }} on the document so a new +review request has not been added. +{% endif %}{% endautoescape %} diff --git a/ietf/templates/review/review_request_changed.txt b/ietf/templates/review/review_request_changed.txt new file mode 100644 index 000000000..4cd471fc6 --- /dev/null +++ b/ietf/templates/review/review_request_changed.txt @@ -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 %} diff --git a/ietf/templates/review/reviewer_assignment_rejected.txt b/ietf/templates/review/reviewer_assignment_rejected.txt new file mode 100644 index 000000000..001de69b3 --- /dev/null +++ b/ietf/templates/review/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/review/reviewer_availability_changed.txt b/ietf/templates/review/reviewer_availability_changed.txt new file mode 100644 index 000000000..63c1fdcb0 --- /dev/null +++ b/ietf/templates/review/reviewer_availability_changed.txt @@ -0,0 +1,8 @@ +{% autoescape off %}{% filter wordwrap:72 %} +Reviewer availability of {{ reviewer }} in {{ team.acronym }} changed by {{ by }}. + +{{ msg }} + +{{ reviewer_overview_url }} + +{% endfilter %}{% endautoescape %} diff --git a/ietf/templates/review/reviewer_reminder.txt b/ietf/templates/review/reviewer_reminder.txt new file mode 100644 index 000000000..4a0dcc540 --- /dev/null +++ b/ietf/templates/review/reviewer_reminder.txt @@ -0,0 +1,8 @@ +{% autoescape off %}{% filter wordwrap:70 %}This is just a friendly reminder that the deadline for the review of {{ review_request.doc_id }} is in {{ deadline_days }} day{{ deadline_days|pluralize }}: + +{{ review_request_url }} + +You are receiving this reminder because you have configured the Datatracker to remind you {{ remind_days }} day{{ remind_days|pluralize }} before deadlines in {{ review_request.team.name }}. You can see your reviews and change your settings here: + +{{ reviewer_overview_url }} +{% endfilter %}{% endautoescape %} diff --git a/ietf/templates/review/unavailable_table.html b/ietf/templates/review/unavailable_table.html new file mode 100644 index 000000000..dcb5d2836 --- /dev/null +++ b/ietf/templates/review/unavailable_table.html @@ -0,0 +1,8 @@ + + {% for p in unavailable_periods %} + + + + + {% endfor %} +
    {{ p.start_date }} - {{ p.end_date|default:"" }}{{ p.get_availability_display }}
    diff --git a/ietf/templates/stats/index.html b/ietf/templates/stats/index.html new file mode 100644 index 000000000..11b9bb8e5 --- /dev/null +++ b/ietf/templates/stats/index.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block content %} + {% origin %} + +

    {% block title %}Statistics{% endblock %}

    + +

    Currently, there are statistics for:

    + + + +{% endblock %} diff --git a/ietf/templates/stats/review_stats.html b/ietf/templates/stats/review_stats.html new file mode 100644 index 000000000..71aefec26 --- /dev/null +++ b/ietf/templates/stats/review_stats.html @@ -0,0 +1,260 @@ +{% extends "base.html" %} + +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block pagehead %} + +{% endblock %} + +{% block content %} + {% origin %} + +

    + {% block title %} + {% if level == "team" %} + Statistics for review teams + {% elif level == "reviewer" %} + Statistics for reviewers in {{ reviewers_for_team.name }} + {% endif %} + {% endblock %} +

    + + {% if level == "reviewer" %} +

    « Back to teams

    + {% endif %} + +
    +
    + Show: +
    + {% for slug, label, url in possible_stats_types %} + {{ label }} + {% endfor %} +
    +
    + +
    + Count: +
    + {% for slug, label, url in possible_count_choices %} + {{ label }} + {% endfor %} +
    +
    + + + Request time: + + + - + + + {% for name, value in request.GET.iteritems %} + {% if name != "from" and name != "to" %} + + {% endif %} + {% endfor %} + + + + + {% if stats_type == "time" %} +
    + +
    + Team: +
    + {% for slug, label, url in possible_teams %} + {{ label }} + {% endfor %} +
    +
    + + {% if selected_team %} +
    + Completion: +
    + {% for slug, label, url in possible_completion_types %} + {{ label }} + {% endfor %} +
    +
    + +
    + Result: +
    + {% for slug, label, url in possible_results %} + {{ label }} + {% endfor %} +
    +
    + +
    + State: +
    + {% for slug, label, url in possible_states %} + {{ label }} + {% endfor %} +
    +
    + {% endif %} + + {% endif %} + +
    + + {% if stats_type == "completion" %} + +

    Completion status and completion time

    + + + + + + + + + + + + + {% for row in data %} + + + + + + + + + + {% endfor %} + +
    + {% if level == "team" %} + Team + {% elif level == "reviewer" %} + Reviewer + {% endif %} + Open in timeOpen lateCompleted in timeCompleted lateNot completedAvg. compl. days{% if count == "pages" %}/page{% endif %}
    {{ row.obj }}{{ row.open_in_time }}{{ row.open_late }}{{ row.completed_in_time }}{{ row.completed_late }}{{ row.not_completed }} + {% if row.average_assignment_to_closure_days != None %} + {{ row.average_assignment_to_closure_days|floatformat }} + {% endif %} +
    + + {% elif stats_type == "results" %} + +

    Results of completed reviews

    + + + + + {% for r in results %} + + {% endfor %} + + + {% for row in data %} + + + {% for c in row.result_list %} + + {% endfor %} + + {% endfor %} + +
    + {% if level == "team" %} + Team + {% elif level == "reviewer" %} + Reviewer + {% endif %} + {{ r.name }}
    {{ row.obj }}{{ c }}
    + + {% elif stats_type == "states" %} + +

    Specific request states

    + + + + + {% for s in states %} + + {% endfor %} + + + {% for row in data %} + + + {% for c in row.state_list %} + + {% endfor %} + + {% endfor %} + +
    + {% if level == "team" %} + Team + {% elif level == "reviewer" %} + Reviewer + {% endif %} + {{ s.name }}
    {{ row.obj }}{{ c }}
    + + {% elif stats_type == "time" and selected_team %} + +

    Counts per month

    + +
    + + + + {% endif %} + + {% if stats_type != "time" %} +

    Note: {% if level == "team" %}teams{% elif level == "reviewer" %}reviewers{% endif %} + with no requests in the period are omitted.

    + {% endif %} + + {% if level == "team" and stats_type != "time" %} +

    Statistics for individual reviewers:

    + +
    + {% for t in teams %} + {{ t.name }} + {% endfor %} +
    + {% endif %} + +{% endblock %} + +{% block js %} + + {% if stats_type == "time" %} + + + + {% endif %} +{% endblock %} diff --git a/ietf/urls.py b/ietf/urls.py index 213dd5203..092f6ba63 100644 --- a/ietf/urls.py +++ b/ietf/urls.py @@ -54,9 +54,10 @@ urlpatterns = patterns('', (r'^secr/', include('ietf.secr.urls')), (r'^sitemap-(?P
    .+).xml$', 'django.contrib.sitemaps.views.sitemap', {'sitemaps': sitemaps}), (r'^sitemap.xml$', 'django.contrib.sitemaps.views.index', { 'sitemaps': sitemaps}), + (r'^stats/', include('ietf.stats.urls')), + (r'^stream/', include('ietf.group.urls_stream')), (r'^submit/', include('ietf.submit.urls')), (r'^sync/', include('ietf.sync.urls')), - (r'^stream/', include('ietf.group.urls_stream')), (r'^templates/', include('ietf.dbtemplate.urls')), (r'^(?P(wg|rg|ag|team|dir|area))/', include('ietf.group.urls_info')), diff --git a/ietf/utils/mail.py b/ietf/utils/mail.py index bed77fa70..101faceb4 100644 --- a/ietf/utils/mail.py +++ b/ietf/utils/mail.py @@ -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 diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index b985bb4fd..12189d8cb 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -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,8 @@ 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, ResultUsedInReviewTeam, + ReviewTypeName, TypeUsedInReviewTeam) def create_person(group, role_name, name=None, username=None, email_address=None, password=None): """Add person/user/email and role.""" @@ -32,6 +34,7 @@ def create_person(group, role_name, name=None, username=None, email_address=None person = Person.objects.create(name=name, ascii=name, user=user) email = Email.objects.create(address=email_address, person=person) Role.objects.create(group=group, name_id=role_name, person=person, email=email) + return person def create_group(**kwargs): return Group.objects.create(state_id="active", **kwargs) @@ -218,7 +221,7 @@ def make_test_data(): mars_wg.save() create_person(ames_wg, "chair", name="Ames Chair Man", username="ameschairman") - create_person(ames_wg, "delegate", name="WG Dèlegate", username="amesdelegate") + create_person(ames_wg, "delegate", name="Ames Delegate", username="amesdelegate") create_person(ames_wg, "secr", name="Mr Secretary", username="amessecretary") ames_wg.role_set.get_or_create(name_id='ad',person=ad,email=ad.role_email('ad')) ames_wg.save() @@ -285,11 +288,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 +393,34 @@ def make_test_data(): #other_doc_factory('recording','recording-42-mars-1-00') return draft + +def make_review_data(doc): + team = create_group(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"]): + ResultUsedInReviewTeam.objects.create(team=team, result=r) + for t in ReviewTypeName.objects.filter(slug__in=["early", "lc", "telechat"]): + TypeUsedInReviewTeam.objects.create(team=team, type=t) + + 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, min_interval=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 + diff --git a/ietf/utils/test_utils.py b/ietf/utils/test_utils.py index bc4ed9b77..b3253aa14 100644 --- a/ietf/utils/test_utils.py +++ b/ietf/utils/test_utils.py @@ -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/') diff --git a/ietf/utils/text.py b/ietf/utils/text.py index 50811af37..7d10d1ee2 100644 --- a/ietf/utils/text.py +++ b/ietf/utils/text.py @@ -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 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")