Merged in ^/branch/iola/review-tracker-r12128@12397, bringing in the review tool functionality described in RFC7735. This adds the ability to set up review management pages for review teams such as genart, secdir, opsdir, etc.; letting the review team secretaries manage requested and completed reviews; letting the reviewers keep track of and document their reviews, and more. See the RFC for full specification, and the branch commit log for a full commit history.
- Legacy-Id: 12419
This commit is contained in:
commit
b914f46313
|
@ -96,7 +96,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))
|
||||
|
|
35
ietf/bin/send-review-reminders
Executable file
35
ietf/bin/send-review-reminders
Executable file
|
@ -0,0 +1,35 @@
|
|||
#!/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,
|
||||
review_requests_needing_secretary_reminder, email_secretary_reminder,
|
||||
)
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
for review_req in review_requests_needing_reviewer_reminder(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))
|
||||
|
||||
for review_req, secretary_role in review_requests_needing_secretary_reminder(today):
|
||||
email_secretary_reminder(review_req, secretary_role)
|
||||
print("Emailed reminder to {} for review of {} in {} (req. id {})".format(review_req.secretary_role.email.address, review_req.doc_id, review_req.team.acronym, review_req.pk))
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
33
ietf/doc/migrations/0016_auto_20160927_0713.py
Normal file
33
ietf/doc/migrations/0016_auto_20160927_0713.py
Normal file
|
@ -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', '0015_auto_20161101_2313'),
|
||||
]
|
||||
|
||||
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'added_message', b'Added message'), (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,
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
||||
|
@ -724,6 +724,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):
|
||||
|
@ -854,11 +859,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')
|
||||
|
|
|
@ -11,8 +11,8 @@ 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, SubmissionDocEvent)
|
||||
|
||||
RelatedDocHistory, BallotPositionDocEvent, AddedMessageEvent, SubmissionDocEvent,
|
||||
ReviewRequestDocEvent)
|
||||
|
||||
from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource
|
||||
class BallotTypeResource(ModelResource):
|
||||
|
@ -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,8 +540,6 @@ class AddedMessageEventResource(ModelResource):
|
|||
}
|
||||
api.doc.register(AddedMessageEventResource())
|
||||
|
||||
|
||||
|
||||
from ietf.person.resources import PersonResource
|
||||
from ietf.submit.resources import SubmissionResource
|
||||
class SubmissionDocEventResource(ModelResource):
|
||||
|
@ -569,3 +565,29 @@ class SubmissionDocEventResource(ModelResource):
|
|||
}
|
||||
api.doc.register(SubmissionDocEventResource())
|
||||
|
||||
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('ietf.review.resources.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())
|
||||
|
||||
|
|
719
ietf/doc/tests_review.py
Normal file
719
ietf/doc/tests_review.py
Normal file
|
@ -0,0 +1,719 @@
|
|||
# -*- 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, ReviewRequestDocEvent
|
||||
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, "reviewsecretary", 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="reviewsecretary").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="reviewsecretary", password="reviewsecretary+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, "reviewsecretary", 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 outbox[0].get_payload(decode=True).decode("utf-8").lower())
|
||||
|
||||
def test_possibly_advance_next_reviewer_for_team(self):
|
||||
doc = make_test_data()
|
||||
|
||||
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))
|
||||
|
||||
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, assigned_review_to_person_id=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, assigned_review_to_person_id=reviewers[1].pk)
|
||||
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
|
||||
|
||||
# skip reviewer 2
|
||||
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=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, assigned_review_to_person_id=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, assigned_review_to_person_id=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, assigned_review_to_person_id=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, assigned_review_to_person_id=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 so advance past reviewer[2]
|
||||
settings, _ = ReviewerSettings.objects.get_or_create(team=team, person=reviewers[2])
|
||||
settings.min_interval = 30
|
||||
settings.save()
|
||||
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, assigned_review_to_person_id=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()
|
||||
|
||||
# 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()
|
||||
|
||||
# set up some reviewer-suitability factors
|
||||
reviewer_email = Email.objects.get(person__user__username="reviewer")
|
||||
DocumentAuthor.objects.create(
|
||||
author=reviewer_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")])
|
||||
|
||||
# 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=reviewer_email,
|
||||
)
|
||||
|
||||
reviewer_settings = ReviewerSettings.objects.get(person__email=reviewer_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=reviewer_email.person,
|
||||
start_date=datetime.date.today() - datetime.timedelta(days=10),
|
||||
availability="unavailable",
|
||||
)
|
||||
|
||||
ReviewWish.objects.create(person=reviewer_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=reviewer_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="reviewsecretary", password="reviewsecretary+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, "reviewsecretary", assign_url)
|
||||
r = self.client.get(assign_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
reviewer_label = q("option[value=\"{}\"]".format(reviewer_email.address)).text().lower()
|
||||
self.assertIn("reviewed document before", reviewer_label)
|
||||
self.assertIn("wishes to review", reviewer_label)
|
||||
self.assertIn("is author", reviewer_label)
|
||||
self.assertIn("regexp matches", reviewer_label)
|
||||
self.assertIn("unavailable indefinitely", reviewer_label)
|
||||
self.assertIn("skip next 1", reviewer_label)
|
||||
self.assertIn("#1", reviewer_label)
|
||||
self.assertIn("no response 0/1", reviewer_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 outbox[0].get_payload(decode=True).decode("utf-8"))
|
||||
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 outbox[0].get_payload(decode=True).decode("utf-8"))
|
||||
self.assertTrue("assigned" in outbox[1].get_payload(decode=True).decode("utf-8"))
|
||||
|
||||
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="reviewsecretary", password="reviewsecretary+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, "reviewsecretary", 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 outbox[0].get_payload(decode=True).decode("utf-8"))
|
||||
|
||||
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"] = "John Doe <johndoe@example.com>"
|
||||
msg["To"] = review_req.team.list_email
|
||||
msg["Subject"] = "Review of {}-01".format(review_req.doc.name)
|
||||
msg["Message-ID"] = email.utils.make_msgid()
|
||||
msg["Archived-At"] = "<https://www.example.com/testmessage>"
|
||||
msg["Date"] = email.utils.formatdate()
|
||||
|
||||
mbox.add(msg)
|
||||
|
||||
# plain text + HTML
|
||||
msg = email.mime.multipart.MIMEMultipart('alternative')
|
||||
msg["From"] = "John Doe II <johndoe2@example.com>"
|
||||
msg["To"] = review_req.team.list_email
|
||||
msg["Subject"] = "Review of {}".format(review_req.doc.name)
|
||||
msg["Message-ID"] = email.utils.make_msgid()
|
||||
msg["Archived-At"] = "<https://www.example.com/testmessage2>"
|
||||
|
||||
msg.attach(email.mime.text.MIMEText("Hi!,\r\nLooks OK!\r\n-John", "plain"))
|
||||
msg.attach(email.mime.text.MIMEText("<html><body><p>Hi!,</p><p>Looks OK!</p><p>-John</p></body></html>", "html"))
|
||||
mbox.add(msg)
|
||||
|
||||
tmp.flush()
|
||||
|
||||
tar.add(os.path.relpath(tmp.name))
|
||||
|
||||
return mbox_path
|
||||
|
||||
def test_search_mail_archive(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
review_req.team.save()
|
||||
|
||||
# test URL construction
|
||||
query_urls = ietf.review.mailarch.construct_query_urls(review_req)
|
||||
self.assertTrue(review_req.doc.name in query_urls["query_data_url"])
|
||||
|
||||
# test parsing
|
||||
mbox_path = self.make_test_mbox_tarball(review_req)
|
||||
|
||||
try:
|
||||
# mock URL generator and point it to local file - for this
|
||||
# to work, the module (and not the function) must be
|
||||
# imported in the view
|
||||
real_fn = ietf.review.mailarch.construct_query_urls
|
||||
ietf.review.mailarch.construct_query_urls = lambda review_req, query=None: { "query_data_url": "file://" + os.path.abspath(mbox_path) }
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.search_mail_archive', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
login_testing_unauthorized(self, "reviewsecretary", url)
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
messages = json.loads(r.content)["messages"]
|
||||
self.assertEqual(len(messages), 2)
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
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[0]["splitfrom"], ["John Doe", "johndoe@example.com"])
|
||||
self.assertEqual(messages[0]["utcdate"][0], today.isoformat())
|
||||
|
||||
self.assertEqual(messages[1]["url"], "https://www.example.com/testmessage2")
|
||||
self.assertTrue("Looks OK" in messages[1]["content"])
|
||||
self.assertTrue("<html>" not in messages[1]["content"])
|
||||
self.assertEqual(messages[1]["subject"], "Review of {}".format(review_req.doc.name))
|
||||
self.assertEqual(messages[1]["splitfrom"], ["John Doe II", "johndoe2@example.com"])
|
||||
self.assertEqual(messages[1]["utcdate"][0], "")
|
||||
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 outbox[0].get_payload(decode=True).decode("utf-8"))
|
||||
|
||||
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)
|
||||
|
||||
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 outbox[0].get_payload(decode=True).decode("utf-8"))
|
||||
|
||||
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)
|
||||
|
||||
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("reviewsecretary@example.com" in outbox[0]["To"])
|
||||
self.assertTrue("partially" in outbox[0]["Subject"].lower())
|
||||
self.assertTrue("new review request" in outbox[0].get_payload(decode=True).decode("utf-8"))
|
||||
|
||||
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 outbox[1].get_payload(decode=True).decode("utf-8"))
|
||||
|
||||
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
|
||||
|
||||
def test_revise_review_enter_content(self):
|
||||
review_req, url = self.setup_complete_review_test()
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="no-response")
|
||||
review_req.save()
|
||||
|
||||
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
|
||||
|
||||
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": "",
|
||||
"completion_date": "2012-12-24",
|
||||
"completion_time": "12:13:14",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "completed")
|
||||
event = ReviewRequestDocEvent.objects.get(type="closed_review_request", review_request=review_req)
|
||||
self.assertEqual(event.time, datetime.datetime(2012, 12, 24, 12, 13, 14))
|
||||
|
||||
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)
|
||||
|
||||
# revise again
|
||||
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 revised review",
|
||||
"review_url": "",
|
||||
"review_file": "",
|
||||
"completion_date": "2013-12-24",
|
||||
"completion_time": "11:11:11",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.review.rev, "01")
|
||||
event = ReviewRequestDocEvent.objects.get(type="closed_review_request", review_request=review_req)
|
||||
self.assertEqual(event.time, datetime.datetime(2013, 12, 24, 11, 11, 11))
|
||||
|
||||
self.assertEqual(len(outbox), 0)
|
||||
|
|
@ -77,6 +77,7 @@ urlpatterns = patterns('',
|
|||
url(r'^%(name)s/ballot/(?P<ballot_id>[0-9]+)/emailposition/$' % settings.URL_REGEXPS, views_ballot.send_ballot_comment, name='doc_send_ballot_comment'),
|
||||
(r'^%(name)s/(?:%(rev)s/)?doc.json$' % settings.URL_REGEXPS, views_doc.document_json),
|
||||
(r'^%(name)s/ballotpopup/(?P<ballot_id>[0-9]+)/$' % settings.URL_REGEXPS, views_doc.ballot_popup),
|
||||
url(r'^(?P<name>[A-Za-z0-9._+-]+)/reviewrequest/', include("ietf.doc.urls_review")),
|
||||
|
||||
url(r'^%(name)s/email-aliases/$' % settings.URL_REGEXPS, RedirectView.as_view(pattern_name='doc_email', permanent=False),name='doc_specific_email_aliases'),
|
||||
|
||||
|
|
13
ietf/doc/urls_review.py
Normal file
13
ietf/doc/urls_review.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from django.conf.urls import patterns, url
|
||||
from ietf.doc import views_review
|
||||
|
||||
urlpatterns = patterns('',
|
||||
url(r'^$', views_review.request_review),
|
||||
url(r'^(?P<request_id>[0-9]+)/$', views_review.review_request),
|
||||
url(r'^(?P<request_id>[0-9]+)/close/$', views_review.close_request),
|
||||
url(r'^(?P<request_id>[0-9]+)/assignreviewer/$', views_review.assign_reviewer),
|
||||
url(r'^(?P<request_id>[0-9]+)/rejectreviewerassignment/$', views_review.reject_reviewer_assignment),
|
||||
url(r'^(?P<request_id>[0-9]+)/complete/$', views_review.complete_review),
|
||||
url(r'^(?P<request_id>[0-9]+)/searchmailarchive/$', views_review.search_mail_archive),
|
||||
)
|
||||
|
|
@ -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):
|
||||
|
|
|
@ -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])
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
587
ietf/doc/views_review.py
Normal file
587
ietf/doc/views_review.py
Normal file
|
@ -0,0 +1,587 @@
|
|||
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, xslugify
|
||||
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_personnel_outside_team=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", "overtaken", "no-response", "part-completed", "completed"]
|
||||
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)
|
||||
completion_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1" }, initial=datetime.date.today, help_text="Date of announcement of the results of this review")
|
||||
completion_time = forms.TimeField(widget=forms.HiddenInput, initial=datetime.time.min)
|
||||
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)
|
||||
|
||||
revising_review = review_req.state_id not in ["requested", "accepted"]
|
||||
|
||||
if not revising_review:
|
||||
self.fields["state"].choices = [
|
||||
(slug, "{} - extra reviewer is to be assigned".format(label)) if slug == "part-completed" else (slug, label)
|
||||
for slug, label in self.fields["state"].choices
|
||||
]
|
||||
|
||||
self.fields["reviewed_rev"].help_text = mark_safe(
|
||||
" ".join("<a class=\"rev label label-default\">{}</a>".format(r)
|
||||
for r in known_revisions))
|
||||
|
||||
self.fields["result"].queryset = self.fields["result"].queryset.filter(resultusedinreviewteam__team=review_req.team)
|
||||
|
||||
def format_submission_choice(label):
|
||||
if revising_review:
|
||||
label = label.replace(" (automatically posts to {mailing_list})", "")
|
||||
|
||||
return label.format(mailing_list=review_req.team.list_email or "[error: team has no mailing list set]")
|
||||
|
||||
self.fields["review_submission"].choices = [ (k, format_submission_choice(label)) for k, label in self.fields["review_submission"].choices]
|
||||
|
||||
if revising_review:
|
||||
del self.fields["cc"]
|
||||
else:
|
||||
del self.fields["completion_date"]
|
||||
del self.fields["completion_time"]
|
||||
|
||||
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)
|
||||
|
||||
revising_review = review_req.state_id not 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']
|
||||
|
||||
review = review_req.review
|
||||
if not review:
|
||||
# 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,
|
||||
xslugify(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.group = review_req.team
|
||||
|
||||
review.rev = "00" if not review.rev else "{:02}".format(int(review.rev) + 1)
|
||||
review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, form.cleaned_data["reviewed_rev"])
|
||||
review.time = datetime.datetime.now()
|
||||
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 and not revising_review
|
||||
|
||||
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."
|
||||
|
||||
completion_datetime = datetime.datetime.now()
|
||||
if "completion_date" in form.cleaned_data:
|
||||
completion_datetime = datetime.datetime.combine(form.cleaned_data["completion_date"], form.cleaned_data.get("completion_time") or datetime.time.min)
|
||||
|
||||
close_event = ReviewRequestDocEvent.objects.filter(type="closed_review_request", review_request=review_req).first()
|
||||
if not close_event:
|
||||
close_event = ReviewRequestDocEvent(type="closed_review_request", review_request=review_req)
|
||||
|
||||
close_event.doc = review_req.doc
|
||||
close_event.by = request.user.person
|
||||
close_event.desc = desc
|
||||
close_event.state = review_req.state
|
||||
close_event.time = completion_datetime
|
||||
close_event.save()
|
||||
|
||||
if review_req.state_id == "part-completed" and not revising_review:
|
||||
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, initial={
|
||||
"reviewed_rev": review_req.reviewed_rev,
|
||||
"result": review_req.result_id
|
||||
})
|
||||
|
||||
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,
|
||||
'revising_review': revising_review,
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
8
ietf/externals/static/flot/jquery.flot.min.js
vendored
Normal file
8
ietf/externals/static/flot/jquery.flot.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
ietf/externals/static/flot/jquery.flot.time.min.js
vendored
Normal file
7
ietf/externals/static/flot/jquery.flot.time.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -6,10 +6,12 @@ class GroupFeatures(object):
|
|||
has_chartering_process = False
|
||||
has_documents = False # i.e. drafts/RFCs
|
||||
has_materials = False
|
||||
has_reviews = False
|
||||
customize_workflow = False
|
||||
about_page = "group_about"
|
||||
default_tab = about_page
|
||||
material_types = ["slides"]
|
||||
admin_roles = ["chair"]
|
||||
|
||||
def __init__(self, group):
|
||||
if group.type_id in ("wg", "rg"):
|
||||
|
@ -24,3 +26,12 @@ class GroupFeatures(object):
|
|||
|
||||
if self.has_chartering_process:
|
||||
self.about_page = "group_charter"
|
||||
|
||||
from ietf.review.utils import active_review_teams
|
||||
if group in active_review_teams():
|
||||
self.has_reviews = True
|
||||
import ietf.group.views
|
||||
self.default_tab = ietf.group.views_review.review_requests
|
||||
|
||||
if group.type_id == "dir":
|
||||
self.admin_roles = ["chair", "secr"]
|
||||
|
|
|
@ -94,7 +94,7 @@ def edit_milestones(request, acronym, group_type=None, milestone_set="current"):
|
|||
|
||||
needs_review = False
|
||||
if not can_manage_group(request.user, group):
|
||||
if group.has_role(request.user, "chair"):
|
||||
if group.has_role(request.user, group.features.admin_roles):
|
||||
if milestone_set == "current":
|
||||
needs_review = True
|
||||
else:
|
||||
|
|
|
@ -29,8 +29,8 @@ from ietf.meeting.factories import SessionFactory
|
|||
from ietf.name.models import DocTagName, GroupStateName, GroupTypeName
|
||||
from ietf.person.models import Person, Email
|
||||
from ietf.utils.mail import outbox, empty_outbox
|
||||
from ietf.utils.test_data import make_test_data, create_person
|
||||
from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent
|
||||
from ietf.utils.test_data import make_test_data, create_person, make_review_data
|
||||
from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects
|
||||
|
||||
def group_urlreverse_list(group, viewname):
|
||||
return [
|
||||
|
@ -575,10 +575,10 @@ class GroupEditTests(TestCase):
|
|||
parent=area.pk,
|
||||
ad=ad.pk,
|
||||
state=state.pk,
|
||||
chairs="aread@ietf.org, ad1@ietf.org",
|
||||
secretaries="aread@ietf.org, ad1@ietf.org, ad2@ietf.org",
|
||||
techadv="aread@ietf.org",
|
||||
delegates="ad2@ietf.org",
|
||||
chair_roles="aread@ietf.org, ad1@ietf.org",
|
||||
secr_roles="aread@ietf.org, ad1@ietf.org, ad2@ietf.org",
|
||||
techadv_roles="aread@ietf.org",
|
||||
delegate_roles="ad2@ietf.org",
|
||||
list_email="mars@mail",
|
||||
list_subscribe="subscribe.mars",
|
||||
list_archive="archive.mars",
|
||||
|
@ -604,6 +604,40 @@ class GroupEditTests(TestCase):
|
|||
for prefix in ['ad1','ad2','aread','marschairman','marsdelegate']:
|
||||
self.assertTrue(prefix+'@' in outbox[0]['To'])
|
||||
|
||||
def test_edit_reviewers(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
group = review_req.team
|
||||
|
||||
url = urlreverse('group_edit', kwargs=dict(group_type=group.type_id, acronym=group.acronym))
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
# normal get
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
self.assertEqual(len(q('form input[name=reviewer_roles]')), 1)
|
||||
|
||||
# set reviewers
|
||||
empty_outbox()
|
||||
r = self.client.post(url,
|
||||
dict(name=group.name,
|
||||
acronym=group.acronym,
|
||||
parent=group.parent_id,
|
||||
ad=Person.objects.get(name="Areað Irector").pk,
|
||||
state=group.state_id,
|
||||
reviewer_roles="ad2@ietf.org",
|
||||
list_email=group.list_email,
|
||||
list_subscribe=group.list_subscribe,
|
||||
list_archive=group.list_archive,
|
||||
urls=""
|
||||
))
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
group = reload_db_objects(group)
|
||||
self.assertEqual(list(group.role_set.filter(name="reviewer").values_list("email", flat=True)), ["ad2@ietf.org"])
|
||||
self.assertTrue('Personnel change' in outbox[0]['Subject'])
|
||||
|
||||
def test_conclude(self):
|
||||
make_test_data()
|
||||
|
||||
|
|
497
ietf/group/tests_review.py
Normal file
497
ietf/group/tests_review.py
Normal file
|
@ -0,0 +1,497 @@
|
|||
import datetime
|
||||
|
||||
from pyquery import PyQuery
|
||||
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
|
||||
from ietf.utils.test_data import make_test_data, make_review_data
|
||||
from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects
|
||||
from ietf.doc.models import TelechatDocEvent
|
||||
from ietf.group.models import Role
|
||||
from ietf.iesg.models import TelechatDate
|
||||
from ietf.person.models import Email, Person
|
||||
from ietf.review.models import ReviewRequest, ReviewerSettings, UnavailablePeriod, ReviewSecretarySettings
|
||||
from ietf.review.utils import (
|
||||
suggested_review_requests_for_team,
|
||||
review_requests_needing_reviewer_reminder, email_reviewer_reminder,
|
||||
review_requests_needing_secretary_reminder, email_secretary_reminder,
|
||||
)
|
||||
from ietf.name.models import ReviewTypeName, ReviewResultName, ReviewRequestStateName
|
||||
import ietf.group.views_review
|
||||
from ietf.utils.mail import outbox, empty_outbox
|
||||
|
||||
class ReviewTests(TestCase):
|
||||
def test_review_requests(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
|
||||
group = review_req.team
|
||||
|
||||
for url in [urlreverse(ietf.group.views_review.review_requests, kwargs={ 'acronym': group.acronym }),
|
||||
urlreverse(ietf.group.views_review.review_requests, kwargs={ 'acronym': group.acronym , 'group_type': group.type_id})]:
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req.doc.name in unicontent(r))
|
||||
self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r))
|
||||
|
||||
url = urlreverse(ietf.group.views_review.review_requests, kwargs={ 'acronym': group.acronym })
|
||||
|
||||
# close request, listed under closed
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="completed")
|
||||
review_req.result = ReviewResultName.objects.get(slug="ready")
|
||||
review_req.save()
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req.doc.name in unicontent(r))
|
||||
|
||||
def test_suggested_review_requests(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
team = review_req.team
|
||||
|
||||
# put on telechat
|
||||
e = TelechatDocEvent.objects.create(
|
||||
type="scheduled_for_telechat",
|
||||
by=Person.objects.get(name="(System)"),
|
||||
doc=doc,
|
||||
telechat_date=TelechatDate.objects.all().first().date,
|
||||
)
|
||||
doc.rev = "10"
|
||||
doc.save_with_history([e])
|
||||
|
||||
prev_rev = "{:02}".format(int(doc.rev) - 1)
|
||||
|
||||
# blocked by existing request
|
||||
review_req.requested_rev = ""
|
||||
review_req.save()
|
||||
|
||||
self.assertEqual(len(suggested_review_requests_for_team(team)), 0)
|
||||
|
||||
# ... but not to previous version
|
||||
review_req.requested_rev = prev_rev
|
||||
review_req.save()
|
||||
suggestions = suggested_review_requests_for_team(team)
|
||||
self.assertEqual(len(suggestions), 1)
|
||||
self.assertEqual(suggestions[0].doc, doc)
|
||||
self.assertEqual(suggestions[0].team, team)
|
||||
|
||||
# blocked by non-versioned refusal
|
||||
review_req.requested_rev = ""
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="no-review-document")
|
||||
review_req.save()
|
||||
|
||||
self.assertEqual(list(suggested_review_requests_for_team(team)), [])
|
||||
|
||||
# blocked by versioned refusal
|
||||
review_req.reviewed_rev = doc.rev
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="no-review-document")
|
||||
review_req.save()
|
||||
|
||||
self.assertEqual(list(suggested_review_requests_for_team(team)), [])
|
||||
|
||||
# blocked by completion
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="completed")
|
||||
review_req.save()
|
||||
|
||||
self.assertEqual(list(suggested_review_requests_for_team(team)), [])
|
||||
|
||||
# ... but not to previous version
|
||||
review_req.reviewed_rev = prev_rev
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="completed")
|
||||
review_req.save()
|
||||
|
||||
self.assertEqual(len(suggested_review_requests_for_team(team)), 1)
|
||||
|
||||
def test_reviewer_overview(self):
|
||||
doc = make_test_data()
|
||||
review_req1 = make_review_data(doc)
|
||||
review_req1.state = ReviewRequestStateName.objects.get(slug="completed")
|
||||
review_req1.save()
|
||||
|
||||
reviewer = review_req1.reviewer.person
|
||||
|
||||
ReviewRequest.objects.create(
|
||||
doc=review_req1.doc,
|
||||
team=review_req1.team,
|
||||
type_id="early",
|
||||
deadline=datetime.date.today() + datetime.timedelta(days=30),
|
||||
state_id="accepted",
|
||||
reviewer=review_req1.reviewer,
|
||||
requested_by=Person.objects.get(user__username="reviewer"),
|
||||
)
|
||||
|
||||
UnavailablePeriod.objects.create(
|
||||
team=review_req1.team,
|
||||
person=reviewer,
|
||||
start_date=datetime.date.today() - datetime.timedelta(days=10),
|
||||
availability="unavailable",
|
||||
)
|
||||
|
||||
settings = ReviewerSettings.objects.get(person=reviewer)
|
||||
settings.skip_next = 1
|
||||
settings.save()
|
||||
|
||||
group = review_req1.team
|
||||
|
||||
# get
|
||||
for url in [urlreverse(ietf.group.views_review.reviewer_overview, kwargs={ 'acronym': group.acronym }),
|
||||
urlreverse(ietf.group.views_review.reviewer_overview, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id })]:
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(unicode(reviewer) in unicontent(r))
|
||||
self.assertTrue(review_req1.doc.name in unicontent(r))
|
||||
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_manage_review_requests(self):
|
||||
doc = make_test_data()
|
||||
review_req1 = make_review_data(doc)
|
||||
|
||||
group = review_req1.team
|
||||
|
||||
url = urlreverse(ietf.group.views_review.manage_review_requests, kwargs={ 'acronym': group.acronym, "assignment_status": "assigned" })
|
||||
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
assigned_url = urlreverse(ietf.group.views_review.manage_review_requests, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id, "assignment_status": "assigned" })
|
||||
unassigned_url = urlreverse(ietf.group.views_review.manage_review_requests, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id, "assignment_status": "unassigned" })
|
||||
|
||||
review_req2 = ReviewRequest.objects.create(
|
||||
doc=review_req1.doc,
|
||||
team=review_req1.team,
|
||||
type_id="early",
|
||||
deadline=datetime.date.today() + datetime.timedelta(days=30),
|
||||
state_id="accepted",
|
||||
reviewer=review_req1.reviewer,
|
||||
requested_by=Person.objects.get(user__username="reviewer"),
|
||||
)
|
||||
|
||||
review_req3 = ReviewRequest.objects.create(
|
||||
doc=review_req1.doc,
|
||||
team=review_req1.team,
|
||||
type_id="early",
|
||||
deadline=datetime.date.today() + datetime.timedelta(days=30),
|
||||
state_id="requested",
|
||||
requested_by=Person.objects.get(user__username="reviewer"),
|
||||
)
|
||||
|
||||
# previous reviews
|
||||
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_req1.team,
|
||||
state=ReviewRequestStateName.objects.get(slug="completed"),
|
||||
result=ReviewResultName.objects.get(slug="ready-nits"),
|
||||
reviewed_rev="01",
|
||||
deadline=datetime.date.today() - datetime.timedelta(days=80),
|
||||
reviewer=review_req1.reviewer,
|
||||
)
|
||||
|
||||
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_req1.team,
|
||||
state=ReviewRequestStateName.objects.get(slug="completed"),
|
||||
result=ReviewResultName.objects.get(slug="ready"),
|
||||
reviewed_rev="01",
|
||||
deadline=datetime.date.today() - datetime.timedelta(days=80),
|
||||
reviewer=review_req1.reviewer,
|
||||
)
|
||||
|
||||
# get
|
||||
r = self.client.get(assigned_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req1.doc.name in unicontent(r))
|
||||
|
||||
# can't save assigned: conflict
|
||||
new_reviewer = Email.objects.get(role__name="reviewer", role__group=group, person__user__username="marschairman")
|
||||
# provoke conflict by posting bogus data
|
||||
r = self.client.post(assigned_url, {
|
||||
"reviewrequest": [str(review_req1.pk), str(review_req2.pk), str(123456)],
|
||||
|
||||
# close
|
||||
"r{}-existing_reviewer".format(review_req1.pk): "123456",
|
||||
"r{}-action".format(review_req1.pk): "close",
|
||||
"r{}-close".format(review_req1.pk): "no-response",
|
||||
|
||||
# assign
|
||||
"r{}-existing_reviewer".format(review_req2.pk): "123456",
|
||||
"r{}-action".format(review_req2.pk): "assign",
|
||||
"r{}-reviewer".format(review_req2.pk): new_reviewer.pk,
|
||||
|
||||
"action": "save-continue",
|
||||
})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
content = unicontent(r).lower()
|
||||
self.assertTrue("1 request closed" in content)
|
||||
self.assertTrue("2 requests changed assignment" in content)
|
||||
|
||||
# can't save unassigned: conflict
|
||||
r = self.client.post(unassigned_url, {
|
||||
"reviewrequest": [str(123456)],
|
||||
"action": "save-continue",
|
||||
})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
content = unicontent(r).lower()
|
||||
self.assertTrue("1 request opened" in content)
|
||||
|
||||
# close and reassign assigned
|
||||
new_reviewer = Email.objects.get(role__name="reviewer", role__group=group, person__user__username="marschairman")
|
||||
r = self.client.post(assigned_url, {
|
||||
"reviewrequest": [str(review_req1.pk), str(review_req2.pk)],
|
||||
|
||||
# close
|
||||
"r{}-existing_reviewer".format(review_req1.pk): review_req1.reviewer_id or "",
|
||||
"r{}-action".format(review_req1.pk): "close",
|
||||
"r{}-close".format(review_req1.pk): "no-response",
|
||||
|
||||
# assign
|
||||
"r{}-existing_reviewer".format(review_req2.pk): review_req2.reviewer_id or "",
|
||||
"r{}-action".format(review_req2.pk): "assign",
|
||||
"r{}-reviewer".format(review_req2.pk): new_reviewer.pk,
|
||||
|
||||
"action": "save",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
# no change on unassigned
|
||||
r = self.client.post(unassigned_url, {
|
||||
"reviewrequest": [str(review_req3.pk)],
|
||||
|
||||
# no change
|
||||
"r{}-existing_reviewer".format(review_req3.pk): review_req3.reviewer_id or "",
|
||||
"r{}-action".format(review_req3.pk): "",
|
||||
"r{}-close".format(review_req3.pk): "no-response",
|
||||
"r{}-reviewer".format(review_req3.pk): "",
|
||||
|
||||
"action": "save",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req1, review_req2, review_req3 = reload_db_objects(review_req1, review_req2, review_req3)
|
||||
self.assertEqual(review_req1.state_id, "no-response")
|
||||
self.assertEqual(review_req2.state_id, "requested")
|
||||
self.assertEqual(review_req2.reviewer, new_reviewer)
|
||||
self.assertEqual(review_req3.state_id, "requested")
|
||||
|
||||
def test_email_open_review_assignments(self):
|
||||
doc = make_test_data()
|
||||
review_req1 = make_review_data(doc)
|
||||
|
||||
group = review_req1.team
|
||||
|
||||
url = urlreverse(ietf.group.views_review.email_open_review_assignments, kwargs={ 'acronym': group.acronym })
|
||||
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
url = urlreverse(ietf.group.views_review.email_open_review_assignments, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id })
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
generated_text = q("[name=body]").text()
|
||||
self.assertTrue(review_req1.doc.name in generated_text)
|
||||
self.assertTrue(unicode(Person.objects.get(user__username="marschairman")) in generated_text)
|
||||
|
||||
empty_outbox()
|
||||
r = self.client.post(url, {
|
||||
"to": group.list_email,
|
||||
"subject": "Test subject",
|
||||
"body": "Test body",
|
||||
"action": "email",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(group.list_email in outbox[0]["To"])
|
||||
self.assertEqual(outbox[0]["subject"], "Test subject")
|
||||
self.assertTrue("Test body" in outbox[0].get_payload(decode=True).decode("utf-8"))
|
||||
|
||||
def test_change_reviewer_settings(self):
|
||||
doc = make_test_data()
|
||||
|
||||
review_req = make_review_data(doc)
|
||||
review_req.reviewer = Email.objects.get(person__user__username="reviewer")
|
||||
review_req.save()
|
||||
|
||||
reviewer = review_req.reviewer.person
|
||||
|
||||
url = urlreverse(ietf.group.views_review.change_reviewer_settings, kwargs={
|
||||
"acronym": review_req.team.acronym,
|
||||
"reviewer_email": review_req.reviewer_id,
|
||||
})
|
||||
|
||||
login_testing_unauthorized(self, reviewer.user.username, url)
|
||||
|
||||
url = urlreverse(ietf.group.views_review.change_reviewer_settings, kwargs={
|
||||
"group_type": review_req.team.type_id,
|
||||
"acronym": review_req.team.acronym,
|
||||
"reviewer_email": review_req.reviewer_id,
|
||||
})
|
||||
|
||||
# get
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# set settings
|
||||
empty_outbox()
|
||||
r = self.client.post(url, {
|
||||
"action": "change_settings",
|
||||
"min_interval": "7",
|
||||
"filter_re": "test-[regexp]",
|
||||
"skip_next": "2",
|
||||
"remind_days_before_deadline": "6"
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
settings = ReviewerSettings.objects.get(person=reviewer, team=review_req.team)
|
||||
self.assertEqual(settings.min_interval, 7)
|
||||
self.assertEqual(settings.filter_re, "test-[regexp]")
|
||||
self.assertEqual(settings.skip_next, 2)
|
||||
self.assertEqual(settings.remind_days_before_deadline, 6)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue("reviewer availability" in outbox[0]["subject"].lower())
|
||||
msg_content = outbox[0].get_payload(decode=True).decode("utf-8").lower()
|
||||
self.assertTrue("frequency changed", msg_content)
|
||||
self.assertTrue("skip next", msg_content)
|
||||
|
||||
# add unavailable period
|
||||
start_date = datetime.date.today() + datetime.timedelta(days=10)
|
||||
empty_outbox()
|
||||
r = self.client.post(url, {
|
||||
"action": "add_period",
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': "",
|
||||
'availability': "unavailable",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
period = UnavailablePeriod.objects.get(person=reviewer, team=review_req.team, start_date=start_date)
|
||||
self.assertEqual(period.end_date, None)
|
||||
self.assertEqual(period.availability, "unavailable")
|
||||
self.assertEqual(len(outbox), 1)
|
||||
msg_content = outbox[0].get_payload(decode=True).decode("utf-8").lower()
|
||||
self.assertTrue(start_date.isoformat(), msg_content)
|
||||
self.assertTrue("indefinite", msg_content)
|
||||
|
||||
# end unavailable period
|
||||
empty_outbox()
|
||||
end_date = start_date + datetime.timedelta(days=10)
|
||||
r = self.client.post(url, {
|
||||
"action": "end_period",
|
||||
'period_id': period.pk,
|
||||
'end_date': end_date.isoformat(),
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
period = reload_db_objects(period)
|
||||
self.assertEqual(period.end_date, end_date)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
msg_content = outbox[0].get_payload(decode=True).decode("utf-8").lower()
|
||||
self.assertTrue(start_date.isoformat(), msg_content)
|
||||
self.assertTrue("indefinite", msg_content)
|
||||
|
||||
# delete unavailable period
|
||||
empty_outbox()
|
||||
r = self.client.post(url, {
|
||||
"action": "delete_period",
|
||||
'period_id': period.pk,
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertEqual(UnavailablePeriod.objects.filter(person=reviewer, team=review_req.team, start_date=start_date).count(), 0)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
msg_content = outbox[0].get_payload(decode=True).decode("utf-8").lower()
|
||||
self.assertTrue(start_date.isoformat(), msg_content)
|
||||
self.assertTrue(end_date.isoformat(), msg_content)
|
||||
|
||||
def test_change_review_secretary_settings(self):
|
||||
doc = make_test_data()
|
||||
|
||||
review_req = make_review_data(doc)
|
||||
|
||||
secretary = Person.objects.get(user__username="reviewsecretary")
|
||||
|
||||
url = urlreverse(ietf.group.views_review.change_review_secretary_settings, kwargs={
|
||||
"acronym": review_req.team.acronym,
|
||||
})
|
||||
|
||||
login_testing_unauthorized(self, secretary.user.username, url)
|
||||
|
||||
url = urlreverse(ietf.group.views_review.change_review_secretary_settings, kwargs={
|
||||
"group_type": review_req.team.type_id,
|
||||
"acronym": review_req.team.acronym,
|
||||
})
|
||||
|
||||
# get
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# set settings
|
||||
r = self.client.post(url, {
|
||||
"remind_days_before_deadline": "6"
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
settings = ReviewSecretarySettings.objects.get(person=secretary, team=review_req.team)
|
||||
self.assertEqual(settings.remind_days_before_deadline, 6)
|
||||
|
||||
def test_review_reminders(self):
|
||||
doc = make_test_data()
|
||||
|
||||
review_req = make_review_data(doc)
|
||||
|
||||
remind_days = 6
|
||||
|
||||
reviewer = Person.objects.get(user__username="reviewer")
|
||||
|
||||
reviewer_settings = ReviewerSettings.objects.get(team=review_req.team, person=reviewer)
|
||||
reviewer_settings.remind_days_before_deadline = remind_days
|
||||
reviewer_settings.save()
|
||||
|
||||
secretary = Person.objects.get(user__username="reviewsecretary")
|
||||
secretary_role = Role.objects.get(group=review_req.team, name="secr", person=secretary)
|
||||
|
||||
secretary_settings = ReviewSecretarySettings(team=review_req.team, person=secretary)
|
||||
secretary_settings.remind_days_before_deadline = remind_days
|
||||
secretary_settings.save()
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
review_req.reviewer = reviewer.email_set.first()
|
||||
review_req.deadline = today + datetime.timedelta(days=remind_days)
|
||||
review_req.save()
|
||||
|
||||
# reviewer
|
||||
needing_reminders = review_requests_needing_reviewer_reminder(today - datetime.timedelta(days=1))
|
||||
self.assertEqual(list(needing_reminders), [])
|
||||
|
||||
needing_reminders = review_requests_needing_reviewer_reminder(today)
|
||||
self.assertEqual(list(needing_reminders), [review_req])
|
||||
|
||||
needing_reminders = review_requests_needing_reviewer_reminder(today + datetime.timedelta(days=1))
|
||||
self.assertEqual(list(needing_reminders), [])
|
||||
|
||||
# secretary
|
||||
needing_reminders = review_requests_needing_secretary_reminder(today - datetime.timedelta(days=1))
|
||||
self.assertEqual(list(needing_reminders), [])
|
||||
|
||||
needing_reminders = review_requests_needing_secretary_reminder(today)
|
||||
self.assertEqual(list(needing_reminders), [(review_req, secretary_role)])
|
||||
|
||||
needing_reminders = review_requests_needing_secretary_reminder(today + datetime.timedelta(days=1))
|
||||
self.assertEqual(list(needing_reminders), [])
|
||||
|
||||
# email reviewer
|
||||
empty_outbox()
|
||||
email_reviewer_reminder(review_req)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(review_req.doc_id in outbox[0].get_payload(decode=True).decode("utf-8"))
|
||||
|
||||
# email secretary
|
||||
empty_outbox()
|
||||
email_secretary_reminder(review_req, secretary_role)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(review_req.doc_id in outbox[0].get_payload(decode=True).decode("utf-8"))
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from django.conf.urls import patterns, url
|
||||
from django.views.generic import RedirectView
|
||||
import views
|
||||
from ietf.group import views, views_review
|
||||
|
||||
urlpatterns = patterns('',
|
||||
(r'^$', 'ietf.group.views.group_home', None, "group_home"),
|
||||
|
@ -30,5 +30,11 @@ urlpatterns = patterns('',
|
|||
(r'^materials/new/(?P<doc_type>[\w-]+)/$', 'ietf.doc.views_material.edit_material', { 'action': "new" }, "group_new_material"),
|
||||
(r'^archives/$', 'ietf.group.views.derived_archives'),
|
||||
(r'^photos/$', views.group_photos),
|
||||
(r'^reviews/$', views_review.review_requests),
|
||||
(r'^reviews/manage/(?P<assignment_status>assigned|unassigned)/$', views_review.manage_review_requests),
|
||||
(r'^reviews/email-assignments/$', views_review.email_open_review_assignments),
|
||||
(r'^reviewers/$', views_review.reviewer_overview),
|
||||
(r'^reviewers/(?P<reviewer_email>[\w%+-.@]+)/settings/$', views_review.change_reviewer_settings),
|
||||
(r'^secretarysettings/$', views_review.change_review_secretary_settings),
|
||||
url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'),
|
||||
)
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
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
|
||||
|
||||
from ietf.group.models import Group, RoleHistory
|
||||
from ietf.group.models import Group, RoleHistory, Role
|
||||
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
|
||||
from ietf.review.utils import can_manage_review_requests_for_team
|
||||
|
||||
|
||||
def save_group_in_history(group):
|
||||
|
@ -149,3 +152,91 @@ 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 and can_manage_review_requests_for_team(request.user, group):
|
||||
import ietf.group.views_review
|
||||
actions.append((u"Manage unassigned reviews", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=dict(assignment_status="unassigned", **kwargs))))
|
||||
actions.append((u"Manage assigned reviews", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=dict(assignment_status="assigned", **kwargs))))
|
||||
|
||||
if Role.objects.filter(name="secr", group=group, person__user=request.user).exists():
|
||||
actions.append((u"Secretary settings", urlreverse(ietf.group.views_review.change_review_secretary_settings, 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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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(strip_suffix(attr, "_roles") for attr in self.fields if attr.endswith("_roles"))
|
||||
- set(roles_for_group_type(self.group_type)))
|
||||
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":
|
||||
|
|
606
ietf/group/views_review.py
Normal file
606
ietf/group/views_review.py
Normal file
|
@ -0,0 +1,606 @@
|
|||
import datetime, math
|
||||
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, ReviewSecretarySettings
|
||||
from ietf.review.utils import (can_manage_review_requests_for_team,
|
||||
can_access_review_stats_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,
|
||||
latest_review_requests_for_reviewers,
|
||||
augment_review_requests_with_events)
|
||||
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 get_open_review_requests_for_team(team, assignment_status=None):
|
||||
open_review_requests = ReviewRequest.objects.filter(
|
||||
team=team,
|
||||
state__in=("requested", "accepted")
|
||||
).prefetch_related(
|
||||
"reviewer__person", "type", "state", "doc", "doc__states",
|
||||
).order_by("-time", "-id")
|
||||
|
||||
if assignment_status == "unassigned":
|
||||
open_review_requests = suggested_review_requests_for_team(team) + list(open_review_requests.filter(reviewer=None))
|
||||
elif assignment_status == "assigned":
|
||||
open_review_requests = list(open_review_requests.exclude(reviewer=None))
|
||||
else:
|
||||
open_review_requests = suggested_review_requests_for_team(team) + list(open_review_requests)
|
||||
|
||||
today = datetime.date.today()
|
||||
unavailable_periods = current_unavailable_periods_for_reviewers(team)
|
||||
for r in open_review_requests:
|
||||
if r.reviewer:
|
||||
r.reviewer_unavailable = any(p.availability == "unavailable"
|
||||
for p in unavailable_periods.get(r.reviewer.person_id, []))
|
||||
r.due = max(0, (today - r.deadline).days)
|
||||
|
||||
return open_review_requests
|
||||
|
||||
def review_requests(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_reviews:
|
||||
raise Http404
|
||||
|
||||
assigned_review_requests = []
|
||||
unassigned_review_requests = []
|
||||
|
||||
for r in get_open_review_requests_for_team(group):
|
||||
if r.reviewer:
|
||||
assigned_review_requests.append(r)
|
||||
else:
|
||||
unassigned_review_requests.append(r)
|
||||
|
||||
open_review_requests = [
|
||||
("Unassigned", unassigned_review_requests),
|
||||
("Assigned", assigned_review_requests),
|
||||
]
|
||||
|
||||
closed_review_requests = ReviewRequest.objects.filter(
|
||||
team=group,
|
||||
).exclude(
|
||||
state__in=("requested", "accepted")
|
||||
).prefetch_related("reviewer__person", "type", "state", "doc", "result").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),
|
||||
"can_access_stats": can_access_review_stats_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()
|
||||
|
||||
req_data_for_reviewers = latest_review_requests_for_reviewers(group)
|
||||
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)):
|
||||
kwargs = { "acronym": group.acronym, "reviewer_email": person.role.email.address }
|
||||
if group_type:
|
||||
kwargs["group_type"] = group_type
|
||||
person.settings_url = urlreverse("ietf.group.views_review.change_reviewer_settings", kwargs=kwargs)
|
||||
person.unavailable_periods = unavailable_periods.get(person.pk, [])
|
||||
person.completely_unavailable = any(p.availability == "unavailable"
|
||||
and (p.start_date is None or p.start_date <= today) and (p.end_date is None or today <= p.end_date)
|
||||
for p in person.unavailable_periods)
|
||||
|
||||
MAX_CLOSED_REQS = 10
|
||||
req_data = req_data_for_reviewers.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:
|
||||
if d.state in ["requested", "accepted"] or len(latest_reqs) < MAX_CLOSED_REQS + open_reqs:
|
||||
latest_reqs.append((d.req_pk, d.doc, d.reviewed_rev, 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,
|
||||
"can_access_stats": can_access_review_stats_for_team(request.user, group)
|
||||
}))
|
||||
|
||||
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, assignment_status=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 = get_open_review_requests_for_team(group, assignment_status=assignment_status)
|
||||
|
||||
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:
|
||||
req.form = ManageReviewRequestForm(req, query_dict)
|
||||
|
||||
# add previous requests
|
||||
l = []
|
||||
for r in document_requests.get(req.doc_id, []):
|
||||
# take all on the latest reviewed rev
|
||||
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]
|
||||
|
||||
augment_review_requests_with_events(l)
|
||||
|
||||
req.latest_reqs = l
|
||||
|
||||
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":
|
||||
if assignment_status:
|
||||
kwargs["assignment_status"] = assignment_status
|
||||
|
||||
return redirect(manage_review_requests, **kwargs)
|
||||
else:
|
||||
import ietf.group.views_review
|
||||
return redirect(ietf.group.views_review.review_requests, **kwargs)
|
||||
|
||||
other_assignment_status = {
|
||||
"unassigned": "assigned",
|
||||
"assigned": "unassigned",
|
||||
}.get(assignment_status)
|
||||
|
||||
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,
|
||||
'assignment_status': assignment_status,
|
||||
'other_assignment_status': other_assignment_status,
|
||||
})
|
||||
|
||||
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"))
|
||||
|
||||
back_url = request.GET.get("next")
|
||||
if not back_url:
|
||||
kwargs = { "acronym": group.acronym }
|
||||
if group_type:
|
||||
kwargs["group_type"] = group_type
|
||||
|
||||
import ietf.group.views_review
|
||||
back_url = urlreverse(ietf.group.views_review.review_requests, kwargs=kwargs)
|
||||
|
||||
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"])
|
||||
|
||||
return HttpResponseRedirect(back_url)
|
||||
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,
|
||||
'back_url': back_url,
|
||||
})
|
||||
|
||||
|
||||
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() if start_date else "" })
|
||||
|
||||
self.start_date = start_date
|
||||
|
||||
def clean_end_date(self):
|
||||
end = self.cleaned_data["end_date"]
|
||||
if self.start_date and 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
|
||||
kwargs = { "acronym": group.acronym}
|
||||
if group_type:
|
||||
kwargs["group_type"] = group_type
|
||||
back_url = urlreverse(ietf.group.views_review.reviewer_overview, kwargs=kwargs)
|
||||
|
||||
# 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() or "Not specified", prev_min_interval or "Not specified"))
|
||||
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() if period.start_date else "indefinite",
|
||||
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() if period.start_date else "indefinite",
|
||||
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() if period.start_date else "indefinite",
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
class ReviewSecretarySettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ReviewSecretarySettings
|
||||
fields = ['remind_days_before_deadline']
|
||||
|
||||
|
||||
@login_required
|
||||
def change_review_secretary_settings(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_reviews:
|
||||
raise Http404
|
||||
if not Role.objects.filter(name="secr", group=group, person__user=request.user).exists():
|
||||
raise Http404
|
||||
|
||||
person = request.user.person
|
||||
|
||||
settings = (ReviewSecretarySettings.objects.filter(person=person, team=group).first()
|
||||
or ReviewSecretarySettings(person=person, team=group))
|
||||
|
||||
import ietf.group.views_review
|
||||
back_url = urlreverse(ietf.group.views_review.review_requests, kwargs={ "acronym": acronym, "group_type": group.type_id })
|
||||
|
||||
# settings
|
||||
if request.method == "POST":
|
||||
settings_form = ReviewSecretarySettingsForm(request.POST, instance=settings)
|
||||
if settings_form.is_valid():
|
||||
settings_form.save()
|
||||
return HttpResponseRedirect(back_url)
|
||||
else:
|
||||
settings_form = ReviewSecretarySettingsForm(instance=settings)
|
||||
|
||||
return render(request, 'group/change_review_secretary_settings.html', {
|
||||
'group': group,
|
||||
'back_url': back_url,
|
||||
'settings_form': settings_form,
|
||||
})
|
|
@ -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":
|
||||
|
|
|
@ -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' ]
|
||||
|
|
|
@ -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()
|
||||
|
||||
review_req = make_review_data(doc)
|
||||
review_req.reviewer = Email.objects.get(person__user__username="reviewer")
|
||||
review_req.save()
|
||||
|
||||
reviewer = review_req.reviewer.person
|
||||
|
||||
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
|
||||
|
|
|
@ -21,4 +21,5 @@ urlpatterns = patterns('ietf.ietfauth.views',
|
|||
url(r'^reset/confirm/(?P<auth>[^/]+)/$', 'confirm_password_reset'),
|
||||
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', 'confirm_new_email'),
|
||||
(r'whitelist/add/?$', add_account_whitelist),
|
||||
url(r'^review/$', 'review_overview'),
|
||||
)
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -261,17 +261,17 @@ class MeetingTests(TestCase):
|
|||
r = self.client.get(urlreverse("ietf.meeting.views.materials", kwargs=dict(num=meeting.number)))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
row = q('#content td div:contains("%s")' % str(session.group.acronym)).closest("tr")
|
||||
row = q('#content #%s' % str(session.group.acronym)).closest("tr")
|
||||
self.assertTrue(row.find('a:contains("Agenda")'))
|
||||
self.assertTrue(row.find('a:contains("Minutes")'))
|
||||
self.assertTrue(row.find('a:contains("Slideshow")'))
|
||||
self.assertFalse(row.find("a:contains(\"Bad Slideshow\")"))
|
||||
|
||||
#test with no meeting number in url
|
||||
# test with no meeting number in url
|
||||
r = self.client.get(urlreverse("ietf.meeting.views.materials", kwargs=dict()))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
row = q('#content td div:contains("%s")' % str(session.group.acronym)).closest("tr")
|
||||
row = q('#content #%s' % str(session.group.acronym)).closest("tr")
|
||||
self.assertTrue(row.find('a:contains("Agenda")'))
|
||||
self.assertTrue(row.find('a:contains("Minutes")'))
|
||||
self.assertTrue(row.find('a:contains("Slideshow")'))
|
||||
|
@ -544,8 +544,8 @@ class SessionDetailsTests(TestCase):
|
|||
|
||||
r = self.client.post(url,dict(drafts=[new_draft.name,old_draft.name]))
|
||||
self.assertTrue(r.status_code, 200)
|
||||
q=PyQuery(r.content)
|
||||
self.assertTrue(q('form .alert-danger:contains("Already linked:")'))
|
||||
q = PyQuery(r.content)
|
||||
self.assertTrue("Already linked:" in q('form .alert-danger').text())
|
||||
|
||||
self.assertEqual(1,session.sessionpresentation_set.count())
|
||||
r = self.client.post(url,dict(drafts=[new_draft.name,]))
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from ietf.name.models import (
|
||||
BallotPositionName, ConstraintName, DBTemplateTypeName,
|
||||
DocRelationshipName, DocReminderTypeName, DocTagName, DocTypeName,
|
||||
DraftSubmissionStateName, FeedbackTypeName, GroupMilestoneStateName,
|
||||
GroupStateName, GroupTypeName, IntendedStdLevelName,
|
||||
IprDisclosureStateName, IprEventTypeName, IprLicenseTypeName,
|
||||
LiaisonStatementEventTypeName, LiaisonStatementPurposeName,
|
||||
LiaisonStatementState, LiaisonStatementTagName, MeetingTypeName,
|
||||
NomineePositionStateName, RoleName, RoomResourceName, SessionStatusName,
|
||||
StdLevelName, StreamName, TimeSlotTypeName, )
|
||||
BallotPositionName, ConstraintName, DBTemplateTypeName, DocRelationshipName,
|
||||
DocReminderTypeName, DocTagName, DocTypeName, DraftSubmissionStateName,
|
||||
FeedbackTypeName, GroupMilestoneStateName, GroupStateName, GroupTypeName,
|
||||
IntendedStdLevelName, IprDisclosureStateName, IprEventTypeName, IprLicenseTypeName,
|
||||
LiaisonStatementEventTypeName, LiaisonStatementPurposeName, LiaisonStatementState,
|
||||
LiaisonStatementTagName, MeetingTypeName, NomineePositionStateName,
|
||||
ReviewRequestStateName, ReviewResultName, ReviewTypeName, RoleName, RoomResourceName,
|
||||
SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName, )
|
||||
|
||||
class NameAdmin(admin.ModelAdmin):
|
||||
list_display = ["slug", "name", "desc", "used"]
|
||||
|
@ -37,17 +37,21 @@ admin.site.register(GroupMilestoneStateName, NameAdmin)
|
|||
admin.site.register(GroupStateName, NameAdmin)
|
||||
admin.site.register(IntendedStdLevelName, NameAdmin)
|
||||
admin.site.register(IprDisclosureStateName, NameAdmin)
|
||||
admin.site.register(IprLicenseTypeName, NameAdmin)
|
||||
admin.site.register(IprEventTypeName, NameAdmin)
|
||||
admin.site.register(LiaisonStatementState, NameAdmin)
|
||||
admin.site.register(IprLicenseTypeName, NameAdmin)
|
||||
admin.site.register(LiaisonStatementEventTypeName, NameAdmin)
|
||||
admin.site.register(LiaisonStatementPurposeName, NameAdmin)
|
||||
admin.site.register(LiaisonStatementState, NameAdmin)
|
||||
admin.site.register(LiaisonStatementTagName, NameAdmin)
|
||||
admin.site.register(MeetingTypeName, NameAdmin)
|
||||
admin.site.register(NomineePositionStateName, NameAdmin)
|
||||
admin.site.register(ReviewRequestStateName, NameAdmin)
|
||||
admin.site.register(ReviewResultName, NameAdmin)
|
||||
admin.site.register(ReviewTypeName, NameAdmin)
|
||||
admin.site.register(RoleName, NameAdmin)
|
||||
admin.site.register(RoomResourceName, NameAdmin)
|
||||
admin.site.register(SessionStatusName, NameAdmin)
|
||||
admin.site.register(StdLevelName, NameAdmin)
|
||||
admin.site.register(StreamName, NameAdmin)
|
||||
admin.site.register(TimeSlotTypeName, NameAdmin)
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,5 +1,7 @@
|
|||
#!/usr/bin/python
|
||||
|
||||
# simple script for exporting name related base data for the tests
|
||||
|
||||
# boiler plate
|
||||
import os, sys
|
||||
import django
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('group', '0008_auto_20160505_0523'),
|
||||
('name', '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,),
|
||||
),
|
||||
]
|
64
ietf/name/migrations/0015_insert_review_name_data.py
Normal file
64
ietf/name/migrations/0015_insert_review_name_data.py
Normal file
|
@ -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),
|
||||
]
|
138
ietf/name/migrations/0016_auto_20161013_1010.py
Normal file
138
ietf/name/migrations/0016_auto_20161013_1010.py
Normal file
|
@ -0,0 +1,138 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('name', '0015_insert_review_name_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='ballotpositionname',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='constraintname',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='dbtemplatetypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='docrelationshipname',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='docremindertypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='doctagname',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='doctypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='draftsubmissionstatename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='feedbacktypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='groupmilestonestatename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='groupstatename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='grouptypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='intendedstdlevelname',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='iprdisclosurestatename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='ipreventtypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='iprlicensetypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='liaisonstatementeventtypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='liaisonstatementpurposename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='liaisonstatementstate',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='liaisonstatementtagname',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='meetingtypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='nomineepositionstatename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='reviewrequeststatename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='reviewresultname',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='reviewtypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='rolename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='roomresourcename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='sessionstatusname',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='stdlevelname',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='streamname',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='timeslottypename',
|
||||
options={'ordering': ['order', 'name']},
|
||||
),
|
||||
]
|
|
@ -14,7 +14,7 @@ class NameModel(models.Model):
|
|||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ['order']
|
||||
ordering = ['order', 'name']
|
||||
|
||||
class GroupStateName(NameModel):
|
||||
"""BOF, Proposed, Active, Dormant, Concluded, Abandoned"""
|
||||
|
@ -88,3 +88,13 @@ class LiaisonStatementEventTypeName(NameModel):
|
|||
"Submitted, Modified, Approved, Posted, Killed, Resurrected, MsgIn, MsgOut, Comment"
|
||||
class LiaisonStatementTagName(NameModel):
|
||||
"Action Required, Action Taken"
|
||||
class ReviewRequestStateName(NameModel):
|
||||
"""Requested, Accepted, Rejected, Withdrawn, Overtaken By Events,
|
||||
No Response, No Review of Version, No Review of Document, Partially Completed, Completed"""
|
||||
class ReviewTypeName(NameModel):
|
||||
"""Early Review, Last Call, Telechat"""
|
||||
class ReviewResultName(NameModel):
|
||||
"""Almost ready, Has issues, Has nits, Not Ready,
|
||||
On the right track, Ready, Ready with issues,
|
||||
Ready with nits, Serious Issues"""
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ from ietf.name.models import (TimeSlotTypeName, GroupStateName, DocTagName, Inte
|
|||
IprEventTypeName, GroupMilestoneStateName, SessionStatusName, DocReminderTypeName,
|
||||
ConstraintName, MeetingTypeName, DocRelationshipName, RoomResourceName, IprLicenseTypeName,
|
||||
LiaisonStatementTagName, FeedbackTypeName, LiaisonStatementState, StreamName,
|
||||
BallotPositionName, DBTemplateTypeName, NomineePositionStateName)
|
||||
BallotPositionName, DBTemplateTypeName, NomineePositionStateName,
|
||||
ReviewRequestStateName, ReviewTypeName, ReviewResultName)
|
||||
|
||||
|
||||
class TimeSlotTypeNameResource(ModelResource):
|
||||
|
@ -413,3 +414,45 @@ class NomineePositionStateNameResource(ModelResource):
|
|||
}
|
||||
api.name.register(NomineePositionStateNameResource())
|
||||
|
||||
class ReviewRequestStateNameResource(ModelResource):
|
||||
class Meta:
|
||||
cache = SimpleCache()
|
||||
queryset = ReviewRequestStateName.objects.all()
|
||||
#resource_name = 'reviewrequeststatename'
|
||||
filtering = {
|
||||
"slug": ALL,
|
||||
"name": ALL,
|
||||
"desc": ALL,
|
||||
"used": ALL,
|
||||
"order": ALL,
|
||||
}
|
||||
api.name.register(ReviewRequestStateNameResource())
|
||||
|
||||
class ReviewTypeNameResource(ModelResource):
|
||||
class Meta:
|
||||
cache = SimpleCache()
|
||||
queryset = ReviewTypeName.objects.all()
|
||||
#resource_name = 'reviewtypename'
|
||||
filtering = {
|
||||
"slug": ALL,
|
||||
"name": ALL,
|
||||
"desc": ALL,
|
||||
"used": ALL,
|
||||
"order": ALL,
|
||||
}
|
||||
api.name.register(ReviewTypeNameResource())
|
||||
|
||||
class ReviewResultNameResource(ModelResource):
|
||||
class Meta:
|
||||
cache = SimpleCache()
|
||||
queryset = ReviewResultName.objects.all()
|
||||
#resource_name = 'reviewresultname'
|
||||
filtering = {
|
||||
"slug": ALL,
|
||||
"name": ALL,
|
||||
"desc": ALL,
|
||||
"used": ALL,
|
||||
"order": ALL,
|
||||
}
|
||||
api.name.register(ReviewResultNameResource())
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import re
|
||||
|
||||
def name_parts(name):
|
||||
prefix, first, middle, last, suffix = "", "", "", "", ""
|
||||
prefix, first, middle, last, suffix = u"", u"", u"", u"", u""
|
||||
|
||||
if not name.strip():
|
||||
return prefix, first, middle, last, suffix
|
||||
|
||||
# if we got a name on the form "Some Name (Foo Bar)", get rid of
|
||||
# the paranthesized part
|
||||
name_with_paren_match = re.search("^([^(]+)\s*\(.*\)$", name)
|
||||
name_with_paren_match = re.search(r"^([^(]+)\s*\(.*\)$", name)
|
||||
if name_with_paren_match:
|
||||
name = name_with_paren_match.group(1)
|
||||
|
||||
|
@ -24,8 +24,8 @@ def name_parts(name):
|
|||
suffix = parts[-1]
|
||||
parts = parts[:-1]
|
||||
if len(parts) > 2:
|
||||
name = " ".join(parts)
|
||||
compound = re.search(" (de|hadi|van|ver|von|el|le|st\.?) ", name.lower())
|
||||
name = u" ".join(parts)
|
||||
compound = re.search(r" (de|hadi|van|ver|von|el|le|st\.?) ", name.lower())
|
||||
if compound:
|
||||
pos = compound.start()
|
||||
parts = name[:pos].split() + [name[pos+1:]]
|
||||
|
@ -35,7 +35,7 @@ def name_parts(name):
|
|||
# Handle reverse-order names with uppercase surname correctly
|
||||
if re.search("^[A-Z-]+$", first):
|
||||
first, last = last, first
|
||||
middle = " ".join(parts[1:-1])
|
||||
middle = u" ".join(parts[1:-1])
|
||||
elif len(parts) == 2:
|
||||
first, last = parts
|
||||
else:
|
||||
|
@ -46,13 +46,13 @@ def initials(name):
|
|||
prefix, first, middle, last, suffix = name_parts(name)
|
||||
given = first
|
||||
if middle:
|
||||
given += " "+middle
|
||||
initials = " ".join([ n[0]+'.' for n in given.split() ])
|
||||
given += u" "+middle
|
||||
initials = u" ".join([ n[0]+'.' for n in given.split() ])
|
||||
return initials
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
name = " ".join(sys.argv[1:])
|
||||
name = u" ".join(sys.argv[1:])
|
||||
print name_parts(name)
|
||||
print initials(name)
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
|
0
ietf/review/__init__.py
Normal file
0
ietf/review/__init__.py
Normal file
71
ietf/review/admin.py
Normal file
71
ietf/review/admin.py
Normal file
|
@ -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)
|
779
ietf/review/import_from_review_tool.py
Executable file
779
ietf/review/import_from_review_tool.py
Executable file
|
@ -0,0 +1,779 @@
|
|||
#!/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, xslugify
|
||||
from ietf.review.utils import possibly_advance_next_reviewer_for_team
|
||||
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")
|
||||
parser.add_argument("--genartdata", help="genart data file")
|
||||
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):
|
||||
import time
|
||||
if not t:
|
||||
return None
|
||||
return datetime.datetime(*time.gmtime(t)[:6])
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
reviewer_blacklist = set([("genart", "alice")])
|
||||
|
||||
name_to_login = {}
|
||||
|
||||
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
|
||||
|
||||
if (team.acronym, row.login) in reviewer_blacklist:
|
||||
continue # ignore
|
||||
|
||||
name_to_login[row.name] = row.login
|
||||
|
||||
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
|
||||
and (not parse_timestamp(row.available) or parse_timestamp(row.available).date() < datetime.date(2020, 1, 1))):
|
||||
|
||||
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_settings, created = ReviewerSettings.objects.get_or_create(
|
||||
team=team,
|
||||
person=email.person,
|
||||
)
|
||||
if created:
|
||||
print "created reviewer settings", reviewer_settings.pk, unicode(reviewer_settings).encode("utf-8")
|
||||
|
||||
reviewer_settings.min_interval = None
|
||||
if autopolicy_days.get(row.autopolicy):
|
||||
reviewer_settings.min_interval = autopolicy_days.get(row.autopolicy)
|
||||
|
||||
reviewer_settings.filter_re = row.donotassign
|
||||
try:
|
||||
reviewer_settings.skip_next = int(row.autopolicy)
|
||||
except ValueError:
|
||||
pass
|
||||
reviewer_settings.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=None,
|
||||
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)
|
||||
possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id=known_personnel[row.value].person.pk)
|
||||
|
||||
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
|
||||
|
||||
ReviewRequestStateName.objects.get_or_create(slug="unknown", name="Unknown", order=20, used=False)
|
||||
|
||||
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
|
||||
latest_deadline = None
|
||||
|
||||
for row in rows:
|
||||
if (team.acronym, row.who) in reviewer_blacklist:
|
||||
continue # ignore
|
||||
|
||||
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 deadline_re.search(row.text):
|
||||
m = deadline_re.search(row.text)
|
||||
latest_deadline = parse_timestamp(int(m.groups()[0]))
|
||||
|
||||
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, latest_deadline)
|
||||
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, latest_deadline)
|
||||
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, latest_deadline)
|
||||
else:
|
||||
latest["closed"] = (row.time, row.who, membername, state, latest_iesg_status, latest_deadline)
|
||||
|
||||
|
||||
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, deadline = non_specific_close
|
||||
latest["closed"] = (close_row.time, close_row.who, m, "done", iesg_status, deadline)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
genart_data = {}
|
||||
if team.acronym == "genart":
|
||||
with open(args.genartdata) as f:
|
||||
for line in f:
|
||||
t = line.strip().split("\t")
|
||||
|
||||
document, reviewer, version_number, result_url, d, review_type, results_time, result_name = t
|
||||
|
||||
d = datetime.datetime.strptime(d, "%Y-%m-%d")
|
||||
|
||||
if results_time:
|
||||
results_time = datetime.datetime.strptime(results_time, "%Y-%m-%dT%H:%M:%S")
|
||||
else:
|
||||
results_time = None
|
||||
|
||||
reviewer_login = name_to_login.get(reviewer)
|
||||
if not reviewer_login:
|
||||
print "WARNING: unknown genart reviewer", reviewer
|
||||
continue
|
||||
|
||||
genart_data[(document, reviewer_login)] = genart_data[(document, reviewer_login, version_number)] = (result_url, d, review_type, results_time, result_name)
|
||||
|
||||
|
||||
system_person = Person.objects.get(name="(System)")
|
||||
|
||||
seen_review_requests = {}
|
||||
|
||||
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
|
||||
|
||||
if (team.acronym, row.reviewer) in reviewer_blacklist:
|
||||
continue # ignore
|
||||
|
||||
reviewed_rev = row.version if row.version and row.version != "99" else ""
|
||||
|
||||
doc_deadline, telechat, lcend, status = doc_metadata.get(row.docname) or (None, None, None, None)
|
||||
|
||||
reviewurl = row.reviewurl
|
||||
reviewstatus = row.docstatus
|
||||
reviewsummary = row.summary
|
||||
donetime = None
|
||||
|
||||
if team.acronym == "genart":
|
||||
extra_data = genart_data.get((row.docname, row.reviewer, reviewed_rev))
|
||||
#if not extra_data:
|
||||
# extra_data = genart_data.get(row.docname)
|
||||
if extra_data:
|
||||
extra_result_url, extra_d, extra_review_type, extra_results_time, extra_result_name = extra_data
|
||||
extra_data = []
|
||||
if not reviewurl and extra_result_url:
|
||||
reviewurl = extra_result_url
|
||||
extra_data.append(reviewurl)
|
||||
|
||||
if not reviewsummary and extra_result_name:
|
||||
reviewsummary = extra_result_name
|
||||
extra_data.append(reviewsummary)
|
||||
|
||||
if reviewstatus != "done" and (reviewurl or reviewsummary):
|
||||
reviewstatus = "done"
|
||||
extra_data.append("done")
|
||||
|
||||
if extra_results_time:
|
||||
donetime = extra_results_time
|
||||
extra_data.append(donetime)
|
||||
|
||||
if extra_data:
|
||||
print "EXTRA DATA", row.docname, extra_data
|
||||
|
||||
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 reviewstatus 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
|
||||
|
||||
deadline = None
|
||||
request_time = None
|
||||
if event_collection:
|
||||
if "closed" in event_collection and not deadline:
|
||||
deadline = event_collection["closed"][5]
|
||||
if "assigned" in event_collection and not deadline:
|
||||
deadline = event_collection["assigned"][5]
|
||||
if "requested" in event_collection and not deadline:
|
||||
deadline = event_collection["requested"][5]
|
||||
|
||||
if "requested" in event_collection:
|
||||
request_time = parse_timestamp(event_collection["requested"][0])
|
||||
elif "assigned" in event_collection:
|
||||
request_time = parse_timestamp(event_collection["assigned"][0])
|
||||
elif "closed" in event_collection:
|
||||
request_time = parse_timestamp(event_collection["closed"][0])
|
||||
|
||||
if not deadline:
|
||||
deadline = doc_deadline
|
||||
|
||||
if not deadline:
|
||||
deadline = parse_timestamp(row.timeout)
|
||||
|
||||
if not deadline and "closed" in event_collection:
|
||||
deadline = parse_timestamp(event_collection["closed"][0])
|
||||
|
||||
if not deadline and "assigned" in event_collection:
|
||||
deadline = parse_timestamp(event_collection["assigned"][0])
|
||||
if deadline:
|
||||
deadline += datetime.timedelta(days=30)
|
||||
|
||||
if not deadline:
|
||||
print "SKIPPING WITH NO DEADLINE", row.reviewid, row.docname, event_collection
|
||||
continue
|
||||
|
||||
if not request_time and donetime:
|
||||
request_time = donetime
|
||||
|
||||
if not request_time:
|
||||
request_time = deadline
|
||||
|
||||
|
||||
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
|
||||
|
||||
fixed_docname = fix_docname(row.docname)
|
||||
|
||||
review_req = ReviewRequest.objects.filter(
|
||||
doc_id=fixed_docname,
|
||||
team=team,
|
||||
old_id=row.reviewid,
|
||||
).first()
|
||||
if not review_req:
|
||||
review_req = ReviewRequest(
|
||||
doc_id=fixed_docname,
|
||||
team=team,
|
||||
old_id=row.reviewid,
|
||||
)
|
||||
|
||||
|
||||
review_req.reviewer = known_personnel[row.reviewer] if row.reviewer else None
|
||||
review_req.result = results.get(reviewsummary.lower()) if reviewsummary else None
|
||||
review_req.state = states.get(reviewstatus) if reviewstatus else None
|
||||
review_req.type = type_name
|
||||
review_req.time = request_time
|
||||
review_req.reviewed_rev = reviewed_rev if review_req.state_id not in ("requested", "accepted") else ""
|
||||
review_req.deadline = deadline.date()
|
||||
review_req.requested_by = system_person
|
||||
|
||||
k = (review_req.doc_id, review_req.result_id, review_req.state_id, review_req.reviewer_id, review_req.reviewed_rev, reviewurl)
|
||||
if k in seen_review_requests:
|
||||
print "SKIPPING SEEN", k
|
||||
|
||||
# there's one special thing we're going to do here, and
|
||||
# that's checking whether we have a real completion event
|
||||
# for this skipped entry where the previous match didn't
|
||||
if "closed" in event_collection and review_req.state_id not in ("requested", "accepted"):
|
||||
review_req = seen_review_requests[k]
|
||||
if ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc, review_request=review_req).exists():
|
||||
continue
|
||||
|
||||
data = event_collection['closed']
|
||||
timestamp, who_did_it, reviewer, state, latest_iesg_status, latest_deadline = data
|
||||
|
||||
if who_did_it in known_personnel:
|
||||
by = known_personnel[who_did_it].person
|
||||
else:
|
||||
by = system_person
|
||||
|
||||
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
|
||||
|
||||
continue
|
||||
|
||||
review_req.save()
|
||||
|
||||
seen_review_requests[k] = review_req
|
||||
|
||||
completion_event = None
|
||||
|
||||
# review request events
|
||||
for key, data in event_collection.iteritems():
|
||||
timestamp, who_did_it, reviewer, state, latest_iesg_status, latest_deadline = 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 = request_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 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 = donetime or 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,
|
||||
xslugify(unicode(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 = 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 status == "done":
|
||||
review_req.state = states["unknown"]
|
||||
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 = donetime or 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")
|
103
ietf/review/mailarch.py
Normal file
103
ietf/review/mailarch.py
Normal file
|
@ -0,0 +1,103 @@
|
|||
# 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")
|
||||
|
||||
# parse a couple of things for the front end
|
||||
utcdate = None
|
||||
d = email.utils.parsedate_tz(msg["Date"])
|
||||
if d:
|
||||
utcdate = datetime.datetime.fromtimestamp(email.utils.mktime_tz(d))
|
||||
|
||||
res.append({
|
||||
"from": msg["From"],
|
||||
"splitfrom": email.utils.parseaddr(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"],
|
||||
"utcdate": (utcdate.date().isoformat(), utcdate.time().isoformat()) if utcdate else ("", ""),
|
||||
})
|
||||
|
||||
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
|
123
ietf/review/migrations/0001_initial.py
Normal file
123
ietf/review/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,123 @@
|
|||
# -*- 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={
|
||||
'verbose_name': 'next reviewer in team setting',
|
||||
'verbose_name_plural': 'next reviewer in team settings',
|
||||
},
|
||||
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={
|
||||
'verbose_name': 'review result used in team setting',
|
||||
'verbose_name_plural': 'review result used in team settings',
|
||||
},
|
||||
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(help_text=b"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.", null=True, blank=True)),
|
||||
('person', models.ForeignKey(to='person.Person')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'reviewer settings',
|
||||
},
|
||||
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={
|
||||
'verbose_name_plural': 'review wishes',
|
||||
},
|
||||
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={
|
||||
'verbose_name': 'review type used in team setting',
|
||||
'verbose_name_plural': 'review type used in team settings',
|
||||
},
|
||||
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,),
|
||||
),
|
||||
]
|
21
ietf/review/migrations/0002_auto_20161017_1218.py
Normal file
21
ietf/review/migrations/0002_auto_20161017_1218.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import datetime
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('review', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='unavailableperiod',
|
||||
name='start_date',
|
||||
field=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.", null=True),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
20
ietf/review/migrations/0003_auto_20161018_0254.py
Normal file
20
ietf/review/migrations/0003_auto_20161018_0254.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('review', '0002_auto_20161017_1218'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='reviewersettings',
|
||||
name='min_interval',
|
||||
field=models.IntegerField(blank=True, null=True, 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')]),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
29
ietf/review/migrations/0004_reviewsecretarysettings.py
Normal file
29
ietf/review/migrations/0004_reviewsecretarysettings.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('person', '0014_auto_20160613_0751'),
|
||||
('group', '0009_auto_20150930_0758'),
|
||||
('review', '0003_auto_20161018_0254'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReviewSecretarySettings',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('remind_days_before_deadline', models.IntegerField(help_text=b"To get an email reminder in case an assigned review gets near its deadline, enter the number of days before a review deadline you want to receive it. Clear the field if you don't want a reminder.", null=True, blank=True)),
|
||||
('person', models.ForeignKey(to='person.Person')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'review secretary settings',
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
]
|
0
ietf/review/migrations/__init__.py
Normal file
0
ietf/review/migrations/__init__.py
Normal file
155
ietf/review/models.py
Normal file
155
ietf/review/models.py
Normal file
|
@ -0,0 +1,155 @@
|
|||
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 a reviewer in a 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(verbose_name="Can review at most", choices=INTERVALS, blank=True, null=True)
|
||||
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 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 ReviewSecretarySettings(models.Model):
|
||||
"""Keeps track of admin data associated with a secretary in a team."""
|
||||
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
|
||||
person = models.ForeignKey(Person)
|
||||
remind_days_before_deadline = models.IntegerField(null=True, blank=True, help_text="To get an email reminder in case a reviewer forgets to do an assigned review, enter the number of days before 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 = "review secretary 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, null=True, 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 is None or 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 or "", 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)
|
||||
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 team setting"
|
||||
verbose_name_plural = "review result used in 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 team setting"
|
||||
verbose_name_plural = "review type used in 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)
|
187
ietf/review/resources.py
Normal file
187
ietf/review/resources.py
Normal file
|
@ -0,0 +1,187 @@
|
|||
# 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,
|
||||
ReviewSecretarySettings)
|
||||
|
||||
|
||||
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())
|
||||
|
||||
|
||||
|
||||
from ietf.person.resources import PersonResource
|
||||
from ietf.group.resources import GroupResource
|
||||
class ReviewSecretarySettingsResource(ModelResource):
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
person = ToOneField(PersonResource, 'person')
|
||||
class Meta:
|
||||
queryset = ReviewSecretarySettings.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'reviewsecretarysettings'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"remind_days_before_deadline": ALL,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"person": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(ReviewSecretarySettingsResource())
|
||||
|
910
ietf/review/utils.py
Normal file
910
ietf/review/utils.py
Normal file
|
@ -0,0 +1,910 @@
|
|||
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, TelechatDocEvent,
|
||||
DocumentAuthor, DocAlias)
|
||||
from ietf.iesg.models import TelechatDate
|
||||
from ietf.person.models import Person
|
||||
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,
|
||||
ReviewSecretarySettings)
|
||||
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)
|
||||
or Role.objects.filter(person__user=user, name="secr", group__in=active_review_teams).exists())
|
||||
|
||||
def can_manage_review_requests_for_team(user, team, allow_personnel_outside_team=True):
|
||||
if not user.is_authenticated():
|
||||
return False
|
||||
|
||||
return (Role.objects.filter(name="secr", person__user=user, group=team).exists()
|
||||
or (allow_personnel_outside_team and has_role(user, "Secretariat")))
|
||||
|
||||
def can_access_review_stats_for_team(user, team):
|
||||
if not user.is_authenticated():
|
||||
return False
|
||||
|
||||
return (Role.objects.filter(name__in=("secr", "reviewer"), person__user=user, group=team).exists()
|
||||
or has_role(user, ["Secretariat", "Area Director"]))
|
||||
|
||||
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 augment_review_requests_with_events(review_reqs):
|
||||
req_dict = { r.pk: r for r in review_reqs }
|
||||
for e in ReviewRequestDocEvent.objects.filter(review_request__in=review_reqs, type__in=["assigned_review_request", "closed_review_request"]).order_by("time"):
|
||||
setattr(req_dict[e.review_request_id], e.type + "_event", e)
|
||||
|
||||
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),
|
||||
Q(start_date__lte=today) | Q(start_date=None),
|
||||
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 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 (in case it is necessary to wait, otherwise reviewer
|
||||
is absent in result)."""
|
||||
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"))
|
||||
|
||||
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)
|
||||
if min_interval is None:
|
||||
continue
|
||||
|
||||
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", "reviewed_rev", "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", "reviewed_rev", "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, reviewed_rev, 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, reviewed_rev, result, team, reviewer,
|
||||
late_days, request_to_assignment_days, assignment_to_closure_days,
|
||||
request_to_closure_days)
|
||||
|
||||
yield d
|
||||
|
||||
def aggregate_raw_review_request_stats(review_request_data, count=None):
|
||||
"""Take a sequence of review request data from
|
||||
extract_review_request_data and aggregate them."""
|
||||
|
||||
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, reviewed_rev, 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
|
||||
|
||||
return state_dict, late_state_dict, result_dict, assignment_to_closure_days_list, assignment_to_closure_days_count
|
||||
|
||||
def compute_review_request_stats(raw_aggregation):
|
||||
"""Compute statistics from aggregated review request data."""
|
||||
state_dict, late_state_dict, result_dict, assignment_to_closure_days_list, assignment_to_closure_days_count = raw_aggregation
|
||||
|
||||
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 sum_raw_review_request_aggregations(raw_aggregations):
|
||||
"""Collapse a sequence of aggregations into one aggregation."""
|
||||
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 raw_aggr in raw_aggregations:
|
||||
i_state_dict, i_late_state_dict, i_result_dict, i_assignment_to_closure_days_list, i_assignment_to_closure_days_count = raw_aggr
|
||||
for s, v in i_state_dict.iteritems():
|
||||
state_dict[s] += v
|
||||
for s, v in i_late_state_dict.iteritems():
|
||||
late_state_dict[s] += v
|
||||
for r, v in i_result_dict.iteritems():
|
||||
result_dict[r] += v
|
||||
|
||||
assignment_to_closure_days_list.extend(i_assignment_to_closure_days_list)
|
||||
assignment_to_closure_days_count += i_assignment_to_closure_days_count
|
||||
|
||||
return state_dict, late_state_dict, result_dict, assignment_to_closure_days_list, assignment_to_closure_days_count
|
||||
|
||||
def latest_review_requests_for_reviewers(team, days_back=365):
|
||||
"""Collect and return stats for reviewers on latest requests, in
|
||||
extract_review_request_data format."""
|
||||
|
||||
extracted_data = extract_review_request_data(
|
||||
teams=[team],
|
||||
time_from=datetime.date.today() - datetime.timedelta(days=days_back),
|
||||
ordering=["reviewer"],
|
||||
)
|
||||
|
||||
req_data_for_reviewers = {
|
||||
reviewer: list(reversed(list(req_data_items)))
|
||||
for reviewer, req_data_items in itertools.groupby(extracted_data, key=lambda data: data.reviewer)
|
||||
}
|
||||
|
||||
return req_data_for_reviewers
|
||||
|
||||
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()
|
||||
|
||||
reviewable_docs_qs = Document.objects.filter(type="draft").exclude(stream="ise")
|
||||
|
||||
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 = reviewable_docs_qs.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 = reviewable_docs_qs.filter(
|
||||
docevent__telechatdocevent__telechat_date__in=telechat_dates
|
||||
)
|
||||
|
||||
# we need to check the latest telechat event for each document
|
||||
# scheduled for the telechat, as the appearance might have been
|
||||
# cancelled/moved
|
||||
telechat_events = TelechatDocEvent.objects.filter(
|
||||
# turn into list so we don't get a complex and slow join sent down to the DB
|
||||
doc__in=list(telechat_docs.values_list("pk", flat=True)),
|
||||
).values_list(
|
||||
"doc", "pk", "time", "telechat_date"
|
||||
).order_by("doc", "-time", "-id").distinct()
|
||||
|
||||
for doc_pk, events in itertools.groupby(telechat_events, lambda t: t[0]):
|
||||
_, _, event_time, event_telechat_date = list(events)[0]
|
||||
|
||||
deadline = None
|
||||
if event_telechat_date in telechat_dates:
|
||||
deadline = event_telechat_date - telechat_deadline_delta
|
||||
|
||||
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(), team=team):
|
||||
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__person__in=possible_person_ids,
|
||||
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__person", 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
|
||||
connections[doc.ad_id] = "is associated Area Director"
|
||||
for r in Role.objects.filter(group=doc.group_id, person__in=possible_person_ids).select_related("name"):
|
||||
connections[r.person_id] = "is group {}".format(r.name)
|
||||
if doc.shepherd:
|
||||
connections[doc.shepherd.person_id] = "is shepherd of document"
|
||||
for author in DocumentAuthor.objects.filter(document=doc, author__person__in=possible_person_ids).values_list("author__person", flat=True):
|
||||
connections[author] = "is author of document"
|
||||
|
||||
# unavailable periods
|
||||
unavailable_periods = current_unavailable_periods_for_reviewers(team)
|
||||
|
||||
# reviewers statistics
|
||||
req_data_for_reviewers = latest_review_requests_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.person_id 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))
|
||||
|
||||
# misc
|
||||
add_boolean_score(+1, e.person_id 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.person_id in connections, connections.get(e.person_id)) # 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")
|
||||
|
||||
# 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"))
|
||||
|
||||
# skip next
|
||||
scores.append(-settings.skip_next)
|
||||
if settings.skip_next > 0:
|
||||
explanations.append("skip next {}".format(settings.skip_next))
|
||||
|
||||
# index
|
||||
index = rotation_index.get(e.person_id, 0)
|
||||
scores.append(-index)
|
||||
explanations.append("#{}".format(index + 1))
|
||||
|
||||
# stats
|
||||
stats = []
|
||||
req_data = req_data_for_reviewers.get(e.person_id, [])
|
||||
|
||||
currently_open = sum(1 for d in req_data if d.state in ["requested", "accepted"])
|
||||
if currently_open > 0:
|
||||
stats.append("currently {} open".format(currently_open))
|
||||
could_have_completed = [d for d in req_data if d.state in ["part-completed", "completed", "no-response"]]
|
||||
if could_have_completed:
|
||||
no_response = sum(1 for d in could_have_completed if d.state == "no-response")
|
||||
stats.append("no response {}/{}".format(no_response, len(could_have_completed)))
|
||||
|
||||
if stats:
|
||||
explanations.append(", ".join(stats))
|
||||
|
||||
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())
|
||||
|
||||
import ietf.ietfauth.views
|
||||
overview_url = urlreverse(ietf.ietfauth.views.review_overview)
|
||||
import ietf.doc.views_review
|
||||
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,
|
||||
})
|
||||
|
||||
def review_requests_needing_secretary_reminder(remind_date):
|
||||
reqs_qs = ReviewRequest.objects.filter(
|
||||
state__in=("requested", "accepted"),
|
||||
team__role__person__reviewsecretarysettings__remind_days_before_deadline__isnull=False,
|
||||
team__role__person__reviewsecretarysettings__team=F("team"),
|
||||
).exclude(
|
||||
reviewer=None
|
||||
).values_list("pk", "deadline", "team__role", "team__role__person__reviewsecretarysettings__remind_days_before_deadline").distinct()
|
||||
|
||||
req_pks = {}
|
||||
for r_pk, deadline, secretary_role_pk, remind_days in reqs_qs:
|
||||
if (deadline - remind_date).days == remind_days:
|
||||
req_pks[r_pk] = secretary_role_pk
|
||||
|
||||
review_reqs = { r.pk: r for r in ReviewRequest.objects.filter(pk__in=req_pks.keys()).select_related("reviewer", "reviewer__person", "state", "team") }
|
||||
secretary_roles = { r.pk: r for r in Role.objects.filter(pk__in=req_pks.values()).select_related("email", "person") }
|
||||
|
||||
return [ (review_reqs[req_pk], secretary_roles[secretary_role_pk]) for req_pk, secretary_role_pk in req_pks.iteritems() ]
|
||||
|
||||
def email_secretary_reminder(review_request, secretary_role):
|
||||
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())
|
||||
|
||||
import ietf.group.views_review
|
||||
settings_url = urlreverse(ietf.group.views_review.change_review_secretary_settings, kwargs={ "acronym": team.acronym, "group_type": team.type_id })
|
||||
import ietf.doc.views_review
|
||||
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 = ReviewSecretarySettings.objects.filter(person=secretary_role.person_id, 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/secretary_reminder.txt", {
|
||||
"review_request_url": "https://{}{}".format(domain, request_url),
|
||||
"settings_url": "https://{}{}".format(domain, settings_url),
|
||||
"review_request": review_request,
|
||||
"deadline_days": deadline_days,
|
||||
"remind_days": remind_days,
|
||||
})
|
||||
|
|
@ -305,6 +305,7 @@ INSTALLED_APPS = (
|
|||
'ietf.person',
|
||||
'ietf.redirects',
|
||||
'ietf.release',
|
||||
'ietf.review',
|
||||
'ietf.submit',
|
||||
'ietf.sync',
|
||||
'ietf.utils',
|
||||
|
@ -386,10 +387,17 @@ TEST_CODE_COVERAGE_EXCLUDE = [
|
|||
"*/admin.py",
|
||||
"*/migrations/*",
|
||||
"*/management/commands/*",
|
||||
"idindex/generate_all_id2_txt.py",
|
||||
"idindex/generate_all_id_txt.py",
|
||||
"idindex/generate_id_abstracts_txt.py",
|
||||
"idindex/generate_id_index_txt.py",
|
||||
"name/generate_fixtures.py",
|
||||
"review/import_from_review_tool.py",
|
||||
"ietf/settings*",
|
||||
"ietf/utils/test_runner.py",
|
||||
"ietf/checks.py",
|
||||
"ietf/utils/templatetags/debug_filters.py",
|
||||
"ietf/review/import_from_review_tool.py",
|
||||
]
|
||||
|
||||
# These are filename globs. They are used by test_parse_templates() and
|
||||
|
@ -453,6 +461,7 @@ MEETING_RECORDINGS_DIR = '/a/www/audio'
|
|||
|
||||
# Mailing list info URL for lists hosted on the IETF servers
|
||||
MAILING_LIST_INFO_URL = "https://www.ietf.org/mailman/listinfo/%(list_addr)s"
|
||||
MAILING_LIST_ARCHIVE_URL = "https://mailarchive.ietf.org"
|
||||
|
||||
# Liaison Statement Tool settings (one is used in DOC_HREFS below)
|
||||
LIAISON_UNIVERSAL_FROM = 'Liaison Statement Management Tool <lsmt@' + IETF_DOMAIN + '>'
|
||||
|
|
|
@ -475,6 +475,125 @@ 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;
|
||||
}
|
||||
|
||||
form.complete-review .mail-archive-search-result .from {
|
||||
width: 9em;
|
||||
padding-left: 0.4em;
|
||||
}
|
||||
|
||||
form.complete-review .mail-archive-search-result .date {
|
||||
width: 6em;
|
||||
padding-left: 0.4em;
|
||||
}
|
||||
|
||||
.closed-review-filter {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
form.review-requests .reviewer-controls, form.review-requests .close-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
form.review-requests .assign-action, form.review-requests .close-action {
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
form.review-requests .request-metadata {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
form.review-requests .abstract {
|
||||
font-size: 95%;
|
||||
}
|
||||
|
||||
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 tr.totals {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.review-stats-teams {
|
||||
-moz-column-width: 18em;
|
||||
-webkit-column-width: 18em;
|
||||
column-width: 18em;
|
||||
}
|
||||
|
||||
.review-stats-teams a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* === Photo pages ========================================================== */
|
||||
|
||||
.photo-name {
|
||||
|
|
136
ietf/static/ietf/js/complete-review.js
Normal file
136
ietf/static/ietf/js/complete-review.js
Normal file
|
@ -0,0 +1,136 @@
|
|||
$(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.utcdate[0]);
|
||||
row.find(".from").text(msg.splitfrom[0]);
|
||||
row.data("url", msg.url);
|
||||
row.data("content", msg.content);
|
||||
row.data("date", msg.utcdate[0]);
|
||||
row.data("time", msg.utcdate[1]);
|
||||
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")).prop("scrollTop", 0);
|
||||
form.find("[name=completion_date]").val(row.data("date"));
|
||||
form.find("[name=completion_time]").val(row.data("time"));
|
||||
});
|
||||
|
||||
|
||||
// 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");
|
||||
});
|
|
@ -191,7 +191,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
|
||||
|
|
101
ietf/static/ietf/js/manage-review-requests.js
Normal file
101
ietf/static/ietf/js/manage-review-requests.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
$(document).ready(function () {
|
||||
var form = $("form.review-requests");
|
||||
var saveButtons = form.find("[name=action][value^=\"save\"]");
|
||||
|
||||
function updateSaveButtons() {
|
||||
saveButtons.prop("disabled", form.find("[name$=\"-action\"][value][value!=\"\"]").length == 0);
|
||||
}
|
||||
|
||||
function setControlDisplay(row) {
|
||||
var action = row.find("[name$=\"-action\"]").val();
|
||||
if (action == "assign") {
|
||||
row.find(".reviewer-controls").show();
|
||||
row.find(".close-controls").hide();
|
||||
row.find(".assign-action,.close-action").hide();
|
||||
}
|
||||
else if (action == "close") {
|
||||
row.find(".reviewer-controls").hide();
|
||||
row.find(".close-controls").show();
|
||||
row.find(".assign-action,.close-action").hide();
|
||||
}
|
||||
else {
|
||||
row.find(".reviewer-controls,.close-controls").hide();
|
||||
row.find(".assign-action,.close-action").show();
|
||||
}
|
||||
|
||||
updateSaveButtons();
|
||||
}
|
||||
|
||||
form.find(".assign-action button").on("click", function () {
|
||||
var row = $(this).closest(".review-request");
|
||||
|
||||
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(".review-request").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(".review-request");
|
||||
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(".review-request");
|
||||
row.find("[name$=\"-action\"]").val("close");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
form.find(".close-controls .undo").on("click", function () {
|
||||
var row = $(this).closest(".review-request");
|
||||
row.find("[name$=\"-action\"]").val("");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
form.find("[name$=\"-action\"]").each(function () {
|
||||
var v = $(this).val();
|
||||
if (!v)
|
||||
return;
|
||||
|
||||
var row = $(this).closest(".review-request");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
updateSaveButtons();
|
||||
});
|
8
ietf/static/ietf/js/review-stats.js
Normal file
8
ietf/static/ietf/js/review-stats.js
Normal file
|
@ -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);
|
||||
}
|
||||
});
|
1
ietf/stats/__init__.py
Normal file
1
ietf/stats/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
|
58
ietf/stats/tests.py
Normal file
58
ietf/stats/tests.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
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)
|
||||
|
||||
completion_url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion" })
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertTrue(completion_url in r["Location"])
|
||||
|
||||
self.client.logout()
|
||||
self.client.login(username="plain", password="plain+password")
|
||||
r = self.client.get(completion_url)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
# check tabular
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
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")'))
|
9
ietf/stats/urls.py
Normal file
9
ietf/stats/urls.py
Normal file
|
@ -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/(?:(?P<stats_type>completion|results|states|time)/)?(?:%(acronym)s/)?$" % settings.URL_REGEXPS, ietf.stats.views.review_stats),
|
||||
)
|
353
ietf/stats/views.py
Normal file
353
ietf/stats/views.py
Normal file
|
@ -0,0 +1,353 @@
|
|||
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, HttpResponseForbidden
|
||||
|
||||
import dateutil.relativedelta
|
||||
|
||||
from ietf.review.utils import (extract_review_request_data,
|
||||
aggregate_raw_review_request_stats,
|
||||
ReviewRequestData,
|
||||
compute_review_request_stats,
|
||||
sum_raw_review_request_aggregations)
|
||||
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_choice(get_parameter, possible_choices, multiple=False):
|
||||
values = request.GET.getlist(get_parameter)
|
||||
found = [t[0] for t in possible_choices if t[0] in values]
|
||||
|
||||
if multiple:
|
||||
return found
|
||||
else:
|
||||
if found:
|
||||
return found[0]
|
||||
else:
|
||||
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_choice("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)
|
||||
|
||||
if not secr_access and not reviewer_only_access:
|
||||
return HttpResponseForbidden("You do not have the necessary permissions to view this page")
|
||||
|
||||
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 filter and aggregate the data
|
||||
possible_teams = possible_completion_types = possible_results = possible_states = None
|
||||
selected_teams = selected_completion_type = selected_result = selected_state = None
|
||||
|
||||
if stats_type == "time":
|
||||
possible_teams = [(t.acronym, t.acronym) for t in teams]
|
||||
selected_teams = get_choice("team", possible_teams, multiple=True)
|
||||
|
||||
def add_if_exists_else_subtract(element, l):
|
||||
if element in l:
|
||||
return [x for x in l if x != element]
|
||||
else:
|
||||
return l + [element]
|
||||
|
||||
possible_teams = [(slug, label, build_review_stats_url(get_overrides={
|
||||
"team": add_if_exists_else_subtract(slug, selected_teams)
|
||||
})) for slug, label in possible_teams]
|
||||
query_teams = [t for t in query_teams if t.acronym in selected_teams]
|
||||
|
||||
extracted_data = extract_review_request_data(query_teams, query_reviewers, from_time, to_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()) # weekly
|
||||
d -= datetime.timedelta(days=d.day) # monthly
|
||||
return d
|
||||
|
||||
found_results = set()
|
||||
found_states = set()
|
||||
aggrs = []
|
||||
for d, request_data_items in itertools.groupby(extracted_data, key=time_key_fn):
|
||||
raw_aggr = aggregate_raw_review_request_stats(request_data_items, count=count)
|
||||
aggr = compute_review_request_stats(raw_aggr)
|
||||
|
||||
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_choice("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_choice("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_choice("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
|
||||
extracted_data = extract_review_request_data(query_teams, query_reviewers, from_time, to_time, ordering=[level])
|
||||
|
||||
data = []
|
||||
|
||||
found_results = set()
|
||||
found_states = set()
|
||||
raw_aggrs = []
|
||||
for group_pk, request_data_items in itertools.groupby(extracted_data, key=lambda t: t[group_by_index]):
|
||||
raw_aggr = aggregate_raw_review_request_stats(request_data_items, count=count)
|
||||
raw_aggrs.append(raw_aggr)
|
||||
|
||||
aggr = compute_review_request_stats(raw_aggr)
|
||||
|
||||
# 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)
|
||||
|
||||
# add totals row
|
||||
if len(raw_aggrs) > 1:
|
||||
totals = compute_review_request_stats(sum_raw_review_request_aggregations(raw_aggrs))
|
||||
totals["obj"] = "Totals"
|
||||
data.append(totals)
|
||||
|
||||
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_teams": selected_teams,
|
||||
"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,
|
||||
})
|
|
@ -1244,7 +1244,7 @@ ZSBvZiBsaW5lcyAtIGJ1dCBpdCBjb3VsZCBiZSBhIGRyYWZ0Cg==
|
|||
self.assertEqual(len(q(selector)), 0)
|
||||
|
||||
# Find the link for our submission in those awaiting drafts
|
||||
submission_url = self.get_href(q, "#waiting-for-draft a#aw{}:contains({})".
|
||||
submission_url = self.get_href(q, "#waiting-for-draft a#aw{}:contains('{}')".
|
||||
format(submission.pk, submission_name_fragment))
|
||||
|
||||
# Follow the link to the status page for this submission
|
||||
|
|
|
@ -106,6 +106,7 @@
|
|||
<li><a href="/ipr/">IPR disclosures</a></li>
|
||||
<li><a href="/liaison/">Liaison statements</a></li>
|
||||
<li><a href="/iesg/agenda/">IESG agenda</a></li>
|
||||
<li><a href="{% url "ietf.stats.views.stats_index" %}">Statistics</a></li>
|
||||
<li><a href="/group/edu/materials/">Tutorials</a></li>
|
||||
{% if flavor == "top" %}<li class="divider hidden-xs"></li>{% endif %}
|
||||
<li><a href="https://tools.ietf.org/tools/ietfdb/newticket"><span class="fa fa-bug"></span> Report a bug</a></li>
|
||||
|
|
|
@ -25,6 +25,10 @@
|
|||
<li><a href="{% url "ietf.ietfauth.views.create_account" %}">{% if request.user.is_authenticated %}Manage account{% else %}New account{% endif %}</a></li>
|
||||
<li><a href="{%url "ietf.cookies.views.preferences" %}" rel="nofollow">Preferences</a></li>
|
||||
|
||||
{% if user|has_role:"Reviewer" %}
|
||||
<li><a href="{% url "ietf.ietfauth.views.review_overview" %}">My reviews</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if user|has_role:"Area Director" %}
|
||||
{% if flavor == "top" %}<li class="divider hidden-xs"></li>{% endif %}
|
||||
<li {%if flavor == "top" %}class="dropdown-header hidden-xs"{% else %}class="nav-header"{% endif %}>AD dashboard</li>
|
||||
|
|
|
@ -192,6 +192,33 @@
|
|||
</td>
|
||||
</tr>
|
||||
|
||||
{% if review_requests or can_request_review %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Reviews</th>
|
||||
<td class="edit"></td>
|
||||
<td>
|
||||
{% for review_request in review_requests %}
|
||||
{% include "doc/review_request_summary.html" with current_doc_name=doc.name current_rev=doc.rev %}
|
||||
{% endfor %}
|
||||
|
||||
{% if no_review_from_teams %}
|
||||
{% for team in no_review_from_teams %}
|
||||
{{ team.acronym.upper }}{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
will not review this version
|
||||
{% endif %}
|
||||
|
||||
{% if can_request_review %}
|
||||
<div>
|
||||
<a class="btn btn-default btn-xs" href="{% url "ietf.doc.views_review.request_review" doc.name %}"><span class="fa fa-check-circle-o"></span> Request review</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if conflict_reviews %}
|
||||
<tr>
|
||||
<th></th>
|
||||
|
|
118
ietf/templates/doc/document_review.html
Normal file
118
ietf/templates/doc/document_review.html
Normal file
|
@ -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" %}
|
||||
|
||||
<table class="table table-condensed">
|
||||
<thead id="message-row">
|
||||
<tr>
|
||||
{% if doc.rev != latest_rev %}
|
||||
<th colspan="4" class="alert-warning">The information below is for an old version of the document</th>
|
||||
{% else %}
|
||||
<th colspan="4"></th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="meta">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Team</th>
|
||||
<td class="edit"></td>
|
||||
<td>
|
||||
{{ doc.group.name }}
|
||||
<a href="{{ doc.group.about_url }}">({{ doc.group.acronym }})</a>
|
||||
|
||||
{% if snapshot %}
|
||||
<span class="label label-warning">Snapshot</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Title</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ doc.title }}</td>
|
||||
</tr>
|
||||
|
||||
{% if doc.get_state_slug != "active" %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>State</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ doc.get_state.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Request</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ review_req.type.name }} - <a href="{% url "ietf.doc.views_review.review_request" review_req.doc.name review_req.pk %}">requested {{ review_req.time|date:"Y-m-d" }}</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Reviewer</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ review_req.reviewer.person }}</td>
|
||||
</tr>
|
||||
|
||||
{% if review_req.result %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Review result</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ review_req.result.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if doc.external_url %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Posted at</th>
|
||||
<td class="edit"></td>
|
||||
<td><a href="{{ doc.external_url }}">{{ doc.external_url }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Last updated</th>
|
||||
<td class="edit"></td>
|
||||
<td>{{ doc.time|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
|
||||
{% if other_reviews %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Other reviews</th>
|
||||
<td class="edit"></td>
|
||||
<td>
|
||||
{% for review_request in other_reviews %}
|
||||
{% include "doc/review_request_summary.html" with current_doc_name=review_req.doc_id current_rev=review_req.reviewed_rev %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>{{ doc.type.name }}<br><small>{{ doc.name }}</small></h2>
|
||||
|
||||
{% if doc.rev and content != None %}
|
||||
{{ content|fill:"80"|safe|linebreaksbr|keep_spacing|sanitize_html|safe }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -14,7 +14,7 @@
|
|||
<p>Resurrect {{ doc }}?</p>
|
||||
|
||||
<p>
|
||||
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 %}.
|
||||
</p>
|
||||
|
||||
<form method="post">
|
||||
|
|
22
ietf/templates/doc/review/assign_reviewer.html
Normal file
22
ietf/templates/doc/review/assign_reviewer.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Assign reviewer for {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Assign reviewer<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary" name="action" value="assign">Assign reviewer</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
24
ietf/templates/doc/review/close_request.html
Normal file
24
ietf/templates/doc/review/close_request.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Close review request for {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Close review request<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<p>Do you want to close the review request?</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Close request</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
92
ietf/templates/doc/review/complete_review.html
Normal file
92
ietf/templates/doc/review/complete_review.html
Normal file
|
@ -0,0 +1,92 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}{% if revising_review %}Revise{% else %}Complete{% endif %} review of {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>{% if revising_review %}Revise{% else %}Complete{% endif %} review<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
{% if not revising_review %}
|
||||
<p>The review findings should be made available here and the review
|
||||
posted to the mailing list. If you enter the findings below, the
|
||||
system will post the review for you. If you already have posted
|
||||
the review, you can try to let the system find the link to the
|
||||
archive and retrieve the email body.</p>
|
||||
{% else %}
|
||||
<p>You can revise this review by entering the results below.</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="complete-review form-horizontal" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{% bootstrap_form form layout="horizontal" %}
|
||||
|
||||
{% buttons %}
|
||||
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">{% if revising_review %}Revise{% else %}Complete{% endif %} review</button>
|
||||
{% endbuttons %}
|
||||
|
||||
<div class="template" style="display:none">
|
||||
{% if mail_archive_query_urls %}
|
||||
<div class="mail-archive-search form-group">
|
||||
<div class="col-md-offset-2 col-md-10">
|
||||
<p class="form-inline">
|
||||
Search mail archive subjects for:
|
||||
<input class="query-input form-control input-sm" value="{{ mail_archive_query_urls.query }}">
|
||||
<button type="button" class="search btn btn-default btn-sm">Search</button>
|
||||
</p>
|
||||
|
||||
<div class="retrieving hidden">
|
||||
<span class="fa fa-spin fa-circle-o-notch"></span>
|
||||
Searching...
|
||||
</div>
|
||||
|
||||
<div class="results hidden">
|
||||
<p>Select one of the following messages to automatically pre-fill link and content:</p>
|
||||
<div class="list-group">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error alert alert-warning hidden">
|
||||
<p>
|
||||
<span class="content"></span>
|
||||
<span class="hidden try-yourself">(searched for <a class="query-url" href="">"<span class="query"></span>"</a>, corresponding <a class="query-data-url" href="">export</a>).</span>
|
||||
You have to fill in link and content yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mail-archive-search">
|
||||
<small class="text-muted">Mailing list does not have a recognized ietf.org archive. Auto-searching disabled.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% spaceless %}
|
||||
<div class="template" style="display:none">
|
||||
<button type="button" class="mail-archive-search-result list-group-item">
|
||||
<small class="date pull-right"></small>
|
||||
<small class="from pull-right"></small>
|
||||
<span class="subject"></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
<script>
|
||||
var searchMailArchiveUrl = "{% url "ietf.doc.views_review.search_mail_archive" name=review_req.doc.name request_id=review_req.pk %}";
|
||||
</script>
|
||||
<script src="{% static 'ietf/js/complete-review.js' %}"></script>
|
||||
{% endblock %}
|
24
ietf/templates/doc/review/reject_reviewer_assignment.html
Normal file
24
ietf/templates/doc/review/reject_reviewer_assignment.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Reject review assignment for {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Reject review assignment<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<p>{{ review_req.reviewer.person }} is currently assigned to do the review. Do you want to reject this assignment?</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary" name="action" value="reject">Reject assignment</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
50
ietf/templates/doc/review/request_review.html
Normal file
50
ietf/templates/doc/review/request_review.html
Normal file
|
@ -0,0 +1,50 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Request review of {{ doc.name }} {% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Request review<br><small>{{ doc.name }}</small></h1>
|
||||
|
||||
<p>Submit a request to have the document reviewed.</p>
|
||||
|
||||
<p>
|
||||
<div>Current revision of the document: <strong>{{ doc.rev }}</strong>.</div>
|
||||
|
||||
{% if lc_ends %}
|
||||
<div>Last Call ends: <strong>{{ lc_ends|date:"Y-m-d" }}</strong> (in {{ lc_ends_days }} day{{ lc_ends_days|pluralize }}).</div>
|
||||
{% endif %}
|
||||
|
||||
{% if scheduled_for_telechat %}
|
||||
<div>Scheduled for telechat: <strong>{{ scheduled_for_telechat|date:"Y-m-d" }}</strong> (in {{ scheduled_for_telechat_days }} day{{ scheduled_for_telechat_days|pluralize }}).</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<form class="form-horizontal" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_field form.requested_by layout="horizontal" %}
|
||||
{% bootstrap_field form.type layout="horizontal" %}
|
||||
{% bootstrap_field form.team layout="horizontal" %}
|
||||
{% bootstrap_field form.deadline layout="horizontal" %}
|
||||
{% bootstrap_field form.requested_rev layout="horizontal" %}
|
||||
|
||||
{% buttons %}
|
||||
<button type="submit" class="btn btn-primary">Request review</button>
|
||||
<a class="btn btn-default pull-right" href="{% url "doc_view" name=doc.canonical_name %}">Back</a>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
<script src="{% static 'select2/select2.min.js' %}"></script>
|
||||
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
|
||||
{% endblock %}
|
167
ietf/templates/doc/review/review_request.html
Normal file
167
ietf/templates/doc/review/review_request.html
Normal file
|
@ -0,0 +1,167 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Review request for {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Review request<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<table class="table table-condensed">
|
||||
<tbody class="meta">
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Review of</th>
|
||||
<td>
|
||||
{% if review_req.requested_rev %}
|
||||
<a href="{% url "doc_view" name=review_req.doc.name rev=review_req.requested_rev %}">{{ review_req.doc.name }}-{{ review_req.requested_rev }}</a>
|
||||
{% else %}
|
||||
<a href="{% url "doc_view" name=review_req.doc.name %}">{{ review_req.doc.name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Requested rev.</th>
|
||||
<td>
|
||||
{% if review_req.requested_rev %}
|
||||
{{ review_req.requested_rev }}
|
||||
{% else %}
|
||||
no specific revision
|
||||
{% endif %}
|
||||
{% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Type</th>
|
||||
<td>{{ review_req.type.name }} Review</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Team</th>
|
||||
<td><a href="{% url "ietf.group.views_review.review_requests" group_type=review_req.team.type_id acronym=review_req.team.acronym %}">{{ review_req.team.acronym|upper }}</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Deadline</th>
|
||||
<td>{{ review_req.deadline|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Requested</th>
|
||||
<td>{{ review_req.time|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
|
||||
{% if review_req.requested_by.name != "(System)" %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Requested by</th>
|
||||
<td>{{ review_req.requested_by }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
|
||||
<tbody class="meta">
|
||||
<tr>
|
||||
<th>Review</th>
|
||||
<th>State</th>
|
||||
<td>{{ review_req.state.name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Reviewer</th>
|
||||
<td>
|
||||
{% if review_req.reviewer %}
|
||||
{{ review_req.reviewer.person }}
|
||||
{% else %}
|
||||
None assigned yet
|
||||
{% endif %}
|
||||
|
||||
{% if can_assign_reviewer %}
|
||||
<a class="btn btn-default btn-xs" href="{% url "ietf.doc.views_review.assign_reviewer" name=doc.name request_id=review_req.pk %}"><span class="fa fa-user"></span> {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer</a>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req.reviewer %}
|
||||
{% if can_reject_reviewer_assignment or can_accept_reviewer_assignment %}
|
||||
<div class="reviewer-assignment-not-accepted">
|
||||
{% if review_req.state_id == "requested"%}
|
||||
<em>Assignment not accepted yet:</em>
|
||||
{% else %}
|
||||
<em>Assignment accepted:</em>
|
||||
{% endif %}
|
||||
|
||||
{% if can_reject_reviewer_assignment %}
|
||||
<a class="btn btn-danger btn-xs" href="{% url "ietf.doc.views_review.reject_reviewer_assignment" name=doc.name request_id=review_req.pk %}"><span class="fa fa-ban"></span> Reject</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_accept_reviewer_assignment %}
|
||||
<form style="display:inline" method="post" action="{% url "ietf.doc.views_review.review_request" name=doc.name request_id=review_req.pk %}">{% csrf_token %}<button class="btn btn-success btn-xs" type="submit" name="action" value="accept"><span class="fa fa-check"></span> Accept</button></form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Review</th>
|
||||
<td>
|
||||
{% if review_req.review %}
|
||||
<a href="{{ review_req.review.get_absolute_url }}">{{ review_req.review.name }}</a>
|
||||
{% elif review_req.state_id == "requested" or review_req.state_id == "accepted" %}
|
||||
Not completed yet
|
||||
{% else %}
|
||||
Not available
|
||||
{% endif %}
|
||||
|
||||
{% if can_complete_review %}
|
||||
<a class="btn btn-primary btn-xs" href="{% url "ietf.doc.views_review.complete_review" name=doc.name request_id=review_req.pk %}"><span class="fa fa-pencil-square-o"></span> {% if review_req.state_id == "requested" or review_req.state_id == "accepted" %}Complete review{% else %}Correct review{% endif %}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% if review_req.review and review_req.review.external_url %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Posted at</th>
|
||||
<td>
|
||||
<a href="{{ review_req.review.external_url }}">{{ review_req.review.external_url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req.reviewed_rev %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Reviewed rev.</th>
|
||||
<td><a href="{% url "doc_view" name=review_req.doc.name rev=review_req.reviewed_rev %}">{{ review_req.reviewed_rev }}</a> {% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req.result %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Review result</th>
|
||||
<td>{{ review_req.result.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div>
|
||||
{% if can_close_request %}
|
||||
<a class="btn btn-danger btn-xs" href="{% url "ietf.doc.views_review.close_request" name=doc.name request_id=review_req.pk %}"><span class="fa fa-ban"></span> Close request</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
12
ietf/templates/doc/review_request_summary.html
Normal file
12
ietf/templates/doc/review_request_summary.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
<div class="review-request-summary">
|
||||
{% if review_request.state_id == "completed" or review_request.state_id == "part-completed" %}
|
||||
<a href="{% if review_request.review %}{% url "doc_view" review_request.review.name %}{% else %}{% url "ietf.doc.views_review.review_request" review_request.doc_id review_request.pk %}{% endif %}">
|
||||
{{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev or review_request.doc_id != current_doc_name %} (of {% if review_request.doc_id != current_doc_name %}{{ review_request.doc_id }}{% endif %}-{{ review_request.reviewed_rev }}){% endif %}{% if review_request.result %}:
|
||||
{{ review_request.result.name }}{% endif %} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<i>
|
||||
<a href="{% url "ietf.doc.views_review.review_request" review_request.doc_id review_request.pk %}">{{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review
|
||||
- due: {{ review_request.deadline|date:"Y-m-d" }}</a></i>
|
||||
{% endif %}
|
||||
</div>
|
|
@ -42,9 +42,16 @@
|
|||
{{ doc.intended_std_level }}
|
||||
{% endif %}
|
||||
|
||||
{% if doc.reviewed_by_teams %}
|
||||
<br>Reviews:
|
||||
{% for g in doc.reviewed_by_teams %}
|
||||
{{ g.acronym }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% for m in doc.milestones %}
|
||||
{% if forloop.first %}<br>{% else %}, {% endif %}
|
||||
<span title="Part of {{ m.group.acronym }} milestone: {{ m.desc }}" class="milestone">{{ m.due|date:"M Y" }}</span>
|
||||
{% if forloop.first %}<br>{% endif %}
|
||||
<span title="Part of {{ m.group.acronym }} milestone: {{ m.desc }}" class="milestone">{{ m.due|date:"M Y" }}</span>{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% else %}{# RFC #}
|
||||
|
|
20
ietf/templates/group/change_review_secretary_settings.html
Normal file
20
ietf/templates/group/change_review_secretary_settings.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
<h1>{% block title %}Change your review secretary settings for {{ group.acronym }}{% endblock %}</h1>
|
||||
|
||||
<form class="change-review-secretary-settings" method="post">{% csrf_token %}
|
||||
{% bootstrap_form settings_form %}
|
||||
|
||||
{% buttons %}
|
||||
<a href="{{ back_url }}" class="btn btn-default pull-right">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="change_settings">Save</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% endblock %}
|
87
ietf/templates/group/change_reviewer_settings.html
Normal file
87
ietf/templates/group/change_reviewer_settings.html
Normal file
|
@ -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 %}
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Change reviewer settings for {{ group.acronym }} for {{ reviewer_email }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
<h1>Change reviewer settings for {{ group.acronym }} for {{ reviewer_email }}</h1>
|
||||
|
||||
<h3>Settings</h3>
|
||||
|
||||
<form class="change-reviewer-settings" method="post">{% csrf_token %}
|
||||
{% bootstrap_form settings_form %}
|
||||
|
||||
{% buttons %}
|
||||
<a href="{{ back_url }}" class="btn btn-default pull-right">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="change_settings">Save</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
|
||||
<h3>Unavailable periods</h3>
|
||||
|
||||
<p>You can register periods where reviews should not be assigned.</p>
|
||||
|
||||
{% if unavailable_periods %}
|
||||
<table class="table">
|
||||
{% for o in unavailable_periods %}
|
||||
<tr class="unavailable-period-{{ o.state }}">
|
||||
<td>
|
||||
{{ o.start_date|default:"indefinite" }} - {{ o.end_date|default:"indefinite" }}
|
||||
</td>
|
||||
<td>{{ o.get_availability_display }}</td>
|
||||
<td>
|
||||
{% if not o.end_date %}
|
||||
<form method="post" class="form-inline" style="display:inline-block">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="period_id" value="{{ o.pk }}">
|
||||
{% bootstrap_form o.end_form layout="inline" %}
|
||||
<button type="submit" class="btn btn-default btn-sm" name="action" value="end_period">End period</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="period_id" value="{{ o.pk }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm" name="action" value="delete_period">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No periods found.</p>
|
||||
{% endif %}
|
||||
|
||||
<div><a class="btn btn-default" data-toggle="collapse" data-target="#add-new-period">Add a new period</a></div>
|
||||
|
||||
<div id="add-new-period" {% if not period_form.errors %}class="collapse"{% endif %}>
|
||||
<h4>Add a new period</h4>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form period_form %}
|
||||
|
||||
{% buttons %}
|
||||
<button type="submit" class="btn btn-primary" name="action" value="add_period">Add period</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p style="padding-top: 2em;">
|
||||
<a href="{{ back_url }}" class="btn btn-default">Back</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
{% endblock %}
|
26
ietf/templates/group/email_open_review_assignments.html
Normal file
26
ietf/templates/group/email_open_review_assignments.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}Email summary of assigned review requests for {{ group.acronym }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
<h1>Email summary of assigned review requests for {{ group.acronym }}</h1>
|
||||
|
||||
{% if review_requests %}
|
||||
<form class="email-open-review-assignments" method="post">{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<a href="{{ back_url }}" class="btn btn-default pull-right">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="email">Send to team mailing list</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% else %}
|
||||
<p>There are currently no open requests.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
8
ietf/templates/group/email_open_review_assignments.txt
Normal file
8
ietf/templates/group/email_open_review_assignments.txt
Normal file
|
@ -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 %}
|
172
ietf/templates/group/manage_review_requests.html
Normal file
172
ietf/templates/group/manage_review_requests.html
Normal file
|
@ -0,0 +1,172 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}Manage open review requests for {{ group.acronym }}{% endblock %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static "jquery.tablesorter/css/theme.bootstrap.min.css" %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
<h1>Manage {{ assignment_status }} open review requests for {{ group.acronym }}</h1>
|
||||
|
||||
<p>Other options:
|
||||
<a href="{% url "ietf.group.views_review.review_requests" group_type=group.type_id acronym=group.acronym %}">All review requests</a>
|
||||
- <a href="{% url "ietf.group.views_review.reviewer_overview" group_type=group.type_id acronym=group.acronym %}">Reviewers</a>
|
||||
- <a href="{% url "ietf.group.views_review.email_open_review_assignments" group_type=group.type_id acronym=group.acronym %}?next={{ request.get_full_path|urlencode }}">Email open assignments summary</a>
|
||||
{% if other_assignment_status %}
|
||||
- <a href="{% url "ietf.group.views_review.manage_review_requests" group_type=group.type_id acronym=group.acronym assignment_status=other_assignment_status %}">Manage {{ other_assignment_status }} reviews</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if newly_closed > 0 or newly_opened > 0 or newly_assigned > 0 %}
|
||||
<p class="alert alert-danger">
|
||||
Changes since last refresh:
|
||||
{% if newly_closed %}{{ newly_closed }} request{{ newly_closed|pluralize }} closed.{% endif %}
|
||||
{% if newly_opened %}{{ newly_opened }} request{{ newly_opened|pluralize }} opened.{% endif %}
|
||||
{% if newly_assigned %}{{ newly_assigned }} request{{ newly_assigned|pluralize }} changed assignment.{% endif %}
|
||||
|
||||
{% if saving %}
|
||||
Check that you are happy with the results, then re-save.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if review_requests %}
|
||||
<form class="review-requests" method="post">{% csrf_token %}
|
||||
|
||||
{% for r in review_requests %}
|
||||
<div class="panel panel-default review-request">
|
||||
<div class="panel-heading">
|
||||
|
||||
<h3 class="panel-title">
|
||||
<span class="pull-right">
|
||||
{{ r.type.name }}
|
||||
- deadline {{ r.deadline|date:"Y-m-d" }}
|
||||
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
|
||||
</span>
|
||||
|
||||
<a href="{% if r.requested_rev %}{% url "doc_view" name=r.doc.name rev=r.requested_rev %}{% else %}{% url "doc_view" name=r.doc.name %}{% endif %}?include_text=1">{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<div class="request-metadata">
|
||||
<p>
|
||||
{% if r.pk != None %}Requested: <a href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}">{{ r.time|date:"Y-m-d" }}</a>
|
||||
{% else %}
|
||||
Auto-suggested
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if r.latest_reqs %}
|
||||
{% for rlatest in r.latest_reqs %}
|
||||
<div>
|
||||
Revious review of <a href="{% url "doc_view" name=rlatest.doc_id rev=rlatest.reviewed_rev %}?include_text=1">{% if rlatest.doc_id != r.doc_id %}{{ rlatest.doc_id }}{% endif %}-{{ rlatest.reviewed_rev }}</a>
|
||||
(<a href="{{ rfcdiff_base_url }}?url1={{ rlatest.doc.name }}-{{ rlatest.reviewed_rev }}&url2={{ r.doc.name }}-{{ r.doc.rev }}">diff</a>):
|
||||
<a href="{% url "ietf.doc.views_review.review_request" name=rlatest.doc_id request_id=rlatest.pk %}">{% if rlatest.result %}{{ rlatest.result.name }}{% else %}result unavail.{% endif %}</a>
|
||||
by {{ rlatest.reviewer.person }}{% if rlatest.closed_review_request_event %} {{ rlatest.closed_review_request_event.time.date|date }}{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div><strong>{{ r.doc.title }}</strong></div>
|
||||
|
||||
<div>
|
||||
{{ r.doc.pages }} page{{ r.doc.pages|pluralize }}
|
||||
- {{ r.doc.friendly_state }}
|
||||
{% if r.doc.telechat_date %}
|
||||
- IESG telechat: {{ r.doc.telechat_date }}
|
||||
{% endif %}
|
||||
{% if r.doc.group.type_id != "individ" %}
|
||||
- <a href="{% url "ietf.group.views.group_home" acronym=r.doc.group.acronym group_type=r.doc.group.type_id %}">{{ r.doc.group.acronym }} {{ r.doc.group.type.name }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6 abstract">
|
||||
{{ r.doc.abstract|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
{% if r.form.non_field_errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for e in r.form.non_field_errors %}
|
||||
{{ e }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<input type="hidden" name="reviewrequest" value="{{ r.pk }}">
|
||||
<input type="hidden" name="{{ r.form.prefix }}-existing_reviewer" value="{{ r.reviewer_id|default:"" }}">
|
||||
|
||||
<span class="assign-action">
|
||||
{% if r.reviewer %}
|
||||
<button type="button" class="btn btn-default btn-success" title="Click to reassign reviewer">
|
||||
{{ r.reviewer.person }}
|
||||
{% if r.state_id == "accepted" %} <span class="label label-default">accepted</span>{% endif %}
|
||||
{% if r.reviewer_unavailable %}<span class="label label-danger">unavailable</span>{% endif %}
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-default btn-success" title="Click to assign reviewer"><em>Not yet assigned</em></button>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{{ r.form.action }}
|
||||
|
||||
<span class="reviewer-controls form-inline">
|
||||
<label for="{{ r.form.reviewer.id_for_label }}">Assign:</label>
|
||||
{{ r.form.reviewer }}
|
||||
<button type="button" class="btn btn-default undo" title="Cancel assignment" data-initial="{{ r.form.fields.reviewer.initial|default:"" }}">Cancel</button>
|
||||
{% if r.form.reviewer.errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for e in r.form.reviewer.errors %}
|
||||
{{ e }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<span class="close-action">
|
||||
<button type="button" class="btn btn-default btn-danger">Close...</button>
|
||||
</span>
|
||||
|
||||
<span class="close-controls form-inline">
|
||||
<label for="{{ r.form.reviewer.id_for_label }}">Close:</label>
|
||||
{{ r.form.close }}
|
||||
<button type="button" class="btn btn-default undo" title="Cancel closing">Cancel</button>
|
||||
{% if r.form.close.errors %}
|
||||
<br>
|
||||
{{ r.form.close.errors }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% buttons %}
|
||||
<a href="{% url "ietf.group.views_review.review_requests" group_type=group.type_id acronym=group.acronym %}" class="btn btn-default pull-right">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="save">Save changes</button>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="save-continue">Save and continue editing</button>
|
||||
<button class="btn btn-default" type="submit" name="action" value="refresh">Refresh (keeping changes)</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% else %}
|
||||
<p>There are currently no {{ assignment_status }} open requests.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static "jquery.tablesorter/js/jquery.tablesorter.combined.min.js" %}"></script>
|
||||
<script src="{% static "ietf/js/manage-review-requests.js" %}"></script>
|
||||
{% endblock %}
|
129
ietf/templates/group/review_requests.html
Normal file
129
ietf/templates/group/review_requests.html
Normal file
|
@ -0,0 +1,129 @@
|
|||
{% extends "group/group_base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block group_subtitle %}Review requests{% endblock %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static "jquery.tablesorter/css/theme.bootstrap.min.css" %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block group_content %}
|
||||
{% origin %}
|
||||
|
||||
{% if can_access_stats %}
|
||||
<h1 class="pull-right"><a href="{% url "ietf.stats.views.review_stats" %}" class="icon-link"> <span class="small fa fa-bar-chart"> </span></a></h1>
|
||||
{% endif %}
|
||||
|
||||
{% for label, review_requests in open_review_requests %}
|
||||
{% if review_requests %}
|
||||
|
||||
<h2>{{ label }} open review requests</h2>
|
||||
|
||||
<table class="table table-condensed table-striped tablesorter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Type</th>
|
||||
<th>Requested</th>
|
||||
<th>Deadline</th>
|
||||
{% if review_requests.0.reviewer %}
|
||||
<th>Reviewer</th>
|
||||
{% endif %}
|
||||
<th>Document state</th>
|
||||
<th>IESG Telechat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in review_requests %}
|
||||
<tr>
|
||||
<td>{% if r.pk != None %}<a href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}">{% endif %}{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}{% if r.pk != None %}</a>{% endif %}</td>
|
||||
<td>{{ r.type.name }}</td>
|
||||
<td>{% if r.pk %}{{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %}</td>
|
||||
<td>
|
||||
{{ r.deadline|date:"Y-m-d" }}
|
||||
{% if r.due %}<span class="label label-warning" title="{{ r.due }} day{{ r.due|pluralize }} past deadline">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
|
||||
</td>
|
||||
{% if r.reviewer %}
|
||||
<td>
|
||||
{{ r.reviewer.person }}
|
||||
{% if r.state_id == "accepted" %}<span class="label label-default">Accepted</span>{% endif %}
|
||||
{% if r.reviewer_unavailable %}<span class="label label-danger">Unavailable</span>{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{{ r.doc.friendly_state }}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.doc.telechat_date %}
|
||||
{{ r.doc.telechat_date }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<h2 id="closed-review-requests">Closed review requests</h2>
|
||||
|
||||
<form class="closed-review-filter" action="#closed-review-requests">
|
||||
Past:
|
||||
<div class="btn-group" role="group">
|
||||
{% for key, label in since_choices %}
|
||||
<button class="btn btn-default {% if since == key %}active{% endif %}" {% if key %}name="since" value="{{ key }}"{% endif %} type="submit">{{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if closed_review_requests %}
|
||||
<table class="table table-condensed table-striped materials tablesorter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Type</th>
|
||||
<th>Requested</th>
|
||||
<th>Deadline</th>
|
||||
<th>Reviewer</th>
|
||||
<th>State</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in closed_review_requests %}
|
||||
<tr>
|
||||
<td><a href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}">{{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %}</a></td>
|
||||
<td>{{ r.type }}</td>
|
||||
<td>{{ r.time|date:"Y-m-d" }}</td>
|
||||
<td>{{ r.deadline|date:"Y-m-d" }}</td>
|
||||
<td>
|
||||
{% if r.reviewer %}
|
||||
{{ r.reviewer.person }}
|
||||
{% else %}
|
||||
<em>not yet assigned</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ r.state.name }}</td>
|
||||
<td>
|
||||
{% if r.result %}
|
||||
{{ r.result.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<p>No closed requests found.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static "jquery.tablesorter/js/jquery.tablesorter.combined.min.js" %}"></script>
|
||||
{% endblock %}
|
76
ietf/templates/group/reviewer_overview.html
Normal file
76
ietf/templates/group/reviewer_overview.html
Normal file
|
@ -0,0 +1,76 @@
|
|||
{% 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 %}
|
||||
|
||||
{% if can_access_stats %}
|
||||
<h1 class="pull-right"><a href="{% url "ietf.stats.views.review_stats" stats_type="completion" acronym=group.acronym %}" class="icon-link"> <span class="small fa fa-bar-chart"> </span></a></h1>
|
||||
{% endif %}
|
||||
|
||||
<h2>Reviewers</h2>
|
||||
|
||||
<p>Status of the reviewers in {{ group.acronym }}, ordered by their
|
||||
rotation with the next reviewer in the rotation at the top.</p>
|
||||
|
||||
{% if reviewers %}
|
||||
<table class="table reviewer-overview">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reviewer</th>
|
||||
<th>Deadline/state/time between assignment and closure for latest assignments</th>
|
||||
<th>Settings</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for person in reviewers %}
|
||||
<tr {% if person.completely_unavailable %}class="completely-unavailable"{% endif %}>
|
||||
<td>{% if person.settings_url %}<a href="{{ person.settings_url }}">{% endif %}{{ person }}{% if person.settings_url %}</a>{% endif %}</td>
|
||||
<td>
|
||||
<table class="simple-table">
|
||||
{% for req_pk, doc_name, reviewed_rev, deadline, state, assignment_to_closure_days in person.latest_reqs %}
|
||||
<tr>
|
||||
<td><a href="{% url "ietf.doc.views_review.review_request" name=doc_name request_id=req_pk %}">{{ deadline|date }}</a></td>
|
||||
<td>
|
||||
<span class="label label-{% if state.slug == "completed" or state.slug == "part-completed" %}success{% elif state.slug == "no-response" %}danger{% elif state.slug == "overtaken" %}warning{% elif state.slug == "requested" or state.slug == "accepted" %}primary{% else %}default{% endif %}">{{ state.name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if assignment_to_closure_days != None %}{{ assignment_to_closure_days }} day{{ assignment_to_closure_days|pluralize }}{% endif %}
|
||||
</td>
|
||||
<td>{{ doc_name }}{% if reviewed_rev %}-{{ reviewed_rev }}{% endif %}</td>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</td>
|
||||
<td>
|
||||
{% if person.settings.min_interval %}
|
||||
{{ person.settings.get_min_interval_display }}<br>
|
||||
{% endif %}
|
||||
|
||||
{% if person.settings.skip_next %}
|
||||
Skip: {{ person.settings.skip_next }}<br>
|
||||
{% endif %}
|
||||
|
||||
{% if person.settings.filter_re %}
|
||||
Filter: <code title="{{ person.settings.filter_re }}">{{ person.settings.filter_re|truncatechars:15 }}</code><br>
|
||||
{% endif %}
|
||||
|
||||
{% if person.unavailable_periods %}
|
||||
{% include "review/unavailable_table.html" with unavailable_periods=person.unavailable_periods %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<p>No reviewers found.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
|
@ -47,6 +47,15 @@
|
|||
<dt>Consensus</dt><dd>{{ doc.consensus }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if doc.review_requests %}
|
||||
<dt>Reviews</dt>
|
||||
<dd>
|
||||
{% for review_request in doc.review_requests %}
|
||||
{% include "doc/review_request_summary.html" with current_doc_name=doc.name current_rev=doc.rev %}
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if doc.lastcall_expires %}
|
||||
<dt>Last call expires</dt><dd>{{ doc.lastcall_expires|date:"Y-m-d" }}</dd>
|
||||
{% endif %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
170
ietf/templates/ietfauth/review_overview.html
Normal file
170
ietf/templates/ietfauth/review_overview.html
Normal file
|
@ -0,0 +1,170 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}
|
||||
|
||||
{% load bootstrap3 static %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Review overview for {{ request.user }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Review overview for {{ request.user }}</h1>
|
||||
|
||||
<h2>Assigned reviews</h2>
|
||||
|
||||
{% if open_review_requests %}
|
||||
<table class="table table-condensed table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Team</th>
|
||||
<th>Type</th>
|
||||
<th>Deadline</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in open_review_requests %}
|
||||
<tr>
|
||||
<td><a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a></td>
|
||||
<td>{{ r.team.acronym }}</td>
|
||||
<td>{{ r.type.name }}</td>
|
||||
<td>
|
||||
{{ r.deadline|date:"Y-m-d" }}
|
||||
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>You do not have any open review requests assigned.</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h2>Latest closed review requests</h2>
|
||||
|
||||
{% if closed_review_requests %}
|
||||
<table class="table table-condensed table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Team</th>
|
||||
<th>Type</th>
|
||||
<th>Deadline</th>
|
||||
<th>State</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in closed_review_requests %}
|
||||
<tr>
|
||||
<td><a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a></td>
|
||||
<td>{{ r.team.acronym }}</td>
|
||||
<td>{{ r.type.name }}</td>
|
||||
<td>
|
||||
{{ r.deadline|date:"Y-m-d" }}
|
||||
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
|
||||
</td>
|
||||
<td><span class="{% if r.state_id == "completed" or r.state_id == "part-completed" %}bg-success{% endif %}">{{ r.state.name }}</span></td>
|
||||
<td>{% if r.result %}{{ r.result.name }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>Did not find any closed review requests assigned to you.</p>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<h2>Review wishes</h2>
|
||||
|
||||
{% if review_wishes %}
|
||||
<p>You have indicated that you would like to review:</p>
|
||||
|
||||
<table class="table">
|
||||
{% for w in review_wishes %}
|
||||
<tr>
|
||||
<td><a href="{% url "doc_view" w.doc_id %}">{{ w.doc_id }}</a></td>
|
||||
<td>{{ w.team.acronym }}</td>
|
||||
<td>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<input name="wish_id" value="{{ w.pk }}" type="hidden">
|
||||
<button class="btn btn-sm btn-danger" type="submit" name="action" value="delete_wish">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>You do not have any review wishes.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if teams %}
|
||||
<p>Add a draft that you would like to review when it becomes available for review:</p>
|
||||
|
||||
<form role="form" method="post" class="form-inline">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form review_wish_form layout="inline" %}
|
||||
|
||||
{% buttons %}
|
||||
<button class="btn btn-default" type="submit" name="action" value="add_wish">Add draft</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% for t in teams %}
|
||||
<h2>Settings for {{ t }}</h2>
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>Can review</th>
|
||||
<td>{{ t.reviewer_settings.get_min_interval_display|default:"No max frequency set" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Skip next assignments</th>
|
||||
<td>{{ t.reviewer_settings.skip_next }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Filter regexp</th>
|
||||
<td>{% if t.reviewer_settings.filter_re %}<code>{{ t.reviewer_settings.filter_re }}</code>{% else %}(None){% endif %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Remind days before deadline</th>
|
||||
<td>{{ t.reviewer_settings.remind_days_before_deadline|default:"(Do not remind)" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Unavailable periods</th>
|
||||
<td>
|
||||
{% if t.unavailable_periods %}
|
||||
{% include "review/unavailable_table.html" with unavailable_periods=t.unavailable_periods %}
|
||||
{% else %}
|
||||
(No periods)
|
||||
{% endif %}
|
||||
</td>
|
||||
</table>
|
||||
|
||||
<div>
|
||||
<a class="btn btn-default" href="{% url "ietf.group.views_review.change_reviewer_settings" group_type=t.type_id acronym=t.acronym reviewer_email=t.role.email.address %}?next={{ request.get_full_path|urlencode }}">Change settings</a>
|
||||
</div>
|
||||
|
||||
|
||||
{% empty %}
|
||||
<h2>Settings</h2>
|
||||
|
||||
<p>It looks like you are not a reviewer in any active review team.</p>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'select2/select2.min.js' %}"></script>
|
||||
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
|
||||
{% endblock %}
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
<p>Value of <code>testmailcc</code>: {{ cookie }}</p>
|
||||
|
||||
<form role-"form" method="post">
|
||||
<form role="form" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
|
||||
|
|
|
@ -81,7 +81,7 @@
|
|||
</li>
|
||||
</ol>
|
||||
</p>
|
||||
<form role-"form" method="post">
|
||||
<form role="form" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
|
||||
|
|
|
@ -103,11 +103,13 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="p col-lg-12 col-md-12 col-sm-12 col-xs-12">
|
||||
|
||||
The material posted as IPR disclosures should be viewed as originating
|
||||
from the source of that information, and any issue or question related
|
||||
to the material should be directed to the source rather than the
|
||||
IETF. There is no implied endorsement or agreement by the IETF, the
|
||||
IESG or any other IETF entities with any of the material.
|
||||
|
||||
</div>
|
||||
|
||||
<a class="btn btn-default pull-right" href="{% url "ietf.ipr.views.showlist" %}">Back</a>
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Drafts already linked to this sesssion</div>
|
||||
<div class="panel-body">
|
||||
<table class="table table-contensed table-striped">
|
||||
<table class="table table-condensed table-striped">
|
||||
<tr>
|
||||
<th class="col-md-1">Revision</th>
|
||||
<th>Document</th>
|
||||
|
|
7
ietf/templates/review/completed_review.txt
Normal file
7
ietf/templates/review/completed_review.txt
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% autoescape off %}{% filter wordwrap:70 %}{% if review_req.state_id == "part-completed" %}Review is partially done. Another review request has been registered for completing it.
|
||||
|
||||
{% endif %}Reviewer: {{ review_req.reviewer.person }}
|
||||
Review result: {{ review_req.result.name }}
|
||||
|
||||
{{ content }}
|
||||
{% endfilter %}{% endautoescape %}
|
10
ietf/templates/review/partially_completed_review.txt
Normal file
10
ietf/templates/review/partially_completed_review.txt
Normal file
|
@ -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 %}
|
9
ietf/templates/review/review_request_changed.txt
Normal file
9
ietf/templates/review/review_request_changed.txt
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% autoescape off %}
|
||||
{{ review_req.type.name }} review of: {{ review_req.doc.name }} ({% if review_req.requested_rev %}rev. {{ review_req.requested_rev }}{% else %}no specific version{% endif %})
|
||||
Deadline: {{ review_req.deadline|date:"Y-m-d" }}
|
||||
|
||||
{{ review_req_url }}
|
||||
|
||||
{{ msg|wordwrap:72 }}
|
||||
|
||||
{% endautoescape %}
|
6
ietf/templates/review/reviewer_assignment_rejected.txt
Normal file
6
ietf/templates/review/reviewer_assignment_rejected.txt
Normal file
|
@ -0,0 +1,6 @@
|
|||
{% autoescape off %}Reviewer assignment rejected by {{ by }}.{% if message_to_secretary %}
|
||||
|
||||
Explanation:
|
||||
|
||||
{{ message_to_secretary }}
|
||||
{% endif %}{% endautoescape %}
|
8
ietf/templates/review/reviewer_availability_changed.txt
Normal file
8
ietf/templates/review/reviewer_availability_changed.txt
Normal file
|
@ -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 %}
|
8
ietf/templates/review/reviewer_reminder.txt
Normal file
8
ietf/templates/review/reviewer_reminder.txt
Normal file
|
@ -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 %}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue