datatracker/ietf/doc/views_conflict_review.py
Ole Laursen e1f0917659 Summary: Add new document saving API, Document.save_with_history(events).
The new API requires at least one event and will automatically save a
snapshot of the document and related state. Document.save() will now
throw an exception if called directly, as the new API is intended to
ensure that documents are saved with both an appropriate snapsnot and
relevant history log, both of which are easily defeated by just
calling .save() directly.

To simplify things, the snapshot is generated after the changes to a
document have been made (in anticipation of coming changes), instead
of before as was usual.

While revising the existing code to work with this API, a couple of
missing events was discovered:

- In draft expiry, a "Document has expired" event was only generated
  in case an IESG process had started on the document - now it's
  always generated, as the document changes its state in any case

- Synchronization updates like title and abstract amendmends from the
  RFC Editor were silently (except for RFC publication) applied and
  not accompanied by a descriptive event - they now are

- do_replace in the Secretariat tools now adds an event

- Proceedings post_process in the Secretariat tools now adds an event

- do_withdraw in the Secretariat tools now adds an event

A migration is needed for snapshotting all documents, takes a while to
run. It turns out that a single document had a bad foreign key so the
migration fixes that too.
 - Legacy-Id: 10101
2015-09-28 14:01:03 +00:00

478 lines
21 KiB
Python

import datetime, os
from django import forms
from django.shortcuts import render_to_response, get_object_or_404, redirect
from django.http import HttpResponseRedirect, Http404
from django.core.urlresolvers import reverse
from django.template import RequestContext
from django.template.loader import render_to_string
from django.conf import settings
from ietf.doc.models import ( BallotDocEvent, BallotPositionDocEvent, DocAlias, DocEvent,
Document, NewRevisionDocEvent, State )
from ietf.doc.utils import ( add_state_change_event, close_open_ballots,
create_ballot_if_not_open, get_document_content, update_telechat )
from ietf.doc.mails import email_iana
from ietf.doc.forms import AdForm
from ietf.group.models import Role, Group
from ietf.iesg.models import TelechatDate
from ietf.ietfauth.utils import has_role, role_required, is_authorized_in_doc_stream
from ietf.person.models import Person
from ietf.utils.mail import send_mail_preformatted
from ietf.utils.textupload import get_cleaned_text_file_content
class ChangeStateForm(forms.Form):
review_state = forms.ModelChoiceField(State.objects.filter(used=True, type="conflrev"), label="Conflict review state", empty_label=None, required=True)
comment = forms.CharField(widget=forms.Textarea, help_text="Optional comment for the review history.", required=False)
@role_required("Area Director", "Secretariat")
def change_state(request, name, option=None):
"""Change state of an IESG review for IETF conflicts in other stream's documents, notifying parties as necessary
and logging the change as a comment."""
review = get_object_or_404(Document, type="conflrev", name=name)
login = request.user.person
if request.method == 'POST':
form = ChangeStateForm(request.POST)
if form.is_valid():
clean = form.cleaned_data
new_state = clean['review_state']
comment = clean['comment'].rstrip()
if comment:
c = DocEvent(type="added_comment", doc=review, by=login)
c.desc = comment
c.save()
prev_state = review.get_state()
if new_state != prev_state:
events = []
review.set_state(new_state)
events.append(add_state_change_event(review, login, prev_state, new_state))
review.save_with_history(events)
if new_state.slug == "iesgeval":
create_ballot_if_not_open(review, login, "conflrev")
ballot = review.latest_event(BallotDocEvent, type="created_ballot")
if has_role(request.user, "Area Director") and not review.latest_event(BallotPositionDocEvent, ad=login, ballot=ballot, type="changed_ballot_position"):
# The AD putting a conflict review into iesgeval who doesn't already have a position is saying "yes"
pos = BallotPositionDocEvent(doc=review, by=login)
pos.ballot = ballot
pos.type = "changed_ballot_position"
pos.ad = login
pos.pos_id = "yes"
pos.desc = "[Ballot Position Update] New position, %s, has been recorded for %s" % (pos.pos.name, pos.ad.plain_name())
pos.save()
send_conflict_eval_email(request,review)
return redirect('doc_view', name=review.name)
else:
s = review.get_state()
init = dict(review_state=s.pk if s else None)
form = ChangeStateForm(initial=init)
return render_to_response('doc/change_state.html',
dict(form=form,
doc=review,
login=login,
help_url=reverse('state_help', kwargs=dict(type="conflict-review")),
),
context_instance=RequestContext(request))
def send_conflict_review_started_email(request, review):
msg = render_to_string("doc/conflict_review/review_started.txt",
dict(frm = settings.DEFAULT_FROM_EMAIL,
by = request.user.person,
review = review,
reviewed_doc = review.relateddocument_set.get(relationship__slug='conflrev').target.document,
review_url = settings.IDTRACKER_BASE_URL+review.get_absolute_url(),
)
)
if not has_role(request.user,"Secretariat"):
send_mail_preformatted(request,msg)
email_iana(request,
review.relateddocument_set.get(relationship__slug='conflrev').target.document,
settings.IANA_EVAL_EMAIL,
msg)
def send_conflict_eval_email(request,review):
msg = render_to_string("doc/eval_email.txt",
dict(doc=review,
doc_url = settings.IDTRACKER_BASE_URL+review.get_absolute_url(),
)
)
send_mail_preformatted(request,msg)
email_iana(request,
review.relateddocument_set.get(relationship__slug='conflrev').target.document,
settings.IANA_EVAL_EMAIL,
msg)
class UploadForm(forms.Form):
content = forms.CharField(widget=forms.Textarea, label="Conflict review response", help_text="Edit the conflict review response.", required=False)
txt = forms.FileField(label=".txt format", help_text="Or upload a .txt file.", required=False)
def clean_content(self):
return self.cleaned_data["content"].replace("\r", "")
def clean_txt(self):
return get_cleaned_text_file_content(self.cleaned_data["txt"])
def save(self, review):
filename = os.path.join(settings.CONFLICT_REVIEW_PATH, '%s-%s.txt' % (review.canonical_name(), review.rev))
with open(filename, 'wb') as destination:
if self.cleaned_data['txt']:
destination.write(self.cleaned_data['txt'])
else:
destination.write(self.cleaned_data['content'])
#This is very close to submit on charter - can we get better reuse?
@role_required('Area Director','Secretariat')
def submit(request, name):
review = get_object_or_404(Document, type="conflrev", name=name)
login = request.user.person
path = os.path.join(settings.CONFLICT_REVIEW_PATH, '%s-%s.txt' % (review.canonical_name(), review.rev))
not_uploaded_yet = review.rev == "00" and not os.path.exists(path)
if not_uploaded_yet:
# this case is special - the conflict review text document doesn't actually exist yet
next_rev = review.rev
else:
next_rev = "%02d" % (int(review.rev)+1)
if request.method == 'POST':
if "submit_response" in request.POST:
form = UploadForm(request.POST, request.FILES)
if form.is_valid():
review.rev = next_rev
events = []
e = NewRevisionDocEvent(doc=review, by=login, type="new_revision")
e.desc = "New version available: <b>%s-%s.txt</b>" % (review.canonical_name(), review.rev)
e.rev = review.rev
e.save()
events.append(e)
# Save file on disk
form.save(review)
review.save_with_history(events)
return redirect('doc_view', name=review.name)
elif "reset_text" in request.POST:
init = { "content": render_to_string("doc/conflict_review/review_choices.txt",dict())}
form = UploadForm(initial=init)
# Protect against handcrufted malicious posts
else:
form = None
else:
form = None
if not form:
init = { "content": ""}
if not_uploaded_yet:
init["content"] = render_to_string("doc/conflict_review/review_choices.txt",
dict(),
)
else:
filename = os.path.join(settings.CONFLICT_REVIEW_PATH, '%s-%s.txt' % (review.canonical_name(), review.rev))
try:
with open(filename, 'r') as f:
init["content"] = f.read()
except IOError:
pass
form = UploadForm(initial=init)
return render_to_response('doc/conflict_review/submit.html',
{'form': form,
'next_rev': next_rev,
'review' : review,
'conflictdoc' : review.relateddocument_set.get(relationship__slug='conflrev').target.document,
},
context_instance=RequestContext(request))
@role_required("Area Director", "Secretariat")
def edit_ad(request, name):
"""Change the shepherding Area Director for this review."""
review = get_object_or_404(Document, type="conflrev", name=name)
if request.method == 'POST':
form = AdForm(request.POST)
if form.is_valid():
review.ad = form.cleaned_data['ad']
c = DocEvent(type="added_comment", doc=review, by=request.user.person)
c.desc = "Shepherding AD changed to "+review.ad.name
c.save()
review.save_with_history([c])
return redirect('doc_view', name=review.name)
else:
init = { "ad" : review.ad_id }
form = AdForm(initial=init)
conflictdoc = review.relateddocument_set.get(relationship__slug='conflrev').target.document
titletext = 'the conflict review of %s-%s' % (conflictdoc.canonical_name(),conflictdoc.rev)
return render_to_response('doc/change_ad.html',
{'form': form,
'doc': review,
'titletext': titletext
},
context_instance = RequestContext(request))
def default_approval_text(review):
filename = "%s-%s.txt" % (review.canonical_name(), review.rev)
current_text = get_document_content(filename, os.path.join(settings.CONFLICT_REVIEW_PATH, filename), split=False, markup=False)
conflictdoc = review.relateddocument_set.get(relationship__slug='conflrev').target.document
if conflictdoc.stream_id=='ise':
receiver = 'RFC-Editor'
elif conflictdoc.stream_id=='irtf':
receiver = 'IRTF'
else:
receiver = 'recipient'
text = render_to_string("doc/conflict_review/approval_text.txt",
dict(review=review,
review_url = settings.IDTRACKER_BASE_URL+review.get_absolute_url(),
conflictdoc = conflictdoc,
conflictdoc_url = settings.IDTRACKER_BASE_URL+conflictdoc.get_absolute_url(),
receiver=receiver,
approved_review = current_text
)
)
return text
class AnnouncementForm(forms.Form):
announcement_text = forms.CharField(widget=forms.Textarea, label="IETF Conflict Review Announcement", help_text="Edit the announcement message.", required=True)
@role_required("Secretariat")
def approve(request, name):
"""Approve this conflict review, setting the appropriate state and send the announcement to the right parties."""
review = get_object_or_404(Document, type="conflrev", name=name)
if review.get_state('conflrev').slug not in ('appr-reqnopub-pend','appr-noprob-pend'):
raise Http404
login = request.user.person
if request.method == 'POST':
form = AnnouncementForm(request.POST)
if form.is_valid():
prev_state = review.get_state()
events = []
new_state_slug = 'appr-reqnopub-sent' if prev_state.slug == 'appr-reqnopub-pend' else 'appr-noprob-sent'
new_state = State.objects.get(used=True, type="conflrev", slug=new_state_slug)
review.set_state(new_state)
e = add_state_change_event(review, login, prev_state, new_state)
events.append(e)
close_open_ballots(review, login)
e = DocEvent(doc=review, by=login)
e.type = "iesg_approved"
e.desc = "IESG has approved the conflict review response"
e.save()
events.append(e)
review.save_with_history(events)
# send announcement
send_mail_preformatted(request, form.cleaned_data['announcement_text'])
c = DocEvent(type="added_comment", doc=review, by=login)
c.desc = "The following approval message was sent\n"+form.cleaned_data['announcement_text']
c.save()
return HttpResponseRedirect(review.get_absolute_url())
else:
init = { "announcement_text" : default_approval_text(review) }
form = AnnouncementForm(initial=init)
return render_to_response('doc/conflict_review/approve.html',
dict(
review = review,
conflictdoc = review.relateddocument_set.get(relationship__slug='conflrev').target.document,
form = form,
),
context_instance=RequestContext(request))
class SimpleStartReviewForm(forms.Form):
notify = forms.CharField(max_length=255, label="Notice emails", help_text="Separate email addresses with commas.", required=False)
class StartReviewForm(forms.Form):
ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active",role__group__type='area').order_by('name'),
label="Shepherding AD", empty_label="(None)", required=True)
create_in_state = forms.ModelChoiceField(State.objects.filter(used=True, type="conflrev", slug__in=("needshep", "adrev")), empty_label=None, required=False)
notify = forms.CharField(max_length=255, label="Notice emails", help_text="Separate email addresses with commas.", required=False)
telechat_date = forms.TypedChoiceField(coerce=lambda x: datetime.datetime.strptime(x, '%Y-%m-%d').date(), empty_value=None, required=False, widget=forms.Select(attrs={'onchange':'make_bold()'}))
def __init__(self, *args, **kwargs):
super(self.__class__, self).__init__(*args, **kwargs)
# telechat choices
dates = [d.date for d in TelechatDate.objects.active().order_by('date')]
#init = kwargs['initial']['telechat_date']
#if init and init not in dates:
# dates.insert(0, init)
self.fields['telechat_date'].choices = [("", "(not on agenda)")] + [(d, d.strftime("%Y-%m-%d")) for d in dates]
@role_required("Secretariat","IRTF Chair","ISE")
def start_review(request, name):
if has_role(request.user,"Secretariat"):
return start_review_as_secretariat(request,name)
else:
return start_review_as_stream_owner(request,name)
def start_review_sanity_check(request, name):
doc_to_review = get_object_or_404(Document, type="draft", name=name)
if ( not doc_to_review.stream_id in ('ise','irtf') ) or ( not is_authorized_in_doc_stream(request.user,doc_to_review)):
raise Http404
# sanity check that there's not already a conflict review document for this document
if [ rel.source for alias in doc_to_review.docalias_set.all() for rel in alias.relateddocument_set.filter(relationship='conflrev') ]:
raise Http404
return doc_to_review
def build_notify_addresses(doc_to_review):
# Take care to do the right thing during ietf chair and stream owner transitions
notify_addresses = []
notify_addresses.extend([r.formatted_email() for r in Role.objects.filter(group__acronym=doc_to_review.stream.slug, name='chair')])
notify_addresses.append("%s@%s" % (doc_to_review.name, settings.DRAFT_ALIAS_DOMAIN))
return notify_addresses
def build_conflict_review_document(login, doc_to_review, ad, notify, create_in_state):
if doc_to_review.name.startswith('draft-'):
review_name = 'conflict-review-'+doc_to_review.name[6:]
else:
# This is a failsafe - and might be treated better as an error
review_name = 'conflict-review-'+doc_to_review.name
iesg_group = Group.objects.get(acronym='iesg')
conflict_review = Document.objects.create(
type_id="conflrev",
title="IETF conflict review for %s" % doc_to_review.name,
name=review_name,
rev="00",
ad=ad,
notify=notify,
stream_id='ietf',
group=iesg_group,
)
conflict_review.set_state(create_in_state)
DocAlias.objects.create( name=review_name , document=conflict_review )
conflict_review.relateddocument_set.create(target=DocAlias.objects.get(name=doc_to_review.name),relationship_id='conflrev')
c = DocEvent(type="added_comment", doc=conflict_review, by=login)
c.desc = "IETF conflict review requested"
c.save()
c = DocEvent(type="added_comment", doc=doc_to_review, by=login)
# Is it really OK to put html tags into comment text?
c.desc = 'IETF conflict review initiated - see <a href="%s">%s</a>' % (reverse('doc_view', kwargs={'name':conflict_review.name}),conflict_review.name)
c.save()
return conflict_review
def start_review_as_secretariat(request, name):
"""Start the conflict review process, setting the initial shepherding AD, and possibly putting the review on a telechat."""
doc_to_review = start_review_sanity_check(request, name)
login = request.user.person
if request.method == 'POST':
form = StartReviewForm(request.POST)
if form.is_valid():
conflict_review = build_conflict_review_document(login = login,
doc_to_review = doc_to_review,
ad = form.cleaned_data['ad'],
notify = form.cleaned_data['notify'],
create_in_state = form.cleaned_data['create_in_state']
)
tc_date = form.cleaned_data['telechat_date']
if tc_date:
update_telechat(request, conflict_review, login, tc_date)
send_conflict_review_started_email(request, conflict_review)
return HttpResponseRedirect(conflict_review.get_absolute_url())
else:
notify_addresses = build_notify_addresses(doc_to_review)
init = {
"ad" : Role.objects.filter(group__acronym='ietf',name='chair')[0].person.id,
"notify" : u', '.join(notify_addresses),
}
form = StartReviewForm(initial=init)
return render_to_response('doc/conflict_review/start.html',
{'form': form,
'doc_to_review': doc_to_review,
},
context_instance = RequestContext(request))
def start_review_as_stream_owner(request, name):
"""Start the conflict review process using defaults for everything but notify and let the secretariat know"""
doc_to_review = start_review_sanity_check(request, name)
login = request.user.person
if request.method == 'POST':
form = SimpleStartReviewForm(request.POST)
if form.is_valid():
conflict_review = build_conflict_review_document(login = login,
doc_to_review = doc_to_review,
ad = Role.objects.filter(group__acronym='ietf',name='chair')[0].person,
notify = form.cleaned_data['notify'],
create_in_state = State.objects.get(used=True,type='conflrev',slug='needshep')
)
send_conflict_review_started_email(request, conflict_review)
return HttpResponseRedirect(conflict_review.get_absolute_url())
else:
notify_addresses = build_notify_addresses(doc_to_review)
init = {
"notify" : u', '.join(notify_addresses),
}
form = SimpleStartReviewForm(initial=init)
return render_to_response('doc/conflict_review/start.html',
{'form': form,
'doc_to_review': doc_to_review,
},
context_instance = RequestContext(request))