Repository: ietf-tools/datatracker

Issue:
Area Directors should not be able to set the "Announcement Sent" for a document
The secretariat manages sending announcements of IESG approval of documents. When that announcement is sent, state machines move to the "announcement sent" state.

Right now, a well meaning AD could set the state directly (through several points where the state of the document can be edited). Doing so _skips_ sending the announcement (and many other side-effects of the secretariat's processing).

This can result in documents being delayed for a long time until the disconnect is discovered and repaired.

Instead, ADs should not be able to set this state directly.


Discussion:
cc: @sginoza @cindymorgan

Resolving patch:
diff --git a/ietf/doc/views_conflict_review.py b/ietf/doc/views_conflict_review.py
--- a/ietf/doc/views_conflict_review.py
+++ b/ietf/doc/views_conflict_review.py
@@ -1,4 +1,4 @@
-# Copyright The IETF Trust 2012-2020, All Rights Reserved
+# Copyright The IETF Trust 2012-2023, All Rights Reserved
 # -*- coding: utf-8 -*-
 
 
@@ -35,6 +35,12 @@ 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
@@ -44,7 +50,7 @@ def change_state(request, name, option=None):
     login = request.user.person
 
     if request.method == 'POST':
-        form = ChangeStateForm(request.POST)
+        form = ChangeStateForm(request.POST, user=request.user)
         if form.is_valid():
             clean = form.cleaned_data
             new_state = clean['review_state']
@@ -95,7 +101,7 @@ def change_state(request, name, option=None):
     else:
         s = review.get_state()
         init = dict(review_state=s.pk if s else None)
-        form = ChangeStateForm(initial=init)
+        form = ChangeStateForm(initial=init, user=request.user)
 
     return render(request, 'doc/change_state.html',
                               dict(form=form,
diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py
--- a/ietf/doc/views_draft.py
+++ b/ietf/doc/views_draft.py
@@ -1,4 +1,4 @@
-# Copyright The IETF Trust 2010-2020, All Rights Reserved
+# Copyright The IETF Trust 2010-2023, All Rights Reserved
 # -*- coding: utf-8 -*-
 
 
@@ -61,6 +61,12 @@ class ChangeStateForm(forms.Form):
     substate = forms.ModelChoiceField(DocTagName.objects.filter(slug__in=IESG_SUBSTATE_TAGS), required=False)
     comment = forms.CharField(widget=forms.Textarea, 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["state"].queryset = self.fields["state"].queryset.exclude(slug="ann")
+
     def clean(self):
         retclean = self.cleaned_data
         state = self.cleaned_data.get('state', '(None)')
@@ -94,7 +100,7 @@ def change_state(request, name):
     login = request.user.person
 
     if request.method == 'POST':
-        form = ChangeStateForm(request.POST)
+        form = ChangeStateForm(request.POST, user=request.user)
         form.docname=name
 
         if form.is_valid():
@@ -175,7 +181,8 @@ def change_state(request, name):
         state = doc.get_state("draft-iesg")
         t = doc.tags.filter(slug__in=IESG_SUBSTATE_TAGS)
         form = ChangeStateForm(initial=dict(state=state.pk if state else None,
-                                            substate=t[0].pk if t else None))
+                                            substate=t[0].pk if t else None),
+                               user=request.user)
         form.docname=name
 
     state = doc.get_state("draft-iesg")
diff --git a/ietf/doc/views_status_change.py b/ietf/doc/views_status_change.py
--- a/ietf/doc/views_status_change.py
+++ b/ietf/doc/views_status_change.py
@@ -42,6 +42,12 @@ class ChangeStateForm(forms.Form):
     new_state = forms.ModelChoiceField(State.objects.filter(type="statchg", used=True), label="Status Change Evaluation 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["new_state"].queryset = self.fields["new_state"].queryset.exclude(slug="appr-sent")
+
 
 @role_required("Area Director", "Secretariat")
 def change_state(request, name, option=None):
@@ -52,7 +58,7 @@ def change_state(request, name, option=None):
     login = request.user.person
 
     if request.method == 'POST':
-        form = ChangeStateForm(request.POST)
+        form = ChangeStateForm(request.POST, user=request.user)
         if form.is_valid():
             clean = form.cleaned_data
             new_state = clean['new_state']
@@ -116,7 +122,7 @@ def change_state(request, name, option=None):
                     type='statchg',
                     label='Status Change Evaluation State',
                    )
-        form = ChangeStateForm(initial=init)
+        form = ChangeStateForm(initial=init, user=request.user)
 
     return render(request, 'doc/change_state.html',
                               dict(form=form,


Test patch:
diff --git a/ietf/doc/tests_conflict_review.py b/ietf/doc/tests_conflict_review.py
--- a/ietf/doc/tests_conflict_review.py
+++ b/ietf/doc/tests_conflict_review.py
@@ -1,4 +1,4 @@
-# Copyright The IETF Trust 2012-2020, All Rights Reserved
+# Copyright The IETF Trust 2012-2023, All Rights Reserved
 # -*- coding: utf-8 -*-
 
 
@@ -168,6 +168,21 @@ def test_change_state(self):
         self.assertTrue(review_doc.active_ballot())
         self.assertEqual(review_doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position").pos_id,'yes')
 
+        # try to change to an AD-forbidden state
+        appr_noprob_sent_pk = str(State.objects.get(used=True, slug='appr-noprob-sent',type__slug='conflrev').pk)
+        r = self.client.post(url,dict(review_state=appr_noprob_sent_pk,comment='xyzzy'))
+        self.assertEqual(r.status_code, 200)
+        q = PyQuery(r.content)
+        self.assertTrue(q('form .invalid-feedback'))
+
+        # try again as secretariat
+        self.client.logout()
+        login_testing_unauthorized(self, 'secretary', url)
+        r = self.client.post(url,dict(review_state=appr_noprob_sent_pk,comment='xyzzy'))
+        self.assertEqual(r.status_code, 302)
+        review_doc = Document.objects.get(name='conflict-review-imaginary-irtf-submission')
+        self.assertEqual(review_doc.get_state('conflrev').slug, 'appr-noprob-sent')
+
 
     def test_edit_notices(self):
         doc = Document.objects.get(name='conflict-review-imaginary-irtf-submission')
diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py
--- a/ietf/doc/tests_draft.py
+++ b/ietf/doc/tests_draft.py
@@ -98,7 +98,7 @@ def test_change_state(self):
         draft.action_holders.add(ad)
 
         url = urlreverse('ietf.doc.views_draft.change_state', kwargs=dict(name=draft.name))
-        login_testing_unauthorized(self, "secretary", url)
+        login_testing_unauthorized(self, "ad", url)
 
         first_state = draft.get_state("draft-iesg")
         next_states = first_state.next_states.all()
@@ -154,6 +154,20 @@ def test_change_state(self):
         q = PyQuery(r.content)
         self.assertEqual(len(q('form [type=submit]:contains("%s")' % first_state.name)), 1)
 
+        # try to change to an AD-forbidden state
+        r = self.client.post(url, dict(state=State.objects.get(used=True, type='draft-iesg', slug='ann').pk, comment='Test comment'))
+        self.assertEqual(r.status_code, 200)
+        q = PyQuery(r.content)
+        self.assertTrue(q('form .invalid-feedback'))
+
+        # try again as secretariat
+        self.client.logout()
+        login_testing_unauthorized(self, 'secretary', url)
+        r = self.client.post(url, dict(state=State.objects.get(used=True, type='draft-iesg', slug='ann').pk, comment='Test comment'))
+        self.assertEqual(r.status_code, 302)
+        draft = Document.objects.get(name=draft.name)
+        self.assertEqual(draft.get_state_slug('draft-iesg'), 'ann')
+
     def test_pull_from_rfc_queue(self):
         ad = Person.objects.get(user__username="ad")
         draft = WgDraftFactory(
diff --git a/ietf/doc/tests_status_change.py b/ietf/doc/tests_status_change.py
--- a/ietf/doc/tests_status_change.py
+++ b/ietf/doc/tests_status_change.py
@@ -147,6 +147,21 @@ def test_change_state(self):
         self.assertTrue(doc.active_ballot())
         self.assertEqual(doc.latest_event(BallotPositionDocEvent, type="changed_ballot_position").pos_id,'yes')
 
+        # try to change to an AD-forbidden state
+        appr_sent_pk = str(State.objects.get(used=True, slug='appr-sent',type__slug='statchg').pk)
+        r = self.client.post(url, dict(new_state=appr_sent_pk, comment='xyzzy'))
+        self.assertEqual(r.status_code, 200)
+        q = PyQuery(r.content)
+        self.assertTrue(q('form .invalid-feedback'))
+
+        # try again as secretariat
+        self.client.logout()
+        login_testing_unauthorized(self, 'secretary', url)
+        r = self.client.post(url, dict(new_state=appr_sent_pk, comment='xyzzy'))
+        self.assertEqual(r.status_code, 302)
+        doc = Document.objects.get(name='status-change-imaginary-mid-review')
+        self.assertEqual(doc.get_state('statchg').slug, 'appr-sent')
+
     def test_edit_notices(self):
         doc = Document.objects.get(name='status-change-imaginary-mid-review')
         url = urlreverse('ietf.doc.views_doc.edit_notify;status-change',kwargs=dict(name=doc.name))