datatracker/ietf/doc/views_ballot.py
Jennifer Richards 08e953995a
feat: better reject null characters in forms (#7472)
* feat: subclass ModelMultipleChoiceField to reject nuls

* refactor: Use custom ModelMultipleChoiceField

* fix: handle value=None
2024-05-28 10:34:55 -05:00

1293 lines
53 KiB
Python

# Copyright The IETF Trust 2010-2020, All Rights Reserved
# -*- coding: utf-8 -*-
# ballot management (voting, commenting, writeups, ...) for Area
# Directors and Secretariat
import datetime, json
from django import forms
from django.conf import settings
from django.http import HttpResponse, HttpResponseRedirect, Http404
from django.shortcuts import render, get_object_or_404, redirect
from django.template.defaultfilters import striptags
from django.template.loader import render_to_string
from django.urls import reverse as urlreverse
from django.views.decorators.csrf import csrf_exempt
from django.utils.html import escape
import debug # pyflakes:ignore
from ietf.doc.models import ( Document, State, DocEvent, BallotDocEvent,
IRSGBallotDocEvent, BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent,
IESG_SUBSTATE_TAGS, RelatedDocument, BallotType )
from ietf.doc.utils import ( add_state_change_event, close_ballot, close_open_ballots,
create_ballot_if_not_open, update_telechat, update_action_holders )
from ietf.doc.mails import ( email_ballot_deferred, email_ballot_undeferred,
extra_automation_headers, generate_last_call_announcement,
generate_issue_ballot_mail, generate_ballot_writeup, generate_ballot_rfceditornote,
generate_approval_mail, email_irsg_ballot_closed, email_irsg_ballot_issued,
email_rsab_ballot_issued, email_rsab_ballot_closed,
email_lc_to_yang_doctors )
from ietf.doc.lastcall import request_last_call
from ietf.doc.templatetags.ietf_filters import can_ballot
from ietf.iesg.models import TelechatDate
from ietf.ietfauth.utils import has_role, role_required, is_authorized_in_doc_stream
from ietf.mailtrigger.utils import gather_address_lists
from ietf.mailtrigger.forms import CcSelectForm
from ietf.message.utils import infer_message
from ietf.name.models import BallotPositionName, DocTypeName
from ietf.person.models import Person
from ietf.utils.fields import ModelMultipleChoiceField
from ietf.utils.mail import send_mail_text, send_mail_preformatted
from ietf.utils.decorators import require_api_key
from ietf.utils.response import permission_denied
from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO
# -------------------------------------------------
# Helper Functions
# -------------------------------------------------
def do_undefer_ballot(request, doc):
'''
Helper function to perform undefer of ballot. Takes the Request object, for use in
logging, and the Document object.
'''
by = request.user.person
telechat_date = TelechatDate.objects.active().order_by("date")[0].date
new_state = doc.get_state()
prev_tags = []
new_tags = []
if doc.type_id == 'draft':
new_state = State.objects.get(used=True, type="draft-iesg", slug='iesg-eva')
prev_tags = doc.tags.filter(slug__in=IESG_SUBSTATE_TAGS)
elif doc.type_id in ['conflrev','statchg']:
new_state = State.objects.get(used=True, type=doc.type_id, slug='iesgeval')
prev_state = doc.get_state(new_state.type_id if new_state else None)
doc.set_state(new_state)
doc.tags.remove(*prev_tags)
events = []
e = add_state_change_event(doc, by, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_telechat(request, doc, by, telechat_date)
if e:
events.append(e)
if events:
doc.save_with_history(events)
email_ballot_undeferred(request, doc, by.plain_name(), telechat_date)
# -------------------------------------------------
class EditPositionForm(forms.Form):
position = forms.ModelChoiceField(queryset=BallotPositionName.objects.all(), widget=forms.RadioSelect, initial="norecord", required=True)
discuss = forms.CharField(required=False, widget=forms.Textarea, strip=False)
comment = forms.CharField(required=False, widget=forms.Textarea, strip=False)
def __init__(self, *args, **kwargs):
ballot_type = kwargs.pop("ballot_type")
super(EditPositionForm, self).__init__(*args, **kwargs)
self.fields['position'].queryset = ballot_type.positions.order_by('order')
if ballot_type.positions.filter(blocking=True).exists():
self.fields['discuss'].label = ballot_type.positions.get(blocking=True).name
def clean_discuss(self):
entered_discuss = self.cleaned_data["discuss"]
entered_pos = self.cleaned_data.get("position", BallotPositionName.objects.get(slug="norecord"))
if entered_pos.blocking and not entered_discuss:
raise forms.ValidationError("You must enter a non-empty discuss")
return entered_discuss
def save_position(form, doc, ballot, balloter, login=None, send_email=False):
# save the vote
if login is None:
login = balloter
clean = form.cleaned_data
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", balloter=balloter, ballot=ballot)
pos = BallotPositionDocEvent(doc=doc, rev=doc.rev, by=login)
pos.type = "changed_ballot_position"
pos.ballot = ballot
pos.balloter = balloter
pos.pos = clean["position"]
pos.comment = clean["comment"].rstrip()
pos.comment_time = old_pos.comment_time if old_pos else None
pos.discuss = clean["discuss"].rstrip()
pos.send_email = send_email
if not pos.pos.blocking:
pos.discuss = ""
pos.discuss_time = old_pos.discuss_time if old_pos else None
changes = []
added_events = []
# possibly add discuss/comment comments to history trail
# so it's easy to see what's happened
old_comment = old_pos.comment if old_pos else ""
if pos.comment != old_comment:
pos.comment_time = pos.time
changes.append("comment")
if pos.comment:
e = DocEvent(doc=doc, rev=doc.rev, by=balloter)
e.type = "added_comment"
e.desc = "[Ballot comment]\n" + pos.comment
added_events.append(e)
old_discuss = old_pos.discuss if old_pos else ""
if pos.discuss != old_discuss:
pos.discuss_time = pos.time
changes.append("discuss")
if pos.pos.blocking:
e = DocEvent(doc=doc, rev=doc.rev, by=balloter)
e.type = "added_comment"
e.desc = "[Ballot %s]\n" % pos.pos.name.lower()
e.desc += pos.discuss
added_events.append(e)
# figure out a description
if not old_pos and pos.pos.slug != "norecord":
pos.desc = "[Ballot Position Update] New position, %s, has been recorded for %s" % (pos.pos.name, pos.balloter.plain_name())
elif old_pos and pos.pos != old_pos.pos:
pos.desc = "[Ballot Position Update] Position for %s has been changed to %s from %s" % (pos.balloter.plain_name(), pos.pos.name, old_pos.pos.name)
if not pos.desc and changes:
pos.desc = "Ballot %s text updated for %s" % (" and ".join(changes), balloter.plain_name())
# only add new event if we actually got a change
if pos.desc:
if login != balloter:
pos.desc += " by %s" % login.plain_name()
pos.save()
for e in added_events:
e.save() # save them after the position is saved to get later id for sorting order
return pos
@role_required("Area Director", "Secretariat", "IRSG Member", "RSAB Member")
def edit_position(request, name, ballot_id):
"""Vote and edit discuss and comment on document"""
doc = get_object_or_404(Document, name=name)
ballot = get_object_or_404(BallotDocEvent, type="created_ballot", pk=ballot_id, doc=doc)
balloter = login = request.user.person
if 'ballot_edit_return_point' in request.session:
return_to_url = request.session['ballot_edit_return_point']
else:
return_to_url = urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name, ballot_id=ballot_id))
# if we're in the Secretariat, we can select a balloter to act as stand-in for
if has_role(request.user, "Secretariat"):
balloter_id = request.GET.get('balloter')
if not balloter_id:
raise Http404
balloter = get_object_or_404(Person, pk=balloter_id)
if request.method == 'POST':
old_pos = None
if not has_role(request.user, "Secretariat") and not can_ballot(request.user, doc):
# prevent pre-ADs from taking a position
permission_denied(request, "Must be an active member (not a pre-AD for example) of the balloting body to take a position")
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
if form.is_valid():
send_mail = True if request.POST.get("send_mail") else False
save_position(form, doc, ballot, balloter, login, send_mail)
if send_mail:
qstr=""
if request.GET.get('balloter'):
qstr += "?balloter=%s" % request.GET.get('balloter')
return HttpResponseRedirect(urlreverse('ietf.doc.views_ballot.send_ballot_comment', kwargs=dict(name=doc.name, ballot_id=ballot_id)) + qstr)
elif request.POST.get("Defer") and doc.stream.slug != "irtf":
return redirect('ietf.doc.views_ballot.defer_ballot', name=doc)
elif request.POST.get("Undefer") and doc.stream.slug != "irtf":
return redirect('ietf.doc.views_ballot.undefer_ballot', name=doc)
else:
return HttpResponseRedirect(return_to_url)
else:
initial = {}
old_pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", balloter=balloter, ballot=ballot)
if old_pos:
initial['position'] = old_pos.pos.slug
initial['discuss'] = old_pos.discuss
initial['comment'] = old_pos.comment
form = EditPositionForm(initial=initial, ballot_type=ballot.ballot_type)
blocking_positions = dict((p.pk, p.name) for p in form.fields["position"].queryset.all() if p.blocking)
ballot_deferred = doc.active_defer_event()
return render(request, 'doc/ballot/edit_position.html',
dict(doc=doc,
form=form,
balloter=balloter,
return_to_url=return_to_url,
old_pos=old_pos,
ballot_deferred=ballot_deferred,
ballot = ballot,
show_discuss_text=old_pos and old_pos.pos.blocking,
blocking_positions=json.dumps(blocking_positions),
))
@require_api_key
@role_required('Area Director')
@csrf_exempt
def api_set_position(request):
def err(code, text):
return HttpResponse(text, status=code, content_type='text/plain')
if request.method == 'POST':
ad = request.user.person
name = request.POST.get('doc')
if not name:
return err(400, "Missing document name")
try:
doc = Document.objects.get(name=name)
except Document.DoesNotExist:
return err(400, "Document not found")
position_names = BallotPositionName.objects.values_list('slug', flat=True)
position = request.POST.get('position')
if not position:
return err(400, "Missing parameter: position, one of: %s " % ','.join(position_names))
if not position in position_names:
return err(400, "Bad position name, must be one of: %s " % ','.join(position_names))
ballot = doc.active_ballot()
if not ballot:
return err(400, "No open ballot found")
form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type)
if form.is_valid():
pos = save_position(form, doc, ballot, ad, send_email=True)
else:
errors = form.errors
summary = ','.join([ "%s: %s" % (f, striptags(errors[f])) for f in errors ])
return err(400, "Form not valid: %s" % summary)
else:
return err(405, "Method not allowed")
# send position email
addrs, frm, subject, body = build_position_email(ad, doc, pos)
send_mail_text(request, addrs.to, frm, subject, body, cc=addrs.cc)
return HttpResponse("Done", status=200, content_type='text/plain')
def build_position_email(balloter, doc, pos):
subj = []
d = ""
blocking_name = "DISCUSS"
if pos.pos.blocking and pos.discuss:
d = pos.discuss
blocking_name = pos.pos.name.upper()
subj.append(blocking_name)
c = ""
if pos.comment:
c = pos.comment
subj.append("COMMENT")
balloter_name_genitive = balloter.plain_name() + "'" if balloter.plain_name().endswith('s') else balloter.plain_name() + "'s"
subject = "%s %s on %s" % (balloter_name_genitive, pos.pos.name if pos.pos else "No Position", doc.name + "-" + doc.rev)
if subj:
subject += ": (with %s)" % " and ".join(subj)
body = render_to_string("doc/ballot/ballot_comment_mail.txt",
dict(discuss=d,
comment=c,
balloter=balloter.plain_name(),
doc=doc,
pos=pos.pos,
blocking_name=blocking_name,
settings=settings))
frm = balloter.role_email("ad").formatted_email()
if doc.stream_id == "irtf":
addrs = gather_address_lists('irsg_ballot_saved',doc=doc)
else:
addrs = gather_address_lists('iesg_ballot_saved',doc=doc)
return addrs, frm, subject, body
@role_required('Area Director','Secretariat','IRSG Member', 'RSAB Member')
def send_ballot_comment(request, name, ballot_id):
"""Email document ballot position discuss/comment for Area Director."""
doc = get_object_or_404(Document, name=name)
ballot = get_object_or_404(BallotDocEvent, type="created_ballot", pk=ballot_id, doc=doc)
if not has_role(request.user, 'Secretariat'):
if any([
doc.stream_id == 'ietf' and not has_role(request.user, 'Area Director'),
doc.stream_id == 'irtf' and not has_role(request.user, 'IRSG Member'),
doc.stream_id == 'editorial' and not has_role(request.user, 'RSAB Member'),
]):
raise Http404
balloter = request.user.person
if 'ballot_edit_return_point' in request.session:
return_to_url = request.session['ballot_edit_return_point']
else:
return_to_url = urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name, ballot_id=ballot_id))
if 'HTTP_REFERER' in request.META:
back_url = request.META['HTTP_REFERER']
else:
back_url = urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name, ballot_id=ballot_id))
# if we're in the Secretariat, we can select a balloter (such as an AD) to act as stand-in for
if has_role(request.user, "Secretariat"):
balloter_id = request.GET.get('balloter')
if not balloter_id:
raise Http404
balloter = get_object_or_404(Person, pk=balloter_id)
pos = doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position", balloter=balloter, ballot=ballot)
if not pos:
raise Http404
addrs, frm, subject, body = build_position_email(balloter, doc, pos)
if doc.stream_id == 'irtf':
mailtrigger_slug='irsg_ballot_saved'
elif doc.stream_id == 'editorial':
mailtrigger_slug='rsab_ballot_saved'
else:
mailtrigger_slug='iesg_ballot_saved'
if request.method == 'POST':
cc = []
cc_select_form = CcSelectForm(data=request.POST,mailtrigger_slug=mailtrigger_slug,mailtrigger_context={'doc':doc})
if cc_select_form.is_valid():
cc.extend(cc_select_form.get_selected_addresses())
extra_cc = [x.strip() for x in request.POST.get("extra_cc","").split(',') if x.strip()]
if extra_cc:
cc.extend(extra_cc)
send_mail_text(request, addrs.to, frm, subject, body, cc=", ".join(cc))
return HttpResponseRedirect(return_to_url)
else:
cc_select_form = CcSelectForm(mailtrigger_slug=mailtrigger_slug,mailtrigger_context={'doc':doc})
return render(request, 'doc/ballot/send_ballot_comment.html',
dict(doc=doc,
subject=subject,
body=body,
frm=frm,
to=addrs.as_strings().to,
balloter=balloter,
back_url=back_url,
cc_select_form = cc_select_form,
))
@role_required('Area Director','Secretariat')
def clear_ballot(request, name, ballot_type_slug):
"""Clear all positions and discusses on every open ballot for a document."""
doc = get_object_or_404(Document, name=name)
if request.method == 'POST':
by = request.user.person
if close_ballot(doc, by, ballot_type_slug):
create_ballot_if_not_open(request, doc, by, ballot_type_slug)
if doc.get_state('draft-iesg').slug == 'defer':
do_undefer_ballot(request,doc)
return redirect("ietf.doc.views_doc.document_main", name=doc.name)
return render(request, 'doc/ballot/clear_ballot.html',
dict(doc=doc,
back_url=doc.get_absolute_url()))
@role_required('Area Director','Secretariat')
def defer_ballot(request, name):
"""Signal post-pone of ballot, notifying relevant parties."""
doc = get_object_or_404(Document, name=name)
if doc.type_id not in ('draft','conflrev','statchg'):
raise Http404
interesting_state = dict(draft='draft-iesg',conflrev='conflrev',statchg='statchg')
state = doc.get_state(interesting_state[doc.type_id])
if not state or state.slug=='defer' or not doc.telechat_date():
raise Http404
login = request.user.person
telechat_date = TelechatDate.objects.active().order_by("date")[1].date
if request.method == 'POST':
new_state = doc.get_state()
prev_tags = []
new_tags = []
if doc.type_id == 'draft':
new_state = State.objects.get(used=True, type="draft-iesg", slug='defer')
prev_tags = doc.tags.filter(slug__in=IESG_SUBSTATE_TAGS)
elif doc.type_id in ['conflrev','statchg']:
new_state = State.objects.get(used=True, type=doc.type_id, slug='defer')
prev_state = doc.get_state(new_state.type_id if new_state else None)
doc.set_state(new_state)
doc.tags.remove(*prev_tags)
events = []
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_telechat(request, doc, login, telechat_date)
if e:
events.append(e)
doc.save_with_history(events)
email_ballot_deferred(request, doc, login.plain_name(), telechat_date)
return HttpResponseRedirect(doc.get_absolute_url())
return render(request, 'doc/ballot/defer_ballot.html',
dict(doc=doc,
telechat_date=telechat_date,
back_url=doc.get_absolute_url()))
@role_required('Area Director','Secretariat')
def undefer_ballot(request, name):
"""undo deferral of ballot ballot."""
doc = get_object_or_404(Document, name=name)
if doc.type_id not in ('draft','conflrev','statchg'):
raise Http404
if doc.type_id == 'draft' and not doc.get_state("draft-iesg"):
raise Http404
interesting_state = dict(draft='draft-iesg',conflrev='conflrev',statchg='statchg')
state = doc.get_state(interesting_state[doc.type_id])
if not state or state.slug!='defer':
raise Http404
telechat_date = TelechatDate.objects.active().order_by("date")[0].date
if request.method == 'POST':
do_undefer_ballot(request,doc)
return HttpResponseRedirect(doc.get_absolute_url())
return render(request, 'doc/ballot/undefer_ballot.html',
dict(doc=doc,
telechat_date=telechat_date,
back_url=doc.get_absolute_url()))
class LastCallTextForm(forms.Form):
last_call_text = forms.CharField(widget=forms.Textarea, required=True, strip=False)
def clean_last_call_text(self):
lines = self.cleaned_data["last_call_text"].split("\r\n")
for l, next in zip(lines, lines[1:]):
if l.startswith('Subject:') and next.strip():
raise forms.ValidationError("Subject line appears to have a line break, please make sure there is no line breaks in the subject line and that it is followed by an empty line.")
return self.cleaned_data["last_call_text"].replace("\r", "")
@role_required('Area Director','Secretariat')
def lastcalltext(request, name):
"""Editing of the last call text"""
doc = get_object_or_404(Document, name=name)
if not doc.get_state("draft-iesg"):
raise Http404
login = request.user.person
existing = doc.latest_event(WriteupDocEvent, type="changed_last_call_text")
if not existing:
existing = generate_last_call_announcement(request, doc)
form = LastCallTextForm(initial=dict(last_call_text=escape(existing.text)))
if request.method == 'POST':
if "save_last_call_text" in request.POST or "send_last_call_request" in request.POST:
form = LastCallTextForm(request.POST)
if form.is_valid():
t = form.cleaned_data['last_call_text']
if t != existing.text:
e = WriteupDocEvent(doc=doc, rev=doc.rev, by=login)
e.by = login
e.type = "changed_last_call_text"
e.desc = "Last call announcement was changed"
e.text = t
e.save()
elif existing.pk == None:
existing.save()
if "send_last_call_request" in request.POST:
prev_state = doc.get_state("draft-iesg")
new_state = State.objects.get(used=True, type="draft-iesg", slug='lc-req')
prev_tags = doc.tags.filter(slug__in=IESG_SUBSTATE_TAGS)
doc.set_state(new_state)
doc.tags.remove(*prev_tags)
events = []
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
if events:
doc.save_with_history(events)
request_last_call(request, doc)
return render(request, 'doc/draft/last_call_requested.html',
dict(doc=doc))
if "regenerate_last_call_text" in request.POST:
e = generate_last_call_announcement(request, doc)
e.save()
# make sure form has the updated text
form = LastCallTextForm(initial=dict(last_call_text=escape(e.text)))
s = doc.get_state("draft-iesg")
can_request_last_call = s.order < 27
can_make_last_call = s.order < 20
need_intended_status = ""
if not doc.intended_std_level:
need_intended_status = doc.file_tag()
return render(request, 'doc/ballot/lastcalltext.html',
dict(doc=doc,
back_url=doc.get_absolute_url(),
last_call_form=form,
can_request_last_call=can_request_last_call,
can_make_last_call=can_make_last_call,
need_intended_status=need_intended_status,
))
class BallotWriteupForm(forms.Form):
ballot_writeup = forms.CharField(widget=forms.Textarea, required=True, strip=False)
def clean_ballot_writeup(self):
return self.cleaned_data["ballot_writeup"].replace("\r", "")
@role_required('Area Director','Secretariat')
def ballot_writeupnotes(request, name):
"""Editing of ballot write-up and notes"""
doc = get_object_or_404(Document, name=name)
prev_state = doc.get_state("draft-iesg")
login = request.user.person
existing = doc.latest_event(WriteupDocEvent, type="changed_ballot_writeup_text")
if not existing:
existing = generate_ballot_writeup(request, doc)
form = BallotWriteupForm(initial=dict(ballot_writeup=escape(existing.text)))
if request.method == 'POST' and "save_ballot_writeup" in request.POST or "issue_ballot" in request.POST:
form = BallotWriteupForm(request.POST)
if form.is_valid():
if prev_state.slug in ['ann', 'approved', 'rfcqueue', 'pub']:
ballot_already_approved = True
else:
ballot_already_approved = False
t = form.cleaned_data["ballot_writeup"]
if t != existing.text:
e = WriteupDocEvent(doc=doc, rev=doc.rev, by=login)
e.by = login
e.type = "changed_ballot_writeup_text"
e.desc = "Ballot writeup was changed"
e.text = t
e.save()
elif existing.pk == None:
existing.save()
if "issue_ballot" in request.POST and not ballot_already_approved:
if prev_state.slug in ['watching', 'writeupw', 'goaheadw']:
new_state = State.objects.get(used=True, type="draft-iesg", slug='iesg-eva')
prev_tags = doc.tags.filter(slug__in=IESG_SUBSTATE_TAGS)
doc.set_state(new_state)
doc.tags.remove(*prev_tags)
events = []
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
if events:
doc.save_with_history(events)
if not ballot_already_approved:
e = create_ballot_if_not_open(request, doc, login, "approve") # pyflakes:ignore
ballot = doc.latest_event(BallotDocEvent, type="created_ballot")
if has_role(request.user, "Area Director") and not doc.latest_event(BallotPositionDocEvent, balloter=login, ballot=ballot):
# sending the ballot counts as a yes
pos = BallotPositionDocEvent(doc=doc, rev=doc.rev, by=login)
pos.ballot = ballot
pos.type = "changed_ballot_position"
pos.balloter = login
pos.pos_id = "yes"
pos.desc = "[Ballot Position Update] New position, %s, has been recorded for %s" % (pos.pos.name, pos.balloter.plain_name())
pos.save()
# Consider mailing this position to 'iesg_ballot_saved'
approval = doc.latest_event(WriteupDocEvent, type="changed_ballot_approval_text")
if not approval:
approval = generate_approval_mail(request, doc)
approval.save()
msg = generate_issue_ballot_mail(request, doc, ballot)
addrs = gather_address_lists('iesg_ballot_issued',doc=doc).as_strings()
override = {'To':addrs.to}
if addrs.cc:
override['CC'] = addrs.cc
send_mail_preformatted(request, msg, override=override)
addrs = gather_address_lists('ballot_issued_iana',doc=doc).as_strings()
override={ "To": addrs.to, "Bcc": None , "Reply-To": [], "CC": addrs.cc or None }
send_mail_preformatted(request, msg, extra=extra_automation_headers(doc), override=override)
e = DocEvent(doc=doc, rev=doc.rev, by=login)
e.by = login
e.type = "sent_ballot_announcement"
e.desc = "Ballot has been issued"
e.save()
return render(request, 'doc/ballot/ballot_issued.html',
dict(doc=doc,
back_url=doc.get_absolute_url()))
need_intended_status = ""
if not doc.intended_std_level:
need_intended_status = doc.file_tag()
return render(request, 'doc/ballot/writeupnotes.html',
dict(doc=doc,
back_url=doc.get_absolute_url(),
ballot_issued=bool(doc.latest_event(type="sent_ballot_announcement")),
ballot_issue_danger=bool(prev_state.slug in ['ad-eval', 'lc']),
ballot_writeup_form=form,
need_intended_status=need_intended_status,
))
class BallotRfcEditorNoteForm(forms.Form):
rfc_editor_note = forms.CharField(widget=forms.Textarea, label="RFC Editor Note", required=True, strip=False)
def clean_rfc_editor_note(self):
return self.cleaned_data["rfc_editor_note"].replace("\r", "")
@role_required('Area Director','Secretariat','IAB Chair','IRTF Chair','ISE')
def ballot_rfceditornote(request, name):
"""Editing of RFC Editor Note"""
doc = get_object_or_404(Document, name=name)
if not is_authorized_in_doc_stream(request.user, doc):
permission_denied(request, "You do not have the necessary permissions to change the RFC Editor Note for this document")
login = request.user.person
existing = doc.latest_event(WriteupDocEvent, type="changed_rfc_editor_note_text")
if not existing or (existing.text == ""):
existing = generate_ballot_rfceditornote(request, doc)
form = BallotRfcEditorNoteForm(auto_id=False, initial=dict(rfc_editor_note=escape(existing.text)))
if request.method == 'POST' and "save_ballot_rfceditornote" in request.POST:
form = BallotRfcEditorNoteForm(request.POST)
if form.is_valid():
t = form.cleaned_data["rfc_editor_note"]
if t != existing.text:
e = WriteupDocEvent(doc=doc, rev=doc.rev, by=login)
e.by = login
e.type = "changed_rfc_editor_note_text"
e.desc = f"RFC Editor Note was changed to \n{t}"
e.text = t.rstrip()
e.save()
if doc.get_state_slug('draft-iesg') in ['approved', 'ann', 'rfcqueue']:
(to, cc) = gather_address_lists('ballot_ednote_changed_late').as_strings()
msg = render_to_string(
'doc/ballot/ednote_changed_late.txt',
context = dict(
to = to,
cc = cc,
event = e,
settings = settings,
)
)
send_mail_preformatted(request, msg)
return redirect('ietf.doc.views_doc.document_writeup', name=doc.name)
if request.method == 'POST' and "clear_ballot_rfceditornote" in request.POST:
e = WriteupDocEvent(doc=doc, rev=doc.rev, by=login)
e.by = login
e.type = "changed_rfc_editor_note_text"
e.desc = "RFC Editor Note was cleared"
e.text = ""
e.save()
# make sure form shows a blank RFC Editor Note
form = BallotRfcEditorNoteForm(initial=dict(rfc_editor_note=" "))
return render(request, 'doc/ballot/rfceditornote.html',
dict(doc=doc,
back_url=doc.get_absolute_url(),
ballot_rfceditornote_form=form,
))
class ApprovalTextForm(forms.Form):
approval_text = forms.CharField(widget=forms.Textarea, required=True, strip=False)
def clean_approval_text(self):
return self.cleaned_data["approval_text"].replace("\r", "")
@role_required('Area Director','Secretariat')
def ballot_approvaltext(request, name):
"""Editing of approval text"""
doc = get_object_or_404(Document, name=name)
if not doc.get_state("draft-iesg"):
raise Http404
login = request.user.person
existing = doc.latest_event(WriteupDocEvent, type="changed_ballot_approval_text")
if not existing:
existing = generate_approval_mail(request, doc)
form = ApprovalTextForm(initial=dict(approval_text=escape(existing.text)))
if request.method == 'POST':
if "save_approval_text" in request.POST:
form = ApprovalTextForm(request.POST)
if form.is_valid():
t = form.cleaned_data['approval_text']
if t != existing.text:
e = WriteupDocEvent(doc=doc, rev=doc.rev, by=login)
e.by = login
e.type = "changed_ballot_approval_text"
e.desc = "Ballot approval text was changed"
e.text = t
e.save()
elif existing.pk == None:
existing.save()
if "regenerate_approval_text" in request.POST:
e = generate_approval_mail(request, doc)
e.save()
# make sure form has the updated text
form = ApprovalTextForm(initial=dict(approval_text=escape(e.text)))
can_announce = doc.get_state("draft-iesg").order > 19
need_intended_status = ""
if not doc.intended_std_level:
need_intended_status = doc.file_tag()
return render(request, 'doc/ballot/approvaltext.html',
dict(doc=doc,
back_url=doc.get_absolute_url(),
approval_text_form=form,
can_announce=can_announce,
need_intended_status=need_intended_status,
))
@role_required('Secretariat')
def approve_ballot(request, name):
"""Approve ballot, sending out announcement, changing state."""
doc = get_object_or_404(Document, name=name)
if not doc.get_state("draft-iesg"):
raise Http404
login = request.user.person
approval_mail_event = doc.latest_event(WriteupDocEvent, type="changed_ballot_approval_text")
if not approval_mail_event:
approval_mail_event = generate_approval_mail(request, doc)
approval_text = approval_mail_event.text
ballot_writeup_event = doc.latest_event(WriteupDocEvent, type="changed_ballot_writeup_text")
if not ballot_writeup_event:
ballot_writeup_event = generate_ballot_writeup(request, doc)
ballot_writeup = ballot_writeup_event.text
error_duplicate_rfc_editor_note = False
e = doc.latest_event(WriteupDocEvent, type="changed_rfc_editor_note_text")
if e and (e.text != ""):
if "RFC Editor Note" in ballot_writeup:
error_duplicate_rfc_editor_note = True
ballot_writeup += "\n\n" + e.text
if error_duplicate_rfc_editor_note:
return render(request, 'doc/draft/rfceditor_note_duplicate_error.html', {'doc': doc})
if "NOT be published" in approval_text:
action = "do_not_publish"
elif "To: RFC Editor" in approval_text:
action = "to_rfc_editor"
else:
action = "to_announcement_list"
# NOTE: according to Michelle Cotton <michelle.cotton@icann.org>
# (as per 2011-10-24) IANA is scraping these messages for
# information so would like to know beforehand if the format
# changes
announcement = approval_text + "\n\n" + ballot_writeup
if request.method == 'POST':
if action == "do_not_publish":
new_state = State.objects.get(used=True, type="draft-iesg", slug="dead")
else:
new_state = State.objects.get(used=True, type="draft-iesg", slug="ann")
prev_state = doc.get_state("draft-iesg")
prev_tags = doc.tags.filter(slug__in=IESG_SUBSTATE_TAGS)
events = []
if approval_mail_event.pk == None:
approval_mail_event.save()
if ballot_writeup_event.pk == None:
ballot_writeup_event.save()
if new_state.slug == "ann" and new_state.slug != prev_state.slug:
# start by notifying the RFC Editor
import ietf.sync.rfceditor
response, error = ietf.sync.rfceditor.post_approved_draft(settings.RFC_EDITOR_SYNC_NOTIFICATION_URL, doc.name)
if error:
return render(request, 'doc/draft/rfceditor_post_approved_draft_failed.html',
dict(name=doc.name,
response=response,
error=error))
doc.set_state(new_state)
doc.tags.remove(*prev_tags)
# fixup document
close_open_ballots(doc, login)
e = DocEvent(doc=doc, rev=doc.rev, by=login)
if action == "do_not_publish":
e.type = "iesg_disapproved"
e.desc = "Do Not Publish note has been sent to the RFC Editor"
else:
e.type = "iesg_approved"
e.desc = "IESG has approved the document"
e.save()
events.append(e)
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
doc.save_with_history(events)
# send announcement
send_mail_preformatted(request, announcement)
if action == "to_announcement_list":
addrs = gather_address_lists('ballot_approved_ietf_stream_iana').as_strings(compact=False)
send_mail_preformatted(request, announcement, extra=extra_automation_headers(doc),
override={ "To": addrs.to, "CC": addrs.cc, "Bcc": None, "Reply-To": []})
msg = infer_message(announcement)
msg.by = login
msg.save()
msg.related_docs.add(doc)
downrefs = [rel for rel in doc.relateddocument_set.all() if rel.is_downref() and not rel.is_approved_downref()]
if not downrefs:
return HttpResponseRedirect(doc.get_absolute_url())
else:
return HttpResponseRedirect(doc.get_absolute_url()+'edit/approvedownrefs/')
return render(request, 'doc/ballot/approve_ballot.html',
dict(doc=doc,
action=action,
announcement=announcement))
class ApproveDownrefsForm(forms.Form):
checkboxes = ModelMultipleChoiceField(
widget = forms.CheckboxSelectMultiple,
queryset = RelatedDocument.objects.none(), )
def __init__(self, queryset, *args, **kwargs):
super(ApproveDownrefsForm, self).__init__(*args, **kwargs)
self.fields['checkboxes'].queryset = queryset
def clean(self):
if 'checkboxes' not in self.cleaned_data:
raise forms.ValidationError("No RFCs were selected")
@role_required('Secretariat')
def approve_downrefs(request, name):
"""Document ballot was just approved; add the checked downwared references to the downref registry."""
doc = get_object_or_404(Document, name=name)
if not doc.get_state("draft-iesg"):
raise Http404
login = request.user.person
downrefs_to_rfc = [
rel
for rel in doc.relateddocument_set.all()
if rel.is_downref()
and not rel.is_approved_downref()
and rel.target.type_id == "rfc"
]
downrefs_to_rfc_qs = RelatedDocument.objects.filter(pk__in=[r.pk for r in downrefs_to_rfc])
last_call_text = doc.latest_event(WriteupDocEvent, type="changed_last_call_text").text.strip()
if request.method == 'POST':
form = ApproveDownrefsForm(downrefs_to_rfc_qs, request.POST)
if form.is_valid():
for rel in form.cleaned_data['checkboxes']:
RelatedDocument.objects.create(source=rel.source,
target=rel.target, relationship_id='downref-approval')
c = DocEvent(type="downref_approved", doc=rel.source,
rev=rel.source.rev, by=login)
c.desc = "Downref to RFC %s approved by Last Call for %s-%s" % (
rel.target.rfc_number, rel.source, rel.source.rev)
c.save()
c = DocEvent(type="downref_approved", doc=rel.target,
rev=rel.target.rev, by=login)
c.desc = "Downref to RFC %s approved by Last Call for %s-%s" % (
rel.target.rfc_number, rel.source, rel.source.rev)
c.save()
return HttpResponseRedirect(doc.get_absolute_url())
else:
form = ApproveDownrefsForm(downrefs_to_rfc_qs)
return render(request, 'doc/ballot/approve_downrefs.html',
dict(doc=doc,
approve_downrefs_form=form,
last_call_text=last_call_text,
downrefs_to_rfc=downrefs_to_rfc))
class MakeLastCallForm(forms.Form):
last_call_sent_date = forms.DateField(required=True)
last_call_expiration_date = forms.DateField(required=True)
@role_required('Secretariat')
def make_last_call(request, name):
"""Make last call for Internet-Draft, sending out announcement."""
doc = get_object_or_404(Document, name=name)
if not (doc.get_state("draft-iesg") or doc.get_state("statchg")):
raise Http404
login = request.user.person
announcement_event = doc.latest_event(WriteupDocEvent, type="changed_last_call_text")
if not announcement_event:
if doc.type_id != 'draft':
raise Http404
announcement_event = generate_last_call_announcement(request, doc)
announcement = announcement_event.text
if request.method == 'POST':
form = MakeLastCallForm(request.POST)
if form.is_valid():
if announcement_event.pk == None:
announcement_event.save()
send_mail_preformatted(request, announcement)
if doc.type.slug in ("draft", "statchg"):
addrs = gather_address_lists('last_call_issued_iana',doc=doc).as_strings(compact=False)
send_mail_preformatted(request, announcement, extra=extra_automation_headers(doc),
override={ "To": addrs.to, "CC": addrs.cc, "Bcc": None, "Reply-To": []})
msg = infer_message(announcement)
msg.by = login
msg.save()
msg.related_docs.add(doc)
new_state = doc.get_state()
prev_tags = []
new_tags = []
events = []
if doc.type.slug == 'draft':
new_state = State.objects.get(used=True, type="draft-iesg", slug='lc')
prev_tags = doc.tags.filter(slug__in=IESG_SUBSTATE_TAGS)
elif doc.type.slug == 'statchg':
new_state = State.objects.get(used=True, type="statchg", slug='in-lc')
prev_state = doc.get_state(new_state.type_id)
doc.set_state(new_state)
doc.tags.remove(*prev_tags)
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
expiration_date = form.cleaned_data['last_call_expiration_date']
e = LastCallDocEvent(doc=doc, rev=doc.rev, by=login)
e.type = "sent_last_call"
e.desc = "The following Last Call announcement was sent out (ends %s):<br><br>" % expiration_date
e.desc += announcement
e_production_time = e.time.astimezone(DEADLINE_TZINFO)
if form.cleaned_data['last_call_sent_date'] != e_production_time.date():
lcsd = form.cleaned_data['last_call_sent_date']
e.time = e_production_time.replace(year=lcsd.year, month=lcsd.month, day=lcsd.day) # preserves tzinfo
e.expires = datetime_from_date(expiration_date, DEADLINE_TZINFO)
e.save()
events.append(e)
# update IANA Review state
if doc.type.slug == 'draft':
prev_state = doc.get_state("draft-iana-review")
if not prev_state:
next_state = State.objects.get(used=True, type="draft-iana-review", slug="need-rev")
doc.set_state(next_state)
e = add_state_change_event(doc, login, prev_state, next_state)
if e:
events.append(e)
doc.save_with_history(events)
sub = doc.submission()
if sub and sub.has_yang():
email_lc_to_yang_doctors(request, doc)
return HttpResponseRedirect(doc.get_absolute_url())
else:
initial = {}
initial["last_call_sent_date"] = date_today()
if doc.type.slug == 'draft':
# This logic is repeated in the code that edits last call text - why?
expire_days = 14
if doc.group.type_id in ("individ", "area"):
expire_days = 28
templ = 'doc/draft/make_last_call.html'
else:
expire_days=28
templ = 'doc/status_change/make_last_call.html'
initial["last_call_expiration_date"] = date_today() + datetime.timedelta(days=expire_days)
form = MakeLastCallForm(initial=initial)
return render(request, templ,
dict(doc=doc,
form=form,
announcement=announcement,
))
@role_required('Secretariat', 'IRTF Chair')
def issue_irsg_ballot(request, name):
doc = get_object_or_404(Document, name=name)
if doc.stream.slug != "irtf" or doc.type != DocTypeName.objects.get(slug="draft"):
raise Http404
by = request.user.person
fillerdate = date_today(DEADLINE_TZINFO) + datetime.timedelta(weeks=2)
if request.method == 'POST':
button = request.POST.get("irsg_button")
if button == 'Yes':
duedate = request.POST.get("duedate")
e = IRSGBallotDocEvent(doc=doc, rev=doc.rev, by=request.user.person)
if (duedate == None or duedate==""):
duedate = str(fillerdate)
e.duedate = datetime_from_date(datetime.datetime.strptime(duedate, '%Y-%m-%d'), DEADLINE_TZINFO)
e.type = "created_ballot"
e.desc = "Created IRSG Ballot"
ballot_type = BallotType.objects.get(doc_type=doc.type, slug="irsg-approve")
e.ballot_type = ballot_type
e.save()
new_state = doc.get_state()
prev_tags = []
new_tags = []
email_irsg_ballot_issued(request, doc, ballot=e) # Send notification email
if doc.type_id == 'draft':
new_state = State.objects.get(used=True, type="draft-stream-irtf", slug='irsgpoll')
prev_state = doc.get_state(new_state.type_id if new_state else None)
doc.set_state(new_state)
doc.tags.remove(*prev_tags)
events = []
e = add_state_change_event(doc, by, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
if events:
doc.save_with_history(events)
return HttpResponseRedirect(doc.get_absolute_url())
else:
templ = 'doc/ballot/irsg_ballot_approve.html'
question = "Confirm issuing a ballot for " + name + "?"
return render(request, templ, dict(doc=doc,
question=question, fillerdate=fillerdate))
@role_required('Secretariat', 'IRTF Chair')
def close_irsg_ballot(request, name):
doc = get_object_or_404(Document, name=name)
if doc.stream.slug != "irtf" or doc.type != DocTypeName.objects.get(slug="draft"):
raise Http404
by = request.user.person
if request.method == 'POST':
button = request.POST.get("irsg_button")
if button == 'Yes':
ballot = close_ballot(doc, by, "irsg-approve")
email_irsg_ballot_closed(request,
doc=doc,
ballot=IRSGBallotDocEvent.objects.get(pk=ballot.pk))
return HttpResponseRedirect(doc.get_absolute_url())
templ = 'doc/ballot/irsg_ballot_close.html'
question = "Confirm closing the ballot for " + name + "?"
return render(request, templ, dict(doc=doc,
question=question))
def irsg_ballot_status(request):
possible_docs = Document.objects.filter(docevent__ballotdocevent__irsgballotdocevent__isnull=False)
docs = []
for doc in possible_docs:
if doc.ballot_open("irsg-approve"):
ballot = doc.active_ballot()
if ballot:
doc.ballot = ballot
doc.duedate=datetime.datetime.strftime(
ballot.irsgballotdocevent.duedate.astimezone(DEADLINE_TZINFO),
'%Y-%m-%d',
)
docs.append(doc)
return render(request, 'doc/irsg_ballot_status.html', {'docs':docs})
@role_required('Secretariat', 'RSAB Chair')
def issue_rsab_ballot(request, name):
doc = get_object_or_404(Document, name=name)
if doc.stream.slug != "editorial" or doc.type != DocTypeName.objects.get(slug="draft"):
raise Http404
by = request.user.person
if request.method == 'POST':
button = request.POST.get("rsab_button") # TODO: Really? There's an irsg button? The templates should be generalized.
if button == 'Yes':
e = BallotDocEvent(doc=doc, rev=doc.rev, by=request.user.person)
e.type = "created_ballot"
e.desc = "Created RSAB Ballot"
ballot_type = BallotType.objects.get(doc_type=doc.type, slug="rsab-approve")
e.ballot_type = ballot_type
e.save()
new_state = doc.get_state()
prev_tags = []
new_tags = []
email_rsab_ballot_issued(request, doc, ballot=e) # Send notification email
if doc.type_id == 'draft':
new_state = State.objects.get(used=True, type="draft-stream-editorial", slug='rsabpoll')
prev_state = doc.get_state(new_state.type_id if new_state else None)
doc.set_state(new_state)
doc.tags.remove(*prev_tags)
events = []
e = add_state_change_event(doc, by, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
if events:
doc.save_with_history(events)
return HttpResponseRedirect(doc.get_absolute_url())
else:
templ = 'doc/ballot/rsab_ballot_approve.html'
question = "Confirm issuing a ballot for " + name + "?"
return render(request, templ, dict(doc=doc, question=question))
@role_required('Secretariat', 'RSAB Chair')
def close_rsab_ballot(request, name):
doc = get_object_or_404(Document, name=name)
if doc.stream.slug != "editorial" or doc.type_id != "draft":
raise Http404
by = request.user.person
if request.method == 'POST':
button = request.POST.get("rsab_button")
if button == 'Yes':
ballot = close_ballot(doc, by, "rsab-approve")
email_rsab_ballot_closed(
request,
doc=doc,
ballot=BallotDocEvent.objects.get(pk=ballot.pk)
)
return HttpResponseRedirect(doc.get_absolute_url())
templ = 'doc/ballot/rsab_ballot_close.html'
question = "Confirm closing the ballot for " + name + "?"
return render(request, templ, dict(doc=doc, question=question))
def rsab_ballot_status(request):
possible_docs = Document.objects.filter(docevent__ballotdocevent__isnull=False)
docs = []
for doc in possible_docs:
if doc.ballot_open("rsab-approve"):
ballot = doc.active_ballot()
if ballot:
doc.ballot = ballot
docs.append(doc)
return render(request, 'doc/rsab_ballot_status.html', {'docs':docs})
# Possible TODO: add a menu item to show this? Maybe only if you're in rsab or an rswg chair?
# There will be so few of these that the general community would follow them from the rswg docs page.
# Maybe the view isn't actually needed at all...