datatracker/ietf/doc/views_conflict_review.py
Robert Sparks 7d464fcc55
fix: keep the ftp dir in sync when the datatracker creates artifacts (#8401)
* chore: fix typo in log message

* fix: write new conflict reviews to the ftp directory

* fix: log exception description

* chore: avoid reusing a name for mypy

* fix: write non-meeting-related slides to ftp/slides

* fix: write status changes to ftp dir

* fix: copy draft yang modules to the ftp dir

the last commit above (see #8401) has no tests :(. There aren't tests of this yang scaffolding to add to. Adding a set of tests should be an independent larger effort.
2025-01-09 11:23:20 -06:00

582 lines
25 KiB
Python

# Copyright The IETF Trust 2012-2023, All Rights Reserved
# -*- coding: utf-8 -*-
import datetime
import io
import os
from pathlib import Path
from django import forms
from django.shortcuts import render, get_object_or_404, redirect
from django.http import HttpResponseRedirect, Http404
from django.urls import reverse
from django.template.loader import render_to_string
from django.conf import settings
from django.utils.html import escape
import debug # pyflakes:ignore
from ietf.doc.models import ( BallotDocEvent, BallotPositionDocEvent, DocEvent,
Document, NewRevisionDocEvent, State )
from ietf.doc.utils import ( add_state_change_event, close_open_ballots,
create_ballot_if_not_open, update_telechat )
from ietf.doc.mails import email_iana, email_ad_approved_conflict_review
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.name.models import DocTagName
from ietf.person.models import Person
from ietf.utils import log
from ietf.utils.mail import send_mail_preformatted
from ietf.utils.textupload import get_cleaned_text_file_content
from ietf.mailtrigger.utils import gather_address_lists
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, strip=False)
def __init__(self, *args, **kwargs):
user = kwargs.pop("user")
super(ChangeStateForm, self).__init__(*args, **kwargs)
if not has_role(user, "Secretariat"):
self.fields["review_state"].queryset = self.fields["review_state"].queryset.exclude(slug__in=("appr-reqnopub-sent","appr-noprob-sent"))
@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, user=request.user)
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, rev=review.rev, 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":
e = create_ballot_if_not_open(request, review, login, "conflrev") # pyflakes:ignore
ballot = review.latest_event(BallotDocEvent, type="created_ballot")
log.assertion('ballot == e')
if has_role(request.user, "Area Director") and not review.latest_event(BallotPositionDocEvent, balloter=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, rev=review.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 that position to 'iesg_ballot_saved'
send_conflict_eval_email(request,review)
elif (new_state.slug in ("appr-reqnopub-pend", "appr-noprob-pend")
and has_role(request.user, "Area Director")):
if new_state.slug == "appr-noprob-pend":
ok_to_publish = True
else:
ok_to_publish = False
email_ad_approved_conflict_review(request,
review,
ok_to_publish)
if new_state.slug in ["appr-reqnopub-sent", "appr-noprob-sent", "withdraw", "dead"]:
doc = review.related_that_doc("conflrev")[0]
update_stream_state(doc, login, 'chair-w' if doc.stream_id=='irtf' else 'ise-rev', 'iesg-com')
return redirect('ietf.doc.views_doc.document_main', name=review.name)
else:
s = review.get_state()
init = dict(review_state=s.pk if s else None)
form = ChangeStateForm(initial=init, user=request.user)
return render(request, 'doc/change_state.html',
dict(form=form,
doc=review,
login=login,
help_url=reverse('ietf.doc.views_help.state_help', kwargs=dict(type="conflict-review")),
))
def send_conflict_review_ad_changed_email(request, review, event):
addrs = gather_address_lists('conflrev_ad_changed', doc=review).as_strings(compact=False)
msg = render_to_string("doc/conflict_review/changed_ad.txt",
dict(frm = settings.DEFAULT_FROM_EMAIL,
to = addrs.to,
cc = addrs.cc,
by = request.user.person,
event = event,
review = review,
reviewed_doc = review.relateddocument_set.get(relationship__slug='conflrev').target,
review_url = settings.IDTRACKER_BASE_URL+review.get_absolute_url(),
)
)
send_mail_preformatted(request,msg)
def send_conflict_review_started_email(request, review):
addrs = gather_address_lists('conflrev_requested',doc=review).as_strings(compact=False)
msg = render_to_string("doc/conflict_review/review_started.txt",
dict(frm = settings.DEFAULT_FROM_EMAIL,
to = addrs.to,
cc = addrs.cc,
by = request.user.person,
review = review,
reviewed_doc = review.relateddocument_set.get(relationship__slug='conflrev').target,
review_url = settings.IDTRACKER_BASE_URL+review.get_absolute_url(),
)
)
if not has_role(request.user,"Secretariat"):
send_mail_preformatted(request,msg)
addrs = gather_address_lists('conflrev_requested_iana',doc=review).as_strings(compact=False)
email_iana(request,
review.relateddocument_set.get(relationship__slug='conflrev').target,
addrs.to,
msg,
cc=addrs.cc)
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(),
)
)
addrs = gather_address_lists('iesg_ballot_issued',doc=review).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=review).as_strings()
email_iana(request,
review.relateddocument_set.get(relationship__slug='conflrev').target,
addrs.to,
msg,
addrs.cc)
class UploadForm(forms.Form):
content = forms.CharField(widget=forms.Textarea, label="Conflict review response", help_text="Edit the conflict review response.", required=False, strip=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):
basename = f"{review.name}-{review.rev}.txt"
filepath = Path(settings.CONFLICT_REVIEW_PATH) / basename
with filepath.open('w', encoding='utf-8') as destination:
if self.cleaned_data['txt']:
destination.write(self.cleaned_data['txt'])
else:
destination.write(self.cleaned_data['content'])
ftp_filepath = Path(settings.FTP_DIR) / "conflict-reviews" / basename
try:
os.link(filepath, ftp_filepath) # Path.hardlink_to is not available until 3.10
except IOError as e:
log.log(
"There was an error creating a hardlink at %s pointing to %s: %s"
% (ftp_filepath, filepath, e)
)
#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.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.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('ietf.doc.views_doc.document_main', name=review.name)
elif "reset_text" in request.POST:
init = { "content": escape(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"] = escape(render_to_string("doc/conflict_review/review_choices.txt",
dict(),
))
else:
filename = os.path.join(settings.CONFLICT_REVIEW_PATH, '%s-%s.txt' % (review.name, review.rev))
try:
with io.open(filename, 'r') as f:
init["content"] = f.read()
except IOError:
pass
form = UploadForm(initial=init)
return render(request, 'doc/conflict_review/submit.html',
{'form': form,
'next_rev': next_rev,
'review' : review,
'conflictdoc' : review.relateddocument_set.get(relationship__slug='conflrev').target,
})
@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, rev=review.rev, by=request.user.person)
c.desc = "Shepherding AD changed to "+review.ad.name
c.save()
review.save_with_history([c])
send_conflict_review_ad_changed_email(request, review, c)
return redirect('ietf.doc.views_doc.document_main', name=review.name)
else:
init = { "ad" : review.ad_id }
form = AdForm(initial=init)
conflictdoc = review.relateddocument_set.get(relationship__slug='conflrev').target
titletext = 'the conflict review of %s-%s' % (conflictdoc.name,conflictdoc.rev)
return render(request, 'doc/change_ad.html',
{'form': form,
'doc': review,
'titletext': titletext
},
)
def default_approval_text(review):
current_text = review.text_or_error() # pyflakes:ignore
conflictdoc = review.relateddocument_set.get(relationship__slug='conflrev').target
if conflictdoc.stream_id=='ise':
receiver = 'Independent Submissions Editor'
elif conflictdoc.stream_id=='irtf':
receiver = 'IRTF'
else:
receiver = 'recipient'
addrs = gather_address_lists('ballot_approved_conflrev',doc=review).as_strings(compact=False)
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,
to = addrs.to,
cc = addrs.cc,
)
)
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, strip=False)
@role_required("Secretariat")
def approve_conflict_review(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, rev=review.rev, 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, rev=review.rev, by=login)
c.desc = "The following approval message was sent\n"+form.cleaned_data['announcement_text']
c.save()
doc = review.related_that_doc("conflrev")[0]
update_stream_state(doc, login, 'chair-w' if doc.stream_id=='irtf' else 'ise-rev', 'iesg-com')
return HttpResponseRedirect(review.get_absolute_url())
else:
init = { "announcement_text" : escape(default_approval_text(review)) }
form = AnnouncementForm(initial=init)
return render(request, 'doc/conflict_review/approve.html',
dict(
review = review,
conflictdoc = review.relateddocument_set.get(relationship__slug='conflrev').target,
form = form,
))
class SimpleStartReviewForm(forms.Form):
notify = forms.CharField(
widget=forms.Textarea,
max_length=1023,
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(
widget=forms.Textarea,
max_length=1023,
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 rel in doc_to_review.targets_related.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)
conflict_review.relateddocument_set.create(target=doc_to_review, relationship_id='conflrev')
c = DocEvent(type="added_comment", doc=conflict_review, rev=conflict_review.rev, by=login)
c.desc = "IETF conflict review requested"
c.save()
c = DocEvent(type="added_comment", doc=doc_to_review, rev=doc_to_review.rev, 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('ietf.doc.views_doc.document_main', 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)
update_stream_state(doc_to_review, login, 'iesg-rev')
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" : ', '.join(notify_addresses),
}
form = StartReviewForm(initial=init)
return render(request, 'doc/conflict_review/start.html',
{'form': form,
'doc_to_review': doc_to_review,
},
)
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)
update_stream_state(doc_to_review, login, 'iesg-rev')
return HttpResponseRedirect(conflict_review.get_absolute_url())
else:
notify_addresses = build_notify_addresses(doc_to_review)
init = {
"notify" : ', '.join(notify_addresses),
}
form = SimpleStartReviewForm(initial=init)
return render(request, 'doc/conflict_review/start.html',
{'form': form,
'doc_to_review': doc_to_review,
},
)
def update_stream_state(doc, by, state, tag=None):
statetype = 'draft-stream-' + doc.stream_id
prev_state = doc.get_state(statetype)
new_state = State.objects.get(type_id=statetype, slug=state)
if tag:
prev_tags = set(doc.tags.all())
new_tags = set(DocTagName.objects.filter(pk=tag))
if new_state != prev_state:
doc.set_state(new_state)
events = []
if tag:
doc.tags.clear()
doc.tags.set(new_tags)
events.append(add_state_change_event(doc, by, prev_state, new_state, prev_tags, new_tags))
else:
events.append(add_state_change_event(doc, by, prev_state, new_state))
doc.save_with_history(events)