diff --git a/ietf/community/models.py b/ietf/community/models.py index ed7238a6c..6839801ce 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -98,6 +98,9 @@ def notify_events(sender, instance, **kwargs): if instance.doc.type_id != 'draft': return + if getattr(instance, "skip_community_list_notification", False): + return + from ietf.community.utils import notify_event_to_subscribers notify_event_to_subscribers(instance) diff --git a/ietf/doc/migrations/0015_auto_20160927_0713.py b/ietf/doc/migrations/0015_auto_20160927_0713.py new file mode 100644 index 000000000..fa127a6d2 --- /dev/null +++ b/ietf/doc/migrations/0015_auto_20160927_0713.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0015_insert_review_name_data'), + ('review', '0001_initial'), + ('doc', '0014_auto_20160824_2218'), + ] + + operations = [ + migrations.CreateModel( + name='ReviewRequestDocEvent', + fields=[ + ('docevent_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='doc.DocEvent')), + ('review_request', models.ForeignKey(to='review.ReviewRequest')), + ('state', models.ForeignKey(blank=True, to='name.ReviewRequestStateName', null=True)), + ], + options={ + }, + bases=('doc.docevent',), + ), + migrations.AlterField( + model_name='docevent', + name='type', + field=models.CharField(max_length=50, choices=[(b'new_revision', b'Added new revision'), (b'changed_document', b'Changed document metadata'), (b'added_comment', b'Added comment'), (b'deleted', b'Deleted document'), (b'changed_state', b'Changed state'), (b'changed_stream', b'Changed document stream'), (b'expired_document', b'Expired document'), (b'extended_expiry', b'Extended expiry of document'), (b'requested_resurrect', b'Requested resurrect'), (b'completed_resurrect', b'Completed resurrect'), (b'changed_consensus', b'Changed consensus'), (b'published_rfc', b'Published RFC'), (b'added_suggested_replaces', b'Added suggested replacement relationships'), (b'reviewed_suggested_replaces', b'Reviewed suggested replacement relationships'), (b'changed_group', b'Changed group'), (b'changed_protocol_writeup', b'Changed protocol writeup'), (b'changed_charter_milestone', b'Changed charter milestone'), (b'initial_review', b'Set initial review time'), (b'changed_review_announcement', b'Changed WG Review text'), (b'changed_action_announcement', b'Changed WG Action text'), (b'started_iesg_process', b'Started IESG process on document'), (b'created_ballot', b'Created ballot'), (b'closed_ballot', b'Closed ballot'), (b'sent_ballot_announcement', b'Sent ballot announcement'), (b'changed_ballot_position', b'Changed ballot position'), (b'changed_ballot_approval_text', b'Changed ballot approval text'), (b'changed_ballot_writeup_text', b'Changed ballot writeup text'), (b'changed_rfc_editor_note_text', b'Changed RFC Editor Note text'), (b'changed_last_call_text', b'Changed last call text'), (b'requested_last_call', b'Requested last call'), (b'sent_last_call', b'Sent last call'), (b'scheduled_for_telechat', b'Scheduled for telechat'), (b'iesg_approved', b'IESG approved document (no problem)'), (b'iesg_disapproved', b'IESG disapproved document (do not publish)'), (b'approved_in_minute', b'Approved in minute'), (b'iana_review', b'IANA review comment'), (b'rfc_in_iana_registry', b'RFC is in IANA registry'), (b'rfc_editor_received_announcement', b'Announcement was received by RFC Editor'), (b'requested_publication', b'Publication at RFC Editor requested'), (b'sync_from_rfc_editor', b'Received updated information from RFC Editor'), (b'requested_review', b'Requested review'), (b'assigned_review_request', b'Assigned review request'), (b'closed_review_request', b'Closed review request')]), + preserve_default=True, + ), + ] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index de7d45282..4722fd252 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -14,7 +14,7 @@ import debug # pyflakes:ignore from ietf.group.models import Group from ietf.name.models import ( DocTypeName, DocTagName, StreamName, IntendedStdLevelName, StdLevelName, - DocRelationshipName, DocReminderTypeName, BallotPositionName ) + DocRelationshipName, DocReminderTypeName, BallotPositionName, ReviewRequestStateName ) from ietf.person.models import Email, Person from ietf.utils.admin import admin_link @@ -685,7 +685,8 @@ EVENT_TYPES = [ # review ("requested_review", "Requested review"), - ("changed_review_request", "Changed review request"), + ("assigned_review_request", "Assigned review request"), + ("closed_review_request", "Closed review request"), ] class DocEvent(models.Model): @@ -816,11 +817,14 @@ class TelechatDocEvent(DocEvent): telechat_date = models.DateField(blank=True, null=True) returning_item = models.BooleanField(default=False) +class ReviewRequestDocEvent(DocEvent): + review_request = models.ForeignKey('review.ReviewRequest') + state = models.ForeignKey(ReviewRequestStateName, blank=True, null=True) + # charter events class InitialReviewDocEvent(DocEvent): expires = models.DateTimeField(blank=True, null=True) - # dumping store for removed events class DeletedEvent(models.Model): content_type = models.ForeignKey(ContentType) diff --git a/ietf/doc/tests_review.py b/ietf/doc/tests_review.py index 54eabbe6f..580cc0153 100644 --- a/ietf/doc/tests_review.py +++ b/ietf/doc/tests_review.py @@ -131,7 +131,7 @@ class ReviewTests(TestCase): review_req = reload_db_objects(review_req) self.assertEqual(review_req.state_id, "withdrawn") e = doc.latest_event() - self.assertEqual(e.type, "changed_review_request") + self.assertEqual(e.type, "closed_review_request") self.assertTrue("closed" in e.desc.lower()) self.assertEqual(len(outbox), 1) self.assertTrue("closed" in unicode(outbox[0]).lower()) @@ -255,7 +255,7 @@ class ReviewTests(TestCase): reviewer=plain_email, ) - reviewer_settings = ReviewerSettings.objects.get(person__email=plain_email) + reviewer_settings = ReviewerSettings.objects.get(person__email=plain_email, team=review_req.team) reviewer_settings.filter_re = doc.name reviewer_settings.skip_next = 1 reviewer_settings.save() @@ -383,7 +383,7 @@ class ReviewTests(TestCase): review_req = reload_db_objects(review_req) self.assertEqual(review_req.state_id, "rejected") e = doc.latest_event() - self.assertEqual(e.type, "changed_review_request") + self.assertEqual(e.type, "closed_review_request") self.assertTrue("rejected" in e.desc) self.assertEqual(ReviewRequest.objects.filter(doc=review_req.doc, team=review_req.team, state="requested").count(), 1) self.assertEqual(len(outbox), 1) diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 1555b9388..8576059ed 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -9,7 +9,8 @@ from django.core.exceptions import ValidationError from django.template.loader import render_to_string from django.core.urlresolvers import reverse as urlreverse -from ietf.doc.models import Document, NewRevisionDocEvent, DocEvent, State, DocAlias, LastCallDocEvent +from ietf.doc.models import (Document, NewRevisionDocEvent, State, DocAlias, + LastCallDocEvent, ReviewRequestDocEvent) from ietf.name.models import ReviewRequestStateName, ReviewResultName, DocTypeName from ietf.review.models import ReviewRequest from ietf.group.models import Group @@ -103,12 +104,14 @@ def request_review(request, name): review_req.team = team review_req.save() - DocEvent.objects.create( + ReviewRequestDocEvent.objects.create( type="requested_review", doc=doc, by=request.user.person, desc="Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()), time=review_req.time, + review_request=review_req, + state=None, ) return redirect('doc_view', name=doc.name) @@ -272,8 +275,8 @@ def reject_reviewer_assignment(request, name, request_id): review_req.state = ReviewRequestStateName.objects.get(slug="rejected") review_req.save() - DocEvent.objects.create( - type="changed_review_request", + ReviewRequestDocEvent.objects.create( + type="closed_review_request", doc=review_req.doc, by=request.user.person, desc="Assignment of request for {} review by {} to {} was rejected".format( @@ -281,13 +284,15 @@ def reject_reviewer_assignment(request, name, request_id): review_req.team.acronym.upper(), review_req.reviewer.person, ), + review_request=review_req, + state=review_req.state, ) # make a new unassigned review request new_review_req = make_new_review_request_from_existing(review_req) new_review_req.save() - msg = render_to_string("doc/mail/reviewer_assignment_rejected.txt", { + msg = render_to_string("review/reviewer_assignment_rejected.txt", { "by": request.user.person, "message_to_secretary": form.cleaned_data.get("message_to_secretary") }) @@ -393,7 +398,7 @@ def complete_review(request, name, request_id): strip_prefix(review_req.doc.name, "draft-"), form.cleaned_data["reviewed_rev"], review_req.team.acronym, - review_req.type.slug if review_req.type.slug != "unknown" else "", + review_req.type.slug, review_req.reviewer.person.ascii_parts()[3], datetime.date.today().isoformat(), ] @@ -403,8 +408,16 @@ def complete_review(request, name, request_id): name = "-".join(c for c in name_components if c).lower() if not Document.objects.filter(name=name).exists(): review = Document.objects.create(name=name) + DocAlias.objects.create(document=review, name=review.name) break + review.type = DocTypeName.objects.get(slug="review") + review.rev = "00" + review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, form.cleaned_data["reviewed_rev"]) + review.group = review_req.team + if review_submission == "link": + review.external_url = form.cleaned_data['review_url'] + e = NewRevisionDocEvent.objects.create( type="new_revision", doc=review, @@ -414,15 +427,9 @@ def complete_review(request, name, request_id): time=review.time, ) - review.type = DocTypeName.objects.get(slug="review") - review.rev = "00" - review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, form.cleaned_data["reviewed_rev"]) - review.group = review_req.team - if review_submission == "link": - review.external_url = form.cleaned_data['review_url'] - review.save_with_history([e]) review.set_state(State.objects.get(type="review", slug="active")) - DocAlias.objects.create(document=review, name=review.name) + + review.save_with_history([e]) # save file on disk if review_submission == "upload": @@ -441,17 +448,25 @@ def complete_review(request, name, request_id): review_req.review = review review_req.save() - DocEvent.objects.create( - type="changed_review_request", + need_to_email_review = review_submission != "link" and review_req.team.list_email + + desc = "Request for {} review by {} {}: {}. Reviewer: {}.".format( + review_req.type.name, + review_req.team.acronym.upper(), + review_req.state.name, + review_req.result.name, + review_req.reviewer.person, + ) + if need_to_email_review: + desc += " " + "Sent review to list." + + close_event = ReviewRequestDocEvent.objects.create( + type="closed_review_request", doc=review_req.doc, by=request.user.person, - desc="Request for {} review by {} {}: {}. Reviewer: {}".format( - review_req.type.name, - review_req.team.acronym.upper(), - review_req.state.name, - review_req.result.name, - review_req.reviewer, - ), + desc=desc, + review_request=review_req, + state=review_req.state, ) if review_req.state_id == "part-completed": @@ -463,7 +478,7 @@ def complete_review(request, name, request_id): url = urlreverse("ietf.doc.views_review.review_request", kwargs={ "name": new_review_req.doc.name, "request_id": new_review_req.pk }) url = request.build_absolute_uri(url) - msg = render_to_string("doc/mail/partially_completed_review.txt", { + msg = render_to_string("review/partially_completed_review.txt", { 'new_review_req_url': url, "by": request.user.person, "new_review_req": new_review_req, @@ -471,28 +486,21 @@ def complete_review(request, name, request_id): email_review_request_change(request, review_req, subject, msg, request.user.person, notify_secretary=True, notify_reviewer=False, notify_requested_by=False) - if review_submission != "link" and review_req.team.list_email: + if need_to_email_review: # email the review subject = "{} of {}-{}".format("Partial review" if review_req.state_id == "part-completed" else "Review", review_req.doc.name, review_req.reviewed_rev) msg = send_mail(request, [(review_req.team.name, review_req.team.list_email)], None, subject, - "doc/mail/completed_review.txt", { + "review/completed_review.txt", { "review_req": review_req, "content": encoded_content.decode("utf-8"), }, cc=form.cleaned_data["cc"]) - e = DocEvent.objects.create( - type="changed_review_request", - doc=review_req.doc, - by=request.user.person, - desc="Sent review to list.", - ) - list_name = mailarch.list_name_from_email(review_req.team.list_email) if list_name: review.external_url = mailarch.construct_message_url(list_name, email.utils.unquote(msg["Message-ID"])) - review.save_with_history([e]) + review.save_with_history([close_event]) return redirect("doc_view", name=review_req.review.name) else: diff --git a/ietf/group/features.py b/ietf/group/features.py index c2a26fcf0..10d52d94a 100644 --- a/ietf/group/features.py +++ b/ietf/group/features.py @@ -31,7 +31,7 @@ class GroupFeatures(object): if group in active_review_teams(): self.has_reviews = True import ietf.group.views - self.default_tab = ietf.group.views.review_requests + self.default_tab = ietf.group.views_review.review_requests if group.type_id == "dir": self.admin_roles = ["chair", "secr"] diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index 478f36310..ef9285d62 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -31,7 +31,6 @@ from ietf.person.models import Person, Email from ietf.utils.mail import outbox, empty_outbox from ietf.utils.test_data import make_test_data, create_person, make_review_data from ietf.utils.test_utils import login_testing_unauthorized, TestCase, unicontent, reload_db_objects -import ietf.group.views def group_urlreverse_list(group, viewname): return [ @@ -336,31 +335,6 @@ class GroupPagesTests(TestCase): self.assertEqual(r.status_code, 200) self.assertTrue(doc.title not in unicontent(r)) - def test_review_requests(self): - doc = make_test_data() - review_req = make_review_data(doc) - - group = review_req.team - - for url in [ urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym }), - urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym , 'group_type': group.type_id}), - ]: - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - self.assertTrue(review_req.doc.name in unicontent(r)) - self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r)) - - url = urlreverse(ietf.group.views.review_requests, kwargs={ 'acronym': group.acronym }) - - # close request, listed under closed - review_req.state_id = "completed" - review_req.result_id = "ready" - review_req.save() - - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - self.assertTrue(review_req.doc.name in unicontent(r)) - def test_history(self): draft = make_test_data() group = draft.group diff --git a/ietf/group/tests_review.py b/ietf/group/tests_review.py index 85260a67b..6be685ea9 100644 --- a/ietf/group/tests_review.py +++ b/ietf/group/tests_review.py @@ -15,6 +15,31 @@ import ietf.group.views_review from ietf.utils.mail import outbox, empty_outbox class ReviewTests(TestCase): + def test_review_requests(self): + doc = make_test_data() + review_req = make_review_data(doc) + + group = review_req.team + + for url in [ urlreverse(ietf.group.views_review.review_requests, kwargs={ 'acronym': group.acronym }), + urlreverse(ietf.group.views_review.review_requests, kwargs={ 'acronym': group.acronym , 'group_type': group.type_id}), + ]: + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(review_req.doc.name in unicontent(r)) + self.assertTrue(unicode(review_req.reviewer.person) in unicontent(r)) + + url = urlreverse(ietf.group.views_review.review_requests, kwargs={ 'acronym': group.acronym }) + + # close request, listed under closed + review_req.state_id = "completed" + review_req.result_id = "ready" + review_req.save() + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(review_req.doc.name in unicontent(r)) + def test_suggested_review_requests(self): doc = make_test_data() review_req = make_review_data(doc) diff --git a/ietf/group/urls_info_details.py b/ietf/group/urls_info_details.py index 62f013753..ff7d08e7e 100644 --- a/ietf/group/urls_info_details.py +++ b/ietf/group/urls_info_details.py @@ -30,7 +30,7 @@ urlpatterns = patterns('', (r'^materials/new/(?P[\w-]+)/$', 'ietf.doc.views_material.edit_material', { 'action': "new" }, "group_new_material"), (r'^archives/$', 'ietf.group.views.derived_archives'), (r'^photos/$', views.group_photos), - (r'^reviews/$', views.review_requests), + (r'^reviews/$', views_review.review_requests), (r'^reviews/manage/$', views_review.manage_review_requests), (r'^reviews/email-assignments/$', views_review.email_open_review_assignments), (r'^reviewers/$', views_review.reviewer_overview), diff --git a/ietf/group/utils.py b/ietf/group/utils.py index 1287f826e..6f998da0b 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -1,6 +1,8 @@ import os from django.shortcuts import get_object_or_404 +from django.utils.safestring import mark_safe +from django.core.urlresolvers import reverse as urlreverse import debug # pyflakes:ignore @@ -9,8 +11,8 @@ from ietf.person.models import Email from ietf.utils.history import get_history_object_for, copy_many_to_many_for_history from ietf.ietfauth.utils import has_role from ietf.community.models import CommunityList, SearchRule -from ietf.community.utils import reset_name_contains_index_for_rule -from ietf.doc.models import State +from ietf.community.utils import reset_name_contains_index_for_rule, can_manage_community_list +from ietf.doc.models import Document, State def save_group_in_history(group): @@ -149,3 +151,86 @@ def setup_default_community_list_for_group(group): state=State.objects.get(slug="active", type="draft"), ) reset_name_contains_index_for_rule(related_docs_rule) + +def get_group_materials(group): + return Document.objects.filter( + group=group, + type__in=group.features.material_types + ).exclude(states__slug__in=['deleted','archived']) + +def construct_group_menu_context(request, group, selected, group_type, others): + """Return context with info for the group menu filled in.""" + kwargs = dict(acronym=group.acronym) + if group_type: + kwargs["group_type"] = group_type + + # menu entries + entries = [] + if group.features.has_documents: + entries.append(("Documents", urlreverse("ietf.group.views.group_documents", kwargs=kwargs))) + if group.features.has_chartering_process: + entries.append(("Charter", urlreverse("group_charter", kwargs=kwargs))) + else: + entries.append(("About", urlreverse("group_about", kwargs=kwargs))) + if group.features.has_materials and get_group_materials(group).exists(): + entries.append(("Materials", urlreverse("ietf.group.views.materials", kwargs=kwargs))) + if group.features.has_reviews: + import ietf.group.views_review + entries.append(("Review requests", urlreverse(ietf.group.views_review.review_requests, kwargs=kwargs))) + entries.append(("Reviewers", urlreverse(ietf.group.views_review.reviewer_overview, kwargs=kwargs))) + if group.type_id in ('rg','wg','team'): + entries.append(("Meetings", urlreverse("ietf.group.views.meetings", kwargs=kwargs))) + entries.append(("History", urlreverse("ietf.group.views.history", kwargs=kwargs))) + entries.append(("Photos", urlreverse("ietf.group.views.group_photos", kwargs=kwargs))) + entries.append(("Email expansions", urlreverse("ietf.group.views.email", kwargs=kwargs))) + if group.list_archive.startswith("http:") or group.list_archive.startswith("https:") or group.list_archive.startswith("ftp:"): + if 'mailarchive.ietf.org' in group.list_archive: + entries.append(("List archive", urlreverse("ietf.group.views.derived_archives", kwargs=kwargs))) + else: + entries.append((mark_safe("List archive »"), group.list_archive)) + if group.has_tools_page(): + entries.append((mark_safe("Tools »"), "https://tools.ietf.org/%s/%s/" % (group.type_id, group.acronym))) + + # actions + actions = [] + + is_admin = group.has_role(request.user, group.features.admin_roles) + can_manage = can_manage_group(request.user, group) + + if group.features.has_milestones: + if group.state_id != "proposed" and (is_admin or can_manage): + actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs))) + + if group.features.has_documents: + clist = CommunityList.objects.filter(group=group).first() + if clist and can_manage_community_list(request.user, clist): + import ietf.community.views + actions.append((u'Manage document list', urlreverse(ietf.community.views.manage_list, kwargs=kwargs))) + + if group.features.has_materials and can_manage_materials(request.user, group): + actions.append((u"Upload material", urlreverse("ietf.doc.views_material.choose_material_type", kwargs=kwargs))) + + if group.features.has_reviews: + import ietf.group.views_review + actions.append((u"Manage review requests", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=kwargs))) + + if group.state_id != "conclude" and (is_admin or can_manage): + actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs))) + + if group.features.customize_workflow and (is_admin or can_manage): + actions.append((u"Customize workflow", urlreverse("ietf.group.views_edit.customize_workflow", kwargs=kwargs))) + + if group.state_id in ("active", "dormant") and not group.type_id in ["sdo", "rfcedtyp", "isoc", ] and can_manage: + actions.append((u"Request closing group", urlreverse("ietf.group.views_edit.conclude", kwargs=kwargs))) + + d = { + "group": group, + "selected_menu_entry": selected, + "menu_entries": entries, + "menu_actions": actions, + "group_type": group_type, + } + + d.update(others) + + return d diff --git a/ietf/group/views.py b/ietf/group/views.py index 4b9bc82bf..d24aeb24e 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -49,18 +49,19 @@ from django.conf import settings from django.core.urlresolvers import reverse as urlreverse from django.views.decorators.cache import cache_page from django.db.models import Q -from django.utils.safestring import mark_safe -from ietf.doc.models import Document, State, DocAlias, RelatedDocument +from ietf.doc.models import State, DocAlias, RelatedDocument from ietf.doc.utils import get_chartering_type from ietf.doc.templatetags.ietf_filters import clean_whitespace from ietf.doc.utils_search import prepare_document_table from ietf.doc.utils_charter import charter_name_for_group from ietf.group.models import Group, Role, ChangeStateGroupEvent from ietf.name.models import GroupTypeName -from ietf.group.utils import get_charter_text, can_manage_group_type, can_manage_group, milestone_reviewer_for_group_type, can_provide_status_update -from ietf.group.utils import can_manage_materials, get_group_or_404 -from ietf.community.utils import docs_tracked_by_community_list, can_manage_community_list +from ietf.group.utils import (get_charter_text, can_manage_group_type, can_manage_group, + milestone_reviewer_for_group_type, can_provide_status_update, + can_manage_materials, get_group_or_404, + construct_group_menu_context, get_group_materials) +from ietf.community.utils import docs_tracked_by_community_list from ietf.community.models import CommunityList, EmailSubscription from ietf.utils.pipe import pipe from ietf.utils.textupload import get_cleaned_text_file_content @@ -69,10 +70,7 @@ from ietf.mailtrigger.utils import gather_relevant_expansions from ietf.ietfauth.utils import has_role from ietf.meeting.utils import group_sessions from ietf.meeting.helpers import get_meeting -from ietf.review.models import ReviewRequest -from ietf.review.utils import (can_manage_review_requests_for_team, - suggested_review_requests_for_team, - current_unavailable_periods_for_reviewers) + def roles(group, role_name): return Role.objects.filter(group=group, name=role_name).select_related("email", "person") @@ -331,85 +329,6 @@ def concluded_groups(request): return render(request, 'group/concluded_groups.html', dict(sections=sections)) -def get_group_materials(group): -# return Document.objects.filter(group=group, type__in=group.features.material_types, session=None).exclude(states__slug="deleted") - return Document.objects.filter(group=group, type__in=group.features.material_types).exclude(states__slug__in=['deleted','archived']) - -def construct_group_menu_context(request, group, selected, group_type, others): - """Return context with info for the group menu filled in.""" - kwargs = dict(acronym=group.acronym) - if group_type: - kwargs["group_type"] = group_type - - # menu entries - entries = [] - if group.features.has_documents: - entries.append(("Documents", urlreverse("ietf.group.views.group_documents", kwargs=kwargs))) - if group.features.has_chartering_process: - entries.append(("Charter", urlreverse("group_charter", kwargs=kwargs))) - else: - entries.append(("About", urlreverse("group_about", kwargs=kwargs))) - if group.features.has_materials and get_group_materials(group).exists(): - entries.append(("Materials", urlreverse("ietf.group.views.materials", kwargs=kwargs))) - if group.features.has_reviews: - entries.append(("Review requests", urlreverse(review_requests, kwargs=kwargs))) - if group.type_id in ('rg','wg','team'): - entries.append(("Meetings", urlreverse("ietf.group.views.meetings", kwargs=kwargs))) - entries.append(("History", urlreverse("ietf.group.views.history", kwargs=kwargs))) - entries.append(("Photos", urlreverse("ietf.group.views.group_photos", kwargs=kwargs))) - entries.append(("Email expansions", urlreverse("ietf.group.views.email", kwargs=kwargs))) - if group.list_archive.startswith("http:") or group.list_archive.startswith("https:") or group.list_archive.startswith("ftp:"): - if 'mailarchive.ietf.org' in group.list_archive: - entries.append(("List archive", urlreverse("ietf.group.views.derived_archives", kwargs=kwargs))) - else: - entries.append((mark_safe("List archive »"), group.list_archive)) - if group.has_tools_page(): - entries.append((mark_safe("Tools »"), "https://tools.ietf.org/%s/%s/" % (group.type_id, group.acronym))) - - # actions - actions = [] - - is_admin = group.has_role(request.user, group.features.admin_roles) - can_manage = can_manage_group(request.user, group) - - if group.features.has_milestones: - if group.state_id != "proposed" and (is_admin or can_manage): - actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs))) - - if group.features.has_documents: - clist = CommunityList.objects.filter(group=group).first() - if clist and can_manage_community_list(request.user, clist): - import ietf.community.views - actions.append((u'Manage document list', urlreverse(ietf.community.views.manage_list, kwargs=kwargs))) - - if group.features.has_materials and can_manage_materials(request.user, group): - actions.append((u"Upload material", urlreverse("ietf.doc.views_material.choose_material_type", kwargs=kwargs))) - - if group.features.has_reviews: - import ietf.group.views_review - actions.append((u"Manage review requests", urlreverse(ietf.group.views_review.manage_review_requests, kwargs=kwargs))) - - if group.state_id != "conclude" and (is_admin or can_manage): - actions.append((u"Edit group", urlreverse("group_edit", kwargs=kwargs))) - - if group.features.customize_workflow and (is_admin or can_manage): - actions.append((u"Customize workflow", urlreverse("ietf.group.views_edit.customize_workflow", kwargs=kwargs))) - - if group.state_id in ("active", "dormant") and not group.type_id in ["sdo", "rfcedtyp", "isoc", ] and can_manage: - actions.append((u"Request closing group", urlreverse("ietf.group.views_edit.conclude", kwargs=kwargs))) - - d = { - "group": group, - "selected_menu_entry": selected, - "menu_entries": entries, - "menu_actions": actions, - "group_type": group_type, - } - - d.update(others) - - return d - def prepare_group_documents(request, group, clist): found_docs, meta = prepare_document_table(request, docs_tracked_by_community_list(clist), request.GET) @@ -662,65 +581,6 @@ def history(request, acronym, group_type=None): "events": events, })) -def review_requests(request, acronym, group_type=None): - group = get_group_or_404(acronym, group_type) - if not group.features.has_reviews: - raise Http404 - - open_review_requests = list(ReviewRequest.objects.filter( - team=group, state__in=("requested", "accepted") - ).prefetch_related("reviewer", "type", "state").order_by("-time", "-id")) - - unavailable_periods = current_unavailable_periods_for_reviewers(group) - for review_req in open_review_requests: - if review_req.reviewer: - review_req.reviewer_unavailable = any(p.availability == "unavailable" - for p in unavailable_periods.get(review_req.reviewer.person_id, [])) - - open_review_requests = suggested_review_requests_for_team(group) + open_review_requests - - today = datetime.date.today() - for r in open_review_requests: - r.due = max(0, (today - r.deadline).days) - - closed_review_requests = ReviewRequest.objects.filter( - team=group, - ).exclude( - state__in=("requested", "accepted") - ).prefetch_related("reviewer", "type", "state", "doc").order_by("-time", "-id") - - since_choices = [ - (None, "1 month"), - ("3m", "3 months"), - ("6m", "6 months"), - ("1y", "1 year"), - ("2y", "2 years"), - ("all", "All"), - ] - since = request.GET.get("since", None) - if since not in [key for key, label in since_choices]: - since = None - - if since != "all": - date_limit = { - None: datetime.timedelta(days=31), - "3m": datetime.timedelta(days=31 * 3), - "6m": datetime.timedelta(days=180), - "1y": datetime.timedelta(days=365), - "2y": datetime.timedelta(days=2 * 365), - }[since] - - closed_review_requests = closed_review_requests.filter(time__gte=datetime.date.today() - date_limit) - - return render(request, 'group/review_requests.html', - construct_group_menu_context(request, group, "review requests", group_type, { - "open_review_requests": open_review_requests, - "closed_review_requests": closed_review_requests, - "since_choices": since_choices, - "since": since, - "can_manage_review_requests": can_manage_review_requests_for_team(request.user, group) - })) - def materials(request, acronym, group_type=None): group = get_group_or_404(acronym, group_type) if not group.features.has_materials: diff --git a/ietf/group/views_review.py b/ietf/group/views_review.py index 108a4d189..072872a43 100644 --- a/ietf/group/views_review.py +++ b/ietf/group/views_review.py @@ -1,4 +1,5 @@ import datetime +from collections import defaultdict from django.shortcuts import render, redirect, get_object_or_404 from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect @@ -14,18 +15,126 @@ from ietf.review.utils import (can_manage_review_requests_for_team, close_review close_review_request, setup_reviewer_field, suggested_review_requests_for_team, - unavailability_periods_to_list, + unavailable_periods_to_list, current_unavailable_periods_for_reviewers, email_reviewer_availability_change, - reviewer_rotation_list) + reviewer_rotation_list, + extract_review_request_data) from ietf.group.models import Role -from ietf.group.utils import get_group_or_404 +from ietf.group.utils import get_group_or_404, construct_group_menu_context from ietf.person.fields import PersonEmailChoiceField +from ietf.name.models import ReviewRequestStateName from ietf.utils.mail import send_mail_text from ietf.utils.fields import DatepickerDateField from ietf.ietfauth.utils import user_is_person +def review_requests(request, acronym, group_type=None): + group = get_group_or_404(acronym, group_type) + if not group.features.has_reviews: + raise Http404 + + open_review_requests = list(ReviewRequest.objects.filter( + team=group, state__in=("requested", "accepted") + ).prefetch_related("reviewer", "type", "state").order_by("-time", "-id")) + + unavailable_periods = current_unavailable_periods_for_reviewers(group) + for review_req in open_review_requests: + if review_req.reviewer: + review_req.reviewer_unavailable = any(p.availability == "unavailable" + for p in unavailable_periods.get(review_req.reviewer.person_id, [])) + + open_review_requests = suggested_review_requests_for_team(group) + open_review_requests + + today = datetime.date.today() + for r in open_review_requests: + r.due = max(0, (today - r.deadline).days) + + closed_review_requests = ReviewRequest.objects.filter( + team=group, + ).exclude( + state__in=("requested", "accepted") + ).prefetch_related("reviewer", "type", "state", "doc").order_by("-time", "-id") + + since_choices = [ + (None, "1 month"), + ("3m", "3 months"), + ("6m", "6 months"), + ("1y", "1 year"), + ("2y", "2 years"), + ("all", "All"), + ] + since = request.GET.get("since", None) + if since not in [key for key, label in since_choices]: + since = None + + if since != "all": + date_limit = { + None: datetime.timedelta(days=31), + "3m": datetime.timedelta(days=31 * 3), + "6m": datetime.timedelta(days=180), + "1y": datetime.timedelta(days=365), + "2y": datetime.timedelta(days=2 * 365), + }[since] + + closed_review_requests = closed_review_requests.filter(time__gte=datetime.date.today() - date_limit) + + return render(request, 'group/review_requests.html', + construct_group_menu_context(request, group, "review requests", group_type, { + "open_review_requests": open_review_requests, + "closed_review_requests": closed_review_requests, + "since_choices": since_choices, + "since": since, + "can_manage_review_requests": can_manage_review_requests_for_team(request.user, group) + })) + +def reviewer_overview(request, acronym, group_type=None): + group = get_group_or_404(acronym, group_type) + if not group.features.has_reviews: + raise Http404 + + can_manage = can_manage_review_requests_for_team(request.user, group) + + reviewers = reviewer_rotation_list(group) + + reviewer_settings = { s.person_id: s for s in ReviewerSettings.objects.filter(team=group) } + unavailable_periods = defaultdict(list) + for p in unavailable_periods_to_list().filter(team=group): + unavailable_periods[p.person_id].append(p) + reviewer_roles = { r.person_id: r for r in Role.objects.filter(group=group, name="reviewer").select_related("email") } + + today = datetime.date.today() + + all_req_data = extract_review_request_data(teams=[group], time_from=today - datetime.timedelta(days=365)) + review_state_by_slug = { n.slug: n for n in ReviewRequestStateName.objects.all() } + + for person in reviewers: + person.settings = reviewer_settings.get(person.pk) or ReviewerSettings(team=group, person=person) + person.settings_url = None + person.role = reviewer_roles.get(person.pk) + if person.role and (can_manage or user_is_person(request.user, person)): + person.settings_url = urlreverse("ietf.group.views_review.change_reviewer_settings", kwargs={ "group_type": group_type, "acronym": group.acronym, "reviewer_email": person.role.email.address }) + person.unavailable_periods = unavailable_periods.get(person.pk, []) + person.completely_unavailable = any(p.availability == "unavailable" + and p.start_date <= today and (p.end_date is None or today <= p.end_date) + for p in person.unavailable_periods) + + MAX_REQS = 5 + req_data = all_req_data.get((group.pk, person.pk), []) + open_reqs = sum(1 for _, _, _, _, state, _, _, _, _, _ in req_data if state in ("requested", "accepted")) + latest_reqs = [] + for req_pk, doc, req_time, state, deadline, result, late_days, request_to_assignment_days, assignment_to_closure_days, request_to_closure_days in req_data: + # any open requests pushes the others out + if ((state in ("requested", "accepted") and len(latest_reqs) < MAX_REQS) or (len(latest_reqs) + open_reqs < MAX_REQS)): + print review_state_by_slug.get(state), assignment_to_closure_days + latest_reqs.append((req_pk, doc, deadline, review_state_by_slug.get(state), assignment_to_closure_days)) + person.latest_reqs = latest_reqs + + return render(request, 'group/reviewer_overview.html', + construct_group_menu_context(request, group, "reviewers", group_type, { + "reviewers": reviewers, + })) + class ManageReviewRequestForm(forms.Form): ACTIONS = [ ("assign", "Assign"), @@ -45,12 +154,12 @@ class ManageReviewRequestForm(forms.Form): super(ManageReviewRequestForm, self).__init__(*args, **kwargs) + if review_req.pk is None: + self.fields["close"].queryset = self.fields["close"].queryset.filter(slug__in=["no-review-version", "no-review-document"]) + close_initial = None if review_req.pk is None: - if review_req.latest_reqs: - close_initial = "no-review-version" - else: - close_initial = "no-review-document" + close_initial = "no-review-version" elif review_req.reviewer: close_initial = "no-response" else: @@ -59,9 +168,6 @@ class ManageReviewRequestForm(forms.Form): if close_initial: self.fields["close"].initial = close_initial - if review_req.pk is None: - self.fields["close"].queryset = self.fields["close"].queryset.filter(slug__in=["noreviewversion", "noreviewdocument"]) - self.fields["close"].widget.attrs["class"] = "form-control input-sm" setup_reviewer_field(self.fields["reviewer"], review_req) @@ -176,8 +282,8 @@ def manage_review_requests(request, acronym, group_type=None): if form_action == "save-continue": return redirect(manage_review_requests, **kwargs) else: - import ietf.group.views - return redirect(ietf.group.views.review_requests, **kwargs) + import ietf.group.views_review + return redirect(ietf.group.views_review.review_requests, **kwargs) return render(request, 'group/manage_review_requests.html', { 'group': group, @@ -241,19 +347,6 @@ def email_open_review_assignments(request, acronym, group_type=None): }) -@login_required -def reviewer_overview(request, acronym, group_type=None): - group = get_group_or_404(acronym, group_type) - if not group.features.has_reviews: - raise Http404 - - reviewer_roles = Role.objects.filter(name="reviewer", group=group) - - if not (any(user_is_person(request.user, r) for r in reviewer_roles) - or can_manage_review_requests_for_team(request.user, group)): - return HttpResponseForbidden("You do not have permission to perform this action") - - class ReviewerSettingsForm(forms.ModelForm): class Meta: model = ReviewerSettings @@ -312,8 +405,8 @@ def change_reviewer_settings(request, acronym, reviewer_email, group_type=None): back_url = request.GET.get("next") if not back_url: - import ietf.group.views - back_url = urlreverse(ietf.group.views.review_requests, kwargs={ "group_type": group.type_id, "acronym": group.acronym}) + import ietf.group.views_review + back_url = urlreverse(ietf.group.views_review.review_requests, kwargs={ "group_type": group.type_id, "acronym": group.acronym}) # settings if request.method == "POST" and request.POST.get("action") == "change_settings": @@ -337,7 +430,7 @@ def change_reviewer_settings(request, acronym, reviewer_email, group_type=None): settings_form = ReviewerSettingsForm(instance=settings) # periods - unavailable_periods = unavailability_periods_to_list().filter(person=reviewer, team=group) + unavailable_periods = unavailable_periods_to_list().filter(person=reviewer, team=group) if request.method == "POST" and request.POST.get("action") == "add_period": period_form = AddUnavailablePeriodForm(request.POST) diff --git a/ietf/ietfauth/views.py b/ietf/ietfauth/views.py index 60e9a7796..3449814dd 100644 --- a/ietf/ietfauth/views.py +++ b/ietf/ietfauth/views.py @@ -56,7 +56,7 @@ from ietf.ietfauth.utils import role_required from ietf.mailinglists.models import Subscribed, Whitelisted from ietf.person.models import Person, Email, Alias from ietf.review.models import ReviewRequest, ReviewerSettings, ReviewWish -from ietf.review.utils import unavailability_periods_to_list +from ietf.review.utils import unavailable_periods_to_list from ietf.utils.mail import send_mail from ietf.doc.fields import SearchableDocumentField @@ -427,13 +427,13 @@ def review_overview(request): settings = { o.team_id: o for o in ReviewerSettings.objects.filter(person__user=request.user, team__in=teams) } unavailable_periods = defaultdict(list) - for o in unavailability_periods_to_list().filter(person__user=request.user, team__in=teams): + for o in unavailable_periods_to_list().filter(person__user=request.user, team__in=teams): unavailable_periods[o.team_id].append(o) roles = { o.group_id: o for o in Role.objects.filter(name="reviewer", person__user=request.user, group__in=teams) } for t in teams: - t.reviewer_settings = settings.get(t.pk) or ReviewerSettings() + t.reviewer_settings = settings.get(t.pk) or ReviewerSettings(team=t) t.unavailable_periods = unavailable_periods.get(t.pk, []) t.role = roles.get(t.pk) diff --git a/ietf/name/migrations/0015_insert_review_name_data.py b/ietf/name/migrations/0015_insert_review_name_data.py index 34aaa2c86..ae94ea98e 100644 --- a/ietf/name/migrations/0015_insert_review_name_data.py +++ b/ietf/name/migrations/0015_insert_review_name_data.py @@ -21,7 +21,6 @@ def insert_initial_review_data(apps, schema_editor): ReviewTypeName.objects.get_or_create(slug="early", name="Early", order=1) ReviewTypeName.objects.get_or_create(slug="lc", name="Last Call", order=2) ReviewTypeName.objects.get_or_create(slug="telechat", name="Telechat", order=3) - ReviewTypeName.objects.get_or_create(slug="unknown", name="Unknown", order=4, used=False) ReviewResultName = apps.get_model("name", "ReviewResultName") ReviewResultName.objects.get_or_create(slug="serious-issues", name="Serious Issues", order=1) diff --git a/ietf/review/import_from_review_tool.py b/ietf/review/import_from_review_tool.py index 5da78119d..8a86acf49 100755 --- a/ietf/review/import_from_review_tool.py +++ b/ietf/review/import_from_review_tool.py @@ -13,7 +13,7 @@ django.setup() # script -import datetime +import datetime, re, itertools from collections import namedtuple from django.db import connections from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, @@ -21,9 +21,10 @@ from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultNam UnavailablePeriod, NextReviewerInTeam) from ietf.group.models import Group, Role, RoleName from ietf.person.models import Person, Email, Alias +from ietf.doc.models import Document, DocAlias, ReviewRequestDocEvent, NewRevisionDocEvent, DocTypeName, State +from ietf.utils.text import strip_prefix import argparse from unidecode import unidecode -from collections import defaultdict parser = argparse.ArgumentParser() parser.add_argument("database", help="database must be included in settings") @@ -110,7 +111,7 @@ with db_con.cursor() as c: team=team, person=email.person, ) - if reviewer: + if created: print "created reviewer", reviewer.pk, unicode(reviewer).encode("utf-8") if autopolicy_days.get(row.autopolicy): @@ -181,12 +182,138 @@ type_names = { n.slug: n for n in ReviewTypeName.objects.all() } # extract relevant log entries -request_assigned = defaultdict(list) +document_history = {} + +requested_re = re.compile("(?:ADDED docname =>|Created: remote=|AUTO UPDATED status TO new|CHANGED status FROM [^ ]+ => new|CHANGE status done => working)") + +add_docstatus_re = re.compile('([a-zA-Z-_]+) ADD docstatus => (\w+)') +update_docstatus_re = re.compile('([a-zA-Z-_]+) (?:UPDATE|CHANGE) docstatus \w+ => (\w+)') +iesgstatus_re = re.compile('(?:ADD|ADDED|CHANGED) iesgstatus (?:FROM )?(?:[^ ]+ )?=> ([a-zA-Z-_]+)?') + +deadline_re = re.compile('(?:ADD|ADDED|CHANGED) deadline (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)') +telechat_re = re.compile('(?:ADD|ADDED|CHANGED) telechat (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)') +lcend_re = re.compile('(?:ADD|ADDED|CHANGED) lcend (?:FROM )?(?:[^ ]+ )?=> ([1-9][0-9]+)') + +close_states = ["done", "rejected", "withdrawn", "noresponse"] with db_con.cursor() as c: - c.execute("select docname, time, who from doclog where text = 'AUTO UPDATED status TO working' order by time desc;") - for row in namedtuplefetchall(c): - request_assigned[row.docname].append((row.time, row.who)) + c.execute("""select docname, time, who, text from doclog where + text like 'Created: remote=%' + or text like '%ADDED docname => %' + or text like '%AUTO UPDATED status TO new%' + or text like '%CHANGED status FROM % => new%' + or text like '%CHANGE status done => working%' + + or text like '% ADD docstatus => %' + or text like '% UPDATE docstatus % => %' + or text like '% CHANGE docstatus % => %' + or text like '%CHANGE status working => done%' + + or text like '%ADDED iesgstatus => %' + or text like '%ADD iesgstatus => %' + or text like '%CHANGED iesgstatus % => %' + order by docname, time asc;""") + for docname, rows in itertools.groupby(namedtuplefetchall(c), lambda row: row.docname): + branches = {} + + latest_requested = None + non_specific_close = None + latest_iesg_status = None + + for row in rows: + state = None + used = False + + if requested_re.search(row.text): + if "Created: remote" in row.text: + latest_iesg_status = "early" + state = "requested" + membername = None + used = True + else: + if "ADD docstatus" in row.text: + m = add_docstatus_re.match(row.text) + assert m, 'row.text "{}" does not match add regexp'.format(row.text) + membername, state = m.groups() + used = True + elif "UPDATE docstatus" in row.text or "CHANGE docstatus" in row.text: + m = update_docstatus_re.match(row.text) + assert m, 'row.text "{}" does not match update regexp'.format(row.text) + membername, state = m.groups() + used = True + + if telechat_re.search(row.text) and not lcend_re.search(row.text): + latest_iesg_status = "telechat" + used = True + elif ((not telechat_re.search(row.text) and lcend_re.search(row.text)) + or (deadline_re.search(row.text) and not telechat_re.search(row.text) and not lcend_re.search(row.text))): + latest_iesg_status = "lc" + used = True + elif (deadline_re.search(row.text) and telechat_re.search(row.text) and lcend_re.search(row.text)): + # if we got both, assume it was a Last Call + latest_iesg_status = "lc" + used = True + + if iesgstatus_re.search(row.text): + m = iesgstatus_re.search(row.text) + if m.groups(): + literal_iesg_status = m.groups()[0] + if literal_iesg_status == "IESG_Evaluation": + latest_iesg_status = "telechat" + elif literal_iesg_status == "In_Last_Call": + latest_iesg_status = "lc" + used = True + + if "CHANGE status working => done" in row.text: + non_specific_close = (row, latest_iesg_status) + used = True + + if not used: + raise Exception("Unknown text {}".format(row.text)) + + if not state: + continue + + if state == "working": + state = "assigned" + + if state == "requested": + latest_requested = (row.time, row.who, membername, state, latest_iesg_status) + else: + if membername not in branches: + branches[membername] = [] + + latest = branches[membername][-1] if branches[membername] else None + + if not latest or ((state == "assigned" and ("assigned" in latest or "closed" in latest)) + or (state not in ("assigned", "accepted") and "closed" in latest)): + # open new + branches[membername].append({}) + latest = branches[membername][-1] + if latest_requested: + latest["requested"] = latest_requested + latest_requested = None + + if state in ("assigned", 'accepted'): + latest[state] = (row.time, row.who, membername, state, latest_iesg_status) + else: + latest["closed"] = (row.time, row.who, membername, state, latest_iesg_status) + + + if branches: + if non_specific_close: + # find any open branches + for m, hs in branches.iteritems(): + latest = hs[-1] + if "assigned" in latest and "closed" not in latest: + #print "closing with non specific", docname + close_row, iesg_status = non_specific_close + latest["closed"] = (close_row.time, close_row.who, m, "done", iesg_status) + + document_history[docname] = branches + + # if docname in document_history: + # print docname, document_history[docname] # extract document request metadata @@ -214,33 +341,97 @@ with db_con.cursor() as c: if not deadline: deadline = parse_timestamp(row.timeout) - type_name = type_names["unknown"] - # FIXME: use lcend and telechat to try to deduce type - reviewed_rev = row.version if row.version and row.version != "99" else "" if row.summary == "noresponse": reviewed_rev = "" - assignment_logs = request_assigned.get(row.docname, []) - if assignment_logs: - time, who = assignment_logs.pop() - - time = parse_timestamp(time) + event_collection = None + branches = document_history.get(row.docname) + if not branches: + print "WARNING: no history for", row.docname else: - time = deadline + history = branches.get(row.reviewer) + if not history: + print "WARNING: reviewer {} not found in history for".format(row.reviewer), row.docname + else: + event_collection = history.pop(0) + if "requested" not in event_collection: + print "WARNING: no requested log entry for", row.docname, [event_collection] + history + + if "assigned" not in event_collection: + print "WARNING: no assigned log entry for", row.docname, [event_collection] + history + + if "closed" not in event_collection and row.docstatus in close_states: + print "WARNING: no {} log entry for".format("/".join(close_states)), row.docname, [event_collection] + history + + def day_delta(time_from, time_to): + if time_from is None or time_to is None: + return None + + return float(time_to[0] - time_from[0]) / (24 * 60 * 60) + + requested_assigned_days = day_delta(event_collection.get("requested"), event_collection.get("assigned")) + if requested_assigned_days is not None and requested_assigned_days < 0: + print "WARNING: assignment before request", requested_assigned_days, row.docname + at_most_days = 20 + if requested_assigned_days is not None and requested_assigned_days > at_most_days: + print "WARNING: more than {} days between request and assignment".format(at_most_days), round(requested_assigned_days), event_collection, row.docname + + if "closed" in event_collection: + assigned_closed_days = day_delta(event_collection.get("assigned"), event_collection.get("closed")) + if assigned_closed_days is not None and assigned_closed_days < 0: + print "WARNING: closure before assignment", assigned_closed_days, row.docname + at_most_days = 60 + if assigned_closed_days is not None and assigned_closed_days > at_most_days and event_collection.get("closed")[3] not in ("noresponse", "withdrawn"): + print "WARNING: more than {} days between assignment and completion".format(at_most_days), round(assigned_closed_days), event_collection, row.docname + + if event_collection: + time = None + if "requested" in event_collection: + time = parse_timestamp(event_collection["requested"][0]) + elif "assigned" in event_collection: + time = parse_timestamp(event_collection["assigned"][0]) + elif "closed" in event_collection: + time = parse_timestamp(event_collection["closed"][0]) + else: + time = deadline + + if not deadline and "closed" in event_collection: + deadline = parse_timestamp(event_collection["closed"][0]) if not deadline: - # bogus row - print "SKIPPING WITH NO DEADLINE", time, row, meta + print "SKIPPING WITH NO DEADLINE", row.reviewid, row.docname, meta, event_collection continue - if status == "done" and row.docstatus in ("assigned", "accepted"): - # filter out some apparently dead requests - print "SKIPPING MARKED DONE even if assigned/accepted", time, row - continue + type_slug = None + if "assigned" in event_collection: + type_slug = event_collection["assigned"][4] + if type_slug: + print "deduced type slug at assignment", type_slug, row.docname - req, _ = ReviewRequest.objects.get_or_create( - doc_id=row.docname, + if not type_slug and "requested" in event_collection: + type_slug = event_collection["requested"][4] + if type_slug: + print "deduced type slug at request", type_slug, row.docname + + if not type_slug and "closed" in event_collection and "assigned" not in event_collection: + type_slug = event_collection["closed"][4] + if type_slug: + print "deduced type slug at closure", type_slug, row.docname + + if not type_slug: + print "did not deduce type slug, assuming early", row.docname, deadline + type_slug = "early" + + type_name = type_names[type_slug] + + def fix_docname(docname): + if docname == "draft-fenner-obsolete": + docname = "draft-fenner-obsolete-1264" + return docname + + review_req, _ = ReviewRequest.objects.get_or_create( + doc_id=fix_docname(row.docname), team=team, old_id=row.reviewid, defaults={ @@ -251,28 +442,142 @@ with db_con.cursor() as c: } ) - req.reviewer = known_personnel[row.reviewer] if row.reviewer else None - req.result = results.get(row.summary.lower()) if row.summary else None - req.state = states.get(row.docstatus) if row.docstatus else None - req.type = type_name - req.time = time - req.reviewed_rev = reviewed_rev - req.deadline = deadline.date() - req.save() + review_req.reviewer = known_personnel[row.reviewer] if row.reviewer else None + review_req.result = results.get(row.summary.lower()) if row.summary else None + review_req.state = states.get(row.docstatus) if row.docstatus else None + review_req.type = type_name + review_req.time = time + review_req.reviewed_rev = reviewed_rev if review_req.state_id not in ("requested", "accepted") else "" + review_req.deadline = deadline.date() + review_req.save() - # FIXME: add log entries - # FIXME: add review from reviewurl - # FIXME: do something about missing result + completion_event = None - # adcomments IGNORED - # lccomments IGNORED - # nits IGNORED - # reviewurl review.external_url + # review request events + for key, data in event_collection.iteritems(): + timestamp, who_did_it, reviewer, state, latest_iesg_status = data - #print meta and meta[0], telechat, lcend, req.type + if who_did_it in known_personnel: + by = known_personnel[who_did_it].person + else: + by = system_person - if req.state_id == "requested" and req.doc.get_state_slug("draft-iesg") in ["approved", "ann", "rfcqueue", "pub"]: - req.state = states["overtaken"] - req.save() + if key == "requested": + if "assigned" in event_collection: + continue # skip requested unless there's no assigned event - print "imported review", row.reviewid, "as", req.pk, req.time, req.deadline, req.type, req.doc_id, req.state, req.doc.get_state_slug("draft-iesg") + e = ReviewRequestDocEvent.objects.filter(type="requested_review", doc=review_req.doc).first() or ReviewRequestDocEvent(type="requested_review", doc=review_req.doc) + e.time = time + e.by = by + e.desc = "Requested {} review by {}".format(review_req.type.name, review_req.team.acronym.upper()) + e.review_request = review_req + e.state = None + e.skip_community_list_notification = True + e.save() + print "imported event requested_review", e.desc, e.doc_id + + elif key == "assigned": + e = ReviewRequestDocEvent.objects.filter(type="assigned_review_request", doc=review_req.doc).first() or ReviewRequestDocEvent(type="assigned_review_request", doc=review_req.doc) + e.time = parse_timestamp(timestamp) + e.by = by + e.desc = "Request for {} review by {} is assigned to {}".format( + review_req.type.name, + review_req.team.acronym.upper(), + review_req.reviewer.person if review_req.reviewer else "(None)", + ) + e.review_request = review_req + e.state = None + e.skip_community_list_notification = True + e.save() + print "imported event assigned_review_request", e.pk, e.desc, e.doc_id + + elif key == "closed" and review_req.state_id not in ("requested", "accepted"): + e = ReviewRequestDocEvent.objects.filter(type="closed_review_request", doc=review_req.doc).first() or ReviewRequestDocEvent(type="closed_review_request", doc=review_req.doc) + e.time = parse_timestamp(timestamp) + e.by = by + e.state = states.get(state) if state else None + if e.state_id == "rejected": + e.desc = "Assignment of request for {} review by {} to {} was rejected".format( + review_req.type.name, + review_req.team.acronym.upper(), + review_req.reviewer.person, + ) + elif e.state_id == "completed": + e.desc = "Request for {} review by {} {}{}. Reviewer: {}.".format( + review_req.type.name, + review_req.team.acronym.upper(), + review_req.state.name, + ": {}".format(review_req.result.name) if review_req.result else "", + review_req.reviewer.person, + ) + else: + e.desc = "Closed request for {} review by {} with state '{}'".format(review_req.type.name, review_req.team.acronym.upper(), e.state.name) + e.review_request = review_req + e.skip_community_list_notification = True + e.save() + completion_event = e + print "imported event closed_review_request", e.desc, e.doc_id + + if review_req.state_id == "completed": + if not row.reviewurl: # don't have anything to store, so skip + continue + + if completion_event: + completion_time = completion_event.time + completion_by = completion_event.by + else: + completion_time = deadline + completion_by = system_person + + # create the review document + if review_req.review: + review = review_req.review + else: + for i in range(1, 100): + name_components = [ + "review", + strip_prefix(review_req.doc_id, "draft-"), + review_req.reviewed_rev, + review_req.team.acronym, + review_req.type_id, + review_req.reviewer.person.ascii_parts()[3], + completion_time.date().isoformat(), + ] + if i > 1: + name_components.append(str(i)) + + name = "-".join(c for c in name_components if c).lower() + if not Document.objects.filter(name=name).exists(): + review = Document.objects.create(name=name) + DocAlias.objects.create(document=review, name=review.name) + break + + review.set_state(State.objects.get(type="review", slug="active")) + + review.time = completion_time + review.type = DocTypeName.objects.get(slug="review") + review.rev = "00" + review.title = "{} Review of {}-{}".format(review_req.type.name, review_req.doc.name, review_req.reviewed_rev) + review.group = review_req.team + review.external_url = row.reviewurl + + existing = NewRevisionDocEvent.objects.filter(doc=review).first() or NewRevisionDocEvent(doc=review) + e.type = "new_revision" + e.by = completion_by + e.rev = review.rev + e.desc = 'New revision available' + e.time = completion_time + e.skip_community_list_notification = True + + review.save_with_history([e]) + + review_req.review = review + review_req.save() + + print "imported review document", review_req.doc, review.name + + if review_req.state_id in ("requested", "accepted") and review_req.doc.get_state_slug("draft-iesg") in ["approved", "ann", "rfcqueue", "pub"]: + review_req.state = states["overtaken"] + review_req.save() + + print "imported review request", row.reviewid, "as", review_req.pk, review_req.time, review_req.deadline, review_req.type, review_req.doc_id, review_req.state, review_req.doc.get_state_slug("draft-iesg") diff --git a/ietf/review/migrations/0001_initial.py b/ietf/review/migrations/0001_initial.py index ec090266c..9c8a17004 100644 --- a/ietf/review/migrations/0001_initial.py +++ b/ietf/review/migrations/0001_initial.py @@ -49,7 +49,7 @@ class Migration(migrations.Migration): ('deadline', models.DateField()), ('requested_rev', models.CharField(help_text=b'Fill in if a specific revision is to be reviewed, e.g. 02', max_length=16, verbose_name=b'requested revision', blank=True)), ('reviewed_rev', models.CharField(max_length=16, verbose_name=b'reviewed revision', blank=True)), - ('doc', models.ForeignKey(related_name='review_request_set', to='doc.Document')), + ('doc', models.ForeignKey(related_name='reviewrequest_set', to='doc.Document')), ('requested_by', models.ForeignKey(to='person.Person')), ('result', models.ForeignKey(blank=True, to='name.ReviewResultName', null=True)), ('review', models.OneToOneField(null=True, blank=True, to='doc.Document')), diff --git a/ietf/review/models.py b/ietf/review/models.py index c60825576..f1ef76798 100644 --- a/ietf/review/models.py +++ b/ietf/review/models.py @@ -96,7 +96,7 @@ class ReviewRequest(models.Model): # constitute the request part. time = models.DateTimeField(default=datetime.datetime.now) type = models.ForeignKey(ReviewTypeName) - doc = models.ForeignKey(Document, related_name='review_request_set') + doc = models.ForeignKey(Document, related_name='reviewrequest_set') team = models.ForeignKey(Group, limit_choices_to=~models.Q(reviewteamresult=None)) deadline = models.DateField() requested_by = models.ForeignKey(Person) diff --git a/ietf/review/utils.py b/ietf/review/utils.py index abfaf56af..4813cac1d 100644 --- a/ietf/review/utils.py +++ b/ietf/review/utils.py @@ -1,11 +1,12 @@ -import datetime, re +import datetime, re, itertools from collections import defaultdict from django.db.models import Q, Max from django.core.urlresolvers import reverse as urlreverse from ietf.group.models import Group, Role -from ietf.doc.models import Document, DocEvent, State, LastCallDocEvent, DocumentAuthor, DocAlias +from ietf.doc.models import (Document, ReviewRequestDocEvent, State, + LastCallDocEvent, DocumentAuthor, DocAlias) from ietf.iesg.models import TelechatDate from ietf.person.models import Person, Email from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream @@ -50,7 +51,7 @@ def no_review_from_teams_on_doc(doc, rev): reviewrequest__state="no-review-version", ).distinct() -def unavailability_periods_to_list(past_days=30): +def unavailable_periods_to_list(past_days=14): return UnavailablePeriod.objects.filter( Q(end_date=None) | Q(end_date__gte=datetime.date.today() - datetime.timedelta(days=past_days)), ).order_by("start_date") @@ -71,7 +72,7 @@ def current_unavailable_periods_for_reviewers(team): return res -def reviewer_rotation_list(team): +def reviewer_rotation_list(team, skip_unavailable=False, dont_skip=[]): """Returns person id -> index in rotation (next reviewer has index 0).""" reviewers = list(Person.objects.filter(role__name="reviewer", role__group=team)) reviewers.sort(key=lambda p: p.last_name()) @@ -94,7 +95,26 @@ def reviewer_rotation_list(team): else: next_reviewer_index = reviewers.index(n) - return reviewers[next_reviewer_index:] + reviewers[:next_reviewer_index] + rotation_list = reviewers[next_reviewer_index:] + reviewers[:next_reviewer_index] + + if skip_unavailable: + # prune reviewers not in the rotation (but not the assigned + # reviewer who must have been available for assignment anyway) + reviewers_to_skip = set() + + unavailable_periods = current_unavailable_periods_for_reviewers(team) + for person_id, periods in unavailable_periods.iteritems(): + if periods and person_id not in dont_skip: + reviewers_to_skip.add(person_id) + + days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(team) + for person_id, days_needed in days_needed_for_reviewers.iteritems(): + if days_needed > 0 and person_id not in dont_skip: + reviewers_to_skip.add(person_id) + + rotation_list = [p.pk for p in rotation_list if p.pk not in reviewers_to_skip] + + return rotation_list def days_needed_to_fulfill_min_interval_for_reviewers(team): """Returns person_id -> days needed until min_interval is fulfilled for @@ -121,6 +141,66 @@ def days_needed_to_fulfill_min_interval_for_reviewers(team): return res +def extract_review_request_data(teams=None, reviewers=None, time_from=None, time_to=None): + """Returns a dict keyed on (team.pk, reviewer_person.pk) which lists data on each review request.""" + + filters = Q() + + if teams: + filters &= Q(team__in=teams) + + if reviewers: + filters &= Q(reviewer__person__in=reviewers) + + if time_from: + filters &= Q(time__gte=time_from) + + if time_to: + filters &= Q(time__lte=time_to) + + res = defaultdict(list) + + # we may be dealing with a big bunch of data, so treat it carefully + event_qs = ReviewRequest.objects.filter(filters).order_by("-time", "-id") + + # left outer join with RequestRequestDocEvent for request/assign/close time + event_qs = event_qs.values_list("pk", "doc", "time", "state", "deadline", "result", "team", "reviewer__person", "reviewrequestdocevent__time", "reviewrequestdocevent__type") + + def positive_days(time_from, time_to): + if time_from is None or time_to is None: + return None + + delta = time_to - time_from + seconds = delta.total_seconds() + if seconds > 0: + return seconds / float(24 * 60 * 60) + else: + return 0.0 + + for _, events in itertools.groupby(event_qs.iterator(), lambda t: t[0]): + requested_time = assigned_time = closed_time = None + + for e in events: + req_pk, doc, req_time, state, deadline, result, team, reviewer, event_time, event_type = e + + if event_type == "requested_review" and requested_time is None: + requested_time = event_time + elif event_type == "assigned_review_request" and assigned_time is None: + assigned_time = event_time + elif event_type == "closed_review_request" and closed_time is None: + closed_time = event_time + + late_days = positive_days(datetime.datetime.combine(deadline, datetime.time.max), closed_time) + request_to_assignment_days = positive_days(requested_time, assigned_time) + assignment_to_closure_days = positive_days(assigned_time, closed_time) + request_to_closure_days = positive_days(requested_time, closed_time) + + res[(team, reviewer)].append((req_pk, doc, req_time, state, deadline, result, + late_days, request_to_assignment_days, assignment_to_closure_days, + request_to_closure_days)) + + return res + def make_new_review_request_from_existing(review_req): obj = ReviewRequest() obj.time = review_req.time @@ -233,10 +313,11 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): review_req.reviewer = reviewer review_req.save() - possibly_advance_next_reviewer_for_team(review_req.team, review_req.reviewer.person_id) + if review_req.reviewer: + possibly_advance_next_reviewer_for_team(review_req.team, review_req.reviewer.person_id) - DocEvent.objects.create( - type="changed_review_request", + ReviewRequestDocEvent.objects.create( + type="assigned_review_request", doc=review_req.doc, by=request.user.person, desc="Request for {} review by {} is assigned to {}".format( @@ -244,6 +325,8 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): review_req.team.acronym.upper(), review_req.reviewer.person if review_req.reviewer else "(None)", ), + review_request=review_req, + state=None, ) email_review_request_change( @@ -253,26 +336,14 @@ def assign_review_request_to_reviewer(request, review_req, reviewer): by=request.user.person, notify_secretary=False, notify_reviewer=True, notify_requested_by=False) def possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id): - # prune reviewers not in the rotation (but not the assigned - # reviewer who must have been available for assignment anyway) - reviewers_to_skip = set() + assert assigned_review_to_person_id is not None - unavailable_periods = current_unavailable_periods_for_reviewers(team) - for person_id, periods in unavailable_periods.iteritems(): - if periods and person_id != assigned_review_to_person_id: - reviewers_to_skip.add(person_id) - - days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(team) - for person_id, days_needed in days_needed_for_reviewers.iteritems(): - if days_needed > 0 and person_id != assigned_review_to_person_id: - reviewers_to_skip.add(person_id) - - rotation_list = [p.pk for p in reviewer_rotation_list(team) if p.pk not in reviewers_to_skip] - - if not rotation_list: - return + rotation_list = reviewer_rotation_list(team, skip_unavailable=True, dont_skip=[assigned_review_to_person_id]) def reviewer_at_index(i): + if not rotation_list: + return None + return rotation_list[i % len(rotation_list)] def reviewer_settings_for(person_id): @@ -281,7 +352,7 @@ def possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id): current_i = 0 - if reviewer_at_index(current_i) == assigned_review_to_person_id: + if assigned_review_to_person_id == reviewer_at_index(current_i): # move 1 ahead current_i += 1 else: @@ -289,6 +360,9 @@ def possibly_advance_next_reviewer_for_team(team, assigned_review_to_person_id): settings.skip_next += 1 settings.save() + if not rotation_list: + return + while True: # as a clean-up step go through any with a skip next > 0 current_reviewer_person_id = reviewer_at_index(current_i) @@ -315,12 +389,14 @@ def close_review_request(request, review_req, close_state): review_req.save() if not suggested_req: - DocEvent.objects.create( - type="changed_review_request", + ReviewRequestDocEvent.objects.create( + type="closed_review_request", doc=review_req.doc, by=request.user.person, desc="Closed request for {} review by {} with state '{}'".format( review_req.type.name, review_req.team.acronym.upper(), close_state.name), + review_request=review_req, + state=review_req.state, ) if prev_state.slug != "requested": @@ -337,6 +413,8 @@ def suggested_review_requests_for_team(team): requests = {} + today = datetime.date.today() + requested_state = ReviewRequestStateName.objects.get(slug="requested", used=True) if True: # FIXME @@ -345,9 +423,9 @@ def suggested_review_requests_for_team(team): last_call_docs = Document.objects.filter(states=State.objects.get(type="draft-iesg", slug="lc", used=True)) last_call_expires = { e.doc_id: e.expires for e in LastCallDocEvent.objects.order_by("time", "id") } for doc in last_call_docs: - deadline = last_call_expires[doc.pk].date() if doc.pk in last_call_expires else datetime.date.today() + deadline = last_call_expires[doc.pk].date() if doc.pk in last_call_expires else today - if deadline > seen_deadlines.get(doc.pk, datetime.date.max): + if deadline > seen_deadlines.get(doc.pk, datetime.date.max) or deadline < today: continue requests[doc.pk] = ReviewRequest( @@ -486,7 +564,7 @@ def make_assignment_choices(email_queryset, review_req): for p in possible_person_ids: if p not in reviewer_settings: - reviewer_settings[p] = ReviewerSettings() + reviewer_settings[p] = ReviewerSettings(team=team) # frequency days_needed_for_reviewers = days_needed_to_fulfill_min_interval_for_reviewers(team) diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index f872a840d..97b12bf0b 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -519,6 +519,10 @@ table.unavailable-periods td { padding-right: 0.5em; } +table.unavailable-periods td:last-child { + padding-right: 0; +} + .unavailable-period-past { color: #777; } @@ -527,6 +531,10 @@ table.unavailable-periods td { font-weight: bold; } +.reviewer-overview .completely-unavailable { + opacity: 0.6; +} + /* === Photo pages ========================================================== */ .photo-name { diff --git a/ietf/templates/doc/document_review.html b/ietf/templates/doc/document_review.html index 1d83e0c00..3d98cc0d9 100644 --- a/ietf/templates/doc/document_review.html +++ b/ietf/templates/doc/document_review.html @@ -69,12 +69,14 @@ {{ review_req.reviewer.person }} + {% if review_req.result %} Review result {{ review_req.result.name }} + {% endif %} {% endif %} {% if doc.external_url %} diff --git a/ietf/templates/doc/review/review_request.html b/ietf/templates/doc/review/review_request.html index fbccf36b6..6cfbb90d9 100644 --- a/ietf/templates/doc/review/review_request.html +++ b/ietf/templates/doc/review/review_request.html @@ -44,7 +44,7 @@ Team - {{ review_req.team.acronym|upper }} + {{ review_req.team.acronym|upper }} diff --git a/ietf/templates/doc/review_request_summary.html b/ietf/templates/doc/review_request_summary.html index 22bf04dcd..4d810fdcf 100644 --- a/ietf/templates/doc/review_request_summary.html +++ b/ietf/templates/doc/review_request_summary.html @@ -1,8 +1,8 @@
{% if review_request.state_id == "completed" or review_request.state_id == "part-completed" %} - {{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev or review_request.doc_id != current_doc_name %} (of {% if review_request.doc_id != current_doc_name %}{{ review_request.doc_id }}{% endif %}-{{ review_request.reviewed_rev }}){% endif %}: - {{ review_request.result.name }} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %} + {{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev or review_request.doc_id != current_doc_name %} (of {% if review_request.doc_id != current_doc_name %}{{ review_request.doc_id }}{% endif %}-{{ review_request.reviewed_rev }}){% endif %}{% if review_request.result %}: + {{ review_request.result.name }}{% endif %} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %} - reviewer: {{ review_request.reviewer.person }} {% else %} diff --git a/ietf/templates/group/manage_review_requests.html b/ietf/templates/group/manage_review_requests.html index 39ce651f3..e8ff4e8d1 100644 --- a/ietf/templates/group/manage_review_requests.html +++ b/ietf/templates/group/manage_review_requests.html @@ -16,7 +16,7 @@

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

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

@@ -59,7 +59,7 @@ {% for rlatest in r.latest_reqs %}
- prev. review of {% if rlatest.doc_id != r.doc_id %}{{ rlatest.doc_id }}{% endif %}-{{ rlatest.reviewed_rev }}: - {{ rlatest.result.name }} + {% if rlatest.result %}{{ rlatest.result.name }}{% else %}result unavail.{% endif %} (diff){% if not forloop.last %},{% endif %}
@@ -129,7 +129,7 @@ {% buttons %} - Cancel + Cancel diff --git a/ietf/templates/group/review_requests.html b/ietf/templates/group/review_requests.html index 0959d0117..d746fbc41 100644 --- a/ietf/templates/group/review_requests.html +++ b/ietf/templates/group/review_requests.html @@ -34,7 +34,7 @@ {% if r.pk %}{{ r.time|date:"Y-m-d" }}{% else %}auto-suggested{% endif %} {{ r.deadline|date:"Y-m-d" }} - {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} + {% if r.due %}{{ r.due }} day{{ r.due|pluralize }}{% endif %} {% if r.reviewer %} diff --git a/ietf/templates/group/reviewer_overview.html b/ietf/templates/group/reviewer_overview.html new file mode 100644 index 000000000..c9b290f48 --- /dev/null +++ b/ietf/templates/group/reviewer_overview.html @@ -0,0 +1,49 @@ +{% extends "group/group_base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %}{% origin %} + +{% load ietf_filters staticfiles bootstrap3 %} + +{% block group_subtitle %}Reviewers{% endblock %} + +{% block group_content %} + {% origin %} + +

Reviewers

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

No reviewers found.

+ {% endif %} + +{% endblock %} diff --git a/ietf/templates/ietfauth/review_overview.html b/ietf/templates/ietfauth/review_overview.html index a28b6d257..b68758ef3 100644 --- a/ietf/templates/ietfauth/review_overview.html +++ b/ietf/templates/ietfauth/review_overview.html @@ -140,14 +140,7 @@ Unavailable periods {% if t.unavailable_periods %} - - {% for o in t.unavailable_periods %} - - - - - {% endfor %} -
{{ o.start_date }} - {{ o.end_date|default:"" }}{{ o.get_availability_display }}
+ {% include "review/unavailable_table.html" with unavailable_periods=t.unavailable_periods %} {% else %} (No periods) {% endif %} diff --git a/ietf/templates/doc/mail/completed_review.txt b/ietf/templates/review/completed_review.txt similarity index 100% rename from ietf/templates/doc/mail/completed_review.txt rename to ietf/templates/review/completed_review.txt diff --git a/ietf/templates/doc/mail/partially_completed_review.txt b/ietf/templates/review/partially_completed_review.txt similarity index 100% rename from ietf/templates/doc/mail/partially_completed_review.txt rename to ietf/templates/review/partially_completed_review.txt diff --git a/ietf/templates/doc/mail/reviewer_assignment_rejected.txt b/ietf/templates/review/reviewer_assignment_rejected.txt similarity index 100% rename from ietf/templates/doc/mail/reviewer_assignment_rejected.txt rename to ietf/templates/review/reviewer_assignment_rejected.txt diff --git a/ietf/templates/review/unavailable_table.html b/ietf/templates/review/unavailable_table.html new file mode 100644 index 000000000..7fc1d5e35 --- /dev/null +++ b/ietf/templates/review/unavailable_table.html @@ -0,0 +1,8 @@ + + {% for p in unavailable_periods %} + + + + + {% endfor %} +
{{ p.start_date }} - {{ p.end_date|default:"" }}{{ p.get_availability_display }}