From ca0f3303061d5d8943a3c3e228ac3ad86bb4eb09 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Wed, 24 Sep 2025 13:54:23 -0500 Subject: [PATCH 1/7] feat: editable wglc messages --- ietf/doc/mails.py | 8 +- ietf/doc/tests_draft.py | 165 +++++++++++------- ietf/doc/urls.py | 1 + ietf/doc/views_draft.py | 164 +++++++++++++++-- .../doc/draft/change_stream_state.html | 14 +- .../doc/mail/wg_last_call_issued.txt | 6 +- 6 files changed, 272 insertions(+), 86 deletions(-) diff --git a/ietf/doc/mails.py b/ietf/doc/mails.py index f20d398c3c..fe107b5e21 100644 --- a/ietf/doc/mails.py +++ b/ietf/doc/mails.py @@ -132,14 +132,9 @@ def email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=None): ) -def email_wg_last_call_issued(request, doc, wglc_duration_weeks=None): - if wglc_duration_weeks is None: - wglc_duration_weeks = 2 +def email_wg_last_call_issued(request, doc, end_date): (to, cc) = gather_address_lists("doc_wg_last_call_issued", doc=doc) frm = request.user.person.formatted_email() - - - end_date = date_today(DEADLINE_TZINFO) + datetime.timedelta(days=7 * wglc_duration_weeks) subject = f"WG Last Call: {doc.name}-{doc.rev} (Ends {end_date})" send_mail( @@ -153,7 +148,6 @@ def email_wg_last_call_issued(request, doc, wglc_duration_weeks=None): subject=subject, url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(), end_date=end_date, - wglc_duration_weeks=wglc_duration_weeks, wg_list=doc.group.list_email, ), cc=cc, diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index ab33acebe6..ab9254e75c 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -20,7 +20,7 @@ import debug # pyflakes:ignore from ietf.doc.expire import expirable_drafts, get_expired_drafts, send_expire_notice_for_draft, expire_draft -from ietf.doc.factories import EditorialDraftFactory, IndividualDraftFactory, WgDraftFactory, RgDraftFactory, DocEventFactory +from ietf.doc.factories import EditorialDraftFactory, IndividualDraftFactory, StateDocEventFactory, WgDraftFactory, RgDraftFactory, DocEventFactory from ietf.doc.models import ( Document, DocReminder, DocEvent, ConsensusDocEvent, LastCallDocEvent, RelatedDocument, State, TelechatDocEvent, WriteupDocEvent, DocRelationshipName, IanaExpertDocEvent ) @@ -2002,6 +2002,81 @@ def test_set_state(self): self.assertTrue("mars-chairs@ietf.org" in outbox[0].as_string()) self.assertTrue("marsdelegate@ietf.org" in outbox[0].as_string()) + def test_set_stream_state_to_wglc(self): + def _form_presents_state_option(response, state): + q = PyQuery(response.content) + option = q(f"select#id_new_state option[value='{state.pk}']") + return len(option) != 0 + + doc = WgDraftFactory() + chair = RoleFactory(name_id="chair", group=doc.group).person + url = urlreverse( + "ietf.doc.views_draft.change_stream_state", + kwargs=dict(name=doc.name, state_type="draft-stream-ietf"), + ) + login_testing_unauthorized(self, chair.user.username, url) + r = self.client.get(url) + self.assertContains( + response=r, text="Issue Working Group Last Call", status_code=200 + ) + wglc_state = State.objects.get(type="draft-stream-ietf", slug="wg-lc") + self.assertFalse(_form_presents_state_option(r, wglc_state)) + r = self.client.post( + url, + dict( + new_state=wglc_state.pk, + comment="some comment", + weeks="10", + tags=[ + t.pk + for t in doc.tags.filter( + slug__in=get_tags_for_stream_id(doc.stream_id) + ) + ], + ), + ) + self.assertEqual(r.status_code, 200) + doc.set_state(wglc_state) + StateDocEventFactory( + doc=doc, + state_type_id="draft-stream-ietf", + state=("draft-stream-ietf", "wg-lc"), + ) + self.assertEqual(doc.docevent_set.count(), 2) + r = self.client.get(url) + self.assertContains( + response=r, text="Issue Another Working Group Last Call", status_code=200 + ) + self.assertTrue(_form_presents_state_option(r, wglc_state)) + r = self.client.post( + url, + dict( + new_state=wglc_state.pk, + comment="some comment", + weeks="10", + tags=[ + t.pk + for t in doc.tags.filter( + slug__in=get_tags_for_stream_id(doc.stream_id) + ) + ], + ), + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(doc.docevent_set.count(), 3) + other_doc = WgDraftFactory() + self.client.logout() + url = urlreverse( + "ietf.doc.views_draft.change_stream_state", + kwargs=dict(name=other_doc.name, state_type="draft-stream-ietf"), + ) + login_testing_unauthorized(self, "secretary", url) + r = self.client.get(url) + self.assertContains( + response=r, text="Issue Working Group Last Call", status_code=200 + ) + self.assertTrue(_form_presents_state_option(r, wglc_state)) + def test_wg_call_for_adoption_issued(self): role = RoleFactory( name_id="chair", @@ -2123,78 +2198,46 @@ def test_wg_call_for_adoption_issued(self): self.assertIn("disclosure obligations", body) self.assertIn("starts a 2-week", body) - def test_wg_last_call_issued(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", - ) - draft = WgDraftFactory(group=role.group) + def test_issue_wg_lc(self): + rg_doc = RgDraftFactory() + rg_chair = RoleFactory(name_id="chair", group=rg_doc.group).person url = urlreverse( - "ietf.doc.views_draft.change_stream_state", - kwargs=dict(name=draft.name, state_type="draft-stream-ietf"), + "ietf.doc.views_draft.issue_wg_lc", kwargs=dict(name=rg_doc.name) ) - login_testing_unauthorized(self, "marschairman", url) - old_state = draft.get_state("draft-stream-%s" % draft.stream_id) - new_state = State.objects.get( - used=True, type="draft-stream-%s" % draft.stream_id, slug="wg-lc" - ) - self.assertNotEqual(old_state, new_state) - empty_outbox() - 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) - ) - ], - ), - ) - self.assertEqual(r.status_code, 302) - self.assertEqual(len(outbox), 2) - self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) - self.assertIn("WG Last Call", outbox[1]["Subject"]) - body = get_payload_text(outbox[1]) - self.assertIn("disclosure obligations", body) - self.assertIn("starts a 10-week", body) - draft = WgDraftFactory(group=role.group) + login_testing_unauthorized(self, rg_chair.user.username, url) + r = self.client.get(url) + self.assertEqual(r.status_code, 404) + self.client.logout() + doc = WgDraftFactory() + chair = RoleFactory(name_id="chair", group=doc.group).person url = urlreverse( - "ietf.doc.views_draft.change_stream_state", - kwargs=dict(name=draft.name, state_type="draft-stream-ietf"), + "ietf.doc.views_draft.issue_wg_lc", kwargs=dict(name=doc.name) ) - old_state = draft.get_state("draft-stream-%s" % draft.stream_id) - new_state = State.objects.get( - used=True, type="draft-stream-%s" % draft.stream_id, slug="wg-lc" - ) - self.assertNotEqual(old_state, new_state) + 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, - dict( - new_state=new_state.pk, - comment="some comment", - tags=[ - t.pk - for t in draft.tags.filter( - slug__in=get_tags_for_stream_id(draft.stream_id) - ) - ], - ), + url, + postdict, ) self.assertEqual(r.status_code, 302) + self.assertEqual(doc.get_state_slug("draft-stream-ietf"),"wg-lc") self.assertEqual(len(outbox), 2) - self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) + self.assertIn(f"{doc.group.acronym}@ietf.org", outbox[1]["To"]) self.assertIn("WG Last Call", outbox[1]["Subject"]) body = get_payload_text(outbox[1]) self.assertIn("disclosure obligations", body) - self.assertIn("starts a 2-week", 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') diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index 6f1b698a9f..10345e9126 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -124,6 +124,7 @@ url(r'^%(name)s/edit/info/$' % settings.URL_REGEXPS, views_draft.edit_info), url(r'^%(name)s/edit/requestresurrect/$' % settings.URL_REGEXPS, views_draft.request_resurrect), url(r'^%(name)s/edit/submit-to-iesg/$' % settings.URL_REGEXPS, views_draft.to_iesg), + url(r'^%(name)s/edit/issue-wg-lc/$' % settings.URL_REGEXPS, views_draft.issue_wg_lc), url(r'^%(name)s/edit/resurrect/$' % settings.URL_REGEXPS, views_draft.resurrect), url(r'^%(name)s/edit/addcomment/$' % settings.URL_REGEXPS, views_doc.add_comment), diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index 16d04ee66a..d740e4563a 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -54,9 +54,9 @@ from ietf.utils.mail import send_mail, send_mail_message, on_behalf_of from ietf.utils.textupload import get_cleaned_text_file_content from ietf.utils import log -from ietf.utils.fields import ModelMultipleChoiceField +from ietf.utils.fields import DatepickerDateField, ModelMultipleChoiceField, MultiEmailField from ietf.utils.response import permission_denied -from ietf.utils.timezone import datetime_today, DEADLINE_TZINFO +from ietf.utils.timezone import date_today, datetime_from_date, datetime_today, DEADLINE_TZINFO class ChangeStateForm(forms.Form): @@ -1679,6 +1679,7 @@ def __init__(self, *args, **kwargs): doc = kwargs.pop("doc") 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.stream = kwargs.pop("stream") super(ChangeStreamStateForm, self).__init__(*args, **kwargs) @@ -1689,11 +1690,18 @@ def __init__(self, *args, **kwargs): f.queryset = f.queryset.exclude(pk__in=unused_states) f.label = state_type.label if self.stream.slug == 'ietf': + help_text_items = [] if self.can_set_sub_pub: - f.help_text = "Only select 'Submitted to IESG for Publication' to correct errors. Use the document's main page to request publication." + help_text_items.append("Only select 'Submitted to IESG for Publication' to correct errors. Use the button above or the document's main page to request publication.") else: f.queryset = f.queryset.exclude(slug='sub-pub') - f.help_text = "You may not set the 'Submitted to IESG for Publication' using this form - Use the document's main page to request publication." + help_text_items.append("You may not set the 'Submitted to IESG for Publication' using this form - Use the button above or the document's main page to request publication.") + if self.can_set_wg_lc: + help_text_items.append("Only select 'In WG Last Call' to correct errors. Use the button above or the document's main page to request a WG LC.") + 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.") + f.help_text = " ".join(help_text_items) f = self.fields['tags'] f.queryset = f.queryset.filter(slug__in=get_tags_for_stream_id(doc.stream_id)) @@ -1704,7 +1712,9 @@ def __init__(self, *args, **kwargs): def clean_new_state(self): new_state = self.cleaned_data.get('new_state') 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) + 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) return new_state @@ -1744,10 +1754,19 @@ def change_stream_state(request, name, state_type): prev_state = doc.get_state(state_type.slug) next_states = next_states_for_stream_state(doc, state_type, prev_state) + # 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') if request.method == 'POST': - form = ChangeStreamStateForm(request.POST, doc=doc, state_type=state_type,can_set_sub_pub=can_set_sub_pub,stream=doc.stream) + form = ChangeStreamStateForm( + request.POST, + doc=doc, + state_type=state_type, + can_set_sub_pub=can_set_sub_pub, + can_set_wg_lc=can_set_wg_lc, + stream=doc.stream, + ) if form.is_valid(): by = request.user.person events = [] @@ -1773,9 +1792,6 @@ def change_stream_state(request, name, state_type): if new_state.slug == "c-adopt": email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=form.cleaned_data["weeks"]) - if new_state.slug == "wg-lc": - email_wg_last_call_issued(request, doc, wglc_duration_weeks=form.cleaned_data["weeks"]) - # tags existing_tags = set(doc.tags.all()) new_tags = set(form.cleaned_data["tags"]) @@ -1811,8 +1827,16 @@ def change_stream_state(request, name, state_type): else: form.add_error(None, "No change in state or tags found, and no comment provided -- nothing to do.") else: - form = ChangeStreamStateForm(initial=dict(new_state=prev_state.pk if prev_state else None, tags= doc.tags.all()), - doc=doc, state_type=state_type, can_set_sub_pub = can_set_sub_pub,stream = doc.stream) + form = ChangeStreamStateForm( + initial=dict( + new_state=prev_state.pk if prev_state else None, tags=doc.tags.all() + ), + doc=doc, + state_type=state_type, + can_set_sub_pub=can_set_sub_pub, + can_set_wg_lc=can_set_wg_lc, + stream=doc.stream, + ) milestones = doc.groupmilestone_set.all() @@ -1823,6 +1847,8 @@ def change_stream_state(request, name, state_type): "milestones": milestones, "state_type": state_type, "next_states": next_states, + "iesg_state_id": doc.get_state_slug("draft-iesg"), + "has_had_wg_lc": doc.docevent_set.filter(statedocevent__state__slug="wg-lc").exists(), }) # This should be in ietf.doc.utils, but placing it there brings a circular import issue with ietf.doc.mail @@ -1857,3 +1883,119 @@ def set_intended_status_level(request, doc, new_level, old_level, comment): msg = "\n".join(e.desc for e in events) email_intended_status_changed(request, doc, msg) + +class IssueWorkingGroupLastCallForm(forms.Form): + end_date = DatepickerDateField( + required=True, + date_format="yyyy-mm-dd", + picker_settings={ + "autoclose": "1", + }, + help_text="The date the Last Call 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 Last Call 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 Last Call 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() + body = cleaned_data["body"] + subject = cleaned_data["subject"] + end_date = cleaned_data["end_date"] + if end_date.isoformat() not in body: + self.add_error( + "body", + forms.ValidationError( + f"Last call end date ({end_date.isoformat()}) not found in body" + ), + ) + if end_date.isoformat() not in subject: + self.add_error( + "subject", + forms.ValidationError( + f"Last call end date ({end_date.isoformat()}) not found in subject" + ), + ) + return cleaned_data + + +@login_required +def issue_wg_lc(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 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 = IssueWorkingGroupLastCallForm(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 = [] + wglc_state = State.objects.get(type="draft-stream-ietf", slug="wg-lc") + doc.set_state(wglc_state) + e = add_state_change_event(doc, by, prev_state, wglc_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, wglc_state, by) + email_wg_last_call_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"WG Last Call: {doc.name}-{doc.rev} (Ends {end_date})" + body = render_to_string( + "doc/mail/wg_last_call_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_last_call_issued", doc=doc) + + form = IssueWorkingGroupLastCallForm( + initial=dict( + end_date=end_date, + to=", ".join(to), + cc=", ".join(cc), + subject=subject, + body=body, + ) + ) + + return render( + request, + "doc/draft/issue_working_group_last_call.html", + dict( + doc=doc, + form=form, + ), + ) diff --git a/ietf/templates/doc/draft/change_stream_state.html b/ietf/templates/doc/draft/change_stream_state.html index 0b13e02fdf..058db2796e 100644 --- a/ietf/templates/doc/draft/change_stream_state.html +++ b/ietf/templates/doc/draft/change_stream_state.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} +{# Copyright The IETF Trust 2015-2025, All Rights Reserved #} {% load origin %} {% load django_bootstrap5 %} {% block title %}Change {{ state_type.label }} for {{ doc }}{% endblock %} @@ -10,6 +10,14 @@


{{ doc }}

+ {% if doc.stream_id == "ietf" %} +
+ {% if iesg_state_id == "idexists" %} + Submit to IESG for Publication + {% endif %} + Issue{% if has_had_wg_lc %} Another{% endif %} Working Group Last Call +
+ {% endif %} {% if next_states %} Help on states @@ -17,9 +25,7 @@

Move document to {{ next_states|pluralize:"to one of" }} the recommended next state{{ next_states|pluralize }}:

{% for state in next_states %} - {% if state.slug == 'sub-pub' %} - {{ state.name }} - {% else %} + {% if state.slug != 'sub-pub' and state.slug != "wg-lc" %} {% endif %} {% endfor %} diff --git a/ietf/templates/doc/mail/wg_last_call_issued.txt b/ietf/templates/doc/mail/wg_last_call_issued.txt index 35b1e149d7..ff967b2ee3 100644 --- a/ietf/templates/doc/mail/wg_last_call_issued.txt +++ b/ietf/templates/doc/mail/wg_last_call_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 WG Last Call for: +{{ doc.name }}-{{ doc.rev }} -This message starts a {{ wglc_duration_weeks }}-week WG Last Call for this document. +This Working Group Last Call ends on {{ end_date }} Abstract: {{ doc.abstract }} From 1f854e35a1b0b64e6598114b518b63d3c9cae03b Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Wed, 24 Sep 2025 14:02:11 -0500 Subject: [PATCH 2/7] chore: partial ruff formatting --- ietf/doc/mails.py | 3 ++- ietf/doc/tests_draft.py | 10 ++++------ ietf/doc/views_draft.py | 4 +++- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/ietf/doc/mails.py b/ietf/doc/mails.py index fe107b5e21..904c510508 100644 --- a/ietf/doc/mails.py +++ b/ietf/doc/mails.py @@ -135,7 +135,7 @@ def email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=None): def email_wg_last_call_issued(request, doc, end_date): (to, cc) = gather_address_lists("doc_wg_last_call_issued", doc=doc) frm = request.user.person.formatted_email() - subject = f"WG Last Call: {doc.name}-{doc.rev} (Ends {end_date})" + subject = f"WG Last Call: {doc.name}-{doc.rev} (Ends {end_date})" send_mail( request, @@ -153,6 +153,7 @@ def email_wg_last_call_issued(request, doc, end_date): cc=cc, ) + def email_pulled_from_rfc_queue(request, doc, comment, prev_state, next_state): extra=extra_automation_headers(doc) addrs = gather_address_lists('doc_pulled_from_rfc_queue',doc=doc) diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index ab9254e75c..b71496114e 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -2210,10 +2210,8 @@ def test_issue_wg_lc(self): self.client.logout() doc = WgDraftFactory() chair = RoleFactory(name_id="chair", group=doc.group).person - url = urlreverse( - "ietf.doc.views_draft.issue_wg_lc", kwargs=dict(name=doc.name) - ) - login_testing_unauthorized(self, chair.user.username, url) + url = urlreverse("ietf.doc.views_draft.issue_wg_lc", 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) @@ -2227,11 +2225,11 @@ def test_issue_wg_lc(self): postdict["body"] = q("textarea#id_body").text() empty_outbox() r = self.client.post( - url, + url, postdict, ) self.assertEqual(r.status_code, 302) - self.assertEqual(doc.get_state_slug("draft-stream-ietf"),"wg-lc") + self.assertEqual(doc.get_state_slug("draft-stream-ietf"), "wg-lc") self.assertEqual(len(outbox), 2) self.assertIn(f"{doc.group.acronym}@ietf.org", outbox[1]["To"]) self.assertIn("WG Last Call", outbox[1]["Subject"]) diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index d740e4563a..7446d70630 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -1962,7 +1962,9 @@ def issue_wg_lc(request, name): e = add_state_change_event(doc, by, prev_state, wglc_state) events.append(e) end_date = form.cleaned_data["end_date"] - update_reminder(doc, "stream-s", e, datetime_from_date(end_date, DEADLINE_TZINFO)) + update_reminder( + doc, "stream-s", e, datetime_from_date(end_date, DEADLINE_TZINFO) + ) email_stream_state_changed(request, doc, prev_state, wglc_state, by) email_wg_last_call_issued(request, doc, end_date) doc.save_with_history(events) From 5648d263924898f9f977221e63d6923d6bb60a54 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Wed, 24 Sep 2025 14:58:19 -0500 Subject: [PATCH 3/7] fix: improved and tested form validation --- ietf/doc/tests_draft.py | 23 ++++++++++++++++++++++- ietf/doc/views_draft.py | 35 ++++++++++++++++++----------------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index b71496114e..e9d9ec06e3 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -26,7 +26,7 @@ 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 +from ietf.doc.views_draft import AdoptDraftForm, IssueWorkingGroupLastCallForm from ietf.name.models import DocTagName, RoleName from ietf.group.factories import GroupFactory, RoleFactory from ietf.group.models import Group, Role @@ -2198,6 +2198,27 @@ def test_wg_call_for_adoption_issued(self): self.assertIn("disclosure obligations", body) self.assertIn("starts a 2-week", body) + def test_issue_wg_lc_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 = IssueWorkingGroupLastCallForm(post) + self.assertTrue(form.is_valid()) + post["end_date"] = date_today(DEADLINE_TZINFO) + form = IssueWorkingGroupLastCallForm(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 = IssueWorkingGroupLastCallForm(post) + self.assertFalse(form.is_valid()) + self.assertIn(f"Last call end date ({post['end_date'].isoformat()}) not found in subject", form.errors["subject"], "form allowed subject without end_date") + self.assertIn(f"Last call end date ({post['end_date'].isoformat()}) not found in body", form.errors["body"], "form allowed body without end_date") + def test_issue_wg_lc(self): rg_doc = RgDraftFactory() rg_chair = RoleFactory(name_id="chair", group=rg_doc.group).person diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index 7446d70630..3683cb16ca 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -1917,23 +1917,24 @@ def clean_end_date(self): def clean(self): cleaned_data = super().clean() - body = cleaned_data["body"] - subject = cleaned_data["subject"] - end_date = cleaned_data["end_date"] - if end_date.isoformat() not in body: - self.add_error( - "body", - forms.ValidationError( - f"Last call end date ({end_date.isoformat()}) not found in body" - ), - ) - if end_date.isoformat() not in subject: - self.add_error( - "subject", - forms.ValidationError( - f"Last call end date ({end_date.isoformat()}) not found in subject" - ), - ) + 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"Last call end date ({end_date.isoformat()}) not found in body" + ), + ) + if end_date.isoformat() not in subject: + self.add_error( + "subject", + forms.ValidationError( + f"Last call end date ({end_date.isoformat()}) not found in subject" + ), + ) return cleaned_data From 24344fa12850c297a2c710b896e6283a052d330d Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Wed, 24 Sep 2025 14:58:41 -0500 Subject: [PATCH 4/7] fix: add missing template --- .../draft/issue_working_group_last_call.html | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 ietf/templates/doc/draft/issue_working_group_last_call.html diff --git a/ietf/templates/doc/draft/issue_working_group_last_call.html b/ietf/templates/doc/draft/issue_working_group_last_call.html new file mode 100644 index 0000000000..412830edc7 --- /dev/null +++ b/ietf/templates/doc/draft/issue_working_group_last_call.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 Last Call for {{ doc }}{% endblock %} +{% block pagehead %} + +{% endblock %} +{% block content %} + {% origin %} +

+ Issue Working Group Last Call +
+ {{ 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 From 110ea35ed57b97271efa992a7587378fbfbb7f5a Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Wed, 24 Sep 2025 15:38:06 -0500 Subject: [PATCH 5/7] chore: partial ruff --- ietf/doc/tests_draft.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index e9d9ec06e3..8ed0ff186f 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -2199,8 +2199,8 @@ def test_wg_call_for_adoption_issued(self): self.assertIn("starts a 2-week", body) def test_issue_wg_lc_form(self): - end_date=date_today(DEADLINE_TZINFO) + datetime.timedelta(days=1) - post=dict( + 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 @@ -2212,12 +2212,24 @@ def test_issue_wg_lc_form(self): post["end_date"] = date_today(DEADLINE_TZINFO) form = IssueWorkingGroupLastCallForm(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") + 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 = IssueWorkingGroupLastCallForm(post) self.assertFalse(form.is_valid()) - self.assertIn(f"Last call end date ({post['end_date'].isoformat()}) not found in subject", form.errors["subject"], "form allowed subject without end_date") - self.assertIn(f"Last call end date ({post['end_date'].isoformat()}) not found in body", form.errors["body"], "form allowed body without end_date") + self.assertIn( + f"Last call end date ({post['end_date'].isoformat()}) not found in subject", + form.errors["subject"], + "form allowed subject without end_date", + ) + self.assertIn( + f"Last call end date ({post['end_date'].isoformat()}) not found in body", + form.errors["body"], + "form allowed body without end_date", + ) def test_issue_wg_lc(self): rg_doc = RgDraftFactory() From 32ff45511073a26fe4437d5ef83dc4972a7ec0c9 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Thu, 25 Sep 2025 11:31:14 -0500 Subject: [PATCH 6/7] fix: guard issuing wglc against several conditions --- ietf/doc/tests_draft.py | 57 +++++++++++++------ ietf/doc/views_draft.py | 6 ++ .../doc/draft/change_stream_state.html | 4 +- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index 8ed0ff186f..cf18e3538b 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -20,7 +20,7 @@ import debug # pyflakes:ignore from ietf.doc.expire import expirable_drafts, get_expired_drafts, send_expire_notice_for_draft, expire_draft -from ietf.doc.factories import EditorialDraftFactory, IndividualDraftFactory, StateDocEventFactory, WgDraftFactory, RgDraftFactory, DocEventFactory +from ietf.doc.factories import EditorialDraftFactory, IndividualDraftFactory, StateDocEventFactory, WgDraftFactory, RgDraftFactory, DocEventFactory, WgRfcFactory from ietf.doc.models import ( Document, DocReminder, DocEvent, ConsensusDocEvent, LastCallDocEvent, RelatedDocument, State, TelechatDocEvent, WriteupDocEvent, DocRelationshipName, IanaExpertDocEvent ) @@ -2007,7 +2007,24 @@ def _form_presents_state_option(response, state): q = PyQuery(response.content) option = q(f"select#id_new_state option[value='{state.pk}']") return len(option) != 0 - + + 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.change_stream_state", + kwargs=dict(name=rfc.came_from_draft().name, state_type="draft-stream-ietf"), + ) + login_testing_unauthorized(self, rfc_chair.user.username, url) + r = self.client.get(url) + self.assertFalse(_view_presents_issue_wglc_button(r)) + self.client.logout() doc = WgDraftFactory() chair = RoleFactory(name_id="chair", group=doc.group).person url = urlreverse( @@ -2016,9 +2033,7 @@ def _form_presents_state_option(response, state): ) login_testing_unauthorized(self, chair.user.username, url) r = self.client.get(url) - self.assertContains( - response=r, text="Issue Working Group Last Call", status_code=200 - ) + self.assertTrue(_view_presents_issue_wglc_button(r)) wglc_state = State.objects.get(type="draft-stream-ietf", slug="wg-lc") self.assertFalse(_form_presents_state_option(r, wglc_state)) r = self.client.post( @@ -2044,9 +2059,7 @@ def _form_presents_state_option(response, state): ) self.assertEqual(doc.docevent_set.count(), 2) r = self.client.get(url) - self.assertContains( - response=r, text="Issue Another Working Group Last Call", status_code=200 - ) + self.assertFalse(_view_presents_issue_wglc_button(r)) self.assertTrue(_form_presents_state_option(r, wglc_state)) r = self.client.post( url, @@ -2064,6 +2077,10 @@ def _form_presents_state_option(response, state): ) self.assertEqual(r.status_code, 302) self.assertEqual(doc.docevent_set.count(), 3) + doc.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_doc = WgDraftFactory() self.client.logout() url = urlreverse( @@ -2232,15 +2249,24 @@ def test_issue_wg_lc_form(self): ) def test_issue_wg_lc(self): + def _assert_rejected(testcase, doc, person): + url = urlreverse( + "ietf.doc.views_draft.issue_wg_lc", 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_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 - url = urlreverse( - "ietf.doc.views_draft.issue_wg_lc", kwargs=dict(name=rg_doc.name) - ) - login_testing_unauthorized(self, rg_chair.user.username, url) - r = self.client.get(url) - self.assertEqual(r.status_code, 404) - self.client.logout() + _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() chair = RoleFactory(name_id="chair", group=doc.group).person url = urlreverse("ietf.doc.views_draft.issue_wg_lc", kwargs=dict(name=doc.name)) @@ -2269,7 +2295,6 @@ def test_issue_wg_lc(self): 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') diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index 3683cb16ca..dd88af86c7 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -1848,6 +1848,8 @@ def change_stream_state(request, name, state_type): "state_type": state_type, "next_states": next_states, "iesg_state_id": doc.get_state_slug("draft-iesg"), + "ietf_stream_state_id": doc.get_state_slug("draft-stream-ietf"), + "draft_state_id": doc.get_state_slug("draft"), "has_had_wg_lc": doc.docevent_set.filter(statedocevent__state__slug="wg-lc").exists(), }) @@ -1946,6 +1948,10 @@ def issue_wg_lc(request, name): raise Http404 if doc.group is None or doc.group.type_id != "wg": raise Http404 + if doc.get_state_slug("draft-stream-ietf") == "wg-lc": + raise Http404 + if doc.get_state_slug("draft") == "rfc": + raise Http404 if not is_authorized_in_doc_stream(request.user, doc): permission_denied(request, "You don't have permission to access this page.") diff --git a/ietf/templates/doc/draft/change_stream_state.html b/ietf/templates/doc/draft/change_stream_state.html index 058db2796e..bdffd60aaa 100644 --- a/ietf/templates/doc/draft/change_stream_state.html +++ b/ietf/templates/doc/draft/change_stream_state.html @@ -12,10 +12,12 @@

{% if doc.stream_id == "ietf" %}
+ {% if ietf_stream_state_id != "wg-lc" and draft_state_id != "rfc" %} + Issue{% if has_had_wg_lc %} Another{% endif %} Working Group Last Call + {% endif %} {% if iesg_state_id == "idexists" %} Submit to IESG for Publication {% endif %} - Issue{% if has_had_wg_lc %} Another{% endif %} Working Group Last Call
{% endif %} {% if next_states %} From fb6adfa63e55041e38d8c8e4878cb6c2581784fc Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Fri, 26 Sep 2025 08:29:40 -0500 Subject: [PATCH 7/7] fix: remove stray html type attributes --- ietf/templates/doc/draft/change_stream_state.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ietf/templates/doc/draft/change_stream_state.html b/ietf/templates/doc/draft/change_stream_state.html index bdffd60aaa..a1b1c86774 100644 --- a/ietf/templates/doc/draft/change_stream_state.html +++ b/ietf/templates/doc/draft/change_stream_state.html @@ -13,10 +13,10 @@

{% if doc.stream_id == "ietf" %}
{% if ietf_stream_state_id != "wg-lc" and draft_state_id != "rfc" %} - Issue{% if has_had_wg_lc %} Another{% endif %} Working Group Last Call + Issue{% if has_had_wg_lc %} Another{% endif %} Working Group Last Call {% endif %} {% if iesg_state_id == "idexists" %} - Submit to IESG for Publication + Submit to IESG for Publication {% endif %}
{% endif %}