From 54c4c5efc561297591db538f3212ac3974862cf6 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 19 May 2016 15:33:02 +0000 Subject: [PATCH 01/90] Make the test data new revision event a proper NewRevisionDocEvent so it's consistent with what the code expects - Legacy-Id: 11205 --- ietf/utils/test_data.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index 7d04ff6f1..638294058 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -6,7 +6,7 @@ from django.contrib.auth.models import User import debug # pyflakes:ignore -from ietf.doc.models import Document, DocAlias, State, DocumentAuthor, BallotType, DocEvent, BallotDocEvent, RelatedDocument +from ietf.doc.models import Document, DocAlias, State, DocumentAuthor, BallotType, DocEvent, BallotDocEvent, RelatedDocument, NewRevisionDocEvent from ietf.group.models import Group, GroupHistory, Role, RoleHistory from ietf.iesg.models import TelechatDate from ietf.ipr.models import HolderIprDisclosure, IprDocRel, IprDisclosureStateName, IprLicenseTypeName @@ -247,11 +247,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( From 64a65340a21123b2896b9ddf502d6ab489f828b6 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 19 May 2016 15:35:30 +0000 Subject: [PATCH 02/90] Add review tracking models, add a request review page (with test), show review requests on doc page - Legacy-Id: 11206 --- ietf/doc/models.py | 5 +- ietf/doc/tests_review.py | 82 +++++++ ietf/doc/urls.py | 3 + ietf/doc/utils.py | 11 +- ietf/doc/views_doc.py | 14 +- ietf/doc/views_review.py | 108 +++++++++ ietf/name/admin.py | 6 +- ietf/name/fixtures/names.json | 211 +++++++++++++++++- ietf/name/generate_fixtures.py | 2 + ...atename_reviewresultname_reviewtypename.py | 61 +++++ .../0012_insert_review_name_data.py | 48 ++++ ietf/name/models.py | 11 + ietf/name/resources.py | 46 +++- ietf/review/__init__.py | 0 ietf/review/migrations/0001_initial.py | 50 +++++ ietf/review/migrations/__init__.py | 0 ietf/review/models.py | 40 ++++ ietf/review/utils.py | 6 + ietf/settings.py | 1 + ietf/templates/doc/document_draft.html | 22 ++ ietf/templates/doc/review/request_review.html | 34 +++ 21 files changed, 753 insertions(+), 8 deletions(-) create mode 100644 ietf/doc/tests_review.py create mode 100644 ietf/doc/views_review.py create mode 100644 ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py create mode 100644 ietf/name/migrations/0012_insert_review_name_data.py create mode 100644 ietf/review/__init__.py create mode 100644 ietf/review/migrations/0001_initial.py create mode 100644 ietf/review/migrations/__init__.py create mode 100644 ietf/review/models.py create mode 100644 ietf/review/utils.py create mode 100644 ietf/templates/doc/review/request_review.html diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 4a772db77..157255dfd 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -703,7 +703,10 @@ EVENT_TYPES = [ # RFC Editor ("rfc_editor_received_announcement", "Announcement was received by RFC Editor"), - ("requested_publication", "Publication at RFC Editor requested") + ("requested_publication", "Publication at RFC Editor requested"), + + # review + ("requested_review", "Requested review"), ] class DocEvent(models.Model): diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py new file mode 100644 index 000000000..fa0893587 --- /dev/null +++ b/ietf/doc/tests_review.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- + +import datetime +from pyquery import PyQuery + +from django.core.urlresolvers import reverse as urlreverse + +import debug # pyflakes:ignore + +from ietf.review.models import ReviewRequest +from ietf.person.models import Person +from ietf.group.models import Group, Role +from ietf.name.models import ReviewResultName +from ietf.utils.test_utils import TestCase +from ietf.utils.test_data import make_test_data +from ietf.utils.test_utils import login_testing_unauthorized + +def make_review_data(): + review_team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team") + review_team.reviewresultname_set.add(ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"])) + + p = Person.objects.get(user__username="plain") + Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=review_team) + + return review_team + +class ReviewTests(TestCase): + def test_request_review(self): + doc = make_test_data() + review_team = make_review_data() + + 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_date = datetime.date.today() + datetime.timedelta(days=10) + + # post request + r = self.client.post(url, { + "type": "early", + "team": review_team.pk, + "deadline_date": deadline_date.isoformat(), + "requested_rev": "01" + }) + self.assertEqual(r.status_code, 302) + + req = ReviewRequest.objects.get(doc=doc) + self.assertEqual(req.deadline.date(), deadline_date) + self.assertEqual(req.deadline.time(), datetime.time(23, 59, 59)) + self.assertEqual(req.state_id, "requested") + self.assertEqual(req.team, review_team) + self.assertEqual(req.requested_rev, "01") + self.assertEqual(doc.latest_event().type, "requested_review") + + def test_request_review_by_reviewer(self): + doc = make_test_data() + review_team = make_review_data() + + url = urlreverse('ietf.doc.views_review.request_review', kwargs={ "name": doc.name }) + login_testing_unauthorized(self, "plain", url) + + # post request + deadline_date = datetime.date.today() + datetime.timedelta(days=10) + + r = self.client.post(url, { + "type": "early", + "team": review_team.pk, + "deadline_date": deadline_date.isoformat(), + "requested_rev": "01" + }) + self.assertEqual(r.status_code, 302) + + req = ReviewRequest.objects.get(doc=doc) + self.assertEqual(req.state_id, "requested") + self.assertEqual(req.team, review_team) + + def test_doc_page(self): + pass + diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index 235a95c17..188ccbb6d 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -36,6 +36,7 @@ from django.views.generic import RedirectView from ietf.doc import views_search, views_draft, views_ballot from ietf.doc import views_status_change from ietf.doc import views_doc +from ietf.doc import views_review session_patterns = [ url(r'^add$', views_doc.add_sessionpresentation), @@ -73,6 +74,8 @@ urlpatterns = patterns('', url(r'^(?P[A-Za-z0-9._+-]+)/ballot/$', views_doc.document_ballot, name="doc_ballot"), (r'^(?P[A-Za-z0-9._+-]+)/(?:(?P[0-9-]+)/)?doc.json$', views_doc.document_json), (r'^(?P[A-Za-z0-9._+-]+)/ballotpopup/(?P[0-9]+)/$', views_doc.ballot_popup), + url(r'^(?P[A-Za-z0-9._+-]+)/requestreview/$', views_review.request_review), + url(r'^(?P[A-Za-z0-9._+-]+)/review/(?P[0-9]+)/$', views_review.review), url(r'^(?P[A-Za-z0-9._+-]+)/email-aliases/$', RedirectView.as_view(pattern_name='doc_email', permanent=False),name='doc_specific_email_aliases'), diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index bb483e14d..923715895 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -16,7 +16,7 @@ from ietf.doc.models import DocEvent, ConsensusDocEvent, BallotDocEvent, NewRevi from ietf.doc.models import save_document_in_history from ietf.name.models import DocReminderTypeName, DocRelationshipName from ietf.group.models import Role -from ietf.ietfauth.utils import has_role +from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream from ietf.utils import draft, markup_txt from ietf.utils.mail import send_mail from ietf.mailtrigger.utils import gather_address_lists @@ -89,6 +89,15 @@ def can_adopt_draft(user, doc): group__state="active", person__user=user).exists()) +def can_request_review_of_doc(user, doc): + if not user.is_authenticated(): + return False + + from ietf.review.utils import active_review_teams + if Role.objects.filter(name="reviewer", person__user=user, group__in=active_review_teams()): + return True + + return is_authorized_in_doc_stream(user, doc) def two_thirds_rule( recused=0 ): # For standards-track, need positions from 2/3 of the non-recused current IESG. diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 5be36fd26..d25d88fc3 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -48,7 +48,8 @@ from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDo from ietf.doc.utils import ( add_links_in_new_revision_events, augment_events_with_revision, can_adopt_draft, get_chartering_type, get_document_content, get_tags_for_stream_id, needed_ballot_positions, nice_consensus, prettify_std_name, update_telechat, has_same_ballot, - get_initial_notify, make_notify_changed_event, crawl_history, default_consensus) + get_initial_notify, make_notify_changed_event, crawl_history, default_consensus, + can_request_review_of_doc ) from ietf.community.utils import augment_docs_with_tracking_info from ietf.group.models import Role from ietf.group.utils import can_manage_group, can_manage_materials @@ -57,10 +58,11 @@ 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 def render_document_top(request, doc, tab, name): tabs = [] @@ -279,8 +281,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) @@ -294,6 +296,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: @@ -353,6 +357,8 @@ 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 = ReviewRequest.objects.filter(doc=doc) + return render_to_response("doc/document_draft.html", dict(doc=doc, group=group, @@ -374,6 +380,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, @@ -412,6 +419,7 @@ def document_main(request, name, rev=None): search_archive=search_archive, actions=actions, presentations=presentations, + review_requests=review_requests, ), context_instance=RequestContext(request)) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py new file mode 100644 index 000000000..5578dcd97 --- /dev/null +++ b/ietf/doc/views_review.py @@ -0,0 +1,108 @@ +import datetime + +from django.http import HttpResponseForbidden +from django.shortcuts import render, get_object_or_404, redirect +from django.core.urlresolvers import reverse as urlreverse +from django import forms +from django.contrib.auth.decorators import login_required + +from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent +from ietf.doc.utils import can_request_review_of_doc +from ietf.ietfauth.utils import is_authorized_in_doc_stream +from ietf.review.models import ReviewRequest, ReviewRequestStateName +from ietf.review.utils import active_review_teams +from ietf.utils.fields import DatepickerDateField + + +class RequestReviewForm(forms.ModelForm): + deadline_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" }) + deadline_time = forms.TimeField(widget=forms.TextInput(attrs={ 'placeholder': "HH:MM" }), help_text="If time is not specified, end of day is assumed", required=False) + + class Meta: + model = ReviewRequest + fields = ('type', 'team', 'deadline', 'requested_rev') + + def __init__(self, user, doc, *args, **kwargs): + super(RequestReviewForm, self).__init__(*args, **kwargs) + + self.doc = doc + + self.fields['type'].widget = forms.RadioSelect(choices=[t for t in self.fields['type'].choices if t[0]]) + + f = self.fields["team"] + f.queryset = active_review_teams() + if not is_authorized_in_doc_stream(user, doc): # user is a reviewer + f.queryset = f.queryset.filter(role__name="reviewer", role__person__user=user) + if len(f.queryset) < 6: + f.widget = forms.RadioSelect(choices=[t for t in f.choices if t[0]]) + + self.fields["deadline"].required = False + self.fields["requested_rev"].label = "Document revision" + + def clean_deadline_date(self): + v = self.cleaned_data.get('deadline_date') + if v < datetime.date.today(): + raise forms.ValidationError("Select a future date.") + return v + + def clean_requested_rev(self): + rev = self.cleaned_data.get("requested_rev") + if rev: + rev = rev.rjust(2, "0") + + if not NewRevisionDocEvent.objects.filter(doc=self.doc, rev=rev).exists(): + raise forms.ValidationError("Could not find revision '{}' of the document.".format(rev)) + + return rev + + def clean(self): + deadline_date = self.cleaned_data.get('deadline_date') + deadline_time = self.cleaned_data.get('deadline_time', None) + + if deadline_date: + if deadline_time is None: + deadline_time = datetime.time(23, 59, 59) + + self.cleaned_data["deadline"] = datetime.datetime.combine(deadline_date, deadline_time) + + 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") + + if request.method == "POST": + form = RequestReviewForm(request.user, doc, request.POST) + + if form.is_valid(): + review_req = form.save(commit=False) + review_req.doc = doc + review_req.state = ReviewRequestStateName.objects.get(slug="requested", used=True) + review_req.save() + + DocEvent.objects.create( + type="requested_review", + doc=doc, + by=request.user.person, + desc="{} review by {} requested".format(review_req.type.name, review_req.team.acronym.upper()), + ) + + # FIXME: if I'm a reviewer, auto-assign to myself? + return redirect('doc_view', name=doc.name) + + else: + form = RequestReviewForm(request.user, doc) + + return render(request, 'doc/review/request_review.html', { + 'doc': doc, + 'form': form, + }) + +def review(request, name, request_id): + doc = get_object_or_404(Document, name=name) + review_request = get_object_or_404(ReviewRequest, pk=request_id) + + print doc, review_request diff --git a/ietf/name/admin.py b/ietf/name/admin.py index 177501f1d..608620ff1 100644 --- a/ietf/name/admin.py +++ b/ietf/name/admin.py @@ -3,7 +3,8 @@ from ietf.name.models import (GroupTypeName, GroupStateName, RoleName, StreamNam DocRelationshipName, DocTypeName, DocTagName, StdLevelName, IntendedStdLevelName, DocReminderTypeName, BallotPositionName, SessionStatusName, TimeSlotTypeName, ConstraintName, NomineePositionStateName, FeedbackTypeName, DBTemplateTypeName, - DraftSubmissionStateName, RoomResourceName) + DraftSubmissionStateName, RoomResourceName, + ReviewRequestStateName, ReviewTypeName, ReviewResultName) class NameAdmin(admin.ModelAdmin): @@ -35,3 +36,6 @@ admin.site.register(FeedbackTypeName, NameAdmin) admin.site.register(DBTemplateTypeName, NameAdmin) admin.site.register(DraftSubmissionStateName, NameAdmin) admin.site.register(RoomResourceName, NameAdmin) +admin.site.register(ReviewRequestStateName, NameAdmin) +admin.site.register(ReviewTypeName, NameAdmin) +admin.site.register(ReviewResultName, NameAdmin) diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 0f355ee2f..5d9e8def4 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -188,7 +188,7 @@ "order": 0, "revname": "Conflict reviewed by", "used": true, - "name": "Conflict reviews", + "name": "conflict reviews", "desc": "" }, "model": "name.docrelationshipname", @@ -1752,6 +1752,205 @@ "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": "noresponse" +}, +{ + "fields": { + "order": 7, + "used": true, + "name": "Completed", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "completed" +}, +{ + "fields": { + "order": 1, + "used": true, + "teams": [], + "name": "Almost Ready", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "almost-ready" +}, +{ + "fields": { + "order": 2, + "used": true, + "teams": [], + "name": "Has Issues", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "issues" +}, +{ + "fields": { + "order": 3, + "used": true, + "teams": [], + "name": "Has Nits", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "nits" +}, +{ + "fields": { + "order": 4, + "used": true, + "teams": [], + "name": "Not Ready", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "not-ready" +}, +{ + "fields": { + "order": 5, + "used": true, + "teams": [], + "name": "On the Right Track", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "right-track" +}, +{ + "fields": { + "order": 6, + "used": true, + "teams": [], + "name": "Ready", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "ready" +}, +{ + "fields": { + "order": 7, + "used": true, + "teams": [], + "name": "Ready with Issues", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "ready-issues" +}, +{ + "fields": { + "order": 8, + "used": true, + "teams": [], + "name": "Ready with Nits", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "ready-nits" +}, +{ + "fields": { + "order": 9, + "used": true, + "teams": [], + "name": "Serious Issues", + "desc": "" + }, + "model": "name.reviewresultname", + "pk": "serious-issues" +}, +{ + "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, @@ -1942,6 +2141,16 @@ "model": "name.rolename", "pk": "matman" }, +{ + "fields": { + "order": 14, + "used": true, + "name": "Reviewer", + "desc": "" + }, + "model": "name.rolename", + "pk": "reviewer" +}, { "fields": { "order": 0, diff --git a/ietf/name/generate_fixtures.py b/ietf/name/generate_fixtures.py index a413cbd13..753f6b8ad 100644 --- a/ietf/name/generate_fixtures.py +++ b/ietf/name/generate_fixtures.py @@ -1,5 +1,7 @@ #!/usr/bin/python +# simple script for exporting name related base data for the tests + # boiler plate import os, sys import django diff --git a/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py b/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py new file mode 100644 index 000000000..fb62deea3 --- /dev/null +++ b/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('group', '0008_auto_20160505_0523'), + ('name', '0010_new_liaison_names'), + ] + + operations = [ + migrations.CreateModel( + name='ReviewRequestStateName', + fields=[ + ('slug', models.CharField(max_length=32, serialize=False, primary_key=True)), + ('name', models.CharField(max_length=255)), + ('desc', models.TextField(blank=True)), + ('used', models.BooleanField(default=True)), + ('order', models.IntegerField(default=0)), + ], + options={ + 'ordering': ['order'], + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='ReviewResultName', + fields=[ + ('slug', models.CharField(max_length=32, serialize=False, primary_key=True)), + ('name', models.CharField(max_length=255)), + ('desc', models.TextField(blank=True)), + ('used', models.BooleanField(default=True)), + ('order', models.IntegerField(default=0)), + ('teams', models.ManyToManyField(help_text=b"Which teams this result can be set for. This also implicitly defines which teams are review teams - if there are no possible review results defined for a given team, it can't be a review team.", to='group.Group', blank=True)), + ], + options={ + 'ordering': ['order'], + 'abstract': False, + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='ReviewTypeName', + fields=[ + ('slug', models.CharField(max_length=32, serialize=False, primary_key=True)), + ('name', models.CharField(max_length=255)), + ('desc', models.TextField(blank=True)), + ('used', models.BooleanField(default=True)), + ('order', models.IntegerField(default=0)), + ], + options={ + 'ordering': ['order'], + 'abstract': False, + }, + bases=(models.Model,), + ), + ] diff --git a/ietf/name/migrations/0012_insert_review_name_data.py b/ietf/name/migrations/0012_insert_review_name_data.py new file mode 100644 index 000000000..ed1a4bfe6 --- /dev/null +++ b/ietf/name/migrations/0012_insert_review_name_data.py @@ -0,0 +1,48 @@ +# -*- 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="noresponse", name="No Response", order=6) + ReviewRequestStateName.objects.get_or_create(slug="completed", name="Completed", order=7) + + ReviewTypeName = apps.get_model("name", "ReviewTypeName") + ReviewTypeName.objects.get_or_create(slug="early", name="Early", order=1) + ReviewTypeName.objects.get_or_create(slug="lc", name="Last Call", order=2) + ReviewTypeName.objects.get_or_create(slug="telechat", name="Telechat", order=3) + + ReviewResultName = apps.get_model("name", "ReviewResultName") + ReviewResultName.objects.get_or_create(slug="almost-ready", name="Almost Ready", order=1) + ReviewResultName.objects.get_or_create(slug="issues", name="Has Issues", order=2) + ReviewResultName.objects.get_or_create(slug="nits", name="Has Nits", order=3) + ReviewResultName.objects.get_or_create(slug="not-ready", name="Not Ready", order=4) + ReviewResultName.objects.get_or_create(slug="right-track", name="On the Right Track", order=5) + ReviewResultName.objects.get_or_create(slug="ready", name="Ready", order=6) + ReviewResultName.objects.get_or_create(slug="ready-issues", name="Ready with Issues", order=7) + ReviewResultName.objects.get_or_create(slug="ready-nits", name="Ready with Nits", order=8) + ReviewResultName.objects.get_or_create(slug="serious-issues", name="Serious Issues", order=9) + + RoleName = apps.get_model("name", "RoleName") + RoleName.objects.get_or_create(slug="reviewer", name="Reviewer", order=max(r.order for r in RoleName.objects.all()) + 1) + +def noop(apps, schema_editor): + pass + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0011_reviewrequeststatename_reviewresultname_reviewtypename'), + ('group', '0001_initial'), + ] + + operations = [ + migrations.RunPython(insert_initial_review_data, noop), + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index 3dd29d5ba..dad83c97f 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -87,3 +87,14 @@ 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 , 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""" + teams = models.ManyToManyField("group.Group", help_text="Which teams this result can be set for. This also implicitly defines which teams are review teams - if there are no possible review results defined for a given team, it can't be a review team.", blank=True) + diff --git a/ietf/name/resources.py b/ietf/name/resources.py index 57e03841a..521f4ee79 100644 --- a/ietf/name/resources.py +++ b/ietf/name/resources.py @@ -13,7 +13,8 @@ from ietf.name.models import (TimeSlotTypeName, GroupStateName, DocTagName, Inte IprEventTypeName, GroupMilestoneStateName, SessionStatusName, DocReminderTypeName, ConstraintName, MeetingTypeName, DocRelationshipName, RoomResourceName, IprLicenseTypeName, LiaisonStatementTagName, FeedbackTypeName, LiaisonStatementState, StreamName, - BallotPositionName, DBTemplateTypeName, NomineePositionStateName) + BallotPositionName, DBTemplateTypeName, NomineePositionStateName, + ReviewRequestStateName, ReviewTypeName, ReviewResultName) class TimeSlotTypeNameResource(ModelResource): @@ -413,3 +414,46 @@ 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, + "teams": ALL_WITH_RELATIONS, + } +api.name.register(ReviewResultNameResource()) + diff --git a/ietf/review/__init__.py b/ietf/review/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py new file mode 100644 index 000000000..523b73c5e --- /dev/null +++ b/ietf/review/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# -*- 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', '0012_insert_review_name_data'), + ('doc', '0012_auto_20160207_0537'), + ] + + operations = [ + migrations.CreateModel( + name='Reviewer', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('frequency', models.IntegerField(help_text=b'Can review every N days')), + ('available', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)), + ('filter_re', models.CharField(max_length=255, blank=True)), + ('skip_next', models.IntegerField(help_text=b'Skip the next N review assignments')), + ('role', models.ForeignKey(to='group.Role')), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='ReviewRequest', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('time', models.DateTimeField(auto_now_add=True)), + ('deadline', models.DateTimeField()), + ('requested_rev', models.CharField(help_text=b'Fill in if a specific revision is to be reviewed, e.g. 02', max_length=16, verbose_name=b'requested revision', blank=True)), + ('reviewed_rev', models.CharField(max_length=16, verbose_name=b'reviewed revision', blank=True)), + ('doc', models.ForeignKey(related_name='review_request_set', to='doc.Document')), + ('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='review.Reviewer', null=True)), + ('state', models.ForeignKey(to='name.ReviewRequestStateName')), + ('team', models.ForeignKey(to='group.Group')), + ('type', models.ForeignKey(to='name.ReviewTypeName')), + ], + options={ + }, + bases=(models.Model,), + ), + ] diff --git a/ietf/review/migrations/__init__.py b/ietf/review/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ietf/review/models.py b/ietf/review/models.py new file mode 100644 index 000000000..c4a1f5475 --- /dev/null +++ b/ietf/review/models.py @@ -0,0 +1,40 @@ +from django.db import models + +from ietf.doc.models import Document +from ietf.group.models import Group, Role +from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName + +class Reviewer(models.Model): + """ + These records associate reviewers with review teams and keep track + of admin data associated with the reviewer in the particular team. + There will be one record for each combination of reviewer and team. + """ + role = models.ForeignKey(Role) + frequency = models.IntegerField(help_text="Can review every N days") + available = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again") + filter_re = models.CharField(max_length=255, blank=True) + skip_next = models.IntegerField(help_text="Skip the next N review assignments") + +class ReviewRequest(models.Model): + """ + There should be one ReviewRequest entered for each combination of + document, rev, and reviewer. + """ + # Fields filled in on the initial record creation: + time = models.DateTimeField(auto_now_add=True) + type = models.ForeignKey(ReviewTypeName) + doc = models.ForeignKey(Document, related_name='review_request_set') + team = models.ForeignKey(Group) + deadline = models.DateTimeField() + 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") + state = models.ForeignKey(ReviewRequestStateName) + # Fields filled in as reviewer is assigned, and as the review + # is uploaded + reviewer = models.ForeignKey(Reviewer, blank=True, null=True) + review = models.OneToOneField(Document, blank=True, null=True) + reviewed_rev = models.CharField(verbose_name="reviewed revision", max_length=16, blank=True) + result = models.ForeignKey(ReviewResultName, blank=True, null=True) + + def __unicode__(self): + return u"%s review on %s by %s %s" % (self.type, self.doc, self.team, self.state) diff --git a/ietf/review/utils.py b/ietf/review/utils.py new file mode 100644 index 000000000..0a1bd86bb --- /dev/null +++ b/ietf/review/utils.py @@ -0,0 +1,6 @@ +from ietf.group.models import Group + +def active_review_teams(): + # if there's a ReviewResultName defined, it's a review team + return Group.objects.filter(state="active").exclude(reviewresultname=None) + diff --git a/ietf/settings.py b/ietf/settings.py index ae716769e..ec91d6fb0 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -273,6 +273,7 @@ INSTALLED_APPS = ( 'ietf.person', 'ietf.redirects', 'ietf.release', + 'ietf.review', 'ietf.submit', 'ietf.sync', 'ietf.utils', diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 4c66aa8b3..20f4a2761 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -192,6 +192,28 @@ + {% if review_requests or can_request_review %} + + + Reviews + + + {% for r in review_requests %} + + {% endfor %} + + {% if can_request_review %} + + {% endif %} + + + {% endif %} + + {% if conflict_reviews %} diff --git a/ietf/templates/doc/review/request_review.html b/ietf/templates/doc/review/request_review.html new file mode 100644 index 000000000..d05b27733 --- /dev/null +++ b/ietf/templates/doc/review/request_review.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2016, All Rights Reserved #} +{% load origin bootstrap3 static %} + +{% block pagehead %} + +{% endblock %} + +{% block title %}Request review of {{ doc.name }} {% endblock %} + +{% block content %} + {% origin %} +

Request review
{{ doc.name }}

+ +

Submit a request to have the document reviewed.

+ +
+ {% csrf_token %} + {% bootstrap_field form.type layout="horizontal" %} + {% bootstrap_field form.team layout="horizontal" %} + {% bootstrap_field form.deadline_date layout="horizontal" %} + {% bootstrap_field form.deadline_time layout="horizontal" %} + {% bootstrap_field form.requested_rev layout="horizontal" %} + + {% buttons %} + + Back + {% endbuttons %} +
+{% endblock %} + +{% block js %} + +{% endblock %} From 44e135345c9ff741d4b49a1b4fa425ef4746f0a3 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Fri, 20 May 2016 14:14:31 +0000 Subject: [PATCH 03/90] Add a page for displaying a review request, add support for withdrawing requests, add tests for these two pages - Legacy-Id: 11217 --- ietf/doc/models.py | 1 + ietf/doc/tests_review.py | 69 +++++++++--- ietf/doc/urls.py | 4 +- ietf/doc/urls_review.py | 9 ++ ietf/doc/views_review.py | 44 +++++++- ietf/name/models.py | 4 +- ietf/review/migrations/0001_initial.py | 2 +- ietf/review/models.py | 2 +- ietf/templates/doc/document_draft.html | 2 +- ietf/templates/doc/review/request_review.html | 2 +- ietf/templates/doc/review/review_request.html | 103 ++++++++++++++++++ .../doc/review/withdraw_request.html | 22 ++++ ietf/utils/test_utils.py | 13 ++- 13 files changed, 247 insertions(+), 30 deletions(-) create mode 100644 ietf/doc/urls_review.py create mode 100644 ietf/templates/doc/review/review_request.html create mode 100644 ietf/templates/doc/review/withdraw_request.html diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 157255dfd..26d610c03 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -707,6 +707,7 @@ EVENT_TYPES = [ # review ("requested_review", "Requested review"), + ("withdrew_review_request", "Withdrew review"), ] class DocEvent(models.Model): diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index fa0893587..01c27711f 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -1,33 +1,44 @@ # -*- coding: utf-8 -*- import datetime -from pyquery import PyQuery from django.core.urlresolvers import reverse as urlreverse import debug # pyflakes:ignore -from ietf.review.models import ReviewRequest +from ietf.review.models import ReviewRequest, Reviewer from ietf.person.models import Person from ietf.group.models import Group, Role -from ietf.name.models import ReviewResultName +from ietf.name.models import ReviewResultName, ReviewRequestStateName from ietf.utils.test_utils import TestCase from ietf.utils.test_data import make_test_data -from ietf.utils.test_utils import login_testing_unauthorized +from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects -def make_review_data(): - review_team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team") - review_team.reviewresultname_set.add(ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"])) +def make_review_data(doc): + team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team") + team.reviewresultname_set.add(ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"])) p = Person.objects.get(user__username="plain") - Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=review_team) + role = Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team) + reviewer = Reviewer.objects.create(role=role, frequency=14, skip_next=0) - return review_team + review_req = ReviewRequest.objects.create( + doc=doc, + team=team, + type_id="early", + deadline=datetime.datetime.now() + datetime.timedelta(days=20), + state_id="ready", + reviewer=reviewer, + reviewed_rev="01", + ) + + return review_req class ReviewTests(TestCase): def test_request_review(self): doc = make_test_data() - review_team = make_review_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) @@ -47,17 +58,17 @@ class ReviewTests(TestCase): }) self.assertEqual(r.status_code, 302) - req = ReviewRequest.objects.get(doc=doc) + req = ReviewRequest.objects.get(doc=doc, state="requested") self.assertEqual(req.deadline.date(), deadline_date) self.assertEqual(req.deadline.time(), datetime.time(23, 59, 59)) - self.assertEqual(req.state_id, "requested") self.assertEqual(req.team, review_team) self.assertEqual(req.requested_rev, "01") self.assertEqual(doc.latest_event().type, "requested_review") def test_request_review_by_reviewer(self): doc = make_test_data() - review_team = make_review_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, "plain", url) @@ -73,10 +84,40 @@ class ReviewTests(TestCase): }) self.assertEqual(r.status_code, 302) - req = ReviewRequest.objects.get(doc=doc) + req = ReviewRequest.objects.get(doc=doc, state="requested") self.assertEqual(req.state_id, "requested") self.assertEqual(req.team, review_team) def test_doc_page(self): + # FIXME: fill in pass + 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_withdraw_request(self): + doc = make_test_data() + review_req = make_review_data(doc) + review_req.state = ReviewRequestStateName.objects.get(slug="accepted") + review_req.save() + + url = urlreverse('ietf.doc.views_review.withdraw_request', 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) + + # withdraw + r = self.client.post(url, { "action": "withdraw" }) + self.assertEqual(r.status_code, 302) + + review_req = reload_db_objects(review_req) + self.assertEqual(review_req.state_id, "withdrawn") + self.assertEqual(doc.latest_event().type, "withdrew_review_request") diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index 188ccbb6d..d75459813 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -36,7 +36,6 @@ from django.views.generic import RedirectView from ietf.doc import views_search, views_draft, views_ballot from ietf.doc import views_status_change from ietf.doc import views_doc -from ietf.doc import views_review session_patterns = [ url(r'^add$', views_doc.add_sessionpresentation), @@ -74,8 +73,7 @@ urlpatterns = patterns('', url(r'^(?P[A-Za-z0-9._+-]+)/ballot/$', views_doc.document_ballot, name="doc_ballot"), (r'^(?P[A-Za-z0-9._+-]+)/(?:(?P[0-9-]+)/)?doc.json$', views_doc.document_json), (r'^(?P[A-Za-z0-9._+-]+)/ballotpopup/(?P[0-9]+)/$', views_doc.ballot_popup), - url(r'^(?P[A-Za-z0-9._+-]+)/requestreview/$', views_review.request_review), - url(r'^(?P[A-Za-z0-9._+-]+)/review/(?P[0-9]+)/$', views_review.review), + url(r'^(?P[A-Za-z0-9._+-]+)/reviewrequest/', include("ietf.doc.urls_review")), url(r'^(?P[A-Za-z0-9._+-]+)/email-aliases/$', RedirectView.as_view(pattern_name='doc_email', permanent=False),name='doc_specific_email_aliases'), diff --git a/ietf/doc/urls_review.py b/ietf/doc/urls_review.py new file mode 100644 index 000000000..afc2f6536 --- /dev/null +++ b/ietf/doc/urls_review.py @@ -0,0 +1,9 @@ +from django.conf.urls import patterns, url +from ietf.doc import views_review + +urlpatterns = patterns('', + url(r'^$', views_review.request_review), + url(r'^(?P[0-9]+)/$', views_review.review_request), + url(r'^(?P[0-9]+)/withdraw/$', views_review.withdraw_request), +) + diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 5578dcd97..d1f7861bb 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -2,7 +2,6 @@ import datetime from django.http import HttpResponseForbidden from django.shortcuts import render, get_object_or_404, redirect -from django.core.urlresolvers import reverse as urlreverse from django import forms from django.contrib.auth.decorators import login_required @@ -13,7 +12,6 @@ from ietf.review.models import ReviewRequest, ReviewRequestStateName from ietf.review.utils import active_review_teams from ietf.utils.fields import DatepickerDateField - class RequestReviewForm(forms.ModelForm): deadline_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" }) deadline_time = forms.TimeField(widget=forms.TextInput(attrs={ 'placeholder': "HH:MM" }), help_text="If time is not specified, end of day is assumed", required=False) @@ -87,7 +85,8 @@ def request_review(request, name): type="requested_review", doc=doc, by=request.user.person, - desc="{} review by {} requested".format(review_req.type.name, review_req.team.acronym.upper()), + desc="Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()), + time=review_req.time, ) # FIXME: if I'm a reviewer, auto-assign to myself? @@ -101,8 +100,41 @@ def request_review(request, name): 'form': form, }) -def review(request, name, request_id): +def review_request(request, name, request_id): doc = get_object_or_404(Document, name=name) - review_request = get_object_or_404(ReviewRequest, pk=request_id) + review_req = get_object_or_404(ReviewRequest, pk=request_id) - print doc, review_request + return render(request, 'doc/review/review_request.html', { + 'doc': doc, + 'review_req': review_req, + 'can_withdraw_request': review_req.state_id in ["requested", "accepted"] and is_authorized_in_doc_stream(request.user, doc), + }) + +def withdraw_request(request, name, request_id): + doc = get_object_or_404(Document, name=name) + review_req = get_object_or_404(ReviewRequest, pk=request_id, state__in=["requested", "accepted"]) + + if not is_authorized_in_doc_stream(request.user, doc): + return HttpResponseForbidden("You do not have permission to perform this action") + + if request.method == "POST" and request.POST.get("action") == "withdraw": + review_req.state = ReviewRequestStateName.objects.get(slug="withdrawn") + review_req.save() + + DocEvent.objects.create( + type="withdrew_review_request", + doc=doc, + by=request.user.person, + desc="Withdrew request for {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()), + ) + + if review_req.state_id != "requested": + # FIXME: handle this case - by emailing? + pass + + return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) + + return render(request, 'doc/review/withdraw_request.html', { + 'doc': doc, + 'review_req': review_req, + }) diff --git a/ietf/name/models.py b/ietf/name/models.py index dad83c97f..733f957b3 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -89,12 +89,12 @@ class LiaisonStatementTagName(NameModel): "Action Required, Action Taken" class ReviewRequestStateName(NameModel): """Requested, Accepted, Rejected, Withdrawn, Overtaken By Events, - No Response , Completed""" + No Response, 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""" - teams = models.ManyToManyField("group.Group", help_text="Which teams this result can be set for. This also implicitly defines which teams are review teams - if there are no possible review results defined for a given team, it can't be a review team.", blank=True) + teams = models.ManyToManyField("group.Group", help_text="Which teams this result can be set for. This also implicitly defines which teams are review teams - if there are no possible review results defined for a given team, it can't be a review team.", blank=True) diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index 523b73c5e..2c19afa60 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): ('available', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)), ('filter_re', models.CharField(max_length=255, blank=True)), ('skip_next', models.IntegerField(help_text=b'Skip the next N review assignments')), - ('role', models.ForeignKey(to='group.Role')), + ('role', models.OneToOneField(to='group.Role')), ], options={ }, diff --git a/ietf/review/models.py b/ietf/review/models.py index c4a1f5475..7e166333c 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -10,7 +10,7 @@ class Reviewer(models.Model): of admin data associated with the reviewer in the particular team. There will be one record for each combination of reviewer and team. """ - role = models.ForeignKey(Role) + role = models.OneToOneField(Role) frequency = models.IntegerField(help_text="Can review every N days") available = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again") filter_re = models.CharField(max_length=255, blank=True) diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 20f4a2761..853d6fcc6 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -200,7 +200,7 @@ {% for r in review_requests %} {% endfor %} diff --git a/ietf/templates/doc/review/request_review.html b/ietf/templates/doc/review/request_review.html index d05b27733..aa1700aab 100644 --- a/ietf/templates/doc/review/request_review.html +++ b/ietf/templates/doc/review/request_review.html @@ -23,7 +23,7 @@ {% bootstrap_field form.requested_rev layout="horizontal" %} {% buttons %} - + Back {% endbuttons %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html new file mode 100644 index 000000000..f1087cd84 --- /dev/null +++ b/ietf/templates/doc/review/review_request.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2016, All Rights Reserved #} +{% load origin bootstrap3 static %} + +{% block title %}Review request for {{ review_req.doc.name }}{% endblock %} + +{% block content %} + {% origin %} +

Review request
{{ review_req.doc.name }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if review_req.reviewer %} + + + + + + {% endif %} + + {% if review_req.review %} + + + + + + {% endif %} + + {% if review_req.reviewed_rev %} + + + + + + {% endif %} + + {% if review_req.result %} + + + + + + {% endif %} + +
RequestReview of + {% if review_req.requested_rev %} + {{ review_req.doc.name }}-{{ review_req.requested_rev }} + {% else %} + {{ review_req.doc.name }} + {% endif %} +
Type{{ review_req.type.name }} Review
Team{{ review_req.team.acronym|upper }}
Deadline + {% if review_req.deadline|date:"H:i" != "23:59" %} + {{ review_req.deadline|date:"Y-m-d H:i" }} + {% else %} + {{ review_req.deadline|date:"Y-m-d" }} + {% endif %} +
Requested{{ review_req.time|date:"Y-m-d" }}
ReviewState{{ review_req.state.name }}
Reviewer{{ review_req.reviewer.role.person }}
Review{{ review_req.review.name }}
Reviewed revision{{ review_req.reviewed_rev }}
Result of review{{ review_req.result.name }
+ +
+ {% if can_withdraw_request %} + Withdraw request + {% endif %} +
+ +{% endblock %} diff --git a/ietf/templates/doc/review/withdraw_request.html b/ietf/templates/doc/review/withdraw_request.html new file mode 100644 index 000000000..191f236dc --- /dev/null +++ b/ietf/templates/doc/review/withdraw_request.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2016, All Rights Reserved #} +{% load origin bootstrap3 static %} + +{% block title %}Withdraw review request for {{ review_req.doc.name }}{% endblock %} + +{% block content %} + {% origin %} +

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

+ +

Do you want to withdraw the review request?

+ +
+ {% csrf_token %} + + {% buttons %} + Cancel + + {% endbuttons %} +
+ +{% endblock %} diff --git a/ietf/utils/test_utils.py b/ietf/utils/test_utils.py index bc4ed9b77..b3253aa14 100644 --- a/ietf/utils/test_utils.py +++ b/ietf/utils/test_utils.py @@ -255,7 +255,7 @@ def canonicalize_sitemap(s): def login_testing_unauthorized(test_case, username, url, password=None): r = test_case.client.get(url) - test_case.assertTrue(r.status_code in (302, 403)) + test_case.assertIn(r.status_code, (302, 403)) if r.status_code == 302: test_case.assertTrue("/accounts/login" in r['Location']) if not password: @@ -272,6 +272,17 @@ def unicontent(r): encoding = 'utf-8' return r.content.decode(encoding) +def reload_db_objects(*objects): + """Rerequest the given arguments from the database so they're refreshed, to be used like + + foo, bar = reload_objects(foo, bar)""" + + t = tuple(o.__class__.objects.get(pk=o.pk) for o in objects) + if len(objects) == 1: + return t[0] + else: + return t + class ReverseLazyTest(django.test.TestCase): def test_redirect_with_lazy_reverse(self): response = self.client.get('/ipr/update/') From b5ef179a6e66b34cac7533b67dfd94b8ee834f79 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 23 May 2016 13:57:01 +0000 Subject: [PATCH 04/90] Add support for rejecting a review assignment - Legacy-Id: 11225 --- ietf/doc/models.py | 2 +- ietf/doc/tests_review.py | 57 +++++++++++++-- ietf/doc/urls_review.py | 1 + ietf/doc/utils.py | 6 ++ ietf/doc/views_doc.py | 2 +- ietf/doc/views_review.py | 69 +++++++++++++++++-- .../0012_insert_review_name_data.py | 4 +- ietf/name/models.py | 2 +- .../doc/review/reject_request_assignment.html | 22 ++++++ ietf/templates/doc/review/review_request.html | 8 ++- 10 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 ietf/templates/doc/review/reject_request_assignment.html diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 26d610c03..ea969cfe2 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -707,7 +707,7 @@ EVENT_TYPES = [ # review ("requested_review", "Requested review"), - ("withdrew_review_request", "Withdrew review"), + ("changed_review_request", "Changed review request"), ] class DocEvent(models.Model): diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index 01c27711f..99e307b90 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -108,16 +108,63 @@ class ReviewTests(TestCase): review_req.state = ReviewRequestStateName.objects.get(slug="accepted") review_req.save() - url = urlreverse('ietf.doc.views_review.withdraw_request', kwargs={ "name": doc.name, "request_id": review_req.pk }) - login_testing_unauthorized(self, "secretary", url) + withdraw_url = urlreverse('ietf.doc.views_review.withdraw_request', kwargs={ "name": doc.name, "request_id": review_req.pk }) - r = self.client.get(url) + + # 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(withdraw_url in unicontent(r)) + self.client.logout() + + # get withdraw page + login_testing_unauthorized(self, "secretary", withdraw_url) + r = self.client.get(withdraw_url) self.assertEqual(r.status_code, 200) # withdraw - r = self.client.post(url, { "action": "withdraw" }) + r = self.client.post(withdraw_url, { "action": "withdraw" }) self.assertEqual(r.status_code, 302) review_req = reload_db_objects(review_req) self.assertEqual(review_req.state_id, "withdrawn") - self.assertEqual(doc.latest_event().type, "withdrew_review_request") + e = doc.latest_event() + self.assertEqual(e.type, "changed_review_request") + self.assertTrue("Withdrew" in e.desc) + + def test_reject_request_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_request_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.role.person) in unicontent(r)) + + # reject + r = self.client.post(reject_url, { "action": "reject" }) + self.assertEqual(r.status_code, 302) + + review_req = reload_db_objects(review_req) + self.assertEqual(review_req.state_id, "rejected") + e = doc.latest_event() + self.assertEqual(e.type, "changed_review_request") + self.assertTrue("unassigned" in e.desc) + self.assertEqual(ReviewRequest.objects.filter(doc=review_req.doc, team=review_req.team, state="accepted").count(), 1) + diff --git a/ietf/doc/urls_review.py b/ietf/doc/urls_review.py index afc2f6536..8ed0b5094 100644 --- a/ietf/doc/urls_review.py +++ b/ietf/doc/urls_review.py @@ -5,5 +5,6 @@ urlpatterns = patterns('', url(r'^$', views_review.request_review), url(r'^(?P[0-9]+)/$', views_review.review_request), url(r'^(?P[0-9]+)/withdraw/$', views_review.withdraw_request), + url(r'^(?P[0-9]+)/rejectassignment/$', views_review.reject_request_assignment), ) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 923715895..3aaa1604b 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -99,6 +99,12 @@ def can_request_review_of_doc(user, doc): return is_authorized_in_doc_stream(user, doc) +def can_manage_review_requests_for_team(user, team): + if not user.is_authenticated(): + return False + + return Role.objects.filter(name="secretary", person__user=user, group=team).exists() or has_role(user, "Secretariat") + 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() diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index d25d88fc3..0e7bc7838 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -357,7 +357,7 @@ 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 = ReviewRequest.objects.filter(doc=doc) + review_requests = ReviewRequest.objects.filter(doc=doc).exclude(state__in=["withdrawn", "rejected"]) return render_to_response("doc/document_draft.html", dict(doc=doc, diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index d1f7861bb..5c8face33 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -6,8 +6,8 @@ from django import forms from django.contrib.auth.decorators import login_required from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent -from ietf.doc.utils import can_request_review_of_doc -from ietf.ietfauth.utils import is_authorized_in_doc_stream +from ietf.doc.utils import can_request_review_of_doc, can_manage_review_requests_for_team +from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person from ietf.review.models import ReviewRequest, ReviewRequestStateName from ietf.review.utils import active_review_teams from ietf.utils.fields import DatepickerDateField @@ -104,10 +104,21 @@ 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.role.person) + can_manage_req = can_manage_review_requests_for_team(request.user, review_req.team) + + can_withdraw_request = (review_req.state_id in ["requested", "accepted"] + and is_authorized_in_doc_stream(request.user, doc)) + + can_reject_request_assignment = (review_req.state_id in ["requested", "accepted"] + and review_req.reviewer_id is not None + and (is_reviewer or can_manage_req)) + return render(request, 'doc/review/review_request.html', { 'doc': doc, 'review_req': review_req, - 'can_withdraw_request': review_req.state_id in ["requested", "accepted"] and is_authorized_in_doc_stream(request.user, doc), + 'can_withdraw_request': can_withdraw_request, + 'can_reject_request_assignment': can_reject_request_assignment, }) def withdraw_request(request, name, request_id): @@ -122,7 +133,7 @@ def withdraw_request(request, name, request_id): review_req.save() DocEvent.objects.create( - type="withdrew_review_request", + type="changed_review_request", doc=doc, by=request.user.person, desc="Withdrew request for {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()), @@ -138,3 +149,53 @@ def withdraw_request(request, name, request_id): 'doc': doc, 'review_req': review_req, }) + +class RejectRequestAssignmentForm(forms.Form): + message_to_secretary = forms.CharField(widget=forms.Textarea, required=False, help_text="Optional explanation of rejection, will be emailed to team secretary") + +def reject_request_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.role.person) + can_manage_req = can_manage_review_requests_for_team(request.user, review_req.team) + + if not (is_reviewer or can_manage_req): + return HttpResponseForbidden("You do not have permission to perform this action") + + if request.method == "POST" and request.POST.get("action") == "reject": + # reject the old request + prev_state = review_req.state + review_req.state = ReviewRequestStateName.objects.get(slug="rejected") + review_req.save() + + # assignment of reviewer is currently considered minutia, so + # not reported in the log + if prev_state.slug == "accepted": + DocEvent.objects.create( + type="changed_review_request", + doc=doc, + by=request.user.person, + desc="Request for {} review by {} is unassigned".format(review_req.type.name, review_req.team.acronym.upper()), + ) + + # make a new, open review request + ReviewRequest.objects.create( + time=review_req.time, + type=review_req.type, + doc=review_req.doc, + team=review_req.team, + deadline=review_req.deadline, + requested_rev=review_req.requested_rev, + state=prev_state, + ) + + return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) + + return render(request, 'doc/review/reject_request_assignment.html', { + 'doc': doc, + 'review_req': review_req, + }) diff --git a/ietf/name/migrations/0012_insert_review_name_data.py b/ietf/name/migrations/0012_insert_review_name_data.py index ed1a4bfe6..1ebbba69f 100644 --- a/ietf/name/migrations/0012_insert_review_name_data.py +++ b/ietf/name/migrations/0012_insert_review_name_data.py @@ -12,7 +12,9 @@ def insert_initial_review_data(apps, schema_editor): 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="noresponse", name="No Response", order=6) - ReviewRequestStateName.objects.get_or_create(slug="completed", name="Completed", order=7) + ReviewRequestStateName.objects.get_or_create(slug="part-completed", name="Partially Completed", order=6) + ReviewRequestStateName.objects.get_or_create(slug="completed", name="Completed", order=8) + ReviewTypeName = apps.get_model("name", "ReviewTypeName") ReviewTypeName.objects.get_or_create(slug="early", name="Early", order=1) diff --git a/ietf/name/models.py b/ietf/name/models.py index 733f957b3..b16da81f7 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -89,7 +89,7 @@ class LiaisonStatementTagName(NameModel): "Action Required, Action Taken" class ReviewRequestStateName(NameModel): """Requested, Accepted, Rejected, Withdrawn, Overtaken By Events, - No Response, Completed""" + No Response, Partially Completed, Completed""" class ReviewTypeName(NameModel): """Early Review, Last Call, Telechat""" class ReviewResultName(NameModel): diff --git a/ietf/templates/doc/review/reject_request_assignment.html b/ietf/templates/doc/review/reject_request_assignment.html new file mode 100644 index 000000000..9ccd6c56b --- /dev/null +++ b/ietf/templates/doc/review/reject_request_assignment.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2016, All Rights Reserved #} +{% load origin bootstrap3 static %} + +{% block title %}Reject review assignment for {{ review_req.doc.name }}{% endblock %} + +{% block content %} + {% origin %} +

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

+ +

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

+ +
+ {% csrf_token %} + + {% buttons %} + Cancel + + {% endbuttons %} +
+ +{% endblock %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index f1087cd84..954c4adcf 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -64,7 +64,13 @@ Reviewer - {{ review_req.reviewer.role.person }} + + {{ review_req.reviewer.role.person }} + + {% if can_reject_request_assignment %} + Reject request assignment + {% endif %} + {% endif %} From 5dd079e2f8623c67edec65bd957861cefc2f70d0 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 23 May 2016 16:42:49 +0000 Subject: [PATCH 05/90] Add support for assigning a reviewer to a review request, still some corners missing. Fix a couple of other issues. - Legacy-Id: 11227 --- ietf/doc/tests_review.py | 89 ++++++++++----- ietf/doc/urls_review.py | 3 +- ietf/doc/utils.py | 16 --- ietf/doc/views_doc.py | 4 +- ietf/doc/views_review.py | 107 +++++++++++++----- ietf/review/migrations/0001_initial.py | 10 +- ietf/review/models.py | 38 ++++--- ietf/review/utils.py | 44 ++++++- .../templates/doc/review/assign_reviewer.html | 22 ++++ ...t.html => reject_reviewer_assignment.html} | 2 +- ietf/templates/doc/review/review_request.html | 18 ++- 11 files changed, 246 insertions(+), 107 deletions(-) create mode 100644 ietf/templates/doc/review/assign_reviewer.html rename ietf/templates/doc/review/{reject_request_assignment.html => reject_reviewer_assignment.html} (84%) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index 99e307b90..b6058ad0d 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -13,6 +13,8 @@ from ietf.name.models import ReviewResultName, ReviewRequestStateName from ietf.utils.test_utils import TestCase from ietf.utils.test_data import make_test_data from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects +from ietf.utils.mail import outbox, empty_outbox + def make_review_data(doc): team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team") @@ -20,7 +22,7 @@ def make_review_data(doc): p = Person.objects.get(user__username="plain") role = Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team) - reviewer = Reviewer.objects.create(role=role, frequency=14, skip_next=0) + Reviewer.objects.create(team=team, person=p, frequency=14, skip_next=0) review_req = ReviewRequest.objects.create( doc=doc, @@ -28,10 +30,13 @@ def make_review_data(doc): type_id="early", deadline=datetime.datetime.now() + datetime.timedelta(days=20), state_id="ready", - reviewer=reviewer, + reviewer=role, reviewed_rev="01", ) + p = Person.objects.get(user__username="marschairman") + role = Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team) + return review_req class ReviewTests(TestCase): @@ -65,29 +70,6 @@ class ReviewTests(TestCase): self.assertEqual(req.requested_rev, "01") self.assertEqual(doc.latest_event().type, "requested_review") - def test_request_review_by_reviewer(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, "plain", url) - - # post request - deadline_date = datetime.date.today() + datetime.timedelta(days=10) - - r = self.client.post(url, { - "type": "early", - "team": review_team.pk, - "deadline_date": deadline_date.isoformat(), - "requested_rev": "01" - }) - self.assertEqual(r.status_code, 302) - - req = ReviewRequest.objects.get(doc=doc, state="requested") - self.assertEqual(req.state_id, "requested") - self.assertEqual(req.team, review_team) - def test_doc_page(self): # FIXME: fill in pass @@ -134,13 +116,56 @@ class ReviewTests(TestCase): self.assertEqual(e.type, "changed_review_request") self.assertTrue("Withdrew" in e.desc) - def test_reject_request_assignment(self): + def test_assign_reviewer(self): + doc = make_test_data() + review_req = make_review_data(doc) + review_req.state = ReviewRequestStateName.objects.get(slug="requested") + review_req.reviewer = None + review_req.save() + + assign_url = urlreverse('ietf.doc.views_review.assign_reviewer', kwargs={ "name": doc.name, "request_id": review_req.pk }) + + + # follow link + req_url = urlreverse('ietf.doc.views_review.review_request', kwargs={ "name": doc.name, "request_id": review_req.pk }) + self.client.login(username="secretary", password="secretary+password") + r = self.client.get(req_url) + self.assertEqual(r.status_code, 200) + self.assertTrue(assign_url in unicontent(r)) + self.client.logout() + + # get assign page + login_testing_unauthorized(self, "secretary", assign_url) + r = self.client.get(assign_url) + self.assertEqual(r.status_code, 200) + + # assign + reviewer = Role.objects.filter(name="reviewer", group=review_req.team).first() + r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk }) + self.assertEqual(r.status_code, 302) + + review_req = reload_db_objects(review_req) + self.assertEqual(review_req.state_id, "requested") + self.assertEqual(review_req.reviewer, reviewer) + + # re-assign + review_req.state = ReviewRequestStateName.objects.get(slug="accepted") + review_req.save() + reviewer = Role.objects.filter(name="reviewer", group=review_req.team).exclude(pk=reviewer.pk).first() + r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk }) + self.assertEqual(r.status_code, 302) + + review_req = reload_db_objects(review_req) + self.assertEqual(review_req.state_id, "requested") + self.assertEqual(review_req.reviewer, reviewer) + + 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_request_assignment', kwargs={ "name": doc.name, "request_id": review_req.pk }) + reject_url = urlreverse('ietf.doc.views_review.reject_reviewer_assignment', kwargs={ "name": doc.name, "request_id": review_req.pk }) # follow link @@ -155,16 +180,18 @@ class ReviewTests(TestCase): 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.role.person) in unicontent(r)) + self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r)) # reject - r = self.client.post(reject_url, { "action": "reject" }) + empty_outbox() + r = self.client.post(reject_url, { "action": "reject", "message_to_secretary": "Test message" }) self.assertEqual(r.status_code, 302) review_req = reload_db_objects(review_req) self.assertEqual(review_req.state_id, "rejected") e = doc.latest_event() self.assertEqual(e.type, "changed_review_request") - self.assertTrue("unassigned" in e.desc) - self.assertEqual(ReviewRequest.objects.filter(doc=review_req.doc, team=review_req.team, state="accepted").count(), 1) + 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) diff --git a/ietf/doc/urls_review.py b/ietf/doc/urls_review.py index 8ed0b5094..527833c0a 100644 --- a/ietf/doc/urls_review.py +++ b/ietf/doc/urls_review.py @@ -5,6 +5,7 @@ urlpatterns = patterns('', url(r'^$', views_review.request_review), url(r'^(?P[0-9]+)/$', views_review.review_request), url(r'^(?P[0-9]+)/withdraw/$', views_review.withdraw_request), - url(r'^(?P[0-9]+)/rejectassignment/$', views_review.reject_request_assignment), + url(r'^(?P[0-9]+)/assignreviewer/$', views_review.assign_reviewer), + url(r'^(?P[0-9]+)/rejectreviewerassignment/$', views_review.reject_reviewer_assignment), ) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 3aaa1604b..500b283bf 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -89,22 +89,6 @@ def can_adopt_draft(user, doc): group__state="active", person__user=user).exists()) -def can_request_review_of_doc(user, doc): - if not user.is_authenticated(): - return False - - from ietf.review.utils import active_review_teams - if Role.objects.filter(name="reviewer", person__user=user, group__in=active_review_teams()): - return True - - return is_authorized_in_doc_stream(user, doc) - -def can_manage_review_requests_for_team(user, team): - if not user.is_authenticated(): - return False - - return Role.objects.filter(name="secretary", person__user=user, group=team).exists() or has_role(user, "Secretariat") - 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() diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 0e7bc7838..23d430a72 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -48,8 +48,7 @@ from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDo from ietf.doc.utils import ( add_links_in_new_revision_events, augment_events_with_revision, can_adopt_draft, get_chartering_type, get_document_content, get_tags_for_stream_id, needed_ballot_positions, nice_consensus, prettify_std_name, update_telechat, has_same_ballot, - get_initial_notify, make_notify_changed_event, crawl_history, default_consensus, - can_request_review_of_doc ) + get_initial_notify, make_notify_changed_event, crawl_history, default_consensus ) from ietf.community.utils import augment_docs_with_tracking_info from ietf.group.models import Role from ietf.group.utils import can_manage_group, can_manage_materials @@ -63,6 +62,7 @@ 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 def render_document_top(request, doc, tab, name): tabs = [] diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 5c8face33..a0722a276 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -6,10 +6,12 @@ from django import forms from django.contrib.auth.decorators import login_required from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent -from ietf.doc.utils import can_request_review_of_doc, can_manage_review_requests_for_team from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person -from ietf.review.models import ReviewRequest, ReviewRequestStateName -from ietf.review.utils import active_review_teams +from ietf.name.models import ReviewRequestStateName +from ietf.group.models import Role +from ietf.review.models import ReviewRequest +from ietf.review.utils import active_review_teams, assign_review_request_to_reviewer +from ietf.review.utils import can_request_review_of_doc, can_manage_review_requests_for_team from ietf.utils.fields import DatepickerDateField class RequestReviewForm(forms.ModelForm): @@ -89,7 +91,6 @@ def request_review(request, name): time=review_req.time, ) - # FIXME: if I'm a reviewer, auto-assign to myself? return redirect('doc_view', name=doc.name) else: @@ -104,21 +105,25 @@ 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.role.person) + is_reviewer = review_req.reviewer and user_is_person(request.user, review_req.reviewer.person) can_manage_req = can_manage_review_requests_for_team(request.user, review_req.team) can_withdraw_request = (review_req.state_id in ["requested", "accepted"] and is_authorized_in_doc_stream(request.user, doc)) - can_reject_request_assignment = (review_req.state_id in ["requested", "accepted"] - and review_req.reviewer_id is not None - and (is_reviewer or can_manage_req)) + can_assign_reviewer = (review_req.state_id in ["requested", "accepted"] + and is_authorized_in_doc_stream(request.user, doc)) + + can_reject_reviewer_assignment = (review_req.state_id in ["requested", "accepted"] + and review_req.reviewer_id is not None + and (is_reviewer or can_manage_req)) return render(request, 'doc/review/review_request.html', { 'doc': doc, 'review_req': review_req, 'can_withdraw_request': can_withdraw_request, - 'can_reject_request_assignment': can_reject_request_assignment, + 'can_reject_reviewer_assignment': can_reject_reviewer_assignment, + 'can_assign_reviewer': can_assign_reviewer, }) def withdraw_request(request, name, request_id): @@ -150,52 +155,96 @@ def withdraw_request(request, name, request_id): 'review_req': review_req, }) -class RejectRequestAssignmentForm(forms.Form): +class PersonEmailLabeledRoleModelChoiceField(forms.ModelChoiceField): + def __init__(self, *args, **kwargs): + if not "queryset" in kwargs: + kwargs["queryset"] = Role.objects.select_related("person", "email") + super(PersonEmailLabeledRoleModelChoiceField, self).__init__(*args, **kwargs) + + def label_from_instance(self, role): + return u"{} <{}>".format(role.person.name, role.email.address) + +class AssignReviewerForm(forms.Form): + reviewer = PersonEmailLabeledRoleModelChoiceField(widget=forms.RadioSelect, empty_label="(None)", required=False) + + def __init__(self, review_req, *args, **kwargs): + super(AssignReviewerForm, self).__init__(*args, **kwargs) + f = self.fields["reviewer"] + f.queryset = f.queryset.filter(name="reviewer", group=review_req.team) + if review_req.reviewer: + f.initial = review_req.reviewer_id + +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"]) + + can_manage_req = can_manage_review_requests_for_team(request.user, review_req.team) + + if not can_manage_req: + 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(review_req, reviewer, request.user.person) + + 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") -def reject_request_assignment(request, name, request_id): +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.role.person) + is_reviewer = user_is_person(request.user, review_req.reviewer.person) can_manage_req = can_manage_review_requests_for_team(request.user, review_req.team) if not (is_reviewer or can_manage_req): return HttpResponseForbidden("You do not have permission to perform this action") if request.method == "POST" and request.POST.get("action") == "reject": - # reject the old request - prev_state = review_req.state + # reject the request review_req.state = ReviewRequestStateName.objects.get(slug="rejected") review_req.save() - # assignment of reviewer is currently considered minutia, so - # not reported in the log - if prev_state.slug == "accepted": - DocEvent.objects.create( - type="changed_review_request", - doc=doc, - by=request.user.person, - desc="Request for {} review by {} is unassigned".format(review_req.type.name, review_req.team.acronym.upper()), - ) - - # make a new, open review request - ReviewRequest.objects.create( + DocEvent.objects.create( + type="changed_review_request", + doc=review_req.doc, + by=request.user.person, + desc="Assignment of request for {} review by {} to {} was rejected".format( + review_req.type.name, + review_req.team.acronym.upper(), + review_req.reviewer.person, + ), + ) + + # make a new unassigned review request + new_review_req = ReviewRequest.objects.create( time=review_req.time, type=review_req.type, doc=review_req.doc, team=review_req.team, deadline=review_req.deadline, requested_rev=review_req.requested_rev, - state=prev_state, + state=ReviewRequestStateName.objects.get(slug="requested"), ) - return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) + return redirect(review_request, name=new_review_req.doc.name, request_id=new_review_req.pk) - return render(request, 'doc/review/reject_request_assignment.html', { + return render(request, 'doc/review/reject_reviewer_assignment.html', { 'doc': doc, 'review_req': review_req, }) diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index 2c19afa60..d9187804b 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -10,6 +10,7 @@ class Migration(migrations.Migration): ('group', '0008_auto_20160505_0523'), ('name', '0012_insert_review_name_data'), ('doc', '0012_auto_20160207_0537'), + ('person', '0006_auto_20160503_0937'), ] operations = [ @@ -17,11 +18,12 @@ class Migration(migrations.Migration): name='Reviewer', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('frequency', models.IntegerField(help_text=b'Can review every N days')), - ('available', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)), + ('frequency', models.IntegerField(default=30, help_text=b'Can review every N days')), + ('unavailable_until', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)), ('filter_re', models.CharField(max_length=255, blank=True)), ('skip_next', models.IntegerField(help_text=b'Skip the next N review assignments')), - ('role', models.OneToOneField(to='group.Role')), + ('person', models.ForeignKey(to='person.Person')), + ('team', models.ForeignKey(to='group.Group')), ], options={ }, @@ -38,7 +40,7 @@ class Migration(migrations.Migration): ('doc', models.ForeignKey(related_name='review_request_set', to='doc.Document')), ('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='review.Reviewer', null=True)), + ('reviewer', models.ForeignKey(blank=True, to='group.Role', null=True)), ('state', models.ForeignKey(to='name.ReviewRequestStateName')), ('team', models.ForeignKey(to='group.Group')), ('type', models.ForeignKey(to='name.ReviewTypeName')), diff --git a/ietf/review/models.py b/ietf/review/models.py index 7e166333c..530e0c939 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -2,36 +2,42 @@ from django.db import models from ietf.doc.models import Document from ietf.group.models import Group, Role +from ietf.person.models import Person from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName class Reviewer(models.Model): - """ - These records associate reviewers with review teams and keep track - of admin data associated with the reviewer in the particular team. - There will be one record for each combination of reviewer and team. - """ - role = models.OneToOneField(Role) - frequency = models.IntegerField(help_text="Can review every N days") - available = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again") + """Keeps track of admin data associated with the reviewer in the + particular team. There will be one record for each combination of + reviewer and team.""" + team = models.ForeignKey(Group) + person = models.ForeignKey(Person) + frequency = models.IntegerField(help_text="Can review every N days", default=30) + unavailable_until = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again") filter_re = models.CharField(max_length=255, blank=True) skip_next = models.IntegerField(help_text="Skip the next N review assignments") 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. - """ - # Fields filled in on the initial record creation: + document, rev, and reviewer.""" + state = models.ForeignKey(ReviewRequestStateName) + + # Fields filled in on the initial record creation - these + # constitute the request part. time = models.DateTimeField(auto_now_add=True) type = models.ForeignKey(ReviewTypeName) doc = models.ForeignKey(Document, related_name='review_request_set') team = models.ForeignKey(Group) deadline = models.DateTimeField() 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") - state = models.ForeignKey(ReviewRequestStateName) - # Fields filled in as reviewer is assigned, and as the review - # is uploaded - reviewer = models.ForeignKey(Reviewer, blank=True, null=True) + + # Fields filled in as reviewer is assigned and as the review is + # uploaded. Once these are filled in and we progress beyond the + # states 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(Role, 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) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 0a1bd86bb..43d495e15 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -1,6 +1,48 @@ -from ietf.group.models import Group +from ietf.group.models import Group, Role +from ietf.doc.models import DocEvent +from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream +from ietf.review.models import ReviewRequestStateName def active_review_teams(): # if there's a ReviewResultName defined, it's a review team return Group.objects.filter(state="active").exclude(reviewresultname=None) + +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): + if not user.is_authenticated(): + return False + + return Role.objects.filter(name="secretary", person__user=user, group=team).exists() or has_role(user, "Secretariat") + +def assign_review_request_to_reviewer(review_req, reviewer, by): + assert review_req.state_id in ("requested", "accepted") + + if review_req.reviewer == reviewer: + return + + prev_state = review_req.state + prev_reviewer = review_req.reviewer + + review_req.state = ReviewRequestStateName.objects.get(slug="requested") + review_req.reviewer = reviewer + review_req.save() + + DocEvent.objects.create( + type="changed_review_request", + doc=review_req.doc, + by=by, + 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)", + ), + ) + if prev_state.slug != "requested" and prev_reviewer: + # FIXME: email old reviewer? + pass diff --git a/ietf/templates/doc/review/assign_reviewer.html b/ietf/templates/doc/review/assign_reviewer.html new file mode 100644 index 000000000..0e7059b03 --- /dev/null +++ b/ietf/templates/doc/review/assign_reviewer.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2016, All Rights Reserved #} +{% load origin bootstrap3 static %} + +{% block title %}Assign reviewer for {{ review_req.doc.name }}{% endblock %} + +{% block content %} + {% origin %} +

Assign reviewer
{{ review_req.doc.name }}

+ +
+ {% csrf_token %} + + {% bootstrap_form form %} + + {% buttons %} + Cancel + + {% endbuttons %} +
+ +{% endblock %} diff --git a/ietf/templates/doc/review/reject_request_assignment.html b/ietf/templates/doc/review/reject_reviewer_assignment.html similarity index 84% rename from ietf/templates/doc/review/reject_request_assignment.html rename to ietf/templates/doc/review/reject_reviewer_assignment.html index 9ccd6c56b..edc8d9696 100644 --- a/ietf/templates/doc/review/reject_request_assignment.html +++ b/ietf/templates/doc/review/reject_reviewer_assignment.html @@ -8,7 +8,7 @@ {% origin %}

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

-

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

+

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

{% csrf_token %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index 954c4adcf..844ff3c94 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -60,19 +60,25 @@ {{ review_req.state.name }} - {% if review_req.reviewer %} Reviewer - {{ review_req.reviewer.role.person }} + {% if review_req.reviewer %} + {{ review_req.reviewer.person }} + {% else %} + None assigned yet + {% endif %} - {% if can_reject_request_assignment %} - Reject request assignment + {% if can_assign_reviewer %} + {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer + {% endif %} + + {% if can_reject_reviewer_assignment %} + Reject reviewer assignment {% endif %} - {% endif %} {% if review_req.review %} @@ -94,7 +100,7 @@ Result of review - {{ review_req.result.name } + {{ review_req.result.name }} {% endif %} From b9f4b7005e2d715fbd6c4e82a006da22f71c0005 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 24 May 2016 14:02:59 +0000 Subject: [PATCH 06/90] Add simple email notifications for assigning/rejecting review requests - Legacy-Id: 11236 --- ietf/doc/tests_review.py | 15 +++- ietf/doc/views_review.py | 80 ++++++++++++------- ietf/review/models.py | 2 +- ietf/review/utils.py | 58 +++++++++++--- .../doc/mail/review_request_changed.txt | 7 ++ 5 files changed, 117 insertions(+), 45 deletions(-) create mode 100644 ietf/templates/doc/mail/review_request_changed.txt diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index b6058ad0d..c2fe46549 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -107,6 +107,7 @@ class ReviewTests(TestCase): self.assertEqual(r.status_code, 200) # withdraw + empty_outbox() r = self.client.post(withdraw_url, { "action": "withdraw" }) self.assertEqual(r.status_code, 302) @@ -115,6 +116,8 @@ class ReviewTests(TestCase): e = doc.latest_event() self.assertEqual(e.type, "changed_review_request") self.assertTrue("Withdrew" in e.desc) + self.assertEqual(len(outbox), 1) + self.assertTrue("withdrawn" in unicode(outbox[0])) def test_assign_reviewer(self): doc = make_test_data() @@ -140,6 +143,7 @@ class ReviewTests(TestCase): self.assertEqual(r.status_code, 200) # assign + empty_outbox() reviewer = Role.objects.filter(name="reviewer", group=review_req.team).first() r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk }) self.assertEqual(r.status_code, 302) @@ -147,8 +151,11 @@ class ReviewTests(TestCase): review_req = reload_db_objects(review_req) self.assertEqual(review_req.state_id, "requested") self.assertEqual(review_req.reviewer, reviewer) + self.assertEqual(len(outbox), 1) + self.assertTrue("assigned" in unicode(outbox[0])) # re-assign + empty_outbox() review_req.state = ReviewRequestStateName.objects.get(slug="accepted") review_req.save() reviewer = Role.objects.filter(name="reviewer", group=review_req.team).exclude(pk=reviewer.pk).first() @@ -156,8 +163,11 @@ class ReviewTests(TestCase): self.assertEqual(r.status_code, 302) review_req = reload_db_objects(review_req) - self.assertEqual(review_req.state_id, "requested") + 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_reject_reviewer_assignment(self): doc = make_test_data() @@ -194,4 +204,5 @@ class ReviewTests(TestCase): 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])) + diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index a0722a276..eabc02beb 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -10,8 +10,9 @@ from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person from ietf.name.models import ReviewRequestStateName from ietf.group.models import Role from ietf.review.models import ReviewRequest -from ietf.review.utils import active_review_teams, assign_review_request_to_reviewer -from ietf.review.utils import can_request_review_of_doc, can_manage_review_requests_for_team +from ietf.review.utils import (active_review_teams, assign_review_request_to_reviewer, + can_request_review_of_doc, can_manage_review_requests_for_team, + email_about_review_request) from ietf.utils.fields import DatepickerDateField class RequestReviewForm(forms.ModelForm): @@ -134,6 +135,7 @@ def withdraw_request(request, name, request_id): return HttpResponseForbidden("You do not have permission to perform this action") if request.method == "POST" and request.POST.get("action") == "withdraw": + prev_state = review_req.state review_req.state = ReviewRequestStateName.objects.get(slug="withdrawn") review_req.save() @@ -144,9 +146,12 @@ def withdraw_request(request, name, request_id): desc="Withdrew request for {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()), ) - if review_req.state_id != "requested": - # FIXME: handle this case - by emailing? - pass + if prev_state.slug != "requested": + email_about_review_request( + request, review_req, + "Withdrew review request for %s" % review_req.doc.name, + "Review request has been withdrawn by %s." % request.user.person, + by=request.user.person, notify_secretary=False, notify_reviewer=True) return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) @@ -187,7 +192,7 @@ def assign_reviewer(request, name, request_id): form = AssignReviewerForm(review_req, request.POST) if form.is_valid(): reviewer = form.cleaned_data["reviewer"] - assign_review_request_to_reviewer(review_req, reviewer, request.user.person) + assign_review_request_to_reviewer(request, review_req, reviewer) return redirect(review_request, name=review_req.doc.name, request_id=review_req.pk) else: @@ -216,35 +221,48 @@ def reject_reviewer_assignment(request, name, request_id): return HttpResponseForbidden("You do not have permission to perform this action") if request.method == "POST" and request.POST.get("action") == "reject": - # reject the request - review_req.state = ReviewRequestStateName.objects.get(slug="rejected") - review_req.save() + form = RejectReviewerAssignmentForm(request.POST) + if form.is_valid(): + # reject the request + review_req.state = ReviewRequestStateName.objects.get(slug="rejected") + review_req.save() - DocEvent.objects.create( - type="changed_review_request", - doc=review_req.doc, - by=request.user.person, - desc="Assignment of request for {} review by {} to {} was rejected".format( - review_req.type.name, - review_req.team.acronym.upper(), - review_req.reviewer.person, - ), - ) - - # make a new unassigned review request - new_review_req = ReviewRequest.objects.create( - time=review_req.time, - type=review_req.type, - doc=review_req.doc, - team=review_req.team, - deadline=review_req.deadline, - requested_rev=review_req.requested_rev, - state=ReviewRequestStateName.objects.get(slug="requested"), - ) + DocEvent.objects.create( + type="changed_review_request", + doc=review_req.doc, + by=request.user.person, + desc="Assignment of request for {} review by {} to {} was rejected".format( + review_req.type.name, + review_req.team.acronym.upper(), + review_req.reviewer.person, + ), + ) - return redirect(review_request, name=new_review_req.doc.name, request_id=new_review_req.pk) + # make a new unassigned review request + new_review_req = ReviewRequest.objects.create( + time=review_req.time, + type=review_req.type, + doc=review_req.doc, + team=review_req.team, + deadline=review_req.deadline, + requested_rev=review_req.requested_rev, + state=ReviewRequestStateName.objects.get(slug="requested"), + ) + + msg = u"Reviewer assignment rejected by %s." % request.user.person + + m = form.cleaned_data.get("message_to_secretary") + if m: + msg += "\n\n" + "Explanation:" + "\n" + m + + email_about_review_request(request, review_req, "Reviewer assignment rejected", msg, by=request.user.person, notify_secretary=True, notify_reviewer=True) + + 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, }) diff --git a/ietf/review/models.py b/ietf/review/models.py index 530e0c939..79092e8b1 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -27,7 +27,7 @@ class ReviewRequest(models.Model): time = models.DateTimeField(auto_now_add=True) type = models.ForeignKey(ReviewTypeName) doc = models.ForeignKey(Document, related_name='review_request_set') - team = models.ForeignKey(Group) + team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewresultname=None)) deadline = models.DateTimeField() 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") diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 43d495e15..8cc692d35 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -1,7 +1,10 @@ +from django.contrib.sites.models import Site + from ietf.group.models import Group, Role from ietf.doc.models import DocEvent from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream from ietf.review.models import ReviewRequestStateName +from ietf.utils.mail import send_mail def active_review_teams(): # if there's a ReviewResultName defined, it's a review team @@ -17,16 +20,47 @@ def can_manage_review_requests_for_team(user, team): if not user.is_authenticated(): return False - return Role.objects.filter(name="secretary", person__user=user, group=team).exists() or has_role(user, "Secretariat") + return Role.objects.filter(name__in=["secretary", "delegate"], person__user=user, group=team).exists() or has_role(user, "Secretariat") -def assign_review_request_to_reviewer(review_req, reviewer, by): - assert review_req.state_id in ("requested", "accepted") +def email_about_review_request(request, review_req, subject, msg, by, notify_secretary, notify_reviewer): + """Notify possibly both secretary and reviewer about change, skipping + a party if the change was done by that party.""" - if review_req.reviewer == reviewer: + def extract_email_addresses(roles): + if any(r.person == by for r in roles if r): + return [] + else: + return [r.formatted_email() for r in roles if r] + + to = [] + + if notify_secretary: + to += extract_email_addresses(Role.objects.filter(name__in=["secretary", "delegate"], group=review_req.team).distinct()) + if notify_reviewer: + to += extract_email_addresses([review_req.reviewer]) + + if not to: return - prev_state = review_req.state - prev_reviewer = review_req.reviewer + send_mail(request, list(set(to)), None, subject, "doc/mail/review_request_changed.txt", { + "domain": Site.objects.get_current().domain, + "review_req": review_req, + "msg": msg, + }) + + +def assign_review_request_to_reviewer(request, review_req, reviewer): + assert review_req.state_id in ("requested", "accepted") + + if reviewer == review_req.reviewer: + return + + if review_req.reviewer: + email_about_review_request( + 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) review_req.state = ReviewRequestStateName.objects.get(slug="requested") review_req.reviewer = reviewer @@ -35,14 +69,16 @@ def assign_review_request_to_reviewer(review_req, reviewer, by): DocEvent.objects.create( type="changed_review_request", doc=review_req.doc, - by=by, + 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)", ), ) - - if prev_state.slug != "requested" and prev_reviewer: - # FIXME: email old reviewer? - pass + + email_about_review_request( + request, review_req, + "Assigned to review of %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) diff --git a/ietf/templates/doc/mail/review_request_changed.txt b/ietf/templates/doc/mail/review_request_changed.txt new file mode 100644 index 000000000..5c8f36d51 --- /dev/null +++ b/ietf/templates/doc/mail/review_request_changed.txt @@ -0,0 +1,7 @@ +{% autoescape off %} + {{ review_req.type.name }} review of: {{ review_req.doc.name }}{% if review_req.requested_rev %}-{{ review_req.requested_rev }}{% endif %} + https://{{ domain }}{% url "ietf.doc.views_review.review_request" name=review_req.doc.name request_id=review_req.pk %} + +{{ msg|wordwrap:72 }} + +{% endautoescape %} From 604287e75d370d3eaa4bedd9652acbd7ad9bf677 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 24 May 2016 15:03:51 +0000 Subject: [PATCH 07/90] Support accepting a reviewer assignment - Legacy-Id: 11237 --- ietf/doc/tests_review.py | 23 ++++++++++++++++ ietf/doc/views_review.py | 26 ++++++++++++++----- ietf/templates/doc/review/review_request.html | 10 ++++--- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index c2fe46549..b5d6787e7 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -4,6 +4,8 @@ import datetime from django.core.urlresolvers import reverse as urlreverse +from pyquery import PyQuery + import debug # pyflakes:ignore from ietf.review.models import ReviewRequest, Reviewer @@ -169,6 +171,27 @@ class ReviewTests(TestCase): 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) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index eabc02beb..2c618a8bd 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -107,17 +107,28 @@ def review_request(request, name, request_id): 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_req = can_manage_review_requests_for_team(request.user, review_req.team) + can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) can_withdraw_request = (review_req.state_id in ["requested", "accepted"] - and is_authorized_in_doc_stream(request.user, doc)) + and (is_authorized_in_doc_stream(request.user, doc) + or can_manage_request)) can_assign_reviewer = (review_req.state_id in ["requested", "accepted"] and is_authorized_in_doc_stream(request.user, doc)) + can_accept_reviewer_assignment = (review_req.state_id == "requested" + and review_req.reviewer_id is not None + and (is_reviewer or can_manage_request)) + can_reject_reviewer_assignment = (review_req.state_id in ["requested", "accepted"] and review_req.reviewer_id is not None - and (is_reviewer or can_manage_req)) + 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, @@ -125,6 +136,7 @@ def review_request(request, name, request_id): 'can_withdraw_request': can_withdraw_request, 'can_reject_reviewer_assignment': can_reject_reviewer_assignment, 'can_assign_reviewer': can_assign_reviewer, + 'can_accept_reviewer_assignment': can_accept_reviewer_assignment, }) def withdraw_request(request, name, request_id): @@ -183,9 +195,9 @@ 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"]) - can_manage_req = can_manage_review_requests_for_team(request.user, review_req.team) + can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) - if not can_manage_req: + if not can_manage_request: return HttpResponseForbidden("You do not have permission to perform this action") if request.method == "POST" and request.POST.get("action") == "assign": @@ -215,9 +227,9 @@ def reject_reviewer_assignment(request, name, request_id): 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_req = can_manage_review_requests_for_team(request.user, review_req.team) + can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) - if not (is_reviewer or can_manage_req): + 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": diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index 844ff3c94..44fdfa615 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -70,12 +70,16 @@ None assigned yet {% endif %} - {% if can_assign_reviewer %} - {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer + {% if can_accept_reviewer_assignment %} + {% endif %} {% if can_reject_reviewer_assignment %} - Reject reviewer assignment + Reject + {% endif %} + + {% if can_assign_reviewer %} + {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer {% endif %} From dfd351c220a2b7dfee994ed838234127b6b1465b Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 24 May 2016 15:06:09 +0000 Subject: [PATCH 08/90] Fix missing CSRF token - Legacy-Id: 11238 --- ietf/templates/doc/review/review_request.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index 44fdfa615..aec78a6c1 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -71,7 +71,7 @@ {% endif %} {% if can_accept_reviewer_assignment %} -
+
{% csrf_token %}
{% endif %} {% if can_reject_reviewer_assignment %} From fdfc0bc8f53ed7bcf87a0421c4d97ba11bf950fb Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 26 May 2016 12:56:00 +0000 Subject: [PATCH 09/90] Insert missing form - Legacy-Id: 11246 --- ietf/templates/doc/review/reject_reviewer_assignment.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ietf/templates/doc/review/reject_reviewer_assignment.html b/ietf/templates/doc/review/reject_reviewer_assignment.html index edc8d9696..26097da19 100644 --- a/ietf/templates/doc/review/reject_reviewer_assignment.html +++ b/ietf/templates/doc/review/reject_reviewer_assignment.html @@ -13,6 +13,8 @@
{% csrf_token %} + {% bootstrap_form form %} + {% buttons %} Cancel From 5757f65598eeee4b5286dbef422b8ba0ac94f723 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 13 Jun 2016 09:48:37 +0000 Subject: [PATCH 10/90] Resolve name objects ordering order clashes by ordering by .name. Most are ordered, but we have a few without a natural order, and alphabetical helps a bit when debugging those. - Legacy-Id: 11336 --- ietf/name/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/name/models.py b/ietf/name/models.py index b16da81f7..f17a55203 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -14,7 +14,7 @@ class NameModel(models.Model): class Meta: abstract = True - ordering = ['order'] + ordering = ['order', 'name'] class GroupStateName(NameModel): """BOF, Proposed, Active, Dormant, Concluded, Abandoned""" From b6e5aebcd4ec8c0b5c50775ff8351603aaeedac7 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 13 Jun 2016 10:13:57 +0000 Subject: [PATCH 11/90] Fix a slightly odd unnecessary form.save() - Legacy-Id: 11337 --- ietf/doc/views_charter.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/ietf/doc/views_charter.py b/ietf/doc/views_charter.py index 251324a77..9a94ba863 100644 --- a/ietf/doc/views_charter.py +++ b/ietf/doc/views_charter.py @@ -343,14 +343,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=None, option=None): if not name.startswith('charter-'): @@ -390,7 +382,12 @@ def submit(request, name=None, option=None): e.save() # 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) From b790781de992509611309945214baeeea7c98795 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 14 Jun 2016 09:54:37 +0000 Subject: [PATCH 12/90] Add return statements to remaining email sending function so it's possibly to get the message back. We need the message and its Message-ID in the review tracking code, to be able to link to it in the mail archive. - Legacy-Id: 11359 --- ietf/utils/mail.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ietf/utils/mail.py b/ietf/utils/mail.py index deeaa67e2..1f17c9c14 100644 --- a/ietf/utils/mail.py +++ b/ietf/utils/mail.py @@ -187,7 +187,7 @@ def encode_message(txt): def send_mail_text(request, to, frm, subject, txt, cc=None, extra=None, toUser=False, bcc=None): """Send plain text message.""" msg = encode_message(txt) - send_mail_mime(request, to, frm, subject, msg, cc, extra, toUser, bcc) + return send_mail_mime(request, to, frm, subject, msg, cc, extra, toUser, bcc) def condition_message(to, frm, subject, msg, cc, extra): if isinstance(frm, tuple): @@ -284,6 +284,8 @@ def send_mail_mime(request, to, frm, subject, msg, cc=None, extra=None, toUser=F build_warning_message(request, e) send_error_email(e) + return msg + def parse_preformatted(preformatted, extra={}, override={}): """Parse preformatted string containing mail with From:, To:, ...,""" msg = message_from_string(preformatted.encode("utf-8")) @@ -323,8 +325,8 @@ def send_mail_message(request, message, extra={}): if message.reply_to: e['Reply-to'] = message.reply_to - send_mail_text(request, message.to, message.frm, message.subject, - message.body, cc=message.cc, bcc=message.bcc, extra=e) + return send_mail_text(request, message.to, message.frm, message.subject, + message.body, cc=message.cc, bcc=message.bcc, extra=e) def exception_components(e): # See if it's a non-smtplib exception that we faked From 7cbe36fb6248e7cbafe5aadf292e5f84c924ebfd Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 14 Jun 2016 11:28:53 +0000 Subject: [PATCH 13/90] Implement completing a review with tests. One can currently enter/upload content or retrieve it from an IETF mailarch archive through integrated searching support. Support for partial completion. - Legacy-Id: 11360 --- ietf/doc/tests_review.py | 290 +++- ietf/doc/urls_review.py | 2 + ietf/doc/utils.py | 2 +- ietf/doc/views_review.py | 284 +++- ietf/name/fixtures/names.json | 1266 +++++++++-------- .../0012_insert_review_name_data.py | 22 +- ietf/review/mailarch.py | 95 ++ ietf/review/resources.py | 65 + ietf/review/utils.py | 14 +- ietf/settings.py | 1 + ietf/static/ietf/css/ietf.css | 13 + ietf/static/ietf/js/complete-review.js | 131 ++ ietf/templates/doc/document_draft.html | 2 +- ietf/templates/doc/mail/completed_review.txt | 6 + .../doc/mail/partially_completed_review.txt | 6 + .../doc/mail/reviewer_assignment_rejected.txt | 6 + .../templates/doc/review/complete_review.html | 81 ++ ietf/templates/doc/review/review_request.html | 74 +- ietf/utils/text.py | 11 + ietf/utils/textupload.py | 12 +- 20 files changed, 1695 insertions(+), 688 deletions(-) create mode 100644 ietf/review/mailarch.py create mode 100644 ietf/review/resources.py create mode 100644 ietf/static/ietf/js/complete-review.js create mode 100644 ietf/templates/doc/mail/completed_review.txt create mode 100644 ietf/templates/doc/mail/partially_completed_review.txt create mode 100644 ietf/templates/doc/mail/reviewer_assignment_rejected.txt create mode 100644 ietf/templates/doc/review/complete_review.html create mode 100644 ietf/utils/text.py diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index b5d6787e7..e937bd27e 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -1,14 +1,19 @@ # -*- coding: utf-8 -*- -import datetime +import datetime, os, shutil, json +import tarfile, tempfile, mailbox +import email.mime.multipart, email.mime.text, email.utils +from StringIO import StringIO from django.core.urlresolvers import reverse as urlreverse +from django.conf import settings from pyquery import PyQuery import debug # pyflakes:ignore from ietf.review.models import ReviewRequest, Reviewer +import ietf.review.mailarch from ietf.person.models import Person from ietf.group.models import Group, Role from ietf.name.models import ReviewResultName, ReviewRequestStateName @@ -17,7 +22,6 @@ from ietf.utils.test_data import make_test_data from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects from ietf.utils.mail import outbox, empty_outbox - def make_review_data(doc): team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team") team.reviewresultname_set.add(ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"])) @@ -39,9 +43,28 @@ def make_review_data(doc): p = Person.objects.get(user__username="marschairman") role = Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team) + p = Person.objects.get(user__username="secretary") + role = Role.objects.create(name_id="secretary", person=p, email=p.email_set.first(), group=team) + return review_req class ReviewTests(TestCase): + def setUp(self): + self.review_dir = os.path.abspath("tmp-review-dir") + if not os.path.exists(self.review_dir): + os.mkdir(self.review_dir) + + self.old_document_path_pattern = settings.DOCUMENT_PATH_PATTERN + settings.DOCUMENT_PATH_PATTERN = self.review_dir + "/{doc.type_id}/" + + self.review_subdir = os.path.join(self.review_dir, "review") + if not os.path.exists(self.review_subdir): + os.mkdir(self.review_subdir) + + def tearDown(self): + shutil.rmtree(self.review_dir) + settings.DOCUMENT_PATH_PATTERN = self.old_document_path_pattern + def test_request_review(self): doc = make_test_data() review_req = make_review_data(doc) @@ -229,3 +252,266 @@ class ReviewTests(TestCase): self.assertEqual(len(outbox), 1) self.assertTrue("Test message" in unicode(outbox[0])) + def make_test_mbox_tarball(self, review_req): + mbox_path = os.path.join(self.review_dir, "testmbox.tar.gz") + with tarfile.open(mbox_path, "w:gz") as tar: + with tempfile.NamedTemporaryFile(dir=self.review_dir, suffix=".mbox") as tmp: + mbox = mailbox.mbox(tmp.name) + + # plain text + msg = email.mime.text.MIMEText("Hello,\n\nI have reviewed the document and did not find any problems.\n\nJohn Doe") + msg["From"] = "johndoe@example.com" + msg["To"] = review_req.team.list_email + msg["Subject"] = "Review of {}-01".format(review_req.doc.name) + msg["Message-ID"] = email.utils.make_msgid() + msg["Archived-At"] = "" + msg["Date"] = email.utils.formatdate() + + mbox.add(msg) + + # plain text + HTML + msg = email.mime.multipart.MIMEMultipart('alternative') + msg["From"] = "johndoe2@example.com" + msg["To"] = review_req.team.list_email + msg["Subject"] = "Review of {}".format(review_req.doc.name) + msg["Message-ID"] = email.utils.make_msgid() + msg["Archived-At"] = "" + + msg.attach(email.mime.text.MIMEText("Hi!,\r\nLooks OK!\r\n-John", "plain")) + msg.attach(email.mime.text.MIMEText("

Hi!,

Looks OK!

-John

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

Complete review
{{ review_req.doc.name }}

+ +

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

+ + + {% csrf_token %} + + {% bootstrap_form form layout="horizontal" %} + + {% buttons %} + Cancel + + {% endbuttons %} + + + + + + + +{% endblock %} + +{% block js %} + + +{% endblock %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index aec78a6c1..e2c137dd4 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -19,6 +19,10 @@ {% else %} {{ review_req.doc.name }} {% endif %} + + {% if can_withdraw_request %} + Withdraw request + {% endif %} @@ -60,43 +64,51 @@ {{ review_req.state.name }} - - - Reviewer - - {% if review_req.reviewer %} - {{ review_req.reviewer.person }} - {% else %} - None assigned yet - {% endif %} + + + Reviewer + + {% if review_req.reviewer %} + {{ review_req.reviewer.person }} + {% else %} + None assigned yet + {% endif %} - {% if can_accept_reviewer_assignment %} -
{% csrf_token %}
- {% endif %} + {% if can_accept_reviewer_assignment %} +
{% csrf_token %}
+ {% endif %} - {% if can_reject_reviewer_assignment %} - Reject - {% endif %} + {% if can_reject_reviewer_assignment %} + Reject + {% endif %} - {% if can_assign_reviewer %} - {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer - {% endif %} - - + {% if can_assign_reviewer %} + {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer + {% endif %} + + - {% if review_req.review %} - - - Review - {{ review_req.review.name }} - - {% endif %} + + + Review + + {% if review_req.review %} + {{ review_req.review.name }} + {% else %} + Not completed yet + {% endif %} + + {% if can_complete_review %} + Complete review + {% endif %} + + {% if review_req.reviewed_rev %} Reviewed revision - {{ review_req.reviewed_rev }} + {{ review_req.reviewed_rev }} {% if review_req.reviewed_rev != review_req.doc.rev %}(currently at {{ review_req.doc.rev }}){% endif %} {% endif %} @@ -110,10 +122,4 @@ -
- {% if can_withdraw_request %} - Withdraw request - {% endif %} -
- {% endblock %} diff --git a/ietf/utils/text.py b/ietf/utils/text.py new file mode 100644 index 000000000..39df9a136 --- /dev/null +++ b/ietf/utils/text.py @@ -0,0 +1,11 @@ +def skip_prefix(text, prefix): + if text.startswith(prefix): + return text[len(prefix):] + else: + return text + +def skip_suffix(text, prefix): + if text.endswith(prefix): + return text[:-len(prefix)] + else: + return text diff --git a/ietf/utils/textupload.py b/ietf/utils/textupload.py index 1a4dbe705..7456825a1 100644 --- a/ietf/utils/textupload.py +++ b/ietf/utils/textupload.py @@ -1,18 +1,18 @@ import re -import django.forms +from django.core.exceptions import ValidationError def get_cleaned_text_file_content(uploaded_file): """Read uploaded file, try to fix up encoding to UTF-8 and transform line endings into Unix style, then return the content as a UTF-8 string. Errors are reported as - django.forms.ValidationError exceptions.""" + django.core.exceptions.ValidationError exceptions.""" if not uploaded_file: return u"" if uploaded_file.size and uploaded_file.size > 10 * 1000 * 1000: - raise django.forms.ValidationError("Text file too large (size %s)." % uploaded_file.size) + raise ValidationError("Text file too large (size %s)." % uploaded_file.size) content = "".join(uploaded_file.chunks()) @@ -29,18 +29,18 @@ def get_cleaned_text_file_content(uploaded_file): filetype = m.from_buffer(content) if not filetype.startswith("text"): - raise django.forms.ValidationError("Uploaded file does not appear to be a text file.") + raise ValidationError("Uploaded file does not appear to be a text file.") match = re.search("charset=([\w-]+)", filetype) if not match: - raise django.forms.ValidationError("File has unknown encoding.") + raise ValidationError("File has unknown encoding.") encoding = match.group(1) if "ascii" not in encoding: try: content = content.decode(encoding) except Exception as e: - raise django.forms.ValidationError("Error decoding file (%s). Try submitting with UTF-8 encoding or remove non-ASCII characters." % str(e)) + raise ValidationError("Error decoding file (%s). Try submitting with UTF-8 encoding or remove non-ASCII characters." % str(e)) # turn line-endings into Unix style content = content.replace("\r\n", "\n").replace("\r", "\n") From 6a35431356b1eb22d3f9f75b9d4440e8f13ab3ed Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 14 Jun 2016 12:38:43 +0000 Subject: [PATCH 14/90] Remember to create a DocAlias when creating a review document - Legacy-Id: 11361 --- ietf/doc/views_review.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index f5c3226b8..b4642049f 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -9,7 +9,7 @@ from django.utils.html import mark_safe from django.core.exceptions import ValidationError from django.template.loader import render_to_string -from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State +from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName from ietf.group.models import Role @@ -396,6 +396,7 @@ def complete_review(request, name, request_id): review.external_url = form.cleaned_data['review_url'] review.save() review.set_state(State.objects.get(type="review", slug="active")) + DocAlias.objects.create(document=review, name=review.name) NewRevisionDocEvent.objects.create( type="new_revision", From 0ed3d554d60570b3db7b280e50b0f0ff037ad140 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 14 Jun 2016 12:48:08 +0000 Subject: [PATCH 15/90] Add simple /doc/review- page for displaying a review, fix a couple of bugs - Legacy-Id: 11362 --- ietf/doc/views_doc.py | 15 +++++ ietf/doc/views_review.py | 6 +- ietf/templates/doc/document_review.html | 73 +++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 ietf/templates/doc/document_review.html diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 23d430a72..2f83b4529 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -571,6 +571,21 @@ 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) + + return render(request, "doc/document_review.html", + dict(doc=doc, + top=top, + content=content, + revisions=revisions, + latest_rev=latest_rev, + snapshot=snapshot, + )) + raise Http404 diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index b4642049f..6c852e396 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -390,7 +390,7 @@ def complete_review(request, name, request_id): review.type = DocTypeName.objects.get(slug="review") review.rev = "00" - review.title = "Review of {}".format(review_req.doc.name) + review.title = "Review of {}-{}".format(review_req.doc.name, review_req.reviewed_rev) review.group = review_req.team if review_submission == "link": review.external_url = form.cleaned_data['review_url'] @@ -402,9 +402,9 @@ def complete_review(request, name, request_id): type="new_revision", doc=review, by=request.user.person, - rev=doc.rev, + rev=review.rev, desc='New revision available', - time=doc.time, + time=review.time, ) # save file on disk diff --git a/ietf/templates/doc/document_review.html b/ietf/templates/doc/document_review.html new file mode 100644 index 000000000..2c4362771 --- /dev/null +++ b/ietf/templates/doc/document_review.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2016, All Rights Reserved #} +{% load origin %} +{% load staticfiles %} +{% load ietf_filters %} + +{% block title %}{{ doc.title }}{% endblock %} + +{% block content %} + {% origin %} + {{ top|safe }} + + {% include "doc/revisions_list.html" %} + + + + + {% if doc.rev != latest_rev %} + + {% else %} + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + {% if doc.external_url %} + + + + + + {% endif %} + + + + + + + +
The information below is for an old version of the document
Team + {{ doc.group.name }} + ({{ doc.group.acronym }}) + + {% if snapshot %} + Snapshot + {% endif %} +
Title{{ doc.title }}
State{{ doc.get_state.name }}
Posted at{{ doc.external_url }}
Last updated{{ doc.time|date:"Y-m-d" }}
+ +

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

+ + {% if doc.rev and content != None %} + {{ content|fill:"80"|safe|linebreaksbr|keep_spacing|sanitize_html|safe }} + {% endif %} +{% endblock %} From 7a406bafc6f9d80f97ceb703fd62d075f94f96a8 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 14 Jun 2016 13:11:06 +0000 Subject: [PATCH 16/90] Polish the various review pages, adding bits of information here and there - Legacy-Id: 11363 --- ietf/doc/views_doc.py | 3 +++ ietf/templates/doc/document_draft.html | 6 +++++- ietf/templates/doc/document_review.html | 8 ++++++++ ietf/templates/doc/mail/completed_review.txt | 1 + ietf/templates/doc/review/review_request.html | 14 ++++++++++++-- 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 2f83b4529..c1f0a6cb2 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -577,6 +577,8 @@ def document_main(request, name, rev=None): 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() + return render(request, "doc/document_review.html", dict(doc=doc, top=top, @@ -584,6 +586,7 @@ def document_main(request, name, rev=None): revisions=revisions, latest_rev=latest_rev, snapshot=snapshot, + review_req=review_req, )) raise Http404 diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index d05e60555..3a0cd68aa 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -200,7 +200,11 @@ {% for r in review_requests %} {% endfor %} diff --git a/ietf/templates/doc/document_review.html b/ietf/templates/doc/document_review.html index 2c4362771..d0900954e 100644 --- a/ietf/templates/doc/document_review.html +++ b/ietf/templates/doc/document_review.html @@ -49,6 +49,14 @@ {{ doc.get_state.name }} + {% if review_req %} + + Review result + + {{ review_req.result.name }} + + {% endif %} + {% if doc.external_url %} Posted at diff --git a/ietf/templates/doc/mail/completed_review.txt b/ietf/templates/doc/mail/completed_review.txt index b3ca9d2f4..7d81d628f 100644 --- a/ietf/templates/doc/mail/completed_review.txt +++ b/ietf/templates/doc/mail/completed_review.txt @@ -1,6 +1,7 @@ {% autoescape off %}{% filter wordwrap:70 %}{% if review_req.state_id == "part-completed" %}Review is partially done. Another review request has been registered for completing it. {% endif %}Reviewer: {{ review_req.reviewer.person }} +Review result: {{ review_req.result.name }} {{ content }} {% endfilter %}{% endautoescape %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index e2c137dd4..d625a7906 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -104,10 +104,20 @@ + {% if review_req.review and review_req.review.external_url %} + + + Posted at + + {{ review_req.review.external_url }} + + + {% endif %} + {% if review_req.reviewed_rev %} - Reviewed revision + Reviewed rev. {{ review_req.reviewed_rev }} {% if review_req.reviewed_rev != review_req.doc.rev %}(currently at {{ review_req.doc.rev }}){% endif %} {% endif %} @@ -115,7 +125,7 @@ {% if review_req.result %} - Result of review + Review result {{ review_req.result.name }} {% endif %} From f7f6d271d2b4e4a394d639933b94cb63f2ed7507 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 14 Jun 2016 14:48:15 +0000 Subject: [PATCH 17/90] Fix PyFlakes warnings - Legacy-Id: 11367 --- ietf/review/resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/review/resources.py b/ietf/review/resources.py index dff2da8ae..ee2c9c5d9 100644 --- a/ietf/review/resources.py +++ b/ietf/review/resources.py @@ -7,7 +7,7 @@ from tastypie.cache import SimpleCache from ietf import api from ietf.api import ToOneField # pyflakes:ignore -from ietf.review.models import * # pyflakes:ignore +from ietf.review.models import Reviewer, ReviewRequest from ietf.person.resources import PersonResource From c1783d4c8bec6142418af31f1a44021aad2e8ea0 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 14 Jun 2016 16:10:22 +0000 Subject: [PATCH 18/90] Bit more polish to the review page - Legacy-Id: 11370 --- ietf/doc/views_review.py | 2 +- ietf/templates/doc/document_review.html | 34 ++++++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 6c852e396..96eaa816d 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -390,7 +390,7 @@ def complete_review(request, name, request_id): review.type = DocTypeName.objects.get(slug="review") review.rev = "00" - review.title = "Review of {}-{}".format(review_req.doc.name, review_req.reviewed_rev) + 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'] diff --git a/ietf/templates/doc/document_review.html b/ietf/templates/doc/document_review.html index d0900954e..8a684e10b 100644 --- a/ietf/templates/doc/document_review.html +++ b/ietf/templates/doc/document_review.html @@ -16,18 +16,19 @@ {% if doc.rev != latest_rev %} - The information below is for an old version of the document + The information below is for an old version of the document {% else %} - + {% endif %} - Team - - + + Team + + {{ doc.group.name }} ({{ doc.group.acronym }}) @@ -38,27 +39,47 @@ + Title {{ doc.title }} + {% if doc.get_state_slug != "active" %} + State {{ doc.get_state.name }} + {% endif %} {% if review_req %} + + Request + + {{ review_req.type.name }} - requested {{ review_req.time|date:"Y-m-d" }} + + + + + Reviewer + + {{ review_req.reviewer.person }} + + + + Review result - {{ review_req.result.name }} + {{ review_req.result.name }} {% endif %} {% if doc.external_url %} + Posted at {{ doc.external_url }} @@ -66,6 +87,7 @@ {% endif %} + Last updated {{ doc.time|date:"Y-m-d" }} From 1a76c6672aea482cd7f5d0e4a48c758bde15645e Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 28 Jun 2016 13:44:10 +0000 Subject: [PATCH 19/90] Rearrange buttons on review request page to make it easier to figure out what's the next step - Legacy-Id: 11485 --- ietf/templates/doc/review/review_request.html | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index d625a7906..5e144904f 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -19,10 +19,6 @@ {% else %} {{ review_req.doc.name }} {% endif %} - - {% if can_withdraw_request %} - Withdraw request - {% endif %} @@ -74,17 +70,29 @@ None assigned yet {% endif %} - {% if can_accept_reviewer_assignment %} -
{% csrf_token %}
- {% endif %} - - {% if can_reject_reviewer_assignment %} - Reject - {% endif %} - {% if can_assign_reviewer %} {% if review_req.reviewer %}Reassign{% else %}Assign{% endif %} reviewer {% endif %} + + {% if review_req.reviewer %} + {% if can_reject_reviewer_assignment or can_accept_reviewer_assignment %} +
+ {% if review_req.state_id == "requested"%} + Assignment not accepted yet: + {% else %} + Assignment accepted: + {% endif %} + + {% if can_reject_reviewer_assignment %} + Reject + {% endif %} + + {% if can_accept_reviewer_assignment %} +
{% csrf_token %}
+ {% endif %} +
+ {% endif %} + {% endif %} @@ -97,7 +105,7 @@ {% else %} Not completed yet {% endif %} - + {% if can_complete_review %} Complete review {% endif %} @@ -132,4 +140,11 @@ +
+ {% if can_withdraw_request %} + Withdraw request + {% endif %} +
+ + {% endblock %} From e2e66522c7368dd97d3262be18082a020d126816 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Fri, 1 Jul 2016 16:06:16 +0000 Subject: [PATCH 20/90] Add review request page for review teams and first draft of manage review requests page. Add importer for importing review data from the existing Perl tool (WIP, gets most but not all of the interesting information out). Fix various bugs. - Legacy-Id: 11508 --- ietf/doc/tests_review.py | 49 +--- ietf/doc/utils.py | 34 +++ ietf/doc/views_doc.py | 2 +- ietf/doc/views_review.py | 25 +- ietf/group/features.py | 7 + ietf/group/tests_info.py | 28 ++- ietf/group/tests_review.py | 72 ++++++ ietf/group/urls_info.py | 4 +- ietf/group/urls_info_details.py | 4 +- ietf/group/views.py | 63 +++++ ietf/group/views_review.py | 142 +++++++++++ ietf/name/fixtures/names.json | 23 +- ...atename_reviewresultname_reviewtypename.py | 1 - .../0012_insert_review_name_data.py | 5 +- ietf/name/models.py | 1 - ietf/name/resources.py | 1 - ietf/person/fields.py | 20 ++ ietf/review/import_from_review_tool.py | 231 ++++++++++++++++++ ietf/review/migrations/0001_initial.py | 20 +- ietf/review/models.py | 33 ++- ietf/review/resources.py | 21 +- ietf/review/utils.py | 126 +++++++++- ietf/static/ietf/css/ietf.css | 15 ++ ietf/static/ietf/js/manage-review-requests.js | 37 +++ ietf/templates/doc/document_draft.html | 4 +- ietf/templates/doc/review/review_request.html | 4 +- .../group/manage_review_requests.html | 109 +++++++++ ietf/templates/group/review_requests.html | 121 +++++++++ ietf/utils/test_data.py | 29 +++ 29 files changed, 1131 insertions(+), 100 deletions(-) create mode 100644 ietf/group/tests_review.py create mode 100644 ietf/group/views_review.py create mode 100755 ietf/review/import_from_review_tool.py create mode 100644 ietf/static/ietf/js/manage-review-requests.js create mode 100644 ietf/templates/group/manage_review_requests.html create mode 100644 ietf/templates/group/review_requests.html diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index e937bd27e..a03b3c060 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -12,42 +12,15 @@ from pyquery import PyQuery import debug # pyflakes:ignore -from ietf.review.models import ReviewRequest, Reviewer +from ietf.review.models import ReviewRequest, ReviewTeamResult import ietf.review.mailarch -from ietf.person.models import Person -from ietf.group.models import Group, Role +from ietf.person.models import Email from ietf.name.models import ReviewResultName, ReviewRequestStateName from ietf.utils.test_utils import TestCase -from ietf.utils.test_data import make_test_data +from ietf.utils.test_data import make_test_data, make_review_data from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects from ietf.utils.mail import outbox, empty_outbox -def make_review_data(doc): - team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team") - team.reviewresultname_set.add(ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"])) - - p = Person.objects.get(user__username="plain") - role = Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team) - Reviewer.objects.create(team=team, person=p, frequency=14, skip_next=0) - - review_req = ReviewRequest.objects.create( - doc=doc, - team=team, - type_id="early", - deadline=datetime.datetime.now() + datetime.timedelta(days=20), - state_id="ready", - reviewer=role, - reviewed_rev="01", - ) - - p = Person.objects.get(user__username="marschairman") - role = Role.objects.create(name_id="reviewer", person=p, email=p.email_set.first(), group=team) - - p = Person.objects.get(user__username="secretary") - role = Role.objects.create(name_id="secretary", person=p, email=p.email_set.first(), group=team) - - return review_req - class ReviewTests(TestCase): def setUp(self): self.review_dir = os.path.abspath("tmp-review-dir") @@ -169,7 +142,7 @@ class ReviewTests(TestCase): # assign empty_outbox() - reviewer = Role.objects.filter(name="reviewer", group=review_req.team).first() + reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team).first() r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk }) self.assertEqual(r.status_code, 302) @@ -183,7 +156,7 @@ class ReviewTests(TestCase): empty_outbox() review_req.state = ReviewRequestStateName.objects.get(slug="accepted") review_req.save() - reviewer = Role.objects.filter(name="reviewer", group=review_req.team).exclude(pk=reviewer.pk).first() + reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team).exclude(pk=reviewer.pk).first() r = self.client.post(assign_url, { "action": "assign", "reviewer": reviewer.pk }) self.assertEqual(r.status_code, 302) @@ -335,7 +308,7 @@ class ReviewTests(TestCase): review_req.save() review_req.team.list_email = "{}@ietf.org".format(review_req.team.acronym) for r in ReviewResultName.objects.filter(slug__in=("issues", "ready")): - review_req.team.reviewresultname_set.add(r) + ReviewTeamResult.objects.get_or_create(team=review_req.team, result=r) review_req.team.save() url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": doc.name, "request_id": review_req.pk }) @@ -373,7 +346,7 @@ class ReviewTests(TestCase): test_file.name = "unnamed" r = self.client.post(url, data={ - "result": ReviewResultName.objects.get(teams=review_req.team, slug="ready").pk, + "result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk, "state": ReviewRequestStateName.objects.get(slug="completed").pk, "reviewed_rev": review_req.doc.rev, "review_submission": "upload", @@ -408,7 +381,7 @@ class ReviewTests(TestCase): empty_outbox() r = self.client.post(url, data={ - "result": ReviewResultName.objects.get(teams=review_req.team, slug="ready").pk, + "result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk, "state": ReviewRequestStateName.objects.get(slug="completed").pk, "reviewed_rev": review_req.doc.rev, "review_submission": "enter", @@ -439,7 +412,7 @@ class ReviewTests(TestCase): empty_outbox() r = self.client.post(url, data={ - "result": ReviewResultName.objects.get(teams=review_req.team, slug="ready").pk, + "result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk, "state": ReviewRequestStateName.objects.get(slug="completed").pk, "reviewed_rev": review_req.doc.rev, "review_submission": "link", @@ -467,7 +440,7 @@ class ReviewTests(TestCase): empty_outbox() r = self.client.post(url, data={ - "result": ReviewResultName.objects.get(teams=review_req.team, slug="ready").pk, + "result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk, "state": ReviewRequestStateName.objects.get(slug="part-completed").pk, "reviewed_rev": review_req.doc.rev, "review_submission": "enter", @@ -501,7 +474,7 @@ class ReviewTests(TestCase): url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": review_req.doc.name, "request_id": review_req.pk }) r = self.client.post(url, data={ - "result": ReviewResultName.objects.get(teams=review_req.team, slug="ready").pk, + "result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk, "state": ReviewRequestStateName.objects.get(slug="completed").pk, "reviewed_rev": review_req.doc.rev, "review_submission": "enter", diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 5d7f02f93..f37caff4c 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -3,6 +3,7 @@ import re import urllib import math import datetime +from collections import defaultdict from django.conf import settings from django.db.models.query import EmptyQuerySet @@ -556,6 +557,39 @@ def uppercase_std_abbreviated_name(name): else: return name +def extract_complete_replaces_ancestor_mapping_for_docs(names): + """Return dict mapping all replaced by relationships of the + replacement ancestors to docs. So if x is directly replaced by y + and y is in names or replaced by something in names, x in + replaces[y].""" + + replaces = defaultdict(set) + + checked = set() + front = names + while True: + if not front: + break + + relations = RelatedDocument.objects.filter( + source__in=front, relationship="replaces" + ).select_related("target").values_list("source", "target__document") + + if not relations: + break + + checked.update(front) + + front = [] + for source_doc, target_doc in relations: + replaces[source_doc].add(target_doc) + + if target_doc not in checked: + front.append(target_doc) + + return replaces + + def crawl_history(doc): # return document history data for inclusion in doc.json (used by timeline) def get_ancestors(doc): diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index c1f0a6cb2..22af819c6 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -357,7 +357,7 @@ 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 = ReviewRequest.objects.filter(doc=doc).exclude(state__in=["withdrawn", "rejected"]) + review_requests = ReviewRequest.objects.filter(doc=doc).exclude(state__in=["withdrawn", "rejected", "overtaken", "no-response"]).order_by("-time", "-id") return render_to_response("doc/document_draft.html", dict(doc=doc, diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 96eaa816d..6d5fb1d0d 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -12,8 +12,8 @@ from django.template.loader import render_to_string from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName -from ietf.group.models import Role from ietf.review.models import ReviewRequest +from ietf.person.fields import PersonEmailChoiceField from ietf.review.utils import (active_review_teams, assign_review_request_to_reviewer, can_request_review_of_doc, can_manage_review_requests_for_team, email_about_review_request, make_new_review_request_from_existing) @@ -188,22 +188,13 @@ def withdraw_request(request, name, request_id): 'review_req': review_req, }) -class PersonEmailLabeledRoleModelChoiceField(forms.ModelChoiceField): - def __init__(self, *args, **kwargs): - if not "queryset" in kwargs: - kwargs["queryset"] = Role.objects.select_related("person", "email") - super(PersonEmailLabeledRoleModelChoiceField, self).__init__(*args, **kwargs) - - def label_from_instance(self, role): - return u"{} <{}>".format(role.person.name, role.email.address) - class AssignReviewerForm(forms.Form): - reviewer = PersonEmailLabeledRoleModelChoiceField(widget=forms.RadioSelect, empty_label="(None)", required=False) + reviewer = PersonEmailChoiceField(widget=forms.RadioSelect, empty_label="(None)", required=False) def __init__(self, review_req, *args, **kwargs): super(AssignReviewerForm, self).__init__(*args, **kwargs) f = self.fields["reviewer"] - f.queryset = f.queryset.filter(name="reviewer", group=review_req.team) + f.queryset = f.queryset.filter(role__name="reviewer", role__group=review_req.team) if review_req.reviewer: f.initial = review_req.reviewer_id @@ -212,9 +203,7 @@ 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"]) - can_manage_request = can_manage_review_requests_for_team(request.user, review_req.team) - - if not can_manage_request: + 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": @@ -322,7 +311,7 @@ class CompleteReviewForm(forms.Form): " ".join("{}".format(r) for r in known_revisions)) - self.fields["result"].queryset = self.fields["result"].queryset.filter(teams=review_req.team) + self.fields["result"].queryset = self.fields["result"].queryset.filter(reviewteamresult__team=review_req.team) self.fields["review_submission"].choices = [ (k, label.format(mailing_list=review_req.team.list_email or "[error: team has no mailing list set]")) for k, label in self.fields["review_submission"].choices @@ -428,10 +417,12 @@ def complete_review(request, name, request_id): type="changed_review_request", doc=review_req.doc, by=request.user.person, - desc="Request for {} review by {} {}".format( + 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, ), ) diff --git a/ietf/group/features.py b/ietf/group/features.py index 7e32136b8..190bd83e0 100644 --- a/ietf/group/features.py +++ b/ietf/group/features.py @@ -6,6 +6,7 @@ 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 @@ -24,3 +25,9 @@ class GroupFeatures(object): if self.has_chartering_process: self.about_page = "group_charter" + + from ietf.review.utils import active_review_teams + if group in active_review_teams(): + self.has_reviews = True + import ietf.group.views + self.default_tab = ietf.group.views.review_requests diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index 081553e20..006f04191 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -25,10 +25,11 @@ from ietf.name.models import DocTagName, GroupStateName, GroupTypeName from ietf.person.models import Person, Email from ietf.utils.test_utils import TestCase, unicontent from ietf.utils.mail import outbox, empty_outbox -from ietf.utils.test_data import make_test_data, create_person +from ietf.utils.test_data import make_test_data, create_person, make_review_data from ietf.utils.test_utils import login_testing_unauthorized from ietf.group.factories import GroupFactory, RoleFactory, GroupEventFactory from ietf.meeting.factories import SessionFactory +import ietf.group.views class GroupPagesTests(TestCase): def setUp(self): @@ -313,6 +314,31 @@ class GroupPagesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertTrue(doc.title not in unicontent(r)) + def test_review_requests(self): + doc = make_test_data() + review_req = make_review_data(doc) + + group = review_req.team + + for url in [ urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym }), + urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym , 'group_type': group.type_id}), + ]: + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(review_req.doc.name in unicontent(r)) + self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r)) + + url = urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym }) + + # close request, listed under closed + review_req.state_id = "completed" + review_req.result_id = "ready" + review_req.save() + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(review_req.doc.name in unicontent(r)) + def test_history(self): draft = make_test_data() group = draft.group diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py new file mode 100644 index 000000000..6dc5d5147 --- /dev/null +++ b/ietf/group/tests_review.py @@ -0,0 +1,72 @@ +import datetime + +#from pyquery import PyQuery + +from django.core.urlresolvers import reverse as urlreverse + +from ietf.utils.test_data import make_test_data, make_review_data +from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects +from ietf.review.models import ReviewRequest +from ietf.person.models import Email +import ietf.group.views_review + +class ReviewTests(TestCase): + 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) + + review_req2 = ReviewRequest.objects.create( + doc=review_req1.doc, + team=review_req1.team, + type_id="early", + deadline=datetime.datetime.combine(datetime.date.today() + datetime.timedelta(days=30), datetime.time(23, 59, 59)), + state_id="accepted", + reviewer=review_req1.reviewer, + ) + + review_req3 = ReviewRequest.objects.create( + doc=review_req1.doc, + team=review_req1.team, + type_id="early", + deadline=datetime.datetime.combine(datetime.date.today() + datetime.timedelta(days=30), datetime.time(23, 59, 59)), + state_id="requested", + ) + + # get + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(review_req1.doc.name in unicontent(r)) + + # close and assign + new_reviewer = Email.objects.get(role__name="reviewer", role__group=group, person__user__username="marschairman") + r = self.client.post(url, { + # close + "r{}-action".format(review_req1.pk): "close", + "r{}-close".format(review_req1.pk): "no-response", + + # assign + "r{}-action".format(review_req2.pk): "assign", + "r{}-reviewer".format(review_req2.pk): new_reviewer.pk, + + # no change + "r{}-action".format(review_req3.pk): "", + "r{}-close".format(review_req3.pk): "no-response", + "r{}-reviewer".format(review_req3.pk): "", + }) + 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") + + # FIXME: test suggested + + diff --git a/ietf/group/urls_info.py b/ietf/group/urls_info.py index c1aadef3c..c96d4d7b1 100644 --- a/ietf/group/urls_info.py +++ b/ietf/group/urls_info.py @@ -3,7 +3,7 @@ from django.conf.urls import patterns, include from django.views.generic import RedirectView -from ietf.group import views, views_edit +from ietf.group import views, views_edit, views_review urlpatterns = patterns('', (r'^$', views.active_groups), @@ -20,5 +20,7 @@ urlpatterns = patterns('', (r'^email-aliases/$', 'ietf.group.views.email_aliases'), (r'^bofs/create/$', views_edit.edit, {'action': "create", }, "bof_create"), (r'^photos/$', views.chair_photos), + (r'^reviews/$', views.review_requests), + (r'^reviews/manage/$', views_review.manage_review_requests), (r'^(?P[a-zA-Z0-9-._]+)/', include('ietf.group.urls_info_details')), ) diff --git a/ietf/group/urls_info_details.py b/ietf/group/urls_info_details.py index 0c60d806a..d5d62c902 100644 --- a/ietf/group/urls_info_details.py +++ b/ietf/group/urls_info_details.py @@ -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,7 @@ urlpatterns = patterns('', (r'^materials/new/(?P[\w-]+)/$', 'ietf.doc.views_material.edit_material', { 'action': "new" }, "group_new_material"), (r'^archives/$', 'ietf.group.views.derived_archives'), (r'^photos/$', views.group_photos), + (r'^reviews/$', views.review_requests), + (r'^reviews/manage/$', views_review.manage_review_requests), url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'), ) diff --git a/ietf/group/views.py b/ietf/group/views.py index 5388819c0..292f32ff1 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -38,6 +38,7 @@ import re from tempfile import mkstemp import datetime from collections import OrderedDict +import math import debug # pyflakes:ignore @@ -67,6 +68,8 @@ from ietf.settings import MAILING_LIST_INFO_URL from ietf.mailtrigger.utils import gather_relevant_expansions from ietf.ietfauth.utils import has_role from ietf.meeting.utils import group_sessions +from ietf.review.models import ReviewRequest +from ietf.review.utils import can_manage_review_requests_for_team, suggested_review_requests_for_team def roles(group, role_name): return Role.objects.filter(group=group, name=role_name).select_related("email", "person") @@ -345,6 +348,8 @@ def construct_group_menu_context(request, group, selected, group_type, others): entries.append(("About", urlreverse("group_about", kwargs=kwargs))) if group.features.has_materials and get_group_materials(group).exists(): entries.append(("Materials", urlreverse("ietf.group.views.materials", kwargs=kwargs))) + if group.features.has_reviews: + entries.append(("Review requests", urlreverse(review_requests, kwargs=kwargs))) if group.type_id in ('rg','wg','team'): entries.append(("Meetings", urlreverse("ietf.group.views.meetings", kwargs=kwargs))) entries.append(("History", urlreverse("ietf.group.views.history", kwargs=kwargs))) @@ -375,6 +380,10 @@ def construct_group_menu_context(request, group, selected, group_type, others): if group.features.has_materials and can_manage_materials(request.user, group): actions.append((u"Upload material", urlreverse("ietf.doc.views_material.choose_material_type", kwargs=kwargs))) + if group.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_chair or can_manage): actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs))) @@ -633,6 +642,60 @@ def history(request, acronym, group_type=None): "events": events, })) +def review_requests(request, acronym, group_type=None): + group = get_group_or_404(acronym, group_type) + if not group.features.has_reviews: + raise Http404 + + open_review_requests = list(ReviewRequest.objects.filter( + team=group, state__in=("requested", "accepted") + ).prefetch_related("reviewer", "type", "state").order_by("time", "id")) + + open_review_requests += suggested_review_requests_for_team(group) + + now = datetime.datetime.now() + for r in open_review_requests: + delta = now - r.deadline + r.due = max(0, int(math.ceil(delta.total_seconds() / 3600.0))) + + closed_review_requests = ReviewRequest.objects.filter( + team=group, + ).exclude( + state__in=("requested", "accepted") + ).prefetch_related("reviewer", "type", "state").order_by("-time", "-id") + + since_choices = [ + (None, "1 month"), + ("3m", "3 months"), + ("6m", "6 months"), + ("1y", "1 year"), + ("2y", "2 years"), + ("all", "All"), + ] + since = request.GET.get("since", None) + if since not in [key for key, label in since_choices]: + since = None + + if since != "all": + date_limit = { + None: datetime.timedelta(days=31), + "3m": datetime.timedelta(days=31 * 3), + "6m": datetime.timedelta(days=180), + "1y": datetime.timedelta(days=365), + "2y": datetime.timedelta(days=2 * 365), + }[since] + + closed_review_requests = closed_review_requests.filter(time__gte=datetime.date.today() - date_limit) + + return render(request, 'group/review_requests.html', + construct_group_menu_context(request, group, "review requests", group_type, { + "open_review_requests": open_review_requests, + "closed_review_requests": closed_review_requests, + "since_choices": since_choices, + "since": since, + "can_manage_review_requests": can_manage_review_requests_for_team(request.user, group) + })) + def materials(request, acronym, group_type=None): group = get_group_or_404(acronym, group_type) if not group.features.has_materials: diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py new file mode 100644 index 000000000..e46c45ae2 --- /dev/null +++ b/ietf/group/views_review.py @@ -0,0 +1,142 @@ +from django.shortcuts import render, redirect +from django.http import Http404, HttpResponseForbidden +from django.contrib.auth.decorators import login_required +from django import forms + +from ietf.review.models import ReviewRequest, ReviewRequestStateName +from ietf.review.utils import (can_manage_review_requests_for_team, + extract_revision_ordered_review_requests_for_documents, + assign_review_request_to_reviewer, +# email_about_review_request, make_new_review_request_from_existing, + suggested_review_requests_for_team) +from ietf.group.utils import get_group_or_404 +from ietf.person.fields import PersonEmailChoiceField + + +class ManageReviewRequestForm(forms.Form): + ACTIONS = [ + ("assign", "Assign"), + ("close", "Close"), + ] + + action = forms.ChoiceField(choices=ACTIONS, widget=forms.HiddenInput, required=False) + + CLOSE_OPTIONS = [ + ("noreviewversion", "No review of this version"), + ("noreviewdocument", "No review of document"), + ("withdraw", "Withdraw request"), + ("no-response", "No response"), + ("overtaken", "Overtaken by events"), + ] + close = forms.ChoiceField(choices=CLOSE_OPTIONS, required=False) + + reviewer = PersonEmailChoiceField(empty_label="(None)", required=False, label_with="person") + + def __init__(self, review_req, *args, **kwargs): + if not "prefix" in kwargs: + if review_req.pk is None: + kwargs["prefix"] = "r{}-{}".format(review_req.type_id, review_req.doc_id) + else: + kwargs["prefix"] = "r{}".format(review_req.pk) + + super(ManageReviewRequestForm, self).__init__(*args, **kwargs) + + close_initial = None + if review_req.pk is None: + if review_req.latest_reqs: + close_initial = "noreviewversion" + else: + close_initial = "noreviewdocument" + 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" + + self.fields["reviewer"].queryset = self.fields["reviewer"].queryset.filter( + role__name="reviewer", + role__group=review_req.team, + ) + + self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm" + + if self.is_bound: + action = self.data.get("action") + if action == "close": + self.fields["close"].required = True + elif action == "assign": + self.fields["reviewer"].required = True + + +@login_required +def manage_review_requests(request, acronym, group_type=None): + group = get_group_or_404(acronym, group_type) + if not group.features.has_reviews: + raise Http404 + + if not can_manage_review_requests_for_team(request.user, group): + return HttpResponseForbidden("You do not have permission to perform this action") + + review_requests = list(ReviewRequest.objects.filter( + team=group, state__in=("requested", "accepted") + ).prefetch_related("reviewer", "type", "state").order_by("time", "id")) + + review_requests += suggested_review_requests_for_team(group) + + document_requests = extract_revision_ordered_review_requests_for_documents( + ReviewRequest.objects.filter(state__in=("part-completed", "completed")).prefetch_related("result"), + set(r.doc_id for r in review_requests), + ) + + for req in review_requests: + l = [] + # take all on the latest reviewed rev + for r in document_requests[req.doc_id]: + if l and l[0].reviewed_rev: + if r.doc_id == l[0].doc_id and r.reviewed_rev: + if int(r.reviewed_rev) > int(l[0].reviewed_rev): + l = [r] + elif int(r.reviewed_rev) == int(l[0].reviewed_rev): + l.append(r) + else: + l = [r] + + req.latest_reqs = l + + req.form = ManageReviewRequestForm(req, request.POST if request.method == "POST" else None) + + if request.method == "POST": + form_results = [] + for req in review_requests: + form_results.append(req.form.is_valid()) + + if all(form_results): + for req in review_requests: + action = req.form.cleaned_data.get("action") + if action == "assign": + assign_review_request_to_reviewer(request, req, req.form.cleaned_data["reviewer"]) + elif action == "close": + close_reason = req.form.cleaned_data["close"] + if close_reason in ("withdraw", "no-response", "overtaken"): + req.state = ReviewRequestStateName.objects.get(slug=close_reason, used=True) + req.save() + # FIXME: notify? + else: + FIXME + + kwargs = { "acronym": group.acronym } + if group_type: + kwargs["group_type"] = group_type + import ietf.group.views + return redirect(ietf.group.views.review_requests, **kwargs) + + + return render(request, 'group/manage_review_requests.html', { + 'group': group, + 'review_requests': review_requests, + }) + diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 72aeb7b2f..72837bf88 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -1821,11 +1821,11 @@ "desc": "" }, "model": "name.reviewrequeststatename", - "pk": "noresponse" + "pk": "no-response" }, { "fields": { - "order": 6, + "order": 7, "used": true, "name": "Partially Completed", "desc": "" @@ -1847,7 +1847,6 @@ "fields": { "order": 1, "used": true, - "teams": [], "name": "Serious Issues", "desc": "" }, @@ -1858,7 +1857,6 @@ "fields": { "order": 2, "used": true, - "teams": [], "name": "Has Issues", "desc": "" }, @@ -1869,7 +1867,6 @@ "fields": { "order": 3, "used": true, - "teams": [], "name": "Has Nits", "desc": "" }, @@ -1880,7 +1877,6 @@ "fields": { "order": 4, "used": true, - "teams": [], "name": "Not Ready", "desc": "" }, @@ -1891,7 +1887,6 @@ "fields": { "order": 5, "used": true, - "teams": [], "name": "On the Right Track", "desc": "" }, @@ -1902,7 +1897,6 @@ "fields": { "order": 6, "used": true, - "teams": [], "name": "Almost Ready", "desc": "" }, @@ -1913,7 +1907,6 @@ "fields": { "order": 7, "used": true, - "teams": [], "name": "Ready with Issues", "desc": "" }, @@ -1924,7 +1917,6 @@ "fields": { "order": 8, "used": true, - "teams": [], "name": "Ready with Nits", "desc": "" }, @@ -1935,7 +1927,6 @@ "fields": { "order": 9, "used": true, - "teams": [], "name": "Ready", "desc": "" }, @@ -1972,6 +1963,16 @@ "model": "name.reviewtypename", "pk": "telechat" }, +{ + "fields": { + "order": 4, + "used": false, + "name": "Unknown", + "desc": "" + }, + "model": "name.reviewtypename", + "pk": "unknown" +}, { "fields": { "order": 0, diff --git a/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py b/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py index fb62deea3..59e57a76f 100644 --- a/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py +++ b/ietf/name/migrations/0011_reviewrequeststatename_reviewresultname_reviewtypename.py @@ -35,7 +35,6 @@ class Migration(migrations.Migration): ('desc', models.TextField(blank=True)), ('used', models.BooleanField(default=True)), ('order', models.IntegerField(default=0)), - ('teams', models.ManyToManyField(help_text=b"Which teams this result can be set for. This also implicitly defines which teams are review teams - if there are no possible review results defined for a given team, it can't be a review team.", to='group.Group', blank=True)), ], options={ 'ordering': ['order'], diff --git a/ietf/name/migrations/0012_insert_review_name_data.py b/ietf/name/migrations/0012_insert_review_name_data.py index 7cc1a7d52..92da06b94 100644 --- a/ietf/name/migrations/0012_insert_review_name_data.py +++ b/ietf/name/migrations/0012_insert_review_name_data.py @@ -11,14 +11,15 @@ def insert_initial_review_data(apps, schema_editor): 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="noresponse", name="No Response", order=6) - ReviewRequestStateName.objects.get_or_create(slug="part-completed", name="Partially Completed", order=6) + ReviewRequestStateName.objects.get_or_create(slug="no-response", name="No Response", order=6) + ReviewRequestStateName.objects.get_or_create(slug="part-completed", name="Partially Completed", order=7) ReviewRequestStateName.objects.get_or_create(slug="completed", name="Completed", order=8) ReviewTypeName = apps.get_model("name", "ReviewTypeName") ReviewTypeName.objects.get_or_create(slug="early", name="Early", order=1) ReviewTypeName.objects.get_or_create(slug="lc", name="Last Call", order=2) ReviewTypeName.objects.get_or_create(slug="telechat", name="Telechat", order=3) + ReviewTypeName.objects.get_or_create(slug="unknown", name="Unknown", order=4, used=False) ReviewResultName = apps.get_model("name", "ReviewResultName") ReviewResultName.objects.get_or_create(slug="serious-issues", name="Serious Issues", order=1) diff --git a/ietf/name/models.py b/ietf/name/models.py index f17a55203..58b861326 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -96,5 +96,4 @@ class ReviewResultName(NameModel): """Almost ready, Has issues, Has nits, Not Ready, On the right track, Ready, Ready with issues, Ready with nits, Serious Issues""" - teams = models.ManyToManyField("group.Group", help_text="Which teams this result can be set for. This also implicitly defines which teams are review teams - if there are no possible review results defined for a given team, it can't be a review team.", blank=True) diff --git a/ietf/name/resources.py b/ietf/name/resources.py index 521f4ee79..64626273c 100644 --- a/ietf/name/resources.py +++ b/ietf/name/resources.py @@ -453,7 +453,6 @@ class ReviewResultNameResource(ModelResource): "desc": ALL, "used": ALL, "order": ALL, - "teams": ALL_WITH_RELATIONS, } api.name.register(ReviewResultNameResource()) diff --git a/ietf/person/fields.py b/ietf/person/fields.py index 177da9cb7..cc86d4fb4 100644 --- a/ietf/person/fields.py +++ b/ietf/person/fields.py @@ -139,3 +139,23 @@ class SearchableEmailField(SearchableEmailsField): return super(SearchableEmailField, self).clean(value).first() +class PersonEmailChoiceField(forms.ModelChoiceField): + """ModelChoiceField targeting Email and displaying choices with the + person name as well as the email address. Needs further + restrictions, e.g. on role, to useful.""" + def __init__(self, *args, **kwargs): + if not "queryset" in kwargs: + kwargs["queryset"] = Email.objects.select_related("person") + + self.label_with = kwargs.pop("label_with", None) + + super(PersonEmailChoiceField, self).__init__(*args, **kwargs) + + def label_from_instance(self, email): + if self.label_with == "person": + return unicode(email.person) + elif self.label_with == "email": + return email.address + else: + return u"{} <{}>".format(email.person, email.address) + diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py new file mode 100755 index 000000000..325f6e06b --- /dev/null +++ b/ietf/review/import_from_review_tool.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python + +import sys, os + +# boilerplate +basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +sys.path = [ basedir ] + sys.path +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ietf.settings") + +import django +django.setup() + + +# script + +import datetime +from collections import namedtuple +from django.db import connections +from ietf.review.models import ReviewRequest, Reviewer, ReviewResultName +from ietf.review.models import ReviewRequestStateName, ReviewTypeName, ReviewTeamResult +from ietf.group.models import Group, Role, RoleName +from ietf.person.models import Person, Email, Alias +import argparse +from unidecode import unidecode +from collections import defaultdict + +parser = argparse.ArgumentParser() +parser.add_argument("database", help="database must be included in settings") +parser.add_argument("team", help="team acronym, must exist") +args = parser.parse_args() + +db_con = connections[args.database] +team = Group.objects.get(acronym=args.team) + +def namedtuplefetchall(cursor): + "Return all rows from a cursor as a namedtuple" + desc = cursor.description + nt_result = namedtuple('Result', [col[0] for col in desc]) + return (nt_result(*row) for row in cursor.fetchall()) + +def parse_timestamp(t): + if not t: + return None + return datetime.datetime.fromtimestamp(t) + +# personnel +with db_con.cursor() as c: + c.execute("select distinct reviewer from reviews;") + known_reviewers = { row[0] for row in c.fetchall() } + +with db_con.cursor() as c: + c.execute("select distinct who from doclog;") + docloggers = { row[0] for row in c.fetchall() } + +with db_con.cursor() as c: + c.execute("select distinct login from members where permissions like '%secretary%';") + secretaries = { row[0] for row in c.fetchall() } + +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", person + existing_aliases = set(Alias.objects.filter(person=person).values_list("name", flat=True)) + curr_names = set(x for x in [person.name, person.ascii, person.ascii_short, person.plain_name(), ] if x) + new_aliases = curr_names - existing_aliases + for name in new_aliases: + Alias.objects.create(person=person, name=name) + + email, created = Email.objects.get_or_create(address=row.email, person=person) + if created: + print "created email", email + + known_personnel[row.login] = email + + if "secretary" in row.permissions: + role, created = Role.objects.get_or_create(name=RoleName.objects.get(slug="secr"), person=email.person, email=email, group=team) + if created: + print "created role", role + + if row.login in known_reviewers: + if row.comment != "Inactive" and row.available != 2145916800: # corresponds to 2038-01-01 + assert not row.autopolicy or row.autopolicy == "monthly" + + 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", role + + reviewer, created = Reviewer.objects.get_or_create( + team=team, + person=email.person, + ) + if reviewer: + print "created reviewer", reviewer + + if row.autopolicy == "monthly": + reviewer.frequency = 30 + reviewer.unavailable_until = parse_timestamp(row.available) + reviewer.filter_re = row.donotassign + reviewer.save() + + +# review requests + +# check that we got the needed names +results = { n.name.lower(): n for n in ReviewResultName.objects.all() } + +with db_con.cursor() as c: + c.execute("select distinct summary from reviews;") + summaries = [r[0].lower() for r in c.fetchall() if r[0]] + missing_result_names = set(summaries) - set(results.keys()) + assert not missing_result_names, "missing result names: {} {}".format(missing_result_names, results.keys()) + + for s in summaries: + ReviewTeamResult.objects.get_or_create(team=team, result=results[s]) + +states = { n.slug: n for n in ReviewRequestStateName.objects.all() } +# map some names +states["assigned"] = states["requested"] +states["done"] = states["completed"] +states["noresponse"] = states["no-response"] + +with db_con.cursor() as c: + c.execute("select distinct docstatus from reviews;") + docstates = [r[0] for r in c.fetchall() if r[0]] + missing_state_names = set(docstates) - set(states.keys()) + assert not missing_state_names, "missing state names: {}".format(missing_state_names) + +type_names = { n.slug: n for n in ReviewTypeName.objects.all() } + +# extract relevant log entries + +request_assigned = defaultdict(list) + +with db_con.cursor() as c: + c.execute("select docname, time, who from doclog where text = 'AUTO UPDATED status TO working' order by time desc;") + for row in namedtuplefetchall(c): + request_assigned[row.docname].append((row.time, row.who)) + +# extract document request metadata + +doc_metadata = {} + +with db_con.cursor() as c: + c.execute("select docname, version, deadline, telechat, lcend, status from documents order by docname, version;") + + for row in namedtuplefetchall(c): + doc_metadata[(row.docname, row.version)] = doc_metadata[row.docname] = (parse_timestamp(row.deadline), parse_timestamp(row.telechat), parse_timestamp(row.lcend), row.status) + + +with db_con.cursor() as c: + c.execute("select * from reviews order by reviewid;") + + for row in namedtuplefetchall(c): + meta = doc_metadata.get((row.docname, row.version)) + if not meta: + meta = doc_metadata.get(row.docname) + + deadline, telechat, lcend, status = meta or (None, None, None, None) + + if not deadline: + deadline = parse_timestamp(row.timeout) + + type_name = type_names["unknown"] + # FIXME: use lcend and telechat to try to deduce type + + reviewed_rev = row.version if row.version and row.version != "99" else "" + if row.summary == "noresponse": + reviewed_rev = "" + + assignment_logs = request_assigned.get(row.docname, []) + if assignment_logs: + time, who = assignment_logs.pop() + + time = parse_timestamp(time) + else: + time = deadline + + if not deadline and row.docstatus == "assigned": + # bogus row + print "SKIPPING WITH NO DEADLINE", time, row + continue + + if status == "done" and row.docstatus in ("assigned", "accepted"): + # filter out some apparently dead requests + print "SKIPPING MARKED DONE even if assigned/accepted", time, row + continue + + req, _ = ReviewRequest.objects.get_or_create( + doc_id=row.docname, + team=team, + old_id=row.reviewid, + defaults={ + "state": states["requested"], + "type": type_name, + "deadline": deadline, + } + ) + + req.reviewer = known_personnel[row.reviewer] if row.reviewer else None + req.result = results.get(row.summary.lower()) if row.summary else None + req.state = states.get(row.docstatus) if row.docstatus else None + req.type = type_name + req.time = time + req.reviewed_rev = reviewed_rev + req.deadline = deadline + req.save() + + # FIXME: add log entries + # FIXME: add review from reviewurl + # adcomments IGNORED + # lccomments IGNORED + # nits IGNORED + # reviewurl review.external_url + + #print meta and meta[0], telechat, lcend, req.type + + print "imported review", row.reviewid, "as", req.pk, req.time, req.deadline, req.type, req.doc_id diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index d9187804b..cb85178c2 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -7,10 +7,10 @@ from django.db import models, migrations class Migration(migrations.Migration): dependencies = [ - ('group', '0008_auto_20160505_0523'), ('name', '0012_insert_review_name_data'), + ('group', '0008_auto_20160505_0523'), + ('person', '0014_auto_20160613_0751'), ('doc', '0012_auto_20160207_0537'), - ('person', '0006_auto_20160503_0937'), ] operations = [ @@ -21,7 +21,7 @@ class Migration(migrations.Migration): ('frequency', models.IntegerField(default=30, help_text=b'Can review every N days')), ('unavailable_until', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)), ('filter_re', models.CharField(max_length=255, blank=True)), - ('skip_next', models.IntegerField(help_text=b'Skip the next N review assignments')), + ('skip_next', models.IntegerField(default=0, help_text=b'Skip the next N review assignments')), ('person', models.ForeignKey(to='person.Person')), ('team', models.ForeignKey(to='group.Group')), ], @@ -33,6 +33,7 @@ class Migration(migrations.Migration): 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(auto_now_add=True)), ('deadline', models.DateTimeField()), ('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)), @@ -40,7 +41,7 @@ class Migration(migrations.Migration): ('doc', models.ForeignKey(related_name='review_request_set', to='doc.Document')), ('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='group.Role', null=True)), + ('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')), @@ -49,4 +50,15 @@ class Migration(migrations.Migration): }, bases=(models.Model,), ), + migrations.CreateModel( + name='ReviewTeamResult', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('result', models.ForeignKey(to='name.ReviewResultName')), + ('team', models.ForeignKey(to='group.Group')), + ], + options={ + }, + bases=(models.Model,), + ), ] diff --git a/ietf/review/models.py b/ietf/review/models.py index 79092e8b1..589899b91 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -1,8 +1,8 @@ from django.db import models from ietf.doc.models import Document -from ietf.group.models import Group, Role -from ietf.person.models import Person +from ietf.group.models import Group +from ietf.person.models import Person, Email from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName class Reviewer(models.Model): @@ -11,10 +11,21 @@ class Reviewer(models.Model): reviewer and team.""" team = models.ForeignKey(Group) person = models.ForeignKey(Person) - frequency = models.IntegerField(help_text="Can review every N days", default=30) + frequency = models.IntegerField(default=30, help_text="Can review every N days") unavailable_until = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again") filter_re = models.CharField(max_length=255, blank=True) - skip_next = models.IntegerField(help_text="Skip the next N review assignments") + skip_next = models.IntegerField(default=0, help_text="Skip the next N review assignments") + + def __unicode__(self): + return "{} in {}".format(self.person, self.team) + +class ReviewTeamResult(models.Model): + """Captures that a result name is valid for a given team for new + reviews. This also implicitly defines which teams are review + teams - if there are no possible review results valid for a given + team, it can't be a review team.""" + team = models.ForeignKey(Group) + result = models.ForeignKey(ReviewResultName) class ReviewRequest(models.Model): """Represents a request for a review and the process it goes through. @@ -22,21 +33,23 @@ class ReviewRequest(models.Model): 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(auto_now_add=True) type = models.ForeignKey(ReviewTypeName) doc = models.ForeignKey(Document, related_name='review_request_set') - team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewresultname=None)) + team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewteamresult=None)) deadline = models.DateTimeField() 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 the - # states 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(Role, blank=True, null=True) + # 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) diff --git a/ietf/review/resources.py b/ietf/review/resources.py index ee2c9c5d9..02aa29d7b 100644 --- a/ietf/review/resources.py +++ b/ietf/review/resources.py @@ -7,7 +7,7 @@ from tastypie.cache import SimpleCache from ietf import api from ietf.api import ToOneField # pyflakes:ignore -from ietf.review.models import Reviewer, ReviewRequest +from ietf.review.models import Reviewer, ReviewRequest, ReviewTeamResult from ietf.person.resources import PersonResource @@ -63,3 +63,22 @@ class ReviewRequestResource(ModelResource): } api.review.register(ReviewRequestResource()) + + +from ietf.group.resources import GroupResource +from ietf.name.resources import ReviewResultNameResource +class ReviewTeamResultResource(ModelResource): + team = ToOneField(GroupResource, 'team') + result = ToOneField(ReviewResultNameResource, 'result') + class Meta: + queryset = ReviewTeamResult.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'reviewteamresult' + filtering = { + "id": ALL, + "team": ALL_WITH_RELATIONS, + "result": ALL_WITH_RELATIONS, + } +api.review.register(ReviewTeamResultResource()) + diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 7da5e1b6b..911b6390b 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -1,14 +1,19 @@ +import datetime +from collections import defaultdict + from django.contrib.sites.models import Site from ietf.group.models import Group, Role -from ietf.doc.models import DocEvent +from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent +from ietf.iesg.models import TelechatDate from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream -from ietf.review.models import ReviewRequestStateName, ReviewRequest +from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewTypeName 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 ReviewResultName defined, it's a review team - return Group.objects.filter(state="active").exclude(reviewresultname=None) + # if there's a ReviewTeamResult defined, it's a review team + return Group.objects.filter(state="active").exclude(reviewteamresult=None) def can_request_review_of_doc(user, doc): if not user.is_authenticated(): @@ -37,11 +42,11 @@ def email_about_review_request(request, review_req, subject, msg, by, notify_sec """Notify possibly both secretary and reviewer about change, skipping a party if the change was done by that party.""" - def extract_email_addresses(roles): - if any(r.person == by for r in roles if r): + def extract_email_addresses(objs): + if any(o.person == by for o in objs if o): return [] else: - return [r.formatted_email() for r in roles if r] + return [o.formatted_email() for o in objs if o] to = [] @@ -92,3 +97,110 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): "Assigned to review of %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) + +def suggested_review_requests_for_team(team): + def fixup_deadline(d): + if d.time() == datetime.time(0): + d = d - datetime.timedelta(seconds=1) # 23:59:59 is treated specially in the view code + return d + + seen_deadlines = {} + + requests = {} + + if True: + # in Last Call + last_call_type = ReviewTypeName.objects.get(slug="lc") + last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True)) + last_call_expires = { e.doc_id: e.expires for e in LastCallDocEvent.objects.order_by("time", "id") } + for doc in last_call_docs: + deadline = fixup_deadline(last_call_expires.get(doc.pk)) if doc.pk in last_call_expires else datetime.datetime.now() + + if deadline > seen_deadlines.get(doc.pk, datetime.datetime.max): + continue + + requests[doc.pk] = ReviewRequest( + time=None, + type=last_call_type, + doc=doc, + team=team, + deadline=deadline, + ) + + seen_deadlines[doc.pk] = deadline + + + if True: + # on Telechat Agenda + telechat_dates = list(TelechatDate.objects.active().order_by('date').values_list("date", flat=True)[:4]) + + telechat_type = ReviewTypeName.objects.get(slug="telechat") + telechat_deadline_delta = datetime.timedelta(days=2) + telechat_docs = Document.objects.filter(docevent__telechatdocevent__telechat_date__in=telechat_dates) + for doc in telechat_docs: + d = doc.telechat_date() + if d not in telechat_dates: + continue + + deadline = datetime.datetime.combine(d - telechat_deadline_delta, datetime.time(23, 59, 59)) + + if deadline > seen_deadlines.get(doc.pk, datetime.datetime.max): + continue + + requests[doc.pk] = ReviewRequest( + time=None, + type=telechat_type, + doc=doc, + team=team, + deadline=deadline, + ) + + 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): + return (existing.doc_id == request.doc_id + and existing.reviewed_rev == request.doc.rev + and existing.state_id not in ("part-completed", "rejected", "overtaken")) + + 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)) + return res + +def extract_revision_ordered_review_requests_for_documents(queryset, names): + names = set(names) + + replaces = extract_complete_replaces_ancestor_mapping_for_docs(names) + + requests_for_each_doc = defaultdict(list) + for r in 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, [])) + + while front: + replaces_reqs = [] + for replaces_name in front: + reqs = requests_for_each_doc.get(replaces_name, []) + if reqs: + replaces_reqs.append(reqs) + + # 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 = [n for l in requests_for_each_doc.get(replaces_name, []) for n in l] + + return res diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 0da7cec77..c8230106c 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -462,6 +462,11 @@ label#list-feeds { /* Review flow */ +.reviewer-assignment-not-accepted { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + form.complete-review .mail-archive-search .query-input { width: 30em; } @@ -472,6 +477,16 @@ form.complete-review .mail-archive-search .results .list-group { margin-bottom: 0.5em; } +.closed-review-filter { + margin-bottom: 1em; +} + +form.review-requests .reviewer-controls, form.review-requests .close-controls { + display: none; +} + +/* Profile */ + .photo-name { height: 3em; } diff --git a/ietf/static/ietf/js/manage-review-requests.js b/ietf/static/ietf/js/manage-review-requests.js new file mode 100644 index 000000000..62a38438d --- /dev/null +++ b/ietf/static/ietf/js/manage-review-requests.js @@ -0,0 +1,37 @@ +$(document).ready(function () { + var form = $("form.review-requests"); + + form.find(".reviewer-action").on("click", function () { + var row = $(this).closest("tr"); + row.find(".close-controls .undo").click(); + row.find("[name$=\"-action\"]").val("assign"); + row.find(".reviewer-action").hide(); + row.find(".reviewer-controls").show(); + }); + + form.find(".reviewer-controls .undo").on("click", function () { + var row = $(this).closest("tr"); + row.find(".reviewer-controls").hide(); + row.find(".reviewer-action").show(); + row.find("[name$=\"-action\"]").val(""); + }); + + form.find(".close-action").on("click", function () { + var row = $(this).closest("tr"); + row.find(".reviewer-controls .undo").click(); + row.find("[name$=\"-action\"]").val("close"); + row.find(".close-action").hide(); + row.find(".close-controls").show(); + }); + + form.find(".close-controls .undo").on("click", function () { + var row = $(this).closest("tr"); + row.find("[name$=\"-action\"]").val(""); + row.find(".close-controls").hide(); + row.find(".close-action").show(); + }); + + form.find("[name$=\"-action\"]").each(function () { + console.log(this); + }); +}); diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 3a0cd68aa..8bb31a851 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -201,9 +201,9 @@ {% for r in review_requests %} {% endfor %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index 5e144904f..3c9826b37 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -102,8 +102,10 @@ {% if review_req.review %} {{ review_req.review.name }} - {% else %} + {% elif review_req.state_id == "requested" or review_req.state_id == "accepted" %} Not completed yet + {% else %} + Not available {% endif %} {% if can_complete_review %} diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html new file mode 100644 index 000000000..9e13bb04f --- /dev/null +++ b/ietf/templates/group/manage_review_requests.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block title %}Manage pending review requests for {{ group.acronym }}{% endblock %} + +{% block pagehead %} + +{% endblock %} + +{% block content %} + {% origin %} + +

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

+ +

For reference: closed review requests + + {% if review_requests %} +

{% csrf_token %} + + + + + + + + + + + + + {% for r in review_requests %} + + + + + + + + + {% endfor %} + +
DocumentTypeRequestedDeadlineReviewerClose as...
{{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %} + {% if r.latest_reqs %} +
+ - prev. review: + {% for rlatest in r.latest_reqs %} + {{ rlatest.result.name }} + (diff){% if not forloop.last %},{% endif %} + {% endfor %} + + {% endif %} +
{{ r.type.name }}{% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} + {% if r.deadline|date:"H:i" != "23:59" %} + {{ r.deadline|date:"Y-m-d H:i" }} + {% else %} + {{ r.deadline|date:"Y-m-d" }} + {% endif %} + {% if r.due %}{{ r.due }} hour{{ r.due|pluralize }}{% endif %} + + {% if r.reviewer %} + + {% else %} + + {% endif %} + + {{ r.form.action }} + + + {% spaceless %} + {{ r.form.reviewer }} + + {% if r.form.reviewer.errors %} +
+ {{ r.form.reviewer.errors }} + {% endif %} + {% endspaceless %} +
+
+ + + + {% spaceless %} + {{ r.form.close }} + + {% if r.form.close.errors %} +
+ {{ r.form.close.errors }} + {% endif %} + {% endspaceless %} +
+
+ + {% buttons %} + Cancel + + {% endbuttons %} +
+ {% else %} +

There are currently no open requests.

+ {% endif %} +{% endblock %} + +{% block js %} + + +{% endblock %} diff --git a/ietf/templates/group/review_requests.html b/ietf/templates/group/review_requests.html new file mode 100644 index 000000000..38a75e20d --- /dev/null +++ b/ietf/templates/group/review_requests.html @@ -0,0 +1,121 @@ +{% extends "group/group_base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block group_subtitle %}Reviews for {{ group.name }}{% endblock %} + +{% block pagehead %} + +{% endblock %} + +{% block group_content %} + {% origin %} + +

Open review requests

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

There are currently no open requests.

+ {% endif %} + +

Closed review requests

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

No closed requests found.

+ {% endif %} + +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index 638294058..d19be0f93 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -13,6 +13,7 @@ from ietf.ipr.models import HolderIprDisclosure, IprDocRel, IprDisclosureStateNa from ietf.meeting.models import Meeting from ietf.name.models import StreamName, DocRelationshipName from ietf.person.models import Person, Email +from ietf.review.models import ReviewRequest, Reviewer, ReviewResultName, ReviewTeamResult def create_person(group, role_name, name=None, username=None, email_address=None, password=None): """Add person/user/email and role.""" @@ -357,3 +358,31 @@ def make_test_data(): #other_doc_factory('recording','recording-42-mars-1-00') return draft + +def make_review_data(doc): + team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team") + for r in ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"]): + ReviewTeamResult.objects.create(team=team, result=r) + + p = Person.objects.get(user__username="plain") + email = p.email_set.first() + Role.objects.create(name_id="reviewer", person=p, email=email, group=team) + Reviewer.objects.create(team=team, person=p, frequency=14, skip_next=0) + + review_req = ReviewRequest.objects.create( + doc=doc, + team=team, + type_id="early", + deadline=datetime.datetime.now() + datetime.timedelta(days=20), + state_id="accepted", + 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="secretary", person=p, email=p.email_set.first(), group=team) + + return review_req + From ebc37dede9b39ca9418bb605b622cefacafc82b4 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Fri, 1 Jul 2016 16:20:01 +0000 Subject: [PATCH 21/90] Link to review request even if it's completed, if there's no review document associated - that shouldn't really happen, but the importer doesn't import reviews yet and we don't have content for all the old reviews. - Legacy-Id: 11509 --- ietf/templates/doc/document_draft.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 8bb31a851..6b43d3db9 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -201,9 +201,12 @@ {% for r in review_requests %}
{% if r.state_id == "completed" or r.state_id == "part-completed" %} - {{ r.team.acronym|upper }} {{ r.type.name }} Review{% if r.reviewed_rev and r.reviewed_rev != doc.rev %} (of -{{ r.reviewed_rev }}){% endif %}: {{ r.result.name }} {% if r.state_id == "part-completed" %}(partially completed){% endif %} - reviewer: {{ r.reviewer.person }} + + {{ r.team.acronym|upper }} {{ r.type.name }} Review{% if r.reviewed_rev and r.reviewed_rev != doc.rev %} (of -{{ r.reviewed_rev }}){% endif %}: + {{ r.result.name }} {% if r.state_id == "part-completed" %}(partially completed){% endif %} + - reviewer: {{ r.reviewer.person }} {% else %} - {{ r.team.acronym|upper }} {{ r.type.name }} Review{% if r.reviewer %} (reviewer: {{ r.reviewer.person }}){% endif %} + {{ r.team.acronym|upper }} {{ r.type.name }} Review{% if r.reviewer %} (reviewer: {{ r.reviewer.person }}){% endif %} {% endif %}
{% endfor %} From 19fff81a4feb7d1605b8ce37e4d6093b63baf5b5 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 5 Jul 2016 16:05:00 +0000 Subject: [PATCH 22/90] Rework closing a review request so the logic is reusable, add the more specific close reasons to the database migration, add ReviewRequest.requested_by so it's possible to notify the requester of a review that it has been dropped. - Legacy-Id: 11520 --- ietf/doc/tests_review.py | 25 +++--- ietf/doc/urls_review.py | 2 +- ietf/doc/views_review.py | 77 +++++++++++------- ietf/group/tests_review.py | 4 +- ietf/group/views_review.py | 36 ++++----- .../0012_insert_review_name_data.py | 6 +- ietf/name/models.py | 2 +- ietf/review/import_from_review_tool.py | 3 + ietf/review/migrations/0001_initial.py | 1 + ietf/review/models.py | 1 + ietf/review/utils.py | 81 +++++++++++++++---- ...thdraw_request.html => close_request.html} | 10 ++- ietf/templates/doc/review/request_review.html | 5 ++ ietf/templates/doc/review/review_request.html | 10 ++- ietf/utils/test_data.py | 1 + 15 files changed, 171 insertions(+), 93 deletions(-) rename ietf/templates/doc/review/{withdraw_request.html => close_request.html} (56%) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index a03b3c060..a19717633 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -14,7 +14,7 @@ import debug # pyflakes:ignore from ietf.review.models import ReviewRequest, ReviewTeamResult import ietf.review.mailarch -from ietf.person.models import Email +from ietf.person.models import Email, Person from ietf.name.models import ReviewResultName, ReviewRequestStateName from ietf.utils.test_utils import TestCase from ietf.utils.test_data import make_test_data, make_review_data @@ -57,7 +57,8 @@ class ReviewTests(TestCase): "type": "early", "team": review_team.pk, "deadline_date": deadline_date.isoformat(), - "requested_rev": "01" + "requested_rev": "01", + "requested_by": Person.objects.get(user__username="plain").pk, }) self.assertEqual(r.status_code, 302) @@ -82,13 +83,13 @@ class ReviewTests(TestCase): self.assertEqual(r.status_code, 200) self.assertTrue(review_req.team.acronym.upper() in unicontent(r)) - def test_withdraw_request(self): + 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() - withdraw_url = urlreverse('ietf.doc.views_review.withdraw_request', kwargs={ "name": doc.name, "request_id": review_req.pk }) + close_url = urlreverse('ietf.doc.views_review.close_request', kwargs={ "name": doc.name, "request_id": review_req.pk }) # follow link @@ -96,26 +97,26 @@ class ReviewTests(TestCase): self.client.login(username="secretary", password="secretary+password") r = self.client.get(req_url) self.assertEqual(r.status_code, 200) - self.assertTrue(withdraw_url in unicontent(r)) + self.assertTrue(close_url in unicontent(r)) self.client.logout() - # get withdraw page - login_testing_unauthorized(self, "secretary", withdraw_url) - r = self.client.get(withdraw_url) + # get close page + login_testing_unauthorized(self, "secretary", close_url) + r = self.client.get(close_url) self.assertEqual(r.status_code, 200) - # withdraw + # close empty_outbox() - r = self.client.post(withdraw_url, { "action": "withdraw" }) + r = self.client.post(close_url, { "close_reason": "withdrawn" }) self.assertEqual(r.status_code, 302) review_req = reload_db_objects(review_req) self.assertEqual(review_req.state_id, "withdrawn") e = doc.latest_event() self.assertEqual(e.type, "changed_review_request") - self.assertTrue("Withdrew" in e.desc) + self.assertTrue("closed" in e.desc.lower()) self.assertEqual(len(outbox), 1) - self.assertTrue("withdrawn" in unicode(outbox[0])) + self.assertTrue("closed" in unicode(outbox[0]).lower()) def test_assign_reviewer(self): doc = make_test_data() diff --git a/ietf/doc/urls_review.py b/ietf/doc/urls_review.py index 89be3f732..0085d6add 100644 --- a/ietf/doc/urls_review.py +++ b/ietf/doc/urls_review.py @@ -4,7 +4,7 @@ from ietf.doc import views_review urlpatterns = patterns('', url(r'^$', views_review.request_review), url(r'^(?P[0-9]+)/$', views_review.review_request), - url(r'^(?P[0-9]+)/withdraw/$', views_review.withdraw_request), + url(r'^(?P[0-9]+)/close/$', views_review.close_request), url(r'^(?P[0-9]+)/assignreviewer/$', views_review.assign_reviewer), url(r'^(?P[0-9]+)/rejectreviewerassignment/$', views_review.reject_reviewer_assignment), url(r'^(?P[0-9]+)/complete/$', views_review.complete_review), diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 6d5fb1d0d..e6400516f 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -10,13 +10,14 @@ from django.core.exceptions import ValidationError from django.template.loader import render_to_string from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias -from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person +from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName from ietf.review.models import ReviewRequest -from ietf.person.fields import PersonEmailChoiceField +from ietf.person.fields import PersonEmailChoiceField, SearchablePersonField from ietf.review.utils import (active_review_teams, assign_review_request_to_reviewer, can_request_review_of_doc, can_manage_review_requests_for_team, - email_about_review_request, make_new_review_request_from_existing) + email_review_request_change, make_new_review_request_from_existing, + close_review_request_states, close_review_request) from ietf.review import mailarch from ietf.utils.fields import DatepickerDateField from ietf.utils.text import skip_prefix @@ -38,7 +39,7 @@ class RequestReviewForm(forms.ModelForm): class Meta: model = ReviewRequest - fields = ('type', 'team', 'deadline', 'requested_rev') + fields = ('requested_by', 'type', 'team', 'deadline', 'requested_rev') def __init__(self, user, doc, *args, **kwargs): super(RequestReviewForm, self).__init__(*args, **kwargs) @@ -57,6 +58,12 @@ class RequestReviewForm(forms.ModelForm): self.fields["deadline"].required = False self.fields["requested_rev"].label = "Document revision" + if has_role(user, "Secretariat"): + self.fields["requested_by"] = SearchablePersonField() + else: + self.fields["requested_by"].widget = forms.HiddenInput() + self.fields["requested_by"].initial = user.person.pk + def clean_deadline_date(self): v = self.cleaned_data.get('deadline_date') if v < datetime.date.today(): @@ -119,9 +126,9 @@ def review_request(request, name, 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_withdraw_request = (review_req.state_id in ["requested", "accepted"] - and (is_authorized_in_doc_stream(request.user, doc) - or can_manage_request)) + 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) @@ -147,47 +154,55 @@ def review_request(request, name, request_id): return render(request, 'doc/review/review_request.html', { 'doc': doc, 'review_req': review_req, - 'can_withdraw_request': can_withdraw_request, + '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 withdraw_request(request, name, request_id): +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"]) - if not is_authorized_in_doc_stream(request.user, doc): + 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" and request.POST.get("action") == "withdraw": - prev_state = review_req.state - review_req.state = ReviewRequestStateName.objects.get(slug="withdrawn") - review_req.save() - - DocEvent.objects.create( - type="changed_review_request", - doc=doc, - by=request.user.person, - desc="Withdrew request for {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()), - ) - - if prev_state.slug != "requested": - email_about_review_request( - request, review_req, - "Withdrew review request for %s" % review_req.doc.name, - "Review request has been withdrawn by %s." % request.user.person, - by=request.user.person, notify_secretary=False, notify_reviewer=True) + 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/withdraw_request.html', { + return render(request, 'doc/review/close_request.html', { 'doc': doc, 'review_req': review_req, + 'form': form, }) + class AssignReviewerForm(forms.Form): reviewer = PersonEmailChoiceField(widget=forms.RadioSelect, empty_label="(None)", required=False) @@ -266,7 +281,7 @@ def reject_reviewer_assignment(request, name, request_id): "message_to_secretary": form.cleaned_data.get("message_to_secretary") }) - email_about_review_request(request, review_req, "Reviewer assignment rejected", msg, by=request.user.person, notify_secretary=True, notify_reviewer=True) + 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: @@ -438,7 +453,7 @@ def complete_review(request, name, request_id): "new_review_req": new_review_req, }) - email_about_review_request(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False) + email_review_request_change(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=False) if review_submission != "link" and review_req.team.list_email: # email the review diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index 6dc5d5147..8420894b7 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse as urlreverse from ietf.utils.test_data import make_test_data, make_review_data from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects from ietf.review.models import ReviewRequest -from ietf.person.models import Email +from ietf.person.models import Email, Person import ietf.group.views_review class ReviewTests(TestCase): @@ -28,6 +28,7 @@ class ReviewTests(TestCase): deadline=datetime.datetime.combine(datetime.date.today() + datetime.timedelta(days=30), datetime.time(23, 59, 59)), state_id="accepted", reviewer=review_req1.reviewer, + requested_by=Person.objects.get(user__username="plain"), ) review_req3 = ReviewRequest.objects.create( @@ -36,6 +37,7 @@ class ReviewTests(TestCase): type_id="early", deadline=datetime.datetime.combine(datetime.date.today() + datetime.timedelta(days=30), datetime.time(23, 59, 59)), state_id="requested", + requested_by=Person.objects.get(user__username="plain"), ) # get diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index e46c45ae2..c634ddf22 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -4,10 +4,11 @@ from django.contrib.auth.decorators import login_required from django import forms from ietf.review.models import ReviewRequest, ReviewRequestStateName -from ietf.review.utils import (can_manage_review_requests_for_team, +from ietf.review.utils import (can_manage_review_requests_for_team, close_review_request_states, extract_revision_ordered_review_requests_for_documents, assign_review_request_to_reviewer, -# email_about_review_request, make_new_review_request_from_existing, + close_review_request, +# email_review_request_change, make_new_review_request_from_existing, suggested_review_requests_for_team) from ietf.group.utils import get_group_or_404 from ietf.person.fields import PersonEmailChoiceField @@ -21,14 +22,7 @@ class ManageReviewRequestForm(forms.Form): action = forms.ChoiceField(choices=ACTIONS, widget=forms.HiddenInput, required=False) - CLOSE_OPTIONS = [ - ("noreviewversion", "No review of this version"), - ("noreviewdocument", "No review of document"), - ("withdraw", "Withdraw request"), - ("no-response", "No response"), - ("overtaken", "Overtaken by events"), - ] - close = forms.ChoiceField(choices=CLOSE_OPTIONS, required=False) + close = forms.ModelChoiceField(queryset=close_review_request_states(), required=False) reviewer = PersonEmailChoiceField(empty_label="(None)", required=False, label_with="person") @@ -44,9 +38,9 @@ class ManageReviewRequestForm(forms.Form): close_initial = None if review_req.pk is None: if review_req.latest_reqs: - close_initial = "noreviewversion" + close_initial = "no-review-version" else: - close_initial = "noreviewdocument" + close_initial = "no-review-document" elif review_req.reviewer: close_initial = "no-response" else: @@ -55,6 +49,9 @@ class ManageReviewRequestForm(forms.Form): if close_initial: self.fields["close"].initial = close_initial + if review_req.pk is None: + self.fields["close"].queryset = self.fields["close"].queryset.filter(slug__in=["noreviewversion", "noreviewdocument"]) + self.fields["close"].widget.attrs["class"] = "form-control input-sm" self.fields["reviewer"].queryset = self.fields["reviewer"].queryset.filter( @@ -115,18 +112,13 @@ def manage_review_requests(request, acronym, group_type=None): form_results.append(req.form.is_valid()) if all(form_results): - for req in review_requests: - action = req.form.cleaned_data.get("action") + for review_req in review_requests: + action = review_req.form.cleaned_data.get("action") if action == "assign": - assign_review_request_to_reviewer(request, req, req.form.cleaned_data["reviewer"]) + assign_review_request_to_reviewer(request, review_req, review_req.form.cleaned_data["reviewer"]) elif action == "close": - close_reason = req.form.cleaned_data["close"] - if close_reason in ("withdraw", "no-response", "overtaken"): - req.state = ReviewRequestStateName.objects.get(slug=close_reason, used=True) - req.save() - # FIXME: notify? - else: - FIXME + close_review_request(request, review_req, review_req.form.cleaned_data["close"]) + kwargs = { "acronym": group.acronym } if group_type: diff --git a/ietf/name/migrations/0012_insert_review_name_data.py b/ietf/name/migrations/0012_insert_review_name_data.py index 92da06b94..13cc20786 100644 --- a/ietf/name/migrations/0012_insert_review_name_data.py +++ b/ietf/name/migrations/0012_insert_review_name_data.py @@ -12,8 +12,10 @@ def insert_initial_review_data(apps, schema_editor): 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="part-completed", name="Partially Completed", order=7) - ReviewRequestStateName.objects.get_or_create(slug="completed", name="Completed", order=8) + ReviewRequestStateName.objects.get_or_create(slug="no-review-version", name="No Review of Version", order=7) + ReviewRequestStateName.objects.get_or_create(slug="no-review-document", name="No Review of 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) diff --git a/ietf/name/models.py b/ietf/name/models.py index 58b861326..4fc9dc720 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -89,7 +89,7 @@ class LiaisonStatementTagName(NameModel): "Action Required, Action Taken" class ReviewRequestStateName(NameModel): """Requested, Accepted, Rejected, Withdrawn, Overtaken By Events, - No Response, Partially Completed, Completed""" + No Response, No Review of Version, No Review of Document, Partially Completed, Completed""" class ReviewTypeName(NameModel): """Early Review, Last Call, Telechat""" class ReviewResultName(NameModel): diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 325f6e06b..ec6a4e422 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -161,6 +161,8 @@ with db_con.cursor() as 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;") @@ -207,6 +209,7 @@ with db_con.cursor() as c: "state": states["requested"], "type": type_name, "deadline": deadline, + "requested_by": system_person, } ) diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index cb85178c2..a78220cc0 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -42,6 +42,7 @@ class Migration(migrations.Migration): ('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)), + ('requested_by', models.ForeignKey(to='person.Person')), ('state', models.ForeignKey(to='name.ReviewRequestStateName')), ('team', models.ForeignKey(to='group.Group')), ('type', models.ForeignKey(to='name.ReviewTypeName')), diff --git a/ietf/review/models.py b/ietf/review/models.py index 589899b91..7f39f96c3 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -42,6 +42,7 @@ class ReviewRequest(models.Model): doc = models.ForeignKey(Document, related_name='review_request_set') team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewteamresult=None)) deadline = models.DateTimeField() + 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 diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 911b6390b..43611cea4 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -6,6 +6,7 @@ from django.contrib.sites.models import Site from ietf.group.models import Group, Role from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent from ietf.iesg.models import TelechatDate +from ietf.person.models import Person from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewTypeName from ietf.utils.mail import send_mail @@ -15,6 +16,9 @@ def active_review_teams(): # if there's a ReviewTeamResult defined, it's a review team return Group.objects.filter(state="active").exclude(reviewteamresult=None) +def close_review_request_states(): + return ReviewRequestStateName.objects.filter(used=True).exclude(slug__in=["requested", "accepted", "rejected", "part-completed", "completed"]) + def can_request_review_of_doc(user, doc): if not user.is_authenticated(): return False @@ -35,30 +39,44 @@ def make_new_review_request_from_existing(review_req): 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_about_review_request(request, review_req, subject, msg, by, notify_secretary, notify_reviewer): +def email_review_request_change(request, review_req, subject, msg, by, notify_secretary, notify_reviewer, notify_requested_by): """Notify possibly both secretary and reviewer about change, skipping a party if the change was done by that party.""" - def extract_email_addresses(objs): - if any(o.person == by for o in objs if o): - return [] - else: - return [o.formatted_email() for o in objs if o] + system_email = Person.objects.get(name="(System)").formatted_email() to = [] - if notify_secretary: - to += extract_email_addresses(Role.objects.filter(name__in=["secretary", "delegate"], group=review_req.team).distinct()) - if notify_reviewer: - to += extract_email_addresses([review_req.reviewer]) + def extract_email_addresses(objs): + if any(o.person == by for o in objs if o): + l = [] + else: + l = [] + for o in objs: + if o: + e = o.formatted_email() + if e != system_email: + l.append(e) + for e in l: + if e not in to: + to.append(e) + + if notify_secretary: + extract_email_addresses(Role.objects.filter(name__in=["secretary", "delegate"], group=review_req.team).distinct()) + if notify_reviewer: + extract_email_addresses([review_req.reviewer]) + if notify_requested_by: + extract_email_addresses([review_req.requested_by.email()]) + if not to: return - send_mail(request, list(set(to)), None, subject, "doc/mail/review_request_changed.txt", { + send_mail(request, to, None, subject, "doc/mail/review_request_changed.txt", { "domain": Site.objects.get_current().domain, "review_req": review_req, "msg": msg, @@ -71,11 +89,11 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): return if review_req.reviewer: - email_about_review_request( + 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) + 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 @@ -92,11 +110,36 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): ), ) - email_about_review_request( + email_review_request_change( request, review_req, "Assigned to review of %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) + by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=False) + +def close_review_request(request, review_req, close_state): + suggested_req = review_req.pk is None + + prev_state = review_req.state + review_req.state = close_state + if close_state.slug == "no-review-version": + review_req.reviewed_rev = review_req.doc.rev # save rev for later reference + review_req.save() + + if not suggested_req: + DocEvent.objects.create( + type="changed_review_request", + doc=review_req.doc, + by=request.user.person, + desc="Closed request for {} review by {} with state '{}'".format( + review_req.type.name, review_req.team.acronym.upper(), close_state.name), + ) + + if prev_state.slug != "requested": + email_review_request_change( + request, review_req, + "Closed review request for {}: {}".format(review_req.doc.name, close_state.name), + "Review request has been closed by {}.".format(request.user.person), + by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=True) def suggested_review_requests_for_team(team): def fixup_deadline(d): @@ -104,11 +147,13 @@ def suggested_review_requests_for_team(team): d = d - datetime.timedelta(seconds=1) # 23:59:59 is treated specially in the view code return d + system_person = Person.objects.get(name="(System)") + seen_deadlines = {} requests = {} - if True: + if True: # FIXME # in Last Call last_call_type = ReviewTypeName.objects.get(slug="lc") last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True)) @@ -125,12 +170,13 @@ def suggested_review_requests_for_team(team): doc=doc, team=team, deadline=deadline, + requested_by=system_person, ) seen_deadlines[doc.pk] = deadline - if True: + if True: # FIXME # on Telechat Agenda telechat_dates = list(TelechatDate.objects.active().order_by('date').values_list("date", flat=True)[:4]) @@ -153,6 +199,7 @@ def suggested_review_requests_for_team(team): doc=doc, team=team, deadline=deadline, + requested_by=system_person, ) seen_deadlines[doc.pk] = deadline diff --git a/ietf/templates/doc/review/withdraw_request.html b/ietf/templates/doc/review/close_request.html similarity index 56% rename from ietf/templates/doc/review/withdraw_request.html rename to ietf/templates/doc/review/close_request.html index 191f236dc..2413cf22f 100644 --- a/ietf/templates/doc/review/withdraw_request.html +++ b/ietf/templates/doc/review/close_request.html @@ -2,20 +2,22 @@ {# Copyright The IETF Trust 2016, All Rights Reserved #} {% load origin bootstrap3 static %} -{% block title %}Withdraw review request for {{ review_req.doc.name }}{% endblock %} +{% block title %}Close review request for {{ review_req.doc.name }}{% endblock %} {% block content %} {% origin %} -

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

+

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

-

Do you want to withdraw the review request?

+

Do you want to close the review request?

{% csrf_token %} + {% bootstrap_form form %} + {% buttons %} Cancel - + {% endbuttons %}
diff --git a/ietf/templates/doc/review/request_review.html b/ietf/templates/doc/review/request_review.html index aa1700aab..0a9d06b1d 100644 --- a/ietf/templates/doc/review/request_review.html +++ b/ietf/templates/doc/review/request_review.html @@ -3,6 +3,8 @@ {% load origin bootstrap3 static %} {% block pagehead %} + + {% endblock %} @@ -16,6 +18,7 @@
{% 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_date layout="horizontal" %} @@ -31,4 +34,6 @@ {% block js %} + + {% endblock %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index 3c9826b37..77ce5b1f7 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -51,6 +51,12 @@ Requested {{ review_req.time|date:"Y-m-d" }} + + + + Requested by + {{ review_req.requested_by }} + @@ -143,8 +149,8 @@
- {% if can_withdraw_request %} - Withdraw request + {% if can_close_request %} + Close request {% endif %}
diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index d19be0f93..d2ed4e20a 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -375,6 +375,7 @@ def make_review_data(doc): type_id="early", deadline=datetime.datetime.now() + datetime.timedelta(days=20), state_id="accepted", + requested_by=p, reviewer=email, ) From 4542b26f1d8ef31040ae7d3f86d98e0627b9993c Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 6 Jul 2016 11:23:47 +0000 Subject: [PATCH 23/90] Don't show requested by for a review request if nobody requested it - Legacy-Id: 11527 --- ietf/templates/doc/review/review_request.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index 77ce5b1f7..5ace6a717 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -52,11 +52,13 @@ {{ review_req.time|date:"Y-m-d" }} - - - Requested by - {{ review_req.requested_by }} - + {% if review_req.requested_by.name != "(System)" %} + + + Requested by + {{ review_req.requested_by }} + + {% endif %} From d54459b28add7a4671455a334da61af798690dc0 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 6 Jul 2016 13:14:11 +0000 Subject: [PATCH 24/90] Add tests of suggested review requests, make suggestions take more things into account - Legacy-Id: 11528 --- ietf/group/tests_review.py | 64 ++++++++++++++++++++++++++++++++++- ietf/group/views_review.py | 7 ++-- ietf/name/fixtures/names.json | 22 +++++++++++- ietf/review/utils.py | 20 ++++++++--- 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index 8420894b7..90b2fe2e2 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -6,11 +6,73 @@ from django.core.urlresolvers import reverse as urlreverse from ietf.utils.test_data import make_test_data, make_review_data from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects -from ietf.review.models import ReviewRequest +from ietf.review.models import ReviewRequest, ReviewRequestStateName +from ietf.doc.models import TelechatDocEvent +from ietf.iesg.models import TelechatDate from ietf.person.models import Email, Person +from ietf.review.utils import suggested_review_requests_for_team import ietf.group.views_review class ReviewTests(TestCase): + def test_suggested_review_requests(self): + doc = make_test_data() + review_req = make_review_data(doc) + team = review_req.team + + # put on telechat + 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() + + prev_rev = "{:02}".format(int(doc.rev) - 1) + + # blocked by existing request + review_req.requested_rev = "" + review_req.save() + + self.assertEqual(len(suggested_review_requests_for_team(team)), 0) + + # ... but not to previous version + review_req.requested_rev = prev_rev + review_req.save() + suggestions = suggested_review_requests_for_team(team) + self.assertEqual(len(suggestions), 1) + self.assertEqual(suggestions[0].doc, doc) + self.assertEqual(suggestions[0].team, team) + + # blocked by non-versioned refusal + review_req.requested_rev = "" + review_req.state = ReviewRequestStateName.objects.get(slug="no-review-document") + review_req.save() + + self.assertEqual(list(suggested_review_requests_for_team(team)), []) + + # blocked by versioned refusal + review_req.reviewed_rev = doc.rev + review_req.state = ReviewRequestStateName.objects.get(slug="no-review-document") + review_req.save() + + self.assertEqual(list(suggested_review_requests_for_team(team)), []) + + # blocked by completion + review_req.state = ReviewRequestStateName.objects.get(slug="completed") + review_req.save() + + self.assertEqual(list(suggested_review_requests_for_team(team)), []) + + # ... but not to previous version + review_req.reviewed_rev = prev_rev + review_req.state = ReviewRequestStateName.objects.get(slug="completed") + review_req.save() + + self.assertEqual(len(suggested_review_requests_for_team(team)), 1) + + def test_manage_review_requests(self): doc = make_test_data() review_req1 = make_review_data(doc) diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index c634ddf22..e5e324e03 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -3,7 +3,7 @@ from django.http import Http404, HttpResponseForbidden from django.contrib.auth.decorators import login_required from django import forms -from ietf.review.models import ReviewRequest, ReviewRequestStateName +from ietf.review.models import ReviewRequest from ietf.review.utils import (can_manage_review_requests_for_team, close_review_request_states, extract_revision_ordered_review_requests_for_documents, assign_review_request_to_reviewer, @@ -62,11 +62,8 @@ class ManageReviewRequestForm(forms.Form): self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm" if self.is_bound: - action = self.data.get("action") - if action == "close": + if self.data.get("action") == "close": self.fields["close"].required = True - elif action == "assign": - self.fields["reviewer"].required = True @login_required diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 72837bf88..dd67211af 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -1827,6 +1827,26 @@ "fields": { "order": 7, "used": true, + "name": "No Review of Version", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "no-review-version" +}, +{ + "fields": { + "order": 8, + "used": true, + "name": "No Review of Document", + "desc": "" + }, + "model": "name.reviewrequeststatename", + "pk": "no-review-document" +}, +{ + "fields": { + "order": 9, + "used": true, "name": "Partially Completed", "desc": "" }, @@ -1835,7 +1855,7 @@ }, { "fields": { - "order": 8, + "order": 10, "used": true, "name": "Completed", "desc": "" diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 43611cea4..411eea322 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -153,6 +153,8 @@ def suggested_review_requests_for_team(team): requests = {} + requested_state = ReviewRequestStateName.objects.get(slug="requested", used=True) + if True: # FIXME # in Last Call last_call_type = ReviewTypeName.objects.get(slug="lc") @@ -171,6 +173,7 @@ def suggested_review_requests_for_team(team): team=team, deadline=deadline, requested_by=system_person, + state=requested_state, ) seen_deadlines[doc.pk] = deadline @@ -200,6 +203,7 @@ def suggested_review_requests_for_team(team): team=team, deadline=deadline, requested_by=system_person, + state=requested_state, ) seen_deadlines[doc.pk] = deadline @@ -210,11 +214,19 @@ def suggested_review_requests_for_team(team): existing_requests[r.doc_id].append(r) def blocks(existing, request): - return (existing.doc_id == request.doc_id - and existing.reviewed_rev == request.doc.rev - and existing.state_id not in ("part-completed", "rejected", "overtaken")) + if existing.doc_id != request.doc_id: + return False - res = [r for r in requests.itervalues() if not any(blocks(e, r) for e in existing_requests[r.doc_id])] + 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)) return res From 7aff7d3e72685a4630d6b2219c5cd1ad2c267389 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 6 Jul 2016 13:27:41 +0000 Subject: [PATCH 25/90] Fix some bugs in retrieving review requests for replacement documents - Legacy-Id: 11529 --- ietf/review/utils.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 411eea322..91df63d8f 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -245,13 +245,23 @@ def extract_revision_ordered_review_requests_for_documents(queryset, 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) @@ -260,6 +270,6 @@ def extract_revision_ordered_review_requests_for_documents(queryset, names): res[name].extend(reqs) # move one level down - front = [n for l in requests_for_each_doc.get(replaces_name, []) for n in l] + front = next_front return res From 5c8be91b08ab85f2c386f733bc8e18f3cac9ffaa Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 7 Jul 2016 12:17:55 +0000 Subject: [PATCH 26/90] Improve review assignment slightly by sorting reviewers by latest review - still missing a bunch of factors, and unassignment is now temporarily gone - Legacy-Id: 11531 --- ietf/doc/views_review.py | 5 +++- ietf/group/views_review.py | 5 ++-- ietf/review/models.py | 9 +++++- ietf/review/utils.py | 60 +++++++++++++++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index e6400516f..b80219d49 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -17,7 +17,8 @@ from ietf.person.fields import PersonEmailChoiceField, SearchablePersonField 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) + close_review_request_states, close_review_request, + construct_review_request_assignment_choices) from ietf.review import mailarch from ietf.utils.fields import DatepickerDateField from ietf.utils.text import skip_prefix @@ -212,6 +213,8 @@ class AssignReviewerForm(forms.Form): f.queryset = f.queryset.filter(role__name="reviewer", role__group=review_req.team) if review_req.reviewer: f.initial = review_req.reviewer_id + f.choices = construct_review_request_assignment_choices(f.queryset, review_req.team, review_req) + @login_required def assign_reviewer(request, name, request_id): diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index e5e324e03..4bb51d387 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -8,7 +8,8 @@ from ietf.review.utils import (can_manage_review_requests_for_team, close_review extract_revision_ordered_review_requests_for_documents, assign_review_request_to_reviewer, close_review_request, -# email_review_request_change, make_new_review_request_from_existing, + construct_review_request_assignment_choices, +# make_new_review_request_from_existing, suggested_review_requests_for_team) from ietf.group.utils import get_group_or_404 from ietf.person.fields import PersonEmailChoiceField @@ -58,7 +59,7 @@ class ManageReviewRequestForm(forms.Form): role__name="reviewer", role__group=review_req.team, ) - + self.fields["reviewer"].choices = construct_review_request_assignment_choices(self.fields["reviewer"].queryset, review_req.team, review_req) self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm" if self.is_bound: diff --git a/ietf/review/models.py b/ietf/review/models.py index 7f39f96c3..f6fbc632e 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -11,7 +11,14 @@ class Reviewer(models.Model): reviewer and team.""" team = models.ForeignKey(Group) person = models.ForeignKey(Person) - frequency = models.IntegerField(default=30, help_text="Can review every N days") + FREQUENCIES = [ + (7, "Once per week"), + (14, "Once per fortnight"), + (30, "Once per month"), + (60, "Once per two months"), + (90, "Once per quarter"), + ] + frequency = models.IntegerField(default=30, help_text="Can review every N days", choices=FREQUENCIES) unavailable_until = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again") filter_re = models.CharField(max_length=255, blank=True) skip_next = models.IntegerField(default=0, help_text="Skip the next N review assignments") diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 91df63d8f..b27a13cf8 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -2,13 +2,14 @@ import datetime from collections import defaultdict from django.contrib.sites.models import Site +from django.db import models from ietf.group.models import Group, Role from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent from ietf.iesg.models import TelechatDate from ietf.person.models import Person from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream -from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewTypeName +from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewTypeName, Reviewer from ietf.utils.mail import send_mail from ietf.doc.utils import extract_complete_replaces_ancestor_mapping_for_docs @@ -273,3 +274,60 @@ def extract_revision_ordered_review_requests_for_documents(queryset, names): front = next_front return res + +def construct_review_request_assignment_choices(possible_emails, team, review_req=None): + possible_emails = list(possible_emails) + + reviewers = { r.person_id: r for r in Reviewer.objects.filter(team=team, person__in=[e.person_id for e in possible_emails]) } + + latest_assignment_for_reviewer = dict(ReviewRequest.objects.filter( + reviewer__in=possible_emails, + ).values_list("reviewer").annotate(models.Max("time"))) + + now = datetime.datetime.now() + + rankings = [] + for e in possible_emails: + reviewer = reviewers.get(e.person_id) + if not reviewer: + reviewer = Reviewer() + + days_past = None + latest = latest_assignment_for_reviewer.get(e.pk) + if latest is not None: + days_past = (now - latest).days - reviewer.frequency + + # FIXME: + # positive: (Perhaps do these separately? As initial values?) + # has done review of previous rev + # would like to review + + # blocks: + # connections to doc + filter_re + # has rejected same request/completed partial review + # is unavailable_until + + if days_past is None: + ready_for = "first time" + else: + d = int(round(days_past)) + if d > 0: + ready_for = "ready for {} {}".format(d, "day" if d == 1 else "days") + else: + d = -d + ready_for = "frequency exceeded - ready in {} {}".format(d, "day" if d == 1 else "days") + + label = "{}: {}".format(e.person, ready_for) + + rank = (-100000 if days_past is None else -days_past,) + + rankings.append({ + "email": e, + "rank": rank, + "label": label, + }) + + rankings.sort(key=lambda r: r["rank"]) + + # FIXME: empty choices + return [(r["email"].pk, r["label"]) for r in rankings] From db4ea01e20c3f8b02dc937cf91a5e20047a58d2d Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 13:04:14 +0000 Subject: [PATCH 27/90] Take a bunch of factors into account when sorting reviewers for assignment to a review request - Legacy-Id: 11626 --- ietf/doc/tests_review.py | 41 +++++++++++++++- ietf/doc/views_review.py | 10 ++-- ietf/group/views_review.py | 8 +--- ietf/review/models.py | 4 +- ietf/review/utils.py | 97 ++++++++++++++++++++++++++++---------- 5 files changed, 120 insertions(+), 40 deletions(-) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index a19717633..00997d730 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -12,10 +12,11 @@ from pyquery import PyQuery import debug # pyflakes:ignore -from ietf.review.models import ReviewRequest, ReviewTeamResult +from ietf.review.models import ReviewRequest, ReviewTeamResult, Reviewer import ietf.review.mailarch from ietf.person.models import Email, Person -from ietf.name.models import ReviewResultName, ReviewRequestStateName +from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName +from ietf.doc.models import DocumentAuthor from ietf.utils.test_utils import TestCase from ietf.utils.test_data import make_test_data, make_review_data from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects @@ -120,11 +121,40 @@ class ReviewTests(TestCase): 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() + + # 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.datetime.now() - datetime.timedelta(days=80), + reviewer=plain_email, + ) + + reviewer_obj = Reviewer.objects.get(person__email=plain_email) + reviewer_obj.filter_re = doc.name + reviewer_obj.unavailable_until = datetime.datetime.now() + datetime.timedelta(days=10) + reviewer_obj.save() + assign_url = urlreverse('ietf.doc.views_review.assign_reviewer', kwargs={ "name": doc.name, "request_id": review_req.pk }) @@ -140,6 +170,13 @@ class ReviewTests(TestCase): login_testing_unauthorized(self, "secretary", assign_url) r = self.client.get(assign_url) self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + plain_label = q("option[value=\"{}\"]".format(plain_email.address)).text().lower() + self.assertIn("ready for", plain_label) + self.assertIn("reviewed document before", plain_label) + self.assertIn("is author", plain_label) + self.assertIn("regexp matches", plain_label) + self.assertIn("unavailable until", plain_label) # assign empty_outbox() diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index b80219d49..8f5a498ed 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -18,7 +18,7 @@ from ietf.review.utils import (active_review_teams, assign_review_request_to_rev 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, - construct_review_request_assignment_choices) + setup_reviewer_field) from ietf.review import mailarch from ietf.utils.fields import DatepickerDateField from ietf.utils.text import skip_prefix @@ -205,15 +205,11 @@ def close_request(request, name, request_id): class AssignReviewerForm(forms.Form): - reviewer = PersonEmailChoiceField(widget=forms.RadioSelect, empty_label="(None)", required=False) + reviewer = PersonEmailChoiceField(empty_label="(None)", required=False) def __init__(self, review_req, *args, **kwargs): super(AssignReviewerForm, self).__init__(*args, **kwargs) - f = self.fields["reviewer"] - f.queryset = f.queryset.filter(role__name="reviewer", role__group=review_req.team) - if review_req.reviewer: - f.initial = review_req.reviewer_id - f.choices = construct_review_request_assignment_choices(f.queryset, review_req.team, review_req) + setup_reviewer_field(self.fields["reviewer"], review_req) @login_required diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index 4bb51d387..c60f1d839 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -8,7 +8,7 @@ from ietf.review.utils import (can_manage_review_requests_for_team, close_review extract_revision_ordered_review_requests_for_documents, assign_review_request_to_reviewer, close_review_request, - construct_review_request_assignment_choices, + setup_reviewer_field, # make_new_review_request_from_existing, suggested_review_requests_for_team) from ietf.group.utils import get_group_or_404 @@ -55,11 +55,7 @@ class ManageReviewRequestForm(forms.Form): self.fields["close"].widget.attrs["class"] = "form-control input-sm" - self.fields["reviewer"].queryset = self.fields["reviewer"].queryset.filter( - role__name="reviewer", - role__group=review_req.team, - ) - self.fields["reviewer"].choices = construct_review_request_assignment_choices(self.fields["reviewer"].queryset, review_req.team, review_req) + setup_reviewer_field(self.fields["reviewer"], review_req) self.fields["reviewer"].widget.attrs["class"] = "form-control input-sm" if self.is_bound: diff --git a/ietf/review/models.py b/ietf/review/models.py index f6fbc632e..15ff18b9b 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -1,3 +1,5 @@ +import datetime + from django.db import models from ietf.doc.models import Document @@ -44,7 +46,7 @@ class ReviewRequest(models.Model): # Fields filled in on the initial record creation - these # constitute the request part. - time = models.DateTimeField(auto_now_add=True) + time = models.DateTimeField(default=datetime.datetime.now) type = models.ForeignKey(ReviewTypeName) doc = models.ForeignKey(Document, related_name='review_request_set') team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewteamresult=None)) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index b27a13cf8..a5764561f 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -1,13 +1,13 @@ -import datetime +import datetime, re from collections import defaultdict from django.contrib.sites.models import Site from django.db import models from ietf.group.models import Group, Role -from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent +from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent, DocumentAuthor, DocAlias from ietf.iesg.models import TelechatDate -from ietf.person.models import Person +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, Reviewer from ietf.utils.mail import send_mail @@ -275,38 +275,80 @@ def extract_revision_ordered_review_requests_for_documents(queryset, names): return res -def construct_review_request_assignment_choices(possible_emails, team, review_req=None): - possible_emails = list(possible_emails) +def setup_reviewer_field(field, review_req): + field.queryset = field.queryset.filter(role__name="reviewer", role__group=review_req.team) + if review_req.reviewer: + field.initial = review_req.reviewer_id + + choices = make_assignment_choices(field.queryset, review_req) + if not field.required: + choices = [("", field.empty_label)] + choices + + field.choices = choices + +def make_assignment_choices(email_queryset, review_req): + doc = review_req.doc + team = review_req.team + + possible_emails = list(email_queryset) + + aliases = DocAlias.objects.filter(document=doc).values_list("name", flat=True) reviewers = { r.person_id: r for r in Reviewer.objects.filter(team=team, person__in=[e.person_id for e in possible_emails]) } + # time since past assignment latest_assignment_for_reviewer = dict(ReviewRequest.objects.filter( reviewer__in=possible_emails, ).values_list("reviewer").annotate(models.Max("time"))) + # previous review of document + has_reviewed_previous = ReviewRequest.objects.filter( + doc=doc, + reviewer__in=possible_emails, + state="completed", + ) + + if review_req.pk is not None: + has_reviewed_previous = has_reviewed_previous.exclude(pk=review_req.pk) + + has_reviewed_previous = set(has_reviewed_previous.values_list("reviewer", flat=True)) + + # review indications + would_like_to_review = set() # FIXME: fill in + + # connections + connections = {} + # examine the closest connections last to let them override + for e in Email.objects.filter(pk__in=possible_emails, person=doc.ad_id): + connections[e] = "is associated Area Director" + for r in Role.objects.filter(group=doc.group_id, email__in=possible_emails).select_related("name"): + connections[r.email_id] = "is group {}".format(r.name) + if doc.shepherd_id: + connections[doc.shepherd_id] = "is shepherd of document" + for e in DocumentAuthor.objects.filter(document=doc, author__in=possible_emails).values_list("author", flat=True): + connections[e] = "is author of document" + now = datetime.datetime.now() - rankings = [] + def add_boolean_score(scores, direction, expr, expl): + scores.append(int(bool(expr)) * direction) + if expr: + explanations.append(expl) + + ranking = [] for e in possible_emails: reviewer = reviewers.get(e.person_id) if not reviewer: reviewer = Reviewer() + explanations = [] + scores = [] # build up score in separate independent components + days_past = None latest = latest_assignment_for_reviewer.get(e.pk) if latest is not None: days_past = (now - latest).days - reviewer.frequency - # FIXME: - # positive: (Perhaps do these separately? As initial values?) - # has done review of previous rev - # would like to review - - # blocks: - # connections to doc + filter_re - # has rejected same request/completed partial review - # is unavailable_until - if days_past is None: ready_for = "first time" else: @@ -315,19 +357,26 @@ def construct_review_request_assignment_choices(possible_emails, team, review_re ready_for = "ready for {} {}".format(d, "day" if d == 1 else "days") else: d = -d - ready_for = "frequency exceeded - ready in {} {}".format(d, "day" if d == 1 else "days") + ready_for = "frequency exceeded, ready in {} {}".format(d, "day" if d == 1 else "days") - label = "{}: {}".format(e.person, ready_for) + explanations.append(ready_for) - rank = (-100000 if days_past is None else -days_past,) + add_boolean_score(scores, +1, e.pk in has_reviewed_previous, "reviewed document before") + add_boolean_score(scores, +1, e.pk in would_like_to_review, "wants to review document") + add_boolean_score(scores, -1, e.pk in connections, connections.get(e.pk)) + add_boolean_score(scores, -1, reviewer.filter_re and any(re.search(reviewer.filter_re, n) for n in aliases), "filter regexp matches") + add_boolean_score(scores, -1, reviewer.unavailable_until and reviewer.unavailable_until > now, "unavailable until {}".format((reviewer.unavailable_until or now).strftime("%Y-%m-%d %H:%M:%S"))) - rankings.append({ + scores.append(100000 if days_past is None else days_past) + + label = "{}: {}".format(e.person, "; ".join(explanations)) + + ranking.append({ "email": e, - "rank": rank, + "scores": scores, "label": label, }) - rankings.sort(key=lambda r: r["rank"]) + ranking.sort(key=lambda r: r["scores"], reverse=True) - # FIXME: empty choices - return [(r["email"].pk, r["label"]) for r in rankings] + return [(r["email"].pk, r["label"]) for r in ranking] From 392eb4fa283527ee5fc60864bddf1bc71974f402 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 13:06:29 +0000 Subject: [PATCH 28/90] Delete stale code that was causing a Javascript bug - Legacy-Id: 11627 --- ietf/templates/doc/review/complete_review.html | 1 - 1 file changed, 1 deletion(-) diff --git a/ietf/templates/doc/review/complete_review.html b/ietf/templates/doc/review/complete_review.html index 3dca99e6a..4edb4fa17 100644 --- a/ietf/templates/doc/review/complete_review.html +++ b/ietf/templates/doc/review/complete_review.html @@ -74,7 +74,6 @@ {% block js %} From 5b191285d69038f98e77bdd3568948f64e4d073b Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 13:42:19 +0000 Subject: [PATCH 29/90] Fix role misspelling that prevented review team secretaries from accessing protected pages - Legacy-Id: 11628 --- ietf/review/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index a5764561f..b9c8801e2 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -30,7 +30,7 @@ def can_manage_review_requests_for_team(user, team): if not user.is_authenticated(): return False - return Role.objects.filter(name__in=["secretary", "delegate"], person__user=user, group=team).exists() or has_role(user, "Secretariat") + return Role.objects.filter(name__in=["secr", "delegate"], person__user=user, group=team).exists() or has_role(user, "Secretariat") def make_new_review_request_from_existing(review_req): obj = ReviewRequest() From c3c1911b473df1d2a4f62495556a4ab5e1422326 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 14:29:19 +0000 Subject: [PATCH 30/90] Some fixes for review importer: interpretation of autopolicy and skip all review requests without a timestamp - Legacy-Id: 11629 --- ietf/review/import_from_review_tool.py | 33 ++++++++++++++++---------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index ec6a4e422..52a5dee33 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -55,7 +55,15 @@ with db_con.cursor() as c: 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;") @@ -72,7 +80,7 @@ with db_con.cursor() as c: if not person: person, created = Person.objects.get_or_create(name=row.name, ascii=unidecode(row.name)) if created: - print "created person", person + 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 @@ -88,31 +96,32 @@ with db_con.cursor() as c: 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", role + 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 - assert not row.autopolicy or row.autopolicy == "monthly" - 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", role + print "created role", unicode(role).encode("utf-8") reviewer, created = Reviewer.objects.get_or_create( team=team, person=email.person, ) if reviewer: - print "created reviewer", reviewer + print "created reviewer", reviewer.pk, unicode(reviewer).encode("utf-8") - if row.autopolicy == "monthly": - reviewer.frequency = 30 + if autopolicy_days.get(row.autopolicy): + reviewer.frequency = autopolicy_days.get(row.autopolicy) reviewer.unavailable_until = parse_timestamp(row.available) reviewer.filter_re = row.donotassign + try: + reviewer.skip_next = int(row.autopolicy) + except ValueError: + pass reviewer.save() - # review requests # check that we got the needed names @@ -191,9 +200,9 @@ with db_con.cursor() as c: else: time = deadline - if not deadline and row.docstatus == "assigned": + if not deadline: # bogus row - print "SKIPPING WITH NO DEADLINE", time, row + print "SKIPPING WITH NO DEADLINE", time, row, meta continue if status == "done" and row.docstatus in ("assigned", "accepted"): From a5980ef75b6a256f7fb390264d4cee7991b371b1 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 14:30:05 +0000 Subject: [PATCH 31/90] Fix unicode format bug, adjust Reviewer.FREQUENCIES days to better match those from the existing tool - Legacy-Id: 11630 --- ietf/review/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ietf/review/models.py b/ietf/review/models.py index 15ff18b9b..839edcdb7 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -17,8 +17,8 @@ class Reviewer(models.Model): (7, "Once per week"), (14, "Once per fortnight"), (30, "Once per month"), - (60, "Once per two months"), - (90, "Once per quarter"), + (61, "Once per two months"), + (91, "Once per quarter"), ] frequency = models.IntegerField(default=30, help_text="Can review every N days", choices=FREQUENCIES) unavailable_until = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again") @@ -26,7 +26,7 @@ class Reviewer(models.Model): skip_next = models.IntegerField(default=0, help_text="Skip the next N review assignments") def __unicode__(self): - return "{} in {}".format(self.person, self.team) + return u"{} in {}".format(self.person, self.team) class ReviewTeamResult(models.Model): """Captures that a result name is valid for a given team for new From 5fc023e8aa8d5195fa9fe7cac69213fe7981b074 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 14:41:30 +0000 Subject: [PATCH 32/90] Clarify which specific revision a review request is for - Legacy-Id: 11631 --- ietf/templates/doc/review/review_request.html | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index 5ace6a717..1e182c83a 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -15,13 +15,26 @@ Review of {% if review_req.requested_rev %} - {{ review_req.doc.name }}-{{ review_req.requested_rev }} + {{ review_req.doc.name }}-{{ review_req.requested_rev }} {% else %} {{ review_req.doc.name }} {% endif %} + + + Requested rev. + + {% if review_req.requested_rev %} + {{ review_req.requested_rev }} + {% else %} + no specific revision + {% endif %} + {% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %} + + + Type @@ -136,7 +149,7 @@ Reviewed rev. - {{ review_req.reviewed_rev }} {% if review_req.reviewed_rev != review_req.doc.rev %}(currently at {{ review_req.doc.rev }}){% endif %} + {{ review_req.reviewed_rev }} {% if review_req.reviewed_rev != review_req.doc.rev %}(document currently at {{ review_req.doc.rev }}){% endif %} {% endif %} From e7ee08069e38b8ad981cfd28baabacd7dfa87803 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 15:06:49 +0000 Subject: [PATCH 33/90] Swap the sort order on the displayed revisions when completing a review - Legacy-Id: 11632 --- ietf/doc/views_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 8f5a498ed..feb187527 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -314,7 +314,7 @@ class CompleteReviewForm(forms.Form): doc = self.review_req.doc - known_revisions = NewRevisionDocEvent.objects.filter(doc=doc).order_by("-time").values_list("rev", flat=True) + 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) From cd1250b22d987cb296283b7f1c91084f4cb8572c Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 15:33:24 +0000 Subject: [PATCH 34/90] Be more specific about the revision and deadline in the review request email - Legacy-Id: 11633 --- ietf/review/utils.py | 2 +- ietf/templates/doc/mail/review_request_changed.txt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index b9c8801e2..5b6e79e0a 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -113,7 +113,7 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): email_review_request_change( request, review_req, - "Assigned to review of %s" % review_req.doc.name, + "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) diff --git a/ietf/templates/doc/mail/review_request_changed.txt b/ietf/templates/doc/mail/review_request_changed.txt index 5c8f36d51..4fb70ea9f 100644 --- a/ietf/templates/doc/mail/review_request_changed.txt +++ b/ietf/templates/doc/mail/review_request_changed.txt @@ -1,5 +1,7 @@ {% autoescape off %} - {{ review_req.type.name }} review of: {{ review_req.doc.name }}{% if review_req.requested_rev %}-{{ review_req.requested_rev }}{% endif %} + {{ 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: {% if review_req.deadline|date:"H:i" != "23:59" %}{{ review_req.deadline|date:"Y-m-d H:i" }}{% else %}{{ review_req.deadline|date:"Y-m-d" }}{% endif %} + https://{{ domain }}{% url "ietf.doc.views_review.review_request" name=review_req.doc.name request_id=review_req.pk %} {{ msg|wordwrap:72 }} From 562d7da312681131867b74eac8b5e7ebc4e418c1 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 15:34:51 +0000 Subject: [PATCH 35/90] Fix accepted wording - Legacy-Id: 11634 --- ietf/templates/group/manage_review_requests.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index 9e13bb04f..5b9af4042 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -56,7 +56,7 @@ {% if r.reviewer %} - + {% else %} {% endif %} @@ -75,7 +75,7 @@ - + {% spaceless %} From 308b854d6654c66cac99b2bec371623c1128e0ba Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 16:02:35 +0000 Subject: [PATCH 36/90] Change the naming algorithm for review documents a bit - Legacy-Id: 11635 --- ietf/doc/tests_review.py | 6 +++--- ietf/doc/views_doc.py | 2 +- ietf/doc/views_review.py | 9 +++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index 00997d730..f9d97907d 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -401,7 +401,7 @@ class ReviewTests(TestCase): self.assertTrue(review_req.team.acronym.lower() in review_req.review.name) self.assertTrue(review_req.doc.rev in review_req.review.name) - with open(os.path.join(self.review_subdir, review_req.review.name + "-" + review_req.review.rev + ".txt")) as f: + 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) @@ -432,7 +432,7 @@ class ReviewTests(TestCase): review_req = reload_db_objects(review_req) self.assertEqual(review_req.state_id, "completed") - with open(os.path.join(self.review_subdir, review_req.review.name + "-" + review_req.review.rev + ".txt")) as f: + 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) @@ -463,7 +463,7 @@ class ReviewTests(TestCase): review_req = reload_db_objects(review_req) self.assertEqual(review_req.state_id, "completed") - with open(os.path.join(self.review_subdir, review_req.review.name + "-" + review_req.review.rev + ".txt")) as f: + 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) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 22af819c6..88330cd1d 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -573,7 +573,7 @@ def document_main(request, name, rev=None): if doc.type_id == "review": - basename = "{}-{}.txt".format(doc.name, doc.rev) + basename = "{}.txt".format(doc.name, doc.rev) pathname = os.path.join(doc.get_file_path(), basename) content = get_document_content(basename, pathname, split=False) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index feb187527..61e1c786b 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -377,11 +377,12 @@ def complete_review(request, name, request_id): for i in range(1, 100): name_components = [ "review", - review_req.team.acronym, - review_req.type.slug, - review_req.reviewer.person.ascii_parts()[3], skip_prefix(review_req.doc.name, "draft-"), form.cleaned_data["reviewed_rev"], + review_req.team.acronym, + review_req.type.slug if review_req.type.slug != "unknown" else "", + review_req.reviewer.person.ascii_parts()[3], + datetime.date.today().isoformat(), ] if i > 1: name_components.append(str(i)) @@ -416,7 +417,7 @@ def complete_review(request, name, request_id): 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)) + filename = os.path.join(review.get_file_path(), '{}.txt'.format(review.name, review.rev)) with open(filename, 'wb') as destination: destination.write(encoded_content) From f7eeb1f0f1c58bf57c02f681ce6c599d3e35f4d1 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 16:12:04 +0000 Subject: [PATCH 37/90] Add missing constraint on team in manage review request page - Legacy-Id: 11636 --- ietf/group/views_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index c60f1d839..6eb9be11f 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -79,7 +79,7 @@ def manage_review_requests(request, acronym, group_type=None): review_requests += suggested_review_requests_for_team(group) document_requests = extract_revision_ordered_review_requests_for_documents( - ReviewRequest.objects.filter(state__in=("part-completed", "completed")).prefetch_related("result"), + ReviewRequest.objects.filter(state__in=("part-completed", "completed"), team=group).prefetch_related("result"), set(r.doc_id for r in review_requests), ) From 6c8df95ee6714ff2c5d75ed3d6f84ec4c2d7852d Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 16:20:42 +0000 Subject: [PATCH 38/90] Close imported review requests that are past IESG approval automatically as overtaken by events - Legacy-Id: 11637 --- ietf/review/import_from_review_tool.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 52a5dee33..244a5f092 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -233,6 +233,8 @@ with db_con.cursor() as c: # FIXME: add log entries # FIXME: add review from reviewurl + # FIXME: do something about missing result + # adcomments IGNORED # lccomments IGNORED # nits IGNORED @@ -240,4 +242,8 @@ with db_con.cursor() as c: #print meta and meta[0], telechat, lcend, req.type - print "imported review", row.reviewid, "as", req.pk, req.time, req.deadline, req.type, req.doc_id + if req.state_id == "requested" and req.doc.get_state_slug("draft-iesg") in ["approved", "ann", "rfcqueue", "pub"]: + req.state = states["overtaken"] + req.save() + + print "imported review", row.reviewid, "as", req.pk, req.time, req.deadline, req.type, req.doc_id, req.state, req.doc.get_state_slug("draft-iesg") From 220a7f6608c1dcd9cd20e0257231ac6db9cecbbb Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 12 Jul 2016 16:25:23 +0000 Subject: [PATCH 39/90] Slight wording fix - Legacy-Id: 11638 --- ietf/templates/doc/review/complete_review.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/templates/doc/review/complete_review.html b/ietf/templates/doc/review/complete_review.html index 4edb4fa17..12112fc98 100644 --- a/ietf/templates/doc/review/complete_review.html +++ b/ietf/templates/doc/review/complete_review.html @@ -11,7 +11,7 @@

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

From 78062adc2feb1ff718c0d6c32e3850760cf82a15 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 3 Aug 2016 15:13:55 +0000 Subject: [PATCH 40/90] Use request.build_absolute_uri instead of grabbing the domain from the current Site in the review code - Legacy-Id: 11753 --- ietf/doc/views_review.py | 7 +++++-- ietf/review/utils.py | 6 ++++-- ietf/templates/doc/mail/partially_completed_review.txt | 2 +- ietf/templates/doc/mail/review_request_changed.txt | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 61e1c786b..1184ba674 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -1,6 +1,5 @@ import datetime, os, email.utils -from django.contrib.sites.models import Site from django.http import HttpResponseForbidden, JsonResponse from django.shortcuts import render, get_object_or_404, redirect from django import forms @@ -8,6 +7,7 @@ from django.contrib.auth.decorators import login_required from django.utils.html import mark_safe from django.core.exceptions import ValidationError from django.template.loader import render_to_string +from django.core.urlresolvers import reverse as urlreverse from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role @@ -447,8 +447,11 @@ def complete_review(request, name, request_id): subject = "Review of {}-{} completed partially".format(review_req.doc.name, review_req.reviewed_rev) + url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": new_review_req.doc.name, "request_id": new_review_req.pk }) + url = request.build_absolute_uri(url) + msg = render_to_string("doc/mail/partially_completed_review.txt", { - "domain": Site.objects.get_current().domain, + 'new_review_req_url': url, "by": request.user.person, "new_review_req": new_review_req, }) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 5b6e79e0a..baa418423 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -1,8 +1,8 @@ import datetime, re from collections import defaultdict -from django.contrib.sites.models import Site from django.db import models +from django.core.urlresolvers import reverse as urlreverse from ietf.group.models import Group, Role from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent, DocumentAuthor, DocAlias @@ -77,8 +77,10 @@ def email_review_request_change(request, review_req, subject, msg, by, notify_se if not to: return + url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": review_req.doc.name, "request_id": review_req.pk }) + url = request.build_absolute_uri(url) send_mail(request, to, None, subject, "doc/mail/review_request_changed.txt", { - "domain": Site.objects.get_current().domain, + "review_req_url": url, "review_req": review_req, "msg": msg, }) diff --git a/ietf/templates/doc/mail/partially_completed_review.txt b/ietf/templates/doc/mail/partially_completed_review.txt index 3e1661e55..3fd6603f7 100644 --- a/ietf/templates/doc/mail/partially_completed_review.txt +++ b/ietf/templates/doc/mail/partially_completed_review.txt @@ -2,5 +2,5 @@ A new review request has been registered for completing the review: -https://{{ domain }}{% url "ietf.doc.views_review.review_request" name=new_review_req.doc.name request_id=new_review_req.pk %} +{{ new_review_req_url }} {% endautoescape %} diff --git a/ietf/templates/doc/mail/review_request_changed.txt b/ietf/templates/doc/mail/review_request_changed.txt index 4fb70ea9f..88e1d151f 100644 --- a/ietf/templates/doc/mail/review_request_changed.txt +++ b/ietf/templates/doc/mail/review_request_changed.txt @@ -2,7 +2,7 @@ {{ 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: {% if review_req.deadline|date:"H:i" != "23:59" %}{{ review_req.deadline|date:"Y-m-d H:i" }}{% else %}{{ review_req.deadline|date:"Y-m-d" }}{% endif %} - https://{{ domain }}{% url "ietf.doc.views_review.review_request" name=review_req.doc.name request_id=review_req.pk %} + {{ review_req_url }} {{ msg|wordwrap:72 }} From 1ae302ca07d509d09e637fd81c72b3e52c303154 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 15 Aug 2016 10:31:50 +0000 Subject: [PATCH 41/90] Add info about last call/scheduled telechat to the request review page, fix a bug - Legacy-Id: 11782 --- ietf/doc/views_review.py | 16 +++++++++++++++- ietf/templates/doc/review/request_review.html | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 1184ba674..eda503866 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError from django.template.loader import render_to_string from django.core.urlresolvers import reverse as urlreverse -from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias +from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias, LastCallDocEvent from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName from ietf.review.models import ReviewRequest @@ -47,6 +47,7 @@ class RequestReviewForm(forms.ModelForm): self.doc = doc + self.fields['type'].queryset = self.fields['type'].queryset.filter(used=True) self.fields['type'].widget = forms.RadioSelect(choices=[t for t in self.fields['type'].choices if t[0]]) f = self.fields["team"] @@ -115,9 +116,22 @@ def request_review(request, name): else: form = RequestReviewForm(request.user, doc) + 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() + 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): diff --git a/ietf/templates/doc/review/request_review.html b/ietf/templates/doc/review/request_review.html index 0a9d06b1d..75c50fe2b 100644 --- a/ietf/templates/doc/review/request_review.html +++ b/ietf/templates/doc/review/request_review.html @@ -16,6 +16,18 @@

Submit a request to have the document reviewed.

+

+

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

+ {% csrf_token %} {% bootstrap_field form.requested_by layout="horizontal" %} From 9454c8bb9c47fa9fdcb6ce353a4c0671cc062b00 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 15 Aug 2016 14:41:12 +0000 Subject: [PATCH 42/90] Show other reviews of document on the review document page - Legacy-Id: 11784 --- ietf/doc/views_doc.py | 5 +++++ ietf/templates/doc/document_draft.html | 13 ++----------- ietf/templates/doc/document_review.html | 13 +++++++++++++ ietf/templates/doc/review_request_summary.html | 10 ++++++++++ 4 files changed, 30 insertions(+), 11 deletions(-) create mode 100644 ietf/templates/doc/review_request_summary.html diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 88330cd1d..080450447 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -579,6 +579,10 @@ def document_main(request, name, rev=None): review_req = ReviewRequest.objects.filter(review=doc.name).first() + other_reviews = [] + if review_req: + other_reviews = ReviewRequest.objects.filter(doc=review_req.doc, state__in=["completed", "part-completed"]).exclude(pk=review_req.pk).order_by("-time", "-id") + return render(request, "doc/document_review.html", dict(doc=doc, top=top, @@ -587,6 +591,7 @@ def document_main(request, name, rev=None): latest_rev=latest_rev, snapshot=snapshot, review_req=review_req, + other_reviews=other_reviews, )) raise Http404 diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 6b43d3db9..8e6a22189 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -198,17 +198,8 @@ Reviews - {% for r in review_requests %} - + {% for review_request in review_requests %} + {% include "doc/review_request_summary.html" with current_rev=doc.rev %} {% endfor %} {% if can_request_review %} diff --git a/ietf/templates/doc/document_review.html b/ietf/templates/doc/document_review.html index 8a684e10b..0745a8c42 100644 --- a/ietf/templates/doc/document_review.html +++ b/ietf/templates/doc/document_review.html @@ -92,6 +92,19 @@ {{ doc.time|date:"Y-m-d" }} + + {% if other_reviews %} + + + Other reviews + + + {% for review_request in other_reviews %} + {% include "doc/review_request_summary.html" with current_rev=review_req.reviewed_rev %} + {% endfor %} + + + {% endif %} diff --git a/ietf/templates/doc/review_request_summary.html b/ietf/templates/doc/review_request_summary.html new file mode 100644 index 000000000..331af5852 --- /dev/null +++ b/ietf/templates/doc/review_request_summary.html @@ -0,0 +1,10 @@ + From 8a5d9d7a72f62733498591200077b29c150fea11 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 15 Aug 2016 15:46:11 +0000 Subject: [PATCH 43/90] Make it more obvious that a review is not completed yet, include ongoing reviews in the list on the review document page - Legacy-Id: 11785 --- ietf/doc/views_doc.py | 6 +++--- ietf/review/utils.py | 5 +++++ ietf/templates/doc/review_request_summary.html | 7 ++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 080450447..3317748ea 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -62,7 +62,7 @@ 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 +from ietf.review.utils import can_request_review_of_doc, review_requests_to_list_for_doc def render_document_top(request, doc, tab, name): tabs = [] @@ -357,7 +357,7 @@ 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 = ReviewRequest.objects.filter(doc=doc).exclude(state__in=["withdrawn", "rejected", "overtaken", "no-response"]).order_by("-time", "-id") + review_requests = review_requests_to_list_for_doc(doc) return render_to_response("doc/document_draft.html", dict(doc=doc, @@ -581,7 +581,7 @@ def document_main(request, name, rev=None): other_reviews = [] if review_req: - other_reviews = ReviewRequest.objects.filter(doc=review_req.doc, state__in=["completed", "part-completed"]).exclude(pk=review_req.pk).order_by("-time", "-id") + other_reviews = review_requests_to_list_for_doc(review_req.doc).exclude(pk=review_req.pk) return render(request, "doc/document_review.html", dict(doc=doc, diff --git a/ietf/review/utils.py b/ietf/review/utils.py index baa418423..42939dde9 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -32,6 +32,11 @@ def can_manage_review_requests_for_team(user, team): return Role.objects.filter(name__in=["secr", "delegate"], person__user=user, group=team).exists() or has_role(user, "Secretariat") +def review_requests_to_list_for_doc(doc): + return ReviewRequest.objects.filter(doc=doc).exclude( + state__in=["withdrawn", "rejected", "overtaken", "no-response"] + ).order_by("-time", "-id") + def make_new_review_request_from_existing(review_req): obj = ReviewRequest() obj.time = review_req.time diff --git a/ietf/templates/doc/review_request_summary.html b/ietf/templates/doc/review_request_summary.html index 331af5852..4631dd256 100644 --- a/ietf/templates/doc/review_request_summary.html +++ b/ietf/templates/doc/review_request_summary.html @@ -5,6 +5,11 @@ {{ review_request.result.name }} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %} - reviewer: {{ review_request.reviewer.person }} {% else %} - {{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.reviewer %} (reviewer: {{ review_request.reviewer.person }}){% endif %} + + {{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review + {% if review_request.reviewer %} + - reviewer: {{ review_request.reviewer.person }} + {% endif %} + - due: {{ review_request.deadline|date:"Y-m-d" }} {% endif %} From 0e808518bcad6f54e0287714bf68623b5bf2acdb Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 15 Aug 2016 16:05:56 +0000 Subject: [PATCH 44/90] Make it possible to request review for more than one team at the time, auto-select the team for team secretaries/delegates - Legacy-Id: 11786 --- ietf/doc/views_review.py | 37 +++++++++++++++++++++---------------- ietf/review/utils.py | 5 +++-- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index eda503866..dc08e7c0b 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -10,10 +10,11 @@ from django.template.loader import render_to_string from django.core.urlresolvers import reverse as urlreverse from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias, LastCallDocEvent -from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName from ietf.review.models import ReviewRequest +from ietf.group.models import Group from ietf.person.fields import PersonEmailChoiceField, SearchablePersonField +from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role from ietf.review.utils import (active_review_teams, assign_review_request_to_reviewer, can_request_review_of_doc, can_manage_review_requests_for_team, email_review_request_change, make_new_review_request_from_existing, @@ -35,12 +36,13 @@ def clean_doc_revision(doc, rev): return rev class RequestReviewForm(forms.ModelForm): + team = forms.ModelMultipleChoiceField(queryset=Group.objects.all(), widget=forms.CheckboxSelectMultiple) deadline_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" }) deadline_time = forms.TimeField(widget=forms.TextInput(attrs={ 'placeholder': "HH:MM" }), help_text="If time is not specified, end of day is assumed", required=False) class Meta: model = ReviewRequest - fields = ('requested_by', 'type', 'team', 'deadline', 'requested_rev') + fields = ('requested_by', 'type', 'deadline', 'requested_rev') def __init__(self, user, doc, *args, **kwargs): super(RequestReviewForm, self).__init__(*args, **kwargs) @@ -54,8 +56,8 @@ class RequestReviewForm(forms.ModelForm): f.queryset = active_review_teams() if not is_authorized_in_doc_stream(user, doc): # user is a reviewer f.queryset = f.queryset.filter(role__name="reviewer", role__person__user=user) - if len(f.queryset) < 6: - f.widget = forms.RadioSelect(choices=[t for t in f.choices if t[0]]) + + 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["deadline"].required = False self.fields["requested_rev"].label = "Document revision" @@ -69,7 +71,7 @@ class RequestReviewForm(forms.ModelForm): def clean_deadline_date(self): v = self.cleaned_data.get('deadline_date') if v < datetime.date.today(): - raise forms.ValidationError("Select a future date.") + raise forms.ValidationError("Select today or a date in the future.") return v def clean_requested_rev(self): @@ -98,18 +100,21 @@ def request_review(request, name): form = RequestReviewForm(request.user, doc, request.POST) if form.is_valid(): - review_req = form.save(commit=False) - review_req.doc = doc - review_req.state = ReviewRequestStateName.objects.get(slug="requested", used=True) - review_req.save() + teams = form.cleaned_data["team"] + for team in teams: + review_req = form.save(commit=False) + review_req.doc = doc + review_req.state = ReviewRequestStateName.objects.get(slug="requested", used=True) + review_req.team = team + review_req.save() - DocEvent.objects.create( - type="requested_review", - doc=doc, - by=request.user.person, - desc="Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()), - time=review_req.time, - ) + DocEvent.objects.create( + type="requested_review", + doc=doc, + by=request.user.person, + desc="Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()), + time=review_req.time, + ) return redirect('doc_view', name=doc.name) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 42939dde9..2321f3f04 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -26,11 +26,12 @@ def can_request_review_of_doc(user, doc): return is_authorized_in_doc_stream(user, doc) -def can_manage_review_requests_for_team(user, team): +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 has_role(user, "Secretariat") + return (Role.objects.filter(name__in=["secr", "delegate"], person__user=user, group=team).exists() + or (allow_non_team_personnel and has_role(user, "Secretariat"))) def review_requests_to_list_for_doc(doc): return ReviewRequest.objects.filter(doc=doc).exclude( From 3ecaf54d5110a363054c3a31ea3e6919c53068c0 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 17 Aug 2016 14:42:57 +0000 Subject: [PATCH 45/90] Pre-fill the review type when requesting a review - Legacy-Id: 11798 --- ietf/doc/views_review.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index dc08e7c0b..8116b820b 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -96,6 +96,15 @@ def request_review(request, 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) @@ -119,16 +128,14 @@ def request_review(request, name): return redirect('doc_view', name=doc.name) else: - form = RequestReviewForm(request.user, doc) + if lc_ends: + review_type = "lc" + elif scheduled_for_telechat: + review_type = "telechat" + else: + review_type = "early" - 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() + form = RequestReviewForm(request.user, doc, initial={ "type": review_type }) return render(request, 'doc/review/request_review.html', { 'doc': doc, From 426542771aca202a7e7877aa333ec65c3e8ab9f1 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 17 Aug 2016 15:10:39 +0000 Subject: [PATCH 46/90] Turn ReviewRequest.deadline into a date field with no time - Legacy-Id: 11801 --- ietf/doc/tests_review.py | 19 +++++++++--------- ietf/doc/views_review.py | 20 +++---------------- ietf/group/tests_review.py | 4 ++-- ietf/group/views.py | 6 +++--- ietf/review/import_from_review_tool.py | 8 ++++---- ietf/review/migrations/0001_initial.py | 13 ++++++------ ietf/review/models.py | 4 ++-- ietf/review/resources.py | 8 ++++---- ietf/review/utils.py | 19 +++++++----------- .../doc/mail/review_request_changed.txt | 2 +- ietf/templates/doc/review/request_review.html | 3 +-- ietf/templates/doc/review/review_request.html | 8 +------- .../group/manage_review_requests.html | 8 ++------ ietf/templates/group/review_requests.html | 19 +++++------------- ietf/utils/test_data.py | 4 ++-- 15 files changed, 53 insertions(+), 92 deletions(-) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index f9d97907d..d43eb02e2 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -12,7 +12,7 @@ from pyquery import PyQuery import debug # pyflakes:ignore -from ietf.review.models import ReviewRequest, ReviewTeamResult, Reviewer +from ietf.review.models import ReviewRequest, ReviewTeamResult, ReviewerSettings import ietf.review.mailarch from ietf.person.models import Email, Person from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName @@ -51,21 +51,20 @@ class ReviewTests(TestCase): r = self.client.get(url) self.assertEqual(r.status_code, 200) - deadline_date = datetime.date.today() + datetime.timedelta(days=10) + deadline = datetime.date.today() + datetime.timedelta(days=10) # post request r = self.client.post(url, { "type": "early", "team": review_team.pk, - "deadline_date": deadline_date.isoformat(), + "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.date(), deadline_date) - self.assertEqual(req.deadline.time(), datetime.time(23, 59, 59)) + 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") @@ -146,14 +145,14 @@ class ReviewTests(TestCase): team=review_req.team, state=ReviewRequestStateName.objects.get(slug="completed"), reviewed_rev="01", - deadline=datetime.datetime.now() - datetime.timedelta(days=80), + deadline=datetime.date.today() - datetime.timedelta(days=80), reviewer=plain_email, ) - reviewer_obj = Reviewer.objects.get(person__email=plain_email) - reviewer_obj.filter_re = doc.name - reviewer_obj.unavailable_until = datetime.datetime.now() + datetime.timedelta(days=10) - reviewer_obj.save() + reviewer_settings = ReviewerSettings.objects.get(person__email=plain_email) + reviewer_settings.filter_re = doc.name + reviewer_settings.unavailable_until = datetime.datetime.now() + datetime.timedelta(days=10) + reviewer_settings.save() assign_url = urlreverse('ietf.doc.views_review.assign_reviewer', kwargs={ "name": doc.name, "request_id": review_req.pk }) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 8116b820b..c43258177 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -37,8 +37,7 @@ def clean_doc_revision(doc, rev): class RequestReviewForm(forms.ModelForm): team = forms.ModelMultipleChoiceField(queryset=Group.objects.all(), widget=forms.CheckboxSelectMultiple) - deadline_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" }) - deadline_time = forms.TimeField(widget=forms.TextInput(attrs={ 'placeholder': "HH:MM" }), help_text="If time is not specified, end of day is assumed", required=False) + deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={ "autoclose": "1", "start-date": "+0d" }) class Meta: model = ReviewRequest @@ -59,7 +58,6 @@ class RequestReviewForm(forms.ModelForm): 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["deadline"].required = False self.fields["requested_rev"].label = "Document revision" if has_role(user, "Secretariat"): @@ -68,8 +66,8 @@ class RequestReviewForm(forms.ModelForm): self.fields["requested_by"].widget = forms.HiddenInput() self.fields["requested_by"].initial = user.person.pk - def clean_deadline_date(self): - v = self.cleaned_data.get('deadline_date') + 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 @@ -77,18 +75,6 @@ class RequestReviewForm(forms.ModelForm): def clean_requested_rev(self): return clean_doc_revision(self.doc, self.cleaned_data.get("requested_rev")) - def clean(self): - deadline_date = self.cleaned_data.get('deadline_date') - deadline_time = self.cleaned_data.get('deadline_time', None) - - if deadline_date: - if deadline_time is None: - deadline_time = datetime.time(23, 59, 59) - - self.cleaned_data["deadline"] = datetime.datetime.combine(deadline_date, deadline_time) - - return self.cleaned_data - @login_required def request_review(request, name): doc = get_object_or_404(Document, name=name) diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index 90b2fe2e2..7cda17314 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -87,7 +87,7 @@ class ReviewTests(TestCase): doc=review_req1.doc, team=review_req1.team, type_id="early", - deadline=datetime.datetime.combine(datetime.date.today() + datetime.timedelta(days=30), datetime.time(23, 59, 59)), + deadline=datetime.date.today() + datetime.timedelta(days=30), state_id="accepted", reviewer=review_req1.reviewer, requested_by=Person.objects.get(user__username="plain"), @@ -97,7 +97,7 @@ class ReviewTests(TestCase): doc=review_req1.doc, team=review_req1.team, type_id="early", - deadline=datetime.datetime.combine(datetime.date.today() + datetime.timedelta(days=30), datetime.time(23, 59, 59)), + deadline=datetime.date.today() + datetime.timedelta(days=30), state_id="requested", requested_by=Person.objects.get(user__username="plain"), ) diff --git a/ietf/group/views.py b/ietf/group/views.py index 292f32ff1..066e0ae83 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -653,10 +653,10 @@ def review_requests(request, acronym, group_type=None): open_review_requests += suggested_review_requests_for_team(group) - now = datetime.datetime.now() + today = datetime.date.today() for r in open_review_requests: - delta = now - r.deadline - r.due = max(0, int(math.ceil(delta.total_seconds() / 3600.0))) + delta = today - r.deadline + r.due = max(0, delta.days()) closed_review_requests = ReviewRequest.objects.filter( team=group, diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 244a5f092..05d940041 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -16,7 +16,7 @@ django.setup() import datetime from collections import namedtuple from django.db import connections -from ietf.review.models import ReviewRequest, Reviewer, ReviewResultName +from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewResultName from ietf.review.models import ReviewRequestStateName, ReviewTypeName, ReviewTeamResult from ietf.group.models import Group, Role, RoleName from ietf.person.models import Person, Email, Alias @@ -105,7 +105,7 @@ with db_con.cursor() as c: if created: print "created role", unicode(role).encode("utf-8") - reviewer, created = Reviewer.objects.get_or_create( + reviewer, created = ReviewerSettings.objects.get_or_create( team=team, person=email.person, ) @@ -217,7 +217,7 @@ with db_con.cursor() as c: defaults={ "state": states["requested"], "type": type_name, - "deadline": deadline, + "deadline": deadline.date(), "requested_by": system_person, } ) @@ -228,7 +228,7 @@ with db_con.cursor() as c: req.type = type_name req.time = time req.reviewed_rev = reviewed_rev - req.deadline = deadline + req.deadline = deadline.date() req.save() # FIXME: add log entries diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index a78220cc0..13b5421f1 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -2,12 +2,13 @@ from __future__ import unicode_literals from django.db import models, migrations +import datetime class Migration(migrations.Migration): dependencies = [ - ('name', '0012_insert_review_name_data'), + ('name', '0013_auto_20160623_0621'), ('group', '0008_auto_20160505_0523'), ('person', '0014_auto_20160613_0751'), ('doc', '0012_auto_20160207_0537'), @@ -15,10 +16,10 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Reviewer', + name='ReviewerSettings', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('frequency', models.IntegerField(default=30, help_text=b'Can review every N days')), + ('frequency', models.IntegerField(default=30, help_text=b'Can review every N days', choices=[(7, b'Once per week'), (14, b'Once per fortnight'), (30, b'Once per month'), (61, b'Once per two months'), (91, b'Once per quarter')])), ('unavailable_until', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)), ('filter_re', models.CharField(max_length=255, blank=True)), ('skip_next', models.IntegerField(default=0, help_text=b'Skip the next N review assignments')), @@ -34,15 +35,15 @@ class Migration(migrations.Migration): 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(auto_now_add=True)), - ('deadline', models.DateTimeField()), + ('time', models.DateTimeField(default=datetime.datetime.now)), + ('deadline', models.DateField()), ('requested_rev', models.CharField(help_text=b'Fill in if a specific revision is to be reviewed, e.g. 02', max_length=16, verbose_name=b'requested revision', blank=True)), ('reviewed_rev', models.CharField(max_length=16, verbose_name=b'reviewed revision', blank=True)), ('doc', models.ForeignKey(related_name='review_request_set', to='doc.Document')), + ('requested_by', models.ForeignKey(to='person.Person')), ('result', models.ForeignKey(blank=True, to='name.ReviewResultName', null=True)), ('review', models.OneToOneField(null=True, blank=True, to='doc.Document')), ('reviewer', models.ForeignKey(blank=True, to='person.Email', null=True)), - ('requested_by', models.ForeignKey(to='person.Person')), ('state', models.ForeignKey(to='name.ReviewRequestStateName')), ('team', models.ForeignKey(to='group.Group')), ('type', models.ForeignKey(to='name.ReviewTypeName')), diff --git a/ietf/review/models.py b/ietf/review/models.py index 839edcdb7..7a21e6bbc 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -7,7 +7,7 @@ from ietf.group.models import Group from ietf.person.models import Person, Email from ietf.name.models import ReviewTypeName, ReviewRequestStateName, ReviewResultName -class Reviewer(models.Model): +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.""" @@ -50,7 +50,7 @@ class ReviewRequest(models.Model): type = models.ForeignKey(ReviewTypeName) doc = models.ForeignKey(Document, related_name='review_request_set') team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewteamresult=None)) - deadline = models.DateTimeField() + 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") diff --git a/ietf/review/resources.py b/ietf/review/resources.py index 02aa29d7b..f86145fb4 100644 --- a/ietf/review/resources.py +++ b/ietf/review/resources.py @@ -7,16 +7,16 @@ from tastypie.cache import SimpleCache from ietf import api from ietf.api import ToOneField # pyflakes:ignore -from ietf.review.models import Reviewer, ReviewRequest, ReviewTeamResult +from ietf.review.models import ReviewerSettings, ReviewRequest, ReviewTeamResult from ietf.person.resources import PersonResource from ietf.group.resources import GroupResource -class ReviewerResource(ModelResource): +class ReviewerSettingsResource(ModelResource): team = ToOneField(GroupResource, 'team') person = ToOneField(PersonResource, 'person') class Meta: - queryset = Reviewer.objects.all() + queryset = ReviewerSettings.objects.all() serializer = api.Serializer() cache = SimpleCache() #resource_name = 'reviewer' @@ -29,7 +29,7 @@ class ReviewerResource(ModelResource): "team": ALL_WITH_RELATIONS, "person": ALL_WITH_RELATIONS, } -api.review.register(ReviewerResource()) +api.review.register(ReviewerSettingsResource()) from ietf.doc.resources import DocumentResource from ietf.group.resources import RoleResource, GroupResource diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 2321f3f04..cfdc43fa7 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -9,7 +9,7 @@ from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent, Documen 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, Reviewer +from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewTypeName, ReviewerSettings from ietf.utils.mail import send_mail from ietf.doc.utils import extract_complete_replaces_ancestor_mapping_for_docs @@ -151,11 +151,6 @@ def close_review_request(request, review_req, close_state): by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=True) def suggested_review_requests_for_team(team): - def fixup_deadline(d): - if d.time() == datetime.time(0): - d = d - datetime.timedelta(seconds=1) # 23:59:59 is treated specially in the view code - return d - system_person = Person.objects.get(name="(System)") seen_deadlines = {} @@ -170,9 +165,9 @@ def suggested_review_requests_for_team(team): last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True)) last_call_expires = { e.doc_id: e.expires for e in LastCallDocEvent.objects.order_by("time", "id") } for doc in last_call_docs: - deadline = fixup_deadline(last_call_expires.get(doc.pk)) if doc.pk in last_call_expires else datetime.datetime.now() + deadline = last_call_expires[doc.pk].date() if doc.pk in last_call_expires else datetime.date.today() - if deadline > seen_deadlines.get(doc.pk, datetime.datetime.max): + if deadline > seen_deadlines.get(doc.pk, datetime.date.max): continue requests[doc.pk] = ReviewRequest( @@ -200,9 +195,9 @@ def suggested_review_requests_for_team(team): if d not in telechat_dates: continue - deadline = datetime.datetime.combine(d - telechat_deadline_delta, datetime.time(23, 59, 59)) + deadline = d - telechat_deadline_delta - if deadline > seen_deadlines.get(doc.pk, datetime.datetime.max): + if deadline > seen_deadlines.get(doc.pk, datetime.date.max): continue requests[doc.pk] = ReviewRequest( @@ -302,7 +297,7 @@ def make_assignment_choices(email_queryset, review_req): aliases = DocAlias.objects.filter(document=doc).values_list("name", flat=True) - reviewers = { r.person_id: r for r in Reviewer.objects.filter(team=team, person__in=[e.person_id for e in possible_emails]) } + reviewers = { r.person_id: r for r in ReviewerSettings.objects.filter(team=team, person__in=[e.person_id for e in possible_emails]) } # time since past assignment latest_assignment_for_reviewer = dict(ReviewRequest.objects.filter( @@ -347,7 +342,7 @@ def make_assignment_choices(email_queryset, review_req): for e in possible_emails: reviewer = reviewers.get(e.person_id) if not reviewer: - reviewer = Reviewer() + reviewer = ReviewerSettings() explanations = [] scores = [] # build up score in separate independent components diff --git a/ietf/templates/doc/mail/review_request_changed.txt b/ietf/templates/doc/mail/review_request_changed.txt index 88e1d151f..190911b8f 100644 --- a/ietf/templates/doc/mail/review_request_changed.txt +++ b/ietf/templates/doc/mail/review_request_changed.txt @@ -1,6 +1,6 @@ {% 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: {% if review_req.deadline|date:"H:i" != "23:59" %}{{ review_req.deadline|date:"Y-m-d H:i" }}{% else %}{{ review_req.deadline|date:"Y-m-d" }}{% endif %} + Deadline: {{ review_req.deadline|date:"Y-m-d" }} {{ review_req_url }} diff --git a/ietf/templates/doc/review/request_review.html b/ietf/templates/doc/review/request_review.html index 75c50fe2b..39625ca64 100644 --- a/ietf/templates/doc/review/request_review.html +++ b/ietf/templates/doc/review/request_review.html @@ -33,8 +33,7 @@ {% bootstrap_field form.requested_by layout="horizontal" %} {% bootstrap_field form.type layout="horizontal" %} {% bootstrap_field form.team layout="horizontal" %} - {% bootstrap_field form.deadline_date layout="horizontal" %} - {% bootstrap_field form.deadline_time layout="horizontal" %} + {% bootstrap_field form.deadline layout="horizontal" %} {% bootstrap_field form.requested_rev layout="horizontal" %} {% buttons %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index 1e182c83a..0b53d1130 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -50,13 +50,7 @@ Deadline - - {% if review_req.deadline|date:"H:i" != "23:59" %} - {{ review_req.deadline|date:"Y-m-d H:i" }} - {% else %} - {{ review_req.deadline|date:"Y-m-d" }} - {% endif %} - + {{ review_req.deadline|date:"Y-m-d" }} diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index 5b9af4042..66ad84e29 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -47,12 +47,8 @@ {{ r.type.name }} {% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} - {% if r.deadline|date:"H:i" != "23:59" %} - {{ r.deadline|date:"Y-m-d H:i" }} - {% else %} - {{ r.deadline|date:"Y-m-d" }} - {% endif %} - {% if r.due %}{{ r.due }} hour{{ r.due|pluralize }}{% endif %} + {{ r.deadline|date:"Y-m-d" }} + {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} {% if r.reviewer %} diff --git a/ietf/templates/group/review_requests.html b/ietf/templates/group/review_requests.html index 38a75e20d..eccb631eb 100644 --- a/ietf/templates/group/review_requests.html +++ b/ietf/templates/group/review_requests.html @@ -33,12 +33,8 @@ {{ r.type.name }} {% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} - {% if r.deadline|date:"H:i" != "23:59" %} - {{ r.deadline|date:"Y-m-d H:i" }} - {% else %} - {{ r.deadline|date:"Y-m-d" }} - {% endif %} - {% if r.due %}{{ r.due }} hour{{ r.due|pluralize }}{% endif %} + {{ r.deadline|date:"Y-m-d" }} + {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} {% if r.reviewer %} @@ -86,13 +82,7 @@ {{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %} {{ r.type }} {{ r.time|date:"Y-m-d" }} - - {% if r.deadline|date:"H:i" != "23:59" %} - {{ r.deadline|date:"Y-m-d H:i" }} - {% else %} - {{ r.deadline|date:"Y-m-d" }} - {% endif %} - + {{ r.deadline|date:"Y-m-d" }} {% if r.reviewer %} {{ r.reviewer.person }} @@ -104,7 +94,8 @@ {% if r.result %} {{ r.result.name }} - {% endif %} + {% endif %} + {% endfor %} diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index d2ed4e20a..876d9cbb5 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -13,7 +13,7 @@ from ietf.ipr.models import HolderIprDisclosure, IprDocRel, IprDisclosureStateNa from ietf.meeting.models import Meeting from ietf.name.models import StreamName, DocRelationshipName from ietf.person.models import Person, Email -from ietf.review.models import ReviewRequest, Reviewer, ReviewResultName, ReviewTeamResult +from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewResultName, ReviewTeamResult def create_person(group, role_name, name=None, username=None, email_address=None, password=None): """Add person/user/email and role.""" @@ -367,7 +367,7 @@ def make_review_data(doc): p = Person.objects.get(user__username="plain") email = p.email_set.first() Role.objects.create(name_id="reviewer", person=p, email=email, group=team) - Reviewer.objects.create(team=team, person=p, frequency=14, skip_next=0) + ReviewerSettings.objects.create(team=team, person=p, frequency=14, skip_next=0) review_req = ReviewRequest.objects.create( doc=doc, From 01732ad39b7ca680e029f7cd11650ee61ea3e908 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 17 Aug 2016 16:10:55 +0000 Subject: [PATCH 47/90] Link to the team page from the review request page - Legacy-Id: 11806 --- ietf/templates/doc/review/review_request.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index 0b53d1130..fbccf36b6 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -44,7 +44,7 @@ Team - {{ review_req.team.acronym|upper }} + {{ review_req.team.acronym|upper }} From 5eba7bf38eef1c5552ff7a7fb40770fa86b44938 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 17 Aug 2016 16:12:17 +0000 Subject: [PATCH 48/90] Fix a bug - Legacy-Id: 11807 --- ietf/group/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/group/views.py b/ietf/group/views.py index 066e0ae83..18b444baf 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -656,7 +656,7 @@ def review_requests(request, acronym, group_type=None): today = datetime.date.today() for r in open_review_requests: delta = today - r.deadline - r.due = max(0, delta.days()) + r.due = max(0, delta.days) closed_review_requests = ReviewRequest.objects.filter( team=group, From 5b784ca4ec72eeaf0059dce20a751efb6ca8407c Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 17 Aug 2016 16:13:31 +0000 Subject: [PATCH 49/90] No Review -> Team Will not Review - Legacy-Id: 11808 --- ietf/name/migrations/0012_insert_review_name_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ietf/name/migrations/0012_insert_review_name_data.py b/ietf/name/migrations/0012_insert_review_name_data.py index 13cc20786..97dca385f 100644 --- a/ietf/name/migrations/0012_insert_review_name_data.py +++ b/ietf/name/migrations/0012_insert_review_name_data.py @@ -12,8 +12,8 @@ def insert_initial_review_data(apps, schema_editor): 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="No Review of Version", order=7) - ReviewRequestStateName.objects.get_or_create(slug="no-review-document", name="No Review of Document", order=8) + 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) From 8b65c3ad65b8c3e0b5d0cda11956c7783abd7f2a Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Fri, 19 Aug 2016 16:37:46 +0000 Subject: [PATCH 50/90] Support adding CC's to the complete review email - Legacy-Id: 11826 --- ietf/doc/views_review.py | 4 +++- ietf/static/ietf/js/complete-review.js | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index c43258177..8502d3c4e 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -318,6 +318,7 @@ class CompleteReviewForm(forms.Form): 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 @@ -478,7 +479,8 @@ def complete_review(request, name, request_id): "doc/mail/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: diff --git a/ietf/static/ietf/js/complete-review.js b/ietf/static/ietf/js/complete-review.js index e810f40a2..c90b9fa3d 100644 --- a/ietf/static/ietf/js/complete-review.js +++ b/ietf/static/ietf/js/complete-review.js @@ -106,8 +106,8 @@ $(document).ready(function () { var val = form.find("[name=review_submission]:checked").val(); var shouldBeVisible = { - "enter": ['[name="review_content"]'], - "upload": ['[name="review_file"]'], + "enter": ['[name="review_content"]', '[name="cc"]'], + "upload": ['[name="review_file"]', '[name="cc"]'], "link": [".mail-archive-search", '[name="review_url"]', '[name="review_content"]'] }; From 507baade017b327d24f39979f5ef43f11b1f5532 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Fri, 19 Aug 2016 16:40:09 +0000 Subject: [PATCH 51/90] Add refresh button to manage reviews page, make save detect changes in the requests and pop the page back up for confirmation if so - Legacy-Id: 11827 --- ietf/group/tests_review.py | 37 ++++++++++++-- ietf/group/views_review.py | 49 ++++++++++++++++--- ietf/static/ietf/js/manage-review-requests.js | 11 ++++- .../group/manage_review_requests.html | 44 ++++++++++++++--- 4 files changed, 123 insertions(+), 18 deletions(-) diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index 7cda17314..76810e3ba 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -107,21 +107,52 @@ class ReviewTests(TestCase): self.assertEqual(r.status_code, 200) self.assertTrue(review_req1.doc.name in unicontent(r)) - # close and assign + # 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", + }) + 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) @@ -130,7 +161,3 @@ class ReviewTests(TestCase): self.assertEqual(review_req2.state_id, "requested") self.assertEqual(review_req2.reviewer, new_reviewer) self.assertEqual(review_req3.state_id, "requested") - - # FIXME: test suggested - - diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index 6eb9be11f..a1549dff2 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -22,9 +22,7 @@ class ManageReviewRequestForm(forms.Form): ] 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): @@ -83,6 +81,8 @@ def manage_review_requests(request, acronym, group_type=None): set(r.doc_id for r in review_requests), ) + # we need a mutable query dict + query_dict = request.POST.copy() if request.method == "POST" else None for req in review_requests: l = [] # take all on the latest reviewed rev @@ -98,14 +98,49 @@ def manage_review_requests(request, acronym, group_type=None): req.latest_reqs = l - req.form = ManageReviewRequestForm(req, request.POST if request.method == "POST" else None) + req.form = ManageReviewRequestForm(req, query_dict) + + saving = False + newly_closed = newly_opened = newly_assigned = 0 if request.method == "POST": + saving = request.POST.get("action") == "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 all(form_results): + 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": @@ -113,16 +148,18 @@ def manage_review_requests(request, acronym, group_type=None): 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 import ietf.group.views return redirect(ietf.group.views.review_requests, **kwargs) - return render(request, 'group/manage_review_requests.html', { 'group': group, 'review_requests': review_requests, + 'newly_closed': newly_closed, + 'newly_opened': newly_opened, + 'newly_assigned': newly_assigned, + 'saving': saving, }) diff --git a/ietf/static/ietf/js/manage-review-requests.js b/ietf/static/ietf/js/manage-review-requests.js index 62a38438d..a47afa9b2 100644 --- a/ietf/static/ietf/js/manage-review-requests.js +++ b/ietf/static/ietf/js/manage-review-requests.js @@ -32,6 +32,15 @@ $(document).ready(function () { }); form.find("[name$=\"-action\"]").each(function () { - console.log(this); + var v = $(this).val(); + if (!v) + return; + + var row = $(this).closest("tr"); + + if (v == "assign") + row.find(".reviewer-action").click(); + else if (v == "close") + row.find(".close-action").click(); }); }); diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index 66ad84e29..6d121f360 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -4,7 +4,7 @@ {% load ietf_filters staticfiles bootstrap3 %} -{% block title %}Manage pending review requests for {{ group.acronym }}{% endblock %} +{% block title %}Manage open review requests for {{ group.acronym }}{% endblock %} {% block pagehead %} @@ -15,7 +15,23 @@

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

-

For reference: closed review requests +

Other options: + Closed review requests + - Email open assignments summary +

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

+ Changes since last refresh: + {% if newly_closed %}{{ newly_closed }} request{{ newly_closed|pluralize }} closed.{% endif %} + {% if newly_opened %}{{ newly_opened }} request{{ newly_opened|pluralize }} opened.{% endif %} + {% if newly_assigned %}{{ newly_assigned }} request{{ newly_assigned|pluralize }} changed assignment.{% endif %} + + {% if saving %} + Check that you are happy with the results, then re-save. + {% endif %} +

+ {% endif %} {% if review_requests %} {% csrf_token %} @@ -33,7 +49,8 @@ {% for r in review_requests %} - {{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %} + + {{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %} {% if r.latest_reqs %}
- prev. review: @@ -43,6 +60,14 @@ {% endfor %} {% endif %} + + {% if r.form.non_field_errors %} +
+ {% for e in r.form.non_field_errors %} + {{ e }} + {% endfor %} +
+ {% endif %} {{ r.type.name }} {% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} @@ -51,6 +76,9 @@ {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} + + + {% if r.reviewer %} {% else %} @@ -64,8 +92,11 @@ {{ r.form.reviewer }} {% if r.form.reviewer.errors %} -
- {{ r.form.reviewer.errors }} +
+ {% for e in r.form.reviewer.errors %} + {{ e }} + {% endfor %} +
{% endif %} {% endspaceless %}
@@ -91,7 +122,8 @@ {% buttons %} Cancel - + + {% endbuttons %} {% else %} From 6e253f040794f5c369aeb330c68c52c28aa728fe Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 22 Aug 2016 11:28:03 +0000 Subject: [PATCH 52/90] Make the manage review requests view a bit more compact to have room for the assignment action, fix a couple of bugs - Legacy-Id: 11834 --- .../group/manage_review_requests.html | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index 6d121f360..f217382e6 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -39,11 +39,9 @@ Document - Type - Requested Deadline - Reviewer - Close as... + Reviewer + Close as... @@ -51,14 +49,22 @@ {{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %} - {% if r.latest_reqs %} -
- - prev. review: - {% for rlatest in r.latest_reqs %} - {{ rlatest.result.name }} - (diff){% if not forloop.last %},{% endif %} - {% endfor %} + +
+ + {% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} - {{ r.type.name }} +
+ + {% if r.latest_reqs %} +
+ - prev. review: + {% for rlatest in r.latest_reqs %} + {{ rlatest.result.name }} + (diff){% if not forloop.last %},{% endif %} + {% endfor %} + +
{% endif %} {% if r.form.non_field_errors %} @@ -69,15 +75,13 @@ {% endif %} - {{ r.type.name }} - {% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} {{ r.deadline|date:"Y-m-d" }} {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} - + {% if r.reviewer %} @@ -90,7 +94,7 @@ {% spaceless %} {{ r.form.reviewer }} - + {% if r.form.reviewer.errors %}
{% for e in r.form.reviewer.errors %} From 742bf4b2332060afee1721953f826eb82fe235dc Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 22 Aug 2016 14:28:56 +0000 Subject: [PATCH 53/90] Auto-select the next reviewer when choosing to assign a reviewer to an unassigned request on the manage review requests page - Legacy-Id: 11837 --- ietf/static/ietf/js/manage-review-requests.js | 50 +++++++++++++++++-- .../group/manage_review_requests.html | 2 +- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/ietf/static/ietf/js/manage-review-requests.js b/ietf/static/ietf/js/manage-review-requests.js index a47afa9b2..2b9af7c37 100644 --- a/ietf/static/ietf/js/manage-review-requests.js +++ b/ietf/static/ietf/js/manage-review-requests.js @@ -3,6 +3,43 @@ $(document).ready(function () { form.find(".reviewer-action").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(".close-controls .undo").click(); row.find("[name$=\"-action\"]").val("assign"); row.find(".reviewer-action").hide(); @@ -14,6 +51,7 @@ $(document).ready(function () { row.find(".reviewer-controls").hide(); row.find(".reviewer-action").show(); row.find("[name$=\"-action\"]").val(""); + row.find("[name$=\"-reviewer\"]").val($(this).data("initial")); }); form.find(".close-action").on("click", function () { @@ -38,9 +76,13 @@ $(document).ready(function () { var row = $(this).closest("tr"); - if (v == "assign") - row.find(".reviewer-action").click(); - else if (v == "close") - row.find(".close-action").click(); + if (v == "assign") { + row.find(".reviewer-action").hide(); + row.find(".reviewer-controls").show(); + } + else if (v == "close") { + row.find(".close-action").hide(); + row.find(".close-controls").show(); + } }); }); diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index f217382e6..aabbf1eb0 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -94,7 +94,7 @@ {% spaceless %} {{ r.form.reviewer }} - + {% if r.form.reviewer.errors %}
{% for e in r.form.reviewer.errors %} From 72467331546de3e9d3fd30cae5003883d5921ccc Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 23 Aug 2016 10:06:48 +0000 Subject: [PATCH 54/90] Reword assignment choices code to make it a bit clearer - Legacy-Id: 11838 --- ietf/review/utils.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index cfdc43fa7..443c19cbc 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -333,10 +333,10 @@ def make_assignment_choices(email_queryset, review_req): now = datetime.datetime.now() - def add_boolean_score(scores, direction, expr, expl): + def add_boolean_score(scores, direction, expr, explanation): scores.append(int(bool(expr)) * direction) if expr: - explanations.append(expl) + explanations.append(explanation) ranking = [] for e in possible_emails: @@ -344,9 +344,6 @@ def make_assignment_choices(email_queryset, review_req): if not reviewer: reviewer = ReviewerSettings() - explanations = [] - scores = [] # build up score in separate independent components - days_past = None latest = latest_assignment_for_reviewer.get(e.pk) if latest is not None: @@ -362,11 +359,17 @@ def make_assignment_choices(email_queryset, review_req): d = -d ready_for = "frequency exceeded, ready in {} {}".format(d, "day" if d == 1 else "days") - explanations.append(ready_for) + + # we sort the reviewers by separate axes, listing the most + # important things first + scores = [] + explanations = [] + + explanations.append(ready_for) # show ready for explanation first, but sort it after the other issues add_boolean_score(scores, +1, e.pk in has_reviewed_previous, "reviewed document before") add_boolean_score(scores, +1, e.pk in would_like_to_review, "wants to review document") - add_boolean_score(scores, -1, e.pk in connections, connections.get(e.pk)) + add_boolean_score(scores, -1, e.pk in connections, connections.get(e.pk)) # reviewer is somehow connected: bad add_boolean_score(scores, -1, reviewer.filter_re and any(re.search(reviewer.filter_re, n) for n in aliases), "filter regexp matches") add_boolean_score(scores, -1, reviewer.unavailable_until and reviewer.unavailable_until > now, "unavailable until {}".format((reviewer.unavailable_until or now).strftime("%Y-%m-%d %H:%M:%S"))) From 0c189cfa84347c31f8f074803b7e20ac65254736 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 23 Aug 2016 10:13:07 +0000 Subject: [PATCH 55/90] Include info about revisions in the manage review requests overview - Legacy-Id: 11839 --- ietf/templates/group/manage_review_requests.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index aabbf1eb0..555864bcd 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -48,23 +48,23 @@ {% for r in review_requests %} - {{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %} + {{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}
- {% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} - {{ r.type.name }} + R: {% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} - {{ r.type.name }}
{% if r.latest_reqs %} -
- - prev. review: - {% for rlatest in r.latest_reqs %} + {% for rlatest in r.latest_reqs %} +
+ - prev. review of {% if rlatest.doc_id != r.doc_id %}{{ rlatest.doc_id }}{% endif %}-{{ rlatest.reviewed_rev }}: {{ rlatest.result.name }} (diff){% if not forloop.last %},{% endif %} - {% endfor %} - -
+
+
+ {% endfor %} {% endif %} {% if r.form.non_field_errors %} From 90617c2b8e8d4e183d6de984777fadeedd2f4e9e Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 23 Aug 2016 11:54:53 +0000 Subject: [PATCH 56/90] Rework manage review request page some more, make more space for the actions, clear up some confusing wording - Legacy-Id: 11842 --- ietf/static/ietf/css/ietf.css | 15 +++++ ietf/static/ietf/js/manage-review-requests.js | 46 +++++++------- .../group/manage_review_requests.html | 62 +++++++++---------- 3 files changed, 71 insertions(+), 52 deletions(-) diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index c8230106c..aec070bbe 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -483,6 +483,21 @@ form.complete-review .mail-archive-search .results .list-group { 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; } /* Profile */ diff --git a/ietf/static/ietf/js/manage-review-requests.js b/ietf/static/ietf/js/manage-review-requests.js index 2b9af7c37..75f924a85 100644 --- a/ietf/static/ietf/js/manage-review-requests.js +++ b/ietf/static/ietf/js/manage-review-requests.js @@ -1,7 +1,25 @@ $(document).ready(function () { var form = $("form.review-requests"); - form.find(".reviewer-action").on("click", function () { + 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(); + } + } + + form.find(".assign-action button").on("click", function () { var row = $(this).closest("tr"); var select = row.find(".reviewer-controls [name$=\"-reviewer\"]"); @@ -40,33 +58,27 @@ $(document).ready(function () { select.val(found); } - row.find(".close-controls .undo").click(); row.find("[name$=\"-action\"]").val("assign"); - row.find(".reviewer-action").hide(); - row.find(".reviewer-controls").show(); + setControlDisplay(row); }); form.find(".reviewer-controls .undo").on("click", function () { var row = $(this).closest("tr"); - row.find(".reviewer-controls").hide(); - row.find(".reviewer-action").show(); row.find("[name$=\"-action\"]").val(""); row.find("[name$=\"-reviewer\"]").val($(this).data("initial")); + setControlDisplay(row); }); - form.find(".close-action").on("click", function () { + form.find(".close-action button").on("click", function () { var row = $(this).closest("tr"); - row.find(".reviewer-controls .undo").click(); row.find("[name$=\"-action\"]").val("close"); - row.find(".close-action").hide(); - row.find(".close-controls").show(); + setControlDisplay(row); }); form.find(".close-controls .undo").on("click", function () { var row = $(this).closest("tr"); row.find("[name$=\"-action\"]").val(""); - row.find(".close-controls").hide(); - row.find(".close-action").show(); + setControlDisplay(row); }); form.find("[name$=\"-action\"]").each(function () { @@ -75,14 +87,6 @@ $(document).ready(function () { return; var row = $(this).closest("tr"); - - if (v == "assign") { - row.find(".reviewer-action").hide(); - row.find(".reviewer-controls").show(); - } - else if (v == "close") { - row.find(".close-action").hide(); - row.find(".close-controls").show(); - } + setControlDisplay(row); }); }); diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index 555864bcd..8b526e90c 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -40,8 +40,7 @@ Document Deadline - Reviewer - Close as... + Action @@ -52,7 +51,7 @@ @@ -75,7 +74,7 @@
{% endif %} - + {{ r.deadline|date:"Y-m-d" }} {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} @@ -83,40 +82,41 @@ - {% if r.reviewer %} - - {% else %} - - {% endif %} + + {% if r.reviewer %} + + {% else %} + + {% endif %} + {{ r.form.action }} - {% spaceless %} - {{ r.form.reviewer }} - - {% if r.form.reviewer.errors %} -
- {% for e in r.form.reviewer.errors %} - {{ e }} - {% endfor %} -
- {% endif %} - {% endspaceless %} + + {{ r.form.reviewer }} + + {% if r.form.reviewer.errors %} +
+ {% for e in r.form.reviewer.errors %} + {{ e }} + {% endfor %} +
+ {% endif %} +
+ + + - - - - {% spaceless %} - {{ r.form.close }} - - {% if r.form.close.errors %} -
- {{ r.form.close.errors }} - {% endif %} - {% endspaceless %} + + {{ r.form.close }} + + {% if r.form.close.errors %} +
+ {{ r.form.close.errors }} + {% endif %}
From 1f71268da13a8f859d7c4f586f12a1e463403874 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 23 Aug 2016 12:12:24 +0000 Subject: [PATCH 57/90] More polish on manage review requests page: Add save and continue editing button, reverse sort order so latest (and probably most interesting) is at the top, disable save buttons when there are no changes to save - Legacy-Id: 11843 --- ietf/group/tests_review.py | 2 +- ietf/group/views.py | 2 +- ietf/group/views_review.py | 13 +++++++++---- ietf/review/utils.py | 2 +- ietf/static/ietf/js/manage-review-requests.js | 9 +++++++++ ietf/templates/group/manage_review_requests.html | 1 + 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index 76810e3ba..b99a4f9ee 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -123,7 +123,7 @@ class ReviewTests(TestCase): "r{}-action".format(review_req2.pk): "assign", "r{}-reviewer".format(review_req2.pk): new_reviewer.pk, - "action": "save", + "action": "save-continue", }) self.assertEqual(r.status_code, 200) content = unicontent(r).lower() diff --git a/ietf/group/views.py b/ietf/group/views.py index 18b444baf..221e0587e 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -649,7 +649,7 @@ def review_requests(request, acronym, group_type=None): open_review_requests = list(ReviewRequest.objects.filter( team=group, state__in=("requested", "accepted") - ).prefetch_related("reviewer", "type", "state").order_by("time", "id")) + ).prefetch_related("reviewer", "type", "state").order_by("-time", "-id")) open_review_requests += suggested_review_requests_for_team(group) diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index a1549dff2..f7af739cf 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -72,7 +72,7 @@ def manage_review_requests(request, acronym, group_type=None): review_requests = list(ReviewRequest.objects.filter( team=group, state__in=("requested", "accepted") - ).prefetch_related("reviewer", "type", "state").order_by("time", "id")) + ).prefetch_related("reviewer", "type", "state").order_by("-time", "-id")) review_requests += suggested_review_requests_for_team(group) @@ -104,7 +104,8 @@ def manage_review_requests(request, acronym, group_type=None): newly_closed = newly_opened = newly_assigned = 0 if request.method == "POST": - saving = request.POST.get("action") == "save" + 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 } @@ -151,8 +152,12 @@ def manage_review_requests(request, acronym, group_type=None): kwargs = { "acronym": group.acronym } if group_type: kwargs["group_type"] = group_type - import ietf.group.views - return redirect(ietf.group.views.review_requests, **kwargs) + + if form_action == "save-continue": + return redirect(manage_review_requests, **kwargs) + else: + import ietf.group.views + return redirect(ietf.group.views.review_requests, **kwargs) return render(request, 'group/manage_review_requests.html', { 'group': group, diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 443c19cbc..b98b11439 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -231,7 +231,7 @@ def suggested_review_requests_for_team(team): 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)) + res.sort(key=lambda r: (r.deadline, r.doc_id), reversed=True) return res def extract_revision_ordered_review_requests_for_documents(queryset, names): diff --git a/ietf/static/ietf/js/manage-review-requests.js b/ietf/static/ietf/js/manage-review-requests.js index 75f924a85..cf9cbe8d7 100644 --- a/ietf/static/ietf/js/manage-review-requests.js +++ b/ietf/static/ietf/js/manage-review-requests.js @@ -1,5 +1,10 @@ $(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(); @@ -17,6 +22,8 @@ $(document).ready(function () { row.find(".reviewer-controls,.close-controls").hide(); row.find(".assign-action,.close-action").show(); } + + updateSaveButtons(); } form.find(".assign-action button").on("click", function () { @@ -89,4 +96,6 @@ $(document).ready(function () { var row = $(this).closest("tr"); setControlDisplay(row); }); + + updateSaveButtons(); }); diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index 8b526e90c..a6e7daf39 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -127,6 +127,7 @@ {% buttons %} Cancel + {% endbuttons %} From 3bfde085c524c4e41c9edb4bea4a6eede16d4a85 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 23 Aug 2016 13:03:16 +0000 Subject: [PATCH 58/90] Fix bug - Legacy-Id: 11844 --- ietf/review/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index b98b11439..955231806 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -231,7 +231,7 @@ def suggested_review_requests_for_team(team): 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), reversed=True) + res.sort(key=lambda r: (r.deadline, r.doc_id), reverse=True) return res def extract_revision_ordered_review_requests_for_documents(queryset, names): From 0958bcba4a991b7994dc987231bde0b16c864098 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 23 Aug 2016 14:05:45 +0000 Subject: [PATCH 59/90] Fill in the list_email attribute on the test review team - Legacy-Id: 11845 --- ietf/doc/tests_review.py | 2 -- ietf/utils/test_data.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index d43eb02e2..e98f729c9 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -302,7 +302,6 @@ class ReviewTests(TestCase): review_req = make_review_data(doc) review_req.state = ReviewRequestStateName.objects.get(slug="accepted") review_req.save() - review_req.team.list_email = "{}@ietf.org".format(review_req.team.acronym) review_req.team.save() # test URL construction @@ -343,7 +342,6 @@ class ReviewTests(TestCase): review_req = make_review_data(doc) review_req.state = ReviewRequestStateName.objects.get(slug="accepted") review_req.save() - review_req.team.list_email = "{}@ietf.org".format(review_req.team.acronym) for r in ReviewResultName.objects.filter(slug__in=("issues", "ready")): ReviewTeamResult.objects.get_or_create(team=review_req.team, result=r) review_req.team.save() diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index 876d9cbb5..1b00490c3 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -360,7 +360,7 @@ def make_test_data(): return draft def make_review_data(doc): - team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team") + team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team", list_email="reviewteam@ietf.org") for r in ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"]): ReviewTeamResult.objects.create(team=team, result=r) From 16e28481f6a057e87a323dc7e17c3ef61671a81f Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 23 Aug 2016 14:06:47 +0000 Subject: [PATCH 60/90] Add page for emailing open review assignments summary to the review team list - Legacy-Id: 11846 --- ietf/group/tests_review.py | 31 ++++++++++- ietf/group/urls_info_details.py | 1 + ietf/group/views_review.py | 53 ++++++++++++++++++- ietf/static/ietf/css/ietf.css | 5 ++ .../group/email_open_review_assignments.html | 26 +++++++++ .../group/email_open_review_assignments.txt | 4 ++ .../group/manage_review_requests.html | 2 +- 7 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 ietf/templates/group/email_open_review_assignments.html create mode 100644 ietf/templates/group/email_open_review_assignments.txt diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index b99a4f9ee..306fd7b3a 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -12,6 +12,7 @@ from ietf.iesg.models import TelechatDate from ietf.person.models import Email, Person from ietf.review.utils import suggested_review_requests_for_team import ietf.group.views_review +from ietf.utils.mail import outbox, empty_outbox class ReviewTests(TestCase): def test_suggested_review_requests(self): @@ -79,7 +80,7 @@ class ReviewTests(TestCase): group = review_req1.team - url = urlreverse(ietf.group.views_review.manage_review_requests, kwargs={ 'acronym': group.acronym }) + url = urlreverse(ietf.group.views_review.manage_review_requests, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id }) login_testing_unauthorized(self, "secretary", url) @@ -161,3 +162,31 @@ class ReviewTests(TestCase): self.assertEqual(review_req2.state_id, "requested") self.assertEqual(review_req2.reviewer, new_reviewer) self.assertEqual(review_req3.state_id, "requested") + + def test_email_open_review_assignments(self): + doc = make_test_data() + review_req1 = make_review_data(doc) + + group = review_req1.team + + url = urlreverse(ietf.group.views_review.email_open_review_assignments, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id }) + + login_testing_unauthorized(self, "secretary", url) + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(review_req1.doc.name in unicontent(r)) + + empty_outbox() + r = self.client.post(url, { + "to": group.list_email, + "subject": "Test subject", + "body": "Test body", + "action": "email", + }) + self.assertEqual(r.status_code, 302) + self.assertEqual(len(outbox), 1) + self.assertTrue(group.list_email in outbox[0]["To"]) + self.assertEqual(outbox[0]["subject"], "Test subject") + self.assertTrue("Test body" in unicode(outbox[0])) + diff --git a/ietf/group/urls_info_details.py b/ietf/group/urls_info_details.py index d5d62c902..cbc822d94 100644 --- a/ietf/group/urls_info_details.py +++ b/ietf/group/urls_info_details.py @@ -32,5 +32,6 @@ urlpatterns = patterns('', (r'^photos/$', views.group_photos), (r'^reviews/$', views.review_requests), (r'^reviews/manage/$', views_review.manage_review_requests), + (r'^reviews/email-assignments/$', views_review.email_open_review_assignments), url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'), ) diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index f7af739cf..483b269a3 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -2,6 +2,7 @@ from django.shortcuts import render, redirect from django.http import Http404, HttpResponseForbidden from django.contrib.auth.decorators import login_required from django import forms +from django.template.loader import render_to_string from ietf.review.models import ReviewRequest from ietf.review.utils import (can_manage_review_requests_for_team, close_review_request_states, @@ -9,10 +10,10 @@ from ietf.review.utils import (can_manage_review_requests_for_team, close_review assign_review_request_to_reviewer, close_review_request, setup_reviewer_field, -# make_new_review_request_from_existing, suggested_review_requests_for_team) from ietf.group.utils import get_group_or_404 from ietf.person.fields import PersonEmailChoiceField +from ietf.utils.mail import send_mail_text class ManageReviewRequestForm(forms.Form): @@ -168,3 +169,53 @@ def manage_review_requests(request, acronym, group_type=None): 'saving': saving, }) +class EmailOpenAssignmentsForm(forms.Form): + to = forms.EmailField(widget=forms.EmailInput(attrs={ "readonly": True })) + subject = forms.CharField() + body = forms.CharField(widget=forms.Textarea) + +@login_required +def email_open_review_assignments(request, acronym, group_type=None): + group = get_group_or_404(acronym, group_type) + if not group.features.has_reviews: + raise Http404 + + if not can_manage_review_requests_for_team(request.user, group): + return HttpResponseForbidden("You do not have permission to perform this action") + + review_requests = list(ReviewRequest.objects.filter( + team=group, + state__in=("requested", "accepted"), + ).exclude( + reviewer=None, + ).prefetch_related("reviewer", "type", "state", "doc").distinct().order_by("deadline", "reviewer")) + + if request.method == "POST" and request.POST.get("action") == "email": + form = EmailOpenAssignmentsForm(request.POST) + if form.is_valid(): + send_mail_text(request, form.cleaned_data["to"], None, form.cleaned_data["subject"], form.cleaned_data["body"]) + + kwargs = { "acronym": group.acronym } + if group_type: + kwargs["group_type"] = group_type + + return redirect(manage_review_requests, **kwargs) + else: + to = group.list_email + subject = "Open review assignments in {}".format(group.acronym) + body = render_to_string("group/email_open_review_assignments.txt", { + "review_requests": review_requests, + }) + + form = EmailOpenAssignmentsForm(initial={ + "to": to, + "subject": subject, + "body": body, + }) + + return render(request, 'group/email_open_review_assignments.html', { + 'group': group, + 'review_requests': review_requests, + 'form': form, + }) + diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index aec070bbe..cd209ab1e 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -500,6 +500,11 @@ form.review-requests label { padding-right: 0.3em; } +form.email-open-review-assignments [name=body] { + height: 50em; + font-family: monospace; +} + /* Profile */ .photo-name { diff --git a/ietf/templates/group/email_open_review_assignments.html b/ietf/templates/group/email_open_review_assignments.html new file mode 100644 index 000000000..c5883eb6c --- /dev/null +++ b/ietf/templates/group/email_open_review_assignments.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block title %}Email summary of assigned review requests for {{ group.acronym }}{% endblock %} + +{% block content %} + {% origin %} + +

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

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

There are currently no open requests.

+ {% endif %} +{% endblock %} diff --git a/ietf/templates/group/email_open_review_assignments.txt b/ietf/templates/group/email_open_review_assignments.txt new file mode 100644 index 000000000..c9bb458e4 --- /dev/null +++ b/ietf/templates/group/email_open_review_assignments.txt @@ -0,0 +1,4 @@ +{% autoescape off %} +Reviewer Deadline Draft +{% for r in review_requests %}{{ r.reviewer.person.plain_name|ljust:"22" }} {{ r.deadline|date:"Y-m-d" }} {{ r.doc_id }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %} +{% endfor %}{% endautoescape %} diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index a6e7daf39..0c76826b5 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -17,7 +17,7 @@

Other options: Closed review requests - - Email open assignments summary + - Email open assignments summary

{% if newly_closed > 0 or newly_opened > 0 or newly_assigned > 0 %} From dd190b87fe8f3ca027ad567bb97512a6d6320ab7 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 23 Aug 2016 14:55:42 +0000 Subject: [PATCH 61/90] Follow replacements when displaying reviews for a draft on the document page, too. Add a test to check that recursive replacements are handled correctly. Polish the display a bit. - Legacy-Id: 11847 --- ietf/doc/tests_review.py | 23 +++++++++++++++---- ietf/doc/views_doc.py | 2 +- ietf/group/views.py | 2 +- ietf/group/views_review.py | 3 ++- ietf/review/utils.py | 15 ++++++++---- ietf/templates/doc/document_draft.html | 2 +- ietf/templates/doc/document_review.html | 2 +- .../templates/doc/review_request_summary.html | 2 +- ietf/templates/group/review_requests.html | 4 ++-- 9 files changed, 38 insertions(+), 17 deletions(-) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index e98f729c9..67a663b76 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -15,8 +15,8 @@ import debug # pyflakes:ignore from ietf.review.models import ReviewRequest, ReviewTeamResult, ReviewerSettings import ietf.review.mailarch from ietf.person.models import Email, Person -from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName -from ietf.doc.models import DocumentAuthor +from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName, DocRelationshipName +from ietf.doc.models import DocumentAuthor, Document, DocAlias, RelatedDocument from ietf.utils.test_utils import TestCase from ietf.utils.test_data import make_test_data, make_review_data from ietf.utils.test_utils import login_testing_unauthorized, unicontent, reload_db_objects @@ -70,8 +70,23 @@ class ReviewTests(TestCase): self.assertEqual(doc.latest_event().type, "requested_review") def test_doc_page(self): - # FIXME: fill in - pass + 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() diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 3317748ea..ba6fde043 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -581,7 +581,7 @@ def document_main(request, name, rev=None): other_reviews = [] if review_req: - other_reviews = review_requests_to_list_for_doc(review_req.doc).exclude(pk=review_req.pk) + other_reviews = [r for r in review_requests_to_list_for_doc(review_req.doc) if r != review_req] return render(request, "doc/document_review.html", dict(doc=doc, diff --git a/ietf/group/views.py b/ietf/group/views.py index 221e0587e..8894e6486 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -662,7 +662,7 @@ def review_requests(request, acronym, group_type=None): team=group, ).exclude( state__in=("requested", "accepted") - ).prefetch_related("reviewer", "type", "state").order_by("-time", "-id") + ).prefetch_related("reviewer", "type", "state", "doc").order_by("-time", "-id") since_choices = [ (None, "1 month"), diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index 483b269a3..47b607ccf 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -82,7 +82,8 @@ def manage_review_requests(request, acronym, group_type=None): set(r.doc_id for r in review_requests), ) - # we need a mutable query dict + # 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 = [] diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 955231806..05d84aa60 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -34,9 +34,12 @@ def can_manage_review_requests_for_team(user, team, allow_non_team_personnel=Tru or (allow_non_team_personnel and has_role(user, "Secretariat"))) def review_requests_to_list_for_doc(doc): - return ReviewRequest.objects.filter(doc=doc).exclude( - state__in=["withdrawn", "rejected", "overtaken", "no-response"] - ).order_by("-time", "-id") + return extract_revision_ordered_review_requests_for_documents( + ReviewRequest.objects.exclude( + state__in=["withdrawn", "rejected", "overtaken", "no-response"], + ).prefetch_related("result"), + [doc.name] + ).get(doc.pk, []) def make_new_review_request_from_existing(review_req): obj = ReviewRequest() @@ -234,13 +237,15 @@ def suggested_review_requests_for_team(team): res.sort(key=lambda r: (r.deadline, r.doc_id), reverse=True) return res -def extract_revision_ordered_review_requests_for_documents(queryset, names): +def extract_revision_ordered_review_requests_for_documents(review_request_queryset, names): + """Extracts all review requests for document names (including replaced ancestors).""" + names = set(names) replaces = extract_complete_replaces_ancestor_mapping_for_docs(names) requests_for_each_doc = defaultdict(list) - for r in queryset.filter(doc__in=set(e for l in replaces.itervalues() for e in l) | names).order_by("-reviewed_rev", "-time", "-id").iterator(): + 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 diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 8e6a22189..790908580 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -199,7 +199,7 @@ {% for review_request in review_requests %} - {% include "doc/review_request_summary.html" with current_rev=doc.rev %} + {% include "doc/review_request_summary.html" with current_doc_name=doc.name current_rev=doc.rev %} {% endfor %} {% if can_request_review %} diff --git a/ietf/templates/doc/document_review.html b/ietf/templates/doc/document_review.html index 0745a8c42..1d83e0c00 100644 --- a/ietf/templates/doc/document_review.html +++ b/ietf/templates/doc/document_review.html @@ -100,7 +100,7 @@ {% for review_request in other_reviews %} - {% include "doc/review_request_summary.html" with current_rev=review_req.reviewed_rev %} + {% include "doc/review_request_summary.html" with current_doc_name=review_req.doc_id current_rev=review_req.reviewed_rev %} {% endfor %} diff --git a/ietf/templates/doc/review_request_summary.html b/ietf/templates/doc/review_request_summary.html index 4631dd256..22bf04dcd 100644 --- a/ietf/templates/doc/review_request_summary.html +++ b/ietf/templates/doc/review_request_summary.html @@ -1,7 +1,7 @@
{% if review_request.state_id == "completed" or review_request.state_id == "part-completed" %} - {{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev %} (of -{{ review_request.reviewed_rev }}){% endif %}: + {{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev or review_request.doc_id != current_doc_name %} (of {% if review_request.doc_id != current_doc_name %}{{ review_request.doc_id }}{% endif %}-{{ review_request.reviewed_rev }}){% endif %}: {{ review_request.result.name }} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %} - reviewer: {{ review_request.reviewer.person }} {% else %} diff --git a/ietf/templates/group/review_requests.html b/ietf/templates/group/review_requests.html index eccb631eb..8e1df839a 100644 --- a/ietf/templates/group/review_requests.html +++ b/ietf/templates/group/review_requests.html @@ -4,7 +4,7 @@ {% load ietf_filters staticfiles bootstrap3 %} -{% block group_subtitle %}Reviews for {{ group.name }}{% endblock %} +{% block group_subtitle %}Review requests{% endblock %} {% block pagehead %} @@ -29,7 +29,7 @@ {% for r in open_review_requests %} - {{ r.doc.name }}{% if r.requested_rev %}-{{ r.requested_rev }}{% endif %} + {{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %} {{ r.type.name }} {% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} From 24dd268a8144fe72f83914a17f6ed10ae16ab914 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 24 Aug 2016 09:08:37 +0000 Subject: [PATCH 62/90] Show on document page if teams are not going to review that version - Legacy-Id: 11851 --- ietf/doc/views_doc.py | 3 +++ ietf/review/utils.py | 13 ++++++++++--- ietf/templates/doc/document_draft.html | 7 +++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index ba6fde043..d88dc325c 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -63,6 +63,7 @@ from ietf.meeting.models import Session from ietf.meeting.utils import group_sessions, get_upcoming_manageable_sessions, sort_sessions from ietf.review.models import ReviewRequest from ietf.review.utils import can_request_review_of_doc, review_requests_to_list_for_doc +from ietf.review.utils import no_review_from_teams_on_doc def render_document_top(request, doc, tab, name): tabs = [] @@ -358,6 +359,7 @@ def document_main(request, name, rev=None): started_iesg_process = doc.latest_event(type="started_iesg_process") review_requests = review_requests_to_list_for_doc(doc) + no_review_from_teams = no_review_from_teams_on_doc(doc, rev or doc.rev) return render_to_response("doc/document_draft.html", dict(doc=doc, @@ -420,6 +422,7 @@ def document_main(request, name, rev=None): actions=actions, presentations=presentations, review_requests=review_requests, + no_review_from_teams=no_review_from_teams, ), context_instance=RequestContext(request)) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 05d84aa60..6bedf4fcf 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -35,12 +35,19 @@ def can_manage_review_requests_for_team(user, team, allow_non_team_personnel=Tru def review_requests_to_list_for_doc(doc): return extract_revision_ordered_review_requests_for_documents( - ReviewRequest.objects.exclude( - state__in=["withdrawn", "rejected", "overtaken", "no-response"], + ReviewRequest.objects.filter( + state__in=["requested", "accepted", "part-completed", "completed"], ).prefetch_related("result"), [doc.name] ).get(doc.pk, []) +def no_review_from_teams_on_doc(doc, rev): + return Group.objects.filter( + reviewrequest__doc=doc, + reviewrequest__reviewed_rev=rev, + reviewrequest__state="no-review-version", + ).distinct() + def make_new_review_request_from_existing(review_req): obj = ReviewRequest() obj.time = review_req.time @@ -134,7 +141,7 @@ def close_review_request(request, review_req, close_state): prev_state = review_req.state review_req.state = close_state if close_state.slug == "no-review-version": - review_req.reviewed_rev = review_req.doc.rev # save rev for later reference + 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: diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 790908580..ee27e58a6 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -202,6 +202,13 @@ {% 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 %}
Request review From 119f7256f125609e6c34dc6b0b38f8bf117cb098 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Fri, 2 Sep 2016 20:28:51 +0000 Subject: [PATCH 63/90] Fix a bug in emailing review request change notifications - Legacy-Id: 11920 --- ietf/review/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 6bedf4fcf..3b5526f36 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -84,7 +84,7 @@ def email_review_request_change(request, review_req, subject, msg, by, notify_se to.append(e) if notify_secretary: - extract_email_addresses(Role.objects.filter(name__in=["secretary", "delegate"], group=review_req.team).distinct()) + extract_email_addresses(Role.objects.filter(name__in=["secr", "delegate"], group=review_req.team).distinct()) if notify_reviewer: extract_email_addresses([review_req.reviewer]) if notify_requested_by: From 1f7d4870a8ec57daf2f42a1619f61432bace8858 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 5 Sep 2016 12:33:54 +0000 Subject: [PATCH 64/90] Refactor role handling in group editing slightly and add support for editing reviewer roles in review teams. Also fix a couple of review related bugs. - Legacy-Id: 11921 --- ietf/group/features.py | 4 +++ ietf/group/milestones.py | 2 +- ietf/group/tests_info.py | 44 ++++++++++++++++++++++++++---- ietf/group/views.py | 9 +++---- ietf/group/views_edit.py | 51 ++++++++++++++++++++++++++--------- ietf/name/fixtures/names.json | 4 +-- ietf/utils/test_data.py | 4 +-- ietf/utils/text.py | 6 ++--- 8 files changed, 93 insertions(+), 31 deletions(-) diff --git a/ietf/group/features.py b/ietf/group/features.py index 190bd83e0..c2a26fcf0 100644 --- a/ietf/group/features.py +++ b/ietf/group/features.py @@ -11,6 +11,7 @@ class GroupFeatures(object): 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"): @@ -31,3 +32,6 @@ class GroupFeatures(object): self.has_reviews = True import ietf.group.views self.default_tab = ietf.group.views.review_requests + + if group.type_id == "dir": + self.admin_roles = ["chair", "secr"] diff --git a/ietf/group/milestones.py b/ietf/group/milestones.py index 0b316fbe8..86575b1ca 100644 --- a/ietf/group/milestones.py +++ b/ietf/group/milestones.py @@ -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: diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index 006f04191..70b3dca67 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -26,7 +26,7 @@ from ietf.person.models import Person, Email from ietf.utils.test_utils import TestCase, unicontent from ietf.utils.mail import outbox, empty_outbox from ietf.utils.test_data import make_test_data, create_person, make_review_data -from ietf.utils.test_utils import login_testing_unauthorized +from ietf.utils.test_utils import login_testing_unauthorized, reload_db_objects from ietf.group.factories import GroupFactory, RoleFactory, GroupEventFactory from ietf.meeting.factories import SessionFactory import ietf.group.views @@ -569,10 +569,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", @@ -598,6 +598,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_initial_charter(self): make_test_data() group = Group.objects.get(acronym="mars") diff --git a/ietf/group/views.py b/ietf/group/views.py index 8894e6486..832f2d2bb 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -38,7 +38,6 @@ import re from tempfile import mkstemp import datetime from collections import OrderedDict -import math import debug # pyflakes:ignore @@ -364,11 +363,11 @@ def construct_group_menu_context(request, group, selected, group_type, others): # actions actions = [] - is_chair = group.has_role(request.user, "chair") + is_admin = group.has_role(request.user, group.features.admin_roles) can_manage = can_manage_group(request.user, group) if group.features.has_milestones: - if group.state_id != "proposed" and (is_chair or can_manage): + if group.state_id != "proposed" and (is_admin or can_manage): actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs))) if group.features.has_documents: @@ -384,10 +383,10 @@ def construct_group_menu_context(request, group, selected, group_type, others): 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_chair or can_manage): + if group.state_id != "conclude" and (is_admin or can_manage): actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs))) - if group.features.customize_workflow and (is_chair or can_manage): + if group.features.customize_workflow and (is_admin or can_manage): actions.append((u"Customize workflow", urlreverse("ietf.group.views_edit.customize_workflow", kwargs=kwargs))) if group.state_id in ("active", "dormant") and not group.type_id in ["sdo", "rfcedtyp", "isoc", ] and can_manage: diff --git a/ietf/group/views_edit.py b/ietf/group/views_edit.py index 91ba6cbbc..2e9f22820 100644 --- a/ietf/group/views_edit.py +++ b/ietf/group/views_edit.py @@ -23,19 +23,31 @@ from ietf.person.fields import SearchableEmailsField from ietf.person.models import Person, Email from ietf.group.mails import ( email_admin_re_charter, email_personnel_change) from ietf.utils.ordereddict import insert_after_in_ordered_dict +from ietf.utils.text import skip_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(skip_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 @@ -211,7 +228,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': @@ -274,10 +292,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 = skip_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, @@ -336,10 +362,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, @@ -347,6 +369,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, ) @@ -400,8 +425,8 @@ def customize_workflow(request, group_type=None, acronym=None): if not group.features.customize_workflow: raise Http404 - if (not has_role(request.user, "Secretariat") and - not group.role_set.filter(name="chair", person__user=request.user)): + if not (can_manage_group(request.user, group) + or group.has_role(request.user, group.features.admin_roles)): return HttpResponseForbidden("You don't have permission to access this view") if group_type == "rg": diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index dd67211af..099f5d192 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -1827,7 +1827,7 @@ "fields": { "order": 7, "used": true, - "name": "No Review of Version", + "name": "Team Will not Review Version", "desc": "" }, "model": "name.reviewrequeststatename", @@ -1837,7 +1837,7 @@ "fields": { "order": 8, "used": true, - "name": "No Review of Document", + "name": "Team Will not Review Document", "desc": "" }, "model": "name.reviewrequeststatename", diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index 1b00490c3..cf54c76a1 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -360,7 +360,7 @@ def make_test_data(): return draft def make_review_data(doc): - team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="team", list_email="reviewteam@ietf.org") + team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="dir", list_email="reviewteam@ietf.org", parent=Group.objects.get(acronym="farfut")) for r in ReviewResultName.objects.filter(slug__in=["issues", "ready-issues", "ready", "not-ready"]): ReviewTeamResult.objects.create(team=team, result=r) @@ -383,7 +383,7 @@ def make_review_data(doc): 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="secretary", person=p, email=p.email_set.first(), group=team) + Role.objects.create(name_id="secr", person=p, email=p.email_set.first(), group=team) return review_req diff --git a/ietf/utils/text.py b/ietf/utils/text.py index 39df9a136..51b659ac7 100644 --- a/ietf/utils/text.py +++ b/ietf/utils/text.py @@ -4,8 +4,8 @@ def skip_prefix(text, prefix): else: return text -def skip_suffix(text, prefix): - if text.endswith(prefix): - return text[:-len(prefix)] +def skip_suffix(text, suffix): + if text.endswith(suffix): + return text[:-len(suffix)] else: return text From fde5bfb016edc7737b4505381486a977f10d1a86 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 6 Sep 2016 10:23:08 +0000 Subject: [PATCH 65/90] Remove accidentally committed debug message - Legacy-Id: 11925 --- ietf/manage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ietf/manage.py b/ietf/manage.py index 28733aa5f..695b686c3 100755 --- a/ietf/manage.py +++ b/ietf/manage.py @@ -9,7 +9,6 @@ import sys path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if not path in sys.path: sys.path.insert(0, path) - print "!jojiojoisdjf", path if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") From 7146e88067c05ebdabe96cbf1debd466750e7a06 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 6 Sep 2016 10:25:34 +0000 Subject: [PATCH 66/90] Remove another bit of debug code - Legacy-Id: 11926 --- ietf/api/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ietf/api/__init__.py b/ietf/api/__init__.py index e5e5b7129..699a5907c 100644 --- a/ietf/api/__init__.py +++ b/ietf/api/__init__.py @@ -79,9 +79,6 @@ for _app in settings.INSTALLED_APPS: _root, _name = _app.split('.', 1) if _root == 'ietf': if not '.' in _name: - if _name in _module_dict: - continue - _api = Api(api_name=_name) _module_dict[_name] = _api _api_list.append((_name, _api)) From aac9578d7d1ac441842911148ade27d6386df130 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 7 Sep 2016 16:05:34 +0000 Subject: [PATCH 67/90] Fix spelling mistake (- instead of =) in two form definitions - Legacy-Id: 11931 --- ietf/templates/ietfauth/testemail.html | 2 +- ietf/templates/ietfauth/whitelist_form.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ietf/templates/ietfauth/testemail.html b/ietf/templates/ietfauth/testemail.html index 9d6301d8e..1d21ac88f 100644 --- a/ietf/templates/ietfauth/testemail.html +++ b/ietf/templates/ietfauth/testemail.html @@ -19,7 +19,7 @@

Value of testmailcc: {{ cookie }}

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

- + {% csrf_token %} {% bootstrap_form form %} From ea6a016ee2ba1fc9909dcc092819e82704e60963 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 7 Sep 2016 16:08:01 +0000 Subject: [PATCH 68/90] Regularize use of ModelForm in ietfauth.forms, a cosmetic change, probably a left-over from way back - Legacy-Id: 11932 --- ietf/ietfauth/forms.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ietf/ietfauth/forms.py b/ietf/ietfauth/forms.py index 9779fd218..f00d21873 100644 --- a/ietf/ietfauth/forms.py +++ b/ietf/ietfauth/forms.py @@ -3,7 +3,6 @@ import re from django import forms from django.conf import settings from django.core.exceptions import ValidationError -from django.forms import ModelForm from django.db import models from django.contrib.auth.models import User from django.utils.html import mark_safe @@ -68,13 +67,13 @@ 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 def __init__(self, *args, **kwargs): - super(ModelForm, self).__init__(*args, **kwargs) + super(forms.ModelForm, self).__init__(*args, **kwargs) # blank ascii if it's the same as name self.fields["ascii"].required = self.fields["ascii"].widget.is_required = False @@ -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' ] From 0a8f3dbe0284e66b3325f88d967be0fe65a4edcf Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 19 Sep 2016 15:14:58 +0000 Subject: [PATCH 69/90] Make it so that we don't have two persons with the same name in the test data (prevents the duplicate warning email from being generated on each test run) - Legacy-Id: 11997 --- ietf/utils/test_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index e64bc607b..9879d100a 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -219,7 +219,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() From 6da25e6bd93a70225c05a423a86697beff97969a Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 19 Sep 2016 16:05:32 +0000 Subject: [PATCH 70/90] Add personal review overview page for reviewers, add page for editing reviewer availability settings, emailing the reviewer/secretary as necessary, add tests for these pages. Fix a bunch of bugs. - Legacy-Id: 11998 --- ietf/doc/tests_review.py | 24 +- ietf/group/tests_review.py | 84 ++++++- ietf/group/urls_info.py | 4 +- ietf/group/urls_info_details.py | 2 + ietf/group/views.py | 15 +- ietf/group/views_review.py | 222 +++++++++++++++++- ietf/ietfauth/tests.py | 38 ++- ietf/ietfauth/urls.py | 1 + ietf/ietfauth/views.py | 80 ++++++- ...atename_reviewresultname_reviewtypename.py | 2 +- .../0015_insert_review_name_data.py | 2 +- ietf/person/fields.py | 2 +- ietf/review/import_from_review_tool.py | 25 +- ietf/review/migrations/0001_initial.py | 34 ++- ietf/review/models.py | 63 ++++- ietf/review/resources.py | 49 +++- ietf/review/utils.py | 163 ++++++++++--- ietf/static/ietf/css/ietf.css | 12 + .../doc/mail/review_request_changed.txt | 9 - .../group/change_reviewer_settings.html | 87 +++++++ .../group/manage_review_requests.html | 8 +- ietf/templates/group/review_requests.html | 8 +- ietf/templates/ietfauth/review_overview.html | 173 ++++++++++++++ .../review/review_request_changed.txt | 9 + .../review/reviewer_availability_changed.txt | 8 + ietf/utils/test_data.py | 2 +- 26 files changed, 1028 insertions(+), 98 deletions(-) delete mode 100644 ietf/templates/doc/mail/review_request_changed.txt create mode 100644 ietf/templates/group/change_reviewer_settings.html create mode 100644 ietf/templates/ietfauth/review_overview.html create mode 100644 ietf/templates/review/review_request_changed.txt create mode 100644 ietf/templates/review/reviewer_availability_changed.txt diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index 1f16c8fd7..4c8709180 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -12,7 +12,7 @@ from pyquery import PyQuery import debug # pyflakes:ignore -from ietf.review.models import ReviewRequest, ReviewTeamResult, ReviewerSettings +from ietf.review.models import ReviewRequest, ReviewTeamResult, ReviewerSettings, ReviewWish, UnavailablePeriod import ietf.review.mailarch from ietf.person.models import Email, Person from ietf.name.models import ReviewResultName, ReviewRequestStateName, ReviewTypeName, DocRelationshipName @@ -166,9 +166,17 @@ class ReviewTests(TestCase): reviewer_settings = ReviewerSettings.objects.get(person__email=plain_email) reviewer_settings.filter_re = doc.name - reviewer_settings.unavailable_until = datetime.datetime.now() + datetime.timedelta(days=10) reviewer_settings.save() + 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) + assign_url = urlreverse('ietf.doc.views_review.assign_reviewer', kwargs={ "name": doc.name, "request_id": review_req.pk }) @@ -188,9 +196,10 @@ class ReviewTests(TestCase): plain_label = q("option[value=\"{}\"]".format(plain_email.address)).text().lower() self.assertIn("ready for", plain_label) self.assertIn("reviewed document before", plain_label) + self.assertIn("wishes to review", plain_label) self.assertIn("is author", plain_label) self.assertIn("regexp matches", plain_label) - self.assertIn("unavailable until", plain_label) + self.assertIn("unavailable", plain_label) # assign empty_outbox() @@ -422,6 +431,15 @@ class ReviewTests(TestCase): 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() diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index cd8c13e51..72da1c92c 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -6,10 +6,10 @@ from django.core.urlresolvers import reverse as urlreverse from ietf.utils.test_data import make_test_data, make_review_data from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects -from ietf.review.models import ReviewRequest, ReviewRequestStateName from ietf.doc.models import TelechatDocEvent from ietf.iesg.models import TelechatDate from ietf.person.models import Email, Person +from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewerSettings, UnavailablePeriod from ietf.review.utils import suggested_review_requests_for_team import ietf.group.views_review from ietf.utils.mail import outbox, empty_outbox @@ -190,3 +190,85 @@ class ReviewTests(TestCase): 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={ + "group_type": review_req.team.type_id, + "acronym": review_req.team.acronym, + "reviewer_email": review_req.reviewer_id, + }) + + login_testing_unauthorized(self, reviewer.user.username, url) + + # 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", + }) + 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(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()) diff --git a/ietf/group/urls_info.py b/ietf/group/urls_info.py index 6aff3b9bb..adf47d60c 100644 --- a/ietf/group/urls_info.py +++ b/ietf/group/urls_info.py @@ -4,7 +4,7 @@ from django.conf.urls import patterns, include from django.views.generic import RedirectView from django.conf import settings -from ietf.group import views, views_edit, views_review +from ietf.group import views, views_edit urlpatterns = patterns('', (r'^$', views.active_groups), @@ -21,7 +21,5 @@ urlpatterns = patterns('', (r'^email-aliases/$', 'ietf.group.views.email_aliases'), (r'^bofs/create/$', views_edit.edit, {'action': "create", }, "bof_create"), (r'^photos/$', views.chair_photos), - (r'^reviews/$', views.review_requests), - (r'^reviews/manage/$', views_review.manage_review_requests), (r'^%(acronym)s/' % settings.URL_REGEXPS, include('ietf.group.urls_info_details')), ) diff --git a/ietf/group/urls_info_details.py b/ietf/group/urls_info_details.py index bc6d2d540..62f013753 100644 --- a/ietf/group/urls_info_details.py +++ b/ietf/group/urls_info_details.py @@ -33,5 +33,7 @@ urlpatterns = patterns('', (r'^reviews/$', views.review_requests), (r'^reviews/manage/$', views_review.manage_review_requests), (r'^reviews/email-assignments/$', views_review.email_open_review_assignments), + (r'^reviewers/$', views_review.reviewer_overview), + (r'^reviewers/(?P[\w%+-.@]+)/settings/$', views_review.change_reviewer_settings), url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'), ) diff --git a/ietf/group/views.py b/ietf/group/views.py index c698f2d82..4b9bc82bf 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -70,7 +70,9 @@ from ietf.ietfauth.utils import has_role from ietf.meeting.utils import group_sessions from ietf.meeting.helpers import get_meeting from ietf.review.models import ReviewRequest -from ietf.review.utils import can_manage_review_requests_for_team, suggested_review_requests_for_team +from ietf.review.utils import (can_manage_review_requests_for_team, + suggested_review_requests_for_team, + current_unavailable_periods_for_reviewers) def roles(group, role_name): return Role.objects.filter(group=group, name=role_name).select_related("email", "person") @@ -669,12 +671,17 @@ def review_requests(request, acronym, group_type=None): team=group, state__in=("requested", "accepted") ).prefetch_related("reviewer", "type", "state").order_by("-time", "-id")) - open_review_requests += suggested_review_requests_for_team(group) + 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: - delta = today - r.deadline - r.due = max(0, delta.days) + r.due = max(0, (today - r.deadline).days) closed_review_requests = ReviewRequest.objects.filter( team=group, diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index 47b607ccf..40cda50eb 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -1,19 +1,28 @@ -from django.shortcuts import render, redirect -from django.http import Http404, HttpResponseForbidden +import datetime + +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 +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, assign_review_request_to_reviewer, close_review_request, setup_reviewer_field, - suggested_review_requests_for_team) + suggested_review_requests_for_team, + unavailability_periods_to_list, + current_unavailable_periods_for_reviewers, + email_reviewer_availability_change) +from ietf.group.models import Role from ietf.group.utils import get_group_or_404 from ietf.person.fields import PersonEmailChoiceField from ietf.utils.mail import send_mail_text +from ietf.utils.fields import DatepickerDateField +from ietf.ietfauth.utils import user_is_person class ManageReviewRequestForm(forms.Form): @@ -71,17 +80,25 @@ def manage_review_requests(request, acronym, group_type=None): 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( + 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")) - review_requests += suggested_review_requests_for_team(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, [])) + + review_requests = suggested_review_requests_for_team(group) + open_review_requests document_requests = extract_revision_ordered_review_requests_for_documents( ReviewRequest.objects.filter(state__in=("part-completed", "completed"), team=group).prefetch_related("result"), set(r.doc_id for r in review_requests), ) + # we need a mutable query dict for resetting upon saving with # conflicts query_dict = request.POST.copy() if request.method == "POST" else None @@ -204,6 +221,7 @@ def email_open_review_assignments(request, acronym, group_type=None): else: to = group.list_email subject = "Open review assignments in {}".format(group.acronym) + # FIXME: add rotation info body = render_to_string("group/email_open_review_assignments.txt", { "review_requests": review_requests, }) @@ -220,3 +238,195 @@ def email_open_review_assignments(request, acronym, group_type=None): 'form': form, }) + +@login_required +def reviewer_overview(request, acronym, group_type=None): + group = get_group_or_404(acronym, group_type) + if not group.features.has_reviews: + raise Http404 + + reviewer_roles = Role.objects.filter(name="reviewer", group=group) + + if not (any(user_is_person(request.user, r) for r in reviewer_roles) + or can_manage_review_requests_for_team(request.user, group)): + return HttpResponseForbidden("You do not have permission to perform this action") + + +class ReviewerSettingsForm(forms.ModelForm): + class Meta: + model = ReviewerSettings + fields = ['min_interval', 'filter_re', 'skip_next'] + +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 + back_url = urlreverse(ietf.group.views.review_requests, 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 = unavailability_periods_to_list().filter(person=reviewer, team=group) + + if request.method == "POST" and request.POST.get("action") == "add_period": + period_form = AddUnavailablePeriodForm(request.POST) + if period_form.is_valid(): + period = period_form.save(commit=False) + period.team = group + period.person = reviewer + period.save() + + today = datetime.date.today() + + in_the_past = period.end_date and period.end_date < today + + if not in_the_past: + msg = "Unavailable for review: {} - {} ({})".format( + period.start_date.isoformat(), + period.end_date.isoformat() if period.end_date else "indefinite", + period.get_availability_display(), + ) + + if period.availability == "unavailable": + # the secretary might need to reassign + # assignments, so mention the current ones + + review_reqs = ReviewRequest.objects.filter(state__in=["requested", "accepted"], reviewer=reviewer_role.email, team=group) + msg += "\n\n" + + if review_reqs: + msg += "{} is currently assigned to review:".format(reviewer_role.person) + for r in review_reqs: + msg += "\n\n" + msg += "{} (deadline: {})".format(r.doc_id, r.deadline.isoformat()) + else: + msg += "{} does not have any assignments currently.".format(reviewer_role.person) + + email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person) + + return HttpResponseRedirect(request.get_full_path()) + else: + period_form = AddUnavailablePeriodForm() + + if request.method == "POST" and request.POST.get("action") == "delete_period": + period_id = request.POST.get("period_id") + if period_id is not None: + for period in unavailable_periods: + if str(period.pk) == period_id: + period.delete() + + today = datetime.date.today() + + in_the_past = period.end_date and period.end_date < today + + if not in_the_past: + msg = "Removed unavailable period: {} - {} ({})".format( + period.start_date.isoformat(), + period.end_date.isoformat() if period.end_date else "indefinite", + period.get_availability_display(), + ) + + email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person) + + return HttpResponseRedirect(request.get_full_path()) + + for p in unavailable_periods: + if not p.end_date: + p.end_form = EndUnavailablePeriodForm(p.start_date, request.POST if request.method == "POST" and request.POST.get("action") == "end_period" else None) + + if request.method == "POST" and request.POST.get("action") == "end_period": + period_id = request.POST.get("period_id") + for period in unavailable_periods: + if str(period.pk) == period_id: + if not period.end_date and period.end_form.is_valid(): + period.end_date = period.end_form.cleaned_data["end_date"] + period.save() + + msg = "Set end date of unavailable period: {} - {} ({})".format( + period.start_date.isoformat(), + period.end_date.isoformat() if period.end_date else "indefinite", + period.get_availability_display(), + ) + + email_reviewer_availability_change(request, group, reviewer_role, msg, request.user.person) + + return HttpResponseRedirect(request.get_full_path()) + + + return render(request, 'group/change_reviewer_settings.html', { + 'group': group, + 'reviewer_email': reviewer_email, + 'back_url': back_url, + 'settings_form': settings_form, + 'period_form': period_form, + 'unavailable_periods': unavailable_periods, + }) diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index 335a5f489..309318054 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -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 from ietf.utils.decorators import skip_coverage import ietf.ietfauth.views @@ -338,6 +339,41 @@ 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() + + url = urlreverse(ietf.ietfauth.views.review_overview) + + login_testing_unauthorized(self, reviewer.user.username, url) + + # get + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(review_req.doc.name in unicontent(r)) + + # wish to review + r = self.client.post(url, { + "action": "add_wish", + 'doc': doc.pk, + "team": review_req.team_id, + }) + self.assertEqual(r.status_code, 302) + self.assertEqual(ReviewWish.objects.filter(doc=doc, team=review_req.team).count(), 1) + + # delete wish + r = self.client.post(url, { + "action": "delete_wish", + 'wish_id': ReviewWish.objects.get(doc=doc, team=review_req.team).pk, + }) + self.assertEqual(r.status_code, 302) + self.assertEqual(ReviewWish.objects.filter(doc=doc, team=review_req.team).count(), 0) + def test_htpasswd_file_with_python(self): # make sure we test both Python and call-out to binary settings.USE_PYTHON_HTDIGEST = True diff --git a/ietf/ietfauth/urls.py b/ietf/ietfauth/urls.py index e7c4f0ed0..bb40446a5 100644 --- a/ietf/ietfauth/urls.py +++ b/ietf/ietfauth/urls.py @@ -21,4 +21,5 @@ urlpatterns = patterns('ietf.ietfauth.views', url(r'^reset/confirm/(?P[^/]+)/$', 'confirm_password_reset'), url(r'^confirmnewemail/(?P[^/]+)/$', 'confirm_new_email'), (r'whitelist/add/?$', add_account_whitelist), + url(r'^review/$', 'review_overview'), ) diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index f3707b9bb..60e9a7796 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -32,7 +32,8 @@ # Copyright The IETF Trust 2007, All Rights Reserved -from datetime import datetime as DateTime, timedelta as TimeDelta +from datetime import datetime as DateTime, timedelta as TimeDelta, date as Date +from collections import defaultdict from django.conf import settings from django.http import Http404 #, HttpResponse, HttpResponseRedirect @@ -43,17 +44,21 @@ from django.contrib.auth.decorators import login_required import django.core.signing from django.contrib.sites.models import Site from django.contrib.auth.models import User +from django import forms import debug # pyflakes:ignore -from ietf.group.models import Role +from ietf.group.models import Role, Group from ietf.ietfauth.forms import RegistrationForm, PasswordForm, ResetPasswordForm, TestEmailForm, WhitelistForm from ietf.ietfauth.forms import get_person_form, RoleEmailForm, NewEmailForm from ietf.ietfauth.htpasswd import update_htpasswd_file from ietf.ietfauth.utils import role_required from ietf.mailinglists.models import Subscribed, Whitelisted from ietf.person.models import Person, Email, Alias +from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewWish +from ietf.review.utils import unavailability_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 unavailability_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() + t.unavailable_periods = unavailable_periods.get(t.pk, []) + t.role = roles.get(t.pk) + + if request.method == "POST" and request.POST.get("action") == "add_wish": + review_wish_form = AddReviewWishForm(teams, request.POST) + if review_wish_form.is_valid(): + ReviewWish.objects.get_or_create( + person=request.user.person, + doc=review_wish_form.cleaned_data["doc"], + team=review_wish_form.cleaned_data["team"], + ) + + return redirect(review_overview) + else: + review_wish_form = AddReviewWishForm(teams) + + if request.method == "POST" and request.POST.get("action") == "delete_wish": + wish_id = request.POST.get("wish_id") + if wish_id is not None: + ReviewWish.objects.filter(pk=wish_id, person=request.user.person).delete() + return redirect(review_overview) + + review_wishes = ReviewWish.objects.filter(person__user=request.user).prefetch_related("team") + + return render(request, 'ietfauth/review_overview.html', { + 'open_review_requests': open_review_requests, + 'closed_review_requests': closed_review_requests, + 'teams': teams, + 'review_wishes': review_wishes, + 'review_wish_form': review_wish_form, + }) diff --git a/ietf/name/migrations/0014_reviewrequeststatename_reviewresultname_reviewtypename.py b/ietf/name/migrations/0014_reviewrequeststatename_reviewresultname_reviewtypename.py index 59e57a76f..abef1e015 100644 --- a/ietf/name/migrations/0014_reviewrequeststatename_reviewresultname_reviewtypename.py +++ b/ietf/name/migrations/0014_reviewrequeststatename_reviewresultname_reviewtypename.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('group', '0008_auto_20160505_0523'), - ('name', '0010_new_liaison_names'), + ('name', '0013_add_group_type_verbose_name_data'), ] operations = [ diff --git a/ietf/name/migrations/0015_insert_review_name_data.py b/ietf/name/migrations/0015_insert_review_name_data.py index 97dca385f..34aaa2c86 100644 --- a/ietf/name/migrations/0015_insert_review_name_data.py +++ b/ietf/name/migrations/0015_insert_review_name_data.py @@ -55,7 +55,7 @@ def noop(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('name', '0011_reviewrequeststatename_reviewresultname_reviewtypename'), + ('name', '0014_reviewrequeststatename_reviewresultname_reviewtypename'), ('group', '0001_initial'), ('doc', '0001_initial'), ] diff --git a/ietf/person/fields.py b/ietf/person/fields.py index cc86d4fb4..84a33ffc1 100644 --- a/ietf/person/fields.py +++ b/ietf/person/fields.py @@ -50,7 +50,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 assert model in [ Email, Person ] diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 05d940041..8e5706997 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -16,8 +16,9 @@ django.setup() import datetime from collections import namedtuple from django.db import connections -from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewResultName -from ietf.review.models import ReviewRequestStateName, ReviewTypeName, ReviewTeamResult +from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, + ReviewRequestStateName, ReviewTypeName, ReviewTeamResult, + UnavailablePeriod) from ietf.group.models import Group, Role, RoleName from ietf.person.models import Person, Email, Alias import argparse @@ -113,8 +114,8 @@ with db_con.cursor() as c: print "created reviewer", reviewer.pk, unicode(reviewer).encode("utf-8") if autopolicy_days.get(row.autopolicy): - reviewer.frequency = autopolicy_days.get(row.autopolicy) - reviewer.unavailable_until = parse_timestamp(row.available) + reviewer.min_interval = autopolicy_days.get(row.autopolicy) + reviewer.filter_re = row.donotassign try: reviewer.skip_next = int(row.autopolicy) @@ -122,6 +123,22 @@ with db_con.cursor() as c: 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: + 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", + ) + + # review requests # check that we got the needed names diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index a5293604c..431600ce6 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -19,10 +19,9 @@ class Migration(migrations.Migration): name='ReviewerSettings', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('frequency', models.IntegerField(default=30, help_text=b'Can review every N days', choices=[(7, b'Once per week'), (14, b'Once per fortnight'), (30, b'Once per month'), (61, b'Once per two months'), (91, b'Once per quarter')])), - ('unavailable_until', models.DateTimeField(help_text=b'When will this reviewer be available again', null=True, blank=True)), - ('filter_re', models.CharField(max_length=255, blank=True)), - ('skip_next', models.IntegerField(default=0, help_text=b'Skip the next N review assignments')), + ('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')), ('person', models.ForeignKey(to='person.Person')), ('team', models.ForeignKey(to='group.Group')), ], @@ -63,4 +62,31 @@ class Migration(migrations.Migration): }, 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='UnavailablePeriod', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('start_date', models.DateField(default=datetime.date.today, help_text=b"Choose the start date so that you can still do a review if it's assigned just before the start date - this usually means you should mark yourself unavailable for assignment some time before you are actually away.")), + ('end_date', models.DateField(help_text=b'Leaving the end date blank means that the period continues indefinitely. You can end it later.', null=True, blank=True)), + ('availability', models.CharField(max_length=30, choices=[(b'canfinish', b'Can do follow-ups'), (b'unavailable', b'Completely unavailable')])), + ('person', models.ForeignKey(to='person.Person')), + ('team', models.ForeignKey(to='group.Group')), + ], + options={ + }, + bases=(models.Model,), + ), ] diff --git a/ietf/review/models.py b/ietf/review/models.py index 7a21e6bbc..52ce2d42f 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -13,28 +13,69 @@ class ReviewerSettings(models.Model): reviewer and team.""" team = models.ForeignKey(Group) person = models.ForeignKey(Person) - FREQUENCIES = [ + INTERVALS = [ (7, "Once per week"), (14, "Once per fortnight"), (30, "Once per month"), (61, "Once per two months"), (91, "Once per quarter"), ] - frequency = models.IntegerField(default=30, help_text="Can review every N days", choices=FREQUENCIES) - unavailable_until = models.DateTimeField(blank=True, null=True, help_text="When will this reviewer be available again") - filter_re = models.CharField(max_length=255, blank=True) - skip_next = models.IntegerField(default=0, help_text="Skip the next N review assignments") + 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") def __unicode__(self): return u"{} in {}".format(self.person, self.team) +class UnavailablePeriod(models.Model): + team = models.ForeignKey(Group) + 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) + 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 ReviewTeamResult(models.Model): - """Captures that a result name is valid for a given team for new - reviews. This also implicitly defines which teams are review - teams - if there are no possible review results valid for a given - team, it can't be a review team.""" - team = models.ForeignKey(Group) - result = models.ForeignKey(ReviewResultName) + """Captures that a result name is valid for a given team for new + reviews. This also implicitly defines which teams are review + teams - if there are no possible review results valid for a given + team, it can't be a review team.""" + team = models.ForeignKey(Group) + result = models.ForeignKey(ReviewResultName) + + def __unicode__(self): + return u"{} in {}".format(self.result.name, self.group.acronym) class ReviewRequest(models.Model): """Represents a request for a review and the process it goes through. diff --git a/ietf/review/resources.py b/ietf/review/resources.py index f86145fb4..be7364c7c 100644 --- a/ietf/review/resources.py +++ b/ietf/review/resources.py @@ -7,7 +7,8 @@ from tastypie.cache import SimpleCache from ietf import api from ietf.api import ToOneField # pyflakes:ignore -from ietf.review.models import ReviewerSettings, ReviewRequest, ReviewTeamResult +from ietf.review.models import (ReviewerSettings, ReviewRequest, ReviewTeamResult, + UnavailablePeriod, ReviewWish) from ietf.person.resources import PersonResource @@ -22,8 +23,7 @@ class ReviewerSettingsResource(ModelResource): #resource_name = 'reviewer' filtering = { "id": ALL, - "frequency": ALL, - "unavailable_until": ALL, + "min_interval": ALL, "filter_re": ALL, "skip_next": ALL, "team": ALL_WITH_RELATIONS, @@ -82,3 +82,46 @@ class ReviewTeamResultResource(ModelResource): } api.review.register(ReviewTeamResultResource()) + + +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()) + diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 3b5526f36..021586c3a 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -2,6 +2,7 @@ import datetime, re from collections import defaultdict from django.db import models +from django.db.models import Q from django.core.urlresolvers import reverse as urlreverse from ietf.group.models import Group, Role @@ -9,7 +10,8 @@ from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent, Documen from ietf.iesg.models import TelechatDate from ietf.person.models import Person, Email from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream -from ietf.review.models import ReviewRequest, ReviewRequestStateName, ReviewTypeName, ReviewerSettings +from ietf.review.models import (ReviewRequest, ReviewRequestStateName, ReviewTypeName, + ReviewerSettings, UnavailablePeriod, ReviewWish) from ietf.utils.mail import send_mail from ietf.doc.utils import extract_complete_replaces_ancestor_mapping_for_docs @@ -34,12 +36,13 @@ def can_manage_review_requests_for_team(user, team, allow_non_team_personnel=Tru or (allow_non_team_personnel and has_role(user, "Secretariat"))) def review_requests_to_list_for_doc(doc): - return extract_revision_ordered_review_requests_for_documents( - ReviewRequest.objects.filter( - state__in=["requested", "accepted", "part-completed", "completed"], - ).prefetch_related("result"), - [doc.name] - ).get(doc.pk, []) + request_qs = ReviewRequest.objects.filter( + state__in=["requested", "accepted", "part-completed", "completed"], + ).prefetch_related("result") + + doc_names = [doc.name] + + return extract_revision_ordered_review_requests_for_documents(request_qs, doc_names).get(doc.pk, []) def no_review_from_teams_on_doc(doc, rev): return Group.objects.filter( @@ -48,6 +51,27 @@ def no_review_from_teams_on_doc(doc, rev): reviewrequest__state="no-review-version", ).distinct() +def unavailability_periods_to_list(past_days=30): + 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 make_new_review_request_from_existing(review_req): obj = ReviewRequest() obj.time = review_req.time @@ -61,6 +85,47 @@ def make_new_review_request_from_existing(review_req): 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.""" @@ -83,22 +148,23 @@ def email_review_request_change(request, review_req, subject, msg, by, notify_se if e not in to: to.append(e) - if notify_secretary: - extract_email_addresses(Role.objects.filter(name__in=["secr", "delegate"], group=review_req.team).distinct()) - if notify_reviewer: - extract_email_addresses([review_req.reviewer]) - if notify_requested_by: - extract_email_addresses([review_req.requested_by.email()]) - + extract_email_addresses(Role.objects.filter(name="secr", group=team).distinct()) + + extract_email_addresses([reviewer_role]) + if not to: return - url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": review_req.doc.name, "request_id": review_req.pk }) + 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, "doc/mail/review_request_changed.txt", { - "review_req_url": url, - "review_req": review_req, + 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): @@ -181,7 +247,6 @@ def suggested_review_requests_for_team(team): continue requests[doc.pk] = ReviewRequest( - time=None, type=last_call_type, doc=doc, team=team, @@ -211,7 +276,6 @@ def suggested_review_requests_for_team(team): continue requests[doc.pk] = ReviewRequest( - time=None, type=telechat_type, doc=doc, team=team, @@ -306,10 +370,19 @@ def make_assignment_choices(email_queryset, review_req): 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) - reviewers = { r.person_id: r for r in ReviewerSettings.objects.filter(team=team, person__in=[e.person_id for e in possible_emails]) } + # 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() # time since past assignment latest_assignment_for_reviewer = dict(ReviewRequest.objects.filter( @@ -328,8 +401,8 @@ def make_assignment_choices(email_queryset, review_req): has_reviewed_previous = set(has_reviewed_previous.values_list("reviewer", flat=True)) - # review indications - would_like_to_review = set() # FIXME: fill in + # 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 = {} @@ -343,23 +416,19 @@ def make_assignment_choices(email_queryset, review_req): for e in DocumentAuthor.objects.filter(document=doc, author__in=possible_emails).values_list("author", flat=True): connections[e] = "is author of document" - now = datetime.datetime.now() + # unavailable periods + unavailable_periods = current_unavailable_periods_for_reviewers(team) - def add_boolean_score(scores, direction, expr, explanation): - scores.append(int(bool(expr)) * direction) - if expr: - explanations.append(explanation) + now = datetime.datetime.now() ranking = [] for e in possible_emails: - reviewer = reviewers.get(e.person_id) - if not reviewer: - reviewer = ReviewerSettings() + settings = reviewer_settings.get(e.person_id) days_past = None latest = latest_assignment_for_reviewer.get(e.pk) if latest is not None: - days_past = (now - latest).days - reviewer.frequency + days_past = (now - latest).days - settings.min_interval if days_past is None: ready_for = "first time" @@ -369,7 +438,7 @@ def make_assignment_choices(email_queryset, review_req): ready_for = "ready for {} {}".format(d, "day" if d == 1 else "days") else: d = -d - ready_for = "frequency exceeded, ready in {} {}".format(d, "day" if d == 1 else "days") + ready_for = "max frequency exceeded, ready in {} {}".format(d, "day" if d == 1 else "days") # we sort the reviewers by separate axes, listing the most @@ -377,13 +446,31 @@ def make_assignment_choices(email_queryset, review_req): scores = [] explanations = [] + def add_boolean_score(direction, expr, explanation=None): + scores.append(direction if expr else -direction) + if expr and explanation: + explanations.append(explanation) + explanations.append(ready_for) # show ready for explanation first, but sort it after the other issues - add_boolean_score(scores, +1, e.pk in has_reviewed_previous, "reviewed document before") - add_boolean_score(scores, +1, e.pk in would_like_to_review, "wants to review document") - add_boolean_score(scores, -1, e.pk in connections, connections.get(e.pk)) # reviewer is somehow connected: bad - add_boolean_score(scores, -1, reviewer.filter_re and any(re.search(reviewer.filter_re, n) for n in aliases), "filter regexp matches") - add_boolean_score(scores, -1, reviewer.unavailable_until and reviewer.unavailable_until > now, "unavailable until {}".format((reviewer.unavailable_until or now).strftime("%Y-%m-%d %H:%M:%S"))) + 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)) + + 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") scores.append(100000 if days_past is None else days_past) diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 1a189fa5b..f872a840d 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -515,6 +515,18 @@ form.email-open-review-assignments [name=body] { font-family: monospace; } +table.unavailable-periods td { + padding-right: 0.5em; +} + +.unavailable-period-past { + color: #777; +} + +.unavailable-period-active { + font-weight: bold; +} + /* === Photo pages ========================================================== */ .photo-name { diff --git a/ietf/templates/doc/mail/review_request_changed.txt b/ietf/templates/doc/mail/review_request_changed.txt deleted file mode 100644 index 190911b8f..000000000 --- a/ietf/templates/doc/mail/review_request_changed.txt +++ /dev/null @@ -1,9 +0,0 @@ -{% autoescape off %} - {{ review_req.type.name }} review of: {{ review_req.doc.name }} ({% if review_req.requested_rev %}rev. {{ review_req.requested_rev }}{% else %}no specific version{% endif %}) - Deadline: {{ review_req.deadline|date:"Y-m-d" }} - - {{ review_req_url }} - -{{ msg|wordwrap:72 }} - -{% endautoescape %} diff --git a/ietf/templates/group/change_reviewer_settings.html b/ietf/templates/group/change_reviewer_settings.html new file mode 100644 index 000000000..3c5ae3b6c --- /dev/null +++ b/ietf/templates/group/change_reviewer_settings.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block pagehead %} + +{% endblock %} + +{% block title %}Change reviewer settings for {{ group.acronym }} for {{ reviewer_email }}{% endblock %} + +{% block content %} + {% origin %} + +

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

+ +

Settings

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

Unavailable periods

+ +

You can register periods where reviews should not be assigned.

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

No periods found.

+ {% endif %} + + + +
+

Add a new period

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

+ Back +

+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index 0c76826b5..39ce651f3 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -51,7 +51,7 @@ @@ -84,7 +84,11 @@ {% if r.reviewer %} - + {% else %} {% endif %} diff --git a/ietf/templates/group/review_requests.html b/ietf/templates/group/review_requests.html index 8e1df839a..0959d0117 100644 --- a/ietf/templates/group/review_requests.html +++ b/ietf/templates/group/review_requests.html @@ -16,7 +16,7 @@

Open review requests

{% if open_review_requests %} - +
@@ -31,14 +31,16 @@ - + + {% if review_req.result %} + {% endif %} {% endif %} {% if doc.external_url %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index fbccf36b6..6cfbb90d9 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -44,7 +44,7 @@ - + diff --git a/ietf/templates/doc/review_request_summary.html b/ietf/templates/doc/review_request_summary.html index 22bf04dcd..4d810fdcf 100644 --- a/ietf/templates/doc/review_request_summary.html +++ b/ietf/templates/doc/review_request_summary.html @@ -1,8 +1,8 @@
{% if review_request.state_id == "completed" or review_request.state_id == "part-completed" %} - {{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev or review_request.doc_id != current_doc_name %} (of {% if review_request.doc_id != current_doc_name %}{{ review_request.doc_id }}{% endif %}-{{ review_request.reviewed_rev }}){% endif %}: - {{ review_request.result.name }} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %} + {{ 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 %} - reviewer: {{ review_request.reviewer.person }} {% else %} diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index 39ce651f3..e8ff4e8d1 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -16,7 +16,7 @@

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

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

@@ -59,7 +59,7 @@ {% for rlatest in r.latest_reqs %}
- prev. review of {% if rlatest.doc_id != r.doc_id %}{{ rlatest.doc_id }}{% endif %}-{{ rlatest.reviewed_rev }}: - {{ rlatest.result.name }} + {% if rlatest.result %}{{ rlatest.result.name }}{% else %}result unavail.{% endif %} (diff){% if not forloop.last %},{% endif %}
@@ -129,7 +129,7 @@
Request
{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %} {{ r.type.name }}{% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %}{% if r.pk %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} {{ r.deadline|date:"Y-m-d" }} {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} {% if r.reviewer %} - {{ r.reviewer.person }} {% if r.state_id == "accepted" %}Accepted{% endif %} + {{ r.reviewer.person }} + {% if r.state_id == "accepted" %}Accepted{% endif %} + {% if r.reviewer_unavailable %}Unavailable{% endif %} {% elif r.pk != None %} not yet assigned {% endif %} diff --git a/ietf/templates/ietfauth/review_overview.html b/ietf/templates/ietfauth/review_overview.html new file mode 100644 index 000000000..a28b6d257 --- /dev/null +++ b/ietf/templates/ietfauth/review_overview.html @@ -0,0 +1,173 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %} + +{% load bootstrap3 static %} + +{% block pagehead %} + + +{% endblock %} + +{% block title %}Review overview for {{ request.user }}{% endblock %} + +{% block content %} + {% origin %} +

Review overview for {{ request.user }}

+ +

Assigned reviews

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

You do not have any open review requests assigned.

+ {% endif %} + + +

Latest closed requests

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

Did not find any closed requests assigned to you.

+ {% endif %} + + +

Review wishes

+ + {% if review_wishes %} +

You have indicated that you would like to review:

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

You do not have any review wishes.

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

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

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

Settings for {{ t }}

+ + + + + + + + + + + + + + + + + +
Can review{{ t.reviewer_settings.get_min_interval_display }}
Skip next assignments{{ t.reviewer_settings.skip_next }}
Filter regexp{{ t.reviewer_settings.filter_re|default:"(None)" }}
Unavailable periods + {% if t.unavailable_periods %} + + {% for o in t.unavailable_periods %} + + + + + {% endfor %} +
{{ o.start_date }} - {{ o.end_date|default:"" }}{{ o.get_availability_display }}
+ {% else %} + (No periods) + {% endif %} +
+ + + + + {% empty %} +

Settings

+ +

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

+ {% endfor %} + +{% endblock %} + +{% block js %} + + +{% endblock %} diff --git a/ietf/templates/review/review_request_changed.txt b/ietf/templates/review/review_request_changed.txt new file mode 100644 index 000000000..4cd471fc6 --- /dev/null +++ b/ietf/templates/review/review_request_changed.txt @@ -0,0 +1,9 @@ +{% autoescape off %} +{{ review_req.type.name }} review of: {{ review_req.doc.name }} ({% if review_req.requested_rev %}rev. {{ review_req.requested_rev }}{% else %}no specific version{% endif %}) +Deadline: {{ review_req.deadline|date:"Y-m-d" }} + +{{ review_req_url }} + +{{ msg|wordwrap:72 }} + +{% endautoescape %} diff --git a/ietf/templates/review/reviewer_availability_changed.txt b/ietf/templates/review/reviewer_availability_changed.txt new file mode 100644 index 000000000..63c1fdcb0 --- /dev/null +++ b/ietf/templates/review/reviewer_availability_changed.txt @@ -0,0 +1,8 @@ +{% autoescape off %}{% filter wordwrap:72 %} +Reviewer availability of {{ reviewer }} in {{ team.acronym }} changed by {{ by }}. + +{{ msg }} + +{{ reviewer_overview_url }} + +{% endfilter %}{% endautoescape %} diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index 9879d100a..cd0f9235d 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -400,7 +400,7 @@ def make_review_data(doc): p = Person.objects.get(user__username="plain") email = p.email_set.first() Role.objects.create(name_id="reviewer", person=p, email=email, group=team) - ReviewerSettings.objects.create(team=team, person=p, frequency=14, skip_next=0) + ReviewerSettings.objects.create(team=team, person=p, min_interval=14, skip_next=0) review_req = ReviewRequest.objects.create( doc=doc, From 6b3e93d5c05ea77bbbd11a90f95d8c5e870f2d72 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 19 Sep 2016 16:26:18 +0000 Subject: [PATCH 71/90] Add test of duplicating a person name - after having fixed the test data, that's no longer in any other test case - Legacy-Id: 11999 --- ietf/person/tests.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ietf/person/tests.py b/ietf/person/tests.py index 0f0cbfeb6..b5d2e9d72 100644 --- a/ietf/person/tests.py +++ b/ietf/person/tests.py @@ -6,8 +6,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): @@ -48,3 +50,8 @@ class PersonTests(TestCase): r = self.client.get(photo_url) self.assertEqual(r.status_code, 200) + 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()) From 99beb58291714ab5a357eb64bdc516f2211e52a5 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 21 Sep 2016 16:52:50 +0000 Subject: [PATCH 72/90] Implement a round-robin rotation scheme for reviewers instead of relying on when they last reviewed. In-order assignments automatically move the rotation forwards while out-of-order assignments increment the skip next of the assigned reviewer. Include rotation in open review assignments email. Fix a couple of issues in the importer. - Legacy-Id: 12015 --- ietf/doc/tests_review.py | 115 ++++++++++++- ietf/group/tests_review.py | 7 +- ietf/group/views_review.py | 6 +- ietf/review/import_from_review_tool.py | 24 ++- ietf/review/migrations/0001_initial.py | 11 ++ ietf/review/models.py | 7 + ietf/review/resources.py | 21 ++- ietf/review/utils.py | 162 ++++++++++++++---- .../group/email_open_review_assignments.txt | 6 +- ietf/utils/test_data.py | 3 +- 10 files changed, 313 insertions(+), 49 deletions(-) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index 4c8709180..54eabbe6f 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -12,13 +12,16 @@ from pyquery import PyQuery import debug # pyflakes:ignore -from ietf.review.models import ReviewRequest, ReviewTeamResult, ReviewerSettings, ReviewWish, UnavailablePeriod +from ietf.review.models import (ReviewRequest, ReviewTeamResult, 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 +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 @@ -133,6 +136,94 @@ class ReviewTests(TestCase): 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() @@ -166,6 +257,7 @@ class ReviewTests(TestCase): reviewer_settings = ReviewerSettings.objects.get(person__email=plain_email) reviewer_settings.filter_re = doc.name + reviewer_settings.skip_next = 1 reviewer_settings.save() UnavailablePeriod.objects.create( @@ -177,6 +269,14 @@ class ReviewTests(TestCase): 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 }) @@ -194,16 +294,18 @@ class ReviewTests(TestCase): self.assertEqual(r.status_code, 200) q = PyQuery(r.content) plain_label = q("option[value=\"{}\"]".format(plain_email.address)).text().lower() - self.assertIn("ready for", plain_label) self.assertIn("reviewed document before", plain_label) self.assertIn("wishes to review", plain_label) self.assertIn("is author", plain_label) self.assertIn("regexp matches", plain_label) - self.assertIn("unavailable", plain_label) + self.assertIn("unavailable indefinitely", plain_label) + self.assertIn("skip next 1", plain_label) + self.assertIn("#1", plain_label) # assign empty_outbox() - reviewer = Email.objects.filter(role__name="reviewer", role__group=review_req.team).first() + 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) @@ -212,12 +314,13 @@ class ReviewTests(TestCase): 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).exclude(pk=reviewer.pk).first() + 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) diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index 72da1c92c..85260a67b 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -1,6 +1,6 @@ import datetime -#from pyquery import PyQuery +from pyquery import PyQuery from django.core.urlresolvers import reverse as urlreverse @@ -175,7 +175,10 @@ class ReviewTests(TestCase): r = self.client.get(url) self.assertEqual(r.status_code, 200) - self.assertTrue(review_req1.doc.name in unicontent(r)) + 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, { diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index 40cda50eb..108a4d189 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -16,7 +16,8 @@ from ietf.review.utils import (can_manage_review_requests_for_team, close_review suggested_review_requests_for_team, unavailability_periods_to_list, current_unavailable_periods_for_reviewers, - email_reviewer_availability_change) + email_reviewer_availability_change, + reviewer_rotation_list) from ietf.group.models import Role from ietf.group.utils import get_group_or_404 from ietf.person.fields import PersonEmailChoiceField @@ -221,9 +222,10 @@ def email_open_review_assignments(request, acronym, group_type=None): else: to = group.list_email subject = "Open review assignments in {}".format(group.acronym) - # FIXME: add rotation info + body = render_to_string("group/email_open_review_assignments.txt", { "review_requests": review_requests, + "rotation_list": reviewer_rotation_list(group)[:10], }) form = EmailOpenAssignmentsForm(initial={ diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 8e5706997..5da78119d 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -18,7 +18,7 @@ from collections import namedtuple from django.db import connections from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, ReviewRequestStateName, ReviewTypeName, ReviewTeamResult, - UnavailablePeriod) + UnavailablePeriod, NextReviewerInTeam) from ietf.group.models import Group, Role, RoleName from ietf.person.models import Person, Email, Alias import argparse @@ -138,9 +138,6 @@ with db_con.cursor() as c: availability="unavailable", ) - -# review requests - # check that we got the needed names results = { n.name.lower(): n for n in ReviewResultName.objects.all() } @@ -150,8 +147,23 @@ with db_con.cursor() as c: missing_result_names = set(summaries) - set(results.keys()) assert not missing_result_names, "missing result names: {} {}".format(missing_result_names, results.keys()) - for s in summaries: - ReviewTeamResult.objects.get_or_create(team=team, result=results[s]) + +# 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: + ReviewTeamResult.objects.get_or_create(team=team, result=results[s]) + +# review requests states = { n.slug: n for n in ReviewRequestStateName.objects.all() } # map some names diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index 431600ce6..ec090266c 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -15,6 +15,17 @@ class Migration(migrations.Migration): ] 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='ReviewerSettings', fields=[ diff --git a/ietf/review/models.py b/ietf/review/models.py index 52ce2d42f..c60825576 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -77,6 +77,13 @@ class ReviewTeamResult(models.Model): def __unicode__(self): return u"{} in {}".format(self.result.name, self.group.acronym) +class NextReviewerInTeam(models.Model): + team = models.ForeignKey(Group) + next_reviewer = models.ForeignKey(Person) + + def __unicode__(self): + return u"{} next in {}".format(self.next_reviewer, self.team) + 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 diff --git a/ietf/review/resources.py b/ietf/review/resources.py index be7364c7c..638f385b6 100644 --- a/ietf/review/resources.py +++ b/ietf/review/resources.py @@ -8,7 +8,7 @@ from ietf import api from ietf.api import ToOneField # pyflakes:ignore from ietf.review.models import (ReviewerSettings, ReviewRequest, ReviewTeamResult, - UnavailablePeriod, ReviewWish) + UnavailablePeriod, ReviewWish, NextReviewerInTeam) from ietf.person.resources import PersonResource @@ -125,3 +125,22 @@ class ReviewWishResource(ModelResource): } 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()) + diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 021586c3a..abfaf56af 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -1,8 +1,7 @@ import datetime, re from collections import defaultdict -from django.db import models -from django.db.models import Q +from django.db.models import Q, Max from django.core.urlresolvers import reverse as urlreverse from ietf.group.models import Group, Role @@ -11,7 +10,7 @@ from ietf.iesg.models import TelechatDate from ietf.person.models import Person, Email from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream from ietf.review.models import (ReviewRequest, ReviewRequestStateName, ReviewTypeName, - ReviewerSettings, UnavailablePeriod, ReviewWish) + ReviewerSettings, UnavailablePeriod, ReviewWish, NextReviewerInTeam) from ietf.utils.mail import send_mail from ietf.doc.utils import extract_complete_replaces_ancestor_mapping_for_docs @@ -72,6 +71,56 @@ def current_unavailable_periods_for_reviewers(team): return res +def reviewer_rotation_list(team): + """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) + + return reviewers[next_reviewer_index:] + reviewers[:next_reviewer_index] + +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 + def make_new_review_request_from_existing(review_req): obj = ReviewRequest() obj.time = review_req.time @@ -184,6 +233,8 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): review_req.reviewer = reviewer review_req.save() + possibly_advance_next_reviewer_for_team(review_req.team, review_req.reviewer.person_id) + DocEvent.objects.create( type="changed_review_request", doc=review_req.doc, @@ -201,6 +252,59 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): "%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): + # 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 != assigned_review_to_person_id: + 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 != assigned_review_to_person_id: + reviewers_to_skip.add(person_id) + + rotation_list = [p.pk for p in reviewer_rotation_list(team) if p.pk not in reviewers_to_skip] + + if not rotation_list: + return + + def reviewer_at_index(i): + 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 reviewer_at_index(current_i) == assigned_review_to_person_id: + # move 1 ahead + current_i += 1 + else: + settings = reviewer_settings_for(assigned_review_to_person_id) + settings.skip_next += 1 + settings.save() + + 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 @@ -384,10 +488,11 @@ def make_assignment_choices(email_queryset, review_req): if p not in reviewer_settings: reviewer_settings[p] = ReviewerSettings() - # time since past assignment - latest_assignment_for_reviewer = dict(ReviewRequest.objects.filter( - reviewer__in=possible_emails, - ).values_list("reviewer").annotate(models.Max("time"))) + # 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( @@ -419,28 +524,10 @@ def make_assignment_choices(email_queryset, review_req): # unavailable periods unavailable_periods = current_unavailable_periods_for_reviewers(team) - now = datetime.datetime.now() - ranking = [] for e in possible_emails: settings = reviewer_settings.get(e.person_id) - days_past = None - latest = latest_assignment_for_reviewer.get(e.pk) - if latest is not None: - days_past = (now - latest).days - settings.min_interval - - if days_past is None: - ready_for = "first time" - else: - d = int(round(days_past)) - if d > 0: - ready_for = "ready for {} {}".format(d, "day" if d == 1 else "days") - else: - d = -d - ready_for = "max frequency exceeded, ready in {} {}".format(d, "day" if d == 1 else "days") - - # we sort the reviewers by separate axes, listing the most # important things first scores = [] @@ -451,8 +538,7 @@ def make_assignment_choices(email_queryset, review_req): if expr and explanation: explanations.append(explanation) - explanations.append(ready_for) # show ready for explanation first, but sort it after the other issues - + # 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) @@ -466,15 +552,31 @@ def make_assignment_choices(email_queryset, review_req): 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") - scores.append(100000 if days_past is None else days_past) + # skip next + scores.append(-settings.skip_next) + if settings.skip_next > 0: + explanations.append("skip next {}".format(settings.skip_next)) - label = "{}: {}".format(e.person, "; ".join(explanations)) + 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, diff --git a/ietf/templates/group/email_open_review_assignments.txt b/ietf/templates/group/email_open_review_assignments.txt index c9bb458e4..ef6b3f692 100644 --- a/ietf/templates/group/email_open_review_assignments.txt +++ b/ietf/templates/group/email_open_review_assignments.txt @@ -1,4 +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 %}{% endautoescape %} +{% endfor %} +{% if rotation_list %}Next in the reviewer rotation: + +{% for p in rotation_list %} {{ p }} +{% endfor %}{% endif %}{% endautoescape %} diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index cd0f9235d..36f033548 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -33,6 +33,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) @@ -393,7 +394,7 @@ def make_test_data(): return draft def make_review_data(doc): - team = Group.objects.create(state_id="active", acronym="reviewteam", name="Review Team", type_id="dir", list_email="reviewteam@ietf.org", parent=Group.objects.get(acronym="farfut")) + 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"]): ReviewTeamResult.objects.create(team=team, result=r) From c586feb5798916f245d8ac100bf1fe2029b05b1f Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Fri, 23 Sep 2016 15:29:27 +0000 Subject: [PATCH 73/90] Fix an old bug in draft resurrection, it was using the long gone doc.idinternal in an explanation - Legacy-Id: 12027 --- ietf/doc/views_draft.py | 13 +++++++++---- ietf/templates/doc/draft/resurrect.html | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index e94ea2d59..42b6cbb7b 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -792,11 +792,15 @@ def resurrect(request, name): if doc.get_state_slug() != "expired": raise Http404 + resurrect_requested_by = None + e = doc.latest_event(type__in=('requested_resurrect', "completed_resurrect")) + if e.type == 'requested_resurrect': + resurrect_requested_by = e.by + if request.method == 'POST': - e = doc.latest_event(type__in=('requested_resurrect', "completed_resurrect")) - if e and e.type == 'requested_resurrect': - email_resurrection_completed(request, doc, requester=e.by) - + if resurrect_requested_by: + email_resurrection_completed(request, doc, requester=resurrect_requested_by) + events = [] e = DocEvent(doc=doc, by=request.user.person) e.type = "completed_resurrect" @@ -812,6 +816,7 @@ def resurrect(request, name): return render_to_response('doc/draft/resurrect.html', dict(doc=doc, + resurrect_requested_by=resurrect_requested_by, back_url=doc.get_absolute_url()), context_instance=RequestContext(request)) diff --git a/ietf/templates/doc/draft/resurrect.html b/ietf/templates/doc/draft/resurrect.html index 04c803e41..472a7f369 100644 --- a/ietf/templates/doc/draft/resurrect.html +++ b/ietf/templates/doc/draft/resurrect.html @@ -14,7 +14,7 @@

Resurrect {{ doc }}?

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

From 4c7b2847ba6e821db62ca3e2b732e4eb9c7715e7 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 3 Oct 2016 15:52:32 +0000 Subject: [PATCH 74/90] Add a utility function for extracting information about review requests for a given set of teams/reviewers (making it trivial to compute statistics), revamp the related doc event code to support this by referencing the review request directly, add a reviewer overview page with recent performance for each reviewer as well as settings/unavailable periods. Fix some bugs and shuffle some of the review code a bit around. Finish the importer from the previous Perl-based review tool, importing log entries, figuring out whether a given review is early/telechat/last call and fixing corner cases. - Legacy-Id: 12080 --- ietf/community/models.py | 3 + .../doc/migrations/0015_auto_20160927_0713.py | 33 ++ ietf/doc/models.py | 10 +- ietf/doc/tests_review.py | 6 +- ietf/doc/views_review.py | 76 ++-- ietf/group/features.py | 2 +- ietf/group/tests_info.py | 26 -- ietf/group/tests_review.py | 25 ++ ietf/group/urls_info_details.py | 2 +- ietf/group/utils.py | 89 +++- ietf/group/views.py | 154 +------ ietf/group/views_review.py | 149 +++++-- ietf/ietfauth/views.py | 6 +- .../0015_insert_review_name_data.py | 1 - ietf/review/import_from_review_tool.py | 393 ++++++++++++++++-- ietf/review/migrations/0001_initial.py | 2 +- ietf/review/models.py | 2 +- ietf/review/utils.py | 140 +++++-- ietf/static/ietf/css/ietf.css | 8 + ietf/templates/doc/document_review.html | 2 + ietf/templates/doc/review/review_request.html | 2 +- .../templates/doc/review_request_summary.html | 4 +- .../group/manage_review_requests.html | 6 +- ietf/templates/group/review_requests.html | 2 +- ietf/templates/group/reviewer_overview.html | 49 +++ ietf/templates/ietfauth/review_overview.html | 9 +- .../{doc/mail => review}/completed_review.txt | 0 .../partially_completed_review.txt | 0 .../reviewer_assignment_rejected.txt | 0 ietf/templates/review/unavailable_table.html | 8 + 30 files changed, 868 insertions(+), 341 deletions(-) create mode 100644 ietf/doc/migrations/0015_auto_20160927_0713.py create mode 100644 ietf/templates/group/reviewer_overview.html rename ietf/templates/{doc/mail => review}/completed_review.txt (100%) rename ietf/templates/{doc/mail => review}/partially_completed_review.txt (100%) rename ietf/templates/{doc/mail => review}/reviewer_assignment_rejected.txt (100%) create mode 100644 ietf/templates/review/unavailable_table.html diff --git a/ietf/community/models.py b/ietf/community/models.py index ed7238a6c..6839801ce 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -98,6 +98,9 @@ def notify_events(sender, instance, **kwargs): if instance.doc.type_id != 'draft': return + if getattr(instance, "skip_community_list_notification", False): + return + from ietf.community.utils import notify_event_to_subscribers notify_event_to_subscribers(instance) diff --git a/ietf/doc/migrations/0015_auto_20160927_0713.py b/ietf/doc/migrations/0015_auto_20160927_0713.py new file mode 100644 index 000000000..fa127a6d2 --- /dev/null +++ b/ietf/doc/migrations/0015_auto_20160927_0713.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0015_insert_review_name_data'), + ('review', '0001_initial'), + ('doc', '0014_auto_20160824_2218'), + ] + + operations = [ + migrations.CreateModel( + name='ReviewRequestDocEvent', + fields=[ + ('docevent_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='doc.DocEvent')), + ('review_request', models.ForeignKey(to='review.ReviewRequest')), + ('state', models.ForeignKey(blank=True, to='name.ReviewRequestStateName', null=True)), + ], + options={ + }, + bases=('doc.docevent',), + ), + migrations.AlterField( + model_name='docevent', + name='type', + field=models.CharField(max_length=50, choices=[(b'new_revision', b'Added new revision'), (b'changed_document', b'Changed document metadata'), (b'added_comment', b'Added comment'), (b'deleted', b'Deleted document'), (b'changed_state', b'Changed state'), (b'changed_stream', b'Changed document stream'), (b'expired_document', b'Expired document'), (b'extended_expiry', b'Extended expiry of document'), (b'requested_resurrect', b'Requested resurrect'), (b'completed_resurrect', b'Completed resurrect'), (b'changed_consensus', b'Changed consensus'), (b'published_rfc', b'Published RFC'), (b'added_suggested_replaces', b'Added suggested replacement relationships'), (b'reviewed_suggested_replaces', b'Reviewed suggested replacement relationships'), (b'changed_group', b'Changed group'), (b'changed_protocol_writeup', b'Changed protocol writeup'), (b'changed_charter_milestone', b'Changed charter milestone'), (b'initial_review', b'Set initial review time'), (b'changed_review_announcement', b'Changed WG Review text'), (b'changed_action_announcement', b'Changed WG Action text'), (b'started_iesg_process', b'Started IESG process on document'), (b'created_ballot', b'Created ballot'), (b'closed_ballot', b'Closed ballot'), (b'sent_ballot_announcement', b'Sent ballot announcement'), (b'changed_ballot_position', b'Changed ballot position'), (b'changed_ballot_approval_text', b'Changed ballot approval text'), (b'changed_ballot_writeup_text', b'Changed ballot writeup text'), (b'changed_rfc_editor_note_text', b'Changed RFC Editor Note text'), (b'changed_last_call_text', b'Changed last call text'), (b'requested_last_call', b'Requested last call'), (b'sent_last_call', b'Sent last call'), (b'scheduled_for_telechat', b'Scheduled for telechat'), (b'iesg_approved', b'IESG approved document (no problem)'), (b'iesg_disapproved', b'IESG disapproved document (do not publish)'), (b'approved_in_minute', b'Approved in minute'), (b'iana_review', b'IANA review comment'), (b'rfc_in_iana_registry', b'RFC is in IANA registry'), (b'rfc_editor_received_announcement', b'Announcement was received by RFC Editor'), (b'requested_publication', b'Publication at RFC Editor requested'), (b'sync_from_rfc_editor', b'Received updated information from RFC Editor'), (b'requested_review', b'Requested review'), (b'assigned_review_request', b'Assigned review request'), (b'closed_review_request', b'Closed review request')]), + preserve_default=True, + ), + ] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index de7d45282..4722fd252 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -14,7 +14,7 @@ import debug # pyflakes:ignore from ietf.group.models import Group from ietf.name.models import ( DocTypeName, DocTagName, StreamName, IntendedStdLevelName, StdLevelName, - DocRelationshipName, DocReminderTypeName, BallotPositionName ) + DocRelationshipName, DocReminderTypeName, BallotPositionName, ReviewRequestStateName ) from ietf.person.models import Email, Person from ietf.utils.admin import admin_link @@ -685,7 +685,8 @@ EVENT_TYPES = [ # review ("requested_review", "Requested review"), - ("changed_review_request", "Changed review request"), + ("assigned_review_request", "Assigned review request"), + ("closed_review_request", "Closed review request"), ] class DocEvent(models.Model): @@ -816,11 +817,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) - # dumping store for removed events class DeletedEvent(models.Model): content_type = models.ForeignKey(ContentType) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index 54eabbe6f..580cc0153 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -131,7 +131,7 @@ class ReviewTests(TestCase): review_req = reload_db_objects(review_req) self.assertEqual(review_req.state_id, "withdrawn") e = doc.latest_event() - self.assertEqual(e.type, "changed_review_request") + self.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()) @@ -255,7 +255,7 @@ class ReviewTests(TestCase): reviewer=plain_email, ) - reviewer_settings = ReviewerSettings.objects.get(person__email=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() @@ -383,7 +383,7 @@ class ReviewTests(TestCase): review_req = reload_db_objects(review_req) self.assertEqual(review_req.state_id, "rejected") e = doc.latest_event() - self.assertEqual(e.type, "changed_review_request") + self.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) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 1555b9388..8576059ed 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -9,7 +9,8 @@ from django.core.exceptions import ValidationError from django.template.loader import render_to_string from django.core.urlresolvers import reverse as urlreverse -from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias, LastCallDocEvent +from ietf.doc.models import (Document, NewRevisionDocEvent, State, DocAlias, + LastCallDocEvent, ReviewRequestDocEvent) from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName from ietf.review.models import ReviewRequest from ietf.group.models import Group @@ -103,12 +104,14 @@ def request_review(request, name): review_req.team = team review_req.save() - DocEvent.objects.create( + 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) @@ -272,8 +275,8 @@ def reject_reviewer_assignment(request, name, request_id): review_req.state = ReviewRequestStateName.objects.get(slug="rejected") review_req.save() - DocEvent.objects.create( - type="changed_review_request", + 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( @@ -281,13 +284,15 @@ def reject_reviewer_assignment(request, name, request_id): 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("doc/mail/reviewer_assignment_rejected.txt", { + msg = render_to_string("review/reviewer_assignment_rejected.txt", { "by": request.user.person, "message_to_secretary": form.cleaned_data.get("message_to_secretary") }) @@ -393,7 +398,7 @@ def complete_review(request, name, request_id): strip_prefix(review_req.doc.name, "draft-"), form.cleaned_data["reviewed_rev"], review_req.team.acronym, - review_req.type.slug if review_req.type.slug != "unknown" else "", + review_req.type.slug, review_req.reviewer.person.ascii_parts()[3], datetime.date.today().isoformat(), ] @@ -403,8 +408,16 @@ def complete_review(request, name, request_id): 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, @@ -414,15 +427,9 @@ def complete_review(request, name, request_id): time=review.time, ) - review.type = DocTypeName.objects.get(slug="review") - review.rev = "00" - review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, form.cleaned_data["reviewed_rev"]) - review.group = review_req.team - if review_submission == "link": - review.external_url = form.cleaned_data['review_url'] - review.save_with_history([e]) review.set_state(State.objects.get(type="review", slug="active")) - DocAlias.objects.create(document=review, name=review.name) + + review.save_with_history([e]) # save file on disk if review_submission == "upload": @@ -441,17 +448,25 @@ def complete_review(request, name, request_id): review_req.review = review review_req.save() - DocEvent.objects.create( - type="changed_review_request", + 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="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, - ), + desc=desc, + review_request=review_req, + state=review_req.state, ) if review_req.state_id == "part-completed": @@ -463,7 +478,7 @@ def complete_review(request, name, request_id): url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": new_review_req.doc.name, "request_id": new_review_req.pk }) url = request.build_absolute_uri(url) - msg = render_to_string("doc/mail/partially_completed_review.txt", { + msg = render_to_string("review/partially_completed_review.txt", { 'new_review_req_url': url, "by": request.user.person, "new_review_req": new_review_req, @@ -471,28 +486,21 @@ def complete_review(request, name, request_id): email_review_request_change(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=False) - if review_submission != "link" and review_req.team.list_email: + 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, - "doc/mail/completed_review.txt", { + "review/completed_review.txt", { "review_req": review_req, "content": encoded_content.decode("utf-8"), }, cc=form.cleaned_data["cc"]) - e = DocEvent.objects.create( - type="changed_review_request", - doc=review_req.doc, - by=request.user.person, - desc="Sent review to list.", - ) - list_name = mailarch.list_name_from_email(review_req.team.list_email) if list_name: review.external_url = mailarch.construct_message_url(list_name, email.utils.unquote(msg["Message-ID"])) - review.save_with_history([e]) + review.save_with_history([close_event]) return redirect("doc_view", name=review_req.review.name) else: diff --git a/ietf/group/features.py b/ietf/group/features.py index c2a26fcf0..10d52d94a 100644 --- a/ietf/group/features.py +++ b/ietf/group/features.py @@ -31,7 +31,7 @@ class GroupFeatures(object): if group in active_review_teams(): self.has_reviews = True import ietf.group.views - self.default_tab = ietf.group.views.review_requests + self.default_tab = ietf.group.views_review.review_requests if group.type_id == "dir": self.admin_roles = ["chair", "secr"] diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index 478f36310..ef9285d62 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -31,7 +31,6 @@ 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, make_review_data from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects -import ietf.group.views def group_urlreverse_list(group, viewname): return [ @@ -336,31 +335,6 @@ class GroupPagesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertTrue(doc.title not in unicontent(r)) - def test_review_requests(self): - doc = make_test_data() - review_req = make_review_data(doc) - - group = review_req.team - - for url in [ urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym }), - urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym , 'group_type': group.type_id}), - ]: - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - self.assertTrue(review_req.doc.name in unicontent(r)) - self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r)) - - url = urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym }) - - # close request, listed under closed - review_req.state_id = "completed" - review_req.result_id = "ready" - review_req.save() - - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - self.assertTrue(review_req.doc.name in unicontent(r)) - def test_history(self): draft = make_test_data() group = draft.group diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index 85260a67b..6be685ea9 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -15,6 +15,31 @@ 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_id = "completed" + review_req.result_id = "ready" + review_req.save() + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(review_req.doc.name in unicontent(r)) + def test_suggested_review_requests(self): doc = make_test_data() review_req = make_review_data(doc) diff --git a/ietf/group/urls_info_details.py b/ietf/group/urls_info_details.py index 62f013753..ff7d08e7e 100644 --- a/ietf/group/urls_info_details.py +++ b/ietf/group/urls_info_details.py @@ -30,7 +30,7 @@ urlpatterns = patterns('', (r'^materials/new/(?P[\w-]+)/$', 'ietf.doc.views_material.edit_material', { 'action': "new" }, "group_new_material"), (r'^archives/$', 'ietf.group.views.derived_archives'), (r'^photos/$', views.group_photos), - (r'^reviews/$', views.review_requests), + (r'^reviews/$', 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), diff --git a/ietf/group/utils.py b/ietf/group/utils.py index 1287f826e..6f998da0b 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -1,6 +1,8 @@ import os from django.shortcuts import get_object_or_404 +from django.utils.safestring import mark_safe +from django.core.urlresolvers import reverse as urlreverse import debug # pyflakes:ignore @@ -9,8 +11,8 @@ from ietf.person.models import Email from ietf.utils.history import get_history_object_for, copy_many_to_many_for_history from ietf.ietfauth.utils import has_role from ietf.community.models import CommunityList, SearchRule -from ietf.community.utils import reset_name_contains_index_for_rule -from ietf.doc.models import State +from ietf.community.utils import reset_name_contains_index_for_rule, can_manage_community_list +from ietf.doc.models import Document, State def save_group_in_history(group): @@ -149,3 +151,86 @@ def setup_default_community_list_for_group(group): state=State.objects.get(slug="active", type="draft"), ) reset_name_contains_index_for_rule(related_docs_rule) + +def get_group_materials(group): + return Document.objects.filter( + group=group, + type__in=group.features.material_types + ).exclude(states__slug__in=['deleted','archived']) + +def construct_group_menu_context(request, group, selected, group_type, others): + """Return context with info for the group menu filled in.""" + kwargs = dict(acronym=group.acronym) + if group_type: + kwargs["group_type"] = group_type + + # menu entries + entries = [] + if group.features.has_documents: + entries.append(("Documents", urlreverse("ietf.group.views.group_documents", kwargs=kwargs))) + if group.features.has_chartering_process: + entries.append(("Charter", urlreverse("group_charter", kwargs=kwargs))) + else: + entries.append(("About", urlreverse("group_about", kwargs=kwargs))) + if group.features.has_materials and get_group_materials(group).exists(): + entries.append(("Materials", urlreverse("ietf.group.views.materials", kwargs=kwargs))) + if group.features.has_reviews: + import ietf.group.views_review + entries.append(("Review requests", urlreverse(ietf.group.views_review.review_requests, kwargs=kwargs))) + entries.append(("Reviewers", urlreverse(ietf.group.views_review.reviewer_overview, kwargs=kwargs))) + if group.type_id in ('rg','wg','team'): + entries.append(("Meetings", urlreverse("ietf.group.views.meetings", kwargs=kwargs))) + entries.append(("History", urlreverse("ietf.group.views.history", kwargs=kwargs))) + entries.append(("Photos", urlreverse("ietf.group.views.group_photos", kwargs=kwargs))) + entries.append(("Email expansions", urlreverse("ietf.group.views.email", kwargs=kwargs))) + if group.list_archive.startswith("http:") or group.list_archive.startswith("https:") or group.list_archive.startswith("ftp:"): + if 'mailarchive.ietf.org' in group.list_archive: + entries.append(("List archive", urlreverse("ietf.group.views.derived_archives", kwargs=kwargs))) + else: + entries.append((mark_safe("List archive »"), group.list_archive)) + if group.has_tools_page(): + entries.append((mark_safe("Tools »"), "https://tools.ietf.org/%s/%s/" % (group.type_id, group.acronym))) + + # actions + actions = [] + + is_admin = group.has_role(request.user, group.features.admin_roles) + can_manage = can_manage_group(request.user, group) + + if group.features.has_milestones: + if group.state_id != "proposed" and (is_admin or can_manage): + actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs))) + + if group.features.has_documents: + clist = CommunityList.objects.filter(group=group).first() + if clist and can_manage_community_list(request.user, clist): + import ietf.community.views + actions.append((u'Manage document list', urlreverse(ietf.community.views.manage_list, kwargs=kwargs))) + + if group.features.has_materials and can_manage_materials(request.user, group): + actions.append((u"Upload material", urlreverse("ietf.doc.views_material.choose_material_type", kwargs=kwargs))) + + if group.features.has_reviews: + import ietf.group.views_review + actions.append((u"Manage review requests", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=kwargs))) + + if group.state_id != "conclude" and (is_admin or can_manage): + actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs))) + + if group.features.customize_workflow and (is_admin or can_manage): + actions.append((u"Customize workflow", urlreverse("ietf.group.views_edit.customize_workflow", kwargs=kwargs))) + + if group.state_id in ("active", "dormant") and not group.type_id in ["sdo", "rfcedtyp", "isoc", ] and can_manage: + actions.append((u"Request closing group", urlreverse("ietf.group.views_edit.conclude", kwargs=kwargs))) + + d = { + "group": group, + "selected_menu_entry": selected, + "menu_entries": entries, + "menu_actions": actions, + "group_type": group_type, + } + + d.update(others) + + return d diff --git a/ietf/group/views.py b/ietf/group/views.py index 4b9bc82bf..d24aeb24e 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -49,18 +49,19 @@ from django.conf import settings from django.core.urlresolvers import reverse as urlreverse from django.views.decorators.cache import cache_page from django.db.models import Q -from django.utils.safestring import mark_safe -from ietf.doc.models import Document, State, DocAlias, RelatedDocument +from ietf.doc.models import State, DocAlias, RelatedDocument from ietf.doc.utils import get_chartering_type from ietf.doc.templatetags.ietf_filters import clean_whitespace from ietf.doc.utils_search import prepare_document_table from ietf.doc.utils_charter import charter_name_for_group from ietf.group.models import Group, Role, ChangeStateGroupEvent from ietf.name.models import GroupTypeName -from ietf.group.utils import get_charter_text, can_manage_group_type, can_manage_group, milestone_reviewer_for_group_type, can_provide_status_update -from ietf.group.utils import can_manage_materials, get_group_or_404 -from ietf.community.utils import docs_tracked_by_community_list, can_manage_community_list +from ietf.group.utils import (get_charter_text, can_manage_group_type, can_manage_group, + milestone_reviewer_for_group_type, can_provide_status_update, + can_manage_materials, get_group_or_404, + construct_group_menu_context, get_group_materials) +from ietf.community.utils import docs_tracked_by_community_list from ietf.community.models import CommunityList, EmailSubscription from ietf.utils.pipe import pipe from ietf.utils.textupload import get_cleaned_text_file_content @@ -69,10 +70,7 @@ from ietf.mailtrigger.utils import gather_relevant_expansions from ietf.ietfauth.utils import has_role from ietf.meeting.utils import group_sessions from ietf.meeting.helpers import get_meeting -from ietf.review.models import ReviewRequest -from ietf.review.utils import (can_manage_review_requests_for_team, - suggested_review_requests_for_team, - current_unavailable_periods_for_reviewers) + def roles(group, role_name): return Role.objects.filter(group=group, name=role_name).select_related("email", "person") @@ -331,85 +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.features.has_reviews: - entries.append(("Review requests", urlreverse(review_requests, kwargs=kwargs))) - if group.type_id in ('rg','wg','team'): - entries.append(("Meetings", urlreverse("ietf.group.views.meetings", kwargs=kwargs))) - entries.append(("History", urlreverse("ietf.group.views.history", kwargs=kwargs))) - 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 - def prepare_group_documents(request, group, clist): found_docs, meta = prepare_document_table(request, docs_tracked_by_community_list(clist), request.GET) @@ -662,65 +581,6 @@ def history(request, acronym, group_type=None): "events": events, })) -def review_requests(request, acronym, group_type=None): - group = get_group_or_404(acronym, group_type) - if not group.features.has_reviews: - raise Http404 - - open_review_requests = list(ReviewRequest.objects.filter( - team=group, state__in=("requested", "accepted") - ).prefetch_related("reviewer", "type", "state").order_by("-time", "-id")) - - 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 materials(request, acronym, group_type=None): group = get_group_or_404(acronym, group_type) if not group.features.has_materials: diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index 108a4d189..072872a43 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -1,4 +1,5 @@ import datetime +from collections import defaultdict from django.shortcuts import render, redirect, get_object_or_404 from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect @@ -14,18 +15,126 @@ from ietf.review.utils import (can_manage_review_requests_for_team, close_review close_review_request, setup_reviewer_field, suggested_review_requests_for_team, - unavailability_periods_to_list, + unavailable_periods_to_list, current_unavailable_periods_for_reviewers, email_reviewer_availability_change, - reviewer_rotation_list) + reviewer_rotation_list, + extract_review_request_data) from ietf.group.models import Role -from ietf.group.utils import get_group_or_404 +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() + + all_req_data = extract_review_request_data(teams=[group], time_from=today - datetime.timedelta(days=365)) + 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 = all_req_data.get((group.pk, person.pk), []) + open_reqs = sum(1 for _, _, _, _, state, _, _, _, _, _ in req_data if state in ("requested", "accepted")) + latest_reqs = [] + for req_pk, doc, req_time, state, deadline, result, late_days, request_to_assignment_days, assignment_to_closure_days, request_to_closure_days in req_data: + # any open requests pushes the others out + if ((state in ("requested", "accepted") and len(latest_reqs) < MAX_REQS) or (len(latest_reqs) + open_reqs < MAX_REQS)): + print review_state_by_slug.get(state), assignment_to_closure_days + latest_reqs.append((req_pk, doc, deadline, review_state_by_slug.get(state), assignment_to_closure_days)) + 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"), @@ -45,12 +154,12 @@ class ManageReviewRequestForm(forms.Form): 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: - if review_req.latest_reqs: - close_initial = "no-review-version" - else: - close_initial = "no-review-document" + close_initial = "no-review-version" elif review_req.reviewer: close_initial = "no-response" else: @@ -59,9 +168,6 @@ class ManageReviewRequestForm(forms.Form): if close_initial: self.fields["close"].initial = close_initial - if review_req.pk is None: - self.fields["close"].queryset = self.fields["close"].queryset.filter(slug__in=["noreviewversion", "noreviewdocument"]) - self.fields["close"].widget.attrs["class"] = "form-control input-sm" setup_reviewer_field(self.fields["reviewer"], review_req) @@ -176,8 +282,8 @@ def manage_review_requests(request, acronym, group_type=None): if form_action == "save-continue": return redirect(manage_review_requests, **kwargs) else: - import ietf.group.views - return redirect(ietf.group.views.review_requests, **kwargs) + import ietf.group.views_review + return redirect(ietf.group.views_review.review_requests, **kwargs) return render(request, 'group/manage_review_requests.html', { 'group': group, @@ -241,19 +347,6 @@ def email_open_review_assignments(request, acronym, group_type=None): }) -@login_required -def reviewer_overview(request, acronym, group_type=None): - group = get_group_or_404(acronym, group_type) - if not group.features.has_reviews: - raise Http404 - - reviewer_roles = Role.objects.filter(name="reviewer", group=group) - - if not (any(user_is_person(request.user, r) for r in reviewer_roles) - or can_manage_review_requests_for_team(request.user, group)): - return HttpResponseForbidden("You do not have permission to perform this action") - - class ReviewerSettingsForm(forms.ModelForm): class Meta: model = ReviewerSettings @@ -312,8 +405,8 @@ def change_reviewer_settings(request, acronym, reviewer_email, group_type=None): back_url = request.GET.get("next") if not back_url: - import ietf.group.views - back_url = urlreverse(ietf.group.views.review_requests, kwargs={ "group_type": group.type_id, "acronym": group.acronym}) + import ietf.group.views_review + back_url = urlreverse(ietf.group.views_review.review_requests, kwargs={ "group_type": group.type_id, "acronym": group.acronym}) # settings if request.method == "POST" and request.POST.get("action") == "change_settings": @@ -337,7 +430,7 @@ def change_reviewer_settings(request, acronym, reviewer_email, group_type=None): settings_form = ReviewerSettingsForm(instance=settings) # periods - unavailable_periods = unavailability_periods_to_list().filter(person=reviewer, team=group) + 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) diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 60e9a7796..3449814dd 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -56,7 +56,7 @@ 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 unavailability_periods_to_list +from ietf.review.utils import unavailable_periods_to_list from ietf.utils.mail import send_mail from ietf.doc.fields import SearchableDocumentField @@ -427,13 +427,13 @@ def review_overview(request): 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 unavailability_periods_to_list().filter(person__user=request.user, team__in=teams): + 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() + 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) diff --git a/ietf/name/migrations/0015_insert_review_name_data.py b/ietf/name/migrations/0015_insert_review_name_data.py index 34aaa2c86..ae94ea98e 100644 --- a/ietf/name/migrations/0015_insert_review_name_data.py +++ b/ietf/name/migrations/0015_insert_review_name_data.py @@ -21,7 +21,6 @@ def insert_initial_review_data(apps, schema_editor): ReviewTypeName.objects.get_or_create(slug="early", name="Early", order=1) ReviewTypeName.objects.get_or_create(slug="lc", name="Last Call", order=2) ReviewTypeName.objects.get_or_create(slug="telechat", name="Telechat", order=3) - ReviewTypeName.objects.get_or_create(slug="unknown", name="Unknown", order=4, used=False) ReviewResultName = apps.get_model("name", "ReviewResultName") ReviewResultName.objects.get_or_create(slug="serious-issues", name="Serious Issues", order=1) diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 5da78119d..8a86acf49 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -13,7 +13,7 @@ django.setup() # script -import datetime +import datetime, re, itertools from collections import namedtuple from django.db import connections from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, @@ -21,9 +21,10 @@ from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultNam 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 -from collections import defaultdict parser = argparse.ArgumentParser() parser.add_argument("database", help="database must be included in settings") @@ -110,7 +111,7 @@ with db_con.cursor() as c: team=team, person=email.person, ) - if reviewer: + if created: print "created reviewer", reviewer.pk, unicode(reviewer).encode("utf-8") if autopolicy_days.get(row.autopolicy): @@ -181,12 +182,138 @@ type_names = { n.slug: n for n in ReviewTypeName.objects.all() } # extract relevant log entries -request_assigned = defaultdict(list) +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"] with db_con.cursor() as c: - c.execute("select docname, time, who from doclog where text = 'AUTO UPDATED status TO working' order by time desc;") - for row in namedtuplefetchall(c): - request_assigned[row.docname].append((row.time, row.who)) + 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): + 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) + 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) + 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 @@ -214,33 +341,97 @@ with db_con.cursor() as c: if not deadline: deadline = parse_timestamp(row.timeout) - type_name = type_names["unknown"] - # FIXME: use lcend and telechat to try to deduce type - reviewed_rev = row.version if row.version and row.version != "99" else "" if row.summary == "noresponse": reviewed_rev = "" - assignment_logs = request_assigned.get(row.docname, []) - if assignment_logs: - time, who = assignment_logs.pop() - - time = parse_timestamp(time) + event_collection = None + branches = document_history.get(row.docname) + if not branches: + print "WARNING: no history for", row.docname else: - time = deadline + 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: - # bogus row - print "SKIPPING WITH NO DEADLINE", time, row, meta + print "SKIPPING WITH NO DEADLINE", row.reviewid, row.docname, meta, event_collection continue - if status == "done" and row.docstatus in ("assigned", "accepted"): - # filter out some apparently dead requests - print "SKIPPING MARKED DONE even if assigned/accepted", time, row - continue + 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 - req, _ = ReviewRequest.objects.get_or_create( - doc_id=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={ @@ -251,28 +442,142 @@ with db_con.cursor() as c: } ) - req.reviewer = known_personnel[row.reviewer] if row.reviewer else None - req.result = results.get(row.summary.lower()) if row.summary else None - req.state = states.get(row.docstatus) if row.docstatus else None - req.type = type_name - req.time = time - req.reviewed_rev = reviewed_rev - req.deadline = deadline.date() - req.save() + 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() - # FIXME: add log entries - # FIXME: add review from reviewurl - # FIXME: do something about missing result + completion_event = None - # adcomments IGNORED - # lccomments IGNORED - # nits IGNORED - # reviewurl review.external_url + # review request events + for key, data in event_collection.iteritems(): + timestamp, who_did_it, reviewer, state, latest_iesg_status = data - #print meta and meta[0], telechat, lcend, req.type + if who_did_it in known_personnel: + by = known_personnel[who_did_it].person + else: + by = system_person - if req.state_id == "requested" and req.doc.get_state_slug("draft-iesg") in ["approved", "ann", "rfcqueue", "pub"]: - req.state = states["overtaken"] - req.save() + if key == "requested": + if "assigned" in event_collection: + continue # skip requested unless there's no assigned event - print "imported review", row.reviewid, "as", req.pk, req.time, req.deadline, req.type, req.doc_id, req.state, req.doc.get_state_slug("draft-iesg") + e = ReviewRequestDocEvent.objects.filter(type="requested_review", doc=review_req.doc).first() or ReviewRequestDocEvent(type="requested_review", doc=review_req.doc) + e.time = time + e.by = by + e.desc = "Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()) + e.review_request = review_req + 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).first() or ReviewRequestDocEvent(type="assigned_review_request", doc=review_req.doc) + 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.review_request = review_req + 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).first() or ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc) + 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.review_request = review_req + 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() + + print "imported review request", row.reviewid, "as", review_req.pk, review_req.time, review_req.deadline, review_req.type, review_req.doc_id, review_req.state, review_req.doc.get_state_slug("draft-iesg") diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index ec090266c..9c8a17004 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -49,7 +49,7 @@ class Migration(migrations.Migration): ('deadline', models.DateField()), ('requested_rev', models.CharField(help_text=b'Fill in if a specific revision is to be reviewed, e.g. 02', max_length=16, verbose_name=b'requested revision', blank=True)), ('reviewed_rev', models.CharField(max_length=16, verbose_name=b'reviewed revision', blank=True)), - ('doc', models.ForeignKey(related_name='review_request_set', to='doc.Document')), + ('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')), diff --git a/ietf/review/models.py b/ietf/review/models.py index c60825576..f1ef76798 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -96,7 +96,7 @@ class ReviewRequest(models.Model): # constitute the request part. time = models.DateTimeField(default=datetime.datetime.now) type = models.ForeignKey(ReviewTypeName) - doc = models.ForeignKey(Document, related_name='review_request_set') + doc = models.ForeignKey(Document, related_name='reviewrequest_set') team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewteamresult=None)) deadline = models.DateField() requested_by = models.ForeignKey(Person) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index abfaf56af..4813cac1d 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -1,11 +1,12 @@ -import datetime, re +import datetime, re, itertools from collections import defaultdict from django.db.models import Q, Max from django.core.urlresolvers import reverse as urlreverse from ietf.group.models import Group, Role -from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent, DocumentAuthor, DocAlias +from ietf.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 @@ -50,7 +51,7 @@ def no_review_from_teams_on_doc(doc, rev): reviewrequest__state="no-review-version", ).distinct() -def unavailability_periods_to_list(past_days=30): +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") @@ -71,7 +72,7 @@ def current_unavailable_periods_for_reviewers(team): return res -def reviewer_rotation_list(team): +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()) @@ -94,7 +95,26 @@ def reviewer_rotation_list(team): else: next_reviewer_index = reviewers.index(n) - return reviewers[next_reviewer_index:] + reviewers[:next_reviewer_index] + 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 @@ -121,6 +141,66 @@ def days_needed_to_fulfill_min_interval_for_reviewers(team): return res +def extract_review_request_data(teams=None, reviewers=None, time_from=None, time_to=None): + """Returns a dict keyed on (team.pk, reviewer_person.pk) which lists data on each review request.""" + + 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) + + res = defaultdict(list) + + # we may be dealing with a big bunch of data, so treat it carefully + event_qs = ReviewRequest.objects.filter(filters).order_by("-time", "-id") + + # left outer join with RequestRequestDocEvent for request/assign/close time + event_qs = event_qs.values_list("pk", "doc", "time", "state", "deadline", "result", "team", "reviewer__person", "reviewrequestdocevent__time", "reviewrequestdocevent__type") + + 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, 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) + + res[(team, reviewer)].append((req_pk, doc, req_time, state, deadline, result, + late_days, request_to_assignment_days, assignment_to_closure_days, + request_to_closure_days)) + + return res + def make_new_review_request_from_existing(review_req): obj = ReviewRequest() obj.time = review_req.time @@ -233,10 +313,11 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): review_req.reviewer = reviewer review_req.save() - possibly_advance_next_reviewer_for_team(review_req.team, review_req.reviewer.person_id) + if review_req.reviewer: + possibly_advance_next_reviewer_for_team(review_req.team, review_req.reviewer.person_id) - DocEvent.objects.create( - type="changed_review_request", + ReviewRequestDocEvent.objects.create( + type="assigned_review_request", doc=review_req.doc, by=request.user.person, desc="Request for {} review by {} is assigned to {}".format( @@ -244,6 +325,8 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): 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( @@ -253,26 +336,14 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): 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): - # prune reviewers not in the rotation (but not the assigned - # reviewer who must have been available for assignment anyway) - reviewers_to_skip = set() + assert assigned_review_to_person_id is not None - unavailable_periods = current_unavailable_periods_for_reviewers(team) - for person_id, periods in unavailable_periods.iteritems(): - if periods and person_id != assigned_review_to_person_id: - 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 != assigned_review_to_person_id: - reviewers_to_skip.add(person_id) - - rotation_list = [p.pk for p in reviewer_rotation_list(team) if p.pk not in reviewers_to_skip] - - if not rotation_list: - return + 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): @@ -281,7 +352,7 @@ def possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id): current_i = 0 - if reviewer_at_index(current_i) == assigned_review_to_person_id: + if assigned_review_to_person_id == reviewer_at_index(current_i): # move 1 ahead current_i += 1 else: @@ -289,6 +360,9 @@ def possibly_advance_next_reviewer_for_team(team, 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) @@ -315,12 +389,14 @@ def close_review_request(request, review_req, close_state): review_req.save() if not suggested_req: - DocEvent.objects.create( - type="changed_review_request", + 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": @@ -337,6 +413,8 @@ def suggested_review_requests_for_team(team): requests = {} + today = datetime.date.today() + requested_state = ReviewRequestStateName.objects.get(slug="requested", used=True) if True: # FIXME @@ -345,9 +423,9 @@ def suggested_review_requests_for_team(team): last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True)) last_call_expires = { e.doc_id: e.expires for e in LastCallDocEvent.objects.order_by("time", "id") } for doc in last_call_docs: - deadline = last_call_expires[doc.pk].date() if doc.pk in last_call_expires else datetime.date.today() + deadline = last_call_expires[doc.pk].date() if doc.pk in last_call_expires else today - if deadline > seen_deadlines.get(doc.pk, datetime.date.max): + if deadline > seen_deadlines.get(doc.pk, datetime.date.max) or deadline < today: continue requests[doc.pk] = ReviewRequest( @@ -486,7 +564,7 @@ def make_assignment_choices(email_queryset, review_req): for p in possible_person_ids: if p not in reviewer_settings: - reviewer_settings[p] = ReviewerSettings() + reviewer_settings[p] = ReviewerSettings(team=team) # frequency days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(team) diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index f872a840d..97b12bf0b 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -519,6 +519,10 @@ table.unavailable-periods td { padding-right: 0.5em; } +table.unavailable-periods td:last-child { + padding-right: 0; +} + .unavailable-period-past { color: #777; } @@ -527,6 +531,10 @@ table.unavailable-periods td { font-weight: bold; } +.reviewer-overview .completely-unavailable { + opacity: 0.6; +} + /* === Photo pages ========================================================== */ .photo-name { diff --git a/ietf/templates/doc/document_review.html b/ietf/templates/doc/document_review.html index 1d83e0c00..3d98cc0d9 100644 --- a/ietf/templates/doc/document_review.html +++ b/ietf/templates/doc/document_review.html @@ -69,12 +69,14 @@
{{ review_req.reviewer.person }}
Review result {{ review_req.result.name }}
Team{{ review_req.team.acronym|upper }}{{ review_req.team.acronym|upper }}
{% buttons %} - Cancel + Cancel diff --git a/ietf/templates/group/review_requests.html b/ietf/templates/group/review_requests.html index 0959d0117..d746fbc41 100644 --- a/ietf/templates/group/review_requests.html +++ b/ietf/templates/group/review_requests.html @@ -34,7 +34,7 @@ {% if r.pk %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} {{ r.deadline|date:"Y-m-d" }} - {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} + {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} {% if r.reviewer %} diff --git a/ietf/templates/group/reviewer_overview.html b/ietf/templates/group/reviewer_overview.html new file mode 100644 index 000000000..c9b290f48 --- /dev/null +++ b/ietf/templates/group/reviewer_overview.html @@ -0,0 +1,49 @@ +{% extends "group/group_base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block group_subtitle %}Reviewers{% endblock %} + +{% block group_content %} + {% origin %} + +

Reviewers

+ + {% if reviewers %} + + + + + + + + + + {% for person in reviewers %} + + + + + + {% endfor %} + +
ReviewerLatest assignments (deadline, state)Settings
{{ person }} + {% for req_pk, doc_name, deadline, state, assignment_to_closure_days in person.latest_reqs %} + + {% endfor %} + + {{ person.settings.get_min_interval_display }} {% if person.settings.skip_next %}(skip: {{ person.settings.skip_next }}){% endif %}
+ {% if person.settings.filter_re %}Filter: {{ person.settings.filter_re|truncatechars:15 }}
{% endif %} + + {% if person.unavailable_periods %} + {% include "review/unavailable_table.html" with unavailable_periods=person.unavailable_periods %} + {% endif %} +
+ + {% else %} +

No reviewers found.

+ {% endif %} + +{% endblock %} diff --git a/ietf/templates/ietfauth/review_overview.html b/ietf/templates/ietfauth/review_overview.html index a28b6d257..b68758ef3 100644 --- a/ietf/templates/ietfauth/review_overview.html +++ b/ietf/templates/ietfauth/review_overview.html @@ -140,14 +140,7 @@ Unavailable periods {% if t.unavailable_periods %} - - {% for o in t.unavailable_periods %} - - - - - {% endfor %} -
{{ o.start_date }} - {{ o.end_date|default:"" }}{{ o.get_availability_display }}
+ {% include "review/unavailable_table.html" with unavailable_periods=t.unavailable_periods %} {% else %} (No periods) {% endif %} diff --git a/ietf/templates/doc/mail/completed_review.txt b/ietf/templates/review/completed_review.txt similarity index 100% rename from ietf/templates/doc/mail/completed_review.txt rename to ietf/templates/review/completed_review.txt diff --git a/ietf/templates/doc/mail/partially_completed_review.txt b/ietf/templates/review/partially_completed_review.txt similarity index 100% rename from ietf/templates/doc/mail/partially_completed_review.txt rename to ietf/templates/review/partially_completed_review.txt diff --git a/ietf/templates/doc/mail/reviewer_assignment_rejected.txt b/ietf/templates/review/reviewer_assignment_rejected.txt similarity index 100% rename from ietf/templates/doc/mail/reviewer_assignment_rejected.txt rename to ietf/templates/review/reviewer_assignment_rejected.txt diff --git a/ietf/templates/review/unavailable_table.html b/ietf/templates/review/unavailable_table.html new file mode 100644 index 000000000..7fc1d5e35 --- /dev/null +++ b/ietf/templates/review/unavailable_table.html @@ -0,0 +1,8 @@ + + {% for p in unavailable_periods %} + + + + + {% endfor %} +
{{ p.start_date }} - {{ p.end_date|default:"" }}{{ p.get_availability_display }}
From a177dc616b6cafcd68b82bf603497a7b93b8133c Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 4 Oct 2016 20:22:11 +0000 Subject: [PATCH 75/90] Fix a couple of bugs, add test for reviewer overview page - Legacy-Id: 12081 --- ietf/doc/resources.py | 31 +++++++- ietf/group/tests_review.py | 78 +++++++++++++++++++- ietf/group/views_review.py | 8 +- ietf/ietfauth/tests.py | 11 ++- ietf/review/import_from_review_tool.py | 29 ++++++-- ietf/review/utils.py | 4 +- ietf/static/ietf/css/ietf.css | 4 +- ietf/templates/group/reviewer_overview.html | 17 ++++- ietf/templates/review/unavailable_table.html | 2 +- 9 files changed, 159 insertions(+), 25 deletions(-) diff --git a/ietf/doc/resources.py b/ietf/doc/resources.py index de2c8df9f..951c72a72 100644 --- a/ietf/doc/resources.py +++ b/ietf/doc/resources.py @@ -11,7 +11,7 @@ from ietf.doc.models import (BallotType, DeletedEvent, StateType, State, Documen DocumentAuthor, DocEvent, StateDocEvent, DocHistory, ConsensusDocEvent, DocAlias, TelechatDocEvent, DocReminder, LastCallDocEvent, NewRevisionDocEvent, WriteupDocEvent, InitialReviewDocEvent, DocHistoryAuthor, BallotDocEvent, RelatedDocument, - RelatedDocHistory, BallotPositionDocEvent) + RelatedDocHistory, BallotPositionDocEvent, ReviewRequestDocEvent) from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource @@ -513,3 +513,32 @@ class BallotPositionDocEventResource(ModelResource): } api.doc.register(BallotPositionDocEventResource()) + + +from ietf.person.resources import PersonResource +from ietf.review.resources import ReviewRequestResource +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(ReviewRequestResource, 'review_request') + state = ToOneField(ReviewRequestStateNameResource, 'state', null=True) + class Meta: + queryset = ReviewRequestDocEvent.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'reviewrequestdocevent' + filtering = { + "id": ALL, + "time": ALL, + "type": ALL, + "desc": ALL, + "by": ALL_WITH_RELATIONS, + "doc": ALL_WITH_RELATIONS, + "docevent_ptr": ALL_WITH_RELATIONS, + "review_request": ALL_WITH_RELATIONS, + "state": ALL_WITH_RELATIONS, + } +api.doc.register(ReviewRequestDocEventResource()) + diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index 6be685ea9..b93e60f11 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -9,8 +9,9 @@ from ietf.utils.test_utils import login_testing_unauthorized, TestCase, uniconte 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, ReviewRequestStateName, ReviewerSettings, UnavailablePeriod +from ietf.review.models import ReviewRequest, ReviewerSettings, UnavailablePeriod from ietf.review.utils import suggested_review_requests_for_team +from ietf.name.models import ReviewTypeName, ReviewResultName, ReviewRequestStateName import ietf.group.views_review from ietf.utils.mail import outbox, empty_outbox @@ -32,8 +33,8 @@ class ReviewTests(TestCase): url = urlreverse(ietf.group.views_review.review_requests, kwargs={ 'acronym': group.acronym }) # close request, listed under closed - review_req.state_id = "completed" - review_req.result_id = "ready" + review_req.state = ReviewRequestStateName.objects.get(slug="completed") + review_req.result = ReviewResultName.objects.get(slug="ready") review_req.save() r = self.client.get(url) @@ -97,8 +98,50 @@ class ReviewTests(TestCase): 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 + + url = urlreverse(ietf.group.views_review.reviewer_overview, kwargs={ 'acronym': group.acronym, 'group_type': group.type_id }) + + # get + 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) @@ -128,6 +171,33 @@ class ReviewTests(TestCase): 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) diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index 072872a43..dc792a8ac 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -1,4 +1,4 @@ -import datetime +import datetime, math from collections import defaultdict from django.shortcuts import render, redirect, get_object_or_404 @@ -126,7 +126,8 @@ def reviewer_overview(request, acronym, group_type=None): for req_pk, doc, req_time, state, deadline, result, late_days, request_to_assignment_days, assignment_to_closure_days, request_to_closure_days in req_data: # any open requests pushes the others out if ((state in ("requested", "accepted") and len(latest_reqs) < MAX_REQS) or (len(latest_reqs) + open_reqs < MAX_REQS)): - print review_state_by_slug.get(state), assignment_to_closure_days + if assignment_to_closure_days is not None: + assignment_to_closure_days = int(math.ceil(assignment_to_closure_days)) latest_reqs.append((req_pk, doc, deadline, review_state_by_slug.get(state), assignment_to_closure_days)) person.latest_reqs = latest_reqs @@ -205,14 +206,13 @@ def manage_review_requests(request, acronym, group_type=None): set(r.doc_id for r in review_requests), ) - # we need a mutable query dict for resetting upon saving with # conflicts query_dict = request.POST.copy() if request.method == "POST" else None for req in review_requests: l = [] # take all on the latest reviewed rev - for r in document_requests[req.doc_id]: + 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): diff --git a/ietf/ietfauth/tests.py b/ietf/ietfauth/tests.py index 309318054..b3ede910c 100644 --- a/ietf/ietfauth/tests.py +++ b/ietf/ietfauth/tests.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import os, shutil, time +import os, shutil, time, datetime from urlparse import urlsplit from pyquery import PyQuery from unittest import skipIf @@ -18,7 +18,7 @@ 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 +from ietf.review.models import ReviewWish, UnavailablePeriod from ietf.utils.decorators import skip_coverage import ietf.ietfauth.views @@ -348,6 +348,13 @@ class IetfAuthTests(TestCase): 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) diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 8a86acf49..71e358698 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -466,18 +466,21 @@ with db_con.cursor() as c: 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).first() or ReviewRequestDocEvent(type="requested_review", doc=review_req.doc) + 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.review_request = review_req 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).first() or ReviewRequestDocEvent(type="assigned_review_request", doc=review_req.doc) + 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( @@ -485,14 +488,15 @@ with db_con.cursor() as c: review_req.team.acronym.upper(), review_req.reviewer.person if review_req.reviewer else "(None)", ) - e.review_request = review_req 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).first() or ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc) + 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 @@ -512,7 +516,6 @@ with db_con.cursor() as c: ) else: e.desc = "Closed request for {} review by {} with state '{}'".format(review_req.type.name, review_req.team.acronym.upper(), e.state.name) - e.review_request = review_req e.skip_community_list_notification = True e.save() completion_event = e @@ -580,4 +583,18 @@ with db_con.cursor() as c: review_req.state = states["overtaken"] review_req.save() + if "closed" not in event_collection and "assigned" in event_collection: + e = ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc, review_request=review_req).first() + if not e: + e = ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc, review_request=review_req) + e.time = datetime.datetime.now() + e.by = by + e.state = review_req.state + e.desc = "Closed request for {} review by {} with state '{}'".format(review_req.type.name, review_req.team.acronym.upper(), e.state.name) + e.skip_community_list_notification = True + e.save() + completion_event = e + print "imported event closed_review_request (generated upon closing)", e.desc, e.doc_id + + print "imported review request", row.reviewid, "as", review_req.pk, review_req.time, review_req.deadline, review_req.type, review_req.doc_id, review_req.state, review_req.doc.get_state_slug("draft-iesg") diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 4813cac1d..29dddf192 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -161,10 +161,10 @@ def extract_review_request_data(teams=None, reviewers=None, time_from=None, time res = defaultdict(list) # we may be dealing with a big bunch of data, so treat it carefully - event_qs = ReviewRequest.objects.filter(filters).order_by("-time", "-id") + event_qs = ReviewRequest.objects.filter(filters) # left outer join with RequestRequestDocEvent for request/assign/close time - event_qs = event_qs.values_list("pk", "doc", "time", "state", "deadline", "result", "team", "reviewer__person", "reviewrequestdocevent__time", "reviewrequestdocevent__type") + event_qs = event_qs.values_list("pk", "doc", "time", "state", "deadline", "result", "team", "reviewer__person", "reviewrequestdocevent__time", "reviewrequestdocevent__type").order_by("-time", "-pk", "-reviewrequestdocevent__time") def positive_days(time_from, time_to): if time_from is None or time_to is None: diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 97b12bf0b..2697bcae0 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -515,11 +515,11 @@ form.email-open-review-assignments [name=body] { font-family: monospace; } -table.unavailable-periods td { +table.simple-table td { padding-right: 0.5em; } -table.unavailable-periods td:last-child { +table.simple-table td:last-child { padding-right: 0; } diff --git a/ietf/templates/group/reviewer_overview.html b/ietf/templates/group/reviewer_overview.html index c9b290f48..6b4107e3d 100644 --- a/ietf/templates/group/reviewer_overview.html +++ b/ietf/templates/group/reviewer_overview.html @@ -16,7 +16,7 @@ Reviewer - Latest assignments (deadline, state) + Deadline/state/time between assignment and closure for latest assignments Settings @@ -25,9 +25,20 @@ {{ person }} - {% for req_pk, doc_name, deadline, state, assignment_to_closure_days in person.latest_reqs %} - + + {% for req_pk, doc_name, deadline, state, assignment_to_closure_days in person.latest_reqs %} + + + + + + {% endfor %} +
{{ deadline|date }} + {{ state.name }} + + {% if assignment_to_closure_days != None %}{{ assignment_to_closure_days }} day{{ assignment_to_closure_days|pluralize }}{% endif %} + {{ doc_name }}
{{ person.settings.get_min_interval_display }} {% if person.settings.skip_next %}(skip: {{ person.settings.skip_next }}){% endif %}
diff --git a/ietf/templates/review/unavailable_table.html b/ietf/templates/review/unavailable_table.html index 7fc1d5e35..dcb5d2836 100644 --- a/ietf/templates/review/unavailable_table.html +++ b/ietf/templates/review/unavailable_table.html @@ -1,4 +1,4 @@ - +
{% for p in unavailable_periods %} From 27d9c18759696ad18090ba13bc3deac0f18da2bf Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 5 Oct 2016 12:37:27 +0000 Subject: [PATCH 76/90] Add personal review overview page to the menu - Legacy-Id: 12083 --- ietf/ietfauth/utils.py | 3 ++- ietf/templates/base/menu_user.html | 4 ++++ ietf/templates/ietfauth/review_overview.html | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/ietf/ietfauth/utils.py b/ietf/ietfauth/utils.py index 0680762e2..98749c21d 100644 --- a/ietf/ietfauth/utils.py +++ b/ietf/ietfauth/utils.py @@ -69,13 +69,14 @@ def has_role(user, role_names, *args, **kwargs): "Nomcom": Q(person=person, group__type="nomcom", group__acronym__icontains=kwargs.get('year', '0000')), "Liaison Manager": Q(person=person,name="liaiman",group__type="sdo",group__state="active", ), "Authorized Individual": Q(person=person,name="auth",group__type="sdo",group__state="active", ), + "Reviewer": Q(person=person, name="reviewer", group__state="active"), } filter_expr = Q() for r in role_names: filter_expr |= role_qs[r] - user.roles_check_cache[key] = bool(Role.objects.filter(filter_expr)[:1]) + user.roles_check_cache[key] = bool(Role.objects.filter(filter_expr).exists()) return user.roles_check_cache[key] diff --git a/ietf/templates/base/menu_user.html b/ietf/templates/base/menu_user.html index 106f74ec7..0814c517d 100644 --- a/ietf/templates/base/menu_user.html +++ b/ietf/templates/base/menu_user.html @@ -25,6 +25,10 @@
  • {% if request.user.is_authenticated %}Manage account{% else %}New account{% endif %}
  • Preferences
  • + {% if user|has_role:"Reviewer" %} +
  • My reviews
  • + {% endif %} + {% if user|has_role:"Area Director" %} {% if flavor == "top" %}{% endif %}
  • AD dashboard
  • diff --git a/ietf/templates/ietfauth/review_overview.html b/ietf/templates/ietfauth/review_overview.html index b68758ef3..d91d8e6d4 100644 --- a/ietf/templates/ietfauth/review_overview.html +++ b/ietf/templates/ietfauth/review_overview.html @@ -46,7 +46,7 @@ {% endif %} -

    Latest closed requests

    +

    Latest closed review requests

    {% if closed_review_requests %}
    {{ p.start_date }} - {{ p.end_date|default:"" }}
    @@ -77,7 +77,7 @@
    {% else %} -

    Did not find any closed requests assigned to you.

    +

    Did not find any closed review requests assigned to you.

    {% endif %} @@ -134,7 +134,7 @@ Filter regexp - {{ t.reviewer_settings.filter_re|default:"(None)" }} + {{ t.reviewer_settings.filter_re|default:"(None)" }} Unavailable periods From e484f6b0aee886fa679bb3ffd09a9123309243d3 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 5 Oct 2016 13:01:13 +0000 Subject: [PATCH 77/90] Fix styling problem on reviewer overview, apparently the Boostrap CSS anchor style gives a color to plain anchors instead of only touching a:link so don't put non-link inside - Legacy-Id: 12084 --- ietf/templates/group/reviewer_overview.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/templates/group/reviewer_overview.html b/ietf/templates/group/reviewer_overview.html index 6b4107e3d..9a25c8f3b 100644 --- a/ietf/templates/group/reviewer_overview.html +++ b/ietf/templates/group/reviewer_overview.html @@ -23,7 +23,7 @@ {% for person in reviewers %} - {{ person }} + {% if person.settings_url %}{% endif %}{{ person }}{% if person.settings_url %}{% endif %} {% for req_pk, doc_name, deadline, state, assignment_to_closure_days in person.latest_reqs %} From 037556bc8f6f78ea66f75e0f8231860f208a8d5f Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 5 Oct 2016 13:04:07 +0000 Subject: [PATCH 78/90] Set request time for auto-suggested review requests to the time where the triggering event happened - this is probably only marginally useful at the moment, but could perhaps be used for statistics later on (e.g. comparing earliest possible request time vs. assignment time) - Legacy-Id: 12085 --- ietf/review/utils.py | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 29dddf192..2578ef705 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -413,7 +413,7 @@ def suggested_review_requests_for_team(team): requests = {} - today = datetime.date.today() + now = datetime.datetime.now() requested_state = ReviewRequestStateName.objects.get(slug="requested", used=True) @@ -421,14 +421,17 @@ def suggested_review_requests_for_team(team): # in Last Call last_call_type = ReviewTypeName.objects.get(slug="lc") last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True)) - last_call_expires = { e.doc_id: e.expires for e in LastCallDocEvent.objects.order_by("time", "id") } + last_call_expiry_events = { e.doc_id: e for e in LastCallDocEvent.objects.order_by("time", "id") } for doc in last_call_docs: - deadline = last_call_expires[doc.pk].date() if doc.pk in last_call_expires else today + e = last_call_expiry_events[doc.pk] if doc.pk in last_call_expiry_events else LastCallDocEvent(expires=now.date(), time=now) - if deadline > seen_deadlines.get(doc.pk, datetime.date.max) or deadline < today: + 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, @@ -446,27 +449,33 @@ def suggested_review_requests_for_team(team): telechat_type = ReviewTypeName.objects.get(slug="telechat") telechat_deadline_delta = datetime.timedelta(days=2) - telechat_docs = Document.objects.filter(docevent__telechatdocevent__telechat_date__in=telechat_dates) - for doc in telechat_docs: - d = doc.telechat_date() - if d not in telechat_dates: + + 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 - deadline = d - telechat_deadline_delta - - if deadline > seen_deadlines.get(doc.pk, datetime.date.max): - continue - - requests[doc.pk] = ReviewRequest( + requests[doc_pk] = ReviewRequest( + time=event_time, type=telechat_type, - doc=doc, + doc_id=doc_pk, team=team, deadline=deadline, requested_by=system_person, state=requested_state, ) - seen_deadlines[doc.pk] = deadline + seen_deadlines[doc_pk] = deadline # filter those with existing requests existing_requests = defaultdict(list) From 0aa8f8a7ccf12792e2f80f583e66c919b95fc753 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 5 Oct 2016 13:34:15 +0000 Subject: [PATCH 79/90] Don't automatically open a new review request if there's already another open request in the team - Legacy-Id: 12086 --- ietf/doc/views_review.py | 18 +++++++++++------- .../review/partially_completed_review.txt | 8 ++++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 8576059ed..999711415 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -470,18 +470,22 @@ def complete_review(request, name, request_id): ) if review_req.state_id == "part-completed": - new_review_req = make_new_review_request_from_existing(review_req) - new_review_req.save() + 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) - url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": new_review_req.doc.name, "request_id": new_review_req.pk }) - url = request.build_absolute_uri(url) - msg = render_to_string("review/partially_completed_review.txt", { - 'new_review_req_url': url, + "new_review_req_url": new_review_req_url, + "existing_open_reqs": existing_open_reqs, "by": request.user.person, - "new_review_req": new_review_req, }) email_review_request_change(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=False) diff --git a/ietf/templates/review/partially_completed_review.txt b/ietf/templates/review/partially_completed_review.txt index 3fd6603f7..6d518357c 100644 --- a/ietf/templates/review/partially_completed_review.txt +++ b/ietf/templates/review/partially_completed_review.txt @@ -1,6 +1,10 @@ {% autoescape off %}Review was partially completed by {{ by }}. -A new review request has been registered for completing the review: +{% if new_review_req_url %} +A new review request has been added for completing the review: {{ new_review_req_url }} -{% endautoescape %} +{% 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 %} From 168fc0425b6f10d51459f5c43d029a247a49ef97 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 6 Oct 2016 12:52:43 +0000 Subject: [PATCH 80/90] Fix formatting bug with multiple milestones in search results - Legacy-Id: 12094 --- ietf/templates/doc/search/status_columns.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ietf/templates/doc/search/status_columns.html b/ietf/templates/doc/search/status_columns.html index 6b799e1e8..7a2655c5f 100644 --- a/ietf/templates/doc/search/status_columns.html +++ b/ietf/templates/doc/search/status_columns.html @@ -43,8 +43,8 @@ {% endif %} {% for m in doc.milestones %} - {% if forloop.first %}
    {% else %}, {% endif %} - {{ m.due|date:"M Y" }} + {% if forloop.first %}
    {% endif %} + {{ m.due|date:"M Y" }}{% if not forloop.last %}, {% endif %} {% endfor %} {% else %}{# RFC #} From 227fdd7953f56bb5ce53ce8f1dd0d72f3c393300 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 6 Oct 2016 14:47:41 +0000 Subject: [PATCH 81/90] Add reviews to search results and IESG agenda. Support restricting review types so that teams will only have those review types listed and suggested that they are configured to have. Fix a couple of things in the importer after having tested it on all the databases. Set unavailable end date >= 2020 to indefinite. Fix a couple of bugs. - Legacy-Id: 12095 --- ietf/doc/resources.py | 3 +- ietf/doc/tests_review.py | 14 ++++----- ietf/doc/utils_search.py | 5 +-- ietf/doc/views_doc.py | 6 ++-- ietf/doc/views_review.py | 24 +++++++++----- ietf/group/views_review.py | 4 +-- ietf/iesg/agenda.py | 6 +++- ietf/review/import_from_review_tool.py | 31 ++++++++++++++----- ietf/review/migrations/0001_initial.py | 13 +++++++- ietf/review/models.py | 15 +++++++-- ietf/review/resources.py | 30 +++++++++++++++--- ietf/review/utils.py | 24 +++++++------- .../templates/doc/review_request_summary.html | 4 --- ietf/templates/doc/search/status_columns.html | 7 +++++ ietf/templates/iesg/agenda_doc.html | 9 ++++++ ietf/utils/test_data.py | 7 +++-- 16 files changed, 142 insertions(+), 60 deletions(-) diff --git a/ietf/doc/resources.py b/ietf/doc/resources.py index 951c72a72..2add45bcc 100644 --- a/ietf/doc/resources.py +++ b/ietf/doc/resources.py @@ -516,13 +516,12 @@ api.doc.register(BallotPositionDocEventResource()) from ietf.person.resources import PersonResource -from ietf.review.resources import ReviewRequestResource 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(ReviewRequestResource, 'review_request') + review_request = ToOneField('review.ReviewRequestResource', 'review_request') state = ToOneField(ReviewRequestStateNameResource, 'state', null=True) class Meta: queryset = ReviewRequestDocEvent.objects.all() diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index 580cc0153..fff8049be 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -12,7 +12,7 @@ from pyquery import PyQuery import debug # pyflakes:ignore -from ietf.review.models import (ReviewRequest, ReviewTeamResult, ReviewerSettings, +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 @@ -470,7 +470,7 @@ class ReviewTests(TestCase): review_req.state = ReviewRequestStateName.objects.get(slug="accepted") review_req.save() for r in ReviewResultName.objects.filter(slug__in=("issues", "ready")): - ReviewTeamResult.objects.get_or_create(team=review_req.team, result=r) + 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 }) @@ -508,7 +508,7 @@ class ReviewTests(TestCase): test_file.name = "unnamed" r = self.client.post(url, data={ - "result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk, + "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", @@ -552,7 +552,7 @@ class ReviewTests(TestCase): empty_outbox() r = self.client.post(url, data={ - "result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk, + "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", @@ -583,7 +583,7 @@ class ReviewTests(TestCase): empty_outbox() r = self.client.post(url, data={ - "result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk, + "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", @@ -611,7 +611,7 @@ class ReviewTests(TestCase): empty_outbox() r = self.client.post(url, data={ - "result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk, + "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", @@ -645,7 +645,7 @@ class ReviewTests(TestCase): url = urlreverse('ietf.doc.views_review.complete_review', kwargs={ "name": review_req.doc.name, "request_id": review_req.pk }) r = self.client.post(url, data={ - "result": ReviewResultName.objects.get(reviewteamresult__team=review_req.team, slug="ready").pk, + "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", diff --git a/ietf/doc/utils_search.py b/ietf/doc/utils_search.py index 7b9e3c159..c7345596d 100644 --- a/ietf/doc/utils_search.py +++ b/ietf/doc/utils_search.py @@ -60,8 +60,9 @@ def fill_in_document_table_attributes(docs): d.expirable = expirable_draft(d) if d.get_state_slug() != "rfc": - d.milestones = d.groupmilestone_set.filter(state="active").order_by("time").select_related("group") + d.milestones = sorted((m for m in d.groupmilestone_set.all() if m.state_id == "active"), key=lambda m: m.time) + d.reviewed_by_teams = sorted(set(r.team for r in d.reviewrequest_set.all()), key=lambda g: g.acronym) # RFCs @@ -101,7 +102,7 @@ def prepare_document_table(request, docs, query=None, max_results=500): # evaluate and fill in attribute results immediately to decrease # the number of queries docs = docs.select_related("ad", "ad__person", "std_level", "intended_std_level", "group", "stream") - docs = docs.prefetch_related("states__type", "tags") + docs = docs.prefetch_related("states__type", "tags", "groupmilestone_set__group", "reviewrequest_set__team") docs = list(docs[:max_results]) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index f0f575b45..d6d2eef70 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -64,7 +64,7 @@ from ietf.mailtrigger.utils import gather_relevant_expansions from ietf.meeting.models import Session from ietf.meeting.utils import group_sessions, get_upcoming_manageable_sessions, sort_sessions from ietf.review.models import ReviewRequest -from ietf.review.utils import can_request_review_of_doc, review_requests_to_list_for_doc +from ietf.review.utils import 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): @@ -360,7 +360,7 @@ def document_main(request, name, rev=None): published = doc.latest_event(type="published_rfc") started_iesg_process = doc.latest_event(type="started_iesg_process") - review_requests = review_requests_to_list_for_doc(doc) + 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", @@ -586,7 +586,7 @@ def document_main(request, name, rev=None): other_reviews = [] if review_req: - other_reviews = [r for r in review_requests_to_list_for_doc(review_req.doc) if r != 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, diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 999711415..d1ea1a064 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -12,7 +12,7 @@ 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 +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 @@ -49,16 +49,13 @@ class RequestReviewForm(forms.ModelForm): self.doc = doc - self.fields['type'].queryset = self.fields['type'].queryset.filter(used=True) - self.fields['type'].widget = forms.RadioSelect(choices=[t for t in self.fields['type'].choices if t[0]]) - f = self.fields["team"] f.queryset = active_review_teams() - if not is_authorized_in_doc_stream(user, doc): # user is a reviewer - f.queryset = f.queryset.filter(role__name="reviewer", role__person__user=user) - f.initial = [group.pk for group in f.queryset if can_manage_review_requests_for_team(user, group, allow_non_team_personnel=False)] + self.fields['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"): @@ -76,6 +73,17 @@ class RequestReviewForm(forms.ModelForm): 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) @@ -343,7 +351,7 @@ class CompleteReviewForm(forms.Form): " ".join("{}".format(r) for r in known_revisions)) - self.fields["result"].queryset = self.fields["result"].queryset.filter(reviewteamresult__team=review_req.team) + 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 diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index dc792a8ac..0d1f74c57 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -10,7 +10,7 @@ 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, + extract_revision_ordered_review_requests_for_documents_and_replaced, assign_review_request_to_reviewer, close_review_request, setup_reviewer_field, @@ -201,7 +201,7 @@ def manage_review_requests(request, acronym, group_type=None): review_requests = suggested_review_requests_for_team(group) + open_review_requests - document_requests = extract_revision_ordered_review_requests_for_documents( + 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), ) diff --git a/ietf/iesg/agenda.py b/ietf/iesg/agenda.py index 3b34b1a18..1a0f00a05 100644 --- a/ietf/iesg/agenda.py +++ b/ietf/iesg/agenda.py @@ -9,7 +9,7 @@ from django.http import Http404 from ietf.doc.models import Document, TelechatDocEvent, LastCallDocEvent, ConsensusDocEvent from ietf.iesg.models import TelechatDate, TelechatAgendaItem - +from ietf.review.utils import review_requests_to_list_for_docs def get_agenda_date(date=None): if not date: @@ -152,6 +152,8 @@ def fill_in_agenda_docs(date, sections, matches=None): matches = Document.objects.filter(docevent__telechatdocevent__telechat_date=date) matches = matches.select_related("stream", "group").distinct() + review_requests_for_docs = review_requests_to_list_for_docs(matches) + for doc in matches: if doc.latest_event(TelechatDocEvent, type="scheduled_for_telechat").telechat_date != date: continue @@ -174,6 +176,8 @@ def fill_in_agenda_docs(date, sections, matches=None): e = doc.latest_event(ConsensusDocEvent, type="changed_consensus") if e and (e.consensus != None): doc.consensus = "Yes" if e.consensus else "No" + + doc.review_requests = review_requests_for_docs.get(doc.pk, []) elif doc.type_id == "conflrev": doc.conflictdoc = doc.relateddocument_set.get(relationship__slug='conflrev').target.document elif doc.type_id == "charter": diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 71e358698..220d18512 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -16,8 +16,8 @@ django.setup() import datetime, re, itertools from collections import namedtuple from django.db import connections -from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, - ReviewRequestStateName, ReviewTypeName, ReviewTeamResult, +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 @@ -129,6 +129,10 @@ with db_con.cursor() as c: 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( @@ -162,7 +166,10 @@ with db_con.cursor() as c: summaries = [v.strip().lower() for v in row.value.split(";") if v.strip()] for s in summaries: - ReviewTeamResult.objects.get_or_create(team=team, result=results[s]) + 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 @@ -186,8 +193,8 @@ 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+)') +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]+)') @@ -196,6 +203,8 @@ lcend_re = re.compile('(?:ADD|ADDED|CHANGED) lcend (?:FROM )?(?:[^ ]+ )?=> ([1-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=%' @@ -214,6 +223,9 @@ with db_con.cursor() as c: 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 @@ -233,12 +245,12 @@ with db_con.cursor() as c: 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) + 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) + assert m, 'row.text "{}" does not match update regexp {}'.format(row.text, docname) membername, state = m.groups() used = True @@ -332,6 +344,9 @@ 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) @@ -345,7 +360,7 @@ with db_con.cursor() as c: if row.summary == "noresponse": reviewed_rev = "" - event_collection = None + event_collection = {} branches = document_history.get(row.docname) if not branches: print "WARNING: no history for", row.docname diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index 9c8a17004..94e72bb4d 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -63,7 +63,7 @@ class Migration(migrations.Migration): bases=(models.Model,), ), migrations.CreateModel( - name='ReviewTeamResult', + name='ResultUsedInReviewTeam', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('result', models.ForeignKey(to='name.ReviewResultName')), @@ -73,6 +73,17 @@ class Migration(migrations.Migration): }, 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='ReviewWish', fields=[ diff --git a/ietf/review/models.py b/ietf/review/models.py index f1ef76798..c2d7ac2b0 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -66,7 +66,7 @@ class ReviewWish(models.Model): def __unicode__(self): return u"{} wishes to review {} in {}".format(self.person, self.doc.name, self.team.acronym) -class ReviewTeamResult(models.Model): +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 @@ -75,7 +75,16 @@ class ReviewTeamResult(models.Model): result = models.ForeignKey(ReviewResultName) def __unicode__(self): - return u"{} in {}".format(self.result.name, self.group.acronym) + return u"{} in {}".format(self.result.name, self.team.acronym) + +class TypeUsedInReviewTeam(models.Model): + """Captures that a type name is valid for a given team for new + reviews. """ + team = models.ForeignKey(Group) + type = models.ForeignKey(ReviewTypeName) + + def __unicode__(self): + return u"{} in {}".format(self.type.name, self.team.acronym) class NextReviewerInTeam(models.Model): team = models.ForeignKey(Group) @@ -97,7 +106,7 @@ class ReviewRequest(models.Model): 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(reviewteamresult=None)) + 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") diff --git a/ietf/review/resources.py b/ietf/review/resources.py index 638f385b6..2dffd8677 100644 --- a/ietf/review/resources.py +++ b/ietf/review/resources.py @@ -7,7 +7,8 @@ from tastypie.cache import SimpleCache from ietf import api from ietf.api import ToOneField # pyflakes:ignore -from ietf.review.models import (ReviewerSettings, ReviewRequest, ReviewTeamResult, +from ietf.review.models import (ReviewerSettings, ReviewRequest, + ResultUsedInReviewTeam, TypeUsedInReviewTeam, UnavailablePeriod, ReviewWish, NextReviewerInTeam) @@ -67,20 +68,20 @@ api.review.register(ReviewRequestResource()) from ietf.group.resources import GroupResource from ietf.name.resources import ReviewResultNameResource -class ReviewTeamResultResource(ModelResource): +class ResultUsedInReviewTeamResource(ModelResource): team = ToOneField(GroupResource, 'team') result = ToOneField(ReviewResultNameResource, 'result') class Meta: - queryset = ReviewTeamResult.objects.all() + queryset = ResultUsedInReviewTeam.objects.all() serializer = api.Serializer() cache = SimpleCache() - #resource_name = 'reviewteamresult' + #resource_name = 'resultusedinreviewteam' filtering = { "id": ALL, "team": ALL_WITH_RELATIONS, "result": ALL_WITH_RELATIONS, } -api.review.register(ReviewTeamResultResource()) +api.review.register(ResultUsedInReviewTeamResource()) @@ -144,3 +145,22 @@ class NextReviewerInTeamResource(ModelResource): } api.review.register(NextReviewerInTeamResource()) + + +from ietf.group.resources import GroupResource +from ietf.name.resources import ReviewTypeNameResource +class TypeUsedInReviewTeamResource(ModelResource): + team = ToOneField(GroupResource, 'team') + type = ToOneField(ReviewTypeNameResource, 'type') + class Meta: + queryset = TypeUsedInReviewTeam.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'typeusedinreviewteam' + filtering = { + "id": ALL, + "team": ALL_WITH_RELATIONS, + "type": ALL_WITH_RELATIONS, + } +api.review.register(TypeUsedInReviewTeamResource()) + diff --git a/ietf/review/utils.py b/ietf/review/utils.py index 2578ef705..e578b9410 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -10,14 +10,14 @@ from ietf.doc.models import (Document, ReviewRequestDocEvent, State, 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, +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 ReviewTeamResult defined, it's a review team - return Group.objects.filter(state="active").exclude(reviewteamresult=None) + # 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"]) @@ -35,14 +35,14 @@ def can_manage_review_requests_for_team(user, team, allow_non_team_personnel=Tru return (Role.objects.filter(name__in=["secr", "delegate"], person__user=user, group=team).exists() or (allow_non_team_personnel and has_role(user, "Secretariat"))) -def review_requests_to_list_for_doc(doc): +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 = [doc.name] + doc_names = [d.name for d in docs] - return extract_revision_ordered_review_requests_for_documents(request_qs, doc_names).get(doc.pk, []) + 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( @@ -417,9 +417,9 @@ def suggested_review_requests_for_team(team): requested_state = ReviewRequestStateName.objects.get(slug="requested", used=True) - if True: # FIXME + last_call_type = ReviewTypeName.objects.get(slug="lc") + if TypeUsedInReviewTeam.objects.filter(team=team, type=last_call_type).exists(): # in Last Call - last_call_type = ReviewTypeName.objects.get(slug="lc") last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True)) last_call_expiry_events = { e.doc_id: e for e in LastCallDocEvent.objects.order_by("time", "id") } for doc in last_call_docs: @@ -443,11 +443,11 @@ def suggested_review_requests_for_team(team): seen_deadlines[doc.pk] = deadline - if True: # FIXME + 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_type = ReviewTypeName.objects.get(slug="telechat") telechat_deadline_delta = datetime.timedelta(days=2) telechat_docs = Document.objects.filter( @@ -499,8 +499,8 @@ def suggested_review_requests_for_team(team): res.sort(key=lambda r: (r.deadline, r.doc_id), reverse=True) return res -def extract_revision_ordered_review_requests_for_documents(review_request_queryset, names): - """Extracts all review requests for document names (including replaced ancestors).""" +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) diff --git a/ietf/templates/doc/review_request_summary.html b/ietf/templates/doc/review_request_summary.html index 4d810fdcf..36aedc8c7 100644 --- a/ietf/templates/doc/review_request_summary.html +++ b/ietf/templates/doc/review_request_summary.html @@ -3,13 +3,9 @@ {{ 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 %} - - reviewer: {{ review_request.reviewer.person }} {% else %} {{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review - {% if review_request.reviewer %} - - reviewer: {{ review_request.reviewer.person }} - {% endif %} - due: {{ review_request.deadline|date:"Y-m-d" }} {% endif %} diff --git a/ietf/templates/doc/search/status_columns.html b/ietf/templates/doc/search/status_columns.html index 7a2655c5f..f5ac41e80 100644 --- a/ietf/templates/doc/search/status_columns.html +++ b/ietf/templates/doc/search/status_columns.html @@ -42,6 +42,13 @@ {{ doc.intended_std_level }} {% endif %} + {% if doc.reviewed_by_teams %} +
    Reviewed by: + {% for g in doc.reviewed_by_teams %} + {{ g.acronym }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% endif %} + {% for m in doc.milestones %} {% if forloop.first %}
    {% endif %} {{ m.due|date:"M Y" }}{% if not forloop.last %}, {% endif %} diff --git a/ietf/templates/iesg/agenda_doc.html b/ietf/templates/iesg/agenda_doc.html index 31f5c18ab..9f0796be9 100644 --- a/ietf/templates/iesg/agenda_doc.html +++ b/ietf/templates/iesg/agenda_doc.html @@ -47,6 +47,15 @@
    Consensus
    {{ doc.consensus }}
    {% endif %} + {% if doc.review_requests %} +
    Reviews
    +
    + {% for review_request in doc.review_requests %} + {% include "doc/review_request_summary.html" with current_doc_name=doc.name current_rev=doc.rev %} + {% endfor %} +
    + {% endif %} + {% if doc.lastcall_expires %}
    Last call expires
    {{ doc.lastcall_expires|date:"Y-m-d" }}
    {% endif %} diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index 36f033548..12189d8cb 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -14,7 +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, ReviewTeamResult +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.""" @@ -396,7 +397,9 @@ def make_test_data(): 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"]): - ReviewTeamResult.objects.create(team=team, result=r) + 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() From 5e030ed2061432127202fc0e93428fac72a47dd4 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 6 Oct 2016 15:01:05 +0000 Subject: [PATCH 82/90] Add reviews to IESG agenda text edition too - Legacy-Id: 12096 --- ietf/templates/iesg/agenda_doc.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ietf/templates/iesg/agenda_doc.txt b/ietf/templates/iesg/agenda_doc.txt index 655736628..49ed89043 100644 --- a/ietf/templates/iesg/agenda_doc.txt +++ b/ietf/templates/iesg/agenda_doc.txt @@ -5,5 +5,7 @@ {% endif %} Token: {{ doc.ad }}{% if doc.iana_review_state %} IANA Review: {{ doc.iana_review_state }}{% endif %}{% if doc.consensus %} Consensus: {{ doc.consensus }}{% endif %}{% if doc.lastcall_expires %} - Last call expires: {{ doc.lastcall_expires|date:"Y-m-d" }}{% endif %} + Last call expires: {{ doc.lastcall_expires|date:"Y-m-d" }}{% endif %}{% if doc.review_requests %} + Reviews: {% for review_request in doc.review_requests %}{% with current_doc_name=doc.name current_rev=doc.rev %}{% if not forloop.first %} {% endif %}{{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.state_id == "completed" or review_request.state_id == "part-completed" %}{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev or review_request.doc_id != current_doc_name %} (of {% if review_request.doc_id != current_doc_name %}{{ review_request.doc_id }}{% endif %}-{{ review_request.reviewed_rev }}){% endif %}{% if review_request.result %}: {{ review_request.result.name }}{% endif %} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %}{% else %} - due: {{ review_request.deadline|date:"Y-m-d" }}{% endif %}{% endwith %} +{% endfor %}{% endif %} {% with doc.active_defer_event as defer %}{% if defer %} Was deferred by {{defer.by}} on {{defer.time|date:"Y-m-d"}}{% endif %}{% endwith %} From 59180240afcff35eaffb65a0667e86b4ca4398ae Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Fri, 7 Oct 2016 16:43:29 +0000 Subject: [PATCH 83/90] Add a simple reminder system for reviewers so that they can opt in to being reminded X days before deadline of an assignment - Legacy-Id: 12101 --- ietf/bin/send-reviewer-reminders | 25 +++++++++++ ietf/group/tests_review.py | 35 ++++++++++++++++ ietf/group/views_review.py | 4 +- ietf/review/migrations/0001_initial.py | 31 +++++++------- ietf/review/models.py | 1 + ietf/review/utils.py | 42 ++++++++++++++++++- .../templates/doc/review_request_summary.html | 1 + ietf/templates/ietfauth/review_overview.html | 6 ++- ietf/templates/review/reviewer_reminder.txt | 8 ++++ 9 files changed, 134 insertions(+), 19 deletions(-) create mode 100755 ietf/bin/send-reviewer-reminders create mode 100644 ietf/templates/review/reviewer_reminder.txt diff --git a/ietf/bin/send-reviewer-reminders b/ietf/bin/send-reviewer-reminders new file mode 100755 index 000000000..07d7f3e46 --- /dev/null +++ b/ietf/bin/send-reviewer-reminders @@ -0,0 +1,25 @@ +#!/usr/bin/env python + +import os, sys +import syslog + +# boilerplate +basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +sys.path = [ basedir ] + sys.path +os.environ["DJANGO_SETTINGS_MODULE"] = "ietf.settings" + +virtualenv_activation = os.path.join(basedir, "bin", "activate_this.py") +if os.path.exists(virtualenv_activation): + execfile(virtualenv_activation, dict(__file__=virtualenv_activation)) + +syslog.openlog(os.path.basename(__file__), syslog.LOG_PID, syslog.LOG_USER) + +import django +django.setup() + +import datetime +from ietf.review.utils import review_requests_needing_reviewer_reminder, email_reviewer_reminder + +for review_req in review_requests_needing_reviewer_reminder(datetime.date.today()): + email_reviewer_reminder(review_req) + print("Emailed reminder to {} for review of {} in {} (req. id {})".format(review_req.reviewer.address, review_req.doc_id, review_req.team.acronym, review_req.pk)) diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index b93e60f11..58457865e 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -11,6 +11,7 @@ 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 @@ -316,12 +317,14 @@ class ReviewTests(TestCase): "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()) @@ -370,3 +373,35 @@ class ReviewTests(TestCase): 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) + print outbox[0] + self.assertTrue(review_req.doc_id in unicode(outbox[0])) diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index 0d1f74c57..d5cd6074c 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -350,7 +350,7 @@ def email_open_review_assignments(request, acronym, group_type=None): class ReviewerSettingsForm(forms.ModelForm): class Meta: model = ReviewerSettings - fields = ['min_interval', 'filter_re', 'skip_next'] + fields = ['min_interval', 'filter_re', 'skip_next', 'remind_days_before_deadline'] class AddUnavailablePeriodForm(forms.ModelForm): class Meta: @@ -406,7 +406,7 @@ def change_reviewer_settings(request, acronym, reviewer_email, group_type=None): back_url = request.GET.get("next") if not back_url: import ietf.group.views_review - back_url = urlreverse(ietf.group.views_review.review_requests, kwargs={ "group_type": group.type_id, "acronym": group.acronym}) + 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": diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index 94e72bb4d..7f618abe1 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -26,6 +26,17 @@ class Migration(migrations.Migration): }, 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=[ @@ -33,6 +44,7 @@ class Migration(migrations.Migration): ('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')), ], @@ -63,10 +75,12 @@ class Migration(migrations.Migration): bases=(models.Model,), ), migrations.CreateModel( - name='ResultUsedInReviewTeam', + name='ReviewWish', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('result', models.ForeignKey(to='name.ReviewResultName')), + ('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={ @@ -84,19 +98,6 @@ class Migration(migrations.Migration): }, 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='UnavailablePeriod', fields=[ diff --git a/ietf/review/models.py b/ietf/review/models.py index c2d7ac2b0..8233a681c 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -23,6 +23,7 @@ class ReviewerSettings(models.Model): 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) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index e578b9410..bdc3e9380 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -1,8 +1,9 @@ import datetime, re, itertools from collections import defaultdict -from django.db.models import Q, Max +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, @@ -674,3 +675,42 @@ def make_assignment_choices(email_queryset, review_req): ranking.sort(key=lambda r: r["scores"], reverse=True) return [(r["email"].pk, r["label"]) for r in ranking] + +def review_requests_needing_reviewer_reminder(remind_date): + reqs_qs = ReviewRequest.objects.filter( + state__in=("requested", "accepted"), + reviewer__person__reviewersettings__remind_days_before_deadline__isnull=False, + reviewer__person__reviewersettings__team=F("team"), + ).exclude( + reviewer=None + ).values_list("pk", "deadline", "reviewer__person__reviewersettings__remind_days_before_deadline").distinct() + + req_pks = [] + for r_pk, deadline, remind_days in reqs_qs: + if (deadline - remind_date).days == remind_days: + req_pks.append(r_pk) + + return ReviewRequest.objects.filter(pk__in=req_pks).select_related("reviewer", "reviewer__person", "state", "team") + +def email_reviewer_reminder(review_request): + team = review_request.team + + deadline_days = (review_request.deadline - datetime.date.today()).days + + subject = "Reminder: deadline for review of {} in {} is {}".format(review_request.doc_id, team.acronym, review_request.deadline.isoformat()) + + overview_url = urlreverse("ietf.ietfauth.views.review_overview") + request_url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": review_request.doc_id, "request_id": review_request.pk }) + + domain = Site.objects.get_current().domain + + settings = ReviewerSettings.objects.filter(person=review_request.reviewer.person, team=team).first() + remind_days = settings.remind_days_before_deadline if settings else 0 + + send_mail(None, [review_request.reviewer.formatted_email()], None, subject, "review/reviewer_reminder.txt", { + "reviewer_overview_url": "https://{}{}".format(domain, overview_url), + "review_request_url": "https://{}{}".format(domain, request_url), + "review_request": review_request, + "deadline_days": deadline_days, + "remind_days": remind_days, + }) diff --git a/ietf/templates/doc/review_request_summary.html b/ietf/templates/doc/review_request_summary.html index 36aedc8c7..7f1faa9c6 100644 --- a/ietf/templates/doc/review_request_summary.html +++ b/ietf/templates/doc/review_request_summary.html @@ -3,6 +3,7 @@ {{ 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 %} + {% else %} {{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review diff --git a/ietf/templates/ietfauth/review_overview.html b/ietf/templates/ietfauth/review_overview.html index d91d8e6d4..edf39f11f 100644 --- a/ietf/templates/ietfauth/review_overview.html +++ b/ietf/templates/ietfauth/review_overview.html @@ -134,7 +134,11 @@
    - + + + + + diff --git a/ietf/templates/review/reviewer_reminder.txt b/ietf/templates/review/reviewer_reminder.txt new file mode 100644 index 000000000..4a0dcc540 --- /dev/null +++ b/ietf/templates/review/reviewer_reminder.txt @@ -0,0 +1,8 @@ +{% autoescape off %}{% filter wordwrap:70 %}This is just a friendly reminder that the deadline for the review of {{ review_request.doc_id }} is in {{ deadline_days }} day{{ deadline_days|pluralize }}: + +{{ review_request_url }} + +You are receiving this reminder because you have configured the Datatracker to remind you {{ remind_days }} day{{ remind_days|pluralize }} before deadlines in {{ review_request.team.name }}. You can see your reviews and change your settings here: + +{{ reviewer_overview_url }} +{% endfilter %}{% endautoescape %} From 46dd4682bf936bca85586c4dea5cd696bfa7f149 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Fri, 7 Oct 2016 16:49:37 +0000 Subject: [PATCH 84/90] Remove debug output - Legacy-Id: 12102 --- ietf/group/tests_review.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index 58457865e..c2b8729fe 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -403,5 +403,4 @@ class ReviewTests(TestCase): empty_outbox() email_reviewer_reminder(review_req) self.assertEqual(len(outbox), 1) - print outbox[0] self.assertTrue(review_req.doc_id in unicode(outbox[0])) From 293ecb14881520e66dbad310b3cb433ac4671086 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 11 Oct 2016 11:44:49 +0000 Subject: [PATCH 85/90] Don't use the bootstrap3 title plugin on th elements, it doesn't work properly for those - Legacy-Id: 12115 --- ietf/static/ietf/js/ietf.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/static/ietf/js/ietf.js b/ietf/static/ietf/js/ietf.js index 709ec3374..a239de0c0 100644 --- a/ietf/static/ietf/js/ietf.js +++ b/ietf/static/ietf/js/ietf.js @@ -194,7 +194,7 @@ $(".snippet .show-all").click(function () { // }); // Use the Bootstrap3 tooltip plugin for all elements with a title attribute -$('[title][title!=""]').tooltip(); +$('[title][title!=""]').not("th").tooltip(); $(document).ready(function () { // add a required class on labels on forms that should have From bf55237112b10cf07fce88185589fceeb8f463e4 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 13 Oct 2016 15:20:04 +0000 Subject: [PATCH 86/90] Add statistics section with review statistics. Fix a couple of missing tests. - Legacy-Id: 12124 --- ietf/externals/static/flot/jquery.flot.min.js | 8 + .../static/flot/jquery.flot.time.min.js | 7 + ietf/group/tests_review.py | 34 ++- ietf/group/views_review.py | 23 +- .../0015_insert_review_name_data.py | 2 +- ietf/review/utils.py | 77 +++++- ietf/static/ietf/css/ietf.css | 37 +++ ietf/static/ietf/js/review-stats.js | 8 + ietf/templates/base/menu.html | 1 + ietf/templates/stats/index.html | 18 ++ ietf/templates/stats/review_stats.html | 260 ++++++++++++++++++ ietf/urls.py | 3 +- 12 files changed, 444 insertions(+), 34 deletions(-) create mode 100644 ietf/externals/static/flot/jquery.flot.min.js create mode 100644 ietf/externals/static/flot/jquery.flot.time.min.js create mode 100644 ietf/static/ietf/js/review-stats.js create mode 100644 ietf/templates/stats/index.html create mode 100644 ietf/templates/stats/review_stats.html diff --git a/ietf/externals/static/flot/jquery.flot.min.js b/ietf/externals/static/flot/jquery.flot.min.js new file mode 100644 index 000000000..968d3ebd9 --- /dev/null +++ b/ietf/externals/static/flot/jquery.flot.min.js @@ -0,0 +1,8 @@ +/* Javascript plotting library for jQuery, version 0.8.3. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +*/ +(function($){$.color={};$.color.make=function(r,g,b,a){var o={};o.r=r||0;o.g=g||0;o.b=b||0;o.a=a!=null?a:1;o.add=function(c,d){for(var i=0;i=1){return"rgb("+[o.r,o.g,o.b].join(",")+")"}else{return"rgba("+[o.r,o.g,o.b,o.a].join(",")+")"}};o.normalize=function(){function clamp(min,value,max){return valuemax?max:value}o.r=clamp(0,parseInt(o.r),255);o.g=clamp(0,parseInt(o.g),255);o.b=clamp(0,parseInt(o.b),255);o.a=clamp(0,o.a,1);return o};o.clone=function(){return $.color.make(o.r,o.b,o.g,o.a)};return o.normalize()};$.color.extract=function(elem,css){var c;do{c=elem.css(css).toLowerCase();if(c!=""&&c!="transparent")break;elem=elem.parent()}while(elem.length&&!$.nodeName(elem.get(0),"body"));if(c=="rgba(0, 0, 0, 0)")c="transparent";return $.color.parse(c)};$.color.parse=function(str){var res,m=$.color.make;if(res=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10));if(res=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10),parseFloat(res[4]));if(res=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55);if(res=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55,parseFloat(res[4]));if(res=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str))return m(parseInt(res[1],16),parseInt(res[2],16),parseInt(res[3],16));if(res=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str))return m(parseInt(res[1]+res[1],16),parseInt(res[2]+res[2],16),parseInt(res[3]+res[3],16));var name=$.trim(str).toLowerCase();if(name=="transparent")return m(255,255,255,0);else{res=lookupColors[name]||[0,0,0];return m(res[0],res[1],res[2])}};var lookupColors={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery);(function($){var hasOwnProperty=Object.prototype.hasOwnProperty;if(!$.fn.detach){$.fn.detach=function(){return this.each(function(){if(this.parentNode){this.parentNode.removeChild(this)}})}}function Canvas(cls,container){var element=container.children("."+cls)[0];if(element==null){element=document.createElement("canvas");element.className=cls;$(element).css({direction:"ltr",position:"absolute",left:0,top:0}).appendTo(container);if(!element.getContext){if(window.G_vmlCanvasManager){element=window.G_vmlCanvasManager.initElement(element)}else{throw new Error("Canvas is not available. If you're using IE with a fall-back such as Excanvas, then there's either a mistake in your conditional include, or the page has no DOCTYPE and is rendering in Quirks Mode.")}}}this.element=element;var context=this.context=element.getContext("2d");var devicePixelRatio=window.devicePixelRatio||1,backingStoreRatio=context.webkitBackingStorePixelRatio||context.mozBackingStorePixelRatio||context.msBackingStorePixelRatio||context.oBackingStorePixelRatio||context.backingStorePixelRatio||1;this.pixelRatio=devicePixelRatio/backingStoreRatio;this.resize(container.width(),container.height());this.textContainer=null;this.text={};this._textCache={}}Canvas.prototype.resize=function(width,height){if(width<=0||height<=0){throw new Error("Invalid dimensions for plot, width = "+width+", height = "+height)}var element=this.element,context=this.context,pixelRatio=this.pixelRatio;if(this.width!=width){element.width=width*pixelRatio;element.style.width=width+"px";this.width=width}if(this.height!=height){element.height=height*pixelRatio;element.style.height=height+"px";this.height=height}context.restore();context.save();context.scale(pixelRatio,pixelRatio)};Canvas.prototype.clear=function(){this.context.clearRect(0,0,this.width,this.height)};Canvas.prototype.render=function(){var cache=this._textCache;for(var layerKey in cache){if(hasOwnProperty.call(cache,layerKey)){var layer=this.getTextLayer(layerKey),layerCache=cache[layerKey];layer.hide();for(var styleKey in layerCache){if(hasOwnProperty.call(layerCache,styleKey)){var styleCache=layerCache[styleKey];for(var key in styleCache){if(hasOwnProperty.call(styleCache,key)){var positions=styleCache[key].positions;for(var i=0,position;position=positions[i];i++){if(position.active){if(!position.rendered){layer.append(position.element);position.rendered=true}}else{positions.splice(i--,1);if(position.rendered){position.element.detach()}}}if(positions.length==0){delete styleCache[key]}}}}}layer.show()}}};Canvas.prototype.getTextLayer=function(classes){var layer=this.text[classes];if(layer==null){if(this.textContainer==null){this.textContainer=$("
    ").css({position:"absolute",top:0,left:0,bottom:0,right:0,"font-size":"smaller",color:"#545454"}).insertAfter(this.element)}layer=this.text[classes]=$("
    ").addClass(classes).css({position:"absolute",top:0,left:0,bottom:0,right:0}).appendTo(this.textContainer)}return layer};Canvas.prototype.getTextInfo=function(layer,text,font,angle,width){var textStyle,layerCache,styleCache,info;text=""+text;if(typeof font==="object"){textStyle=font.style+" "+font.variant+" "+font.weight+" "+font.size+"px/"+font.lineHeight+"px "+font.family}else{textStyle=font}layerCache=this._textCache[layer];if(layerCache==null){layerCache=this._textCache[layer]={}}styleCache=layerCache[textStyle];if(styleCache==null){styleCache=layerCache[textStyle]={}}info=styleCache[text];if(info==null){var element=$("
    ").html(text).css({position:"absolute","max-width":width,top:-9999}).appendTo(this.getTextLayer(layer));if(typeof font==="object"){element.css({font:textStyle,color:font.color})}else if(typeof font==="string"){element.addClass(font)}info=styleCache[text]={width:element.outerWidth(true),height:element.outerHeight(true),element:element,positions:[]};element.detach()}return info};Canvas.prototype.addText=function(layer,x,y,text,font,angle,width,halign,valign){var info=this.getTextInfo(layer,text,font,angle,width),positions=info.positions;if(halign=="center"){x-=info.width/2}else if(halign=="right"){x-=info.width}if(valign=="middle"){y-=info.height/2}else if(valign=="bottom"){y-=info.height}for(var i=0,position;position=positions[i];i++){if(position.x==x&&position.y==y){position.active=true;return}}position={active:true,rendered:false,element:positions.length?info.element.clone():info.element,x:x,y:y};positions.push(position);position.element.css({top:Math.round(y),left:Math.round(x),"text-align":halign})};Canvas.prototype.removeText=function(layer,x,y,text,font,angle){if(text==null){var layerCache=this._textCache[layer];if(layerCache!=null){for(var styleKey in layerCache){if(hasOwnProperty.call(layerCache,styleKey)){var styleCache=layerCache[styleKey];for(var key in styleCache){if(hasOwnProperty.call(styleCache,key)){var positions=styleCache[key].positions;for(var i=0,position;position=positions[i];i++){position.active=false}}}}}}}else{var positions=this.getTextInfo(layer,text,font,angle).positions;for(var i=0,position;position=positions[i];i++){if(position.x==x&&position.y==y){position.active=false}}}};function Plot(placeholder,data_,options_,plugins){var series=[],options={colors:["#edc240","#afd8f8","#cb4b4b","#4da74d","#9440ed"],legend:{show:true,noColumns:1,labelFormatter:null,labelBoxBorderColor:"#ccc",container:null,position:"ne",margin:5,backgroundColor:null,backgroundOpacity:.85,sorted:null},xaxis:{show:null,position:"bottom",mode:null,font:null,color:null,tickColor:null,transform:null,inverseTransform:null,min:null,max:null,autoscaleMargin:null,ticks:null,tickFormatter:null,labelWidth:null,labelHeight:null,reserveSpace:null,tickLength:null,alignTicksWithAxis:null,tickDecimals:null,tickSize:null,minTickSize:null},yaxis:{autoscaleMargin:.02,position:"left"},xaxes:[],yaxes:[],series:{points:{show:false,radius:3,lineWidth:2,fill:true,fillColor:"#ffffff",symbol:"circle"},lines:{lineWidth:2,fill:false,fillColor:null,steps:false},bars:{show:false,lineWidth:2,barWidth:1,fill:true,fillColor:null,align:"left",horizontal:false,zero:true},shadowSize:3,highlightColor:null},grid:{show:true,aboveData:false,color:"#545454",backgroundColor:null,borderColor:null,tickColor:null,margin:0,labelMargin:5,axisMargin:8,borderWidth:2,minBorderMargin:null,markings:null,markingsColor:"#f4f4f4",markingsLineWidth:2,clickable:false,hoverable:false,autoHighlight:true,mouseActiveRadius:10},interaction:{redrawOverlayInterval:1e3/60},hooks:{}},surface=null,overlay=null,eventHolder=null,ctx=null,octx=null,xaxes=[],yaxes=[],plotOffset={left:0,right:0,top:0,bottom:0},plotWidth=0,plotHeight=0,hooks={processOptions:[],processRawData:[],processDatapoints:[],processOffset:[],drawBackground:[],drawSeries:[],draw:[],bindEvents:[],drawOverlay:[],shutdown:[]},plot=this;plot.setData=setData;plot.setupGrid=setupGrid;plot.draw=draw;plot.getPlaceholder=function(){return placeholder};plot.getCanvas=function(){return surface.element};plot.getPlotOffset=function(){return plotOffset};plot.width=function(){return plotWidth};plot.height=function(){return plotHeight};plot.offset=function(){var o=eventHolder.offset();o.left+=plotOffset.left;o.top+=plotOffset.top;return o};plot.getData=function(){return series};plot.getAxes=function(){var res={},i;$.each(xaxes.concat(yaxes),function(_,axis){if(axis)res[axis.direction+(axis.n!=1?axis.n:"")+"axis"]=axis});return res};plot.getXAxes=function(){return xaxes};plot.getYAxes=function(){return yaxes};plot.c2p=canvasToAxisCoords;plot.p2c=axisToCanvasCoords;plot.getOptions=function(){return options};plot.highlight=highlight;plot.unhighlight=unhighlight;plot.triggerRedrawOverlay=triggerRedrawOverlay;plot.pointOffset=function(point){return{left:parseInt(xaxes[axisNumber(point,"x")-1].p2c(+point.x)+plotOffset.left,10),top:parseInt(yaxes[axisNumber(point,"y")-1].p2c(+point.y)+plotOffset.top,10)}};plot.shutdown=shutdown;plot.destroy=function(){shutdown();placeholder.removeData("plot").empty();series=[];options=null;surface=null;overlay=null;eventHolder=null;ctx=null;octx=null;xaxes=[];yaxes=[];hooks=null;highlights=[];plot=null};plot.resize=function(){var width=placeholder.width(),height=placeholder.height();surface.resize(width,height);overlay.resize(width,height)};plot.hooks=hooks;initPlugins(plot);parseOptions(options_);setupCanvases();setData(data_);setupGrid();draw();bindEvents();function executeHooks(hook,args){args=[plot].concat(args);for(var i=0;imaxIndex){maxIndex=sc}}}if(neededColors<=maxIndex){neededColors=maxIndex+1}var c,colors=[],colorPool=options.colors,colorPoolSize=colorPool.length,variation=0;for(i=0;i=0){if(variation<.5){variation=-variation-.2}else variation=0}else variation=-variation}colors[i]=c.scale("rgb",1+variation)}var colori=0,s;for(i=0;iaxis.datamax&&max!=fakeInfinity)axis.datamax=max}$.each(allAxes(),function(_,axis){axis.datamin=topSentry;axis.datamax=bottomSentry;axis.used=false});for(i=0;i0&&points[k-ps]!=null&&points[k-ps]!=points[k]&&points[k-ps+1]!=points[k+1]){for(m=0;mxmax)xmax=val}if(f.y){if(valymax)ymax=val}}}if(s.bars.show){var delta;switch(s.bars.align){case"left":delta=0;break;case"right":delta=-s.bars.barWidth;break;default:delta=-s.bars.barWidth/2}if(s.bars.horizontal){ymin+=delta;ymax+=delta+s.bars.barWidth}else{xmin+=delta;xmax+=delta+s.bars.barWidth}}updateAxis(s.xaxis,xmin,xmax);updateAxis(s.yaxis,ymin,ymax)}$.each(allAxes(),function(_,axis){if(axis.datamin==topSentry)axis.datamin=null;if(axis.datamax==bottomSentry)axis.datamax=null})}function setupCanvases(){placeholder.css("padding",0).children().filter(function(){return!$(this).hasClass("flot-overlay")&&!$(this).hasClass("flot-base")}).remove();if(placeholder.css("position")=="static")placeholder.css("position","relative");surface=new Canvas("flot-base",placeholder);overlay=new Canvas("flot-overlay",placeholder);ctx=surface.context;octx=overlay.context;eventHolder=$(overlay.element).unbind();var existing=placeholder.data("plot");if(existing){existing.shutdown();overlay.clear()}placeholder.data("plot",plot)}function bindEvents(){if(options.grid.hoverable){eventHolder.mousemove(onMouseMove);eventHolder.bind("mouseleave",onMouseLeave)}if(options.grid.clickable)eventHolder.click(onClick);executeHooks(hooks.bindEvents,[eventHolder])}function shutdown(){if(redrawTimeout)clearTimeout(redrawTimeout);eventHolder.unbind("mousemove",onMouseMove);eventHolder.unbind("mouseleave",onMouseLeave);eventHolder.unbind("click",onClick);executeHooks(hooks.shutdown,[eventHolder])}function setTransformationHelpers(axis){function identity(x){return x}var s,m,t=axis.options.transform||identity,it=axis.options.inverseTransform;if(axis.direction=="x"){s=axis.scale=plotWidth/Math.abs(t(axis.max)-t(axis.min));m=Math.min(t(axis.max),t(axis.min))}else{s=axis.scale=plotHeight/Math.abs(t(axis.max)-t(axis.min));s=-s;m=Math.max(t(axis.max),t(axis.min))}if(t==identity)axis.p2c=function(p){return(p-m)*s};else axis.p2c=function(p){return(t(p)-m)*s};if(!it)axis.c2p=function(c){return m+c/s};else axis.c2p=function(c){return it(m+c/s)}}function measureTickLabels(axis){var opts=axis.options,ticks=axis.ticks||[],labelWidth=opts.labelWidth||0,labelHeight=opts.labelHeight||0,maxWidth=labelWidth||(axis.direction=="x"?Math.floor(surface.width/(ticks.length||1)):null),legacyStyles=axis.direction+"Axis "+axis.direction+axis.n+"Axis",layer="flot-"+axis.direction+"-axis flot-"+axis.direction+axis.n+"-axis "+legacyStyles,font=opts.font||"flot-tick-label tickLabel";for(var i=0;i=0;--i)allocateAxisBoxFirstPhase(allocatedAxes[i]);adjustLayoutForThingsStickingOut();$.each(allocatedAxes,function(_,axis){allocateAxisBoxSecondPhase(axis)})}plotWidth=surface.width-plotOffset.left-plotOffset.right;plotHeight=surface.height-plotOffset.bottom-plotOffset.top;$.each(axes,function(_,axis){setTransformationHelpers(axis)});if(showGrid){drawAxisLabels()}insertLegend()}function setRange(axis){var opts=axis.options,min=+(opts.min!=null?opts.min:axis.datamin),max=+(opts.max!=null?opts.max:axis.datamax),delta=max-min;if(delta==0){var widen=max==0?1:.01;if(opts.min==null)min-=widen;if(opts.max==null||opts.min!=null)max+=widen}else{var margin=opts.autoscaleMargin;if(margin!=null){if(opts.min==null){min-=delta*margin;if(min<0&&axis.datamin!=null&&axis.datamin>=0)min=0}if(opts.max==null){max+=delta*margin;if(max>0&&axis.datamax!=null&&axis.datamax<=0)max=0}}}axis.min=min;axis.max=max}function setupTickGeneration(axis){var opts=axis.options;var noTicks;if(typeof opts.ticks=="number"&&opts.ticks>0)noTicks=opts.ticks;else noTicks=.3*Math.sqrt(axis.direction=="x"?surface.width:surface.height);var delta=(axis.max-axis.min)/noTicks,dec=-Math.floor(Math.log(delta)/Math.LN10),maxDec=opts.tickDecimals;if(maxDec!=null&&dec>maxDec){dec=maxDec}var magn=Math.pow(10,-dec),norm=delta/magn,size;if(norm<1.5){size=1}else if(norm<3){size=2;if(norm>2.25&&(maxDec==null||dec+1<=maxDec)){size=2.5;++dec}}else if(norm<7.5){size=5}else{size=10}size*=magn;if(opts.minTickSize!=null&&size0){if(opts.min==null)axis.min=Math.min(axis.min,niceTicks[0]);if(opts.max==null&&niceTicks.length>1)axis.max=Math.max(axis.max,niceTicks[niceTicks.length-1])}axis.tickGenerator=function(axis){var ticks=[],v,i;for(i=0;i1&&/\..*0$/.test((ts[1]-ts[0]).toFixed(extraDec))))axis.tickDecimals=extraDec}}}}function setTicks(axis){var oticks=axis.options.ticks,ticks=[];if(oticks==null||typeof oticks=="number"&&oticks>0)ticks=axis.tickGenerator(axis);else if(oticks){if($.isFunction(oticks))ticks=oticks(axis);else ticks=oticks}var i,v;axis.ticks=[];for(i=0;i1)label=t[1]}else v=+t;if(label==null)label=axis.tickFormatter(v,axis);if(!isNaN(v))axis.ticks.push({v:v,label:label})}}function snapRangeToTicks(axis,ticks){if(axis.options.autoscaleMargin&&ticks.length>0){if(axis.options.min==null)axis.min=Math.min(axis.min,ticks[0].v);if(axis.options.max==null&&ticks.length>1)axis.max=Math.max(axis.max,ticks[ticks.length-1].v)}}function draw(){surface.clear();executeHooks(hooks.drawBackground,[ctx]);var grid=options.grid;if(grid.show&&grid.backgroundColor)drawBackground();if(grid.show&&!grid.aboveData){drawGrid()}for(var i=0;ito){var tmp=from;from=to;to=tmp}return{from:from,to:to,axis:axis}}function drawBackground(){ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.fillStyle=getColorOrGradient(options.grid.backgroundColor,plotHeight,0,"rgba(255, 255, 255, 0)");ctx.fillRect(0,0,plotWidth,plotHeight);ctx.restore()}function drawGrid(){var i,axes,bw,bc;ctx.save();ctx.translate(plotOffset.left,plotOffset.top);var markings=options.grid.markings;if(markings){if($.isFunction(markings)){axes=plot.getAxes();axes.xmin=axes.xaxis.min;axes.xmax=axes.xaxis.max;axes.ymin=axes.yaxis.min;axes.ymax=axes.yaxis.max;markings=markings(axes)}for(i=0;ixrange.axis.max||yrange.toyrange.axis.max)continue;xrange.from=Math.max(xrange.from,xrange.axis.min);xrange.to=Math.min(xrange.to,xrange.axis.max);yrange.from=Math.max(yrange.from,yrange.axis.min);yrange.to=Math.min(yrange.to,yrange.axis.max);var xequal=xrange.from===xrange.to,yequal=yrange.from===yrange.to;if(xequal&&yequal){continue}xrange.from=Math.floor(xrange.axis.p2c(xrange.from));xrange.to=Math.floor(xrange.axis.p2c(xrange.to));yrange.from=Math.floor(yrange.axis.p2c(yrange.from));yrange.to=Math.floor(yrange.axis.p2c(yrange.to));if(xequal||yequal){var lineWidth=m.lineWidth||options.grid.markingsLineWidth,subPixel=lineWidth%2?.5:0;ctx.beginPath();ctx.strokeStyle=m.color||options.grid.markingsColor;ctx.lineWidth=lineWidth;if(xequal){ctx.moveTo(xrange.to+subPixel,yrange.from);ctx.lineTo(xrange.to+subPixel,yrange.to)}else{ctx.moveTo(xrange.from,yrange.to+subPixel);ctx.lineTo(xrange.to,yrange.to+subPixel)}ctx.stroke()}else{ctx.fillStyle=m.color||options.grid.markingsColor;ctx.fillRect(xrange.from,yrange.to,xrange.to-xrange.from,yrange.from-yrange.to)}}}axes=allAxes();bw=options.grid.borderWidth;for(var j=0;jaxis.max||t=="full"&&(typeof bw=="object"&&bw[axis.position]>0||bw>0)&&(v==axis.min||v==axis.max))continue;if(axis.direction=="x"){x=axis.p2c(v);yoff=t=="full"?-plotHeight:t;if(axis.position=="top")yoff=-yoff}else{y=axis.p2c(v);xoff=t=="full"?-plotWidth:t;if(axis.position=="left")xoff=-xoff}if(ctx.lineWidth==1){if(axis.direction=="x")x=Math.floor(x)+.5;else y=Math.floor(y)+.5}ctx.moveTo(x,y);ctx.lineTo(x+xoff,y+yoff)}ctx.stroke()}if(bw){bc=options.grid.borderColor;if(typeof bw=="object"||typeof bc=="object"){if(typeof bw!=="object"){bw={top:bw,right:bw,bottom:bw,left:bw}}if(typeof bc!=="object"){bc={top:bc,right:bc,bottom:bc,left:bc}}if(bw.top>0){ctx.strokeStyle=bc.top;ctx.lineWidth=bw.top;ctx.beginPath();ctx.moveTo(0-bw.left,0-bw.top/2);ctx.lineTo(plotWidth,0-bw.top/2);ctx.stroke()}if(bw.right>0){ctx.strokeStyle=bc.right;ctx.lineWidth=bw.right;ctx.beginPath();ctx.moveTo(plotWidth+bw.right/2,0-bw.top);ctx.lineTo(plotWidth+bw.right/2,plotHeight);ctx.stroke()}if(bw.bottom>0){ctx.strokeStyle=bc.bottom;ctx.lineWidth=bw.bottom;ctx.beginPath();ctx.moveTo(plotWidth+bw.right,plotHeight+bw.bottom/2);ctx.lineTo(0,plotHeight+bw.bottom/2);ctx.stroke()}if(bw.left>0){ctx.strokeStyle=bc.left;ctx.lineWidth=bw.left;ctx.beginPath();ctx.moveTo(0-bw.left/2,plotHeight+bw.bottom);ctx.lineTo(0-bw.left/2,0);ctx.stroke()}}else{ctx.lineWidth=bw;ctx.strokeStyle=options.grid.borderColor;ctx.strokeRect(-bw/2,-bw/2,plotWidth+bw,plotHeight+bw)}}ctx.restore()}function drawAxisLabels(){$.each(allAxes(),function(_,axis){var box=axis.box,legacyStyles=axis.direction+"Axis "+axis.direction+axis.n+"Axis",layer="flot-"+axis.direction+"-axis flot-"+axis.direction+axis.n+"-axis "+legacyStyles,font=axis.options.font||"flot-tick-label tickLabel",tick,x,y,halign,valign;surface.removeText(layer);if(!axis.show||axis.ticks.length==0)return;for(var i=0;iaxis.max)continue;if(axis.direction=="x"){halign="center";x=plotOffset.left+axis.p2c(tick.v);if(axis.position=="bottom"){y=box.top+box.padding}else{y=box.top+box.height-box.padding;valign="bottom"}}else{valign="middle";y=plotOffset.top+axis.p2c(tick.v);if(axis.position=="left"){x=box.left+box.width-box.padding;halign="right"}else{x=box.left+box.padding}}surface.addText(layer,x,y,tick.label,font,null,null,halign,valign)}})}function drawSeries(series){if(series.lines.show)drawSeriesLines(series);if(series.bars.show)drawSeriesBars(series);if(series.points.show)drawSeriesPoints(series)}function drawSeriesLines(series){function plotLine(datapoints,xoffset,yoffset,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize,prevx=null,prevy=null;ctx.beginPath();for(var i=ps;i=y2&&y1>axisy.max){if(y2>axisy.max)continue;x1=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.max}else if(y2>=y1&&y2>axisy.max){if(y1>axisy.max)continue;x2=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.max}if(x1<=x2&&x1=x2&&x1>axisx.max){if(x2>axisx.max)continue;y1=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.max}else if(x2>=x1&&x2>axisx.max){if(x1>axisx.max)continue;y2=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.max}if(x1!=prevx||y1!=prevy)ctx.moveTo(axisx.p2c(x1)+xoffset,axisy.p2c(y1)+yoffset);prevx=x2;prevy=y2;ctx.lineTo(axisx.p2c(x2)+xoffset,axisy.p2c(y2)+yoffset)}ctx.stroke()}function plotLineArea(datapoints,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize,bottom=Math.min(Math.max(0,axisy.min),axisy.max),i=0,top,areaOpen=false,ypos=1,segmentStart=0,segmentEnd=0;while(true){if(ps>0&&i>points.length+ps)break;i+=ps;var x1=points[i-ps],y1=points[i-ps+ypos],x2=points[i],y2=points[i+ypos];if(areaOpen){if(ps>0&&x1!=null&&x2==null){segmentEnd=i;ps=-ps;ypos=2;continue}if(ps<0&&i==segmentStart+ps){ctx.fill();areaOpen=false;ps=-ps;ypos=1;i=segmentStart=segmentEnd+ps;continue}}if(x1==null||x2==null)continue;if(x1<=x2&&x1=x2&&x1>axisx.max){if(x2>axisx.max)continue;y1=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x1=axisx.max}else if(x2>=x1&&x2>axisx.max){if(x1>axisx.max)continue;y2=(axisx.max-x1)/(x2-x1)*(y2-y1)+y1;x2=axisx.max}if(!areaOpen){ctx.beginPath();ctx.moveTo(axisx.p2c(x1),axisy.p2c(bottom));areaOpen=true}if(y1>=axisy.max&&y2>=axisy.max){ctx.lineTo(axisx.p2c(x1),axisy.p2c(axisy.max));ctx.lineTo(axisx.p2c(x2),axisy.p2c(axisy.max));continue}else if(y1<=axisy.min&&y2<=axisy.min){ctx.lineTo(axisx.p2c(x1),axisy.p2c(axisy.min));ctx.lineTo(axisx.p2c(x2),axisy.p2c(axisy.min));continue}var x1old=x1,x2old=x2;if(y1<=y2&&y1=axisy.min){x1=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.min}else if(y2<=y1&&y2=axisy.min){x2=(axisy.min-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.min}if(y1>=y2&&y1>axisy.max&&y2<=axisy.max){x1=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y1=axisy.max}else if(y2>=y1&&y2>axisy.max&&y1<=axisy.max){x2=(axisy.max-y1)/(y2-y1)*(x2-x1)+x1;y2=axisy.max}if(x1!=x1old){ctx.lineTo(axisx.p2c(x1old),axisy.p2c(y1))}ctx.lineTo(axisx.p2c(x1),axisy.p2c(y1));ctx.lineTo(axisx.p2c(x2),axisy.p2c(y2));if(x2!=x2old){ctx.lineTo(axisx.p2c(x2),axisy.p2c(y2));ctx.lineTo(axisx.p2c(x2old),axisy.p2c(y2))}}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);ctx.lineJoin="round";var lw=series.lines.lineWidth,sw=series.shadowSize;if(lw>0&&sw>0){ctx.lineWidth=sw;ctx.strokeStyle="rgba(0,0,0,0.1)";var angle=Math.PI/18;plotLine(series.datapoints,Math.sin(angle)*(lw/2+sw/2),Math.cos(angle)*(lw/2+sw/2),series.xaxis,series.yaxis);ctx.lineWidth=sw/2;plotLine(series.datapoints,Math.sin(angle)*(lw/2+sw/4),Math.cos(angle)*(lw/2+sw/4),series.xaxis,series.yaxis)}ctx.lineWidth=lw;ctx.strokeStyle=series.color;var fillStyle=getFillStyle(series.lines,series.color,0,plotHeight);if(fillStyle){ctx.fillStyle=fillStyle;plotLineArea(series.datapoints,series.xaxis,series.yaxis)}if(lw>0)plotLine(series.datapoints,0,0,series.xaxis,series.yaxis);ctx.restore()}function drawSeriesPoints(series){function plotPoints(datapoints,radius,fillStyle,offset,shadow,axisx,axisy,symbol){var points=datapoints.points,ps=datapoints.pointsize;for(var i=0;iaxisx.max||yaxisy.max)continue;ctx.beginPath();x=axisx.p2c(x);y=axisy.p2c(y)+offset;if(symbol=="circle")ctx.arc(x,y,radius,0,shadow?Math.PI:Math.PI*2,false);else symbol(ctx,x,y,radius,shadow);ctx.closePath();if(fillStyle){ctx.fillStyle=fillStyle;ctx.fill()}ctx.stroke()}}ctx.save();ctx.translate(plotOffset.left,plotOffset.top);var lw=series.points.lineWidth,sw=series.shadowSize,radius=series.points.radius,symbol=series.points.symbol;if(lw==0)lw=1e-4;if(lw>0&&sw>0){var w=sw/2;ctx.lineWidth=w;ctx.strokeStyle="rgba(0,0,0,0.1)";plotPoints(series.datapoints,radius,null,w+w/2,true,series.xaxis,series.yaxis,symbol);ctx.strokeStyle="rgba(0,0,0,0.2)";plotPoints(series.datapoints,radius,null,w/2,true,series.xaxis,series.yaxis,symbol)}ctx.lineWidth=lw;ctx.strokeStyle=series.color;plotPoints(series.datapoints,radius,getFillStyle(series.points,series.color),0,false,series.xaxis,series.yaxis,symbol);ctx.restore()}function drawBar(x,y,b,barLeft,barRight,fillStyleCallback,axisx,axisy,c,horizontal,lineWidth){var left,right,bottom,top,drawLeft,drawRight,drawTop,drawBottom,tmp;if(horizontal){drawBottom=drawRight=drawTop=true;drawLeft=false;left=b;right=x;top=y+barLeft;bottom=y+barRight;if(rightaxisx.max||topaxisy.max)return;if(leftaxisx.max){right=axisx.max;drawRight=false}if(bottomaxisy.max){top=axisy.max;drawTop=false}left=axisx.p2c(left);bottom=axisy.p2c(bottom);right=axisx.p2c(right);top=axisy.p2c(top);if(fillStyleCallback){c.fillStyle=fillStyleCallback(bottom,top);c.fillRect(left,top,right-left,bottom-top)}if(lineWidth>0&&(drawLeft||drawRight||drawTop||drawBottom)){c.beginPath();c.moveTo(left,bottom);if(drawLeft)c.lineTo(left,top);else c.moveTo(left,top);if(drawTop)c.lineTo(right,top);else c.moveTo(right,top);if(drawRight)c.lineTo(right,bottom);else c.moveTo(right,bottom);if(drawBottom)c.lineTo(left,bottom);else c.moveTo(left,bottom);c.stroke()}}function drawSeriesBars(series){function plotBars(datapoints,barLeft,barRight,fillStyleCallback,axisx,axisy){var points=datapoints.points,ps=datapoints.pointsize;for(var i=0;i");fragments.push("
    ");rowStarted=true}fragments.push(''+'")}if(rowStarted)fragments.push("");if(fragments.length==0)return;var table='
    Filter regexp{{ t.reviewer_settings.filter_re|default:"(None)" }}{% if t.reviewer_settings.filter_re %}{{ t.reviewer_settings.filter_re }}{% else %}(None){% endif %}
    Remind days before deadline{{ t.reviewer_settings.remind_days_before_deadline|default:"(Do not remind)" }}
    Unavailable periods
    '+entry.label+"
    '+fragments.join("")+"
    ";if(options.legend.container!=null)$(options.legend.container).html(table);else{var pos="",p=options.legend.position,m=options.legend.margin;if(m[0]==null)m=[m,m];if(p.charAt(0)=="n")pos+="top:"+(m[1]+plotOffset.top)+"px;";else if(p.charAt(0)=="s")pos+="bottom:"+(m[1]+plotOffset.bottom)+"px;";if(p.charAt(1)=="e")pos+="right:"+(m[0]+plotOffset.right)+"px;";else if(p.charAt(1)=="w")pos+="left:"+(m[0]+plotOffset.left)+"px;";var legend=$('
    '+table.replace('style="','style="position:absolute;'+pos+";")+"
    ").appendTo(placeholder);if(options.legend.backgroundOpacity!=0){var c=options.legend.backgroundColor;if(c==null){c=options.grid.backgroundColor;if(c&&typeof c=="string")c=$.color.parse(c);else c=$.color.extract(legend,"background-color");c.a=1;c=c.toString()}var div=legend.children();$('
    ').prependTo(legend).css("opacity",options.legend.backgroundOpacity)}}}var highlights=[],redrawTimeout=null;function findNearbyItem(mouseX,mouseY,seriesFilter){var maxDistance=options.grid.mouseActiveRadius,smallestDistance=maxDistance*maxDistance+1,item=null,foundPoint=false,i,j,ps;for(i=series.length-1;i>=0;--i){if(!seriesFilter(series[i]))continue;var s=series[i],axisx=s.xaxis,axisy=s.yaxis,points=s.datapoints.points,mx=axisx.c2p(mouseX),my=axisy.c2p(mouseY),maxx=maxDistance/axisx.scale,maxy=maxDistance/axisy.scale;ps=s.datapoints.pointsize;if(axisx.options.inverseTransform)maxx=Number.MAX_VALUE;if(axisy.options.inverseTransform)maxy=Number.MAX_VALUE;if(s.lines.show||s.points.show){for(j=0;jmaxx||x-mx<-maxx||y-my>maxy||y-my<-maxy)continue;var dx=Math.abs(axisx.p2c(x)-mouseX),dy=Math.abs(axisy.p2c(y)-mouseY),dist=dx*dx+dy*dy;if(dist=Math.min(b,x)&&my>=y+barLeft&&my<=y+barRight:mx>=x+barLeft&&mx<=x+barRight&&my>=Math.min(b,y)&&my<=Math.max(b,y))item=[i,j/ps]}}}if(item){i=item[0];j=item[1];ps=series[i].datapoints.pointsize;return{datapoint:series[i].datapoints.points.slice(j*ps,(j+1)*ps),dataIndex:j,series:series[i],seriesIndex:i}}return null}function onMouseMove(e){if(options.grid.hoverable)triggerClickHoverEvent("plothover",e,function(s){return s["hoverable"]!=false})}function onMouseLeave(e){if(options.grid.hoverable)triggerClickHoverEvent("plothover",e,function(s){return false})}function onClick(e){triggerClickHoverEvent("plotclick",e,function(s){return s["clickable"]!=false})}function triggerClickHoverEvent(eventname,event,seriesFilter){var offset=eventHolder.offset(),canvasX=event.pageX-offset.left-plotOffset.left,canvasY=event.pageY-offset.top-plotOffset.top,pos=canvasToAxisCoords({left:canvasX,top:canvasY});pos.pageX=event.pageX;pos.pageY=event.pageY;var item=findNearbyItem(canvasX,canvasY,seriesFilter);if(item){item.pageX=parseInt(item.series.xaxis.p2c(item.datapoint[0])+offset.left+plotOffset.left,10);item.pageY=parseInt(item.series.yaxis.p2c(item.datapoint[1])+offset.top+plotOffset.top,10)}if(options.grid.autoHighlight){for(var i=0;iaxisx.max||yaxisy.max)return;var pointRadius=series.points.radius+series.points.lineWidth/2;octx.lineWidth=pointRadius;octx.strokeStyle=highlightColor;var radius=1.5*pointRadius;x=axisx.p2c(x);y=axisy.p2c(y);octx.beginPath();if(series.points.symbol=="circle")octx.arc(x,y,radius,0,2*Math.PI,false);else series.points.symbol(octx,x,y,radius,false);octx.closePath();octx.stroke()}function drawBarHighlight(series,point){var highlightColor=typeof series.highlightColor==="string"?series.highlightColor:$.color.parse(series.color).scale("a",.5).toString(),fillStyle=highlightColor,barLeft;switch(series.bars.align){case"left":barLeft=0;break;case"right":barLeft=-series.bars.barWidth;break;default:barLeft=-series.bars.barWidth/2}octx.lineWidth=series.bars.lineWidth;octx.strokeStyle=highlightColor;drawBar(point[0],point[1],point[2]||0,barLeft,barLeft+series.bars.barWidth,function(){return fillStyle},series.xaxis,series.yaxis,octx,series.bars.horizontal,series.bars.lineWidth)}function getColorOrGradient(spec,bottom,top,defaultColor){if(typeof spec=="string")return spec;else{var gradient=ctx.createLinearGradient(0,top,0,bottom);for(var i=0,l=spec.colors.length;i12){hours12=hours-12}else if(hours==0){hours12=12}else{hours12=hours}for(var i=0;i=minSize){break}}var size=spec[i][0];var unit=spec[i][1];if(unit=="year"){if(opts.minTickSize!=null&&opts.minTickSize[1]=="year"){size=Math.floor(opts.minTickSize[0])}else{var magn=Math.pow(10,Math.floor(Math.log(axis.delta/timeUnitSize.year)/Math.LN10));var norm=axis.delta/timeUnitSize.year/magn;if(norm<1.5){size=1}else if(norm<3){size=2}else if(norm<7.5){size=5}else{size=10}size*=magn}if(size<1){size=1}}axis.tickSize=opts.tickSize||[size,unit];var tickSize=axis.tickSize[0];unit=axis.tickSize[1];var step=tickSize*timeUnitSize[unit];if(unit=="second"){d.setSeconds(floorInBase(d.getSeconds(),tickSize))}else if(unit=="minute"){d.setMinutes(floorInBase(d.getMinutes(),tickSize))}else if(unit=="hour"){d.setHours(floorInBase(d.getHours(),tickSize))}else if(unit=="month"){d.setMonth(floorInBase(d.getMonth(),tickSize))}else if(unit=="quarter"){d.setMonth(3*floorInBase(d.getMonth()/3,tickSize))}else if(unit=="year"){d.setFullYear(floorInBase(d.getFullYear(),tickSize))}d.setMilliseconds(0);if(step>=timeUnitSize.minute){d.setSeconds(0)}if(step>=timeUnitSize.hour){d.setMinutes(0)}if(step>=timeUnitSize.day){d.setHours(0)}if(step>=timeUnitSize.day*4){d.setDate(1)}if(step>=timeUnitSize.month*2){d.setMonth(floorInBase(d.getMonth(),3))}if(step>=timeUnitSize.quarter*2){d.setMonth(floorInBase(d.getMonth(),6))}if(step>=timeUnitSize.year){d.setMonth(0)}var carry=0;var v=Number.NaN;var prev;do{prev=v;v=d.getTime();ticks.push(v);if(unit=="month"||unit=="quarter"){if(tickSize<1){d.setDate(1);var start=d.getTime();d.setMonth(d.getMonth()+(unit=="quarter"?3:1));var end=d.getTime();d.setTime(v+carry*timeUnitSize.hour+(end-start)*tickSize);carry=d.getHours();d.setHours(0)}else{d.setMonth(d.getMonth()+tickSize*(unit=="quarter"?3:1))}}else if(unit=="year"){d.setFullYear(d.getFullYear()+tickSize)}else{d.setTime(v+step)}}while(v 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 @@ -215,6 +271,7 @@ def make_new_review_request_from_existing(review_req): 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.""" diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 2697bcae0..f5b65f7f5 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -535,6 +535,43 @@ table.simple-table td:last-child { opacity: 0.6; } +/* === Statistics =========================================================== */ + +.stats-options > * { + margin-bottom: 1em; +} + +.stats-options > *:last-child { + margin-bottom: 0; +} + +.stats-options .date-range input.form-control { + display: inline-block; + width: 7em; +} + +.stats-time-graph { + height: 15em; +} + +.review-stats th:first-child, .review-stats td:first-child { + text-align: left; +} + +.review-stats th, .review-stats td { + text-align: center; +} + +.review-stats-teams { + -moz-column-width: 18em; + -webkit-column-width: 18em; + column-width: 18em; +} + +.review-stats-teams a { + display: block; +} + /* === Photo pages ========================================================== */ .photo-name { diff --git a/ietf/static/ietf/js/review-stats.js b/ietf/static/ietf/js/review-stats.js new file mode 100644 index 000000000..385eab982 --- /dev/null +++ b/ietf/static/ietf/js/review-stats.js @@ -0,0 +1,8 @@ +$(document).ready(function () { + if (window.timeSeriesData && window.timeSeriesOptions) { + var placeholder = $(".stats-time-graph"); + placeholder.height(Math.round(placeholder.width() * 1 / 3)); + + $.plot(placeholder, window.timeSeriesData, window.timeSeriesOptions); + } +}); diff --git a/ietf/templates/base/menu.html b/ietf/templates/base/menu.html index d31ddf3fb..03ebc9946 100644 --- a/ietf/templates/base/menu.html +++ b/ietf/templates/base/menu.html @@ -102,6 +102,7 @@
  • IPR disclosures
  • Liaison statements
  • IESG agenda
  • +
  • Statistics
  • Tutorials
  • {% if flavor == "top" %}{% endif %}
  • Report a bug
  • diff --git a/ietf/templates/stats/index.html b/ietf/templates/stats/index.html new file mode 100644 index 000000000..11b9bb8e5 --- /dev/null +++ b/ietf/templates/stats/index.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block content %} + {% origin %} + +

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

    + +

    Currently, there are statistics for:

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

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

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

    « Back to teams

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

    Completion status and completion time

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

    Results of completed reviews

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

    Specific request states

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

    Counts per month

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

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

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

    Statistics for individual reviewers:

    + +
    + {% for t in teams %} + {{ t.name }} + {% endfor %} +
    + {% endif %} + +{% endblock %} + +{% block js %} + + {% if stats_type == "time" %} + + + + {% endif %} +{% endblock %} diff --git a/ietf/urls.py b/ietf/urls.py index 213dd5203..092f6ba63 100644 --- a/ietf/urls.py +++ b/ietf/urls.py @@ -54,9 +54,10 @@ urlpatterns = patterns('', (r'^secr/', include('ietf.secr.urls')), (r'^sitemap-(?P
    .+).xml$', 'django.contrib.sitemaps.views.sitemap', {'sitemaps': sitemaps}), (r'^sitemap.xml$', 'django.contrib.sitemaps.views.index', { 'sitemaps': sitemaps}), + (r'^stats/', include('ietf.stats.urls')), + (r'^stream/', include('ietf.group.urls_stream')), (r'^submit/', include('ietf.submit.urls')), (r'^sync/', include('ietf.sync.urls')), - (r'^stream/', include('ietf.group.urls_stream')), (r'^templates/', include('ietf.dbtemplate.urls')), (r'^(?P(wg|rg|ag|team|dir|area))/', include('ietf.group.urls_info')), From 6d7bfd7b3701bfa995418f7f78b2dd81329f90bd Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 13 Oct 2016 15:36:14 +0000 Subject: [PATCH 87/90] Actually commit the statistics section. - Legacy-Id: 12125 --- ietf/stats/__init__.py | 1 + ietf/stats/tests.py | 50 +++++++ ietf/stats/urls.py | 9 ++ ietf/stats/views.py | 320 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 ietf/stats/__init__.py create mode 100644 ietf/stats/tests.py create mode 100644 ietf/stats/urls.py create mode 100644 ietf/stats/views.py diff --git a/ietf/stats/__init__.py b/ietf/stats/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/ietf/stats/__init__.py @@ -0,0 +1 @@ + diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py new file mode 100644 index 000000000..bd66946dd --- /dev/null +++ b/ietf/stats/tests.py @@ -0,0 +1,50 @@ +from pyquery import PyQuery + +from django.core.urlresolvers import reverse as urlreverse + +from ietf.utils.test_data import make_test_data, make_review_data +from ietf.utils.test_utils import login_testing_unauthorized, TestCase +import ietf.stats.views + +class StatisticsTests(TestCase): + def test_stats_index(self): + url = urlreverse(ietf.stats.views.stats_index) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + def test_review_stats(self): + doc = make_test_data() + review_req = make_review_data(doc) + + # check redirect + url = urlreverse(ietf.stats.views.review_stats) + + login_testing_unauthorized(self, "secretary", url) + + r = self.client.get(url) + self.assertEqual(r.status_code, 302) + self.assertTrue(urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion" }) in r["Location"]) + + # check tabular + for stats_type in ["completion", "results", "states"]: + url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": stats_type }) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + if stats_type != "results": + self.assertTrue(q('.review-stats td:contains("1")')) + + # check chart + url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "time" }) + url += "?team={}".format(review_req.team.acronym) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertTrue(q('.stats-time-graph')) + + # check reviewer level + url = urlreverse(ietf.stats.views.review_stats, kwargs={ "stats_type": "completion", "acronym": review_req.team.acronym }) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertTrue(q('.review-stats td:contains("1")')) diff --git a/ietf/stats/urls.py b/ietf/stats/urls.py new file mode 100644 index 000000000..8a05f9659 --- /dev/null +++ b/ietf/stats/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import patterns, url +from django.conf import settings + +import ietf.stats.views + +urlpatterns = patterns('', + url("^$", ietf.stats.views.stats_index), + url("^review/(?:(?Pcompletion|results|states|time)/)?(?:%(acronym)s/)?$" % settings.URL_REGEXPS, ietf.stats.views.review_stats), +) diff --git a/ietf/stats/views.py b/ietf/stats/views.py new file mode 100644 index 000000000..348ffc496 --- /dev/null +++ b/ietf/stats/views.py @@ -0,0 +1,320 @@ +import datetime, itertools, json, calendar + +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.core.urlresolvers import reverse as urlreverse +from django.http import HttpResponseRedirect + +import dateutil.relativedelta + +from ietf.review.utils import extract_review_request_data, aggregate_review_request_stats, ReviewRequestData +from ietf.group.models import Role, Group +from ietf.person.models import Person +from ietf.name.models import ReviewRequestStateName, ReviewResultName +from ietf.ietfauth.utils import has_role + +def stats_index(request): + return render(request, "stats/index.html") + +@login_required +def review_stats(request, stats_type=None, acronym=None): + # This view is a bit complex because we want to show a bunch of + # tables with various filtering options, and both a team overview + # and a reviewers-within-team overview - and a time series chart. + # And in order to make the UI quick to navigate, we're not using + # one big form but instead presenting a bunch of immediate + # actions, with a URL scheme where the most common options (level + # and statistics type) are incorporated directly into the URL to + # be a bit nicer. + + def build_review_stats_url(stats_type_override=Ellipsis, acronym_override=Ellipsis, get_overrides={}): + kwargs = { + "stats_type": stats_type if stats_type_override is Ellipsis else stats_type_override, + } + acr = acronym if acronym_override is Ellipsis else acronym_override + if acr: + kwargs["acronym"] = acr + + base_url = urlreverse(review_stats, kwargs=kwargs) + query_part = u"" + + if request.GET or get_overrides: + d = request.GET.copy() + for k, v in get_overrides.iteritems(): + if type(v) in (list, tuple): + if not v: + if k in d: + del d[k] + else: + d.setlist(k, v) + else: + if v is None or v == u"": + if k in d: + del d[k] + else: + d[k] = v + + if d: + query_part = u"?" + d.urlencode() + + return base_url + query_part + + def get_from_selection(get_parameter, possible_choices): + val = request.GET.get(get_parameter) + for slug, label, url in possible_choices: + if slug == val: + return slug + return None + + # which overview - team or reviewer + if acronym: + level = "reviewer" + else: + level = "team" + + # statistics type - one of the tables or the chart + possible_stats_types = [ + ("completion", "Completion status"), + ("results", "Review results"), + ("states", "Request states"), + ] + + if level == "team": + possible_stats_types.append(("time", "Changes over time")) + + possible_stats_types = [ (slug, label, build_review_stats_url(stats_type_override=slug)) + for slug, label in possible_stats_types ] + + if not stats_type: + return HttpResponseRedirect(build_review_stats_url(stats_type_override=possible_stats_types[0][0])) + + # what to count + possible_count_choices = [ + ("", "Review requests"), + ("pages", "Reviewed pages"), + ] + + possible_count_choices = [ (slug, label, build_review_stats_url(get_overrides={ "count": slug })) for slug, label in possible_count_choices ] + + count = get_from_selection("count", possible_count_choices) or "" + + # time range + def parse_date(s): + if not s: + return None + try: + return datetime.datetime.strptime(s.strip(), "%Y-%m-%d").date() + except ValueError: + return None + + today = datetime.date.today() + from_date = parse_date(request.GET.get("from")) or today - dateutil.relativedelta.relativedelta(years=1) + to_date = parse_date(request.GET.get("to")) or today + + from_time = datetime.datetime.combine(from_date, datetime.time.min) + to_time = datetime.datetime.combine(to_date, datetime.time.max) + + # teams/reviewers + teams = list(Group.objects.exclude(reviewrequest=None).distinct().order_by("name")) + + reviewer_filter_args = {} + + # - interlude: access control + if has_role(request.user, ["Secretariat", "Area Director"]): + pass + else: + secr_access = set() + reviewer_only_access = set() + + for r in Role.objects.filter(person__user=request.user, name__in=["secr", "reviewer"], group__in=teams).distinct(): + if r.name_id == "secr": + secr_access.add(r.group_id) + reviewer_only_access.discard(r.group_id) + elif r.name_id == "reviewer": + if not r.group_id in secr_access: + reviewer_only_access.add(r.group_id) + + teams = [t for t in teams if t.pk in secr_access or t.pk in reviewer_only_access] + + for t in reviewer_only_access: + reviewer_filter_args[t] = { "user": request.user } + + reviewers_for_team = None + + if level == "team": + for t in teams: + t.reviewer_stats_url = build_review_stats_url(acronym_override=t.acronym) + + query_teams = teams + query_reviewers = None + + group_by_objs = { t.pk: t for t in query_teams } + group_by_index = ReviewRequestData._fields.index("team") + + elif level == "reviewer": + for t in teams: + if t.acronym == acronym: + reviewers_for_team = t + break + else: + return HttpResponseRedirect(urlreverse(review_stats)) + + query_reviewers = list(Person.objects.filter( + email__reviewrequest__time__gte=from_time, + email__reviewrequest__time__lte=to_time, + email__reviewrequest__team=reviewers_for_team, + **reviewer_filter_args.get(t.pk, {}) + ).distinct()) + query_reviewers.sort(key=lambda p: p.last_name()) + + query_teams = [t] + + group_by_objs = { r.pk: r for r in query_reviewers } + group_by_index = ReviewRequestData._fields.index("reviewer") + + # now aggregate the data + possible_teams = possible_completion_types = possible_results = possible_states = None + selected_team = selected_completion_type = selected_result = selected_state = None + + if stats_type == "time": + possible_teams = [(t.acronym, t.acronym, build_review_stats_url(get_overrides={ "team": t.acronym })) for t in teams] + selected_team = get_from_selection("team", possible_teams) + query_teams = [t for t in query_teams if t.acronym == selected_team] + + extracted_data = extract_review_request_data(query_teams, query_reviewers, from_time, to_time, ordering=[level]) + + if stats_type == "time": + req_time_index = ReviewRequestData._fields.index("req_time") + + def time_key_fn(t): + d = t[req_time_index].date() + #d -= datetime.timedelta(days=d.weekday()) + d -= datetime.timedelta(days=d.day) + return (t[group_by_index], d) + + found_results = set() + found_states = set() + aggrs = [] + for (group_pk, d), request_data_items in itertools.groupby(extracted_data, key=time_key_fn): + aggr = aggregate_review_request_stats(request_data_items, count=count) + + aggrs.append((d, aggr)) + + for slug in aggr["result"]: + found_results.add(slug) + for slug in aggr["state"]: + found_states.add(slug) + + results = ReviewResultName.objects.filter(slug__in=found_results) + states = ReviewRequestStateName.objects.filter(slug__in=found_states) + + # choice + + possible_completion_types = [ + ("completed_in_time", "Completed in time"), + ("completed_late", "Completed late"), + ("not_completed", "Not completed"), + ("average_assignment_to_closure_days", "Avg. compl. days"), + ] + + possible_completion_types = [ + (slug, label, build_review_stats_url(get_overrides={ "completion": slug, "result": None, "state": None })) + for slug, label in possible_completion_types + ] + + selected_completion_type = get_from_selection("completion", possible_completion_types) + + possible_results = [ + (r.slug, r.name, build_review_stats_url(get_overrides={ "completion": None, "result": r.slug, "state": None })) + for r in results + ] + + selected_result = get_from_selection("result", possible_results) + + possible_states = [ + (s.slug, s.name, build_review_stats_url(get_overrides={ "completion": None, "result": None, "state": s.slug })) + for s in states + ] + + selected_state = get_from_selection("state", possible_states) + + if not selected_completion_type and not selected_result and not selected_state: + selected_completion_type = "completed_in_time" + + series_data = [] + for d, aggr in aggrs: + v = 0 + if selected_completion_type is not None: + v = aggr[selected_completion_type] + elif selected_result is not None: + v = aggr["result"][selected_result] + elif selected_state is not None: + v = aggr["state"][selected_state] + + series_data.append((calendar.timegm(d.timetuple()) * 1000, v)) + + data = json.dumps([{ + "data": series_data + }]) + + else: # tabular data + + data = [] + + found_results = set() + found_states = set() + for group_pk, request_data_items in itertools.groupby(extracted_data, key=lambda t: t[group_by_index]): + aggr = aggregate_review_request_stats(request_data_items, count=count) + + # skip zero-valued rows + if aggr["open"] == 0 and aggr["completed"] == 0 and aggr["not_completed"] == 0: + continue + + aggr["obj"] = group_by_objs.get(group_pk) + + for slug in aggr["result"]: + found_results.add(slug) + for slug in aggr["state"]: + found_states.add(slug) + + data.append(aggr) + + results = ReviewResultName.objects.filter(slug__in=found_results) + states = ReviewRequestStateName.objects.filter(slug__in=found_states) + + # massage states/results breakdowns for template rendering + for aggr in data: + aggr["state_list"] = [aggr["state"].get(x.slug, 0) for x in states] + aggr["result_list"] = [aggr["result"].get(x.slug, 0) for x in results] + + + return render(request, 'stats/review_stats.html', { + "team_level_url": build_review_stats_url(acronym_override=None), + "level": level, + "reviewers_for_team": reviewers_for_team, + "teams": teams, + "data": data, + "states": states, + "results": results, + + # options + "possible_stats_types": possible_stats_types, + "stats_type": stats_type, + + "possible_count_choices": possible_count_choices, + "count": count, + + "from_date": from_date, + "to_date": to_date, + "today": today, + + # time options + "possible_teams": possible_teams, + "selected_team": selected_team, + "possible_completion_types": possible_completion_types, + "selected_completion_type": selected_completion_type, + "possible_results": possible_results, + "selected_result": selected_result, + "possible_states": possible_states, + "selected_state": selected_state, + }) From 336e2bbc5e84ab59739537722d0e8682e6e8e222 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 13 Oct 2016 15:53:14 +0000 Subject: [PATCH 88/90] In case the review data contains a proper name for a person we already know, use that - this prevents problems with email addresses being embedded in review document names. - Legacy-Id: 12126 --- ietf/review/import_from_review_tool.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 220d18512..c1ee35629 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -93,6 +93,13 @@ with db_con.cursor() as c: 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: From 5a878981fe47d3c0d09aa83defdf40a55c90fb9a Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 13 Oct 2016 15:54:11 +0000 Subject: [PATCH 89/90] Guard against reviewer name not being filled in properly (still containing a @) on the complete review page - Legacy-Id: 12127 --- ietf/doc/views_review.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index d1ea1a064..6ba6a81ff 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -367,6 +367,9 @@ class CompleteReviewForm(forms.Form): 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.")) From 8f497af6e67af6c40145526e70cf6ed7d175c9f4 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 13 Oct 2016 16:16:34 +0000 Subject: [PATCH 90/90] Add admin.py for review models - Legacy-Id: 12128 --- ietf/review/admin.py | 71 +++++++++++++++++++++++++++++++++++++++++++ ietf/review/models.py | 30 ++++++++++++++---- 2 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 ietf/review/admin.py diff --git a/ietf/review/admin.py b/ietf/review/admin.py new file mode 100644 index 000000000..8ad3733a8 --- /dev/null +++ b/ietf/review/admin.py @@ -0,0 +1,71 @@ +from django.contrib import admin + +from ietf.review.models import (ReviewerSettings, UnavailablePeriod, ReviewWish, + ResultUsedInReviewTeam, TypeUsedInReviewTeam, NextReviewerInTeam, + ReviewRequest) + +class ReviewerSettingsAdmin(admin.ModelAdmin): + list_filter = ["team"] + search_fields = ["person__name"] + ordering = ["-id"] + raw_id_fields = ["team", "person"] + +admin.site.register(ReviewerSettings, ReviewerSettingsAdmin) + +class UnavailablePeriodAdmin(admin.ModelAdmin): + list_display = ["person", "team", "start_date", "end_date", "availability"] + list_display_links = ["person"] + list_filter = ["team"] + date_hierarchy = "start_date" + search_fields = ["person__name"] + ordering = ["-id"] + raw_id_fields = ["team", "person"] + +admin.site.register(UnavailablePeriod, UnavailablePeriodAdmin) + +class ReviewWishAdmin(admin.ModelAdmin): + list_display = ["person", "team", "doc"] + list_display_links = ["person"] + list_filter = ["team"] + search_fields = ["person__name"] + ordering = ["-id"] + raw_id_fields = ["team", "person", "doc"] + +admin.site.register(ReviewWish, ReviewWishAdmin) + +class ResultUsedInReviewTeamAdmin(admin.ModelAdmin): + list_display = ["team", "result"] + list_display_links = ["team"] + list_filter = ["team"] + ordering = ["team", "result__order"] + raw_id_fields = ["team"] + +admin.site.register(ResultUsedInReviewTeam, ResultUsedInReviewTeamAdmin) + +class TypeUsedInReviewTeamAdmin(admin.ModelAdmin): + list_display = ["team", "type"] + list_display_links = ["team"] + list_filter = ["team"] + ordering = ["team", "type__order"] + raw_id_fields = ["team"] + +admin.site.register(TypeUsedInReviewTeam, TypeUsedInReviewTeamAdmin) + +class NextReviewerInTeamAdmin(admin.ModelAdmin): + list_display = ["team", "next_reviewer"] + list_display_links = ["team"] + ordering = ["team"] + raw_id_fields = ["team", "next_reviewer"] + +admin.site.register(NextReviewerInTeam, NextReviewerInTeamAdmin) + +class ReviewRequestAdmin(admin.ModelAdmin): + list_display = ["doc", "time", "type", "team", "deadline"] + list_display_links = ["doc"] + list_filter = ["team", "type", "state", "result"] + ordering = ["-id"] + raw_id_fields = ["doc", "team", "requested_by", "reviewer", "review"] + date_hierarchy = "time" + search_fields = ["doc__name", "reviewer__person__name"] + +admin.site.register(ReviewRequest, ReviewRequestAdmin) diff --git a/ietf/review/models.py b/ietf/review/models.py index 8233a681c..808bee3c7 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -11,7 +11,7 @@ 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) + team = models.ForeignKey(Group, limit_choices_to=~models.Q(resultusedinreviewteam=None)) person = models.ForeignKey(Person) INTERVALS = [ (7, "Once per week"), @@ -28,8 +28,11 @@ class ReviewerSettings(models.Model): 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) + 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.") @@ -60,40 +63,55 @@ class UnavailablePeriod(models.Model): 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) + 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) + 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) + 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) + 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