Add a utility function for extracting information about review

requests for a given set of teams/reviewers (making it trivial to
compute statistics), revamp the related doc event code to support this
by referencing the review request directly, add a reviewer overview
page with recent performance for each reviewer as well as
settings/unavailable periods. Fix some bugs and shuffle some of the
review code a bit around.

Finish the importer from the previous Perl-based review tool,
importing log entries, figuring out whether a given review is
early/telechat/last call and fixing corner cases.
 - Legacy-Id: 12080
This commit is contained in:
Ole Laursen 2016-10-03 15:52:32 +00:00
parent c586feb579
commit 4c7b2847ba
30 changed files with 868 additions and 341 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,7 +30,7 @@ urlpatterns = patterns('',
(r'^materials/new/(?P<doc_type>[\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),

View file

@ -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 &raquo;"), group.list_archive))
if group.has_tools_page():
entries.append((mark_safe("Tools &raquo;"), "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

View file

@ -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 &raquo;"), group.list_archive))
if group.has_tools_page():
entries.append((mark_safe("Tools &raquo;"), "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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -69,12 +69,14 @@
<td>{{ review_req.reviewer.person }}</td>
</tr>
{% if review_req.result %}
<tr>
<th></th>
<th>Review result</th>
<td class="edit"></td>
<td>{{ review_req.result.name }}</td>
</tr>
{% endif %}
{% endif %}
{% if doc.external_url %}

View file

@ -44,7 +44,7 @@
<tr>
<th></th>
<th>Team</th>
<td><a href="{% url "ietf.group.views.review_requests" group_type=review_req.team.type_id acronym=review_req.team.acronym %}">{{ review_req.team.acronym|upper }}</a></td>
<td><a href="{% url "ietf.group.views_review.review_requests" group_type=review_req.team.type_id acronym=review_req.team.acronym %}">{{ review_req.team.acronym|upper }}</a></td>
</tr>
<tr>

View file

@ -1,8 +1,8 @@
<div class="review-request-summary">
{% if review_request.state_id == "completed" or review_request.state_id == "part-completed" %}
<a href="{% if review_request.review %}{% url "doc_view" review_request.review.name %}{% else %}{% url "ietf.doc.views_review.review_request" review_request.doc_id review_request.pk %}{% endif %}">
{{ review_request.team.acronym|upper }} {{ review_request.type.name }} Review{% if review_request.reviewed_rev and review_request.reviewed_rev != current_rev or review_request.doc_id != current_doc_name %} (of {% if review_request.doc_id != current_doc_name %}{{ review_request.doc_id }}{% endif %}-{{ review_request.reviewed_rev }}){% endif %}:
{{ review_request.result.name }} {% if review_request.state_id == "part-completed" %}(partially completed){% endif %}
{{ 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 }}</a>
{% else %}
<i>

View file

@ -16,7 +16,7 @@
<h1>Manage open review requests for {{ group.acronym }}</h1>
<p>Other options:
<a href="{% url "ietf.group.views.review_requests" group_type=group.type_id acronym=group.acronym %}#closed-review-requests">Closed review requests</a>
<a href="{% url "ietf.group.views_review.reviewer_overview" group_type=group.type_id acronym=group.acronym %}">Reviewers in team</a>
- <a href="{% url "ietf.group.views_review.email_open_review_assignments" group_type=group.type_id acronym=group.acronym %}">Email open assignments summary</a>
</p>
@ -59,7 +59,7 @@
{% for rlatest in r.latest_reqs %}
<div>
<small>- prev. review of {% if rlatest.doc_id != r.doc_id %}{{ rlatest.doc_id }}{% endif %}-{{ rlatest.reviewed_rev }}:
<a href="{% url "ietf.doc.views_review.review_request" name=rlatest.doc_id request_id=rlatest.pk %}">{{ rlatest.result.name }}</a>
<a href="{% url "ietf.doc.views_review.review_request" name=rlatest.doc_id request_id=rlatest.pk %}">{% if rlatest.result %}{{ rlatest.result.name }}{% else %}result unavail.{% endif %}</a>
(<a href="{{ rfcdiff_base_url }}?url1={{ rlatest.doc.name }}-{{ rlatest.reviewed_rev }}&url2={{ r.doc.name }}-{{ r.doc.rev }}">diff</a>){% if not forloop.last %},{% endif %}
</small>
</div>
@ -129,7 +129,7 @@
</table>
{% buttons %}
<a href="{% url "ietf.group.views.review_requests" group_type=group.type_id acronym=group.acronym %}" class="btn btn-default pull-right">Cancel</a>
<a href="{% url "ietf.group.views_review.review_requests" group_type=group.type_id acronym=group.acronym %}" class="btn btn-default pull-right">Cancel</a>
<button class="btn btn-primary" type="submit" name="action" value="save">Save changes</button>
<button class="btn btn-primary" type="submit" name="action" value="save-continue">Save and continue editing</button>
<button class="btn btn-default" type="submit" name="action" value="refresh">Refresh (keeping changes)</button>

View file

@ -34,7 +34,7 @@
<td>{% if r.pk %}{{ r.time|date:"Y-m-d" }}{% else %}<em>auto-suggested</em>{% endif %}</td>
<td>
{{ r.deadline|date:"Y-m-d" }}
{% if r.due %}<span class="label label-warning">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
{% if r.due %}<span class="label label-warning" title="{{ r.due }} day{{ r.due|pluralize }} past deadline">{{ r.due }} day{{ r.due|pluralize }}</span>{% endif %}
</td>
<td>
{% if r.reviewer %}

View file

@ -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 %}
<h2>Reviewers</h2>
{% if reviewers %}
<table class="table reviewer-overview">
<thead>
<tr>
<th>Reviewer</th>
<th>Latest assignments (deadline, state)</th>
<th>Settings</th>
</tr>
</thead>
<tbody>
{% for person in reviewers %}
<tr {% if person.completely_unavailable %}class="completely-unavailable"{% endif %}>
<td><a {% if person.settings_url %}href="{{ person.settings_url }}"{% endif %}>{{ person }}</a></td>
<td>
{% for req_pk, doc_name, deadline, state, assignment_to_closure_days in person.latest_reqs %}
<div><a href="{% url "ietf.doc.views_review.review_request" name=doc_name request_id=req_pk %}">{{ deadline|date }} {{ doc_name }}: {{ state.name }} {% if assignment_to_closure_days != None %}{% if state.slug == "completed" or state.slug == "part-completed" %}(in {{ assignment_to_closure_days|floatformat }} day{{ assignment_to_closure_days|pluralize }}){% endif %}{% endif %}</a></div>
{% endfor %}
</td>
<td>
{{ person.settings.get_min_interval_display }} {% if person.settings.skip_next %}(skip: {{ person.settings.skip_next }}){% endif %}<br>
{% if person.settings.filter_re %}Filter: <code title="{{ person.settings.filter_re }}">{{ person.settings.filter_re|truncatechars:15 }}</code><br>{% endif %}
{% if person.unavailable_periods %}
{% include "review/unavailable_table.html" with unavailable_periods=person.unavailable_periods %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No reviewers found.</p>
{% endif %}
{% endblock %}

View file

@ -140,14 +140,7 @@
<th>Unavailable periods</th>
<td>
{% if t.unavailable_periods %}
<table class="unavailable-periods">
{% for o in t.unavailable_periods %}
<tr class="unavailable-period-{{ o.state }}">
<td>{{ o.start_date }} - {{ o.end_date|default:"" }}</td>
<td>{{ o.get_availability_display }}</td>
</tr>
{% endfor %}
</table>
{% include "review/unavailable_table.html" with unavailable_periods=t.unavailable_periods %}
{% else %}
(No periods)
{% endif %}

View file

@ -0,0 +1,8 @@
<table class="unavailable-periods">
{% for p in unavailable_periods %}
<tr class="unavailable-period-{{ p.state }}">
<td>{{ p.start_date }} - {{ p.end_date|default:"" }}</td>
<td>{{ p.get_availability_display }}</td>
</tr>
{% endfor %}
</table>