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
This commit is contained in:
Ole Laursen 2016-09-19 16:05:32 +00:00
parent 0a8f3dbe02
commit 6da25e6bd9
26 changed files with 1028 additions and 98 deletions

View file

@ -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()

View file

@ -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())

View file

@ -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')),
)

View file

@ -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<reviewer_email>[\w%+-.@]+)/settings/$', views_review.change_reviewer_settings),
url(r'^email-aliases/$', RedirectView.as_view(pattern_name='ietf.group.views.email',permanent=False),name='old_group_email_aliases'),
)

View file

@ -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,

View file

@ -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,
})

View file

@ -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

View file

@ -21,4 +21,5 @@ urlpatterns = patterns('ietf.ietfauth.views',
url(r'^reset/confirm/(?P<auth>[^/]+)/$', 'confirm_password_reset'),
url(r'^confirmnewemail/(?P<auth>[^/]+)/$', 'confirm_new_email'),
(r'whitelist/add/?$', add_account_whitelist),
url(r'^review/$', 'review_overview'),
)

View file

@ -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,
})

View file

@ -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 = [

View file

@ -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'),
]

View file

@ -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 ]

View file

@ -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

View file

@ -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,),
),
]

View file

@ -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.

View file

@ -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())

View file

@ -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)

View file

@ -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 {

View file

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

View file

@ -0,0 +1,87 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}{% origin %}
{% load ietf_filters staticfiles bootstrap3 %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'bootstrap-datepicker/css/bootstrap-datepicker3.min.css' %}">
{% endblock %}
{% block title %}Change reviewer settings for {{ group.acronym }} for {{ reviewer_email }}{% endblock %}
{% block content %}
{% origin %}
<h1>Change reviewer settings for {{ group.acronym }} for {{ reviewer_email }}</h1>
<h3>Settings</h3>
<form class="change-reviewer-settings" method="post">{% csrf_token %}
{% bootstrap_form settings_form %}
{% buttons %}
<a href="{{ back_url }}" class="btn btn-default pull-right">Cancel</a>
<button class="btn btn-primary" type="submit" name="action" value="change_settings">Save</button>
{% endbuttons %}
</form>
<h3>Unavailable periods</h3>
<p>You can register periods where reviews should not be assigned.</p>
{% if unavailable_periods %}
<table class="table">
{% for o in unavailable_periods %}
<tr class="unavailable-period-{{ o.state }}">
<td>
{{ o.start_date }} - {{ o.end_date|default:"indefinite" }}
</td>
<td>{{ o.get_availability_display }}</td>
<td>
{% if not o.end_date %}
<form method="post" class="form-inline" style="display:inline-block">
{% csrf_token %}
<input type="hidden" name="period_id" value="{{ o.pk }}">
{% bootstrap_form o.end_form layout="inline" %}
<button type="submit" class="btn btn-default btn-sm" name="action" value="end_period">End period</button>
</form>
{% endif %}
</td>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="period_id" value="{{ o.pk }}">
<button type="submit" class="btn btn-danger btn-sm" name="action" value="delete_period">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>No periods found.</p>
{% endif %}
<div><a class="btn btn-default" data-toggle="collapse" data-target="#add-new-period">Add a new period</a></div>
<div id="add-new-period" {% if not period_form.errors %}class="collapse"{% endif %}>
<h4>Add a new period</h4>
<form method="post">
{% csrf_token %}
{% bootstrap_form period_form %}
{% buttons %}
<button type="submit" class="btn btn-primary" name="action" value="add_period">Add period</button>
{% endbuttons %}
</form>
</div>
<p style="padding-top: 2em;">
<a href="{{ back_url }}" class="btn btn-default">Back</a>
</p>
{% endblock %}
{% block js %}
<script src="{% static 'bootstrap-datepicker/js/bootstrap-datepicker.min.js' %}"></script>
{% endblock %}

View file

@ -51,7 +51,7 @@
<div>
<small>
<a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{% if r.time %}Req: {{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %} - {{ r.type.name }}</a>
<a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{% if r.pk != None %}Req: {{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %} - {{ r.type.name }}</a>
</small>
</div>
@ -84,7 +84,11 @@
<span class="assign-action">
{% if r.reviewer %}
<button type="button" class="btn btn-default btn-sm" title="Click to reassign reviewer">{{ r.reviewer.person }}{% if r.state_id == "accepted" %} <span class="label label-default">accp</span>{% endif %}</button>
<button type="button" class="btn btn-default btn-sm" title="Click to reassign reviewer">
{{ r.reviewer.person }}
{% if r.state_id == "accepted" %} <span class="label label-default">accp</span>{% endif %}
{% if r.reviewer_unavailable %}<span class="label label-danger">unavail</span>{% endif %}
</button>
{% else %}
<button type="button" class="btn btn-default btn-sm" title="Click to assign reviewer"><em>not yet assigned</em></button>
{% endif %}

View file

@ -16,7 +16,7 @@
<h2>Open review requests</h2>
{% if open_review_requests %}
<table class="table table-condensed table-striped materials tablesorter">
<table class="table table-condensed table-striped tablesorter">
<thead>
<tr>
<th>Request</th>
@ -31,14 +31,16 @@
<tr>
<td><a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a></td>
<td>{{ r.type.name }}</td>
<td>{% if r.time %}{{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %}</td>
<td>{% if r.pk %}{{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %}</td>
<td>
{{ r.deadline|date:"Y-m-d" }}
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
</td>
<td>
{% if r.reviewer %}
{{ r.reviewer.person }} {% if r.state_id == "accepted" %}<span class="label label-default">Accepted</span>{% endif %}
{{ r.reviewer.person }}
{% if r.state_id == "accepted" %}<span class="label label-default">Accepted</span>{% endif %}
{% if r.reviewer_unavailable %}<span class="label label-danger">Unavailable</span>{% endif %}
{% elif r.pk != None %}
<em>not yet assigned</em>
{% endif %}

View file

@ -0,0 +1,173 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load bootstrap3 static %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{% endblock %}
{% block title %}Review overview for {{ request.user }}{% endblock %}
{% block content %}
{% origin %}
<h1>Review overview for {{ request.user }}</h1>
<h2>Assigned reviews</h2>
{% if open_review_requests %}
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Request</th>
<th>Team</th>
<th>Type</th>
<th>Deadline</th>
</tr>
</thead>
<tbody>
{% for r in open_review_requests %}
<tr>
<td><a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a></td>
<td>{{ r.team.acronym }}</td>
<td>{{ r.type.name }}</td>
<td>
{{ r.deadline|date:"Y-m-d" }}
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>You do not have any open review requests assigned.</p>
{% endif %}
<h2>Latest closed requests</h2>
{% if closed_review_requests %}
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Request</th>
<th>Team</th>
<th>Type</th>
<th>Deadline</th>
<th>State</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{% for r in closed_review_requests %}
<tr>
<td><a {% if r.pk != None %}href="{% url "ietf.doc.views_review.review_request" name=r.doc.name request_id=r.pk %}"{% endif %}>{{ r.doc.name }}-{% if r.requested_rev %}{{ r.requested_rev }}{% else %}{{ r.doc.rev }}{% endif %}</a></td>
<td>{{ r.team.acronym }}</td>
<td>{{ r.type.name }}</td>
<td>
{{ r.deadline|date:"Y-m-d" }}
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
</td>
<td><span class="{% if r.state_id == "completed" or r.state_id == "part-completed" %}bg-success{% endif %}">{{ r.state.name }}</span></td>
<td>{% if r.result %}{{ r.result.name }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Did not find any closed requests assigned to you.</p>
{% endif %}
<h2>Review wishes</h2>
{% if review_wishes %}
<p>You have indicated that you would like to review:</p>
<table class="table">
{% for w in review_wishes %}
<tr>
<td><a href="{% url "doc_view" w.doc_id %}">{{ w.doc_id }}</a></td>
<td>{{ w.team.acronym }}</td>
<td>
<form method="post">
{% csrf_token %}
<input name="wish_id" value="{{ w.pk }}" type="hidden">
<button class="btn btn-sm btn-danger" type="submit" name="action" value="delete_wish">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>You do not have any review wishes.</p>
{% endif %}
{% if teams %}
<p>Add a draft that you would like to review when it becomes available for review:</p>
<form role="form" method="post" class="form-inline">
{% csrf_token %}
{% bootstrap_form review_wish_form layout="inline" %}
{% buttons %}
<button class="btn btn-default" type="submit" name="action" value="add_wish">Add draft</button>
{% endbuttons %}
</form>
{% endif %}
{% for t in teams %}
<h2>Settings for {{ t }}</h2>
<table class="table">
<tr>
<th>Can review</th>
<td>{{ t.reviewer_settings.get_min_interval_display }}</td>
</tr>
<tr>
<th>Skip next assignments</th>
<td>{{ t.reviewer_settings.skip_next }}</td>
</tr>
<tr>
<th>Filter regexp</th>
<td>{{ t.reviewer_settings.filter_re|default:"(None)" }}</td>
</tr>
<tr>
<th>Unavailable periods</th>
<td>
{% if t.unavailable_periods %}
<table class="unavailable-periods">
{% for o in t.unavailable_periods %}
<tr class="unavailable-period-{{ o.state }}">
<td>{{ o.start_date }} - {{ o.end_date|default:"" }}</td>
<td>{{ o.get_availability_display }}</td>
</tr>
{% endfor %}
</table>
{% else %}
(No periods)
{% endif %}
</td>
</table>
<div>
<a class="btn btn-default" href="{% url "ietf.group.views_review.change_reviewer_settings" group_type=t.type_id acronym=t.acronym reviewer_email=t.role.email.address %}?next={{ request.get_full_path|urlencode }}">Change settings</a>
</div>
{% empty %}
<h2>Settings</h2>
<p>It looks like you are not a reviewer in any active review team.</p>
{% endfor %}
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
{% endblock %}

View file

@ -0,0 +1,9 @@
{% autoescape off %}
{{ review_req.type.name }} review of: {{ review_req.doc.name }} ({% if review_req.requested_rev %}rev. {{ review_req.requested_rev }}{% else %}no specific version{% endif %})
Deadline: {{ review_req.deadline|date:"Y-m-d" }}
{{ review_req_url }}
{{ msg|wordwrap:72 }}
{% endautoescape %}

View file

@ -0,0 +1,8 @@
{% autoescape off %}{% filter wordwrap:72 %}
Reviewer availability of {{ reviewer }} in {{ team.acronym }} changed by {{ by }}.
{{ msg }}
{{ reviewer_overview_url }}
{% endfilter %}{% endautoescape %}

View file

@ -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,