diff --git a/ietf/doc/mails.py b/ietf/doc/mails.py index f20d398c3c..904c510508 100644 --- a/ietf/doc/mails.py +++ b/ietf/doc/mails.py @@ -132,15 +132,10 @@ 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})" + subject = f"WG Last Call: {doc.name}-{doc.rev} (Ends {end_date})" send_mail( request, @@ -153,12 +148,12 @@ 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, ) + 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 ab33acebe6..cf18e3538b 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -20,13 +20,13 @@ 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, WgRfcFactory from ietf.doc.models import ( Document, DocReminder, DocEvent, ConsensusDocEvent, LastCallDocEvent, RelatedDocument, State, TelechatDocEvent, 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 @@ -2002,6 +2002,98 @@ 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 + + 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( + "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.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( + 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.assertFalse(_view_presents_issue_wglc_button(r)) + 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) + 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( + "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 +2215,85 @@ 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) - url = urlreverse( - "ietf.doc.views_draft.change_stream_state", - kwargs=dict(name=draft.name, state_type="draft-stream-ietf"), + 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()}", ) - 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" + 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", ) - 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) - ) - ], - ), + 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.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) - url = urlreverse( - "ietf.doc.views_draft.change_stream_state", - kwargs=dict(name=draft.name, state_type="draft-stream-ietf"), + self.assertIn( + f"Last call end date ({post['end_date'].isoformat()}) not found in body", + form.errors["body"], + "form allowed body without end_date", ) - 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) + + 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 + _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)) + 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) - ) - ], - ), + 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..dd88af86c7 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,10 @@ 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"), + "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(), }) # This should be in ietf.doc.utils, but placing it there brings a circular import issue with ietf.doc.mail @@ -1857,3 +1885,126 @@ 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() + 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 + + +@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 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.") + + 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..a1b1c86774 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,16 @@
+ Please correct the following: +
+ {% endif %} + {% bootstrap_form_errors form %} + +{% endblock %} +{% block js %} + +{% endblock %} \ No newline at end of file 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 }}