From 87fc3ee36ed907dfa6821fdcfd659b2be7eff247 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Fri, 10 Oct 2025 16:29:14 -0500 Subject: [PATCH 1/3] feat: (WIP) sketch of workflow changes to allow editing call for adoption message --- ietf/doc/mails.py | 8 +- ietf/doc/templatetags/ietf_filters.py | 18 +- ietf/doc/urls.py | 4 + ietf/doc/views_doc.py | 5 +- ietf/doc/views_draft.py | 212 +++++++++++++++++- .../doc/draft/ask_about_ietf_adoption.html | 22 ++ ...issue_working_group_call_for_adoption.html | 30 +++ .../doc/mail/wg_call_for_adoption_issued.txt | 8 +- 8 files changed, 282 insertions(+), 25 deletions(-) create mode 100644 ietf/templates/doc/draft/ask_about_ietf_adoption.html create mode 100644 ietf/templates/doc/draft/issue_working_group_call_for_adoption.html diff --git a/ietf/doc/mails.py b/ietf/doc/mails.py index 904c510508d..5529dc3d810 100644 --- a/ietf/doc/mails.py +++ b/ietf/doc/mails.py @@ -104,14 +104,9 @@ def email_stream_changed(request, doc, old_stream, new_stream, text=""): url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url()), cc=cc) -def email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=None): - if cfa_duration_weeks is None: - cfa_duration_weeks=2 +def email_wg_call_for_adoption_issued(request, doc, end_date): (to, cc) = gather_address_lists("doc_wg_call_for_adoption_issued", doc=doc) frm = request.user.person.formatted_email() - - end_date = date_today(DEADLINE_TZINFO) + datetime.timedelta(days=7 * cfa_duration_weeks) - subject = f"Call for adoption: {doc.name}-{doc.rev} (Ends {end_date})" send_mail( @@ -125,7 +120,6 @@ def email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=None): subject=subject, url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(), end_date=end_date, - cfa_duration_weeks=cfa_duration_weeks, wg_list=doc.group.list_email, ), cc=cc, diff --git a/ietf/doc/templatetags/ietf_filters.py b/ietf/doc/templatetags/ietf_filters.py index 870bb4a00fd..953200b2141 100644 --- a/ietf/doc/templatetags/ietf_filters.py +++ b/ietf/doc/templatetags/ietf_filters.py @@ -1020,7 +1020,23 @@ def is_in_stream(doc): @register.filter def can_issue_ietf_call_for_adoption(doc): - return False # TODO + return all( + [ + doc.stream_id == "ietf", + doc.get_state_slug("draft-stream-ietf") + not in [ + "wg-doc", + "parked", + "dead", + "wg-lc", + "waiting-for-implementation", + "chair-w", + "writeupw", + "sub-pub", + ], + doc.became_rfc() is None, + ] + ) @register.filter def can_issue_ietf_wg_lc(doc): diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index f452c175978..fd50802fd51 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -143,7 +143,11 @@ url(r'^%(name)s/edit/shepherdemail/$' % settings.URL_REGEXPS, views_draft.change_shepherd_email), url(r'^%(name)s/edit/shepherdwriteup/$' % settings.URL_REGEXPS, views_draft.edit_shepherd_writeup), url(r'^%(name)s/edit/requestpublication/$' % settings.URL_REGEXPS, views_draft.request_publication), + url(r'^%(name)s/edit/ask-about-ietf-adoption/$' % settings.URL_REGEXPS, views_draft.ask_about_ietf_adoption_call), url(r'^%(name)s/edit/adopt/$' % settings.URL_REGEXPS, views_draft.adopt_draft), + url(r'^%(name)s/edit/issue-wg-call-for-adoption/$' % settings.URL_REGEXPS, views_draft.issue_wg_call_for_adoption), + + url(r'^%(name)s/edit/release/$' % settings.URL_REGEXPS, views_draft.release_draft), url(r'^%(name)s/edit/state/(?Pdraft-stream-[a-z]+)/$' % settings.URL_REGEXPS, views_draft.change_stream_state), url(r'^%(name)s/edit/wg-action-helpers/$' % settings.URL_REGEXPS, views_draft.offer_wg_action_helpers), diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 4a20db3c890..79d8afa6cfe 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -515,12 +515,15 @@ def document_main(request, name, rev=None, document_html=False): actions = [] if can_adopt_draft(request.user, doc) and not doc.get_state_slug() in ["rfc"] and not snapshot: + target = urlreverse("ietf.doc.views_draft.adopt_draft", kwargs=dict(name=doc.name)) if doc.group and doc.group.acronym != 'none': # individual submission # already adopted in one group button_text = "Switch adoption" else: button_text = "Manage adoption" - actions.append((button_text, urlreverse('ietf.doc.views_draft.adopt_draft', kwargs=dict(name=doc.name)))) + if has_role(request.user, ["Secretariat", "Area Director", "WG Chair"]): + target = urlreverse("ietf.doc.views_draft.ask_about_ietf_adoption_call", kwargs=dict(name=doc.name)) + actions.append((button_text, target)) if can_unadopt_draft(request.user, doc) and not doc.get_state_slug() in ["rfc"] and not snapshot: if doc.get_state_slug('draft-iesg') == 'idexists': diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index 0caa741b3e8..c709aa3fd2c 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -34,6 +34,7 @@ email_iesg_processing_document, email_ad_approved_doc, email_iana_expert_review_state_changed ) from ietf.doc.storage_utils import retrieve_bytes, store_bytes +from ietf.doc.templatetags.ietf_filters import can_issue_ietf_call_for_adoption from ietf.doc.utils import ( add_state_change_event, can_adopt_draft, can_unadopt_draft, get_tags_for_stream_id, nice_consensus, update_action_holders, update_reminder, update_telechat, make_notify_changed_event, get_initial_notify, @@ -1564,7 +1565,7 @@ def adopt_draft(request, name): events.append(e) due_date = None - if form.cleaned_data["weeks"] != None: + if form.cleaned_data["weeks"] is not None: due_date = datetime_today(DEADLINE_TZINFO) + datetime.timedelta(weeks=form.cleaned_data["weeks"]) update_reminder(doc, "stream-s", e, due_date) @@ -1573,11 +1574,6 @@ def adopt_draft(request, name): # setting states that are _not_ the adopted state. email_adopted(request, doc, prev_state, new_state, by, comment) - # Currently only the IETF stream uses the c-adopt state - guard against other - # streams starting to use it asthe IPR rules for those streams will be different. - if doc.stream_id == "ietf" and new_state.slug == "c-adopt": - email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=form.cleaned_data["weeks"]) - # comment if comment: e = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=by) @@ -1680,6 +1676,7 @@ def __init__(self, *args, **kwargs): state_type = kwargs.pop("state_type") self.can_set_sub_pub = kwargs.pop("can_set_sub_pub") self.can_set_wg_lc = kwargs.pop("can_set_wg_lc") + self.can_set_call_for_adoption = kwargs.pop("can_set_call_for_adoption") self.stream = kwargs.pop("stream") super(ChangeStreamStateForm, self).__init__(*args, **kwargs) @@ -1700,7 +1697,12 @@ def __init__(self, *args, **kwargs): help_text_items.append("Only select 'In WG Last Call' to correct errors. This is not how to issue a working group last call.") else: f.queryset = f.queryset.exclude(slug='wg-lc') - help_text_items.append("You may not set the 'In WG Last Call' state using this form - Use the button above or the document's main page to request a WG LC.") + help_text_items.append("You may not set the 'In WG Last Call' state using this form - Use the document's action helper view to issues a WG LC.") + if self.can_set_call_for_adoption: + help_text_items.append("Only select 'Call For Adoption By WG Issued' to correct errors. This is not how to issue a call for adoption.") + else: + f.queryset = f.queryset.exclude(slug='c-adopt') + help_text_items.append("You may not set the 'In WG Last Call' state using this form - Use the document's action helper view to issue a call for adoption.") f.help_text = " ".join(help_text_items) f = self.fields['tags'] @@ -1714,7 +1716,9 @@ def clean_new_state(self): if new_state.slug=='sub-pub' and not self.can_set_sub_pub: raise forms.ValidationError('You may not set the %s state using this form. Use the "Submit to IESG for Publication" button on the document\'s main page instead. If that button does not appear, the document may already have IESG state. Ask your Area Director or the Secretariat for help.'%new_state.name) if new_state.slug=='wg-lc' and not self.can_set_wg_lc: - raise forms.ValidationError('You may not set the %s state using this form. Use the "Request Working Group Last Call" button on the document\'s main page instead. If that button does not appear, the document may already have IESG state. Ask your Area Director or the Secretariat for help.'%new_state.name) + raise forms.ValidationError('You may not set the %s state using this form. Use the "Issue Working Group Last Call" button on the document\'s action helper view instead. If that button does not appear, the document may not be in an appropriate current state. Ask your Area Director or the Secretariat for help.'%new_state.name) + if new_state.slug=='c-adopt' and not self.can_set_call_for_adoption: + raise forms.ValidationError('You may not set the %s state using this form. Use the "Issue Call for Adoption" button on the document\'s action helper view instead. If that button does not appear, the document may not be in an appropriate current state. Ask your Area Director or the Secretariat for help.'%new_state.name) return new_state @@ -1770,6 +1774,7 @@ def change_stream_state(request, name, state_type): # These tell the form to allow directly setting the state to fix up errors. can_set_sub_pub = has_role(request.user,('Secretariat','Area Director')) or (prev_state and prev_state.slug=='sub-pub') can_set_wg_lc = has_role(request.user,('Secretariat','Area Director')) or (prev_state and prev_state.slug=='wg-lc') + can_set_call_for_adoption = has_role(request.user,('Secretariat','Area Director')) or (prev_state and prev_state.slug=='c-adopt') if request.method == 'POST': form = ChangeStreamStateForm( @@ -1778,6 +1783,7 @@ def change_stream_state(request, name, state_type): state_type=state_type, can_set_sub_pub=can_set_sub_pub, can_set_wg_lc=can_set_wg_lc, + can_set_call_for_adoption=can_set_call_for_adoption, stream=doc.stream, ) if form.is_valid(): @@ -1800,10 +1806,6 @@ def change_stream_state(request, name, state_type): update_reminder(doc, "stream-s", e, due_date) email_stream_state_changed(request, doc, prev_state, new_state, by, comment) - - if doc.stream_id == "ietf": - if new_state.slug == "c-adopt": - email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=form.cleaned_data["weeks"]) # tags existing_tags = set(doc.tags.all()) @@ -1848,6 +1850,7 @@ def change_stream_state(request, name, state_type): state_type=state_type, can_set_sub_pub=can_set_sub_pub, can_set_wg_lc=can_set_wg_lc, + can_set_call_for_adoption=can_set_call_for_adoption, stream=doc.stream, ) @@ -2017,3 +2020,188 @@ def issue_wg_lc(request, name): form=form, ), ) + +class IssueCallForAdoptionForm(forms.Form): + end_date = DatepickerDateField( + required=True, + date_format="yyyy-mm-dd", + picker_settings={ + "autoclose": "1", + }, + help_text="The date the Call for Adoption closes. If you change this, you must MANUALLY change the date in the subject and body below.", + ) + + to = MultiEmailField( + required=True, + help_text="Comma separated list of address to use in the To: header", + ) + cc = MultiEmailField( + required=False, help_text="Comma separated list of addresses to copy" + ) + subject = forms.CharField( + required=True, + help_text="Subject for Call for Adoption message. If you change the date here, be sure to make a matching change in the body.", + ) + body = forms.CharField( + widget=forms.Textarea, required=True, help_text="Body for Call for Adoption message" + ) + + def clean_end_date(self): + end_date = self.cleaned_data["end_date"] + if end_date <= date_today(DEADLINE_TZINFO): + raise forms.ValidationError("End date must be later than today") + return end_date + + def clean(self): + cleaned_data = super().clean() + end_date = cleaned_data.get("end_date") + if end_date is not None: + body = cleaned_data.get("body") + subject = cleaned_data.get("subject") + if end_date.isoformat() not in body: + self.add_error( + "body", + forms.ValidationError( + f"Call for adoption end date ({end_date.isoformat()}) not found in body" + ), + ) + if end_date.isoformat() not in subject: + self.add_error( + "subject", + forms.ValidationError( + f"Call for adoption end date ({end_date.isoformat()}) not found in subject" + ), + ) + return cleaned_data + +@login_required +def issue_wg_call_for_adoption(request, name): + doc = get_object_or_404(Document, name=name) + + if doc.stream_id != "ietf": + raise Http404 + if doc.group is None or doc.group.type_id != "wg": + raise Http404 + if not can_issue_ietf_call_for_adoption(doc): + raise Http404 + + if not is_authorized_in_doc_stream(request.user, doc): + permission_denied(request, "You don't have permission to access this page.") + + if request.method == "POST": + form = IssueCallForAdoptionForm(request.POST) + if form.is_valid(): + # Intentionally not changing tags or adding a comment + # those things can be done with other workflows + by = request.user.person + prev_state = doc.get_state("draft-stream-ietf") + events = [] + c_adopt_state = State.objects.get(type="draft-stream-ietf", slug="c-adopt") + doc.set_state(c_adopt_state) + e = add_state_change_event(doc, by, prev_state, c_adopt_state) + events.append(e) + end_date = form.cleaned_data["end_date"] + update_reminder( + doc, "stream-s", e, datetime_from_date(end_date, DEADLINE_TZINFO) + ) + email_stream_state_changed(request, doc, prev_state, c_adopt_state, by) + email_wg_call_for_adoption_issued(request, doc, end_date) + doc.save_with_history(events) + return redirect("ietf.doc.views_doc.document_main", name=doc.name) + else: + end_date = date_today(DEADLINE_TZINFO) + datetime.timedelta(days=14) + subject = f"Call for adoption: {doc.name}-{doc.rev} (Ends {end_date})" + body = render_to_string( + "doc/mail/wg_call_for_adoption_issued.txt", + dict( + doc=doc, + end_date=end_date, + url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(), + wg_list=doc.group.list_email, + ), + ) + (to, cc) = gather_address_lists("doc_wg_call_for_adoption_issued", doc=doc) + + form = IssueCallForAdoptionForm( + initial=dict( + end_date=end_date, + to=", ".join(to), + cc=", ".join(cc), + subject=subject, + body=body, + ) + ) + + return render( + request, + "doc/draft/issue_working_group_call_for_adoption.html", + dict( + doc=doc, + form=form, + ), + ) + +class GroupModelChoiceField(forms.ModelChoiceField): + def label_from_instance(self, obj): + return f"{obj.acronym} - {obj.name}" + + +class WgForm(forms.Form): + group = GroupModelChoiceField( + queryset=Group.objects.filter(type_id="wg", state="active") + .order_by("acronym") + .distinct(), + required=True, + empty_label="Select IETF Working Group", + ) + + def __init__(self, *args, **kwargs): + user = kwargs.pop("user") + super(WgForm, self).__init__(*args, **kwargs) + if not has_role(user, ["Secretariat", "Area Director"]): + self.fields["group"].queryset = self.fields["group"].queryset.filter( + role__name_id="chair", role__person=user.person + ) + + +@role_required("Secretariat", "Area Director", "WG Chair") +def ask_about_ietf_adoption_call(request, name): + doc = get_object_or_404(Document, name=name) + if doc.stream is not None or doc.group.acronym != "none": + raise Http404 + if request.method == "POST": + form = WgForm(request.POST, user=request.user) + if form.is_valid(): + by = request.user.person + events = [] + group = form.cleaned_data["group"] + stream = StreamName.objects.get(slug="ietf") + doc.stream = stream + e = DocEvent(type="changed_stream", doc=doc, rev=doc.rev, by=by) + e.desc = f"Changed stream to {stream.name}" # Propogates embedding html in DocEvent.desc for consistency + e.save() + events.append(e) + e = DocEvent(type="changed_group", doc=doc, rev=doc.rev, by=by) + e.desc = f"Changed group to {group.name} ({group.acronym.upper()})" # Even if it makes the cats cry + e.save() + events.append(e) + doc.group = group + state = State.objects.get( + type_id="draft-stream-ietf", slug="wg-cand" + ) # Stepping through this state + prev_state = doc.get_state("draft-stream-ietf") + doc.set_state(state) + e = add_state_change_event(doc, by, prev_state, state) + events.append(e) + doc.save_with_history(events) + return redirect(issue_wg_call_for_adoption, name=doc.name) + else: + form = WgForm(initial={"group": None}, user=request.user) + return render( + request, + "doc/draft/ask_about_ietf_adoption.html", + dict( + doc=doc, + form=form, + ), + ) diff --git a/ietf/templates/doc/draft/ask_about_ietf_adoption.html b/ietf/templates/doc/draft/ask_about_ietf_adoption.html new file mode 100644 index 00000000000..d19e4572b79 --- /dev/null +++ b/ietf/templates/doc/draft/ask_about_ietf_adoption.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015-2025, All Rights Reserved #} +{% load django_bootstrap5 ietf_filters origin %} +{% block title %}Manage Adoption of {{ doc }}{% endblock %} +{% block content %} + {% origin %} +

+ Manage Adoption +
+ {{ doc }} +

+
+ Do you wish to issue an IETF Working Group call for adoption to one of these working groups? +
+
+ {% csrf_token %} + {% bootstrap_form form %} + + No, I wish to manage adoption directly, perhaps with non-IETF-stream groups + Back +
+{% endblock %} \ No newline at end of file diff --git a/ietf/templates/doc/draft/issue_working_group_call_for_adoption.html b/ietf/templates/doc/draft/issue_working_group_call_for_adoption.html new file mode 100644 index 00000000000..23af570d022 --- /dev/null +++ b/ietf/templates/doc/draft/issue_working_group_call_for_adoption.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2025, All Rights Reserved #} +{% load origin django_bootstrap5 static %} +={% block title %}Issue Working Group Call for Adoption of {{ doc }}{% endblock %} +{% block pagehead %} + +{% endblock %} +{% block content %} + {% origin %} +

+ Issue Working Group Call for Adoption +
+ {{ doc }} +

+ {% if form.errors %} +

+ Please correct the following: +

+ {% endif %} + {% bootstrap_form_errors form %} +
+ {% csrf_token %} + {% bootstrap_form form %} + + Back +
+{% endblock %} +{% block js %} + +{% endblock %} \ No newline at end of file diff --git a/ietf/templates/doc/mail/wg_call_for_adoption_issued.txt b/ietf/templates/doc/mail/wg_call_for_adoption_issued.txt index c4a2401bc29..e5b49c9b9d4 100644 --- a/ietf/templates/doc/mail/wg_call_for_adoption_issued.txt +++ b/ietf/templates/doc/mail/wg_call_for_adoption_issued.txt @@ -1,7 +1,7 @@ -{% load ietf_filters %}{% load mail_filters %}{% autoescape off %}{% filter wordwrap:78 %} -Subject: {{ subject }} +{% load ietf_filters %}{% load mail_filters %}{% autoescape off %}{% filter wordwrap:78 %}This message starts a {{doc.group.acronym}} WG Call for Adoption of: +{{ doc.name }}-{{ doc.rev }} -This message starts a {{ cfa_duration_weeks }}-week Call for Adoption for this document. +This Working Group Call for Adoption ends on {{ end_date }} Abstract: {{ doc.abstract }} @@ -9,7 +9,7 @@ Abstract: File can be retrieved from: {{ url }} -Please reply to this message keeping {{ wg_list }} in copy by indicating whether you support or not the adoption of this draft as a WG document. Comments to motivate your preference are highly appreciated. +Please reply to this message keeping {{ wg_list }} in copy by indicating whether you support or not the adoption of this draft as a {{doc.group.acronym}} WG document. Comments to motivate your preference are highly appreciated. Authors, and WG participants in general, are reminded of the Intellectual Property Rights (IPR) disclosure obligations described in BCP 79 [2]. Appropriate IPR disclosures required for full conformance with the provisions of BCP 78 [1] and BCP 79 [2] must be filed, if you are aware of any. Sanctions available for application to violators of IETF IPR Policy can be found at [3]. From 866a675e1369155ce8c8e427e16192d416b45566 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Mon, 13 Oct 2025 21:23:54 -0500 Subject: [PATCH 2/3] feat: improved tests and related code adjustments --- ietf/doc/templatetags/ietf_filters.py | 3 +- ietf/doc/tests_draft.py | 225 ++++++++++++++++++++++---- ietf/doc/views_doc.py | 5 +- ietf/doc/views_draft.py | 2 +- 4 files changed, 201 insertions(+), 34 deletions(-) diff --git a/ietf/doc/templatetags/ietf_filters.py b/ietf/doc/templatetags/ietf_filters.py index 953200b2141..acd5c1b8355 100644 --- a/ietf/doc/templatetags/ietf_filters.py +++ b/ietf/doc/templatetags/ietf_filters.py @@ -1022,7 +1022,7 @@ def is_in_stream(doc): def can_issue_ietf_call_for_adoption(doc): return all( [ - doc.stream_id == "ietf", + doc.stream_id in ["ietf", None], doc.get_state_slug("draft-stream-ietf") not in [ "wg-doc", @@ -1034,6 +1034,7 @@ def can_issue_ietf_call_for_adoption(doc): "writeupw", "sub-pub", ], + doc.get_state_slug("draft") != "rfc", doc.became_rfc() is None, ] ) diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index caa0663fc5c..c64eb2c680f 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -26,7 +26,8 @@ WriteupDocEvent, DocRelationshipName, IanaExpertDocEvent ) from ietf.doc.storage_utils import exists_in_storage, store_str from ietf.doc.utils import get_tags_for_stream_id, create_ballot_if_not_open -from ietf.doc.views_draft import AdoptDraftForm, IssueWorkingGroupLastCallForm +from ietf.doc.views_draft import AdoptDraftForm, IssueCallForAdoptionForm, IssueWorkingGroupLastCallForm +from ietf.ietfauth.utils import has_role from ietf.name.models import DocTagName, RoleName from ietf.group.factories import GroupFactory, RoleFactory from ietf.group.models import Group, Role @@ -1760,11 +1761,7 @@ def test_adopt_document(self): self.assertEqual(draft.group, chair_role.group) self.assertEqual(draft.stream_id, stream_state_type_slug[type_id][13:]) # trim off "draft-stream-" self.assertEqual(draft.docevent_set.count() - events_before, 5) - self.assertEqual(len(outbox), 2) - self.assertTrue("Call For Adoption" in outbox[0]["Subject"]) - self.assertTrue(f"{chair_role.group.acronym}-chairs@" in outbox[0]['To']) - self.assertTrue(f"{draft.name}@" in outbox[0]['To']) - self.assertTrue(f"{chair_role.group.acronym}@" in outbox[0]['To']) + self.assertEqual(len(outbox), 1) # contents of outbox[1] are tested elsewhere # adopt @@ -2131,12 +2128,7 @@ def test_wg_call_for_adoption_issued(self): ), ) self.assertEqual(r.status_code, 302) - self.assertEqual(len(outbox), 2) - self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) - self.assertIn("Call for adoption", outbox[1]["Subject"]) - body = get_payload_text(outbox[1]) - self.assertIn("disclosure obligations", body) - self.assertIn("starts a 10-week", body) + self.assertEqual(len(outbox), 1) # Test not entering a duration on the form draft = IndividualDraftFactory() url = urlreverse( @@ -2153,12 +2145,7 @@ def test_wg_call_for_adoption_issued(self): ), ) self.assertEqual(r.status_code, 302) - self.assertEqual(len(outbox), 2) - self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) - self.assertIn("Call for adoption", outbox[1]["Subject"]) - body = get_payload_text(outbox[1]) - self.assertIn("disclosure obligations", body) - self.assertIn("starts a 2-week", body) + self.assertEqual(len(outbox), 1) # Test the less usual workflow of issuing a call for adoption # of a document that's already in the ietf stream @@ -2187,13 +2174,30 @@ def test_wg_call_for_adoption_issued(self): ], ), ) + # A chair doesn't get c-adopt as an alternative + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertTrue(len(q("#id_new_state_error")),1) + + self.client.logout() + self.client.login(username="secretary", password="secretary+password") + r = self.client.post( + url, + dict( + new_state=new_state.pk, + comment="some comment", + weeks="10", + tags=[ + t.pk + for t in draft.tags.filter( + slug__in=get_tags_for_stream_id(draft.stream_id) + ) + ], + ), + ) + # A member of the secretariat can set this state directly still self.assertEqual(r.status_code, 302) - self.assertEqual(len(outbox), 2) - self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) - self.assertIn("Call for adoption", outbox[1]["Subject"]) - body = get_payload_text(outbox[1]) - self.assertIn("disclosure obligations", body) - self.assertIn("starts a 10-week", body) + self.assertEqual(len(outbox), 1) draft = WgDraftFactory(group=role.group) url = urlreverse( "ietf.doc.views_draft.change_stream_state", @@ -2219,12 +2223,7 @@ def test_wg_call_for_adoption_issued(self): ), ) self.assertEqual(r.status_code, 302) - self.assertEqual(len(outbox), 2) - self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) - self.assertIn("Call for adoption", outbox[1]["Subject"]) - body = get_payload_text(outbox[1]) - self.assertIn("disclosure obligations", body) - self.assertIn("starts a 2-week", body) + self.assertEqual(len(outbox), 1) def test_issue_wg_lc_form(self): end_date = date_today(DEADLINE_TZINFO) + datetime.timedelta(days=1) @@ -2306,6 +2305,88 @@ def _assert_rejected(testcase, doc, person): body = get_payload_text(outbox[1]) self.assertIn("disclosure obligations", body) + def test_issue_wg_call_for_adoption_form(self): + end_date = date_today(DEADLINE_TZINFO) + datetime.timedelta(days=1) + post = dict( + end_date=end_date, + to="foo@example.net, bar@example.com", + # Intentionally not passing cc + subject=f"garbage {end_date.isoformat()}", + body=f"garbage {end_date.isoformat()}", + ) + form = IssueCallForAdoptionForm(post) + self.assertTrue(form.is_valid()) + post["end_date"] = date_today(DEADLINE_TZINFO) + form = IssueCallForAdoptionForm(post) + self.assertFalse(form.is_valid()) + self.assertIn( + "End date must be later than today", + form.errors["end_date"], + "Form accepted a too-early date", + ) + post["end_date"] = end_date + datetime.timedelta(days=2) + form = IssueCallForAdoptionForm(post) + self.assertFalse(form.is_valid()) + self.assertIn( + f"Call for adoption end date ({post['end_date'].isoformat()}) not found in subject", + form.errors["subject"], + "form allowed subject without end_date", + ) + self.assertIn( + f"Call for adoption end date ({post['end_date'].isoformat()}) not found in body", + form.errors["body"], + "form allowed body without end_date", + ) + + def test_issue_wg_call_for_adoption(self): + def _assert_rejected(testcase, doc, person): + url = urlreverse( + "ietf.doc.views_draft.issue_wg_call_for_adoption", kwargs=dict(name=doc.name) + ) + login_testing_unauthorized(testcase, person.user.username, url) + r = testcase.client.get(url) + testcase.assertEqual(r.status_code, 404) + testcase.client.logout() + + already_rfc = WgDraftFactory(states=[("draft", "rfc")]) + rfc = WgRfcFactory(group=already_rfc.group) + already_rfc.relateddocument_set.create(relationship_id="became_rfc",target=rfc) + rfc_chair = RoleFactory(name_id="chair", group=already_rfc.group).person + _assert_rejected(self, already_rfc, rfc_chair) + rg_doc = RgDraftFactory() + rg_chair = RoleFactory(name_id="chair", group=rg_doc.group).person + _assert_rejected(self, rg_doc, rg_chair) + inwglc_doc = WgDraftFactory(states=[("draft-stream-ietf", "wg-lc")]) + inwglc_chair = RoleFactory(name_id="chair", group=inwglc_doc.group).person + _assert_rejected(self, inwglc_doc, inwglc_chair) + doc = WgDraftFactory(states=[("draft-stream-ietf","wg-cand")]) + chair = RoleFactory(name_id="chair",group=doc.group).person + url = urlreverse("ietf.doc.views_draft.issue_wg_call_for_adoption", kwargs=dict(name=doc.name)) + login_testing_unauthorized(self, chair.user.username, url) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + postdict = dict() + postdict["end_date"] = q("input#id_end_date").attr("value") + postdict["to"] = q("input#id_to").attr("value") + cc = q("input#id_cc").attr("value") + if cc is not None: + postdict["cc"] = cc + postdict["subject"] = q("input#id_subject").attr("value") + postdict["body"] = q("textarea#id_body").text() + empty_outbox() + r = self.client.post( + url, + postdict, + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(doc.get_state_slug("draft-stream-ietf"), "c-adopt") + self.assertEqual(len(outbox), 2) + self.assertIn(f"{doc.group.acronym}@ietf.org", outbox[1]["To"]) + self.assertIn("Call for adoption", outbox[1]["Subject"]) + body = get_payload_text(outbox[1]) + self.assertIn("disclosure obligations", body) + def test_pubreq_validation(self): role = RoleFactory(name_id='chair',group__acronym='mars',group__list_email='mars-wg@ietf.org',person__user__username='marschairman',person__name='WG Cháir Man') RoleFactory(name_id='delegate',group=role.group,person__user__email='marsdelegate@ietf.org') @@ -2501,3 +2582,87 @@ def test_editorial_metadata(self): top_level_metadata_headings = q("tbody>tr>th:first-child").text() self.assertNotIn("IESG", top_level_metadata_headings) self.assertNotIn("IANA", top_level_metadata_headings) + +class IetfGroupActionHelperTests(TestCase): + def test_manage_adoption_routing(self): + draft = IndividualDraftFactory() + nobody = PersonFactory() + rgchair = RoleFactory(group__type_id="rg", name_id="chair").person + wgchair = RoleFactory(group__type_id="wg", name_id="chair").person + multichair = RoleFactory(group__type_id="rg", name_id="chair").person + RoleFactory(group__type_id="wg", person=multichair, name_id="chair") + ad = RoleFactory(group__type_id="area", name_id="ad").person + secretary = Role.objects.filter( + name_id="secr", group__acronym="secretariat" + ).first() + self.assertIsNotNone(secretary) + secretary = secretary.person + self.assertFalse( + has_role(rgchair.user, ["Secretariat", "Area Director", "WG Chair"]) + ) + url = urlreverse( + "ietf.doc.views_doc.document_main", kwargs={"name": draft.name} + ) + ask_about_ietf_link = urlreverse( + "ietf.doc.views_draft.ask_about_ietf_adoption_call", + kwargs={"name": draft.name}, + ) + non_ietf_adoption_link = urlreverse( + "ietf.doc.views_draft.adopt_draft", kwargs={"name": draft.name} + ) + for person in (None, nobody, rgchair, wgchair, multichair, ad, secretary): + if person is not None: + self.client.login( + username=person.user.username, + password=f"{person.user.username}+password", + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + has_ask_about_ietf_link = len(q(f'a[href="{ask_about_ietf_link}"]')) != 0 + has_non_ietf_adoption_link = ( + len(q(f'a[href="{non_ietf_adoption_link}"]')) != 0 + ) + ask_about_r = self.client.get(ask_about_ietf_link) + ask_about_link_return_code = ask_about_r.status_code + if person == rgchair: + self.assertFalse(has_ask_about_ietf_link) + self.assertTrue(has_non_ietf_adoption_link) + self.assertEqual(ask_about_link_return_code, 403) + elif person in (ad, nobody, None): + self.assertFalse(has_ask_about_ietf_link) + self.assertFalse(has_non_ietf_adoption_link) + self.assertEqual( + ask_about_link_return_code, 302 if person is None else 403 + ) + else: + self.assertTrue(has_ask_about_ietf_link) + self.assertFalse(has_non_ietf_adoption_link) + self.assertEqual(ask_about_link_return_code, 200) + self.client.logout() + + def test_ask_about_ietf_adoption_call(self): + # Basic permission tests above + doc = IndividualDraftFactory() + self.assertEqual(doc.docevent_set.count(), 1) + chair_role = RoleFactory(group__type_id="wg", name_id="chair") + chair = chair_role.person + group = chair_role.group + othergroup = GroupFactory(type_id="wg") + url = urlreverse( + "ietf.doc.views_draft.ask_about_ietf_adoption_call", + kwargs={"name": doc.name}, + ) + login_testing_unauthorized(self, chair.user.username, url) + r = self.client.post(url, {"group": othergroup.pk}) + self.assertEqual(r.status_code, 200) + r = self.client.post(url, {"group": group.pk}) + self.assertEqual(r.status_code, 302) + doc.refresh_from_db() + self.assertEqual(doc.group, group) + self.assertEqual(doc.stream_id, "ietf") + self.assertEqual(doc.get_state_slug("draft-stream-ietf"), "wg-cand") + self.assertCountEqual( + doc.docevent_set.values_list("type", flat=True), + ["changed_state", "changed_group", "changed_stream", "new_revision"], + ) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 79d8afa6cfe..5128aaad9db 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -514,14 +514,15 @@ def document_main(request, name, rev=None, document_html=False): # remaining actions actions = [] - if can_adopt_draft(request.user, doc) and not doc.get_state_slug() in ["rfc"] and not snapshot: + if can_adopt_draft(request.user, doc) and doc.get_state_slug() not in ["rfc"] and not snapshot: target = urlreverse("ietf.doc.views_draft.adopt_draft", kwargs=dict(name=doc.name)) if doc.group and doc.group.acronym != 'none': # individual submission # already adopted in one group button_text = "Switch adoption" else: button_text = "Manage adoption" - if has_role(request.user, ["Secretariat", "Area Director", "WG Chair"]): + # can_adopt_draft currently returns False for Area Directors + if has_role(request.user, ["Secretariat", "WG Chair"]): target = urlreverse("ietf.doc.views_draft.ask_about_ietf_adoption_call", kwargs=dict(name=doc.name)) actions.append((button_text, target)) diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index c709aa3fd2c..78a622bae86 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -2164,7 +2164,7 @@ def __init__(self, *args, **kwargs): ) -@role_required("Secretariat", "Area Director", "WG Chair") +@role_required("Secretariat", "WG Chair") def ask_about_ietf_adoption_call(request, name): doc = get_object_or_404(Document, name=name) if doc.stream is not None or doc.group.acronym != "none": From ba0efe148f0fe36e17734f07e9646fb27e62a8f4 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Tue, 14 Oct 2025 11:07:09 -0500 Subject: [PATCH 3/3] feat: improve the change state interstitial workflow --- ietf/doc/templatetags/ietf_filters.py | 27 ++- ietf/doc/tests_draft.py | 159 ++++++++++++------ ietf/templates/doc/document_draft.html | 2 +- .../doc/draft/wg_action_helpers.html | 5 +- 4 files changed, 134 insertions(+), 59 deletions(-) diff --git a/ietf/doc/templatetags/ietf_filters.py b/ietf/doc/templatetags/ietf_filters.py index acd5c1b8355..42103658ce7 100644 --- a/ietf/doc/templatetags/ietf_filters.py +++ b/ietf/doc/templatetags/ietf_filters.py @@ -1022,7 +1022,7 @@ def is_in_stream(doc): def can_issue_ietf_call_for_adoption(doc): return all( [ - doc.stream_id in ["ietf", None], + doc.stream_id == "ietf", doc.get_state_slug("draft-stream-ietf") not in [ "wg-doc", @@ -1041,13 +1041,32 @@ def can_issue_ietf_call_for_adoption(doc): @register.filter def can_issue_ietf_wg_lc(doc): - return doc.stream_id == "ietf" and doc.get_state_slug("draft-stream-ietf") != "wg-lc" and doc.get_state_slug("draft") != "rfc" + return all( + [ + doc.stream_id == "ietf", + doc.get_state_slug("draft-stream-ietf") + not in ["wg-cand", "c-adopt", "wg-lc"], + doc.get_state_slug("draft") != "rfc", + doc.became_rfc() is None, + ] + ) + @register.filter def can_submit_to_iesg(doc): - return doc.stream_id == "ietf" and doc.get_state_slug("draft-iesg") == "idexists" + return all( + [ + doc.stream_id == "ietf", + doc.get_state_slug("draft-iesg") == "idexists", + doc.get_state_slug("draft-stream-ietf") not in ["wg-cand", "c-adopt"], + ] + ) + @register.filter def has_had_ietf_wg_lc(doc): - return doc.stream_id == "ietf" and doc.docevent_set.filter(statedocevent__state__slug="wg-lc").exists() + return ( + doc.stream_id == "ietf" + and doc.docevent_set.filter(statedocevent__state__slug="wg-lc").exists() + ) diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index c64eb2c680f..27d5cc90d3f 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -86,59 +86,6 @@ def test_ad_approved(self): self.assertTrue("Approved: " in outbox[-1]['Subject']) self.assertTrue(draft.name in outbox[-1]['Subject']) self.assertTrue('iesg@' in outbox[-1]['To']) - - def test_offer_wg_action_helpers(self): - def _view_presents_issue_wglc_button(response): - q = PyQuery(response.content) - button = q("#id_wglc_button") - return len(button) != 0 - - came_from_draft = WgDraftFactory(states=[("draft","rfc")]) - rfc = WgRfcFactory(group=came_from_draft.group) - came_from_draft.relateddocument_set.create(relationship_id="became_rfc",target=rfc) - rfc_chair = RoleFactory(name_id="chair", group=rfc.group).person - url = urlreverse("ietf.doc.views_draft.offer_wg_action_helpers", kwargs=dict(name=came_from_draft.name)) - login_testing_unauthorized(self, rfc_chair.user.username, url) - r = self.client.get(url) - self.assertEqual(r.status_code, 404) - self.client.logout() - rg_draft = RgDraftFactory() - rg_chair = RoleFactory(group=rg_draft.group, name_id="chair").person - url = urlreverse("ietf.doc.views_draft.offer_wg_action_helpers", kwargs=dict(name=rg_draft.name)) - login_testing_unauthorized(self, rg_chair.user.username, url) - r = self.client.get(url) - self.assertEqual(r.status_code,404) - self.client.logout() - draft = WgDraftFactory() - chair = RoleFactory(group=draft.group, name_id="chair").person - url = urlreverse("ietf.doc.views_draft.offer_wg_action_helpers", kwargs=dict(name=draft.name)) - login_testing_unauthorized(self, chair.user.username, url) - r = self.client.get(url) - self.assertEqual(r.status_code,200) - self.assertTrue(_view_presents_issue_wglc_button(r)) - draft.set_state(State.objects.get(type_id="draft-stream-ietf", slug="wg-lc")) - StateDocEventFactory( - doc=draft, - state_type_id="draft-stream-ietf", - state=("draft-stream-ietf", "wg-lc"), - ) - self.assertEqual(draft.docevent_set.count(), 2) - r = self.client.get(url) - self.assertEqual(r.status_code,200) - self.assertFalse(_view_presents_issue_wglc_button(r)) - draft.set_state(State.objects.get(type_id="draft-stream-ietf",slug="chair-w")) - r = self.client.get(url) - self.assertTrue(_view_presents_issue_wglc_button(r)) - self.assertContains(response=r,text="Issue Another Working Group Last Call", status_code=200) - other_draft = WgDraftFactory() - self.client.logout() - url = urlreverse("ietf.doc.views_draft.offer_wg_action_helpers", kwargs=dict(name=other_draft.name)) - login_testing_unauthorized(self, "secretary", url) - r = self.client.get(url) - self.assertTrue(_view_presents_issue_wglc_button(r)) - self.assertContains( - response=r, text="Issue Working Group Last Call", status_code=200 - ) def test_change_state(self): ad = Person.objects.get(user__username="ad") @@ -2666,3 +2613,109 @@ def test_ask_about_ietf_adoption_call(self): doc.docevent_set.values_list("type", flat=True), ["changed_state", "changed_group", "changed_stream", "new_revision"], ) + + def test_offer_wg_action_helpers(self): + def _assert_view_presents_buttons(testcase, response, expected): + q = PyQuery(response.content) + for id, expect in expected: + button = q(f"#{id}") + testcase.assertEqual( + len(button) != 0, + expect + ) + + # View rejects access + came_from_draft = WgDraftFactory(states=[("draft","rfc")]) + rfc = WgRfcFactory(group=came_from_draft.group) + came_from_draft.relateddocument_set.create(relationship_id="became_rfc",target=rfc) + rfc_chair = RoleFactory(name_id="chair", group=rfc.group).person + url = urlreverse("ietf.doc.views_draft.offer_wg_action_helpers", kwargs=dict(name=came_from_draft.name)) + login_testing_unauthorized(self, rfc_chair.user.username, url) + r = self.client.get(url) + self.assertEqual(r.status_code, 404) + self.client.logout() + rg_draft = RgDraftFactory() + rg_chair = RoleFactory(group=rg_draft.group, name_id="chair").person + url = urlreverse("ietf.doc.views_draft.offer_wg_action_helpers", kwargs=dict(name=rg_draft.name)) + login_testing_unauthorized(self, rg_chair.user.username, url) + r = self.client.get(url) + self.assertEqual(r.status_code,404) + self.client.logout() + + # View offers access + draft = WgDraftFactory() + chair = RoleFactory(group=draft.group, name_id="chair").person + url = urlreverse("ietf.doc.views_draft.offer_wg_action_helpers", kwargs=dict(name=draft.name)) + login_testing_unauthorized(self, chair.user.username, url) + r = self.client.get(url) + self.assertEqual(r.status_code,200) + _assert_view_presents_buttons( + self, + r, + [ + ("id_wgadopt_button", False), + ("id_wglc_button", True), + ("id_pubreq_button", True), + ], + ) + draft.set_state(State.objects.get(type_id="draft-stream-ietf", slug="wg-cand")) + r = self.client.get(url) + self.assertEqual(r.status_code,200) + _assert_view_presents_buttons( + self, + r, + [ + ("id_wgadopt_button", True), + ("id_wglc_button", False), + ("id_pubreq_button", False), + ], + ) + draft.set_state(State.objects.get(type_id="draft-stream-ietf", slug="wg-lc")) + StateDocEventFactory( + doc=draft, + state_type_id="draft-stream-ietf", + state=("draft-stream-ietf", "wg-lc"), + ) + self.assertEqual(draft.docevent_set.count(), 2) + r = self.client.get(url) + self.assertEqual(r.status_code,200) + _assert_view_presents_buttons( + self, + r, + [ + ("id_wgadopt_button", False), + ("id_wglc_button", False), + ("id_pubreq_button", True), + ], + ) + draft.set_state(State.objects.get(type_id="draft-stream-ietf",slug="chair-w")) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + _assert_view_presents_buttons( + self, + r, + [ + ("id_wgadopt_button", False), + ("id_wglc_button", True), + ("id_pubreq_button", True), + ], + ) + self.assertContains(response=r,text="Issue Another Working Group Last Call", status_code=200) + other_draft = WgDraftFactory() + self.client.logout() + url = urlreverse("ietf.doc.views_draft.offer_wg_action_helpers", kwargs=dict(name=other_draft.name)) + login_testing_unauthorized(self, "secretary", url) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + _assert_view_presents_buttons( + self, + r, + [ + ("id_wgadopt_button", False), + ("id_wglc_button", True), + ("id_pubreq_button", True), + ], + ) + self.assertContains( + response=r, text="Issue Working Group Last Call", status_code=200 + ) diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 66152ffe42a..0004323c7ba 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -63,7 +63,7 @@ {% if doc.stream and can_edit_stream_info and doc.stream.slug != "legacy" and not snapshot %} {{ doc }}
+ {% if doc|can_issue_ietf_call_for_adoption %} + Issue WG Call for Adoption + {% endif %} {% if doc|can_issue_ietf_wg_lc %} Issue{% if doc|has_had_ietf_wg_lc %} Another{% endif %} Working Group Last Call {% endif %} {% if doc|can_submit_to_iesg %} - Submit to IESG for Publication + Submit to IESG for Publication {% endif %} Set a different state Back