diff --git a/ietf/doc/templatetags/ietf_filters.py b/ietf/doc/templatetags/ietf_filters.py index 42103658ce..6cd9e51a26 100644 --- a/ietf/doc/templatetags/ietf_filters.py +++ b/ietf/doc/templatetags/ietf_filters.py @@ -1018,9 +1018,10 @@ def is_in_stream(doc): return True return False + @register.filter -def can_issue_ietf_call_for_adoption(doc): - return all( +def is_doc_ietf_adoptable(doc): + return doc.stream_id is None or all( [ doc.stream_id == "ietf", doc.get_state_slug("draft-stream-ietf") @@ -1039,6 +1040,7 @@ def can_issue_ietf_call_for_adoption(doc): ] ) + @register.filter def can_issue_ietf_wg_lc(doc): return all( diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index eb0cff4dc0..5889f11d2d 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -2287,15 +2287,50 @@ def test_issue_wg_call_for_adoption_form(self): ) def test_issue_wg_call_for_adoption(self): - def _assert_rejected(testcase, doc, person): + def _assert_rejected(testcase, doc, person, group=None): + target_acronym = group.acronym if group is not None else doc.group.acronym url = urlreverse( - "ietf.doc.views_draft.issue_wg_call_for_adoption", kwargs=dict(name=doc.name) + "ietf.doc.views_draft.issue_wg_call_for_adoption", + kwargs=dict(name=doc.name, acronym=target_acronym), ) login_testing_unauthorized(testcase, person.user.username, url) r = testcase.client.get(url) - testcase.assertEqual(r.status_code, 404) + testcase.assertEqual(r.status_code, 403) testcase.client.logout() + def _verify_call_issued(testcase, doc, chair_role): + url = urlreverse( + "ietf.doc.views_draft.issue_wg_call_for_adoption", + kwargs=dict(name=doc.name, acronym=chair_role.group.acronym), + ) + login_testing_unauthorized(testcase, chair_role.person.user.username, url) + r = testcase.client.get(url) + testcase.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 = testcase.client.post( + url, + postdict, + ) + testcase.assertEqual(r.status_code, 302) + doc.refresh_from_db() + 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) + self.client.logout() + return doc + already_rfc = WgDraftFactory(states=[("draft", "rfc")]) rfc = WgRfcFactory(group=already_rfc.group) already_rfc.relateddocument_set.create(relationship_id="became_rfc",target=rfc) @@ -2307,33 +2342,25 @@ def _assert_rejected(testcase, doc, person): 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) + ind_doc = IndividualDraftFactory() + _assert_rejected(self, ind_doc, rg_chair, rg_doc.group) + + # Successful call issued for doc already in WG 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) + chair_role = RoleFactory(name_id="chair",group=doc.group) + _ = _verify_call_issued(self, doc, chair_role) + + # Successful call issued for doc not yet in WG + doc = IndividualDraftFactory() + chair_role = RoleFactory(name_id="chair",group__type_id="wg") + doc = _verify_call_issued(self, doc, chair_role) + self.assertEqual(doc.group, chair_role.group) + self.assertEqual(doc.stream_id, "ietf") 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) + self.assertCountEqual( + doc.docevent_set.values_list("type", flat=True), + ["changed_state", "changed_group", "changed_stream", "new_revision"] + ) 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') @@ -2606,14 +2633,6 @@ def test_ask_about_ietf_adoption_call(self): 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"], - ) def test_offer_wg_action_helpers(self): def _assert_view_presents_buttons(testcase, response, expected): diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index 4d60599ed2..61e94b2231 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -146,8 +146,7 @@ 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/issue-wg-call-for-adoption/%(acronym)s/$' % 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), diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index 78a622bae8..2a07036cca 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -34,7 +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.templatetags.ietf_filters import is_doc_ietf_adoptable 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, @@ -2075,18 +2075,22 @@ def clean(self): return cleaned_data @login_required -def issue_wg_call_for_adoption(request, name): +def issue_wg_call_for_adoption(request, name, acronym): 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.") + group = Group.objects.filter(acronym=acronym, type_id="wg").first() + reject = False + if group is None or doc.type_id != "draft" or not is_doc_ietf_adoptable(doc): + reject = True + if doc.stream is None: + if not can_adopt_draft(request.user, doc): + reject = True + elif doc.stream_id != "ietf": + reject = True + else: # doc.stream_id == "ietf" + if not is_authorized_in_doc_stream(request.user, doc): + reject = True + if reject: + raise permission_denied(request, f"You can't issue a {acronym} wg call for adoption for this document.") if request.method == "POST": form = IssueCallForAdoptionForm(request.POST) @@ -2094,8 +2098,22 @@ def issue_wg_call_for_adoption(request, name): # 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 = [] + if doc.stream_id != "ietf": + 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) + if doc.group != group: + doc.group = group + 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) + prev_state = doc.get_state("draft-stream-ietf") 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) @@ -2104,9 +2122,9 @@ def issue_wg_call_for_adoption(request, name): update_reminder( doc, "stream-s", e, datetime_from_date(end_date, DEADLINE_TZINFO) ) + doc.save_with_history(events) 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) @@ -2115,6 +2133,7 @@ def issue_wg_call_for_adoption(request, name): "doc/mail/wg_call_for_adoption_issued.txt", dict( doc=doc, + group=group, end_date=end_date, url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(), wg_list=doc.group.list_email, @@ -2172,29 +2191,8 @@ def ask_about_ietf_adoption_call(request, name): 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) + return redirect(issue_wg_call_for_adoption, name=doc.name, acronym=group.acronym) else: form = WgForm(initial={"group": None}, user=request.user) return render( diff --git a/ietf/ietfauth/utils.py b/ietf/ietfauth/utils.py index e2893a90f7..1f634278be 100644 --- a/ietf/ietfauth/utils.py +++ b/ietf/ietfauth/utils.py @@ -211,9 +211,9 @@ def role_required(*role_names): # specific permissions + def is_authorized_in_doc_stream(user, doc): - """Return whether user is authorized to perform stream duties on - document.""" + """Is user authorized to perform stream duties on doc?""" if has_role(user, ["Secretariat"]): return True diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 0004323c7b..eab1d779fb 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 + {% if doc|is_doc_ietf_adoptable %} + 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 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 e5b49c9b9d..b824270a86 100644 --- a/ietf/templates/doc/mail/wg_call_for_adoption_issued.txt +++ b/ietf/templates/doc/mail/wg_call_for_adoption_issued.txt @@ -1,6 +1,7 @@ -{% load ietf_filters %}{% load mail_filters %}{% autoescape off %}{% filter wordwrap:78 %}This message starts a {{doc.group.acronym}} WG Call for Adoption of: +{% load ietf_filters %}{% load mail_filters %}{% autoescape off %}{% filter wordwrap:78 %}This message starts a {{group.acronym}} WG Call for Adoption of: {{ doc.name }}-{{ doc.rev }} + This Working Group Call for Adoption ends on {{ end_date }} Abstract: @@ -9,7 +10,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 {{doc.group.acronym}} 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 {{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].