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

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 @@
- {% if r.time %}Req: {{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} - {{ r.type.name }} + {% if r.pk != None %}Req: {{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} - {{ r.type.name }}
@@ -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 @@ - +
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,