Make new branch from trunk and merge in review-tracker-r11921
- Legacy-Id: 12129
This commit is contained in:
commit
958ba5ba95
|
@ -94,7 +94,6 @@ for _app in settings.INSTALLED_APPS:
|
|||
_root, _name = _app.split('.', 1)
|
||||
if _root == 'ietf':
|
||||
if not '.' in _name:
|
||||
|
||||
_api = Api(api_name=_name)
|
||||
_module_dict[_name] = _api
|
||||
_api_list.append((_name, _api))
|
||||
|
|
25
ietf/bin/send-reviewer-reminders
Executable file
25
ietf/bin/send-reviewer-reminders
Executable file
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import os, sys
|
||||
import syslog
|
||||
|
||||
# boilerplate
|
||||
basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
|
||||
sys.path = [ basedir ] + sys.path
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "ietf.settings"
|
||||
|
||||
virtualenv_activation = os.path.join(basedir, "bin", "activate_this.py")
|
||||
if os.path.exists(virtualenv_activation):
|
||||
execfile(virtualenv_activation, dict(__file__=virtualenv_activation))
|
||||
|
||||
syslog.openlog(os.path.basename(__file__), syslog.LOG_PID, syslog.LOG_USER)
|
||||
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
import datetime
|
||||
from ietf.review.utils import review_requests_needing_reviewer_reminder, email_reviewer_reminder
|
||||
|
||||
for review_req in review_requests_needing_reviewer_reminder(datetime.date.today()):
|
||||
email_reviewer_reminder(review_req)
|
||||
print("Emailed reminder to {} for review of {} in {} (req. id {})".format(review_req.reviewer.address, review_req.doc_id, review_req.team.acronym, review_req.pk))
|
|
@ -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/0015_auto_20160927_0713.py
Normal file
33
ietf/doc/migrations/0015_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', '0014_auto_20160824_2218'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReviewRequestDocEvent',
|
||||
fields=[
|
||||
('docevent_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='doc.DocEvent')),
|
||||
('review_request', models.ForeignKey(to='review.ReviewRequest')),
|
||||
('state', models.ForeignKey(blank=True, to='name.ReviewRequestStateName', null=True)),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=('doc.docevent',),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='docevent',
|
||||
name='type',
|
||||
field=models.CharField(max_length=50, choices=[(b'new_revision', b'Added new revision'), (b'changed_document', b'Changed document metadata'), (b'added_comment', b'Added comment'), (b'deleted', b'Deleted document'), (b'changed_state', b'Changed state'), (b'changed_stream', b'Changed document stream'), (b'expired_document', b'Expired document'), (b'extended_expiry', b'Extended expiry of document'), (b'requested_resurrect', b'Requested resurrect'), (b'completed_resurrect', b'Completed resurrect'), (b'changed_consensus', b'Changed consensus'), (b'published_rfc', b'Published RFC'), (b'added_suggested_replaces', b'Added suggested replacement relationships'), (b'reviewed_suggested_replaces', b'Reviewed suggested replacement relationships'), (b'changed_group', b'Changed group'), (b'changed_protocol_writeup', b'Changed protocol writeup'), (b'changed_charter_milestone', b'Changed charter milestone'), (b'initial_review', b'Set initial review time'), (b'changed_review_announcement', b'Changed WG Review text'), (b'changed_action_announcement', b'Changed WG Action text'), (b'started_iesg_process', b'Started IESG process on document'), (b'created_ballot', b'Created ballot'), (b'closed_ballot', b'Closed ballot'), (b'sent_ballot_announcement', b'Sent ballot announcement'), (b'changed_ballot_position', b'Changed ballot position'), (b'changed_ballot_approval_text', b'Changed ballot approval text'), (b'changed_ballot_writeup_text', b'Changed ballot writeup text'), (b'changed_rfc_editor_note_text', b'Changed RFC Editor Note text'), (b'changed_last_call_text', b'Changed last call text'), (b'requested_last_call', b'Requested last call'), (b'sent_last_call', b'Sent last call'), (b'scheduled_for_telechat', b'Scheduled for telechat'), (b'iesg_approved', b'IESG approved document (no problem)'), (b'iesg_disapproved', b'IESG disapproved document (do not publish)'), (b'approved_in_minute', b'Approved in minute'), (b'iana_review', b'IANA review comment'), (b'rfc_in_iana_registry', b'RFC is in IANA registry'), (b'rfc_editor_received_announcement', b'Announcement was received by RFC Editor'), (b'requested_publication', b'Publication at RFC Editor requested'), (b'sync_from_rfc_editor', b'Received updated information from RFC Editor'), (b'requested_review', b'Requested review'), (b'assigned_review_request', b'Assigned review request'), (b'closed_review_request', b'Closed review request')]),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
|
@ -14,7 +14,7 @@ import debug # pyflakes:ignore
|
|||
|
||||
from ietf.group.models import Group
|
||||
from ietf.name.models import ( DocTypeName, DocTagName, StreamName, IntendedStdLevelName, StdLevelName,
|
||||
DocRelationshipName, DocReminderTypeName, BallotPositionName )
|
||||
DocRelationshipName, DocReminderTypeName, BallotPositionName, ReviewRequestStateName )
|
||||
from ietf.person.models import Email, Person
|
||||
from ietf.utils.admin import admin_link
|
||||
|
||||
|
@ -697,6 +697,11 @@ EVENT_TYPES = [
|
|||
("rfc_editor_received_announcement", "Announcement was received by RFC Editor"),
|
||||
("requested_publication", "Publication at RFC Editor requested"),
|
||||
("sync_from_rfc_editor", "Received updated information from RFC Editor"),
|
||||
|
||||
# review
|
||||
("requested_review", "Requested review"),
|
||||
("assigned_review_request", "Assigned review request"),
|
||||
("closed_review_request", "Closed review request"),
|
||||
]
|
||||
|
||||
class DocEvent(models.Model):
|
||||
|
@ -827,11 +832,14 @@ class TelechatDocEvent(DocEvent):
|
|||
telechat_date = models.DateField(blank=True, null=True)
|
||||
returning_item = models.BooleanField(default=False)
|
||||
|
||||
class ReviewRequestDocEvent(DocEvent):
|
||||
review_request = models.ForeignKey('review.ReviewRequest')
|
||||
state = models.ForeignKey(ReviewRequestStateName, blank=True, null=True)
|
||||
|
||||
# charter events
|
||||
class InitialReviewDocEvent(DocEvent):
|
||||
expires = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
|
||||
class AddedMessageEvent(DocEvent):
|
||||
import ietf.message.models
|
||||
message = models.ForeignKey(ietf.message.models.Message, null=True, blank=True,related_name='doc_manualevents')
|
||||
|
|
|
@ -11,7 +11,7 @@ from ietf.doc.models import (BallotType, DeletedEvent, StateType, State, Documen
|
|||
DocumentAuthor, DocEvent, StateDocEvent, DocHistory, ConsensusDocEvent, DocAlias,
|
||||
TelechatDocEvent, DocReminder, LastCallDocEvent, NewRevisionDocEvent, WriteupDocEvent,
|
||||
InitialReviewDocEvent, DocHistoryAuthor, BallotDocEvent, RelatedDocument,
|
||||
RelatedDocHistory, BallotPositionDocEvent, AddedMessageEvent)
|
||||
RelatedDocHistory, BallotPositionDocEvent, AddedMessageEvent, ReviewRequestDocEvent)
|
||||
|
||||
|
||||
from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource
|
||||
|
@ -513,8 +513,6 @@ class BallotPositionDocEventResource(ModelResource):
|
|||
}
|
||||
api.doc.register(BallotPositionDocEventResource())
|
||||
|
||||
|
||||
|
||||
from ietf.person.resources import PersonResource
|
||||
from ietf.message.resources import MessageResource
|
||||
class AddedMessageEventResource(ModelResource):
|
||||
|
@ -542,3 +540,28 @@ class AddedMessageEventResource(ModelResource):
|
|||
}
|
||||
api.doc.register(AddedMessageEventResource())
|
||||
|
||||
from ietf.person.resources import PersonResource
|
||||
from ietf.name.resources import ReviewRequestStateNameResource
|
||||
class ReviewRequestDocEventResource(ModelResource):
|
||||
by = ToOneField(PersonResource, 'by')
|
||||
doc = ToOneField(DocumentResource, 'doc')
|
||||
docevent_ptr = ToOneField(DocEventResource, 'docevent_ptr')
|
||||
review_request = ToOneField('review.ReviewRequestResource', 'review_request')
|
||||
state = ToOneField(ReviewRequestStateNameResource, 'state', null=True)
|
||||
class Meta:
|
||||
queryset = ReviewRequestDocEvent.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'reviewrequestdocevent'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"time": ALL,
|
||||
"type": ALL,
|
||||
"desc": ALL,
|
||||
"by": ALL_WITH_RELATIONS,
|
||||
"doc": ALL_WITH_RELATIONS,
|
||||
"docevent_ptr": ALL_WITH_RELATIONS,
|
||||
"review_request": ALL_WITH_RELATIONS,
|
||||
"state": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.doc.register(ReviewRequestDocEventResource())
|
||||
|
|
661
ietf/doc/tests_review.py
Normal file
661
ietf/doc/tests_review.py
Normal file
|
@ -0,0 +1,661 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import datetime, os, shutil, json
|
||||
import tarfile, tempfile, mailbox
|
||||
import email.mime.multipart, email.mime.text, email.utils
|
||||
from StringIO import StringIO
|
||||
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
from django.conf import settings
|
||||
|
||||
from pyquery import PyQuery
|
||||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from ietf.review.models import (ReviewRequest, ResultUsedInReviewTeam, ReviewerSettings,
|
||||
ReviewWish, UnavailablePeriod, NextReviewerInTeam)
|
||||
from ietf.review.utils import reviewer_rotation_list, possibly_advance_next_reviewer_for_team
|
||||
import ietf.review.mailarch
|
||||
from ietf.person.models import Email, Person
|
||||
from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName, DocRelationshipName
|
||||
from ietf.group.models import Group
|
||||
from ietf.doc.models import DocumentAuthor, Document, DocAlias, RelatedDocument, DocEvent
|
||||
from ietf.utils.test_utils import TestCase
|
||||
from ietf.utils.test_data import make_test_data, make_review_data, create_person
|
||||
from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects
|
||||
from ietf.utils.mail import outbox, empty_outbox
|
||||
|
||||
class ReviewTests(TestCase):
|
||||
def setUp(self):
|
||||
self.review_dir = os.path.abspath("tmp-review-dir")
|
||||
if not os.path.exists(self.review_dir):
|
||||
os.mkdir(self.review_dir)
|
||||
|
||||
self.old_document_path_pattern = settings.DOCUMENT_PATH_PATTERN
|
||||
settings.DOCUMENT_PATH_PATTERN = self.review_dir + "/{doc.type_id}/"
|
||||
|
||||
self.review_subdir = os.path.join(self.review_dir, "review")
|
||||
if not os.path.exists(self.review_subdir):
|
||||
os.mkdir(self.review_subdir)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.review_dir)
|
||||
settings.DOCUMENT_PATH_PATTERN = self.old_document_path_pattern
|
||||
|
||||
def test_request_review(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_team = review_req.team
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.request_review', kwargs={ "name": doc.name })
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
# get
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
deadline = datetime.date.today() + datetime.timedelta(days=10)
|
||||
|
||||
# post request
|
||||
r = self.client.post(url, {
|
||||
"type": "early",
|
||||
"team": review_team.pk,
|
||||
"deadline": deadline.isoformat(),
|
||||
"requested_rev": "01",
|
||||
"requested_by": Person.objects.get(user__username="plain").pk,
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
req = ReviewRequest.objects.get(doc=doc, state="requested")
|
||||
self.assertEqual(req.deadline, deadline)
|
||||
self.assertEqual(req.team, review_team)
|
||||
self.assertEqual(req.requested_rev, "01")
|
||||
self.assertEqual(doc.latest_event().type, "requested_review")
|
||||
|
||||
def test_doc_page(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
|
||||
# move the review request to a doubly-replaced document to
|
||||
# check we can fish it out
|
||||
old_doc = Document.objects.get(name="draft-foo-mars-test")
|
||||
older_doc = Document.objects.create(name="draft-older")
|
||||
older_docalias = DocAlias.objects.create(name=older_doc.name, document=older_doc)
|
||||
RelatedDocument.objects.create(source=old_doc, target=older_docalias, relationship=DocRelationshipName.objects.get(slug='replaces'))
|
||||
review_req.doc = older_doc
|
||||
review_req.save()
|
||||
|
||||
url = urlreverse('doc_view', kwargs={ "name": doc.name })
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
content = unicontent(r)
|
||||
self.assertTrue("{} Review".format(review_req.type.name) in content)
|
||||
|
||||
def test_review_request(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req.team.acronym.upper() in unicontent(r))
|
||||
|
||||
def test_close_request(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
|
||||
close_url = urlreverse('ietf.doc.views_review.close_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
|
||||
|
||||
# follow link
|
||||
req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
r = self.client.get(req_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(close_url in unicontent(r))
|
||||
self.client.logout()
|
||||
|
||||
# get close page
|
||||
login_testing_unauthorized(self, "secretary", close_url)
|
||||
r = self.client.get(close_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# close
|
||||
empty_outbox()
|
||||
r = self.client.post(close_url, { "close_reason": "withdrawn" })
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "withdrawn")
|
||||
e = doc.latest_event()
|
||||
self.assertEqual(e.type, "closed_review_request")
|
||||
self.assertTrue("closed" in e.desc.lower())
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue("closed" in unicode(outbox[0]).lower())
|
||||
|
||||
def make_data_for_rotation_tests(self, doc):
|
||||
team = Group.objects.create(state_id="active", acronym="rotationteam", name="Review Team", type_id="dir",
|
||||
list_email="rotationteam@ietf.org", parent=Group.objects.get(acronym="farfut"))
|
||||
|
||||
# make a bunch of reviewers
|
||||
reviewers = [
|
||||
create_person(team, "reviewer", name="Test Reviewer{}".format(i), username="testreviewer{}".format(i))
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
self.assertEqual(reviewers, reviewer_rotation_list(team))
|
||||
|
||||
return team, reviewers
|
||||
|
||||
def test_possibly_advance_next_reviewer_for_team(self):
|
||||
doc = make_test_data()
|
||||
|
||||
team, reviewers = self.make_data_for_rotation_tests(doc)
|
||||
|
||||
def get_skip_next(person):
|
||||
settings = (ReviewerSettings.objects.filter(team=team, person=person).first()
|
||||
or ReviewerSettings(team=team))
|
||||
return settings.skip_next
|
||||
|
||||
possibly_advance_next_reviewer_for_team(team, reviewers[0].pk)
|
||||
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[1])
|
||||
self.assertEqual(get_skip_next(reviewers[0]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[1]), 0)
|
||||
|
||||
possibly_advance_next_reviewer_for_team(team, reviewers[1].pk)
|
||||
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
|
||||
|
||||
# skip reviewer 2
|
||||
possibly_advance_next_reviewer_for_team(team, reviewers[3].pk)
|
||||
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
|
||||
self.assertEqual(get_skip_next(reviewers[0]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[1]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[2]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[3]), 1)
|
||||
|
||||
# pick reviewer 2, use up reviewer 3's skip_next
|
||||
possibly_advance_next_reviewer_for_team(team, reviewers[2].pk)
|
||||
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[4])
|
||||
self.assertEqual(get_skip_next(reviewers[0]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[1]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[2]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[3]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[4]), 0)
|
||||
|
||||
# check wrap-around
|
||||
possibly_advance_next_reviewer_for_team(team, reviewers[4].pk)
|
||||
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[0])
|
||||
self.assertEqual(get_skip_next(reviewers[0]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[1]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[2]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[3]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[4]), 0)
|
||||
|
||||
# unavailable
|
||||
today = datetime.date.today()
|
||||
UnavailablePeriod.objects.create(team=team, person=reviewers[1], start_date=today, end_date=today, availability="unavailable")
|
||||
possibly_advance_next_reviewer_for_team(team, reviewers[0].pk)
|
||||
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
|
||||
self.assertEqual(get_skip_next(reviewers[0]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[1]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[2]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[3]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[4]), 0)
|
||||
|
||||
# pick unavailable anyway
|
||||
possibly_advance_next_reviewer_for_team(team, reviewers[1].pk)
|
||||
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[2])
|
||||
self.assertEqual(get_skip_next(reviewers[0]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[1]), 1)
|
||||
self.assertEqual(get_skip_next(reviewers[2]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[3]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[4]), 0)
|
||||
|
||||
# not through min_interval
|
||||
ReviewRequest.objects.create(team=team, doc=doc, type_id="early", state_id="accepted", deadline=today, requested_by=reviewers[0], reviewer=reviewers[2].email_set.first())
|
||||
possibly_advance_next_reviewer_for_team(team, reviewers[3].pk)
|
||||
self.assertEqual(NextReviewerInTeam.objects.get(team=team).next_reviewer, reviewers[4])
|
||||
self.assertEqual(get_skip_next(reviewers[0]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[1]), 1)
|
||||
self.assertEqual(get_skip_next(reviewers[2]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[3]), 0)
|
||||
self.assertEqual(get_skip_next(reviewers[4]), 0)
|
||||
|
||||
def test_assign_reviewer(self):
|
||||
doc = make_test_data()
|
||||
|
||||
# set up some reviewer-suitability factors
|
||||
plain_email = Email.objects.filter(person__user__username="plain").first()
|
||||
DocumentAuthor.objects.create(
|
||||
author=plain_email,
|
||||
document=doc,
|
||||
)
|
||||
doc.rev = "10"
|
||||
doc.save_with_history([DocEvent.objects.create(doc=doc, type="changed_document", by=Person.objects.get(user__username="secretary"), desc="Test")])
|
||||
|
||||
# review to assign to
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
|
||||
review_req.reviewer = None
|
||||
review_req.save()
|
||||
|
||||
# previous review
|
||||
ReviewRequest.objects.create(
|
||||
time=datetime.datetime.now() - datetime.timedelta(days=100),
|
||||
requested_by=Person.objects.get(name="(System)"),
|
||||
doc=doc,
|
||||
type=ReviewTypeName.objects.get(slug="early"),
|
||||
team=review_req.team,
|
||||
state=ReviewRequestStateName.objects.get(slug="completed"),
|
||||
reviewed_rev="01",
|
||||
deadline=datetime.date.today() - datetime.timedelta(days=80),
|
||||
reviewer=plain_email,
|
||||
)
|
||||
|
||||
reviewer_settings = ReviewerSettings.objects.get(person__email=plain_email, team=review_req.team)
|
||||
reviewer_settings.filter_re = doc.name
|
||||
reviewer_settings.skip_next = 1
|
||||
reviewer_settings.save()
|
||||
|
||||
UnavailablePeriod.objects.create(
|
||||
team=review_req.team,
|
||||
person=plain_email.person,
|
||||
start_date=datetime.date.today() - datetime.timedelta(days=10),
|
||||
availability="unavailable",
|
||||
)
|
||||
|
||||
ReviewWish.objects.create(person=plain_email.person, team=review_req.team, doc=doc)
|
||||
|
||||
# pick a non-existing reviewer as next to see that we can
|
||||
# handle reviewers who have left
|
||||
NextReviewerInTeam.objects.filter(team=review_req.team).delete()
|
||||
NextReviewerInTeam.objects.create(
|
||||
team=review_req.team,
|
||||
next_reviewer=Person.objects.exclude(pk=plain_email.person_id).first(),
|
||||
)
|
||||
|
||||
assign_url = urlreverse('ietf.doc.views_review.assign_reviewer', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
|
||||
|
||||
# follow link
|
||||
req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
r = self.client.get(req_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(assign_url in unicontent(r))
|
||||
self.client.logout()
|
||||
|
||||
# get assign page
|
||||
login_testing_unauthorized(self, "secretary", assign_url)
|
||||
r = self.client.get(assign_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
plain_label = q("option[value=\"{}\"]".format(plain_email.address)).text().lower()
|
||||
self.assertIn("reviewed document before", plain_label)
|
||||
self.assertIn("wishes to review", plain_label)
|
||||
self.assertIn("is author", plain_label)
|
||||
self.assertIn("regexp matches", plain_label)
|
||||
self.assertIn("unavailable indefinitely", plain_label)
|
||||
self.assertIn("skip next 1", plain_label)
|
||||
self.assertIn("#1", plain_label)
|
||||
|
||||
# assign
|
||||
empty_outbox()
|
||||
rotation_list = reviewer_rotation_list(review_req.team)
|
||||
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team, person=rotation_list[0]).first()
|
||||
r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk })
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "requested")
|
||||
self.assertEqual(review_req.reviewer, reviewer)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue("assigned" in unicode(outbox[0]))
|
||||
self.assertEqual(NextReviewerInTeam.objects.get(team=review_req.team).next_reviewer, rotation_list[1])
|
||||
|
||||
# re-assign
|
||||
empty_outbox()
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team, person=rotation_list[1]).first()
|
||||
r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk })
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "requested") # check that state is reset
|
||||
self.assertEqual(review_req.reviewer, reviewer)
|
||||
self.assertEqual(len(outbox), 2)
|
||||
self.assertTrue("cancelled your assignment" in unicode(outbox[0]))
|
||||
self.assertTrue("assigned" in unicode(outbox[1]))
|
||||
|
||||
def test_accept_reviewer_assignment(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
|
||||
review_req.save()
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
username = review_req.reviewer.person.user.username
|
||||
self.client.login(username=username, password=username + "+password")
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
self.assertTrue(q("[name=action][value=accept]"))
|
||||
|
||||
# accept
|
||||
r = self.client.post(url, { "action": "accept" })
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "accepted")
|
||||
|
||||
def test_reject_reviewer_assignment(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
|
||||
reject_url = urlreverse('ietf.doc.views_review.reject_reviewer_assignment', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
|
||||
|
||||
# follow link
|
||||
req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
r = self.client.get(req_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(reject_url in unicontent(r))
|
||||
self.client.logout()
|
||||
|
||||
# get reject page
|
||||
login_testing_unauthorized(self, "secretary", reject_url)
|
||||
r = self.client.get(reject_url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r))
|
||||
|
||||
# reject
|
||||
empty_outbox()
|
||||
r = self.client.post(reject_url, { "action": "reject", "message_to_secretary": "Test message" })
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "rejected")
|
||||
e = doc.latest_event()
|
||||
self.assertEqual(e.type, "closed_review_request")
|
||||
self.assertTrue("rejected" in e.desc)
|
||||
self.assertEqual(ReviewRequest.objects.filter(doc=review_req.doc, team=review_req.team, state="requested").count(), 1)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue("Test message" in unicode(outbox[0]))
|
||||
|
||||
def make_test_mbox_tarball(self, review_req):
|
||||
mbox_path = os.path.join(self.review_dir, "testmbox.tar.gz")
|
||||
with tarfile.open(mbox_path, "w:gz") as tar:
|
||||
with tempfile.NamedTemporaryFile(dir=self.review_dir, suffix=".mbox") as tmp:
|
||||
mbox = mailbox.mbox(tmp.name)
|
||||
|
||||
# plain text
|
||||
msg = email.mime.text.MIMEText("Hello,\n\nI have reviewed the document and did not find any problems.\n\nJohn Doe")
|
||||
msg["From"] = "johndoe@example.com"
|
||||
msg["To"] = review_req.team.list_email
|
||||
msg["Subject"] = "Review of {}-01".format(review_req.doc.name)
|
||||
msg["Message-ID"] = email.utils.make_msgid()
|
||||
msg["Archived-At"] = "<https://www.example.com/testmessage>"
|
||||
msg["Date"] = email.utils.formatdate()
|
||||
|
||||
mbox.add(msg)
|
||||
|
||||
# plain text + HTML
|
||||
msg = email.mime.multipart.MIMEMultipart('alternative')
|
||||
msg["From"] = "johndoe2@example.com"
|
||||
msg["To"] = review_req.team.list_email
|
||||
msg["Subject"] = "Review of {}".format(review_req.doc.name)
|
||||
msg["Message-ID"] = email.utils.make_msgid()
|
||||
msg["Archived-At"] = "<https://www.example.com/testmessage2>"
|
||||
|
||||
msg.attach(email.mime.text.MIMEText("Hi!,\r\nLooks OK!\r\n-John", "plain"))
|
||||
msg.attach(email.mime.text.MIMEText("<html><body><p>Hi!,</p><p>Looks OK!</p><p>-John</p></body></html>", "html"))
|
||||
mbox.add(msg)
|
||||
|
||||
tmp.flush()
|
||||
|
||||
tar.add(os.path.relpath(tmp.name))
|
||||
|
||||
return mbox_path
|
||||
|
||||
def test_search_mail_archive(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
review_req.team.save()
|
||||
|
||||
# test URL construction
|
||||
query_urls = ietf.review.mailarch.construct_query_urls(review_req)
|
||||
self.assertTrue(review_req.doc.name in query_urls["query_data_url"])
|
||||
|
||||
# test parsing
|
||||
mbox_path = self.make_test_mbox_tarball(review_req)
|
||||
|
||||
try:
|
||||
# mock URL generator and point it to local file - for this
|
||||
# to work, the module (and not the function) must be
|
||||
# imported in the view
|
||||
real_fn = ietf.review.mailarch.construct_query_urls
|
||||
ietf.review.mailarch.construct_query_urls = lambda review_req, query=None: { "query_data_url": "file://" + os.path.abspath(mbox_path) }
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.search_mail_archive', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
messages = json.loads(r.content)["messages"]
|
||||
self.assertEqual(len(messages), 2)
|
||||
|
||||
self.assertEqual(messages[0]["url"], "https://www.example.com/testmessage")
|
||||
self.assertTrue("John Doe" in messages[0]["content"])
|
||||
self.assertEqual(messages[0]["subject"], "Review of {}-01".format(review_req.doc.name))
|
||||
|
||||
self.assertEqual(messages[1]["url"], "https://www.example.com/testmessage2")
|
||||
self.assertTrue("Looks OK" in messages[1]["content"])
|
||||
self.assertTrue("<html>" not in messages[1]["content"])
|
||||
self.assertEqual(messages[1]["subject"], "Review of {}".format(review_req.doc.name))
|
||||
finally:
|
||||
ietf.review.mailarch.construct_query_urls = real_fn
|
||||
|
||||
def setup_complete_review_test(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
for r in ReviewResultName.objects.filter(slug__in=("issues", "ready")):
|
||||
ResultUsedInReviewTeam.objects.get_or_create(team=review_req.team, result=r)
|
||||
review_req.team.save()
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": doc.name, "request_id": review_req.pk })
|
||||
|
||||
return review_req, url
|
||||
|
||||
def test_complete_review_upload_content(self):
|
||||
review_req, url = self.setup_complete_review_test()
|
||||
|
||||
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
|
||||
|
||||
# get
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# faulty post
|
||||
r = self.client.post(url, data={
|
||||
"result": "ready",
|
||||
"state": "completed",
|
||||
"reviewed_rev": "abc",
|
||||
"review_submission": "upload",
|
||||
"review_content": "",
|
||||
"review_url": "",
|
||||
"review_file": "",
|
||||
})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
self.assertTrue(q("[name=reviewed_rev]").closest(".form-group").filter(".has-error"))
|
||||
self.assertTrue(q("[name=review_file]").closest(".form-group").filter(".has-error"))
|
||||
|
||||
# complete by uploading file
|
||||
empty_outbox()
|
||||
|
||||
test_file = StringIO("This is a review\nwith two lines")
|
||||
test_file.name = "unnamed"
|
||||
|
||||
r = self.client.post(url, data={
|
||||
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
|
||||
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
|
||||
"reviewed_rev": review_req.doc.rev,
|
||||
"review_submission": "upload",
|
||||
"review_content": "",
|
||||
"review_url": "",
|
||||
"review_file": test_file,
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "completed")
|
||||
self.assertEqual(review_req.result_id, "ready")
|
||||
self.assertEqual(review_req.reviewed_rev, review_req.doc.rev)
|
||||
self.assertTrue(review_req.team.acronym.lower() in review_req.review.name)
|
||||
self.assertTrue(review_req.doc.rev in review_req.review.name)
|
||||
|
||||
with open(os.path.join(self.review_subdir, review_req.review.name + ".txt")) as f:
|
||||
self.assertEqual(f.read(), "This is a review\nwith two lines")
|
||||
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(review_req.team.list_email in outbox[0]["To"])
|
||||
self.assertTrue("This is a review" in unicode(outbox[0]))
|
||||
|
||||
self.assertTrue(settings.MAILING_LIST_ARCHIVE_URL in review_req.review.external_url)
|
||||
|
||||
# check the review document page
|
||||
url = urlreverse('doc_view', kwargs={ "name": review_req.review.name })
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
content = unicontent(r)
|
||||
self.assertTrue("{} Review".format(review_req.type.name) in content)
|
||||
self.assertTrue("This is a review" in content)
|
||||
|
||||
|
||||
def test_complete_review_enter_content(self):
|
||||
review_req, url = self.setup_complete_review_test()
|
||||
|
||||
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
|
||||
|
||||
# complete by uploading file
|
||||
empty_outbox()
|
||||
|
||||
r = self.client.post(url, data={
|
||||
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
|
||||
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
|
||||
"reviewed_rev": review_req.doc.rev,
|
||||
"review_submission": "enter",
|
||||
"review_content": "This is a review\nwith two lines",
|
||||
"review_url": "",
|
||||
"review_file": "",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "completed")
|
||||
|
||||
with open(os.path.join(self.review_subdir, review_req.review.name + ".txt")) as f:
|
||||
self.assertEqual(f.read(), "This is a review\nwith two lines")
|
||||
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(review_req.team.list_email in outbox[0]["To"])
|
||||
self.assertTrue("This is a review" in unicode(outbox[0]))
|
||||
|
||||
self.assertTrue(settings.MAILING_LIST_ARCHIVE_URL in review_req.review.external_url)
|
||||
|
||||
def test_complete_review_link_to_mailing_list(self):
|
||||
review_req, url = self.setup_complete_review_test()
|
||||
|
||||
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
|
||||
|
||||
# complete by uploading file
|
||||
empty_outbox()
|
||||
|
||||
r = self.client.post(url, data={
|
||||
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
|
||||
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
|
||||
"reviewed_rev": review_req.doc.rev,
|
||||
"review_submission": "link",
|
||||
"review_content": "This is a review\nwith two lines",
|
||||
"review_url": "http://example.com/testreview/",
|
||||
"review_file": "",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "completed")
|
||||
|
||||
with open(os.path.join(self.review_subdir, review_req.review.name + ".txt")) as f:
|
||||
self.assertEqual(f.read(), "This is a review\nwith two lines")
|
||||
|
||||
self.assertEqual(len(outbox), 0)
|
||||
self.assertTrue("http://example.com" in review_req.review.external_url)
|
||||
|
||||
def test_partially_complete_review(self):
|
||||
review_req, url = self.setup_complete_review_test()
|
||||
|
||||
login_testing_unauthorized(self, review_req.reviewer.person.user.username, url)
|
||||
|
||||
# partially complete
|
||||
empty_outbox()
|
||||
|
||||
r = self.client.post(url, data={
|
||||
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
|
||||
"state": ReviewRequestStateName.objects.get(slug="part-completed").pk,
|
||||
"reviewed_rev": review_req.doc.rev,
|
||||
"review_submission": "enter",
|
||||
"review_content": "This is a review\nwith two lines",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "part-completed")
|
||||
self.assertTrue(review_req.doc.rev in review_req.review.name)
|
||||
|
||||
self.assertEqual(len(outbox), 2)
|
||||
self.assertTrue("secretary" in outbox[0]["To"])
|
||||
self.assertTrue("partially" in outbox[0]["Subject"].lower())
|
||||
self.assertTrue("new review request" in unicode(outbox[0]))
|
||||
|
||||
self.assertTrue(review_req.team.list_email in outbox[1]["To"])
|
||||
self.assertTrue("partial review" in outbox[1]["Subject"].lower())
|
||||
self.assertTrue("This is a review" in unicode(outbox[1]))
|
||||
|
||||
first_review = review_req.review
|
||||
first_reviewer = review_req.reviewer
|
||||
|
||||
|
||||
# complete
|
||||
review_req = ReviewRequest.objects.get(state="requested", doc=review_req.doc, team=review_req.team)
|
||||
self.assertEqual(review_req.reviewer, None)
|
||||
review_req.reviewer = first_reviewer # same reviewer, so we can test uniquification
|
||||
review_req.save()
|
||||
|
||||
url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": review_req.doc.name, "request_id": review_req.pk })
|
||||
|
||||
r = self.client.post(url, data={
|
||||
"result": ReviewResultName.objects.get(resultusedinreviewteam__team=review_req.team, slug="ready").pk,
|
||||
"state": ReviewRequestStateName.objects.get(slug="completed").pk,
|
||||
"reviewed_rev": review_req.doc.rev,
|
||||
"review_submission": "enter",
|
||||
"review_content": "This is another review\nwith\nthree lines",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req = reload_db_objects(review_req)
|
||||
self.assertEqual(review_req.state_id, "completed")
|
||||
self.assertTrue(review_req.doc.rev in review_req.review.name)
|
||||
second_review = review_req.review
|
||||
self.assertTrue(first_review.name != second_review.name)
|
||||
self.assertTrue(second_review.name.endswith("-2")) # uniquified
|
|
@ -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))
|
||||
|
||||
|
|
556
ietf/doc/views_review.py
Normal file
556
ietf/doc/views_review.py
Normal file
|
@ -0,0 +1,556 @@
|
|||
import datetime, os, email.utils
|
||||
|
||||
from django.http import HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django import forms
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.html import mark_safe
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.template.loader import render_to_string
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
|
||||
from ietf.doc.models import (Document, NewRevisionDocEvent, State, DocAlias,
|
||||
LastCallDocEvent, ReviewRequestDocEvent)
|
||||
from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName
|
||||
from ietf.review.models import ReviewRequest, TypeUsedInReviewTeam
|
||||
from ietf.group.models import Group
|
||||
from ietf.person.fields import PersonEmailChoiceField, SearchablePersonField
|
||||
from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role
|
||||
from ietf.review.utils import (active_review_teams, assign_review_request_to_reviewer,
|
||||
can_request_review_of_doc, can_manage_review_requests_for_team,
|
||||
email_review_request_change, make_new_review_request_from_existing,
|
||||
close_review_request_states, close_review_request,
|
||||
setup_reviewer_field)
|
||||
from ietf.review import mailarch
|
||||
from ietf.utils.fields import DatepickerDateField
|
||||
from ietf.utils.text import strip_prefix
|
||||
from ietf.utils.textupload import get_cleaned_text_file_content
|
||||
from ietf.utils.mail import send_mail
|
||||
|
||||
def clean_doc_revision(doc, rev):
|
||||
if rev:
|
||||
rev = rev.rjust(2, "0")
|
||||
|
||||
if not NewRevisionDocEvent.objects.filter(doc=doc, rev=rev).exists():
|
||||
raise forms.ValidationError("Could not find revision \"{}\" of the document.".format(rev))
|
||||
|
||||
return rev
|
||||
|
||||
class RequestReviewForm(forms.ModelForm):
|
||||
team = forms.ModelMultipleChoiceField(queryset=Group.objects.all(), widget=forms.CheckboxSelectMultiple)
|
||||
deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" })
|
||||
|
||||
class Meta:
|
||||
model = ReviewRequest
|
||||
fields = ('requested_by', 'type', 'deadline', 'requested_rev')
|
||||
|
||||
def __init__(self, user, doc, *args, **kwargs):
|
||||
super(RequestReviewForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.doc = doc
|
||||
|
||||
f = self.fields["team"]
|
||||
f.queryset = active_review_teams()
|
||||
f.initial = [group.pk for group in f.queryset if can_manage_review_requests_for_team(user, group, allow_non_team_personnel=False)]
|
||||
|
||||
self.fields['type'].queryset = self.fields['type'].queryset.filter(used=True, typeusedinreviewteam__team__in=self.fields["team"].queryset).distinct()
|
||||
self.fields['type'].widget = forms.RadioSelect(choices=[t for t in self.fields['type'].choices if t[0]])
|
||||
|
||||
self.fields["requested_rev"].label = "Document revision"
|
||||
|
||||
if has_role(user, "Secretariat"):
|
||||
self.fields["requested_by"] = SearchablePersonField()
|
||||
else:
|
||||
self.fields["requested_by"].widget = forms.HiddenInput()
|
||||
self.fields["requested_by"].initial = user.person.pk
|
||||
|
||||
def clean_deadline(self):
|
||||
v = self.cleaned_data.get('deadline')
|
||||
if v < datetime.date.today():
|
||||
raise forms.ValidationError("Select today or a date in the future.")
|
||||
return v
|
||||
|
||||
def clean_requested_rev(self):
|
||||
return clean_doc_revision(self.doc, self.cleaned_data.get("requested_rev"))
|
||||
|
||||
def clean(self):
|
||||
chosen_type = self.cleaned_data.get("type")
|
||||
chosen_teams = self.cleaned_data.get("team")
|
||||
|
||||
if chosen_type and chosen_teams:
|
||||
for t in chosen_teams:
|
||||
if not TypeUsedInReviewTeam.objects.filter(type=chosen_type, team=t).exists():
|
||||
self.add_error("type", "{} does not use the review type {}.".format(t.name, chosen_type.name))
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
@login_required
|
||||
def request_review(request, name):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
|
||||
if not can_request_review_of_doc(request.user, doc):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
lc_ends = None
|
||||
e = doc.latest_event(LastCallDocEvent, type="sent_last_call")
|
||||
if e and e.expires >= now:
|
||||
lc_ends = e.expires
|
||||
|
||||
scheduled_for_telechat = doc.telechat_date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = RequestReviewForm(request.user, doc, request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
teams = form.cleaned_data["team"]
|
||||
for team in teams:
|
||||
review_req = form.save(commit=False)
|
||||
review_req.doc = doc
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="requested", used=True)
|
||||
review_req.team = team
|
||||
review_req.save()
|
||||
|
||||
ReviewRequestDocEvent.objects.create(
|
||||
type="requested_review",
|
||||
doc=doc,
|
||||
by=request.user.person,
|
||||
desc="Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()),
|
||||
time=review_req.time,
|
||||
review_request=review_req,
|
||||
state=None,
|
||||
)
|
||||
|
||||
return redirect('doc_view', name=doc.name)
|
||||
|
||||
else:
|
||||
if lc_ends:
|
||||
review_type = "lc"
|
||||
elif scheduled_for_telechat:
|
||||
review_type = "telechat"
|
||||
else:
|
||||
review_type = "early"
|
||||
|
||||
form = RequestReviewForm(request.user, doc, initial={ "type": review_type })
|
||||
|
||||
return render(request, 'doc/review/request_review.html', {
|
||||
'doc': doc,
|
||||
'form': form,
|
||||
'lc_ends': lc_ends,
|
||||
'lc_ends_days': (lc_ends - now).days if lc_ends else None,
|
||||
'scheduled_for_telechat': scheduled_for_telechat,
|
||||
'scheduled_for_telechat_days': (scheduled_for_telechat - now.date()).days if scheduled_for_telechat else None,
|
||||
})
|
||||
|
||||
def review_request(request, name, request_id):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id)
|
||||
|
||||
is_reviewer = review_req.reviewer and user_is_person(request.user, review_req.reviewer.person)
|
||||
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
|
||||
|
||||
can_close_request = (review_req.state_id in ["requested", "accepted"]
|
||||
and (is_authorized_in_doc_stream(request.user, doc)
|
||||
or can_manage_request))
|
||||
|
||||
can_assign_reviewer = (review_req.state_id in ["requested", "accepted"]
|
||||
and can_manage_request)
|
||||
|
||||
can_accept_reviewer_assignment = (review_req.state_id == "requested"
|
||||
and review_req.reviewer
|
||||
and (is_reviewer or can_manage_request))
|
||||
|
||||
can_reject_reviewer_assignment = (review_req.state_id in ["requested", "accepted"]
|
||||
and review_req.reviewer
|
||||
and (is_reviewer or can_manage_request))
|
||||
|
||||
can_complete_review = (review_req.state_id in ["requested", "accepted"]
|
||||
and review_req.reviewer
|
||||
and (is_reviewer or can_manage_request))
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "accept" and can_accept_reviewer_assignment:
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="accepted")
|
||||
review_req.save()
|
||||
|
||||
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
|
||||
|
||||
return render(request, 'doc/review/review_request.html', {
|
||||
'doc': doc,
|
||||
'review_req': review_req,
|
||||
'can_close_request': can_close_request,
|
||||
'can_reject_reviewer_assignment': can_reject_reviewer_assignment,
|
||||
'can_assign_reviewer': can_assign_reviewer,
|
||||
'can_accept_reviewer_assignment': can_accept_reviewer_assignment,
|
||||
'can_complete_review': can_complete_review,
|
||||
})
|
||||
|
||||
|
||||
class CloseReviewRequestForm(forms.Form):
|
||||
close_reason = forms.ModelChoiceField(queryset=close_review_request_states(), widget=forms.RadioSelect, empty_label=None)
|
||||
|
||||
def __init__(self, can_manage_request, *args, **kwargs):
|
||||
super(CloseReviewRequestForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if not can_manage_request:
|
||||
self.fields["close_reason"].queryset = self.fields["close_reason"].queryset.filter(slug__in=["withdrawn"])
|
||||
|
||||
if len(self.fields["close_reason"].queryset) == 1:
|
||||
self.fields["close_reason"].initial = self.fields["close_reason"].queryset.first().pk
|
||||
self.fields["close_reason"].widget = forms.HiddenInput()
|
||||
|
||||
|
||||
@login_required
|
||||
def close_request(request, name, request_id):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
|
||||
|
||||
can_request = is_authorized_in_doc_stream(request.user, doc)
|
||||
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
|
||||
|
||||
if not (can_request or can_manage_request):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
if request.method == "POST":
|
||||
form = CloseReviewRequestForm(can_manage_request, request.POST)
|
||||
if form.is_valid():
|
||||
close_review_request(request, review_req, form.cleaned_data["close_reason"])
|
||||
|
||||
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
|
||||
else:
|
||||
form = CloseReviewRequestForm(can_manage_request)
|
||||
|
||||
return render(request, 'doc/review/close_request.html', {
|
||||
'doc': doc,
|
||||
'review_req': review_req,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
|
||||
class AssignReviewerForm(forms.Form):
|
||||
reviewer = PersonEmailChoiceField(empty_label="(None)", required=False)
|
||||
|
||||
def __init__(self, review_req, *args, **kwargs):
|
||||
super(AssignReviewerForm, self).__init__(*args, **kwargs)
|
||||
setup_reviewer_field(self.fields["reviewer"], review_req)
|
||||
|
||||
|
||||
@login_required
|
||||
def assign_reviewer(request, name, request_id):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
|
||||
|
||||
if not can_manage_review_requests_for_team(request.user, review_req.team):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "assign":
|
||||
form = AssignReviewerForm(review_req, request.POST)
|
||||
if form.is_valid():
|
||||
reviewer = form.cleaned_data["reviewer"]
|
||||
assign_review_request_to_reviewer(request, review_req, reviewer)
|
||||
|
||||
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
|
||||
else:
|
||||
form = AssignReviewerForm(review_req)
|
||||
|
||||
return render(request, 'doc/review/assign_reviewer.html', {
|
||||
'doc': doc,
|
||||
'review_req': review_req,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
class RejectReviewerAssignmentForm(forms.Form):
|
||||
message_to_secretary = forms.CharField(widget=forms.Textarea, required=False, help_text="Optional explanation of rejection, will be emailed to team secretary if filled in")
|
||||
|
||||
@login_required
|
||||
def reject_reviewer_assignment(request, name, request_id):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
|
||||
|
||||
if not review_req.reviewer:
|
||||
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
|
||||
|
||||
is_reviewer = user_is_person(request.user, review_req.reviewer.person)
|
||||
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
|
||||
|
||||
if not (is_reviewer or can_manage_request):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "reject":
|
||||
form = RejectReviewerAssignmentForm(request.POST)
|
||||
if form.is_valid():
|
||||
# reject the request
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="rejected")
|
||||
review_req.save()
|
||||
|
||||
ReviewRequestDocEvent.objects.create(
|
||||
type="closed_review_request",
|
||||
doc=review_req.doc,
|
||||
by=request.user.person,
|
||||
desc="Assignment of request for {} review by {} to {} was rejected".format(
|
||||
review_req.type.name,
|
||||
review_req.team.acronym.upper(),
|
||||
review_req.reviewer.person,
|
||||
),
|
||||
review_request=review_req,
|
||||
state=review_req.state,
|
||||
)
|
||||
|
||||
# make a new unassigned review request
|
||||
new_review_req = make_new_review_request_from_existing(review_req)
|
||||
new_review_req.save()
|
||||
|
||||
msg = render_to_string("review/reviewer_assignment_rejected.txt", {
|
||||
"by": request.user.person,
|
||||
"message_to_secretary": form.cleaned_data.get("message_to_secretary")
|
||||
})
|
||||
|
||||
email_review_request_change(request, review_req, "Reviewer assignment rejected", msg, by=request.user.person, notify_secretary=True, notify_reviewer=True, notify_requested_by=False)
|
||||
|
||||
return redirect(review_request, name=new_review_req.doc.name, request_id=new_review_req.pk)
|
||||
else:
|
||||
form = RejectReviewerAssignmentForm()
|
||||
|
||||
return render(request, 'doc/review/reject_reviewer_assignment.html', {
|
||||
'doc': doc,
|
||||
'review_req': review_req,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
class CompleteReviewForm(forms.Form):
|
||||
state = forms.ModelChoiceField(queryset=ReviewRequestStateName.objects.filter(slug__in=("completed", "part-completed")).order_by("-order"), widget=forms.RadioSelect, initial="completed")
|
||||
reviewed_rev = forms.CharField(label="Reviewed revision", max_length=4)
|
||||
result = forms.ModelChoiceField(queryset=ReviewResultName.objects.filter(used=True), widget=forms.RadioSelect, empty_label=None)
|
||||
ACTIONS = [
|
||||
("enter", "Enter review content (automatically posts to {mailing_list})"),
|
||||
("upload", "Upload review content in text file (automatically posts to {mailing_list})"),
|
||||
("link", "Link to review message already sent to {mailing_list}"),
|
||||
]
|
||||
review_submission = forms.ChoiceField(choices=ACTIONS, widget=forms.RadioSelect)
|
||||
|
||||
review_url = forms.URLField(label="Link to message", required=False)
|
||||
review_file = forms.FileField(label="Text file to upload", required=False)
|
||||
review_content = forms.CharField(widget=forms.Textarea, required=False)
|
||||
cc = forms.CharField(required=False, help_text="Email addresses to send to in addition to the review team list")
|
||||
|
||||
def __init__(self, review_req, *args, **kwargs):
|
||||
self.review_req = review_req
|
||||
|
||||
super(CompleteReviewForm, self).__init__(*args, **kwargs)
|
||||
|
||||
doc = self.review_req.doc
|
||||
|
||||
known_revisions = NewRevisionDocEvent.objects.filter(doc=doc).order_by("time", "id").values_list("rev", flat=True)
|
||||
|
||||
self.fields["state"].choices = [
|
||||
(slug, "{} - extra reviewer is to be assigned".format(label)) if slug == "part-completed" else (slug, label)
|
||||
for slug, label in self.fields["state"].choices
|
||||
]
|
||||
|
||||
self.fields["reviewed_rev"].help_text = mark_safe(
|
||||
" ".join("<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)
|
||||
self.fields["review_submission"].choices = [
|
||||
(k, label.format(mailing_list=review_req.team.list_email or "[error: team has no mailing list set]"))
|
||||
for k, label in self.fields["review_submission"].choices
|
||||
]
|
||||
|
||||
def clean_reviewed_rev(self):
|
||||
return clean_doc_revision(self.review_req.doc, self.cleaned_data.get("reviewed_rev"))
|
||||
|
||||
def clean_review_content(self):
|
||||
return self.cleaned_data["review_content"].replace("\r", "")
|
||||
|
||||
def clean_review_file(self):
|
||||
return get_cleaned_text_file_content(self.cleaned_data["review_file"])
|
||||
|
||||
def clean(self):
|
||||
if "@" in self.review_req.reviewer.person.ascii:
|
||||
raise forms.ValidationError("Reviewer name must be filled in (the ASCII version is currently \"{}\" - since it contains an @ sign the name is probably still the original email address).".format(self.review_req.reviewer.person.ascii))
|
||||
|
||||
def require_field(f):
|
||||
if not self.cleaned_data.get(f):
|
||||
self.add_error(f, ValidationError("You must fill in this field."))
|
||||
|
||||
submission_method = self.cleaned_data.get("review_submission")
|
||||
if submission_method == "enter":
|
||||
require_field("review_content")
|
||||
elif submission_method == "upload":
|
||||
require_field("review_file")
|
||||
elif submission_method == "link":
|
||||
require_field("review_url")
|
||||
require_field("review_content")
|
||||
|
||||
@login_required
|
||||
def complete_review(request, name, request_id):
|
||||
doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
|
||||
|
||||
if not review_req.reviewer:
|
||||
return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk)
|
||||
|
||||
is_reviewer = user_is_person(request.user, review_req.reviewer.person)
|
||||
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
|
||||
|
||||
if not (is_reviewer or can_manage_request):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
if request.method == "POST":
|
||||
form = CompleteReviewForm(review_req, request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
review_submission = form.cleaned_data['review_submission']
|
||||
|
||||
# create review doc
|
||||
for i in range(1, 100):
|
||||
name_components = [
|
||||
"review",
|
||||
strip_prefix(review_req.doc.name, "draft-"),
|
||||
form.cleaned_data["reviewed_rev"],
|
||||
review_req.team.acronym,
|
||||
review_req.type.slug,
|
||||
review_req.reviewer.person.ascii_parts()[3],
|
||||
datetime.date.today().isoformat(),
|
||||
]
|
||||
if i > 1:
|
||||
name_components.append(str(i))
|
||||
|
||||
name = "-".join(c for c in name_components if c).lower()
|
||||
if not Document.objects.filter(name=name).exists():
|
||||
review = Document.objects.create(name=name)
|
||||
DocAlias.objects.create(document=review, name=review.name)
|
||||
break
|
||||
|
||||
review.type = DocTypeName.objects.get(slug="review")
|
||||
review.rev = "00"
|
||||
review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, form.cleaned_data["reviewed_rev"])
|
||||
review.group = review_req.team
|
||||
if review_submission == "link":
|
||||
review.external_url = form.cleaned_data['review_url']
|
||||
|
||||
e = NewRevisionDocEvent.objects.create(
|
||||
type="new_revision",
|
||||
doc=review,
|
||||
by=request.user.person,
|
||||
rev=review.rev,
|
||||
desc='New revision available',
|
||||
time=review.time,
|
||||
)
|
||||
|
||||
review.set_state(State.objects.get(type="review", slug="active"))
|
||||
|
||||
review.save_with_history([e])
|
||||
|
||||
# save file on disk
|
||||
if review_submission == "upload":
|
||||
encoded_content = form.cleaned_data['review_file']
|
||||
else:
|
||||
encoded_content = form.cleaned_data['review_content'].encode("utf-8")
|
||||
|
||||
filename = os.path.join(review.get_file_path(), '{}.txt'.format(review.name, review.rev))
|
||||
with open(filename, 'wb') as destination:
|
||||
destination.write(encoded_content)
|
||||
|
||||
# close review request
|
||||
review_req.state = form.cleaned_data["state"]
|
||||
review_req.reviewed_rev = form.cleaned_data["reviewed_rev"]
|
||||
review_req.result = form.cleaned_data["result"]
|
||||
review_req.review = review
|
||||
review_req.save()
|
||||
|
||||
need_to_email_review = review_submission != "link" and review_req.team.list_email
|
||||
|
||||
desc = "Request for {} review by {} {}: {}. Reviewer: {}.".format(
|
||||
review_req.type.name,
|
||||
review_req.team.acronym.upper(),
|
||||
review_req.state.name,
|
||||
review_req.result.name,
|
||||
review_req.reviewer.person,
|
||||
)
|
||||
if need_to_email_review:
|
||||
desc += " " + "Sent review to list."
|
||||
|
||||
close_event = ReviewRequestDocEvent.objects.create(
|
||||
type="closed_review_request",
|
||||
doc=review_req.doc,
|
||||
by=request.user.person,
|
||||
desc=desc,
|
||||
review_request=review_req,
|
||||
state=review_req.state,
|
||||
)
|
||||
|
||||
if review_req.state_id == "part-completed":
|
||||
existing_open_reqs = ReviewRequest.objects.filter(doc=review_req.doc, team=review_req.team, state__in=("requested", "accepted"))
|
||||
|
||||
new_review_req_url = new_review_req = None
|
||||
if not existing_open_reqs:
|
||||
new_review_req = make_new_review_request_from_existing(review_req)
|
||||
new_review_req.save()
|
||||
|
||||
new_review_req_url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": new_review_req.doc.name, "request_id": new_review_req.pk })
|
||||
new_review_req_url = request.build_absolute_uri(new_review_req_url)
|
||||
|
||||
subject = "Review of {}-{} completed partially".format(review_req.doc.name, review_req.reviewed_rev)
|
||||
|
||||
msg = render_to_string("review/partially_completed_review.txt", {
|
||||
"new_review_req_url": new_review_req_url,
|
||||
"existing_open_reqs": existing_open_reqs,
|
||||
"by": request.user.person,
|
||||
})
|
||||
|
||||
email_review_request_change(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=False)
|
||||
|
||||
if need_to_email_review:
|
||||
# email the review
|
||||
subject = "{} of {}-{}".format("Partial review" if review_req.state_id == "part-completed" else "Review", review_req.doc.name, review_req.reviewed_rev)
|
||||
msg = send_mail(request, [(review_req.team.name, review_req.team.list_email)], None,
|
||||
subject,
|
||||
"review/completed_review.txt", {
|
||||
"review_req": review_req,
|
||||
"content": encoded_content.decode("utf-8"),
|
||||
},
|
||||
cc=form.cleaned_data["cc"])
|
||||
|
||||
list_name = mailarch.list_name_from_email(review_req.team.list_email)
|
||||
if list_name:
|
||||
review.external_url = mailarch.construct_message_url(list_name, email.utils.unquote(msg["Message-ID"]))
|
||||
review.save_with_history([close_event])
|
||||
|
||||
return redirect("doc_view", name=review_req.review.name)
|
||||
else:
|
||||
form = CompleteReviewForm(review_req)
|
||||
|
||||
mail_archive_query_urls = mailarch.construct_query_urls(review_req)
|
||||
|
||||
return render(request, 'doc/review/complete_review.html', {
|
||||
'doc': doc,
|
||||
'review_req': review_req,
|
||||
'form': form,
|
||||
'mail_archive_query_urls': mail_archive_query_urls,
|
||||
})
|
||||
|
||||
def search_mail_archive(request, name, request_id):
|
||||
#doc = get_object_or_404(Document, name=name)
|
||||
review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"])
|
||||
|
||||
is_reviewer = user_is_person(request.user, review_req.reviewer.person)
|
||||
can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team)
|
||||
|
||||
if not (is_reviewer or can_manage_request):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
res = mailarch.construct_query_urls(review_req, query=request.GET.get("query"))
|
||||
if not res:
|
||||
return JsonResponse({ "error": "Couldn't do lookup in mail archive - don't know where to look"})
|
||||
|
||||
MAX_RESULTS = 30
|
||||
|
||||
try:
|
||||
res["messages"] = mailarch.retrieve_messages(res["query_data_url"])[:MAX_RESULTS]
|
||||
except Exception as e:
|
||||
res["error"] = "Retrieval from mail archive failed: {}".format(unicode(e))
|
||||
# raise # useful when debugging
|
||||
|
||||
return JsonResponse(res)
|
||||
|
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()
|
||||
|
||||
|
|
414
ietf/group/tests_review.py
Normal file
414
ietf/group/tests_review.py
Normal file
|
@ -0,0 +1,414 @@
|
|||
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.iesg.models import TelechatDate
|
||||
from ietf.person.models import Email, Person
|
||||
from ietf.review.models import ReviewRequest, ReviewerSettings, UnavailablePeriod
|
||||
from ietf.review.utils import suggested_review_requests_for_team
|
||||
from ietf.review.utils import review_requests_needing_reviewer_reminder, email_reviewer_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="plain"),
|
||||
)
|
||||
|
||||
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 })
|
||||
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
url = urlreverse(ietf.group.views_review.manage_review_requests, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id })
|
||||
|
||||
review_req2 = ReviewRequest.objects.create(
|
||||
doc=review_req1.doc,
|
||||
team=review_req1.team,
|
||||
type_id="early",
|
||||
deadline=datetime.date.today() + datetime.timedelta(days=30),
|
||||
state_id="accepted",
|
||||
reviewer=review_req1.reviewer,
|
||||
requested_by=Person.objects.get(user__username="plain"),
|
||||
)
|
||||
|
||||
review_req3 = ReviewRequest.objects.create(
|
||||
doc=review_req1.doc,
|
||||
team=review_req1.team,
|
||||
type_id="early",
|
||||
deadline=datetime.date.today() + datetime.timedelta(days=30),
|
||||
state_id="requested",
|
||||
requested_by=Person.objects.get(user__username="plain"),
|
||||
)
|
||||
|
||||
# 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(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req1.doc.name in unicontent(r))
|
||||
|
||||
# can't save: conflict
|
||||
new_reviewer = Email.objects.get(role__name="reviewer", role__group=group, person__user__username="marschairman")
|
||||
# provoke conflict by posting bogus data
|
||||
r = self.client.post(url, {
|
||||
"reviewrequest": [str(review_req1.pk), str(review_req2.pk), str(123456)],
|
||||
|
||||
# close
|
||||
"r{}-existing_reviewer".format(review_req1.pk): "123456",
|
||||
"r{}-action".format(review_req1.pk): "close",
|
||||
"r{}-close".format(review_req1.pk): "no-response",
|
||||
|
||||
# assign
|
||||
"r{}-existing_reviewer".format(review_req2.pk): "123456",
|
||||
"r{}-action".format(review_req2.pk): "assign",
|
||||
"r{}-reviewer".format(review_req2.pk): new_reviewer.pk,
|
||||
|
||||
"action": "save-continue",
|
||||
})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
content = unicontent(r).lower()
|
||||
self.assertTrue("1 request closed" in content)
|
||||
self.assertTrue("1 request opened" in content)
|
||||
self.assertTrue("2 requests changed assignment" in content)
|
||||
|
||||
# close and assign
|
||||
new_reviewer = Email.objects.get(role__name="reviewer", role__group=group, person__user__username="marschairman")
|
||||
r = self.client.post(url, {
|
||||
"reviewrequest": [str(review_req1.pk), str(review_req2.pk), str(review_req3.pk)],
|
||||
|
||||
# close
|
||||
"r{}-existing_reviewer".format(review_req1.pk): review_req1.reviewer_id or "",
|
||||
"r{}-action".format(review_req1.pk): "close",
|
||||
"r{}-close".format(review_req1.pk): "no-response",
|
||||
|
||||
# assign
|
||||
"r{}-existing_reviewer".format(review_req2.pk): review_req2.reviewer_id or "",
|
||||
"r{}-action".format(review_req2.pk): "assign",
|
||||
"r{}-reviewer".format(review_req2.pk): new_reviewer.pk,
|
||||
|
||||
# no change
|
||||
"r{}-existing_reviewer".format(review_req3.pk): review_req3.reviewer_id or "",
|
||||
"r{}-action".format(review_req3.pk): "",
|
||||
"r{}-close".format(review_req3.pk): "no-response",
|
||||
"r{}-reviewer".format(review_req3.pk): "",
|
||||
|
||||
"action": "save",
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
review_req1, review_req2, review_req3 = reload_db_objects(review_req1, review_req2, review_req3)
|
||||
self.assertEqual(review_req1.state_id, "no-response")
|
||||
self.assertEqual(review_req2.state_id, "requested")
|
||||
self.assertEqual(review_req2.reviewer, new_reviewer)
|
||||
self.assertEqual(review_req3.state_id, "requested")
|
||||
|
||||
def test_email_open_review_assignments(self):
|
||||
doc = make_test_data()
|
||||
review_req1 = make_review_data(doc)
|
||||
|
||||
group = review_req1.team
|
||||
|
||||
url = urlreverse(ietf.group.views_review.email_open_review_assignments, kwargs={ 'acronym': group.acronym })
|
||||
|
||||
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 unicode(outbox[0]))
|
||||
|
||||
def test_change_reviewer_settings(self):
|
||||
doc = make_test_data()
|
||||
|
||||
reviewer = Person.objects.get(name="Plain Man")
|
||||
|
||||
review_req = make_review_data(doc)
|
||||
review_req.reviewer = reviewer.email_set.first()
|
||||
review_req.save()
|
||||
|
||||
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())
|
||||
self.assertTrue("frequency changed", unicode(outbox[0]).lower())
|
||||
self.assertTrue("skip next", unicode(outbox[0]).lower())
|
||||
|
||||
# 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)
|
||||
self.assertTrue(start_date.isoformat(), unicode(outbox[0]).lower())
|
||||
self.assertTrue("indefinite", unicode(outbox[0]).lower())
|
||||
|
||||
# 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)
|
||||
self.assertTrue(start_date.isoformat(), unicode(outbox[0]).lower())
|
||||
self.assertTrue("indefinite", unicode(outbox[0]).lower())
|
||||
|
||||
# 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)
|
||||
self.assertTrue(start_date.isoformat(), unicode(outbox[0]).lower())
|
||||
self.assertTrue(end_date.isoformat(), unicode(outbox[0]).lower())
|
||||
|
||||
def test_reviewer_reminders(self):
|
||||
doc = make_test_data()
|
||||
|
||||
reviewer = Person.objects.get(name="Plain Man")
|
||||
|
||||
review_req = make_review_data(doc)
|
||||
|
||||
settings = ReviewerSettings.objects.get(team=review_req.team, person=reviewer)
|
||||
settings.remind_days_before_deadline = 6
|
||||
settings.save()
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
review_req.reviewer = reviewer.email_set.first()
|
||||
review_req.deadline = today + datetime.timedelta(days=settings.remind_days_before_deadline)
|
||||
review_req.save()
|
||||
|
||||
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), [])
|
||||
|
||||
empty_outbox()
|
||||
email_reviewer_reminder(review_req)
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(review_req.doc_id in unicode(outbox[0]))
|
|
@ -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,10 @@ 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/$', 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),
|
||||
url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'),
|
||||
)
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import os
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
|
@ -9,8 +11,8 @@ from ietf.person.models import Email
|
|||
from ietf.utils.history import get_history_object_for, copy_many_to_many_for_history
|
||||
from ietf.ietfauth.utils import has_role
|
||||
from ietf.community.models import CommunityList, SearchRule
|
||||
from ietf.community.utils import reset_name_contains_index_for_rule
|
||||
from ietf.doc.models import State
|
||||
from ietf.community.utils import reset_name_contains_index_for_rule, can_manage_community_list
|
||||
from ietf.doc.models import Document, State
|
||||
|
||||
|
||||
def save_group_in_history(group):
|
||||
|
@ -149,3 +151,86 @@ def setup_default_community_list_for_group(group):
|
|||
state=State.objects.get(slug="active", type="draft"),
|
||||
)
|
||||
reset_name_contains_index_for_rule(related_docs_rule)
|
||||
|
||||
def get_group_materials(group):
|
||||
return Document.objects.filter(
|
||||
group=group,
|
||||
type__in=group.features.material_types
|
||||
).exclude(states__slug__in=['deleted','archived'])
|
||||
|
||||
def construct_group_menu_context(request, group, selected, group_type, others):
|
||||
"""Return context with info for the group menu filled in."""
|
||||
kwargs = dict(acronym=group.acronym)
|
||||
if group_type:
|
||||
kwargs["group_type"] = group_type
|
||||
|
||||
# menu entries
|
||||
entries = []
|
||||
if group.features.has_documents:
|
||||
entries.append(("Documents", urlreverse("ietf.group.views.group_documents", kwargs=kwargs)))
|
||||
if group.features.has_chartering_process:
|
||||
entries.append(("Charter", urlreverse("group_charter", kwargs=kwargs)))
|
||||
else:
|
||||
entries.append(("About", urlreverse("group_about", kwargs=kwargs)))
|
||||
if group.features.has_materials and get_group_materials(group).exists():
|
||||
entries.append(("Materials", urlreverse("ietf.group.views.materials", kwargs=kwargs)))
|
||||
if group.features.has_reviews:
|
||||
import ietf.group.views_review
|
||||
entries.append(("Review requests", urlreverse(ietf.group.views_review.review_requests, kwargs=kwargs)))
|
||||
entries.append(("Reviewers", urlreverse(ietf.group.views_review.reviewer_overview, kwargs=kwargs)))
|
||||
if group.type_id in ('rg','wg','team'):
|
||||
entries.append(("Meetings", urlreverse("ietf.group.views.meetings", kwargs=kwargs)))
|
||||
entries.append(("History", urlreverse("ietf.group.views.history", kwargs=kwargs)))
|
||||
entries.append(("Photos", urlreverse("ietf.group.views.group_photos", kwargs=kwargs)))
|
||||
entries.append(("Email expansions", urlreverse("ietf.group.views.email", kwargs=kwargs)))
|
||||
if group.list_archive.startswith("http:") or group.list_archive.startswith("https:") or group.list_archive.startswith("ftp:"):
|
||||
if 'mailarchive.ietf.org' in group.list_archive:
|
||||
entries.append(("List archive", urlreverse("ietf.group.views.derived_archives", kwargs=kwargs)))
|
||||
else:
|
||||
entries.append((mark_safe("List archive »"), group.list_archive))
|
||||
if group.has_tools_page():
|
||||
entries.append((mark_safe("Tools »"), "https://tools.ietf.org/%s/%s/" % (group.type_id, group.acronym)))
|
||||
|
||||
# actions
|
||||
actions = []
|
||||
|
||||
is_admin = group.has_role(request.user, group.features.admin_roles)
|
||||
can_manage = can_manage_group(request.user, group)
|
||||
|
||||
if group.features.has_milestones:
|
||||
if group.state_id != "proposed" and (is_admin or can_manage):
|
||||
actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs)))
|
||||
|
||||
if group.features.has_documents:
|
||||
clist = CommunityList.objects.filter(group=group).first()
|
||||
if clist and can_manage_community_list(request.user, clist):
|
||||
import ietf.community.views
|
||||
actions.append((u'Manage document list', urlreverse(ietf.community.views.manage_list, kwargs=kwargs)))
|
||||
|
||||
if group.features.has_materials and can_manage_materials(request.user, group):
|
||||
actions.append((u"Upload material", urlreverse("ietf.doc.views_material.choose_material_type", kwargs=kwargs)))
|
||||
|
||||
if group.features.has_reviews:
|
||||
import ietf.group.views_review
|
||||
actions.append((u"Manage review requests", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=kwargs)))
|
||||
|
||||
if group.state_id != "conclude" and (is_admin or can_manage):
|
||||
actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs)))
|
||||
|
||||
if group.features.customize_workflow and (is_admin or can_manage):
|
||||
actions.append((u"Customize workflow", urlreverse("ietf.group.views_edit.customize_workflow", kwargs=kwargs)))
|
||||
|
||||
if group.state_id in ("active", "dormant") and not group.type_id in ["sdo", "rfcedtyp", "isoc", ] and can_manage:
|
||||
actions.append((u"Request closing group", urlreverse("ietf.group.views_edit.conclude", kwargs=kwargs)))
|
||||
|
||||
d = {
|
||||
"group": group,
|
||||
"selected_menu_entry": selected,
|
||||
"menu_entries": entries,
|
||||
"menu_actions": actions,
|
||||
"group_type": group_type,
|
||||
}
|
||||
|
||||
d.update(others)
|
||||
|
||||
return d
|
||||
|
|
|
@ -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(roles_for_group_type(self.group_type))
|
||||
- set(strip_suffix(attr, "_roles") for attr in self.fields if attr.endswith("_roles")))
|
||||
for r in role_fields_to_remove:
|
||||
del self.fields[r + "_roles"]
|
||||
|
||||
def clean_acronym(self):
|
||||
# Changing the acronym of an already existing group will cause 404s all
|
||||
# over the place, loose history, and generally muck up a lot of
|
||||
|
@ -219,7 +236,8 @@ def edit(request, group_type=None, acronym=None, action="edit"):
|
|||
group = get_group_or_404(acronym, group_type)
|
||||
if not group_type and group:
|
||||
group_type = group.type_id
|
||||
if not (can_manage_group(request.user, group) or group.has_role(request.user, "chair")):
|
||||
if not (can_manage_group(request.user, group)
|
||||
or group.has_role(request.user, group.features.admin_roles)):
|
||||
return HttpResponseForbidden("You don't have permission to access this view")
|
||||
|
||||
if request.method == 'POST':
|
||||
|
@ -283,10 +301,18 @@ def edit(request, group_type=None, acronym=None, action="edit"):
|
|||
personnel_change_text=""
|
||||
changed_personnel = set()
|
||||
# update roles
|
||||
for attr, slug, title in [('ad','ad','Shepherding AD'), ('chairs', 'chair', "Chairs"), ('secretaries', 'secr', "Secretaries"), ('techadv', 'techadv', "Tech Advisors"), ('delegates', 'delegate', "Delegates")]:
|
||||
for attr, f in form.fields.iteritems():
|
||||
if not (attr.endswith("_roles") or attr == "ad"):
|
||||
continue
|
||||
|
||||
slug = attr
|
||||
slug = strip_suffix(slug, "_roles")
|
||||
|
||||
title = f.label
|
||||
|
||||
new = clean[attr]
|
||||
if attr == 'ad':
|
||||
new = [ new.role_email('ad'),] if new else []
|
||||
new = [ new.role_email('ad') ] if new else []
|
||||
old = Email.objects.filter(role__group=group, role__name=slug).select_related("person")
|
||||
if set(new) != set(old):
|
||||
changes.append((attr, new, desc(title,
|
||||
|
@ -345,10 +371,6 @@ def edit(request, group_type=None, acronym=None, action="edit"):
|
|||
init = dict(name=group.name,
|
||||
acronym=group.acronym,
|
||||
state=group.state,
|
||||
chairs=Email.objects.filter(role__group=group, role__name="chair"),
|
||||
secretaries=Email.objects.filter(role__group=group, role__name="secr"),
|
||||
techadv=Email.objects.filter(role__group=group, role__name="techadv"),
|
||||
delegates=Email.objects.filter(role__group=group, role__name="delegate"),
|
||||
ad=ad_role and ad_role.person and ad_role.person.id,
|
||||
parent=group.parent.id if group.parent else None,
|
||||
list_email=group.list_email if group.list_email else None,
|
||||
|
@ -356,6 +378,9 @@ def edit(request, group_type=None, acronym=None, action="edit"):
|
|||
list_archive=group.list_archive if group.list_archive else None,
|
||||
urls=format_urls(group.groupurl_set.all()),
|
||||
)
|
||||
|
||||
for slug in roles_for_group_type(group_type):
|
||||
init[slug + "_roles"] = Email.objects.filter(role__group=group, role__name=slug)
|
||||
else:
|
||||
init = dict(ad=request.user.person.id if group_type == "wg" and has_role(request.user, "Area Director") else None,
|
||||
)
|
||||
|
@ -409,8 +434,8 @@ def customize_workflow(request, group_type=None, acronym=None):
|
|||
if not group.features.customize_workflow:
|
||||
raise Http404
|
||||
|
||||
if (not has_role(request.user, "Secretariat") and
|
||||
not group.role_set.filter(name="chair", person__user=request.user)):
|
||||
if not (can_manage_group(request.user, group)
|
||||
or group.has_role(request.user, group.features.admin_roles)):
|
||||
return HttpResponseForbidden("You don't have permission to access this view")
|
||||
|
||||
if group_type == "rg":
|
||||
|
|
532
ietf/group/views_review.py
Normal file
532
ietf/group/views_review.py
Normal file
|
@ -0,0 +1,532 @@
|
|||
import datetime, math, itertools
|
||||
from collections import defaultdict
|
||||
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
from django import forms
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from ietf.review.models import ReviewRequest, ReviewerSettings, UnavailablePeriod
|
||||
from ietf.review.utils import (can_manage_review_requests_for_team, close_review_request_states,
|
||||
extract_revision_ordered_review_requests_for_documents_and_replaced,
|
||||
assign_review_request_to_reviewer,
|
||||
close_review_request,
|
||||
setup_reviewer_field,
|
||||
suggested_review_requests_for_team,
|
||||
unavailable_periods_to_list,
|
||||
current_unavailable_periods_for_reviewers,
|
||||
email_reviewer_availability_change,
|
||||
reviewer_rotation_list,
|
||||
extract_review_request_data)
|
||||
from ietf.group.models import Role
|
||||
from ietf.group.utils import get_group_or_404, construct_group_menu_context
|
||||
from ietf.person.fields import PersonEmailChoiceField
|
||||
from ietf.name.models import ReviewRequestStateName
|
||||
from ietf.utils.mail import send_mail_text
|
||||
from ietf.utils.fields import DatepickerDateField
|
||||
from ietf.ietfauth.utils import user_is_person
|
||||
|
||||
|
||||
def review_requests(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_reviews:
|
||||
raise Http404
|
||||
|
||||
open_review_requests = list(ReviewRequest.objects.filter(
|
||||
team=group, state__in=("requested", "accepted")
|
||||
).prefetch_related("reviewer", "type", "state").order_by("-time", "-id"))
|
||||
|
||||
unavailable_periods = current_unavailable_periods_for_reviewers(group)
|
||||
for review_req in open_review_requests:
|
||||
if review_req.reviewer:
|
||||
review_req.reviewer_unavailable = any(p.availability == "unavailable"
|
||||
for p in unavailable_periods.get(review_req.reviewer.person_id, []))
|
||||
|
||||
open_review_requests = suggested_review_requests_for_team(group) + open_review_requests
|
||||
|
||||
today = datetime.date.today()
|
||||
for r in open_review_requests:
|
||||
r.due = max(0, (today - r.deadline).days)
|
||||
|
||||
closed_review_requests = ReviewRequest.objects.filter(
|
||||
team=group,
|
||||
).exclude(
|
||||
state__in=("requested", "accepted")
|
||||
).prefetch_related("reviewer", "type", "state", "doc").order_by("-time", "-id")
|
||||
|
||||
since_choices = [
|
||||
(None, "1 month"),
|
||||
("3m", "3 months"),
|
||||
("6m", "6 months"),
|
||||
("1y", "1 year"),
|
||||
("2y", "2 years"),
|
||||
("all", "All"),
|
||||
]
|
||||
since = request.GET.get("since", None)
|
||||
if since not in [key for key, label in since_choices]:
|
||||
since = None
|
||||
|
||||
if since != "all":
|
||||
date_limit = {
|
||||
None: datetime.timedelta(days=31),
|
||||
"3m": datetime.timedelta(days=31 * 3),
|
||||
"6m": datetime.timedelta(days=180),
|
||||
"1y": datetime.timedelta(days=365),
|
||||
"2y": datetime.timedelta(days=2 * 365),
|
||||
}[since]
|
||||
|
||||
closed_review_requests = closed_review_requests.filter(time__gte=datetime.date.today() - date_limit)
|
||||
|
||||
return render(request, 'group/review_requests.html',
|
||||
construct_group_menu_context(request, group, "review requests", group_type, {
|
||||
"open_review_requests": open_review_requests,
|
||||
"closed_review_requests": closed_review_requests,
|
||||
"since_choices": since_choices,
|
||||
"since": since,
|
||||
"can_manage_review_requests": can_manage_review_requests_for_team(request.user, group)
|
||||
}))
|
||||
|
||||
def reviewer_overview(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_reviews:
|
||||
raise Http404
|
||||
|
||||
can_manage = can_manage_review_requests_for_team(request.user, group)
|
||||
|
||||
reviewers = reviewer_rotation_list(group)
|
||||
|
||||
reviewer_settings = { s.person_id: s for s in ReviewerSettings.objects.filter(team=group) }
|
||||
unavailable_periods = defaultdict(list)
|
||||
for p in unavailable_periods_to_list().filter(team=group):
|
||||
unavailable_periods[p.person_id].append(p)
|
||||
reviewer_roles = { r.person_id: r for r in Role.objects.filter(group=group, name="reviewer").select_related("email") }
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
extracted_data = extract_review_request_data(teams=[group], time_from=today - datetime.timedelta(days=365), ordering=["reviewer"])
|
||||
req_data_for_reviewer = {}
|
||||
for reviewer, req_data_items in itertools.groupby(extracted_data, key=lambda data: data.reviewer):
|
||||
l = list(req_data_items)
|
||||
l.reverse()
|
||||
req_data_for_reviewer[reviewer] = l
|
||||
review_state_by_slug = { n.slug: n for n in ReviewRequestStateName.objects.all() }
|
||||
|
||||
for person in reviewers:
|
||||
person.settings = reviewer_settings.get(person.pk) or ReviewerSettings(team=group, person=person)
|
||||
person.settings_url = None
|
||||
person.role = reviewer_roles.get(person.pk)
|
||||
if person.role and (can_manage or user_is_person(request.user, person)):
|
||||
person.settings_url = urlreverse("ietf.group.views_review.change_reviewer_settings", kwargs={ "group_type": group_type, "acronym": group.acronym, "reviewer_email": person.role.email.address })
|
||||
person.unavailable_periods = unavailable_periods.get(person.pk, [])
|
||||
person.completely_unavailable = any(p.availability == "unavailable"
|
||||
and p.start_date <= today and (p.end_date is None or today <= p.end_date)
|
||||
for p in person.unavailable_periods)
|
||||
|
||||
MAX_REQS = 5
|
||||
req_data = req_data_for_reviewer.get(person.pk, [])
|
||||
open_reqs = sum(1 for d in req_data if d.state in ("requested", "accepted"))
|
||||
latest_reqs = []
|
||||
for d in req_data:
|
||||
# any open requests pushes the others out
|
||||
if ((d.state in ("requested", "accepted") and len(latest_reqs) < MAX_REQS) or (len(latest_reqs) + open_reqs < MAX_REQS)):
|
||||
latest_reqs.append((d.req_pk, d.doc, d.deadline,
|
||||
review_state_by_slug.get(d.state),
|
||||
int(math.ceil(d.assignment_to_closure_days)) if d.assignment_to_closure_days is not None else None))
|
||||
person.latest_reqs = latest_reqs
|
||||
|
||||
return render(request, 'group/reviewer_overview.html',
|
||||
construct_group_menu_context(request, group, "reviewers", group_type, {
|
||||
"reviewers": reviewers,
|
||||
}))
|
||||
|
||||
class ManageReviewRequestForm(forms.Form):
|
||||
ACTIONS = [
|
||||
("assign", "Assign"),
|
||||
("close", "Close"),
|
||||
]
|
||||
|
||||
action = forms.ChoiceField(choices=ACTIONS, widget=forms.HiddenInput, required=False)
|
||||
close = forms.ModelChoiceField(queryset=close_review_request_states(), required=False)
|
||||
reviewer = PersonEmailChoiceField(empty_label="(None)", required=False, label_with="person")
|
||||
|
||||
def __init__(self, review_req, *args, **kwargs):
|
||||
if not "prefix" in kwargs:
|
||||
if review_req.pk is None:
|
||||
kwargs["prefix"] = "r{}-{}".format(review_req.type_id, review_req.doc_id)
|
||||
else:
|
||||
kwargs["prefix"] = "r{}".format(review_req.pk)
|
||||
|
||||
super(ManageReviewRequestForm, self).__init__(*args, **kwargs)
|
||||
|
||||
if review_req.pk is None:
|
||||
self.fields["close"].queryset = self.fields["close"].queryset.filter(slug__in=["no-review-version", "no-review-document"])
|
||||
|
||||
close_initial = None
|
||||
if review_req.pk is None:
|
||||
close_initial = "no-review-version"
|
||||
elif review_req.reviewer:
|
||||
close_initial = "no-response"
|
||||
else:
|
||||
close_initial = "overtaken"
|
||||
|
||||
if close_initial:
|
||||
self.fields["close"].initial = close_initial
|
||||
|
||||
self.fields["close"].widget.attrs["class"] = "form-control input-sm"
|
||||
|
||||
setup_reviewer_field(self.fields["reviewer"], review_req)
|
||||
self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm"
|
||||
|
||||
if self.is_bound:
|
||||
if self.data.get("action") == "close":
|
||||
self.fields["close"].required = True
|
||||
|
||||
|
||||
@login_required
|
||||
def manage_review_requests(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_reviews:
|
||||
raise Http404
|
||||
|
||||
if not can_manage_review_requests_for_team(request.user, group):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
unavailable_periods = current_unavailable_periods_for_reviewers(group)
|
||||
|
||||
open_review_requests = list(ReviewRequest.objects.filter(
|
||||
team=group, state__in=("requested", "accepted")
|
||||
).prefetch_related("reviewer", "type", "state").order_by("-time", "-id"))
|
||||
|
||||
for review_req in open_review_requests:
|
||||
if review_req.reviewer:
|
||||
review_req.reviewer_unavailable = any(p.availability == "unavailable"
|
||||
for p in unavailable_periods.get(review_req.reviewer.person_id, []))
|
||||
|
||||
review_requests = suggested_review_requests_for_team(group) + open_review_requests
|
||||
|
||||
document_requests = extract_revision_ordered_review_requests_for_documents_and_replaced(
|
||||
ReviewRequest.objects.filter(state__in=("part-completed", "completed"), team=group).prefetch_related("result"),
|
||||
set(r.doc_id for r in review_requests),
|
||||
)
|
||||
|
||||
# we need a mutable query dict for resetting upon saving with
|
||||
# conflicts
|
||||
query_dict = request.POST.copy() if request.method == "POST" else None
|
||||
for req in review_requests:
|
||||
l = []
|
||||
# take all on the latest reviewed rev
|
||||
for r in document_requests.get(req.doc_id, []):
|
||||
if l and l[0].reviewed_rev:
|
||||
if r.doc_id == l[0].doc_id and r.reviewed_rev:
|
||||
if int(r.reviewed_rev) > int(l[0].reviewed_rev):
|
||||
l = [r]
|
||||
elif int(r.reviewed_rev) == int(l[0].reviewed_rev):
|
||||
l.append(r)
|
||||
else:
|
||||
l = [r]
|
||||
|
||||
req.latest_reqs = l
|
||||
|
||||
req.form = ManageReviewRequestForm(req, query_dict)
|
||||
|
||||
saving = False
|
||||
newly_closed = newly_opened = newly_assigned = 0
|
||||
|
||||
if request.method == "POST":
|
||||
form_action = request.POST.get("action", "")
|
||||
saving = form_action.startswith("save")
|
||||
|
||||
# check for conflicts
|
||||
review_requests_dict = { unicode(r.pk): r for r in review_requests }
|
||||
posted_reqs = set(request.POST.getlist("reviewrequest", []))
|
||||
current_reqs = set(review_requests_dict.iterkeys())
|
||||
|
||||
closed_reqs = posted_reqs - current_reqs
|
||||
newly_closed += len(closed_reqs)
|
||||
|
||||
opened_reqs = current_reqs - posted_reqs
|
||||
newly_opened += len(opened_reqs)
|
||||
for r in opened_reqs:
|
||||
review_requests_dict[r].form.add_error(None, "New request.")
|
||||
|
||||
for req in review_requests:
|
||||
existing_reviewer = request.POST.get(req.form.prefix + "-existing_reviewer")
|
||||
if existing_reviewer is None:
|
||||
continue
|
||||
|
||||
if existing_reviewer != unicode(req.reviewer_id or ""):
|
||||
msg = "Assignment was changed."
|
||||
a = req.form["action"].value()
|
||||
if a == "assign":
|
||||
msg += " Didn't assign reviewer."
|
||||
elif a == "close":
|
||||
msg += " Didn't close request."
|
||||
req.form.add_error(None, msg)
|
||||
req.form.data[req.form.prefix + "-action"] = "" # cancel the action
|
||||
|
||||
newly_assigned += 1
|
||||
|
||||
form_results = []
|
||||
for req in review_requests:
|
||||
form_results.append(req.form.is_valid())
|
||||
|
||||
if saving and all(form_results) and not (newly_closed > 0 or newly_opened > 0 or newly_assigned > 0):
|
||||
for review_req in review_requests:
|
||||
action = review_req.form.cleaned_data.get("action")
|
||||
if action == "assign":
|
||||
assign_review_request_to_reviewer(request, review_req, review_req.form.cleaned_data["reviewer"])
|
||||
elif action == "close":
|
||||
close_review_request(request, review_req, review_req.form.cleaned_data["close"])
|
||||
|
||||
kwargs = { "acronym": group.acronym }
|
||||
if group_type:
|
||||
kwargs["group_type"] = group_type
|
||||
|
||||
if form_action == "save-continue":
|
||||
return redirect(manage_review_requests, **kwargs)
|
||||
else:
|
||||
import ietf.group.views_review
|
||||
return redirect(ietf.group.views_review.review_requests, **kwargs)
|
||||
|
||||
return render(request, 'group/manage_review_requests.html', {
|
||||
'group': group,
|
||||
'review_requests': review_requests,
|
||||
'newly_closed': newly_closed,
|
||||
'newly_opened': newly_opened,
|
||||
'newly_assigned': newly_assigned,
|
||||
'saving': saving,
|
||||
})
|
||||
|
||||
class EmailOpenAssignmentsForm(forms.Form):
|
||||
to = forms.EmailField(widget=forms.EmailInput(attrs={ "readonly": True }))
|
||||
subject = forms.CharField()
|
||||
body = forms.CharField(widget=forms.Textarea)
|
||||
|
||||
@login_required
|
||||
def email_open_review_assignments(request, acronym, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_reviews:
|
||||
raise Http404
|
||||
|
||||
if not can_manage_review_requests_for_team(request.user, group):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
review_requests = list(ReviewRequest.objects.filter(
|
||||
team=group,
|
||||
state__in=("requested", "accepted"),
|
||||
).exclude(
|
||||
reviewer=None,
|
||||
).prefetch_related("reviewer", "type", "state", "doc").distinct().order_by("deadline", "reviewer"))
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "email":
|
||||
form = EmailOpenAssignmentsForm(request.POST)
|
||||
if form.is_valid():
|
||||
send_mail_text(request, form.cleaned_data["to"], None, form.cleaned_data["subject"], form.cleaned_data["body"])
|
||||
|
||||
kwargs = { "acronym": group.acronym }
|
||||
if group_type:
|
||||
kwargs["group_type"] = group_type
|
||||
|
||||
return redirect(manage_review_requests, **kwargs)
|
||||
else:
|
||||
to = group.list_email
|
||||
subject = "Open review assignments in {}".format(group.acronym)
|
||||
|
||||
body = render_to_string("group/email_open_review_assignments.txt", {
|
||||
"review_requests": review_requests,
|
||||
"rotation_list": reviewer_rotation_list(group)[:10],
|
||||
})
|
||||
|
||||
form = EmailOpenAssignmentsForm(initial={
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
})
|
||||
|
||||
return render(request, 'group/email_open_review_assignments.html', {
|
||||
'group': group,
|
||||
'review_requests': review_requests,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
|
||||
class ReviewerSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ReviewerSettings
|
||||
fields = ['min_interval', 'filter_re', 'skip_next', 'remind_days_before_deadline']
|
||||
|
||||
class AddUnavailablePeriodForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UnavailablePeriod
|
||||
fields = ['start_date', 'end_date', 'availability']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AddUnavailablePeriodForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields["start_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label=self.fields["start_date"].label, help_text=self.fields["start_date"].help_text, required=self.fields["start_date"].required)
|
||||
self.fields["end_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label=self.fields["end_date"].label, help_text=self.fields["end_date"].help_text, required=self.fields["end_date"].required)
|
||||
|
||||
self.fields['availability'].widget = forms.RadioSelect(choices=UnavailablePeriod.LONG_AVAILABILITY_CHOICES)
|
||||
|
||||
def clean(self):
|
||||
start = self.cleaned_data.get("start_date")
|
||||
end = self.cleaned_data.get("end_date")
|
||||
if start and end and start > end:
|
||||
self.add_error("start_date", "Start date must be before or equal to end date.")
|
||||
return self.cleaned_data
|
||||
|
||||
class EndUnavailablePeriodForm(forms.Form):
|
||||
def __init__(self, start_date, *args, **kwargs):
|
||||
super(EndUnavailablePeriodForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields["end_date"] = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1", "start-date": start_date.isoformat() })
|
||||
|
||||
self.start_date = start_date
|
||||
|
||||
def clean_end_date(self):
|
||||
end = self.cleaned_data["end_date"]
|
||||
if end < self.start_date:
|
||||
raise forms.ValidationError("End date must be equal to or come after start date.")
|
||||
return end
|
||||
|
||||
|
||||
@login_required
|
||||
def change_reviewer_settings(request, acronym, reviewer_email, group_type=None):
|
||||
group = get_group_or_404(acronym, group_type)
|
||||
if not group.features.has_reviews:
|
||||
raise Http404
|
||||
|
||||
reviewer_role = get_object_or_404(Role, name="reviewer", group=group, email=reviewer_email)
|
||||
reviewer = reviewer_role.person
|
||||
|
||||
if not (user_is_person(request.user, reviewer)
|
||||
or can_manage_review_requests_for_team(request.user, group)):
|
||||
return HttpResponseForbidden("You do not have permission to perform this action")
|
||||
|
||||
settings = (ReviewerSettings.objects.filter(person=reviewer, team=group).first()
|
||||
or ReviewerSettings(person=reviewer, team=group))
|
||||
|
||||
back_url = request.GET.get("next")
|
||||
if not back_url:
|
||||
import ietf.group.views_review
|
||||
back_url = urlreverse(ietf.group.views_review.reviewer_overview, kwargs={ "group_type": group.type_id, "acronym": group.acronym})
|
||||
|
||||
# settings
|
||||
if request.method == "POST" and request.POST.get("action") == "change_settings":
|
||||
prev_min_interval = settings.get_min_interval_display()
|
||||
prev_skip_next = settings.skip_next
|
||||
settings_form = ReviewerSettingsForm(request.POST, instance=settings)
|
||||
if settings_form.is_valid():
|
||||
settings = settings_form.save()
|
||||
|
||||
changes = []
|
||||
if settings.get_min_interval_display() != prev_min_interval:
|
||||
changes.append("Frequency changed to \"{}\" from \"{}\".".format(settings.get_min_interval_display(), prev_min_interval))
|
||||
if settings.skip_next != prev_skip_next:
|
||||
changes.append("Skip next assignments changed to {} from {}.".format(settings.skip_next, prev_skip_next))
|
||||
|
||||
if changes:
|
||||
email_reviewer_availability_change(request, group, reviewer_role, "\n\n".join(changes), request.user.person)
|
||||
|
||||
return HttpResponseRedirect(back_url)
|
||||
else:
|
||||
settings_form = ReviewerSettingsForm(instance=settings)
|
||||
|
||||
# periods
|
||||
unavailable_periods = unavailable_periods_to_list().filter(person=reviewer, team=group)
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "add_period":
|
||||
period_form = AddUnavailablePeriodForm(request.POST)
|
||||
if period_form.is_valid():
|
||||
period = period_form.save(commit=False)
|
||||
period.team = group
|
||||
period.person = reviewer
|
||||
period.save()
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
in_the_past = period.end_date and period.end_date < today
|
||||
|
||||
if not in_the_past:
|
||||
msg = "Unavailable for review: {} - {} ({})".format(
|
||||
period.start_date.isoformat(),
|
||||
period.end_date.isoformat() if period.end_date else "indefinite",
|
||||
period.get_availability_display(),
|
||||
)
|
||||
|
||||
if period.availability == "unavailable":
|
||||
# the secretary might need to reassign
|
||||
# assignments, so mention the current ones
|
||||
|
||||
review_reqs = ReviewRequest.objects.filter(state__in=["requested", "accepted"], reviewer=reviewer_role.email, team=group)
|
||||
msg += "\n\n"
|
||||
|
||||
if review_reqs:
|
||||
msg += "{} is currently assigned to review:".format(reviewer_role.person)
|
||||
for r in review_reqs:
|
||||
msg += "\n\n"
|
||||
msg += "{} (deadline: {})".format(r.doc_id, r.deadline.isoformat())
|
||||
else:
|
||||
msg += "{} does not have any assignments currently.".format(reviewer_role.person)
|
||||
|
||||
email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person)
|
||||
|
||||
return HttpResponseRedirect(request.get_full_path())
|
||||
else:
|
||||
period_form = AddUnavailablePeriodForm()
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "delete_period":
|
||||
period_id = request.POST.get("period_id")
|
||||
if period_id is not None:
|
||||
for period in unavailable_periods:
|
||||
if str(period.pk) == period_id:
|
||||
period.delete()
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
in_the_past = period.end_date and period.end_date < today
|
||||
|
||||
if not in_the_past:
|
||||
msg = "Removed unavailable period: {} - {} ({})".format(
|
||||
period.start_date.isoformat(),
|
||||
period.end_date.isoformat() if period.end_date else "indefinite",
|
||||
period.get_availability_display(),
|
||||
)
|
||||
|
||||
email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person)
|
||||
|
||||
return HttpResponseRedirect(request.get_full_path())
|
||||
|
||||
for p in unavailable_periods:
|
||||
if not p.end_date:
|
||||
p.end_form = EndUnavailablePeriodForm(p.start_date, request.POST if request.method == "POST" and request.POST.get("action") == "end_period" else None)
|
||||
|
||||
if request.method == "POST" and request.POST.get("action") == "end_period":
|
||||
period_id = request.POST.get("period_id")
|
||||
for period in unavailable_periods:
|
||||
if str(period.pk) == period_id:
|
||||
if not period.end_date and period.end_form.is_valid():
|
||||
period.end_date = period.end_form.cleaned_data["end_date"]
|
||||
period.save()
|
||||
|
||||
msg = "Set end date of unavailable period: {} - {} ({})".format(
|
||||
period.start_date.isoformat(),
|
||||
period.end_date.isoformat() if period.end_date else "indefinite",
|
||||
period.get_availability_display(),
|
||||
)
|
||||
|
||||
email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person)
|
||||
|
||||
return HttpResponseRedirect(request.get_full_path())
|
||||
|
||||
|
||||
return render(request, 'group/change_reviewer_settings.html', {
|
||||
'group': group,
|
||||
'reviewer_email': reviewer_email,
|
||||
'back_url': back_url,
|
||||
'settings_form': settings_form,
|
||||
'period_form': period_form,
|
||||
'unavailable_periods': unavailable_periods,
|
||||
})
|
|
@ -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()
|
||||
|
||||
reviewer = Person.objects.get(name="Plain Man")
|
||||
|
||||
review_req = make_review_data(doc)
|
||||
review_req.reviewer = reviewer.email_set.first()
|
||||
review_req.save()
|
||||
|
||||
UnavailablePeriod.objects.create(
|
||||
team=review_req.team,
|
||||
person=reviewer,
|
||||
start_date=datetime.date.today() - datetime.timedelta(days=10),
|
||||
availability="unavailable",
|
||||
)
|
||||
|
||||
url = urlreverse(ietf.ietfauth.views.review_overview)
|
||||
|
||||
login_testing_unauthorized(self, reviewer.user.username, url)
|
||||
|
||||
# get
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(review_req.doc.name in unicontent(r))
|
||||
|
||||
# wish to review
|
||||
r = self.client.post(url, {
|
||||
"action": "add_wish",
|
||||
'doc': doc.pk,
|
||||
"team": review_req.team_id,
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertEqual(ReviewWish.objects.filter(doc=doc, team=review_req.team).count(), 1)
|
||||
|
||||
# delete wish
|
||||
r = self.client.post(url, {
|
||||
"action": "delete_wish",
|
||||
'wish_id': ReviewWish.objects.get(doc=doc, team=review_req.team).pk,
|
||||
})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertEqual(ReviewWish.objects.filter(doc=doc, team=review_req.team).count(), 0)
|
||||
|
||||
def test_htpasswd_file_with_python(self):
|
||||
# make sure we test both Python and call-out to binary
|
||||
settings.USE_PYTHON_HTDIGEST = True
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -3,7 +3,8 @@ from ietf.name.models import (GroupTypeName, GroupStateName, RoleName, StreamNam
|
|||
DocRelationshipName, DocTypeName, DocTagName, StdLevelName, IntendedStdLevelName,
|
||||
DocReminderTypeName, BallotPositionName, SessionStatusName, TimeSlotTypeName,
|
||||
ConstraintName, NomineePositionStateName, FeedbackTypeName, DBTemplateTypeName,
|
||||
DraftSubmissionStateName, RoomResourceName)
|
||||
DraftSubmissionStateName, RoomResourceName,
|
||||
ReviewRequestStateName, ReviewTypeName, ReviewResultName)
|
||||
|
||||
|
||||
class NameAdmin(admin.ModelAdmin):
|
||||
|
@ -39,3 +40,6 @@ admin.site.register(FeedbackTypeName, NameAdmin)
|
|||
admin.site.register(DBTemplateTypeName, NameAdmin)
|
||||
admin.site.register(DraftSubmissionStateName, NameAdmin)
|
||||
admin.site.register(RoomResourceName, NameAdmin)
|
||||
admin.site.register(ReviewRequestStateName, NameAdmin)
|
||||
admin.site.register(ReviewTypeName, NameAdmin)
|
||||
admin.site.register(ReviewResultName, NameAdmin)
|
||||
|
|
|
@ -777,6 +777,17 @@
|
|||
"model": "name.doctypename",
|
||||
"pk": "bluesheets"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 0,
|
||||
"prefix": "",
|
||||
"used": true,
|
||||
"name": "Review",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.doctypename",
|
||||
"pk": "review"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 1,
|
||||
|
@ -1780,6 +1791,226 @@
|
|||
"model": "name.nomineepositionstatename",
|
||||
"pk": "declined"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 1,
|
||||
"used": true,
|
||||
"name": "Requested",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewrequeststatename",
|
||||
"pk": "requested"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 2,
|
||||
"used": true,
|
||||
"name": "Accepted",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewrequeststatename",
|
||||
"pk": "accepted"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 3,
|
||||
"used": true,
|
||||
"name": "Rejected",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewrequeststatename",
|
||||
"pk": "rejected"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 4,
|
||||
"used": true,
|
||||
"name": "Withdrawn",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewrequeststatename",
|
||||
"pk": "withdrawn"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 5,
|
||||
"used": true,
|
||||
"name": "Overtaken by Events",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewrequeststatename",
|
||||
"pk": "overtaken"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 6,
|
||||
"used": true,
|
||||
"name": "No Response",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewrequeststatename",
|
||||
"pk": "no-response"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 7,
|
||||
"used": true,
|
||||
"name": "Team Will not Review Version",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewrequeststatename",
|
||||
"pk": "no-review-version"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 8,
|
||||
"used": true,
|
||||
"name": "Team Will not Review Document",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewrequeststatename",
|
||||
"pk": "no-review-document"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 9,
|
||||
"used": true,
|
||||
"name": "Partially Completed",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewrequeststatename",
|
||||
"pk": "part-completed"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 10,
|
||||
"used": true,
|
||||
"name": "Completed",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewrequeststatename",
|
||||
"pk": "completed"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 1,
|
||||
"used": true,
|
||||
"name": "Serious Issues",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewresultname",
|
||||
"pk": "serious-issues"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 2,
|
||||
"used": true,
|
||||
"name": "Has Issues",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewresultname",
|
||||
"pk": "issues"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 3,
|
||||
"used": true,
|
||||
"name": "Has Nits",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewresultname",
|
||||
"pk": "nits"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 4,
|
||||
"used": true,
|
||||
"name": "Not Ready",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewresultname",
|
||||
"pk": "not-ready"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 5,
|
||||
"used": true,
|
||||
"name": "On the Right Track",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewresultname",
|
||||
"pk": "right-track"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 6,
|
||||
"used": true,
|
||||
"name": "Almost Ready",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewresultname",
|
||||
"pk": "almost-ready"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 7,
|
||||
"used": true,
|
||||
"name": "Ready with Issues",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewresultname",
|
||||
"pk": "ready-issues"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 8,
|
||||
"used": true,
|
||||
"name": "Ready with Nits",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewresultname",
|
||||
"pk": "ready-nits"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 9,
|
||||
"used": true,
|
||||
"name": "Ready",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewresultname",
|
||||
"pk": "ready"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 1,
|
||||
"used": true,
|
||||
"name": "Early",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewtypename",
|
||||
"pk": "early"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 2,
|
||||
"used": true,
|
||||
"name": "Last Call",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewtypename",
|
||||
"pk": "lc"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 3,
|
||||
"used": true,
|
||||
"name": "Telechat",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.reviewtypename",
|
||||
"pk": "telechat"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 0,
|
||||
|
@ -1970,6 +2201,16 @@
|
|||
"model": "name.rolename",
|
||||
"pk": "matman"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 14,
|
||||
"used": true,
|
||||
"name": "Reviewer",
|
||||
"desc": ""
|
||||
},
|
||||
"model": "name.rolename",
|
||||
"pk": "reviewer"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"order": 0,
|
||||
|
@ -2480,6 +2721,13 @@
|
|||
"model": "doc.statetype",
|
||||
"pk": "reuse_policy"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"label": "Review"
|
||||
},
|
||||
"model": "doc.statetype",
|
||||
"pk": "review"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"used": true,
|
||||
|
@ -4227,6 +4475,32 @@
|
|||
"model": "doc.state",
|
||||
"pk": 142
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"used": true,
|
||||
"name": "Active",
|
||||
"next_states": [],
|
||||
"slug": "active",
|
||||
"type": "review",
|
||||
"order": 1,
|
||||
"desc": ""
|
||||
},
|
||||
"model": "doc.state",
|
||||
"pk": 143
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"used": true,
|
||||
"name": "Deleted",
|
||||
"next_states": [],
|
||||
"slug": "deleted",
|
||||
"type": "review",
|
||||
"order": 2,
|
||||
"desc": ""
|
||||
},
|
||||
"model": "doc.state",
|
||||
"pk": 144
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"used": true,
|
||||
|
@ -5253,6 +5527,19 @@
|
|||
"model": "mailtrigger.mailtrigger",
|
||||
"pk": "charter_internal_review"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"cc": [
|
||||
"group_mail_list"
|
||||
],
|
||||
"to": [
|
||||
"ietf_announce"
|
||||
],
|
||||
"desc": "Recipients when an interim meeting is announced"
|
||||
},
|
||||
"model": "mailtrigger.mailtrigger",
|
||||
"pk": "interim_announced"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"cc": [],
|
||||
|
@ -5560,19 +5847,6 @@
|
|||
"model": "mailtrigger.mailtrigger",
|
||||
"pk": "group_personnel_change"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"cc": [
|
||||
"group_mail_list"
|
||||
],
|
||||
"to": [
|
||||
"ietf_announce"
|
||||
],
|
||||
"desc": "Recipients when an interim meeting is announced"
|
||||
},
|
||||
"model": "mailtrigger.mailtrigger",
|
||||
"pk": "interim_announced"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"cc": [],
|
||||
|
|
|
@ -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),
|
||||
]
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
622
ietf/review/import_from_review_tool.py
Executable file
622
ietf/review/import_from_review_tool.py
Executable file
|
@ -0,0 +1,622 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import sys, os
|
||||
|
||||
# boilerplate
|
||||
basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
|
||||
sys.path = [ basedir ] + sys.path
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ietf.settings")
|
||||
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
|
||||
# script
|
||||
|
||||
import datetime, re, itertools
|
||||
from collections import namedtuple
|
||||
from django.db import connections
|
||||
from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, ResultUsedInReviewTeam,
|
||||
ReviewRequestStateName, ReviewTypeName, TypeUsedInReviewTeam,
|
||||
UnavailablePeriod, NextReviewerInTeam)
|
||||
from ietf.group.models import Group, Role, RoleName
|
||||
from ietf.person.models import Person, Email, Alias
|
||||
from ietf.doc.models import Document, DocAlias, ReviewRequestDocEvent, NewRevisionDocEvent, DocTypeName, State
|
||||
from ietf.utils.text import strip_prefix
|
||||
import argparse
|
||||
from unidecode import unidecode
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("database", help="database must be included in settings")
|
||||
parser.add_argument("team", help="team acronym, must exist")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_con = connections[args.database]
|
||||
team = Group.objects.get(acronym=args.team)
|
||||
|
||||
def namedtuplefetchall(cursor):
|
||||
"Return all rows from a cursor as a namedtuple"
|
||||
desc = cursor.description
|
||||
nt_result = namedtuple('Result', [col[0] for col in desc])
|
||||
return (nt_result(*row) for row in cursor.fetchall())
|
||||
|
||||
def parse_timestamp(t):
|
||||
if not t:
|
||||
return None
|
||||
return datetime.datetime.fromtimestamp(t)
|
||||
|
||||
# personnel
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select distinct reviewer from reviews;")
|
||||
known_reviewers = { row[0] for row in c.fetchall() }
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select distinct who from doclog;")
|
||||
docloggers = { row[0] for row in c.fetchall() }
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select distinct login from members where permissions like '%secretary%';")
|
||||
secretaries = { row[0] for row in c.fetchall() }
|
||||
|
||||
autopolicy_days = {
|
||||
'weekly': 7,
|
||||
'biweekly': 14,
|
||||
'monthly': 30,
|
||||
'bimonthly': 61,
|
||||
'quarterly': 91,
|
||||
}
|
||||
|
||||
known_personnel = {}
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select * from members;")
|
||||
|
||||
needed_personnel = known_reviewers | docloggers | secretaries
|
||||
|
||||
for row in namedtuplefetchall(c):
|
||||
if row.login not in needed_personnel:
|
||||
continue
|
||||
|
||||
email = Email.objects.filter(address=row.email).select_related("person").first()
|
||||
if not email:
|
||||
person = Person.objects.filter(alias__name=row.name).first()
|
||||
if not person:
|
||||
person, created = Person.objects.get_or_create(name=row.name, ascii=unidecode(row.name))
|
||||
if created:
|
||||
print "created person", unicode(person).encode("utf-8")
|
||||
existing_aliases = set(Alias.objects.filter(person=person).values_list("name", flat=True))
|
||||
curr_names = set(x for x in [person.name, person.ascii, person.ascii_short, person.plain_name(), ] if x)
|
||||
new_aliases = curr_names - existing_aliases
|
||||
for name in new_aliases:
|
||||
Alias.objects.create(person=person, name=name)
|
||||
|
||||
email, created = Email.objects.get_or_create(address=row.email, person=person)
|
||||
if created:
|
||||
print "created email", email
|
||||
|
||||
if "@" in email.person.name and row.name:
|
||||
old_name = email.person.name
|
||||
email.person.name = row.name
|
||||
email.person.ascii = row.name
|
||||
email.person.save()
|
||||
print "fixed name of", email, old_name, "->", row.name
|
||||
|
||||
known_personnel[row.login] = email
|
||||
|
||||
if "secretary" in row.permissions:
|
||||
role, created = Role.objects.get_or_create(name=RoleName.objects.get(slug="secr"), person=email.person, email=email, group=team)
|
||||
if created:
|
||||
print "created role", unicode(role).encode("utf-8")
|
||||
|
||||
if row.login in known_reviewers:
|
||||
if row.comment != "Inactive" and row.available != 2145916800: # corresponds to 2038-01-01
|
||||
role, created = Role.objects.get_or_create(name=RoleName.objects.get(slug="reviewer"), person=email.person, email=email, group=team)
|
||||
|
||||
if created:
|
||||
print "created role", unicode(role).encode("utf-8")
|
||||
|
||||
reviewer, created = ReviewerSettings.objects.get_or_create(
|
||||
team=team,
|
||||
person=email.person,
|
||||
)
|
||||
if created:
|
||||
print "created reviewer", reviewer.pk, unicode(reviewer).encode("utf-8")
|
||||
|
||||
if autopolicy_days.get(row.autopolicy):
|
||||
reviewer.min_interval = autopolicy_days.get(row.autopolicy)
|
||||
|
||||
reviewer.filter_re = row.donotassign
|
||||
try:
|
||||
reviewer.skip_next = int(row.autopolicy)
|
||||
except ValueError:
|
||||
pass
|
||||
reviewer.save()
|
||||
|
||||
unavailable_until = parse_timestamp(row.available)
|
||||
if unavailable_until:
|
||||
today = datetime.date.today()
|
||||
end_date = unavailable_until.date()
|
||||
if end_date >= today:
|
||||
if end_date >= datetime.date(2020, 1, 1):
|
||||
# convert hacked end dates to indefinite
|
||||
end_date = None
|
||||
|
||||
UnavailablePeriod.objects.filter(person=email.person, team=team).delete()
|
||||
|
||||
UnavailablePeriod.objects.create(
|
||||
team=team,
|
||||
person=email.person,
|
||||
start_date=today,
|
||||
end_date=end_date,
|
||||
availability="unavailable",
|
||||
)
|
||||
|
||||
# check that we got the needed names
|
||||
results = { n.name.lower(): n for n in ReviewResultName.objects.all() }
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select distinct summary from reviews;")
|
||||
summaries = [r[0].lower() for r in c.fetchall() if r[0]]
|
||||
missing_result_names = set(summaries) - set(results.keys())
|
||||
assert not missing_result_names, "missing result names: {} {}".format(missing_result_names, results.keys())
|
||||
|
||||
|
||||
# configuration options
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select * from config;")
|
||||
|
||||
for row in namedtuplefetchall(c):
|
||||
if row.name == "next": # next reviewer
|
||||
NextReviewerInTeam.objects.filter(team=team).delete()
|
||||
NextReviewerInTeam.objects.create(team=team, next_reviewer=known_personnel[row.value].person)
|
||||
|
||||
if row.name == "summary-list": # review results used in team
|
||||
summaries = [v.strip().lower() for v in row.value.split(";") if v.strip()]
|
||||
|
||||
for s in summaries:
|
||||
ResultUsedInReviewTeam.objects.get_or_create(team=team, result=results[s])
|
||||
|
||||
for t in ReviewTypeName.objects.filter(slug__in=["early", "lc", "telechat"]):
|
||||
TypeUsedInReviewTeam.objects.get_or_create(team=team, type=t)
|
||||
|
||||
# review requests
|
||||
|
||||
states = { n.slug: n for n in ReviewRequestStateName.objects.all() }
|
||||
# map some names
|
||||
states["assigned"] = states["requested"]
|
||||
states["done"] = states["completed"]
|
||||
states["noresponse"] = states["no-response"]
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select distinct docstatus from reviews;")
|
||||
docstates = [r[0] for r in c.fetchall() if r[0]]
|
||||
missing_state_names = set(docstates) - set(states.keys())
|
||||
assert not missing_state_names, "missing state names: {}".format(missing_state_names)
|
||||
|
||||
type_names = { n.slug: n for n in ReviewTypeName.objects.all() }
|
||||
|
||||
# extract relevant log entries
|
||||
|
||||
document_history = {}
|
||||
|
||||
requested_re = re.compile("(?:ADDED docname =>|Created: remote=|AUTO UPDATED status TO new|CHANGED status FROM [^ ]+ => new|CHANGE status done => working)")
|
||||
|
||||
add_docstatus_re = re.compile('([a-zA-Z-_@.]+) ADD docstatus => (\w+)')
|
||||
update_docstatus_re = re.compile('([a-zA-Z-_@.]+) (?:UPDATE|CHANGE) docstatus \w+ => (\w+)')
|
||||
iesgstatus_re = re.compile('(?:ADD|ADDED|CHANGED) iesgstatus (?:FROM )?(?:[^ ]+ )?=> ([a-zA-Z-_]+)?')
|
||||
|
||||
deadline_re = re.compile('(?:ADD|ADDED|CHANGED) deadline (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)')
|
||||
telechat_re = re.compile('(?:ADD|ADDED|CHANGED) telechat (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)')
|
||||
lcend_re = re.compile('(?:ADD|ADDED|CHANGED) lcend (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)')
|
||||
|
||||
close_states = ["done", "rejected", "withdrawn", "noresponse"]
|
||||
|
||||
document_blacklist = set([(u"tsvdir", u"draft-arkko-ipv6-transition-guidelines-09 ")])
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("""select docname, time, who, text from doclog where
|
||||
text like 'Created: remote=%'
|
||||
or text like '%ADDED docname => %'
|
||||
or text like '%AUTO UPDATED status TO new%'
|
||||
or text like '%CHANGED status FROM % => new%'
|
||||
or text like '%CHANGE status done => working%'
|
||||
|
||||
or text like '% ADD docstatus => %'
|
||||
or text like '% UPDATE docstatus % => %'
|
||||
or text like '% CHANGE docstatus % => %'
|
||||
or text like '%CHANGE status working => done%'
|
||||
|
||||
or text like '%ADDED iesgstatus => %'
|
||||
or text like '%ADD iesgstatus => %'
|
||||
or text like '%CHANGED iesgstatus % => %'
|
||||
order by docname, time asc;""")
|
||||
for docname, rows in itertools.groupby(namedtuplefetchall(c), lambda row: row.docname):
|
||||
if (team.acronym, docname) in document_blacklist:
|
||||
continue # ignore
|
||||
|
||||
branches = {}
|
||||
|
||||
latest_requested = None
|
||||
non_specific_close = None
|
||||
latest_iesg_status = None
|
||||
|
||||
for row in rows:
|
||||
state = None
|
||||
used = False
|
||||
|
||||
if requested_re.search(row.text):
|
||||
if "Created: remote" in row.text:
|
||||
latest_iesg_status = "early"
|
||||
state = "requested"
|
||||
membername = None
|
||||
used = True
|
||||
else:
|
||||
if "ADD docstatus" in row.text:
|
||||
m = add_docstatus_re.match(row.text)
|
||||
assert m, 'row.text "{}" does not match add regexp {}'.format(row.text, docname)
|
||||
membername, state = m.groups()
|
||||
used = True
|
||||
elif "UPDATE docstatus" in row.text or "CHANGE docstatus" in row.text:
|
||||
m = update_docstatus_re.match(row.text)
|
||||
assert m, 'row.text "{}" does not match update regexp {}'.format(row.text, docname)
|
||||
membername, state = m.groups()
|
||||
used = True
|
||||
|
||||
if telechat_re.search(row.text) and not lcend_re.search(row.text):
|
||||
latest_iesg_status = "telechat"
|
||||
used = True
|
||||
elif ((not telechat_re.search(row.text) and lcend_re.search(row.text))
|
||||
or (deadline_re.search(row.text) and not telechat_re.search(row.text) and not lcend_re.search(row.text))):
|
||||
latest_iesg_status = "lc"
|
||||
used = True
|
||||
elif (deadline_re.search(row.text) and telechat_re.search(row.text) and lcend_re.search(row.text)):
|
||||
# if we got both, assume it was a Last Call
|
||||
latest_iesg_status = "lc"
|
||||
used = True
|
||||
|
||||
if iesgstatus_re.search(row.text):
|
||||
m = iesgstatus_re.search(row.text)
|
||||
if m.groups():
|
||||
literal_iesg_status = m.groups()[0]
|
||||
if literal_iesg_status == "IESG_Evaluation":
|
||||
latest_iesg_status = "telechat"
|
||||
elif literal_iesg_status == "In_Last_Call":
|
||||
latest_iesg_status = "lc"
|
||||
used = True
|
||||
|
||||
if "CHANGE status working => done" in row.text:
|
||||
non_specific_close = (row, latest_iesg_status)
|
||||
used = True
|
||||
|
||||
if not used:
|
||||
raise Exception("Unknown text {}".format(row.text))
|
||||
|
||||
if not state:
|
||||
continue
|
||||
|
||||
if state == "working":
|
||||
state = "assigned"
|
||||
|
||||
if state == "requested":
|
||||
latest_requested = (row.time, row.who, membername, state, latest_iesg_status)
|
||||
else:
|
||||
if membername not in branches:
|
||||
branches[membername] = []
|
||||
|
||||
latest = branches[membername][-1] if branches[membername] else None
|
||||
|
||||
if not latest or ((state == "assigned" and ("assigned" in latest or "closed" in latest))
|
||||
or (state not in ("assigned", "accepted") and "closed" in latest)):
|
||||
# open new
|
||||
branches[membername].append({})
|
||||
latest = branches[membername][-1]
|
||||
if latest_requested:
|
||||
latest["requested"] = latest_requested
|
||||
latest_requested = None
|
||||
|
||||
if state in ("assigned", 'accepted'):
|
||||
latest[state] = (row.time, row.who, membername, state, latest_iesg_status)
|
||||
else:
|
||||
latest["closed"] = (row.time, row.who, membername, state, latest_iesg_status)
|
||||
|
||||
|
||||
if branches:
|
||||
if non_specific_close:
|
||||
# find any open branches
|
||||
for m, hs in branches.iteritems():
|
||||
latest = hs[-1]
|
||||
if "assigned" in latest and "closed" not in latest:
|
||||
#print "closing with non specific", docname
|
||||
close_row, iesg_status = non_specific_close
|
||||
latest["closed"] = (close_row.time, close_row.who, m, "done", iesg_status)
|
||||
|
||||
document_history[docname] = branches
|
||||
|
||||
# if docname in document_history:
|
||||
# print docname, document_history[docname]
|
||||
|
||||
# extract document request metadata
|
||||
|
||||
doc_metadata = {}
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select docname, version, deadline, telechat, lcend, status from documents order by docname, version;")
|
||||
|
||||
for row in namedtuplefetchall(c):
|
||||
doc_metadata[(row.docname, row.version)] = doc_metadata[row.docname] = (parse_timestamp(row.deadline), parse_timestamp(row.telechat), parse_timestamp(row.lcend), row.status)
|
||||
|
||||
|
||||
system_person = Person.objects.get(name="(System)")
|
||||
|
||||
with db_con.cursor() as c:
|
||||
c.execute("select * from reviews order by reviewid;")
|
||||
|
||||
for row in namedtuplefetchall(c):
|
||||
if (team.acronym, row.docname) in document_blacklist:
|
||||
continue # ignore
|
||||
|
||||
meta = doc_metadata.get((row.docname, row.version))
|
||||
if not meta:
|
||||
meta = doc_metadata.get(row.docname)
|
||||
|
||||
deadline, telechat, lcend, status = meta or (None, None, None, None)
|
||||
|
||||
if not deadline:
|
||||
deadline = parse_timestamp(row.timeout)
|
||||
|
||||
reviewed_rev = row.version if row.version and row.version != "99" else ""
|
||||
if row.summary == "noresponse":
|
||||
reviewed_rev = ""
|
||||
|
||||
event_collection = {}
|
||||
branches = document_history.get(row.docname)
|
||||
if not branches:
|
||||
print "WARNING: no history for", row.docname
|
||||
else:
|
||||
history = branches.get(row.reviewer)
|
||||
if not history:
|
||||
print "WARNING: reviewer {} not found in history for".format(row.reviewer), row.docname
|
||||
else:
|
||||
event_collection = history.pop(0)
|
||||
if "requested" not in event_collection:
|
||||
print "WARNING: no requested log entry for", row.docname, [event_collection] + history
|
||||
|
||||
if "assigned" not in event_collection:
|
||||
print "WARNING: no assigned log entry for", row.docname, [event_collection] + history
|
||||
|
||||
if "closed" not in event_collection and row.docstatus in close_states:
|
||||
print "WARNING: no {} log entry for".format("/".join(close_states)), row.docname, [event_collection] + history
|
||||
|
||||
def day_delta(time_from, time_to):
|
||||
if time_from is None or time_to is None:
|
||||
return None
|
||||
|
||||
return float(time_to[0] - time_from[0]) / (24 * 60 * 60)
|
||||
|
||||
requested_assigned_days = day_delta(event_collection.get("requested"), event_collection.get("assigned"))
|
||||
if requested_assigned_days is not None and requested_assigned_days < 0:
|
||||
print "WARNING: assignment before request", requested_assigned_days, row.docname
|
||||
at_most_days = 20
|
||||
if requested_assigned_days is not None and requested_assigned_days > at_most_days:
|
||||
print "WARNING: more than {} days between request and assignment".format(at_most_days), round(requested_assigned_days), event_collection, row.docname
|
||||
|
||||
if "closed" in event_collection:
|
||||
assigned_closed_days = day_delta(event_collection.get("assigned"), event_collection.get("closed"))
|
||||
if assigned_closed_days is not None and assigned_closed_days < 0:
|
||||
print "WARNING: closure before assignment", assigned_closed_days, row.docname
|
||||
at_most_days = 60
|
||||
if assigned_closed_days is not None and assigned_closed_days > at_most_days and event_collection.get("closed")[3] not in ("noresponse", "withdrawn"):
|
||||
print "WARNING: more than {} days between assignment and completion".format(at_most_days), round(assigned_closed_days), event_collection, row.docname
|
||||
|
||||
if event_collection:
|
||||
time = None
|
||||
if "requested" in event_collection:
|
||||
time = parse_timestamp(event_collection["requested"][0])
|
||||
elif "assigned" in event_collection:
|
||||
time = parse_timestamp(event_collection["assigned"][0])
|
||||
elif "closed" in event_collection:
|
||||
time = parse_timestamp(event_collection["closed"][0])
|
||||
else:
|
||||
time = deadline
|
||||
|
||||
if not deadline and "closed" in event_collection:
|
||||
deadline = parse_timestamp(event_collection["closed"][0])
|
||||
|
||||
if not deadline:
|
||||
print "SKIPPING WITH NO DEADLINE", row.reviewid, row.docname, meta, event_collection
|
||||
continue
|
||||
|
||||
type_slug = None
|
||||
if "assigned" in event_collection:
|
||||
type_slug = event_collection["assigned"][4]
|
||||
if type_slug:
|
||||
print "deduced type slug at assignment", type_slug, row.docname
|
||||
|
||||
if not type_slug and "requested" in event_collection:
|
||||
type_slug = event_collection["requested"][4]
|
||||
if type_slug:
|
||||
print "deduced type slug at request", type_slug, row.docname
|
||||
|
||||
if not type_slug and "closed" in event_collection and "assigned" not in event_collection:
|
||||
type_slug = event_collection["closed"][4]
|
||||
if type_slug:
|
||||
print "deduced type slug at closure", type_slug, row.docname
|
||||
|
||||
if not type_slug:
|
||||
print "did not deduce type slug, assuming early", row.docname, deadline
|
||||
type_slug = "early"
|
||||
|
||||
type_name = type_names[type_slug]
|
||||
|
||||
def fix_docname(docname):
|
||||
if docname == "draft-fenner-obsolete":
|
||||
docname = "draft-fenner-obsolete-1264"
|
||||
return docname
|
||||
|
||||
review_req, _ = ReviewRequest.objects.get_or_create(
|
||||
doc_id=fix_docname(row.docname),
|
||||
team=team,
|
||||
old_id=row.reviewid,
|
||||
defaults={
|
||||
"state": states["requested"],
|
||||
"type": type_name,
|
||||
"deadline": deadline.date(),
|
||||
"requested_by": system_person,
|
||||
}
|
||||
)
|
||||
|
||||
review_req.reviewer = known_personnel[row.reviewer] if row.reviewer else None
|
||||
review_req.result = results.get(row.summary.lower()) if row.summary else None
|
||||
review_req.state = states.get(row.docstatus) if row.docstatus else None
|
||||
review_req.type = type_name
|
||||
review_req.time = time
|
||||
review_req.reviewed_rev = reviewed_rev if review_req.state_id not in ("requested", "accepted") else ""
|
||||
review_req.deadline = deadline.date()
|
||||
review_req.save()
|
||||
|
||||
completion_event = None
|
||||
|
||||
# review request events
|
||||
for key, data in event_collection.iteritems():
|
||||
timestamp, who_did_it, reviewer, state, latest_iesg_status = data
|
||||
|
||||
if who_did_it in known_personnel:
|
||||
by = known_personnel[who_did_it].person
|
||||
else:
|
||||
by = system_person
|
||||
|
||||
if key == "requested":
|
||||
if "assigned" in event_collection:
|
||||
continue # skip requested unless there's no assigned event
|
||||
|
||||
e = ReviewRequestDocEvent.objects.filter(type="requested_review", doc=review_req.doc, review_request=review_req).first()
|
||||
if not e:
|
||||
e = ReviewRequestDocEvent(type="requested_review", doc=review_req.doc, review_request=review_req)
|
||||
e.time = time
|
||||
e.by = by
|
||||
e.desc = "Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper())
|
||||
e.state = None
|
||||
e.skip_community_list_notification = True
|
||||
e.save()
|
||||
print "imported event requested_review", e.desc, e.doc_id
|
||||
|
||||
elif key == "assigned":
|
||||
e = ReviewRequestDocEvent.objects.filter(type="assigned_review_request", doc=review_req.doc, review_request=review_req).first()
|
||||
if not e:
|
||||
e = ReviewRequestDocEvent(type="assigned_review_request", doc=review_req.doc, review_request=review_req)
|
||||
e.time = parse_timestamp(timestamp)
|
||||
e.by = by
|
||||
e.desc = "Request for {} review by {} is assigned to {}".format(
|
||||
review_req.type.name,
|
||||
review_req.team.acronym.upper(),
|
||||
review_req.reviewer.person if review_req.reviewer else "(None)",
|
||||
)
|
||||
e.state = None
|
||||
e.skip_community_list_notification = True
|
||||
e.save()
|
||||
print "imported event assigned_review_request", e.pk, e.desc, e.doc_id
|
||||
|
||||
elif key == "closed" and review_req.state_id not in ("requested", "accepted"):
|
||||
e = ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc, review_request=review_req).first()
|
||||
if not e:
|
||||
e = ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc, review_request=review_req)
|
||||
e.time = parse_timestamp(timestamp)
|
||||
e.by = by
|
||||
e.state = states.get(state) if state else None
|
||||
if e.state_id == "rejected":
|
||||
e.desc = "Assignment of request for {} review by {} to {} was rejected".format(
|
||||
review_req.type.name,
|
||||
review_req.team.acronym.upper(),
|
||||
review_req.reviewer.person,
|
||||
)
|
||||
elif e.state_id == "completed":
|
||||
e.desc = "Request for {} review by {} {}{}. Reviewer: {}.".format(
|
||||
review_req.type.name,
|
||||
review_req.team.acronym.upper(),
|
||||
review_req.state.name,
|
||||
": {}".format(review_req.result.name) if review_req.result else "",
|
||||
review_req.reviewer.person,
|
||||
)
|
||||
else:
|
||||
e.desc = "Closed request for {} review by {} with state '{}'".format(review_req.type.name, review_req.team.acronym.upper(), e.state.name)
|
||||
e.skip_community_list_notification = True
|
||||
e.save()
|
||||
completion_event = e
|
||||
print "imported event closed_review_request", e.desc, e.doc_id
|
||||
|
||||
if review_req.state_id == "completed":
|
||||
if not row.reviewurl: # don't have anything to store, so skip
|
||||
continue
|
||||
|
||||
if completion_event:
|
||||
completion_time = completion_event.time
|
||||
completion_by = completion_event.by
|
||||
else:
|
||||
completion_time = deadline
|
||||
completion_by = system_person
|
||||
|
||||
# create the review document
|
||||
if review_req.review:
|
||||
review = review_req.review
|
||||
else:
|
||||
for i in range(1, 100):
|
||||
name_components = [
|
||||
"review",
|
||||
strip_prefix(review_req.doc_id, "draft-"),
|
||||
review_req.reviewed_rev,
|
||||
review_req.team.acronym,
|
||||
review_req.type_id,
|
||||
review_req.reviewer.person.ascii_parts()[3],
|
||||
completion_time.date().isoformat(),
|
||||
]
|
||||
if i > 1:
|
||||
name_components.append(str(i))
|
||||
|
||||
name = "-".join(c for c in name_components if c).lower()
|
||||
if not Document.objects.filter(name=name).exists():
|
||||
review = Document.objects.create(name=name)
|
||||
DocAlias.objects.create(document=review, name=review.name)
|
||||
break
|
||||
|
||||
review.set_state(State.objects.get(type="review", slug="active"))
|
||||
|
||||
review.time = completion_time
|
||||
review.type = DocTypeName.objects.get(slug="review")
|
||||
review.rev = "00"
|
||||
review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, review_req.reviewed_rev)
|
||||
review.group = review_req.team
|
||||
review.external_url = row.reviewurl
|
||||
|
||||
existing = NewRevisionDocEvent.objects.filter(doc=review).first() or NewRevisionDocEvent(doc=review)
|
||||
e.type = "new_revision"
|
||||
e.by = completion_by
|
||||
e.rev = review.rev
|
||||
e.desc = 'New revision available'
|
||||
e.time = completion_time
|
||||
e.skip_community_list_notification = True
|
||||
|
||||
review.save_with_history([e])
|
||||
|
||||
review_req.review = review
|
||||
review_req.save()
|
||||
|
||||
print "imported review document", review_req.doc, review.name
|
||||
|
||||
if review_req.state_id in ("requested", "accepted") and review_req.doc.get_state_slug("draft-iesg") in ["approved", "ann", "rfcqueue", "pub"]:
|
||||
review_req.state = states["overtaken"]
|
||||
review_req.save()
|
||||
|
||||
if "closed" not in event_collection and "assigned" in event_collection:
|
||||
e = ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc, review_request=review_req).first()
|
||||
if not e:
|
||||
e = ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc, review_request=review_req)
|
||||
e.time = datetime.datetime.now()
|
||||
e.by = by
|
||||
e.state = review_req.state
|
||||
e.desc = "Closed request for {} review by {} with state '{}'".format(review_req.type.name, review_req.team.acronym.upper(), e.state.name)
|
||||
e.skip_community_list_notification = True
|
||||
e.save()
|
||||
completion_event = e
|
||||
print "imported event closed_review_request (generated upon closing)", e.desc, e.doc_id
|
||||
|
||||
|
||||
print "imported review request", row.reviewid, "as", review_req.pk, review_req.time, review_req.deadline, review_req.type, review_req.doc_id, review_req.state, review_req.doc.get_state_slug("draft-iesg")
|
95
ietf/review/mailarch.py
Normal file
95
ietf/review/mailarch.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
# various utilities for working with the mailarch mail archive at
|
||||
# mailarchive.ietf.org
|
||||
|
||||
import datetime, tarfile, mailbox, tempfile, hashlib, base64, email.utils
|
||||
import urllib
|
||||
import urllib2, contextlib
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
def list_name_from_email(list_email):
|
||||
if not list_email.endswith("@ietf.org"):
|
||||
return None
|
||||
|
||||
return list_email[:-len("@ietf.org")]
|
||||
|
||||
def hash_list_message_id(list_name, msgid):
|
||||
# hash in mailarch is computed similar to
|
||||
# https://www.mail-archive.com/faq.html#listserver except the list
|
||||
# name (without "@ietf.org") is used instead of the full address,
|
||||
# and rightmost "=" signs are (optionally) stripped
|
||||
sha = hashlib.sha1(msgid)
|
||||
sha.update(list_name)
|
||||
return base64.urlsafe_b64encode(sha.digest()).rstrip("=")
|
||||
|
||||
def construct_query_urls(review_req, query=None):
|
||||
list_name = list_name_from_email(review_req.team.list_email)
|
||||
if not list_name:
|
||||
return None
|
||||
|
||||
if not query:
|
||||
query = review_req.doc.name
|
||||
|
||||
encoded_query = "?" + urllib.urlencode({
|
||||
"qdr": "c", # custom time frame
|
||||
"start_date": (datetime.date.today() - datetime.timedelta(days=180)).isoformat(),
|
||||
"email_list": list_name,
|
||||
"q": "subject:({})".format(query),
|
||||
"as": "1", # this is an advanced search
|
||||
})
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"query_url": settings.MAILING_LIST_ARCHIVE_URL + "/arch/search/" + encoded_query,
|
||||
"query_data_url": settings.MAILING_LIST_ARCHIVE_URL + "/arch/export/mbox/" + encoded_query,
|
||||
}
|
||||
|
||||
def construct_message_url(list_name, msgid):
|
||||
return "{}/arch/msg/{}/{}".format(settings.MAILING_LIST_ARCHIVE_URL, list_name, hash_list_message_id(list_name, msgid))
|
||||
|
||||
def retrieve_messages_from_mbox(mbox_fileobj):
|
||||
"""Return selected content in message from mbox from mailarch."""
|
||||
res = []
|
||||
with tempfile.NamedTemporaryFile(suffix=".mbox") as mbox_file:
|
||||
# mailbox.mbox needs a path, so we need to put the contents
|
||||
# into a file
|
||||
mbox_data = mbox_fileobj.read()
|
||||
mbox_file.write(mbox_data)
|
||||
mbox_file.flush()
|
||||
|
||||
mbox = mailbox.mbox(mbox_file.name, create=False)
|
||||
for msg in mbox:
|
||||
content = u""
|
||||
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
content += part.get_payload(decode=True).decode(charset, "ignore")
|
||||
|
||||
res.append({
|
||||
"from": msg["From"],
|
||||
"subject": msg["Subject"],
|
||||
"content": content.replace("\r\n", "\n").replace("\r", "\n").strip("\n"),
|
||||
"message_id": email.utils.unquote(msg["Message-ID"]),
|
||||
"url": email.utils.unquote(msg["Archived-At"]),
|
||||
"date": msg["Date"],
|
||||
})
|
||||
|
||||
return res
|
||||
|
||||
def retrieve_messages(query_data_url):
|
||||
"""Retrieve and return selected content from mailarch."""
|
||||
res = []
|
||||
|
||||
with contextlib.closing(urllib2.urlopen(query_data_url, timeout=15)) as fileobj:
|
||||
content_type = fileobj.info()["Content-type"]
|
||||
if not content_type.startswith("application/x-tar"):
|
||||
raise Exception("Export failed - this usually means no matches were found")
|
||||
|
||||
with tarfile.open(fileobj=fileobj, mode='r|*') as tar:
|
||||
for entry in tar:
|
||||
if entry.isfile():
|
||||
mbox_fileobj = tar.extractfile(entry)
|
||||
res.extend(retrieve_messages_from_mbox(mbox_fileobj))
|
||||
|
||||
return res
|
115
ietf/review/migrations/0001_initial.py
Normal file
115
ietf/review/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,115 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import datetime
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('name', '0015_insert_review_name_data'),
|
||||
('group', '0008_auto_20160505_0523'),
|
||||
('person', '0014_auto_20160613_0751'),
|
||||
('doc', '0012_auto_20160207_0537'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NextReviewerInTeam',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('next_reviewer', models.ForeignKey(to='person.Person')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ResultUsedInReviewTeam',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('result', models.ForeignKey(to='name.ReviewResultName')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReviewerSettings',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('min_interval', models.IntegerField(default=30, verbose_name=b'Can review at most', choices=[(7, b'Once per week'), (14, b'Once per fortnight'), (30, b'Once per month'), (61, b'Once per two months'), (91, b'Once per quarter')])),
|
||||
('filter_re', models.CharField(help_text=b'Draft names matching regular expression should not be assigned', max_length=255, verbose_name=b'Filter regexp', blank=True)),
|
||||
('skip_next', models.IntegerField(default=0, verbose_name=b'Skip next assignments')),
|
||||
('remind_days_before_deadline', models.IntegerField(null=True, blank=True)),
|
||||
('person', models.ForeignKey(to='person.Person')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReviewRequest',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('old_id', models.IntegerField(help_text=b'ID in previous review system', null=True, blank=True)),
|
||||
('time', models.DateTimeField(default=datetime.datetime.now)),
|
||||
('deadline', models.DateField()),
|
||||
('requested_rev', models.CharField(help_text=b'Fill in if a specific revision is to be reviewed, e.g. 02', max_length=16, verbose_name=b'requested revision', blank=True)),
|
||||
('reviewed_rev', models.CharField(max_length=16, verbose_name=b'reviewed revision', blank=True)),
|
||||
('doc', models.ForeignKey(related_name='reviewrequest_set', to='doc.Document')),
|
||||
('requested_by', models.ForeignKey(to='person.Person')),
|
||||
('result', models.ForeignKey(blank=True, to='name.ReviewResultName', null=True)),
|
||||
('review', models.OneToOneField(null=True, blank=True, to='doc.Document')),
|
||||
('reviewer', models.ForeignKey(blank=True, to='person.Email', null=True)),
|
||||
('state', models.ForeignKey(to='name.ReviewRequestStateName')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
('type', models.ForeignKey(to='name.ReviewTypeName')),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReviewWish',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('time', models.DateTimeField(default=datetime.datetime.now)),
|
||||
('doc', models.ForeignKey(to='doc.Document')),
|
||||
('person', models.ForeignKey(to='person.Person')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TypeUsedInReviewTeam',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
('type', models.ForeignKey(to='name.ReviewTypeName')),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UnavailablePeriod',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('start_date', models.DateField(default=datetime.date.today, help_text=b"Choose the start date so that you can still do a review if it's assigned just before the start date - this usually means you should mark yourself unavailable for assignment some time before you are actually away.")),
|
||||
('end_date', models.DateField(help_text=b'Leaving the end date blank means that the period continues indefinitely. You can end it later.', null=True, blank=True)),
|
||||
('availability', models.CharField(max_length=30, choices=[(b'canfinish', b'Can do follow-ups'), (b'unavailable', b'Completely unavailable')])),
|
||||
('person', models.ForeignKey(to='person.Person')),
|
||||
('team', models.ForeignKey(to='group.Group')),
|
||||
],
|
||||
options={
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
]
|
0
ietf/review/migrations/__init__.py
Normal file
0
ietf/review/migrations/__init__.py
Normal file
145
ietf/review/models.py
Normal file
145
ietf/review/models.py
Normal file
|
@ -0,0 +1,145 @@
|
|||
import datetime
|
||||
|
||||
from django.db import models
|
||||
|
||||
from ietf.doc.models import Document
|
||||
from ietf.group.models import Group
|
||||
from ietf.person.models import Person, Email
|
||||
from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName
|
||||
|
||||
class ReviewerSettings(models.Model):
|
||||
"""Keeps track of admin data associated with the reviewer in the
|
||||
particular team. There will be one record for each combination of
|
||||
reviewer and team."""
|
||||
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
|
||||
person = models.ForeignKey(Person)
|
||||
INTERVALS = [
|
||||
(7, "Once per week"),
|
||||
(14, "Once per fortnight"),
|
||||
(30, "Once per month"),
|
||||
(61, "Once per two months"),
|
||||
(91, "Once per quarter"),
|
||||
]
|
||||
min_interval = models.IntegerField(default=30, verbose_name="Can review at most", choices=INTERVALS)
|
||||
filter_re = models.CharField(max_length=255, verbose_name="Filter regexp", blank=True, help_text="Draft names matching regular expression should not be assigned")
|
||||
skip_next = models.IntegerField(default=0, verbose_name="Skip next assignments")
|
||||
remind_days_before_deadline = models.IntegerField(null=True, blank=True, help_text="To get an email reminder in case you forget to do an assigned review, enter the number of days before a review deadline you want to receive it. Clear the field if you don't want a reminder.")
|
||||
|
||||
def __unicode__(self):
|
||||
return u"{} in {}".format(self.person, self.team)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "reviewer settings"
|
||||
|
||||
class UnavailablePeriod(models.Model):
|
||||
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
|
||||
person = models.ForeignKey(Person)
|
||||
start_date = models.DateField(default=datetime.date.today, help_text="Choose the start date so that you can still do a review if it's assigned just before the start date - this usually means you should mark yourself unavailable for assignment some time before you are actually away.")
|
||||
end_date = models.DateField(blank=True, null=True, help_text="Leaving the end date blank means that the period continues indefinitely. You can end it later.")
|
||||
AVAILABILITY_CHOICES = [
|
||||
("canfinish", "Can do follow-ups"),
|
||||
("unavailable", "Completely unavailable"),
|
||||
]
|
||||
LONG_AVAILABILITY_CHOICES = [
|
||||
("canfinish", "Can do follow-up reviews and finish outstanding reviews"),
|
||||
("unavailable", "Completely unavailable - reassign any outstanding reviews"),
|
||||
]
|
||||
availability = models.CharField(max_length=30, choices=AVAILABILITY_CHOICES)
|
||||
|
||||
def state(self):
|
||||
import datetime
|
||||
today = datetime.date.today()
|
||||
if self.start_date <= today:
|
||||
if not self.end_date or today <= self.end_date:
|
||||
return "active"
|
||||
else:
|
||||
return "past"
|
||||
else:
|
||||
return "future"
|
||||
|
||||
def __unicode__(self):
|
||||
return u"{} is unavailable in {} {} - {}".format(self.person, self.team.acronym, self.start_date, self.end_date or "")
|
||||
|
||||
class ReviewWish(models.Model):
|
||||
"""Reviewer wishes to review a document when it becomes available for review."""
|
||||
time = models.DateTimeField(default=datetime.datetime.now)
|
||||
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
|
||||
person = models.ForeignKey(Person)
|
||||
doc = models.ForeignKey(Document)
|
||||
|
||||
def __unicode__(self):
|
||||
return u"{} wishes to review {} in {}".format(self.person, self.doc.name, self.team.acronym)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "review wishes"
|
||||
|
||||
class ResultUsedInReviewTeam(models.Model):
|
||||
"""Captures that a result name is valid for a given team for new
|
||||
reviews. This also implicitly defines which teams are review
|
||||
teams - if there are no possible review results valid for a given
|
||||
team, it can't be a review team."""
|
||||
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
|
||||
result = models.ForeignKey(ReviewResultName)
|
||||
|
||||
def __unicode__(self):
|
||||
return u"{} in {}".format(self.result.name, self.team.acronym)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "review result used in review team setting"
|
||||
verbose_name_plural = "review result used in review team settings"
|
||||
|
||||
class TypeUsedInReviewTeam(models.Model):
|
||||
"""Captures that a type name is valid for a given team for new
|
||||
reviews. """
|
||||
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
|
||||
type = models.ForeignKey(ReviewTypeName)
|
||||
|
||||
def __unicode__(self):
|
||||
return u"{} in {}".format(self.type.name, self.team.acronym)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "review type used in review team setting"
|
||||
verbose_name_plural = "review type used in review team settings"
|
||||
|
||||
class NextReviewerInTeam(models.Model):
|
||||
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
|
||||
next_reviewer = models.ForeignKey(Person)
|
||||
|
||||
def __unicode__(self):
|
||||
return u"{} next in {}".format(self.next_reviewer, self.team)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "next reviewer in team setting"
|
||||
verbose_name_plural = "next reviewer in team settings"
|
||||
|
||||
class ReviewRequest(models.Model):
|
||||
"""Represents a request for a review and the process it goes through.
|
||||
There should be one ReviewRequest entered for each combination of
|
||||
document, rev, and reviewer."""
|
||||
state = models.ForeignKey(ReviewRequestStateName)
|
||||
|
||||
old_id = models.IntegerField(blank=True, null=True, help_text="ID in previous review system") # FIXME: remove this when everything has been migrated
|
||||
|
||||
# Fields filled in on the initial record creation - these
|
||||
# constitute the request part.
|
||||
time = models.DateTimeField(default=datetime.datetime.now)
|
||||
type = models.ForeignKey(ReviewTypeName)
|
||||
doc = models.ForeignKey(Document, related_name='reviewrequest_set')
|
||||
team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None))
|
||||
deadline = models.DateField()
|
||||
requested_by = models.ForeignKey(Person)
|
||||
requested_rev = models.CharField(verbose_name="requested revision", max_length=16, blank=True, help_text="Fill in if a specific revision is to be reviewed, e.g. 02")
|
||||
|
||||
# Fields filled in as reviewer is assigned and as the review is
|
||||
# uploaded. Once these are filled in and we progress beyond being
|
||||
# requested/assigned, any changes to the assignment happens by
|
||||
# closing down the current request and making a new one, copying
|
||||
# the request-part fields above.
|
||||
reviewer = models.ForeignKey(Email, blank=True, null=True)
|
||||
|
||||
review = models.OneToOneField(Document, blank=True, null=True)
|
||||
reviewed_rev = models.CharField(verbose_name="reviewed revision", max_length=16, blank=True)
|
||||
result = models.ForeignKey(ReviewResultName, blank=True, null=True)
|
||||
|
||||
def __unicode__(self):
|
||||
return u"%s review on %s by %s %s" % (self.type, self.doc, self.team, self.state)
|
166
ietf/review/resources.py
Normal file
166
ietf/review/resources.py
Normal file
|
@ -0,0 +1,166 @@
|
|||
# Autogenerated by the makeresources management command 2016-06-14 04:21 PDT
|
||||
from tastypie.resources import ModelResource
|
||||
from tastypie.fields import ToManyField # pyflakes:ignore
|
||||
from tastypie.constants import ALL, ALL_WITH_RELATIONS # pyflakes:ignore
|
||||
from tastypie.cache import SimpleCache
|
||||
|
||||
from ietf import api
|
||||
from ietf.api import ToOneField # pyflakes:ignore
|
||||
|
||||
from ietf.review.models import (ReviewerSettings, ReviewRequest,
|
||||
ResultUsedInReviewTeam, TypeUsedInReviewTeam,
|
||||
UnavailablePeriod, ReviewWish, NextReviewerInTeam)
|
||||
|
||||
|
||||
from ietf.person.resources import PersonResource
|
||||
from ietf.group.resources import GroupResource
|
||||
class ReviewerSettingsResource(ModelResource):
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
person = ToOneField(PersonResource, 'person')
|
||||
class Meta:
|
||||
queryset = ReviewerSettings.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'reviewer'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"min_interval": ALL,
|
||||
"filter_re": ALL,
|
||||
"skip_next": ALL,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"person": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(ReviewerSettingsResource())
|
||||
|
||||
from ietf.doc.resources import DocumentResource
|
||||
from ietf.group.resources import RoleResource, GroupResource
|
||||
from ietf.name.resources import ReviewRequestStateNameResource, ReviewResultNameResource, ReviewTypeNameResource
|
||||
class ReviewRequestResource(ModelResource):
|
||||
state = ToOneField(ReviewRequestStateNameResource, 'state')
|
||||
type = ToOneField(ReviewTypeNameResource, 'type')
|
||||
doc = ToOneField(DocumentResource, 'doc')
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
reviewer = ToOneField(RoleResource, 'reviewer', null=True)
|
||||
review = ToOneField(DocumentResource, 'review', null=True)
|
||||
result = ToOneField(ReviewResultNameResource, 'result', null=True)
|
||||
class Meta:
|
||||
queryset = ReviewRequest.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'reviewrequest'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"time": ALL,
|
||||
"deadline": ALL,
|
||||
"requested_rev": ALL,
|
||||
"reviewed_rev": ALL,
|
||||
"state": ALL_WITH_RELATIONS,
|
||||
"type": ALL_WITH_RELATIONS,
|
||||
"doc": ALL_WITH_RELATIONS,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"reviewer": ALL_WITH_RELATIONS,
|
||||
"review": ALL_WITH_RELATIONS,
|
||||
"result": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(ReviewRequestResource())
|
||||
|
||||
|
||||
|
||||
from ietf.group.resources import GroupResource
|
||||
from ietf.name.resources import ReviewResultNameResource
|
||||
class ResultUsedInReviewTeamResource(ModelResource):
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
result = ToOneField(ReviewResultNameResource, 'result')
|
||||
class Meta:
|
||||
queryset = ResultUsedInReviewTeam.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'resultusedinreviewteam'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"result": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(ResultUsedInReviewTeamResource())
|
||||
|
||||
|
||||
|
||||
from ietf.person.resources import PersonResource
|
||||
from ietf.group.resources import GroupResource
|
||||
class UnavailablePeriodResource(ModelResource):
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
person = ToOneField(PersonResource, 'person')
|
||||
class Meta:
|
||||
queryset = UnavailablePeriod.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'unavailableperiod'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"start_date": ALL,
|
||||
"end_date": ALL,
|
||||
"availability": ALL,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"person": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(UnavailablePeriodResource())
|
||||
|
||||
from ietf.person.resources import PersonResource
|
||||
from ietf.group.resources import GroupResource
|
||||
from ietf.doc.resources import DocumentResource
|
||||
class ReviewWishResource(ModelResource):
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
person = ToOneField(PersonResource, 'person')
|
||||
doc = ToOneField(DocumentResource, 'doc')
|
||||
class Meta:
|
||||
queryset = ReviewWish.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'reviewwish'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"time": ALL,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"person": ALL_WITH_RELATIONS,
|
||||
"doc": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(ReviewWishResource())
|
||||
|
||||
|
||||
|
||||
from ietf.person.resources import PersonResource
|
||||
from ietf.group.resources import GroupResource
|
||||
class NextReviewerInTeamResource(ModelResource):
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
next_reviewer = ToOneField(PersonResource, 'next_reviewer')
|
||||
class Meta:
|
||||
queryset = NextReviewerInTeam.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'nextreviewerinteam'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"next_reviewer": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(NextReviewerInTeamResource())
|
||||
|
||||
|
||||
|
||||
from ietf.group.resources import GroupResource
|
||||
from ietf.name.resources import ReviewTypeNameResource
|
||||
class TypeUsedInReviewTeamResource(ModelResource):
|
||||
team = ToOneField(GroupResource, 'team')
|
||||
type = ToOneField(ReviewTypeNameResource, 'type')
|
||||
class Meta:
|
||||
queryset = TypeUsedInReviewTeam.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'typeusedinreviewteam'
|
||||
filtering = {
|
||||
"id": ALL,
|
||||
"team": ALL_WITH_RELATIONS,
|
||||
"type": ALL_WITH_RELATIONS,
|
||||
}
|
||||
api.review.register(TypeUsedInReviewTeamResource())
|
||||
|
773
ietf/review/utils.py
Normal file
773
ietf/review/utils.py
Normal file
|
@ -0,0 +1,773 @@
|
|||
import datetime, re, itertools
|
||||
from collections import defaultdict, namedtuple
|
||||
|
||||
from django.db.models import Q, Max, F
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
from ietf.group.models import Group, Role
|
||||
from ietf.doc.models import (Document, ReviewRequestDocEvent, State,
|
||||
LastCallDocEvent, DocumentAuthor, DocAlias)
|
||||
from ietf.iesg.models import TelechatDate
|
||||
from ietf.person.models import Person, Email
|
||||
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream
|
||||
from ietf.review.models import (ReviewRequest, ReviewRequestStateName, ReviewTypeName, TypeUsedInReviewTeam,
|
||||
ReviewerSettings, UnavailablePeriod, ReviewWish, NextReviewerInTeam)
|
||||
from ietf.utils.mail import send_mail
|
||||
from ietf.doc.utils import extract_complete_replaces_ancestor_mapping_for_docs
|
||||
|
||||
def active_review_teams():
|
||||
# if there's a ResultUsedInReviewTeam defined, it's a review team
|
||||
return Group.objects.filter(state="active").exclude(resultusedinreviewteam=None)
|
||||
|
||||
def close_review_request_states():
|
||||
return ReviewRequestStateName.objects.filter(used=True).exclude(slug__in=["requested", "accepted", "rejected", "part-completed", "completed"])
|
||||
|
||||
def can_request_review_of_doc(user, doc):
|
||||
if not user.is_authenticated():
|
||||
return False
|
||||
|
||||
return is_authorized_in_doc_stream(user, doc)
|
||||
|
||||
def can_manage_review_requests_for_team(user, team, allow_non_team_personnel=True):
|
||||
if not user.is_authenticated():
|
||||
return False
|
||||
|
||||
return (Role.objects.filter(name__in=["secr", "delegate"], person__user=user, group=team).exists()
|
||||
or (allow_non_team_personnel and has_role(user, "Secretariat")))
|
||||
|
||||
def review_requests_to_list_for_docs(docs):
|
||||
request_qs = ReviewRequest.objects.filter(
|
||||
state__in=["requested", "accepted", "part-completed", "completed"],
|
||||
).prefetch_related("result")
|
||||
|
||||
doc_names = [d.name for d in docs]
|
||||
|
||||
return extract_revision_ordered_review_requests_for_documents_and_replaced(request_qs, doc_names)
|
||||
|
||||
def no_review_from_teams_on_doc(doc, rev):
|
||||
return Group.objects.filter(
|
||||
reviewrequest__doc=doc,
|
||||
reviewrequest__reviewed_rev=rev,
|
||||
reviewrequest__state="no-review-version",
|
||||
).distinct()
|
||||
|
||||
def unavailable_periods_to_list(past_days=14):
|
||||
return UnavailablePeriod.objects.filter(
|
||||
Q(end_date=None) | Q(end_date__gte=datetime.date.today() - datetime.timedelta(days=past_days)),
|
||||
).order_by("start_date")
|
||||
|
||||
def current_unavailable_periods_for_reviewers(team):
|
||||
"""Return dict with currently active unavailable periods for reviewers."""
|
||||
today = datetime.date.today()
|
||||
|
||||
unavailable_period_qs = UnavailablePeriod.objects.filter(
|
||||
Q(end_date__gte=today) | Q(end_date=None),
|
||||
start_date__lte=today,
|
||||
team=team,
|
||||
).order_by("end_date")
|
||||
|
||||
res = defaultdict(list)
|
||||
for period in unavailable_period_qs:
|
||||
res[period.person_id].append(period)
|
||||
|
||||
return res
|
||||
|
||||
def reviewer_rotation_list(team, skip_unavailable=False, dont_skip=[]):
|
||||
"""Returns person id -> index in rotation (next reviewer has index 0)."""
|
||||
reviewers = list(Person.objects.filter(role__name="reviewer", role__group=team))
|
||||
reviewers.sort(key=lambda p: p.last_name())
|
||||
|
||||
next_reviewer_index = 0
|
||||
|
||||
# now to figure out where the rotation is currently at
|
||||
saved_reviewer = NextReviewerInTeam.objects.filter(team=team).select_related("next_reviewer").first()
|
||||
if saved_reviewer:
|
||||
n = saved_reviewer.next_reviewer
|
||||
|
||||
if n not in reviewers:
|
||||
# saved reviewer might not still be here, if not just
|
||||
# insert and use that position (Python will wrap around,
|
||||
# so no harm done by using the index on the original list
|
||||
# afterwards)
|
||||
reviewers_with_next = reviewers[:] + [n]
|
||||
reviewers_with_next.sort(key=lambda p: p.last_name())
|
||||
next_reviewer_index = reviewers_with_next.index(n)
|
||||
else:
|
||||
next_reviewer_index = reviewers.index(n)
|
||||
|
||||
rotation_list = reviewers[next_reviewer_index:] + reviewers[:next_reviewer_index]
|
||||
|
||||
if skip_unavailable:
|
||||
# prune reviewers not in the rotation (but not the assigned
|
||||
# reviewer who must have been available for assignment anyway)
|
||||
reviewers_to_skip = set()
|
||||
|
||||
unavailable_periods = current_unavailable_periods_for_reviewers(team)
|
||||
for person_id, periods in unavailable_periods.iteritems():
|
||||
if periods and person_id not in dont_skip:
|
||||
reviewers_to_skip.add(person_id)
|
||||
|
||||
days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(team)
|
||||
for person_id, days_needed in days_needed_for_reviewers.iteritems():
|
||||
if days_needed > 0 and person_id not in dont_skip:
|
||||
reviewers_to_skip.add(person_id)
|
||||
|
||||
rotation_list = [p.pk for p in rotation_list if p.pk not in reviewers_to_skip]
|
||||
|
||||
return rotation_list
|
||||
|
||||
def days_needed_to_fulfill_min_interval_for_reviewers(team):
|
||||
"""Returns person_id -> days needed until min_interval is fulfilled for
|
||||
reviewer."""
|
||||
latest_assignments = dict(ReviewRequest.objects.filter(
|
||||
team=team,
|
||||
).values_list("reviewer__person").annotate(Max("time")))
|
||||
|
||||
min_intervals = dict(ReviewerSettings.objects.filter(team=team).values_list("person_id", "min_interval"))
|
||||
|
||||
default_min_interval = ReviewerSettings(team=team).min_interval
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
res = {}
|
||||
for person_id, latest_assignment_time in latest_assignments.iteritems():
|
||||
if latest_assignment_time is not None:
|
||||
min_interval = min_intervals.get(person_id, default_min_interval)
|
||||
|
||||
days_needed = max(0, min_interval - (now - latest_assignment_time).days)
|
||||
|
||||
if days_needed > 0:
|
||||
res[person_id] = days_needed
|
||||
|
||||
return res
|
||||
|
||||
ReviewRequestData = namedtuple("ReviewRequestData", [
|
||||
"req_pk", "doc", "doc_pages", "req_time", "state", "deadline", "result", "team", "reviewer",
|
||||
"late_days",
|
||||
"request_to_assignment_days", "assignment_to_closure_days", "request_to_closure_days"])
|
||||
|
||||
|
||||
def extract_review_request_data(teams=None, reviewers=None, time_from=None, time_to=None, ordering=[]):
|
||||
"""Yield data on each review request, sorted by (*ordering, time)
|
||||
for easy use with itertools.groupby. Valid entries in *ordering are "team" and "reviewer"."""
|
||||
|
||||
filters = Q()
|
||||
|
||||
if teams:
|
||||
filters &= Q(team__in=teams)
|
||||
|
||||
if reviewers:
|
||||
filters &= Q(reviewer__person__in=reviewers)
|
||||
|
||||
if time_from:
|
||||
filters &= Q(time__gte=time_from)
|
||||
|
||||
if time_to:
|
||||
filters &= Q(time__lte=time_to)
|
||||
|
||||
# we may be dealing with a big bunch of data, so treat it carefully
|
||||
event_qs = ReviewRequest.objects.filter(filters)
|
||||
|
||||
# left outer join with RequestRequestDocEvent for request/assign/close time
|
||||
event_qs = event_qs.values_list(
|
||||
"pk", "doc", "doc__pages", "time", "state", "deadline", "result", "team",
|
||||
"reviewer__person", "reviewrequestdocevent__time", "reviewrequestdocevent__type"
|
||||
)
|
||||
|
||||
event_qs = event_qs.order_by(*[o.replace("reviewer", "reviewer__person") for o in ordering] + ["time", "pk", "-reviewrequestdocevent__time"])
|
||||
|
||||
def positive_days(time_from, time_to):
|
||||
if time_from is None or time_to is None:
|
||||
return None
|
||||
|
||||
delta = time_to - time_from
|
||||
seconds = delta.total_seconds()
|
||||
if seconds > 0:
|
||||
return seconds / float(24 * 60 * 60)
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
for _, events in itertools.groupby(event_qs.iterator(), lambda t: t[0]):
|
||||
requested_time = assigned_time = closed_time = None
|
||||
|
||||
for e in events:
|
||||
req_pk, doc, doc_pages, req_time, state, deadline, result, team, reviewer, event_time, event_type = e
|
||||
|
||||
if event_type == "requested_review" and requested_time is None:
|
||||
requested_time = event_time
|
||||
elif event_type == "assigned_review_request" and assigned_time is None:
|
||||
assigned_time = event_time
|
||||
elif event_type == "closed_review_request" and closed_time is None:
|
||||
closed_time = event_time
|
||||
|
||||
late_days = positive_days(datetime.datetime.combine(deadline, datetime.time.max), closed_time)
|
||||
request_to_assignment_days = positive_days(requested_time, assigned_time)
|
||||
assignment_to_closure_days = positive_days(assigned_time, closed_time)
|
||||
request_to_closure_days = positive_days(requested_time, closed_time)
|
||||
|
||||
d = ReviewRequestData(req_pk, doc, doc_pages, req_time, state, deadline, result, team, reviewer,
|
||||
late_days, request_to_assignment_days, assignment_to_closure_days,
|
||||
request_to_closure_days)
|
||||
|
||||
yield d
|
||||
|
||||
def aggregate_review_request_stats(review_request_data, count=None):
|
||||
"""Take a sequence of review request data from
|
||||
extract_review_request_data and compute aggregated statistics."""
|
||||
|
||||
state_dict = defaultdict(int)
|
||||
late_state_dict = defaultdict(int)
|
||||
result_dict = defaultdict(int)
|
||||
assignment_to_closure_days_list = []
|
||||
assignment_to_closure_days_count = 0
|
||||
|
||||
for (req_pk, doc, doc_pages, req_time, state, deadline, result, team, reviewer,
|
||||
late_days, request_to_assignment_days, assignment_to_closure_days, request_to_closure_days) in review_request_data:
|
||||
if count == "pages":
|
||||
c = doc_pages
|
||||
else:
|
||||
c = 1
|
||||
|
||||
state_dict[state] += c
|
||||
|
||||
if late_days is not None and late_days > 0:
|
||||
late_state_dict[state] += c
|
||||
|
||||
if state in ("completed", "part-completed"):
|
||||
result_dict[result] += c
|
||||
if assignment_to_closure_days is not None:
|
||||
assignment_to_closure_days_list.append(assignment_to_closure_days)
|
||||
assignment_to_closure_days_count += c
|
||||
|
||||
res = {}
|
||||
res["state"] = state_dict
|
||||
res["result"] = result_dict
|
||||
|
||||
res["open"] = sum(state_dict.get(s, 0) for s in ("requested", "accepted"))
|
||||
res["completed"] = sum(state_dict.get(s, 0) for s in ("completed", "part-completed"))
|
||||
res["not_completed"] = sum(state_dict.get(s, 0) for s in state_dict if s in ("rejected", "withdrawn", "overtaken", "no-response"))
|
||||
|
||||
res["open_late"] = sum(late_state_dict.get(s, 0) for s in ("requested", "accepted"))
|
||||
res["open_in_time"] = res["open"] - res["open_late"]
|
||||
res["completed_late"] = sum(late_state_dict.get(s, 0) for s in ("completed", "part-completed"))
|
||||
res["completed_in_time"] = res["completed"] - res["completed_late"]
|
||||
|
||||
res["average_assignment_to_closure_days"] = float(sum(assignment_to_closure_days_list)) / (assignment_to_closure_days_count or 1) if assignment_to_closure_days_list else None
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def make_new_review_request_from_existing(review_req):
|
||||
obj = ReviewRequest()
|
||||
obj.time = review_req.time
|
||||
obj.type = review_req.type
|
||||
obj.doc = review_req.doc
|
||||
obj.team = review_req.team
|
||||
obj.deadline = review_req.deadline
|
||||
obj.requested_rev = review_req.requested_rev
|
||||
obj.requested_by = review_req.requested_by
|
||||
obj.state = ReviewRequestStateName.objects.get(slug="requested")
|
||||
return obj
|
||||
|
||||
def email_review_request_change(request, review_req, subject, msg, by, notify_secretary, notify_reviewer, notify_requested_by):
|
||||
|
||||
"""Notify stakeholders about change, skipping a party if the change
|
||||
was done by that party."""
|
||||
|
||||
system_email = Person.objects.get(name="(System)").formatted_email()
|
||||
|
||||
to = []
|
||||
|
||||
def extract_email_addresses(objs):
|
||||
if any(o.person == by for o in objs if o):
|
||||
l = []
|
||||
else:
|
||||
l = []
|
||||
for o in objs:
|
||||
if o:
|
||||
e = o.formatted_email()
|
||||
if e != system_email:
|
||||
l.append(e)
|
||||
|
||||
for e in l:
|
||||
if e not in to:
|
||||
to.append(e)
|
||||
|
||||
if notify_secretary:
|
||||
extract_email_addresses(Role.objects.filter(name="secr", group=review_req.team).distinct())
|
||||
if notify_reviewer:
|
||||
extract_email_addresses([review_req.reviewer])
|
||||
if notify_requested_by:
|
||||
extract_email_addresses([review_req.requested_by.email()])
|
||||
|
||||
if not to:
|
||||
return
|
||||
|
||||
url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": review_req.doc.name, "request_id": review_req.pk })
|
||||
url = request.build_absolute_uri(url)
|
||||
send_mail(request, to, None, subject, "review/review_request_changed.txt", {
|
||||
"review_req_url": url,
|
||||
"review_req": review_req,
|
||||
"msg": msg,
|
||||
})
|
||||
|
||||
def email_reviewer_availability_change(request, team, reviewer_role, msg, by):
|
||||
"""Notify possibly both secretary and reviewer about change, skipping
|
||||
a party if the change was done by that party."""
|
||||
|
||||
system_email = Person.objects.get(name="(System)").formatted_email()
|
||||
|
||||
to = []
|
||||
|
||||
def extract_email_addresses(objs):
|
||||
if any(o.person == by for o in objs if o):
|
||||
l = []
|
||||
else:
|
||||
l = []
|
||||
for o in objs:
|
||||
if o:
|
||||
e = o.formatted_email()
|
||||
if e != system_email:
|
||||
l.append(e)
|
||||
|
||||
for e in l:
|
||||
if e not in to:
|
||||
to.append(e)
|
||||
|
||||
extract_email_addresses(Role.objects.filter(name="secr", group=team).distinct())
|
||||
|
||||
extract_email_addresses([reviewer_role])
|
||||
|
||||
if not to:
|
||||
return
|
||||
|
||||
subject = "Reviewer availability of {} changed in {}".format(reviewer_role.person, team.acronym)
|
||||
|
||||
url = urlreverse("ietf.group.views_review.reviewer_overview", kwargs={ "group_type": team.type_id, "acronym": team.acronym })
|
||||
url = request.build_absolute_uri(url)
|
||||
send_mail(request, to, None, subject, "review/reviewer_availability_changed.txt", {
|
||||
"reviewer_overview_url": url,
|
||||
"reviewer": reviewer_role.person,
|
||||
"team": team,
|
||||
"msg": msg,
|
||||
"by": by,
|
||||
})
|
||||
|
||||
def assign_review_request_to_reviewer(request, review_req, reviewer):
|
||||
assert review_req.state_id in ("requested", "accepted")
|
||||
|
||||
if reviewer == review_req.reviewer:
|
||||
return
|
||||
|
||||
if review_req.reviewer:
|
||||
email_review_request_change(
|
||||
request, review_req,
|
||||
"Unassigned from review of %s" % review_req.doc.name,
|
||||
"%s has cancelled your assignment to the review." % request.user.person,
|
||||
by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=False)
|
||||
|
||||
review_req.state = ReviewRequestStateName.objects.get(slug="requested")
|
||||
review_req.reviewer = reviewer
|
||||
review_req.save()
|
||||
|
||||
if review_req.reviewer:
|
||||
possibly_advance_next_reviewer_for_team(review_req.team, review_req.reviewer.person_id)
|
||||
|
||||
ReviewRequestDocEvent.objects.create(
|
||||
type="assigned_review_request",
|
||||
doc=review_req.doc,
|
||||
by=request.user.person,
|
||||
desc="Request for {} review by {} is assigned to {}".format(
|
||||
review_req.type.name,
|
||||
review_req.team.acronym.upper(),
|
||||
review_req.reviewer.person if review_req.reviewer else "(None)",
|
||||
),
|
||||
review_request=review_req,
|
||||
state=None,
|
||||
)
|
||||
|
||||
email_review_request_change(
|
||||
request, review_req,
|
||||
"Assigned to review %s" % review_req.doc.name,
|
||||
"%s has assigned you to review the document." % request.user.person,
|
||||
by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=False)
|
||||
|
||||
def possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id):
|
||||
assert assigned_review_to_person_id is not None
|
||||
|
||||
rotation_list = reviewer_rotation_list(team, skip_unavailable=True, dont_skip=[assigned_review_to_person_id])
|
||||
|
||||
def reviewer_at_index(i):
|
||||
if not rotation_list:
|
||||
return None
|
||||
|
||||
return rotation_list[i % len(rotation_list)]
|
||||
|
||||
def reviewer_settings_for(person_id):
|
||||
return (ReviewerSettings.objects.filter(team=team, person=person_id).first()
|
||||
or ReviewerSettings(team=team, person_id=person_id))
|
||||
|
||||
current_i = 0
|
||||
|
||||
if assigned_review_to_person_id == reviewer_at_index(current_i):
|
||||
# move 1 ahead
|
||||
current_i += 1
|
||||
else:
|
||||
settings = reviewer_settings_for(assigned_review_to_person_id)
|
||||
settings.skip_next += 1
|
||||
settings.save()
|
||||
|
||||
if not rotation_list:
|
||||
return
|
||||
|
||||
while True:
|
||||
# as a clean-up step go through any with a skip next > 0
|
||||
current_reviewer_person_id = reviewer_at_index(current_i)
|
||||
settings = reviewer_settings_for(current_reviewer_person_id)
|
||||
if settings.skip_next > 0:
|
||||
settings.skip_next -= 1
|
||||
settings.save()
|
||||
|
||||
current_i += 1
|
||||
else:
|
||||
nr = NextReviewerInTeam.objects.filter(team=team).first() or NextReviewerInTeam(team=team)
|
||||
nr.next_reviewer_id = current_reviewer_person_id
|
||||
nr.save()
|
||||
|
||||
break
|
||||
|
||||
def close_review_request(request, review_req, close_state):
|
||||
suggested_req = review_req.pk is None
|
||||
|
||||
prev_state = review_req.state
|
||||
review_req.state = close_state
|
||||
if close_state.slug == "no-review-version":
|
||||
review_req.reviewed_rev = review_req.requested_rev or review_req.doc.rev # save rev for later reference
|
||||
review_req.save()
|
||||
|
||||
if not suggested_req:
|
||||
ReviewRequestDocEvent.objects.create(
|
||||
type="closed_review_request",
|
||||
doc=review_req.doc,
|
||||
by=request.user.person,
|
||||
desc="Closed request for {} review by {} with state '{}'".format(
|
||||
review_req.type.name, review_req.team.acronym.upper(), close_state.name),
|
||||
review_request=review_req,
|
||||
state=review_req.state,
|
||||
)
|
||||
|
||||
if prev_state.slug != "requested":
|
||||
email_review_request_change(
|
||||
request, review_req,
|
||||
"Closed review request for {}: {}".format(review_req.doc.name, close_state.name),
|
||||
"Review request has been closed by {}.".format(request.user.person),
|
||||
by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=True)
|
||||
|
||||
def suggested_review_requests_for_team(team):
|
||||
system_person = Person.objects.get(name="(System)")
|
||||
|
||||
seen_deadlines = {}
|
||||
|
||||
requests = {}
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
requested_state = ReviewRequestStateName.objects.get(slug="requested", used=True)
|
||||
|
||||
last_call_type = ReviewTypeName.objects.get(slug="lc")
|
||||
if TypeUsedInReviewTeam.objects.filter(team=team, type=last_call_type).exists():
|
||||
# in Last Call
|
||||
last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True))
|
||||
last_call_expiry_events = { e.doc_id: e for e in LastCallDocEvent.objects.order_by("time", "id") }
|
||||
for doc in last_call_docs:
|
||||
e = last_call_expiry_events[doc.pk] if doc.pk in last_call_expiry_events else LastCallDocEvent(expires=now.date(), time=now)
|
||||
|
||||
deadline = e.expires.date()
|
||||
|
||||
if deadline > seen_deadlines.get(doc.pk, datetime.date.max) or deadline < now.date():
|
||||
continue
|
||||
|
||||
requests[doc.pk] = ReviewRequest(
|
||||
time=e.time,
|
||||
type=last_call_type,
|
||||
doc=doc,
|
||||
team=team,
|
||||
deadline=deadline,
|
||||
requested_by=system_person,
|
||||
state=requested_state,
|
||||
)
|
||||
|
||||
seen_deadlines[doc.pk] = deadline
|
||||
|
||||
|
||||
telechat_type = ReviewTypeName.objects.get(slug="telechat")
|
||||
if TypeUsedInReviewTeam.objects.filter(team=team, type=telechat_type).exists():
|
||||
# on Telechat Agenda
|
||||
telechat_dates = list(TelechatDate.objects.active().order_by('date').values_list("date", flat=True)[:4])
|
||||
|
||||
telechat_deadline_delta = datetime.timedelta(days=2)
|
||||
|
||||
telechat_docs = Document.objects.filter(
|
||||
docevent__telechatdocevent__telechat_date__in=telechat_dates
|
||||
).values_list(
|
||||
"pk", "docevent__telechatdocevent__time", "docevent__telechatdocevent__telechat_date"
|
||||
).order_by("pk", "docevent__telechatdocevent__telechat_date")
|
||||
for doc_pk, events in itertools.groupby(telechat_docs, lambda t: t[0]):
|
||||
event_time = deadline = None
|
||||
for _, event_time, event_telechat_date in events:
|
||||
if event_telechat_date in telechat_dates:
|
||||
deadline = event_telechat_date - telechat_deadline_delta
|
||||
break
|
||||
|
||||
if not deadline or deadline > seen_deadlines.get(doc_pk, datetime.date.max):
|
||||
continue
|
||||
|
||||
requests[doc_pk] = ReviewRequest(
|
||||
time=event_time,
|
||||
type=telechat_type,
|
||||
doc_id=doc_pk,
|
||||
team=team,
|
||||
deadline=deadline,
|
||||
requested_by=system_person,
|
||||
state=requested_state,
|
||||
)
|
||||
|
||||
seen_deadlines[doc_pk] = deadline
|
||||
|
||||
# filter those with existing requests
|
||||
existing_requests = defaultdict(list)
|
||||
for r in ReviewRequest.objects.filter(doc__in=requests.iterkeys()):
|
||||
existing_requests[r.doc_id].append(r)
|
||||
|
||||
def blocks(existing, request):
|
||||
if existing.doc_id != request.doc_id:
|
||||
return False
|
||||
|
||||
no_review_document = existing.state_id == "no-review-document"
|
||||
pending = (existing.state_id in ("requested", "accepted")
|
||||
and (not existing.requested_rev or existing.requested_rev == request.doc.rev))
|
||||
completed_or_closed = (existing.state_id not in ("part-completed", "rejected", "overtaken", "no-response")
|
||||
and existing.reviewed_rev == request.doc.rev)
|
||||
|
||||
return no_review_document or pending or completed_or_closed
|
||||
|
||||
res = [r for r in requests.itervalues()
|
||||
if not any(blocks(e, r) for e in existing_requests[r.doc_id])]
|
||||
res.sort(key=lambda r: (r.deadline, r.doc_id), reverse=True)
|
||||
return res
|
||||
|
||||
def extract_revision_ordered_review_requests_for_documents_and_replaced(review_request_queryset, names):
|
||||
"""Extracts all review requests for document names (including replaced ancestors), return them neatly sorted."""
|
||||
|
||||
names = set(names)
|
||||
|
||||
replaces = extract_complete_replaces_ancestor_mapping_for_docs(names)
|
||||
|
||||
requests_for_each_doc = defaultdict(list)
|
||||
for r in review_request_queryset.filter(doc__in=set(e for l in replaces.itervalues() for e in l) | names).order_by("-reviewed_rev", "-time", "-id").iterator():
|
||||
requests_for_each_doc[r.doc_id].append(r)
|
||||
|
||||
# now collect in breadth-first order to keep the revision order intact
|
||||
res = defaultdict(list)
|
||||
for name in names:
|
||||
front = replaces.get(name, [])
|
||||
res[name].extend(requests_for_each_doc.get(name, []))
|
||||
|
||||
seen = set()
|
||||
|
||||
while front:
|
||||
replaces_reqs = []
|
||||
next_front = []
|
||||
for replaces_name in front:
|
||||
if replaces_name in seen:
|
||||
continue
|
||||
|
||||
seen.add(replaces_name)
|
||||
|
||||
reqs = requests_for_each_doc.get(replaces_name, [])
|
||||
if reqs:
|
||||
replaces_reqs.append(reqs)
|
||||
|
||||
next_front.extend(replaces.get(replaces_name, []))
|
||||
|
||||
# in case there are multiple replaces, move the ones with
|
||||
# the latest reviews up front
|
||||
replaces_reqs.sort(key=lambda l: l[0].time, reverse=True)
|
||||
|
||||
for reqs in replaces_reqs:
|
||||
res[name].extend(reqs)
|
||||
|
||||
# move one level down
|
||||
front = next_front
|
||||
|
||||
return res
|
||||
|
||||
def setup_reviewer_field(field, review_req):
|
||||
field.queryset = field.queryset.filter(role__name="reviewer", role__group=review_req.team)
|
||||
if review_req.reviewer:
|
||||
field.initial = review_req.reviewer_id
|
||||
|
||||
choices = make_assignment_choices(field.queryset, review_req)
|
||||
if not field.required:
|
||||
choices = [("", field.empty_label)] + choices
|
||||
|
||||
field.choices = choices
|
||||
|
||||
def make_assignment_choices(email_queryset, review_req):
|
||||
doc = review_req.doc
|
||||
team = review_req.team
|
||||
|
||||
possible_emails = list(email_queryset)
|
||||
possible_person_ids = [e.person_id for e in possible_emails]
|
||||
|
||||
aliases = DocAlias.objects.filter(document=doc).values_list("name", flat=True)
|
||||
|
||||
# settings
|
||||
reviewer_settings = {
|
||||
r.person_id: r
|
||||
for r in ReviewerSettings.objects.filter(team=team, person__in=possible_person_ids)
|
||||
}
|
||||
|
||||
for p in possible_person_ids:
|
||||
if p not in reviewer_settings:
|
||||
reviewer_settings[p] = ReviewerSettings(team=team)
|
||||
|
||||
# frequency
|
||||
days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(team)
|
||||
|
||||
# rotation
|
||||
rotation_index = { p.pk: i for i, p in enumerate(reviewer_rotation_list(team)) }
|
||||
|
||||
# previous review of document
|
||||
has_reviewed_previous = ReviewRequest.objects.filter(
|
||||
doc=doc,
|
||||
reviewer__in=possible_emails,
|
||||
state="completed",
|
||||
)
|
||||
|
||||
if review_req.pk is not None:
|
||||
has_reviewed_previous = has_reviewed_previous.exclude(pk=review_req.pk)
|
||||
|
||||
has_reviewed_previous = set(has_reviewed_previous.values_list("reviewer", flat=True))
|
||||
|
||||
# review wishes
|
||||
wish_to_review = set(ReviewWish.objects.filter(team=team, person__in=possible_person_ids, doc=doc).values_list("person", flat=True))
|
||||
|
||||
# connections
|
||||
connections = {}
|
||||
# examine the closest connections last to let them override
|
||||
for e in Email.objects.filter(pk__in=possible_emails, person=doc.ad_id):
|
||||
connections[e] = "is associated Area Director"
|
||||
for r in Role.objects.filter(group=doc.group_id, email__in=possible_emails).select_related("name"):
|
||||
connections[r.email_id] = "is group {}".format(r.name)
|
||||
if doc.shepherd_id:
|
||||
connections[doc.shepherd_id] = "is shepherd of document"
|
||||
for e in DocumentAuthor.objects.filter(document=doc, author__in=possible_emails).values_list("author", flat=True):
|
||||
connections[e] = "is author of document"
|
||||
|
||||
# unavailable periods
|
||||
unavailable_periods = current_unavailable_periods_for_reviewers(team)
|
||||
|
||||
ranking = []
|
||||
for e in possible_emails:
|
||||
settings = reviewer_settings.get(e.person_id)
|
||||
|
||||
# we sort the reviewers by separate axes, listing the most
|
||||
# important things first
|
||||
scores = []
|
||||
explanations = []
|
||||
|
||||
def add_boolean_score(direction, expr, explanation=None):
|
||||
scores.append(direction if expr else -direction)
|
||||
if expr and explanation:
|
||||
explanations.append(explanation)
|
||||
|
||||
# unavailable for review periods
|
||||
periods = unavailable_periods.get(e.person_id, [])
|
||||
unavailable_at_the_moment = periods and not (e.pk in has_reviewed_previous and all(p.availability == "canfinish" for p in periods))
|
||||
add_boolean_score(-1, unavailable_at_the_moment)
|
||||
|
||||
def format_period(p):
|
||||
if p.end_date:
|
||||
res = "unavailable until {}".format(p.end_date.isoformat())
|
||||
else:
|
||||
res = "unavailable indefinitely"
|
||||
return "{} ({})".format(res, p.get_availability_display())
|
||||
|
||||
if periods:
|
||||
explanations.append(", ".join(format_period(p) for p in periods))
|
||||
|
||||
# minimum interval between reviews
|
||||
days_needed = days_needed_for_reviewers.get(e.person_id, 0)
|
||||
scores.append(-days_needed)
|
||||
if days_needed > 0:
|
||||
explanations.append("max frequency exceeded, ready in {} {}".format(days_needed, "day" if days_needed == 1 else "days"))
|
||||
|
||||
# misc
|
||||
add_boolean_score(+1, e.pk in has_reviewed_previous, "reviewed document before")
|
||||
add_boolean_score(+1, e.person_id in wish_to_review, "wishes to review document")
|
||||
add_boolean_score(-1, e.pk in connections, connections.get(e.pk)) # reviewer is somehow connected: bad
|
||||
add_boolean_score(-1, settings.filter_re and any(re.search(settings.filter_re, n) for n in aliases), "filter regexp matches")
|
||||
|
||||
# skip next
|
||||
scores.append(-settings.skip_next)
|
||||
if settings.skip_next > 0:
|
||||
explanations.append("skip next {}".format(settings.skip_next))
|
||||
|
||||
index = rotation_index.get(e.person_id, 0)
|
||||
scores.append(-index)
|
||||
explanations.append("#{}".format(index + 1))
|
||||
|
||||
label = unicode(e.person)
|
||||
if explanations:
|
||||
label = u"{}: {}".format(label, u"; ".join(explanations))
|
||||
|
||||
ranking.append({
|
||||
"email": e,
|
||||
"scores": scores,
|
||||
"label": label,
|
||||
})
|
||||
|
||||
ranking.sort(key=lambda r: r["scores"], reverse=True)
|
||||
|
||||
return [(r["email"].pk, r["label"]) for r in ranking]
|
||||
|
||||
def review_requests_needing_reviewer_reminder(remind_date):
|
||||
reqs_qs = ReviewRequest.objects.filter(
|
||||
state__in=("requested", "accepted"),
|
||||
reviewer__person__reviewersettings__remind_days_before_deadline__isnull=False,
|
||||
reviewer__person__reviewersettings__team=F("team"),
|
||||
).exclude(
|
||||
reviewer=None
|
||||
).values_list("pk", "deadline", "reviewer__person__reviewersettings__remind_days_before_deadline").distinct()
|
||||
|
||||
req_pks = []
|
||||
for r_pk, deadline, remind_days in reqs_qs:
|
||||
if (deadline - remind_date).days == remind_days:
|
||||
req_pks.append(r_pk)
|
||||
|
||||
return ReviewRequest.objects.filter(pk__in=req_pks).select_related("reviewer", "reviewer__person", "state", "team")
|
||||
|
||||
def email_reviewer_reminder(review_request):
|
||||
team = review_request.team
|
||||
|
||||
deadline_days = (review_request.deadline - datetime.date.today()).days
|
||||
|
||||
subject = "Reminder: deadline for review of {} in {} is {}".format(review_request.doc_id, team.acronym, review_request.deadline.isoformat())
|
||||
|
||||
overview_url = urlreverse("ietf.ietfauth.views.review_overview")
|
||||
request_url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": review_request.doc_id, "request_id": review_request.pk })
|
||||
|
||||
domain = Site.objects.get_current().domain
|
||||
|
||||
settings = ReviewerSettings.objects.filter(person=review_request.reviewer.person, team=team).first()
|
||||
remind_days = settings.remind_days_before_deadline if settings else 0
|
||||
|
||||
send_mail(None, [review_request.reviewer.formatted_email()], None, subject, "review/reviewer_reminder.txt", {
|
||||
"reviewer_overview_url": "https://{}{}".format(domain, overview_url),
|
||||
"review_request_url": "https://{}{}".format(domain, request_url),
|
||||
"review_request": review_request,
|
||||
"deadline_days": deadline_days,
|
||||
"remind_days": remind_days,
|
||||
})
|
|
@ -304,6 +304,7 @@ INSTALLED_APPS = (
|
|||
'ietf.person',
|
||||
'ietf.redirects',
|
||||
'ietf.release',
|
||||
'ietf.review',
|
||||
'ietf.submit',
|
||||
'ietf.sync',
|
||||
'ietf.utils',
|
||||
|
@ -447,6 +448,7 @@ MEETING_RECORDINGS_DIR = '/a/www/audio'
|
|||
|
||||
# Mailing list info URL for lists hosted on the IETF servers
|
||||
MAILING_LIST_INFO_URL = "https://www.ietf.org/mailman/listinfo/%(list_addr)s"
|
||||
MAILING_LIST_ARCHIVE_URL = "https://mailarchive.ietf.org"
|
||||
|
||||
# Liaison Statement Tool settings (one is used in DOC_HREFS below)
|
||||
LIAISON_UNIVERSAL_FROM = 'Liaison Statement Management Tool <lsmt@' + IETF_DOMAIN + '>'
|
||||
|
|
|
@ -470,6 +470,108 @@ label#list-feeds {
|
|||
margin-left: 3em;
|
||||
}
|
||||
|
||||
/* === Review flow ========================================================== */
|
||||
|
||||
.reviewer-assignment-not-accepted {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
form.complete-review .mail-archive-search .query-input {
|
||||
width: 30em;
|
||||
}
|
||||
|
||||
form.complete-review .mail-archive-search .results .list-group {
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.closed-review-filter {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
form.review-requests .reviewer-controls, form.review-requests .close-controls {
|
||||
display: none;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
form.review-requests .assign-action, form.review-requests .close-action {
|
||||
display: inline-block;
|
||||
min-width: 11em;
|
||||
}
|
||||
|
||||
form.review-requests .deadline {
|
||||
padding-top: 0.45em;
|
||||
}
|
||||
|
||||
form.review-requests label {
|
||||
font-weight: normal;
|
||||
padding-right: 0.3em;
|
||||
}
|
||||
|
||||
form.email-open-review-assignments [name=body] {
|
||||
height: 50em;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
table.simple-table td {
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
table.simple-table td:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.unavailable-period-past {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.unavailable-period-active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.reviewer-overview .completely-unavailable {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* === Statistics =========================================================== */
|
||||
|
||||
.stats-options > * {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.stats-options > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-options .date-range input.form-control {
|
||||
display: inline-block;
|
||||
width: 7em;
|
||||
}
|
||||
|
||||
.stats-time-graph {
|
||||
height: 15em;
|
||||
}
|
||||
|
||||
.review-stats th:first-child, .review-stats td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.review-stats th, .review-stats td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.review-stats-teams {
|
||||
-moz-column-width: 18em;
|
||||
-webkit-column-width: 18em;
|
||||
column-width: 18em;
|
||||
}
|
||||
|
||||
.review-stats-teams a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* === Photo pages ========================================================== */
|
||||
|
||||
.photo-name {
|
||||
|
|
131
ietf/static/ietf/js/complete-review.js
Normal file
131
ietf/static/ietf/js/complete-review.js
Normal file
|
@ -0,0 +1,131 @@
|
|||
$(document).ready(function () {
|
||||
var form = $("form.complete-review");
|
||||
|
||||
var reviewedRev = form.find("[name=reviewed_rev]");
|
||||
reviewedRev.closest(".form-group").find("a.rev").on("click", function (e) {
|
||||
e.preventDefault();
|
||||
reviewedRev.val($(this).text());
|
||||
});
|
||||
|
||||
// mail archive search functionality
|
||||
var mailArchiveSearchTemplate = form.find(".template .mail-archive-search").parent().html();
|
||||
var mailArchiveSearchResultTemplate = form.find(".template .mail-archive-search-result").parent().html();
|
||||
|
||||
form.find("[name=review_url]").closest(".form-group").before(mailArchiveSearchTemplate);
|
||||
|
||||
var mailArchiveSearch = form.find(".mail-archive-search");
|
||||
|
||||
var retrievingData = null;
|
||||
|
||||
function searchMailArchive() {
|
||||
if (retrievingData)
|
||||
return;
|
||||
|
||||
var queryInput = mailArchiveSearch.find(".query-input");
|
||||
if (queryInput.length == 0 || !$.trim(queryInput.val()))
|
||||
return;
|
||||
|
||||
mailArchiveSearch.find(".search").prop("disabled", true);
|
||||
mailArchiveSearch.find(".error").addClass("hidden");
|
||||
mailArchiveSearch.find(".retrieving").removeClass("hidden");
|
||||
mailArchiveSearch.find(".results").addClass("hidden");
|
||||
|
||||
retrievingData = $.ajax({
|
||||
url: searchMailArchiveUrl,
|
||||
method: "GET",
|
||||
data: {
|
||||
query: queryInput.val()
|
||||
},
|
||||
dataType: "json",
|
||||
timeout: 20 * 1000
|
||||
}).then(function (data) {
|
||||
retrievingData = null;
|
||||
mailArchiveSearch.find(".search").prop("disabled", false);
|
||||
mailArchiveSearch.find(".retrieving").addClass("hidden");
|
||||
|
||||
var err = data.error;
|
||||
if (!err && (!data.messages || !data.messages.length))
|
||||
err = "No messages matching document name found in archive";
|
||||
|
||||
if (err) {
|
||||
var errorDiv = mailArchiveSearch.find(".error");
|
||||
errorDiv.removeClass("hidden");
|
||||
errorDiv.find(".content").text(err);
|
||||
if (data.query && data.query_url && data.query_data_url) {
|
||||
errorDiv.find(".try-yourself .query").text(data.query);
|
||||
errorDiv.find(".try-yourself .query-url").prop("href", data.query_url);
|
||||
errorDiv.find(".try-yourself .query-data-url").prop("href", data.query_data_url);
|
||||
errorDiv.find(".try-yourself").removeClass("hidden");
|
||||
}
|
||||
}
|
||||
else {
|
||||
mailArchiveSearch.find(".results").removeClass("hidden");
|
||||
|
||||
var results = mailArchiveSearch.find(".results .list-group");
|
||||
results.children().remove();
|
||||
|
||||
for (var i = 0; i < data.messages.length; ++i) {
|
||||
var msg = data.messages[i];
|
||||
var row = $(mailArchiveSearchResultTemplate).attr("title", "Click to fill in link and content from this message");
|
||||
row.find(".subject").text(msg.subject);
|
||||
row.find(".date").text(msg.date);
|
||||
row.data("url", msg.url);
|
||||
row.data("content", msg.content);
|
||||
results.append(row);
|
||||
}
|
||||
}
|
||||
}, function () {
|
||||
retrievingData = null;
|
||||
mailArchiveSearch.find(".search").prop("disabled", false);
|
||||
mailArchiveSearch.find(".retrieving").addClass("hidden");
|
||||
|
||||
var errorDiv = mailArchiveSearch.find(".error");
|
||||
errorDiv.removeClass("hidden");
|
||||
errorDiv.find(".content").text("Error trying to retrieve data from mailing list archive.");
|
||||
});
|
||||
}
|
||||
|
||||
mailArchiveSearch.find(".search").on("click", function () {
|
||||
searchMailArchive();
|
||||
});
|
||||
|
||||
mailArchiveSearch.find(".results").on("click", ".mail-archive-search-result", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var row = $(this);
|
||||
if (!row.is(".mail-archive-search-result"))
|
||||
row = row.closest(".mail-archive-search-result");
|
||||
|
||||
form.find("[name=review_url]").val(row.data("url"));
|
||||
form.find("[name=review_content]").val(row.data("content"));
|
||||
});
|
||||
|
||||
|
||||
// review submission selection
|
||||
form.find("[name=review_submission]").on("click change", function () {
|
||||
var val = form.find("[name=review_submission]:checked").val();
|
||||
|
||||
var shouldBeVisible = {
|
||||
"enter": ['[name="review_content"]', '[name="cc"]'],
|
||||
"upload": ['[name="review_file"]', '[name="cc"]'],
|
||||
"link": [".mail-archive-search", '[name="review_url"]', '[name="review_content"]']
|
||||
};
|
||||
|
||||
for (var v in shouldBeVisible) {
|
||||
for (var i in shouldBeVisible[v]) {
|
||||
var selector = shouldBeVisible[v][i];
|
||||
var row = form.find(selector);
|
||||
if (!row.is(".form-group"))
|
||||
row = row.closest(".form-group");
|
||||
|
||||
if ($.inArray(selector, shouldBeVisible[val]) != -1)
|
||||
row.show();
|
||||
else
|
||||
row.hide();
|
||||
}
|
||||
}
|
||||
|
||||
if (val == "link")
|
||||
searchMailArchive();
|
||||
}).trigger("change");
|
||||
});
|
|
@ -194,7 +194,7 @@ $(".snippet .show-all").click(function () {
|
|||
// });
|
||||
|
||||
// Use the Bootstrap3 tooltip plugin for all elements with a title attribute
|
||||
$('[title][title!=""]').tooltip();
|
||||
$('[title][title!=""]').not("th").tooltip();
|
||||
|
||||
$(document).ready(function () {
|
||||
// add a required class on labels on forms that should have
|
||||
|
|
101
ietf/static/ietf/js/manage-review-requests.js
Normal file
101
ietf/static/ietf/js/manage-review-requests.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
$(document).ready(function () {
|
||||
var form = $("form.review-requests");
|
||||
var saveButtons = form.find("[name=action][value^=\"save\"]");
|
||||
|
||||
function updateSaveButtons() {
|
||||
saveButtons.prop("disabled", form.find("[name$=\"-action\"][value][value!=\"\"]").length == 0);
|
||||
}
|
||||
|
||||
function setControlDisplay(row) {
|
||||
var action = row.find("[name$=\"-action\"]").val();
|
||||
if (action == "assign") {
|
||||
row.find(".reviewer-controls").show();
|
||||
row.find(".close-controls").hide();
|
||||
row.find(".assign-action,.close-action").hide();
|
||||
}
|
||||
else if (action == "close") {
|
||||
row.find(".reviewer-controls").hide();
|
||||
row.find(".close-controls").show();
|
||||
row.find(".assign-action,.close-action").hide();
|
||||
}
|
||||
else {
|
||||
row.find(".reviewer-controls,.close-controls").hide();
|
||||
row.find(".assign-action,.close-action").show();
|
||||
}
|
||||
|
||||
updateSaveButtons();
|
||||
}
|
||||
|
||||
form.find(".assign-action button").on("click", function () {
|
||||
var row = $(this).closest("tr");
|
||||
|
||||
var select = row.find(".reviewer-controls [name$=\"-reviewer\"]");
|
||||
if (!select.val()) {
|
||||
// collect reviewers already assigned in this session
|
||||
var reviewerAssigned = {};
|
||||
select.find("option").each(function () {
|
||||
if (this.value)
|
||||
reviewerAssigned[this.value] = 0;
|
||||
});
|
||||
|
||||
form.find("[name$=\"-action\"][value=\"assign\"]").each(function () {
|
||||
var v = $(this).closest("tr").find("[name$=\"-reviewer\"]").val();
|
||||
if (v)
|
||||
reviewerAssigned[v] += 1;
|
||||
});
|
||||
|
||||
// by default, the select box contains a sorted list, so
|
||||
// we should be able to select the first, unless that
|
||||
// person has already been assigned to review in this
|
||||
// session
|
||||
var found = null;
|
||||
var options = select.find("option").get();
|
||||
for (var round = 0; round < 100 && !found; ++round) {
|
||||
for (var i = 0; i < options.length && !found; ++i) {
|
||||
var v = options[i].value;
|
||||
if (!v)
|
||||
continue;
|
||||
|
||||
if (reviewerAssigned[v] == round)
|
||||
found = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (found)
|
||||
select.val(found);
|
||||
}
|
||||
|
||||
row.find("[name$=\"-action\"]").val("assign");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
form.find(".reviewer-controls .undo").on("click", function () {
|
||||
var row = $(this).closest("tr");
|
||||
row.find("[name$=\"-action\"]").val("");
|
||||
row.find("[name$=\"-reviewer\"]").val($(this).data("initial"));
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
form.find(".close-action button").on("click", function () {
|
||||
var row = $(this).closest("tr");
|
||||
row.find("[name$=\"-action\"]").val("close");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
form.find(".close-controls .undo").on("click", function () {
|
||||
var row = $(this).closest("tr");
|
||||
row.find("[name$=\"-action\"]").val("");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
form.find("[name$=\"-action\"]").each(function () {
|
||||
var v = $(this).val();
|
||||
if (!v)
|
||||
return;
|
||||
|
||||
var row = $(this).closest("tr");
|
||||
setControlDisplay(row);
|
||||
});
|
||||
|
||||
updateSaveButtons();
|
||||
});
|
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 @@
|
|||
|
50
ietf/stats/tests.py
Normal file
50
ietf/stats/tests.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
from pyquery import PyQuery
|
||||
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
|
||||
from ietf.utils.test_data import make_test_data, make_review_data
|
||||
from ietf.utils.test_utils import login_testing_unauthorized, TestCase
|
||||
import ietf.stats.views
|
||||
|
||||
class StatisticsTests(TestCase):
|
||||
def test_stats_index(self):
|
||||
url = urlreverse(ietf.stats.views.stats_index)
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_review_stats(self):
|
||||
doc = make_test_data()
|
||||
review_req = make_review_data(doc)
|
||||
|
||||
# check redirect
|
||||
url = urlreverse(ietf.stats.views.review_stats)
|
||||
|
||||
login_testing_unauthorized(self, "secretary", url)
|
||||
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertTrue(urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion" }) in r["Location"])
|
||||
|
||||
# check tabular
|
||||
for stats_type in ["completion", "results", "states"]:
|
||||
url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": stats_type })
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
if stats_type != "results":
|
||||
self.assertTrue(q('.review-stats td:contains("1")'))
|
||||
|
||||
# check chart
|
||||
url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "time" })
|
||||
url += "?team={}".format(review_req.team.acronym)
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
self.assertTrue(q('.stats-time-graph'))
|
||||
|
||||
# check reviewer level
|
||||
url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion", "acronym": review_req.team.acronym })
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
self.assertTrue(q('.review-stats td:contains("1")'))
|
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),
|
||||
)
|
320
ietf/stats/views.py
Normal file
320
ietf/stats/views.py
Normal file
|
@ -0,0 +1,320 @@
|
|||
import datetime, itertools, json, calendar
|
||||
|
||||
from django.shortcuts import render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.urlresolvers import reverse as urlreverse
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
import dateutil.relativedelta
|
||||
|
||||
from ietf.review.utils import extract_review_request_data, aggregate_review_request_stats, ReviewRequestData
|
||||
from ietf.group.models import Role, Group
|
||||
from ietf.person.models import Person
|
||||
from ietf.name.models import ReviewRequestStateName, ReviewResultName
|
||||
from ietf.ietfauth.utils import has_role
|
||||
|
||||
def stats_index(request):
|
||||
return render(request, "stats/index.html")
|
||||
|
||||
@login_required
|
||||
def review_stats(request, stats_type=None, acronym=None):
|
||||
# This view is a bit complex because we want to show a bunch of
|
||||
# tables with various filtering options, and both a team overview
|
||||
# and a reviewers-within-team overview - and a time series chart.
|
||||
# And in order to make the UI quick to navigate, we're not using
|
||||
# one big form but instead presenting a bunch of immediate
|
||||
# actions, with a URL scheme where the most common options (level
|
||||
# and statistics type) are incorporated directly into the URL to
|
||||
# be a bit nicer.
|
||||
|
||||
def build_review_stats_url(stats_type_override=Ellipsis, acronym_override=Ellipsis, get_overrides={}):
|
||||
kwargs = {
|
||||
"stats_type": stats_type if stats_type_override is Ellipsis else stats_type_override,
|
||||
}
|
||||
acr = acronym if acronym_override is Ellipsis else acronym_override
|
||||
if acr:
|
||||
kwargs["acronym"] = acr
|
||||
|
||||
base_url = urlreverse(review_stats, kwargs=kwargs)
|
||||
query_part = u""
|
||||
|
||||
if request.GET or get_overrides:
|
||||
d = request.GET.copy()
|
||||
for k, v in get_overrides.iteritems():
|
||||
if type(v) in (list, tuple):
|
||||
if not v:
|
||||
if k in d:
|
||||
del d[k]
|
||||
else:
|
||||
d.setlist(k, v)
|
||||
else:
|
||||
if v is None or v == u"":
|
||||
if k in d:
|
||||
del d[k]
|
||||
else:
|
||||
d[k] = v
|
||||
|
||||
if d:
|
||||
query_part = u"?" + d.urlencode()
|
||||
|
||||
return base_url + query_part
|
||||
|
||||
def get_from_selection(get_parameter, possible_choices):
|
||||
val = request.GET.get(get_parameter)
|
||||
for slug, label, url in possible_choices:
|
||||
if slug == val:
|
||||
return slug
|
||||
return None
|
||||
|
||||
# which overview - team or reviewer
|
||||
if acronym:
|
||||
level = "reviewer"
|
||||
else:
|
||||
level = "team"
|
||||
|
||||
# statistics type - one of the tables or the chart
|
||||
possible_stats_types = [
|
||||
("completion", "Completion status"),
|
||||
("results", "Review results"),
|
||||
("states", "Request states"),
|
||||
]
|
||||
|
||||
if level == "team":
|
||||
possible_stats_types.append(("time", "Changes over time"))
|
||||
|
||||
possible_stats_types = [ (slug, label, build_review_stats_url(stats_type_override=slug))
|
||||
for slug, label in possible_stats_types ]
|
||||
|
||||
if not stats_type:
|
||||
return HttpResponseRedirect(build_review_stats_url(stats_type_override=possible_stats_types[0][0]))
|
||||
|
||||
# what to count
|
||||
possible_count_choices = [
|
||||
("", "Review requests"),
|
||||
("pages", "Reviewed pages"),
|
||||
]
|
||||
|
||||
possible_count_choices = [ (slug, label, build_review_stats_url(get_overrides={ "count": slug })) for slug, label in possible_count_choices ]
|
||||
|
||||
count = get_from_selection("count", possible_count_choices) or ""
|
||||
|
||||
# time range
|
||||
def parse_date(s):
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.strptime(s.strip(), "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
today = datetime.date.today()
|
||||
from_date = parse_date(request.GET.get("from")) or today - dateutil.relativedelta.relativedelta(years=1)
|
||||
to_date = parse_date(request.GET.get("to")) or today
|
||||
|
||||
from_time = datetime.datetime.combine(from_date, datetime.time.min)
|
||||
to_time = datetime.datetime.combine(to_date, datetime.time.max)
|
||||
|
||||
# teams/reviewers
|
||||
teams = list(Group.objects.exclude(reviewrequest=None).distinct().order_by("name"))
|
||||
|
||||
reviewer_filter_args = {}
|
||||
|
||||
# - interlude: access control
|
||||
if has_role(request.user, ["Secretariat", "Area Director"]):
|
||||
pass
|
||||
else:
|
||||
secr_access = set()
|
||||
reviewer_only_access = set()
|
||||
|
||||
for r in Role.objects.filter(person__user=request.user, name__in=["secr", "reviewer"], group__in=teams).distinct():
|
||||
if r.name_id == "secr":
|
||||
secr_access.add(r.group_id)
|
||||
reviewer_only_access.discard(r.group_id)
|
||||
elif r.name_id == "reviewer":
|
||||
if not r.group_id in secr_access:
|
||||
reviewer_only_access.add(r.group_id)
|
||||
|
||||
teams = [t for t in teams if t.pk in secr_access or t.pk in reviewer_only_access]
|
||||
|
||||
for t in reviewer_only_access:
|
||||
reviewer_filter_args[t] = { "user": request.user }
|
||||
|
||||
reviewers_for_team = None
|
||||
|
||||
if level == "team":
|
||||
for t in teams:
|
||||
t.reviewer_stats_url = build_review_stats_url(acronym_override=t.acronym)
|
||||
|
||||
query_teams = teams
|
||||
query_reviewers = None
|
||||
|
||||
group_by_objs = { t.pk: t for t in query_teams }
|
||||
group_by_index = ReviewRequestData._fields.index("team")
|
||||
|
||||
elif level == "reviewer":
|
||||
for t in teams:
|
||||
if t.acronym == acronym:
|
||||
reviewers_for_team = t
|
||||
break
|
||||
else:
|
||||
return HttpResponseRedirect(urlreverse(review_stats))
|
||||
|
||||
query_reviewers = list(Person.objects.filter(
|
||||
email__reviewrequest__time__gte=from_time,
|
||||
email__reviewrequest__time__lte=to_time,
|
||||
email__reviewrequest__team=reviewers_for_team,
|
||||
**reviewer_filter_args.get(t.pk, {})
|
||||
).distinct())
|
||||
query_reviewers.sort(key=lambda p: p.last_name())
|
||||
|
||||
query_teams = [t]
|
||||
|
||||
group_by_objs = { r.pk: r for r in query_reviewers }
|
||||
group_by_index = ReviewRequestData._fields.index("reviewer")
|
||||
|
||||
# now aggregate the data
|
||||
possible_teams = possible_completion_types = possible_results = possible_states = None
|
||||
selected_team = selected_completion_type = selected_result = selected_state = None
|
||||
|
||||
if stats_type == "time":
|
||||
possible_teams = [(t.acronym, t.acronym, build_review_stats_url(get_overrides={ "team": t.acronym })) for t in teams]
|
||||
selected_team = get_from_selection("team", possible_teams)
|
||||
query_teams = [t for t in query_teams if t.acronym == selected_team]
|
||||
|
||||
extracted_data = extract_review_request_data(query_teams, query_reviewers, from_time, to_time, ordering=[level])
|
||||
|
||||
if stats_type == "time":
|
||||
req_time_index = ReviewRequestData._fields.index("req_time")
|
||||
|
||||
def time_key_fn(t):
|
||||
d = t[req_time_index].date()
|
||||
#d -= datetime.timedelta(days=d.weekday())
|
||||
d -= datetime.timedelta(days=d.day)
|
||||
return (t[group_by_index], d)
|
||||
|
||||
found_results = set()
|
||||
found_states = set()
|
||||
aggrs = []
|
||||
for (group_pk, d), request_data_items in itertools.groupby(extracted_data, key=time_key_fn):
|
||||
aggr = aggregate_review_request_stats(request_data_items, count=count)
|
||||
|
||||
aggrs.append((d, aggr))
|
||||
|
||||
for slug in aggr["result"]:
|
||||
found_results.add(slug)
|
||||
for slug in aggr["state"]:
|
||||
found_states.add(slug)
|
||||
|
||||
results = ReviewResultName.objects.filter(slug__in=found_results)
|
||||
states = ReviewRequestStateName.objects.filter(slug__in=found_states)
|
||||
|
||||
# choice
|
||||
|
||||
possible_completion_types = [
|
||||
("completed_in_time", "Completed in time"),
|
||||
("completed_late", "Completed late"),
|
||||
("not_completed", "Not completed"),
|
||||
("average_assignment_to_closure_days", "Avg. compl. days"),
|
||||
]
|
||||
|
||||
possible_completion_types = [
|
||||
(slug, label, build_review_stats_url(get_overrides={ "completion": slug, "result": None, "state": None }))
|
||||
for slug, label in possible_completion_types
|
||||
]
|
||||
|
||||
selected_completion_type = get_from_selection("completion", possible_completion_types)
|
||||
|
||||
possible_results = [
|
||||
(r.slug, r.name, build_review_stats_url(get_overrides={ "completion": None, "result": r.slug, "state": None }))
|
||||
for r in results
|
||||
]
|
||||
|
||||
selected_result = get_from_selection("result", possible_results)
|
||||
|
||||
possible_states = [
|
||||
(s.slug, s.name, build_review_stats_url(get_overrides={ "completion": None, "result": None, "state": s.slug }))
|
||||
for s in states
|
||||
]
|
||||
|
||||
selected_state = get_from_selection("state", possible_states)
|
||||
|
||||
if not selected_completion_type and not selected_result and not selected_state:
|
||||
selected_completion_type = "completed_in_time"
|
||||
|
||||
series_data = []
|
||||
for d, aggr in aggrs:
|
||||
v = 0
|
||||
if selected_completion_type is not None:
|
||||
v = aggr[selected_completion_type]
|
||||
elif selected_result is not None:
|
||||
v = aggr["result"][selected_result]
|
||||
elif selected_state is not None:
|
||||
v = aggr["state"][selected_state]
|
||||
|
||||
series_data.append((calendar.timegm(d.timetuple()) * 1000, v))
|
||||
|
||||
data = json.dumps([{
|
||||
"data": series_data
|
||||
}])
|
||||
|
||||
else: # tabular data
|
||||
|
||||
data = []
|
||||
|
||||
found_results = set()
|
||||
found_states = set()
|
||||
for group_pk, request_data_items in itertools.groupby(extracted_data, key=lambda t: t[group_by_index]):
|
||||
aggr = aggregate_review_request_stats(request_data_items, count=count)
|
||||
|
||||
# skip zero-valued rows
|
||||
if aggr["open"] == 0 and aggr["completed"] == 0 and aggr["not_completed"] == 0:
|
||||
continue
|
||||
|
||||
aggr["obj"] = group_by_objs.get(group_pk)
|
||||
|
||||
for slug in aggr["result"]:
|
||||
found_results.add(slug)
|
||||
for slug in aggr["state"]:
|
||||
found_states.add(slug)
|
||||
|
||||
data.append(aggr)
|
||||
|
||||
results = ReviewResultName.objects.filter(slug__in=found_results)
|
||||
states = ReviewRequestStateName.objects.filter(slug__in=found_states)
|
||||
|
||||
# massage states/results breakdowns for template rendering
|
||||
for aggr in data:
|
||||
aggr["state_list"] = [aggr["state"].get(x.slug, 0) for x in states]
|
||||
aggr["result_list"] = [aggr["result"].get(x.slug, 0) for x in results]
|
||||
|
||||
|
||||
return render(request, 'stats/review_stats.html', {
|
||||
"team_level_url": build_review_stats_url(acronym_override=None),
|
||||
"level": level,
|
||||
"reviewers_for_team": reviewers_for_team,
|
||||
"teams": teams,
|
||||
"data": data,
|
||||
"states": states,
|
||||
"results": results,
|
||||
|
||||
# options
|
||||
"possible_stats_types": possible_stats_types,
|
||||
"stats_type": stats_type,
|
||||
|
||||
"possible_count_choices": possible_count_choices,
|
||||
"count": count,
|
||||
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"today": today,
|
||||
|
||||
# time options
|
||||
"possible_teams": possible_teams,
|
||||
"selected_team": selected_team,
|
||||
"possible_completion_types": possible_completion_types,
|
||||
"selected_completion_type": selected_completion_type,
|
||||
"possible_results": possible_results,
|
||||
"selected_result": selected_result,
|
||||
"possible_states": possible_states,
|
||||
"selected_state": selected_state,
|
||||
})
|
|
@ -102,6 +102,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 %}
|
80
ietf/templates/doc/review/complete_review.html
Normal file
80
ietf/templates/doc/review/complete_review.html
Normal file
|
@ -0,0 +1,80 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Complete review of {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Complete review<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<p>The review findings should be made available here and the review
|
||||
posted to the mailing list. If you enter the findings below, the
|
||||
system will post the review for you. If you already have posted
|
||||
the review, you can try to let the system find the link to the
|
||||
archive and retrieve the email body.</p>
|
||||
|
||||
<form class="complete-review form-horizontal" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{% bootstrap_form form layout="horizontal" %}
|
||||
|
||||
{% buttons %}
|
||||
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Complete review</button>
|
||||
{% endbuttons %}
|
||||
|
||||
<div class="template" style="display:none">
|
||||
{% if mail_archive_query_urls %}
|
||||
<div class="mail-archive-search form-group">
|
||||
<div class="col-md-offset-2 col-md-10">
|
||||
<p class="form-inline">
|
||||
Search mail archive subjects for:
|
||||
<input class="query-input form-control input-sm" value="{{ mail_archive_query_urls.query }}">
|
||||
<button type="button" class="search btn btn-default btn-sm">Search</button>
|
||||
</p>
|
||||
|
||||
<div class="retrieving hidden">
|
||||
<span class="fa fa-spin fa-circle-o-notch"></span>
|
||||
Searching...
|
||||
</div>
|
||||
|
||||
<div class="results hidden">
|
||||
<p>Select one of the following messages to automatically pre-fill link and content:</p>
|
||||
<div class="list-group">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error alert alert-warning hidden">
|
||||
<p>
|
||||
<span class="content"></span>
|
||||
<span class="hidden try-yourself">(searched for <a class="query-url" href="">"<span class="query"></span>"</a>, corresponding <a class="query-data-url" href="">export</a>).</span>
|
||||
You have to fill in link and content yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mail-archive-search">
|
||||
<small class="text-muted">Mailing list does not have a recognized ietf.org archive. Auto-searching disabled.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="template" style="display:none">
|
||||
<button type="button" class="mail-archive-search-result list-group-item">
|
||||
<span class="date badge"></span>
|
||||
<span class="subject"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
var searchMailArchiveUrl = "{% url "ietf.doc.views_review.search_mail_archive" name=review_req.doc.name request_id=review_req.pk %}";
|
||||
</script>
|
||||
<script src="{% static 'ietf/js/complete-review.js' %}"></script>
|
||||
{% endblock %}
|
24
ietf/templates/doc/review/reject_reviewer_assignment.html
Normal file
24
ietf/templates/doc/review/reject_reviewer_assignment.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Reject review assignment for {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Reject review assignment<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<p>{{ review_req.reviewer.person }} is currently assigned to do the review. Do you want to reject this assignment?</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<a class="btn btn-default" href="{% url "ietf.doc.views_review.review_request" name=doc.canonical_name request_id=review_req.pk %}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary" name="action" value="reject">Reject assignment</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
50
ietf/templates/doc/review/request_review.html
Normal file
50
ietf/templates/doc/review/request_review.html
Normal file
|
@ -0,0 +1,50 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Request review of {{ doc.name }} {% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Request review<br><small>{{ doc.name }}</small></h1>
|
||||
|
||||
<p>Submit a request to have the document reviewed.</p>
|
||||
|
||||
<p>
|
||||
<div>Current revision of the document: <strong>{{ doc.rev }}</strong>.</div>
|
||||
|
||||
{% if lc_ends %}
|
||||
<div>Last Call ends: <strong>{{ lc_ends|date:"Y-m-d" }}</strong> (in {{ lc_ends_days }} day{{ lc_ends_days|pluralize }}).</div>
|
||||
{% endif %}
|
||||
|
||||
{% if scheduled_for_telechat %}
|
||||
<div>Scheduled for telechat: <strong>{{ scheduled_for_telechat|date:"Y-m-d" }}</strong> (in {{ scheduled_for_telechat_days }} day{{ scheduled_for_telechat_days|pluralize }}).</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<form class="form-horizontal" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_field form.requested_by layout="horizontal" %}
|
||||
{% bootstrap_field form.type layout="horizontal" %}
|
||||
{% bootstrap_field form.team layout="horizontal" %}
|
||||
{% bootstrap_field form.deadline layout="horizontal" %}
|
||||
{% bootstrap_field form.requested_rev layout="horizontal" %}
|
||||
|
||||
{% buttons %}
|
||||
<button type="submit" class="btn btn-primary">Request review</button>
|
||||
<a class="btn btn-default pull-right" href="{% url "doc_view" name=doc.canonical_name %}">Back</a>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
<script src="{% static 'select2/select2.min.js' %}"></script>
|
||||
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
|
||||
{% endblock %}
|
167
ietf/templates/doc/review/review_request.html
Normal file
167
ietf/templates/doc/review/review_request.html
Normal file
|
@ -0,0 +1,167 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2016, All Rights Reserved #}
|
||||
{% load origin bootstrap3 static %}
|
||||
|
||||
{% block title %}Review request for {{ review_req.doc.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>Review request<br><small>{{ review_req.doc.name }}</small></h1>
|
||||
|
||||
<table class="table table-condensed">
|
||||
<tbody class="meta">
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Review of</th>
|
||||
<td>
|
||||
{% if review_req.requested_rev %}
|
||||
<a href="{% url "doc_view" name=review_req.doc.name rev=review_req.requested_rev %}">{{ review_req.doc.name }}-{{ review_req.requested_rev }}</a>
|
||||
{% else %}
|
||||
<a href="{% url "doc_view" name=review_req.doc.name %}">{{ review_req.doc.name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Requested rev.</th>
|
||||
<td>
|
||||
{% if review_req.requested_rev %}
|
||||
{{ review_req.requested_rev }}
|
||||
{% else %}
|
||||
no specific revision
|
||||
{% endif %}
|
||||
{% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Type</th>
|
||||
<td>{{ review_req.type.name }} Review</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Team</th>
|
||||
<td><a href="{% url "ietf.group.views_review.review_requests" group_type=review_req.team.type_id acronym=review_req.team.acronym %}">{{ review_req.team.acronym|upper }}</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Deadline</th>
|
||||
<td>{{ review_req.deadline|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Requested</th>
|
||||
<td>{{ review_req.time|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
|
||||
{% if review_req.requested_by.name != "(System)" %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Requested by</th>
|
||||
<td>{{ review_req.requested_by }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
|
||||
<tbody class="meta">
|
||||
<tr>
|
||||
<th>Review</th>
|
||||
<th>State</th>
|
||||
<td>{{ review_req.state.name }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Reviewer</th>
|
||||
<td>
|
||||
{% if review_req.reviewer %}
|
||||
{{ review_req.reviewer.person }}
|
||||
{% else %}
|
||||
None assigned yet
|
||||
{% endif %}
|
||||
|
||||
{% if can_assign_reviewer %}
|
||||
<a class="btn btn-default btn-xs" href="{% url "ietf.doc.views_review.assign_reviewer" name=doc.name request_id=review_req.pk %}"><span class="fa fa-user"></span> {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer</a>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req.reviewer %}
|
||||
{% if can_reject_reviewer_assignment or can_accept_reviewer_assignment %}
|
||||
<div class="reviewer-assignment-not-accepted">
|
||||
{% if review_req.state_id == "requested"%}
|
||||
<em>Assignment not accepted yet:</em>
|
||||
{% else %}
|
||||
<em>Assignment accepted:</em>
|
||||
{% endif %}
|
||||
|
||||
{% if can_reject_reviewer_assignment %}
|
||||
<a class="btn btn-danger btn-xs" href="{% url "ietf.doc.views_review.reject_reviewer_assignment" name=doc.name request_id=review_req.pk %}"><span class="fa fa-ban"></span> Reject</a>
|
||||
{% endif %}
|
||||
|
||||
{% if can_accept_reviewer_assignment %}
|
||||
<form style="display:inline" method="post" action="{% url "ietf.doc.views_review.review_request" name=doc.name request_id=review_req.pk %}">{% csrf_token %}<button class="btn btn-success btn-xs" type="submit" name="action" value="accept"><span class="fa fa-check"></span> Accept</button></form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Review</th>
|
||||
<td>
|
||||
{% if review_req.review %}
|
||||
<a href="{{ review_req.review.get_absolute_url }}">{{ review_req.review.name }}</a>
|
||||
{% elif review_req.state_id == "requested" or review_req.state_id == "accepted" %}
|
||||
Not completed yet
|
||||
{% else %}
|
||||
Not available
|
||||
{% endif %}
|
||||
|
||||
{% if can_complete_review %}
|
||||
<a class="btn btn-primary btn-xs" href="{% url "ietf.doc.views_review.complete_review" name=doc.name request_id=review_req.pk %}"><span class="fa fa-pencil-square-o"></span> Complete review</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% if review_req.review and review_req.review.external_url %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Posted at</th>
|
||||
<td>
|
||||
<a href="{{ review_req.review.external_url }}">{{ review_req.review.external_url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req.reviewed_rev %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Reviewed rev.</th>
|
||||
<td><a href="{% url "doc_view" name=review_req.doc.name rev=review_req.reviewed_rev %}">{{ review_req.reviewed_rev }}</a> {% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if review_req.result %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Review result</th>
|
||||
<td>{{ review_req.result.name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div>
|
||||
{% if can_close_request %}
|
||||
<a class="btn btn-danger btn-xs" href="{% url "ietf.doc.views_review.close_request" name=doc.name request_id=review_req.pk %}"><span class="fa fa-ban"></span> Close request</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
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>Reviewed by:
|
||||
{% for g in doc.reviewed_by_teams %}
|
||||
{{ g.acronym }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% for m in doc.milestones %}
|
||||
{% if forloop.first %}<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 #}
|
||||
|
|
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 }} - {{ 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="{% url "ietf.group.views_review.manage_review_requests" group_type=group.type_id acronym=group.acronym %}" class="btn btn-default pull-right">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="email">Send to team mailing list</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% else %}
|
||||
<p>There are currently no open requests.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
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 %}
|
146
ietf/templates/group/manage_review_requests.html
Normal file
146
ietf/templates/group/manage_review_requests.html
Normal file
|
@ -0,0 +1,146 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block title %}Manage open review requests for {{ group.acronym }}{% endblock %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static "jquery.tablesorter/css/theme.bootstrap.min.css" %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
<h1>Manage open review requests for {{ group.acronym }}</h1>
|
||||
|
||||
<p>Other options:
|
||||
<a href="{% url "ietf.group.views_review.reviewer_overview" group_type=group.type_id acronym=group.acronym %}">Reviewers in team</a>
|
||||
- <a href="{% url "ietf.group.views_review.email_open_review_assignments" group_type=group.type_id acronym=group.acronym %}">Email open assignments summary</a>
|
||||
</p>
|
||||
|
||||
{% if newly_closed > 0 or newly_opened > 0 or newly_assigned > 0 %}
|
||||
<p class="alert alert-danger">
|
||||
Changes since last refresh:
|
||||
{% if newly_closed %}{{ newly_closed }} request{{ newly_closed|pluralize }} closed.{% endif %}
|
||||
{% if newly_opened %}{{ newly_opened }} request{{ newly_opened|pluralize }} opened.{% endif %}
|
||||
{% if newly_assigned %}{{ newly_assigned }} request{{ newly_assigned|pluralize }} changed assignment.{% endif %}
|
||||
|
||||
{% if saving %}
|
||||
Check that you are happy with the results, then re-save.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if review_requests %}
|
||||
<form class="review-requests" method="post">{% csrf_token %}
|
||||
<table class="table table-condensed table-striped materials">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Document</th>
|
||||
<th>Deadline</th>
|
||||
<th style="min-width:65%">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in review_requests %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% if r.requested_rev %}{% url "doc_view" name=r.doc.name rev=r.requested_rev %}{% else %}{% url "doc_view" name=r.doc.name %}{% endif %}">{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a>
|
||||
|
||||
<div>
|
||||
<small>
|
||||
<a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{% if r.pk != None %}Req: {{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %} - {{ r.type.name }}</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{% if r.latest_reqs %}
|
||||
{% for rlatest in r.latest_reqs %}
|
||||
<div>
|
||||
<small>- prev. review of {% if rlatest.doc_id != r.doc_id %}{{ rlatest.doc_id }}{% endif %}-{{ rlatest.reviewed_rev }}:
|
||||
<a href="{% url "ietf.doc.views_review.review_request" name=rlatest.doc_id request_id=rlatest.pk %}">{% if rlatest.result %}{{ rlatest.result.name }}{% else %}result unavail.{% endif %}</a>
|
||||
(<a href="{{ rfcdiff_base_url }}?url1={{ rlatest.doc.name }}-{{ rlatest.reviewed_rev }}&url2={{ r.doc.name }}-{{ r.doc.rev }}">diff</a>){% if not forloop.last %},{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if r.form.non_field_errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for e in r.form.non_field_errors %}
|
||||
{{ e }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="deadline">
|
||||
{{ r.deadline|date:"Y-m-d" }}
|
||||
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<input type="hidden" name="reviewrequest" value="{{ r.pk }}">
|
||||
<input type="hidden" name="{{ r.form.prefix }}-existing_reviewer" value="{{ r.reviewer_id|default:"" }}">
|
||||
|
||||
<span class="assign-action">
|
||||
{% if r.reviewer %}
|
||||
<button type="button" class="btn btn-default btn-sm" title="Click to reassign reviewer">
|
||||
{{ r.reviewer.person }}
|
||||
{% if r.state_id == "accepted" %} <span class="label label-default">accp</span>{% endif %}
|
||||
{% if r.reviewer_unavailable %}<span class="label label-danger">unavail</span>{% endif %}
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-default btn-sm" title="Click to assign reviewer"><em>not yet assigned</em></button>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{{ r.form.action }}
|
||||
|
||||
<span class="reviewer-controls form-inline">
|
||||
<label for="{{ r.form.reviewer.id_for_label }}">Assign:</label>
|
||||
{{ r.form.reviewer }}
|
||||
<button type="button" class="btn btn-default btn-sm undo" title="Cancel assignment" data-initial="{{ r.form.fields.reviewer.initial|default:"" }}">Cancel</button>
|
||||
{% if r.form.reviewer.errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for e in r.form.reviewer.errors %}
|
||||
{{ e }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<span class="close-action">
|
||||
<button type="button" class="btn btn-default btn-sm">Close...</button>
|
||||
</span>
|
||||
|
||||
<span class="close-controls form-inline">
|
||||
<label for="{{ r.form.reviewer.id_for_label }}">Close:</label>
|
||||
{{ r.form.close }}
|
||||
<button type="button" class="btn btn-default btn-sm undo" title="Cancel closing">Cancel</button>
|
||||
{% if r.form.close.errors %}
|
||||
<br>
|
||||
{{ r.form.close.errors }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% buttons %}
|
||||
<a href="{% url "ietf.group.views_review.review_requests" group_type=group.type_id acronym=group.acronym %}" class="btn btn-default pull-right">Cancel</a>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="save">Save changes</button>
|
||||
<button class="btn btn-primary" type="submit" name="action" value="save-continue">Save and continue editing</button>
|
||||
<button class="btn btn-default" type="submit" name="action" value="refresh">Refresh (keeping changes)</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% else %}
|
||||
<p>There are currently no open requests.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static "jquery.tablesorter/js/jquery.tablesorter.combined.min.js" %}"></script>
|
||||
<script src="{% static "ietf/js/manage-review-requests.js" %}"></script>
|
||||
{% endblock %}
|
114
ietf/templates/group/review_requests.html
Normal file
114
ietf/templates/group/review_requests.html
Normal file
|
@ -0,0 +1,114 @@
|
|||
{% extends "group/group_base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block group_subtitle %}Review requests{% endblock %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static "jquery.tablesorter/css/theme.bootstrap.min.css" %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block group_content %}
|
||||
{% origin %}
|
||||
|
||||
<h2>Open review requests</h2>
|
||||
|
||||
{% if open_review_requests %}
|
||||
<table class="table table-condensed table-striped tablesorter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Type</th>
|
||||
<th>Requested</th>
|
||||
<th>Deadline</th>
|
||||
<th>Reviewer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in open_review_requests %}
|
||||
<tr>
|
||||
<td><a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a></td>
|
||||
<td>{{ r.type.name }}</td>
|
||||
<td>{% if r.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>
|
||||
<td>
|
||||
{% if r.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 %}
|
||||
{% elif r.pk != None %}
|
||||
<em>not yet assigned</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<p>There are currently no open requests.</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 id="closed-review-requests">Closed review requests</h2>
|
||||
|
||||
<form class="closed-review-filter" action="#closed-review-requests">
|
||||
Past:
|
||||
<div class="btn-group" role="group">
|
||||
{% for key, label in since_choices %}
|
||||
<button class="btn btn-default {% if since == key %}active{% endif %}" {% if key %}name="since" value="{{ key }}"{% endif %} type="submit">{{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if closed_review_requests %}
|
||||
<table class="table table-condensed table-striped materials tablesorter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Request</th>
|
||||
<th>Type</th>
|
||||
<th>Requested</th>
|
||||
<th>Deadline</th>
|
||||
<th>Reviewer</th>
|
||||
<th>State</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in closed_review_requests %}
|
||||
<tr>
|
||||
<td><a href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}">{{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %}</a></td>
|
||||
<td>{{ r.type }}</td>
|
||||
<td>{{ r.time|date:"Y-m-d" }}</td>
|
||||
<td>{{ r.deadline|date:"Y-m-d" }}</td>
|
||||
<td>
|
||||
{% if r.reviewer %}
|
||||
{{ r.reviewer.person }}
|
||||
{% else %}
|
||||
<em>not yet assigned</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ r.state.name }}</td>
|
||||
<td>
|
||||
{% if r.result %}
|
||||
{{ r.result.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<p>No closed requests found.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static "jquery.tablesorter/js/jquery.tablesorter.combined.min.js" %}"></script>
|
||||
{% endblock %}
|
60
ietf/templates/group/reviewer_overview.html
Normal file
60
ietf/templates/group/reviewer_overview.html
Normal file
|
@ -0,0 +1,60 @@
|
|||
{% extends "group/group_base.html" %}
|
||||
{# Copyright The IETF Trust 2015, All Rights Reserved #}
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block group_subtitle %}Reviewers{% endblock %}
|
||||
|
||||
{% block group_content %}
|
||||
{% origin %}
|
||||
|
||||
<h2>Reviewers</h2>
|
||||
|
||||
{% 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, 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 }}</td>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</td>
|
||||
<td>
|
||||
{{ person.settings.get_min_interval_display }} {% if person.settings.skip_next %}(skip: {{ person.settings.skip_next }}){% endif %}<br>
|
||||
{% 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 }}</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 %}
|
||||
|
||||
|
|
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 %}
|
8
ietf/templates/review/unavailable_table.html
Normal file
8
ietf/templates/review/unavailable_table.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
<table class="simple-table">
|
||||
{% for p in unavailable_periods %}
|
||||
<tr class="unavailable-period-{{ p.state }}">
|
||||
<td>{{ p.start_date }} - {{ p.end_date|default:"" }}</td>
|
||||
<td>{{ p.get_availability_display }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
18
ietf/templates/stats/index.html
Normal file
18
ietf/templates/stats/index.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
<h1>{% block title %}Statistics{% endblock %}</h1>
|
||||
|
||||
<p>Currently, there are statistics for:</p>
|
||||
|
||||
<ul>
|
||||
<li><a rel="nofollow" href="{% url "ietf.stats.views.review_stats" %}">Reviews in review teams</a> (requires login)</li>
|
||||
</ul>
|
||||
|
||||
{% endblock %}
|
260
ietf/templates/stats/review_stats.html
Normal file
260
ietf/templates/stats/review_stats.html
Normal file
|
@ -0,0 +1,260 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load origin %}{% origin %}
|
||||
|
||||
{% load ietf_filters staticfiles bootstrap3 %}
|
||||
|
||||
{% block pagehead %}
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
|
||||
<h1>
|
||||
{% block title %}
|
||||
{% if level == "team" %}
|
||||
Statistics for review teams
|
||||
{% elif level == "reviewer" %}
|
||||
Statistics for reviewers in {{ reviewers_for_team.name }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</h1>
|
||||
|
||||
{% if level == "reviewer" %}
|
||||
<p><a href="{{ team_level_url }}">« Back to teams</a></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="stats-options well">
|
||||
<div>
|
||||
Show:
|
||||
<div class="btn-group">
|
||||
{% for slug, label, url in possible_stats_types %}
|
||||
<a class="btn btn-default {% if slug == stats_type %}active{% endif %}" href="{{ url }}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Count:
|
||||
<div class="btn-group">
|
||||
{% for slug, label, url in possible_count_choices %}
|
||||
<a class="btn btn-default {% if slug == count %}active{% endif %}" href="{{ url }}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="form-inline date-range">
|
||||
Request time:
|
||||
|
||||
<input class="form-control" type="text" name="from" value="{{ from_date }}" data-provide="datepicker" data-date-format="yyyy-mm-dd" data-date-autoclose="1" data-date-end-date="{{ today.isoformat }}" data-date-start-view="months">
|
||||
-
|
||||
<input class="form-control" type="text" name="to" value="{{ to_date }}" data-provide="datepicker" data-date-format="yyyy-mm-dd" data-date-autoclose="1" data-date-end-date="{{ today.isoformat }}" data-date-start-view="months">
|
||||
|
||||
{% for name, value in request.GET.iteritems %}
|
||||
{% if name != "from" and name != "to" %}
|
||||
<input type="hidden" name="{{ name }}" value="{{ value }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button class="btn btn-default" type="submit">Set</button>
|
||||
</form>
|
||||
|
||||
|
||||
{% if stats_type == "time" %}
|
||||
<hr>
|
||||
|
||||
<div>
|
||||
Team:
|
||||
<div class="btn-group">
|
||||
{% for slug, label, url in possible_teams %}
|
||||
<a class="btn btn-default {% if slug == selected_team %}active{% endif %}" href="{{ url }}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if selected_team %}
|
||||
<div>
|
||||
Completion:
|
||||
<div class="btn-group">
|
||||
{% for slug, label, url in possible_completion_types %}
|
||||
<a class="btn btn-default {% if slug == selected_completion_type %}active{% endif %}" href="{{ url }}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Result:
|
||||
<div class="btn-group">
|
||||
{% for slug, label, url in possible_results %}
|
||||
<a class="btn btn-default {% if slug == selected_result %}active{% endif %}" href="{{ url }}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
State:
|
||||
<div class="btn-group">
|
||||
{% for slug, label, url in possible_states %}
|
||||
<a class="btn btn-default {% if slug == selected_state %}active{% endif %}" href="{{ url }}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if stats_type == "completion" %}
|
||||
|
||||
<h3>Completion status and completion time</h3>
|
||||
|
||||
<table class="review-stats table">
|
||||
<thead>
|
||||
<th>
|
||||
{% if level == "team" %}
|
||||
Team
|
||||
{% elif level == "reviewer" %}
|
||||
Reviewer
|
||||
{% endif %}
|
||||
</th>
|
||||
<th title="Requests that are currently requested or accepted by reviewer">Open in time</th>
|
||||
<th title="Requests that are currently requested or accepted by reviewer and past the deadline">Open late</th>
|
||||
<th title="Requests that have been completed partially or completely">Completed in time</th>
|
||||
<th title="Requests that have been completed partially or completely past the deadline">Completed late</th>
|
||||
<th title="Requests that are rejected by the reviewer, withdrawn, overtaken by events or with no response from reviewer">Not completed</th>
|
||||
<th title="Average time between assignment and completion for completed reviews, in days">Avg. compl. days{% if count == "pages" %}/page{% endif %}</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in data %}
|
||||
<tr>
|
||||
<td>{{ row.obj }}</td>
|
||||
<td>{{ row.open_in_time }}</td>
|
||||
<td>{{ row.open_late }}</td>
|
||||
<td>{{ row.completed_in_time }}</td>
|
||||
<td>{{ row.completed_late }}</td>
|
||||
<td>{{ row.not_completed }}</td>
|
||||
<td>
|
||||
{% if row.average_assignment_to_closure_days != None %}
|
||||
{{ row.average_assignment_to_closure_days|floatformat }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% elif stats_type == "results" %}
|
||||
|
||||
<h3>Results of completed reviews</h3>
|
||||
|
||||
<table class="review-stats table">
|
||||
<thead>
|
||||
<th>
|
||||
{% if level == "team" %}
|
||||
Team
|
||||
{% elif level == "reviewer" %}
|
||||
Reviewer
|
||||
{% endif %}
|
||||
</th>
|
||||
{% for r in results %}
|
||||
<th>{{ r.name }}</th>
|
||||
{% endfor %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in data %}
|
||||
<tr>
|
||||
<td>{{ row.obj }}</td>
|
||||
{% for c in row.result_list %}
|
||||
<td>{{ c }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% elif stats_type == "states" %}
|
||||
|
||||
<h3>Specific request states</h3>
|
||||
|
||||
<table class="review-stats table">
|
||||
<thead>
|
||||
<th>
|
||||
{% if level == "team" %}
|
||||
Team
|
||||
{% elif level == "reviewer" %}
|
||||
Reviewer
|
||||
{% endif %}
|
||||
</th>
|
||||
{% for s in states %}
|
||||
<th>{{ s.name }}</th>
|
||||
{% endfor %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in data %}
|
||||
<tr>
|
||||
<td>{{ row.obj }}</td>
|
||||
{% for c in row.state_list %}
|
||||
<td>{{ c }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% elif stats_type == "time" and selected_team %}
|
||||
|
||||
<h3>Counts per month</h3>
|
||||
|
||||
<div class="stats-time-graph"></div>
|
||||
|
||||
<script>
|
||||
var timeSeriesData = {{ data|safe }};
|
||||
var timeSeriesOptions = {
|
||||
xaxis: {
|
||||
mode: "time",
|
||||
tickLength: 0
|
||||
},
|
||||
yaxis: {
|
||||
tickDecimals: {% if selected_completion_type == "average_assignment_to_closure_days" %}null{% else %}0{% endif %}
|
||||
},
|
||||
series: {
|
||||
color: "#3d22b3",
|
||||
bars: {
|
||||
show: true,
|
||||
barWidth: 20 * 24 * 60 * 60 * 1000,
|
||||
align: "center",
|
||||
lineWidth: 1,
|
||||
fill: 0.6
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if stats_type != "time" %}
|
||||
<p class="text-muted text-right">Note: {% if level == "team" %}teams{% elif level == "reviewer" %}reviewers{% endif %}
|
||||
with no requests in the period are omitted.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if level == "team" and stats_type != "time" %}
|
||||
<p>Statistics for individual reviewers:</p>
|
||||
|
||||
<div class="review-stats-teams">
|
||||
{% for t in teams %}
|
||||
<a href="{{ t.reviewer_stats_url }}">{{ t.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
|
||||
{% if stats_type == "time" %}
|
||||
<script src="{% static 'flot/jquery.flot.min.js' %}"></script>
|
||||
<script src="{% static 'flot/jquery.flot.time.min.js' %}"></script>
|
||||
<script src="{% static 'ietf/js/review-stats.js' %}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -54,9 +54,10 @@ urlpatterns = patterns('',
|
|||
(r'^secr/', include('ietf.secr.urls')),
|
||||
(r'^sitemap-(?P<section>.+).xml$', 'django.contrib.sitemaps.views.sitemap', {'sitemaps': sitemaps}),
|
||||
(r'^sitemap.xml$', 'django.contrib.sitemaps.views.index', { 'sitemaps': sitemaps}),
|
||||
(r'^stats/', include('ietf.stats.urls')),
|
||||
(r'^stream/', include('ietf.group.urls_stream')),
|
||||
(r'^submit/', include('ietf.submit.urls')),
|
||||
(r'^sync/', include('ietf.sync.urls')),
|
||||
(r'^stream/', include('ietf.group.urls_stream')),
|
||||
(r'^templates/', include('ietf.dbtemplate.urls')),
|
||||
(r'^(?P<group_type>(wg|rg|ag|team|dir|area))/', include('ietf.group.urls_info')),
|
||||
|
||||
|
|
|
@ -187,7 +187,7 @@ def encode_message(txt):
|
|||
def send_mail_text(request, to, frm, subject, txt, cc=None, extra=None, toUser=False, bcc=None):
|
||||
"""Send plain text message."""
|
||||
msg = encode_message(txt)
|
||||
send_mail_mime(request, to, frm, subject, msg, cc, extra, toUser, bcc)
|
||||
return send_mail_mime(request, to, frm, subject, msg, cc, extra, toUser, bcc)
|
||||
|
||||
def condition_message(to, frm, subject, msg, cc, extra):
|
||||
if isinstance(frm, tuple):
|
||||
|
@ -284,6 +284,8 @@ def send_mail_mime(request, to, frm, subject, msg, cc=None, extra=None, toUser=F
|
|||
build_warning_message(request, e)
|
||||
send_error_email(e)
|
||||
|
||||
return msg
|
||||
|
||||
def parse_preformatted(preformatted, extra={}, override={}):
|
||||
"""Parse preformatted string containing mail with From:, To:, ...,"""
|
||||
msg = message_from_string(preformatted.encode("utf-8"))
|
||||
|
@ -323,8 +325,8 @@ def send_mail_message(request, message, extra={}):
|
|||
if message.reply_to:
|
||||
e['Reply-to'] = message.reply_to
|
||||
|
||||
send_mail_text(request, message.to, message.frm, message.subject,
|
||||
message.body, cc=message.cc, bcc=message.bcc, extra=e)
|
||||
return send_mail_text(request, message.to, message.frm, message.subject,
|
||||
message.body, cc=message.cc, bcc=message.bcc, extra=e)
|
||||
|
||||
def exception_components(e):
|
||||
# See if it's a non-smtplib exception that we faked
|
||||
|
|
|
@ -6,7 +6,7 @@ from django.contrib.auth.models import User
|
|||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from ietf.doc.models import Document, DocAlias, State, DocumentAuthor, BallotType, DocEvent, BallotDocEvent, RelatedDocument
|
||||
from ietf.doc.models import Document, DocAlias, State, DocumentAuthor, BallotType, DocEvent, BallotDocEvent, RelatedDocument, NewRevisionDocEvent
|
||||
from ietf.group.models import Group, GroupHistory, Role, RoleHistory
|
||||
from ietf.iesg.models import TelechatDate
|
||||
from ietf.ipr.models import HolderIprDisclosure, IprDocRel, IprDisclosureStateName, IprLicenseTypeName
|
||||
|
@ -14,6 +14,8 @@ from ietf.meeting.models import Meeting
|
|||
from ietf.name.models import StreamName, DocRelationshipName
|
||||
from ietf.person.models import Person, Email
|
||||
from ietf.group.utils import setup_default_community_list_for_group
|
||||
from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, ResultUsedInReviewTeam,
|
||||
ReviewTypeName, TypeUsedInReviewTeam)
|
||||
|
||||
def create_person(group, role_name, name=None, username=None, email_address=None, password=None):
|
||||
"""Add person/user/email and role."""
|
||||
|
@ -32,6 +34,7 @@ def create_person(group, role_name, name=None, username=None, email_address=None
|
|||
person = Person.objects.create(name=name, ascii=name, user=user)
|
||||
email = Email.objects.create(address=email_address, person=person)
|
||||
Role.objects.create(group=group, name_id=role_name, person=person, email=email)
|
||||
return person
|
||||
|
||||
def create_group(**kwargs):
|
||||
return Group.objects.create(state_id="active", **kwargs)
|
||||
|
@ -218,7 +221,7 @@ def make_test_data():
|
|||
mars_wg.save()
|
||||
|
||||
create_person(ames_wg, "chair", name="Ames Chair Man", username="ameschairman")
|
||||
create_person(ames_wg, "delegate", name="WG Dèlegate", username="amesdelegate")
|
||||
create_person(ames_wg, "delegate", name="Ames Delegate", username="amesdelegate")
|
||||
create_person(ames_wg, "secr", name="Mr Secretary", username="amessecretary")
|
||||
ames_wg.role_set.get_or_create(name_id='ad',person=ad,email=ad.role_email('ad'))
|
||||
ames_wg.save()
|
||||
|
@ -285,11 +288,12 @@ def make_test_data():
|
|||
desc="Started IESG process",
|
||||
)
|
||||
|
||||
DocEvent.objects.create(
|
||||
NewRevisionDocEvent.objects.create(
|
||||
type="new_revision",
|
||||
by=ad,
|
||||
doc=draft,
|
||||
desc="New revision available",
|
||||
rev="01",
|
||||
)
|
||||
|
||||
BallotDocEvent.objects.create(
|
||||
|
@ -389,3 +393,34 @@ def make_test_data():
|
|||
#other_doc_factory('recording','recording-42-mars-1-00')
|
||||
|
||||
return draft
|
||||
|
||||
def make_review_data(doc):
|
||||
team = create_group(acronym="reviewteam", name="Review Team", type_id="dir", list_email="reviewteam@ietf.org", parent=Group.objects.get(acronym="farfut"))
|
||||
for r in ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"]):
|
||||
ResultUsedInReviewTeam.objects.create(team=team, result=r)
|
||||
for t in ReviewTypeName.objects.filter(slug__in=["early", "lc", "telechat"]):
|
||||
TypeUsedInReviewTeam.objects.create(team=team, type=t)
|
||||
|
||||
p = Person.objects.get(user__username="plain")
|
||||
email = p.email_set.first()
|
||||
Role.objects.create(name_id="reviewer", person=p, email=email, group=team)
|
||||
ReviewerSettings.objects.create(team=team, person=p, min_interval=14, skip_next=0)
|
||||
|
||||
review_req = ReviewRequest.objects.create(
|
||||
doc=doc,
|
||||
team=team,
|
||||
type_id="early",
|
||||
deadline=datetime.datetime.now() + datetime.timedelta(days=20),
|
||||
state_id="accepted",
|
||||
requested_by=p,
|
||||
reviewer=email,
|
||||
)
|
||||
|
||||
p = Person.objects.get(user__username="marschairman")
|
||||
Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team)
|
||||
|
||||
p = Person.objects.get(user__username="secretary")
|
||||
Role.objects.create(name_id="secr", person=p, email=p.email_set.first(), group=team)
|
||||
|
||||
return review_req
|
||||
|
||||
|
|
|
@ -255,7 +255,7 @@ def canonicalize_sitemap(s):
|
|||
|
||||
def login_testing_unauthorized(test_case, username, url, password=None):
|
||||
r = test_case.client.get(url)
|
||||
test_case.assertTrue(r.status_code in (302, 403))
|
||||
test_case.assertIn(r.status_code, (302, 403))
|
||||
if r.status_code == 302:
|
||||
test_case.assertTrue("/accounts/login" in r['Location'])
|
||||
if not password:
|
||||
|
@ -272,6 +272,17 @@ def unicontent(r):
|
|||
encoding = 'utf-8'
|
||||
return r.content.decode(encoding)
|
||||
|
||||
def reload_db_objects(*objects):
|
||||
"""Rerequest the given arguments from the database so they're refreshed, to be used like
|
||||
|
||||
foo, bar = reload_objects(foo, bar)"""
|
||||
|
||||
t = tuple(o.__class__.objects.get(pk=o.pk) for o in objects)
|
||||
if len(objects) == 1:
|
||||
return t[0]
|
||||
else:
|
||||
return t
|
||||
|
||||
class ReverseLazyTest(django.test.TestCase):
|
||||
def test_redirect_with_lazy_reverse(self):
|
||||
response = self.client.get('/ipr/update/')
|
||||
|
|
|
@ -18,3 +18,15 @@ def xslugify(value):
|
|||
value = re.sub('[^\w\s/-]', '', value).strip().lower()
|
||||
return mark_safe(re.sub('[-\s/]+', '-', value))
|
||||
xslugify = allow_lazy(xslugify, six.text_type)
|
||||
|
||||
def strip_prefix(text, prefix):
|
||||
if text.startswith(prefix):
|
||||
return text[len(prefix):]
|
||||
else:
|
||||
return text
|
||||
|
||||
def strip_suffix(text, suffix):
|
||||
if text.endswith(suffix):
|
||||
return text[:-len(suffix)]
|
||||
else:
|
||||
return text
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import re
|
||||
|
||||
import django.forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
def get_cleaned_text_file_content(uploaded_file):
|
||||
"""Read uploaded file, try to fix up encoding to UTF-8 and
|
||||
transform line endings into Unix style, then return the content as
|
||||
a UTF-8 string. Errors are reported as
|
||||
django.forms.ValidationError exceptions."""
|
||||
django.core.exceptions.ValidationError exceptions."""
|
||||
|
||||
if not uploaded_file:
|
||||
return u""
|
||||
|
||||
if uploaded_file.size and uploaded_file.size > 10 * 1000 * 1000:
|
||||
raise django.forms.ValidationError("Text file too large (size %s)." % uploaded_file.size)
|
||||
raise ValidationError("Text file too large (size %s)." % uploaded_file.size)
|
||||
|
||||
content = "".join(uploaded_file.chunks())
|
||||
|
||||
|
@ -29,18 +29,18 @@ def get_cleaned_text_file_content(uploaded_file):
|
|||
filetype = m.from_buffer(content)
|
||||
|
||||
if not filetype.startswith("text"):
|
||||
raise django.forms.ValidationError("Uploaded file does not appear to be a text file.")
|
||||
raise ValidationError("Uploaded file does not appear to be a text file.")
|
||||
|
||||
match = re.search("charset=([\w-]+)", filetype)
|
||||
if not match:
|
||||
raise django.forms.ValidationError("File has unknown encoding.")
|
||||
raise ValidationError("File has unknown encoding.")
|
||||
|
||||
encoding = match.group(1)
|
||||
if "ascii" not in encoding:
|
||||
try:
|
||||
content = content.decode(encoding)
|
||||
except Exception as e:
|
||||
raise django.forms.ValidationError("Error decoding file (%s). Try submitting with UTF-8 encoding or remove non-ASCII characters." % str(e))
|
||||
raise ValidationError("Error decoding file (%s). Try submitting with UTF-8 encoding or remove non-ASCII characters." % str(e))
|
||||
|
||||
# turn line-endings into Unix style
|
||||
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
|
Loading…
Reference in a new issue