From 64a65340a21123b2896b9ddf502d6ab489f828b6 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 19 May 2016 15:35:30 +0000 Subject: [PATCH] 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 %}