diff --git a/ietf/doc/forms.py b/ietf/doc/forms.py index 8a1e9ecb986..768d6f96af2 100644 --- a/ietf/doc/forms.py +++ b/ietf/doc/forms.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2013-2020, All Rights Reserved +# Copyright The IETF Trust 2013-2025, All Rights Reserved # -*- coding: utf-8 -*- @@ -9,7 +9,7 @@ from django.core.validators import validate_email from ietf.doc.fields import SearchableDocumentField, SearchableDocumentsField -from ietf.doc.models import RelatedDocument, DocExtResource +from ietf.doc.models import RelatedDocument, DocExtResource, State from ietf.iesg.models import TelechatDate from ietf.iesg.utils import telechat_page_count from ietf.person.fields import SearchablePersonField, SearchablePersonsField @@ -61,7 +61,7 @@ class DocAuthorChangeBasisForm(forms.Form): basis = forms.CharField(max_length=255, label='Reason for change', help_text='What is the source or reasoning for the changes to the author list?') - + class AdForm(forms.Form): ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active", role__group__type='area').order_by('name'), label="Shepherding AD", empty_label="(None)", required=True) @@ -288,3 +288,10 @@ def clean_name_fragment(self): if any(c in name_fragment for c in disallowed_characters): raise ValidationError(f"The following characters are disallowed: {', '.join(disallowed_characters)}") return name_fragment + + +class ChangeStatementStateForm(forms.Form): + state = forms.ModelChoiceField( + State.objects.filter(used=True, type="statement"), + empty_label=None, + ) diff --git a/ietf/doc/tests_statement.py b/ietf/doc/tests_statement.py index fea42b97d6f..bb097b18280 100644 --- a/ietf/doc/tests_statement.py +++ b/ietf/doc/tests_statement.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2023, All Rights Reserved +# Copyright The IETF Trust 2023-2025, All Rights Reserved import debug # pyflakes:ignore @@ -372,3 +372,36 @@ def test_submit_non_markdown_formats(self): self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertTrue("Unexpected content" in q("#id_statement_file").next().text()) + + def test_change_statement_state(self): + statement = StatementFactory() # starts in "active" state + active_state = State.objects.get(type_id="statement", slug="active") + replaced_state = State.objects.get(type_id="statement", slug="replaced") + url = urlreverse( + "ietf.doc.views_statement.change_statement_state", + kwargs={"name": statement.name}, + ) + + events_before = statement.docevent_set.count() + login_testing_unauthorized(self, "secretary", url) + + r = self.client.get(url) + self.assertEqual(r.status_code,200) + + r = self.client.post(url, {"state": active_state.pk}, follow=True) + self.assertContains(r, "State not changed", status_code=200) + statement = Document.objects.get(pk=statement.pk) # bust the state cache + self.assertEqual(statement.get_state(), active_state) + + r = self.client.post(url, {"state": replaced_state.pk}, follow=True) + self.assertContains(r, "State changed to", status_code=200) + statement = Document.objects.get(pk=statement.pk) # bust the state cache + self.assertEqual(statement.get_state(), replaced_state) + + events_after = statement.docevent_set.count() + self.assertEqual(events_after, events_before + 1) + event = statement.docevent_set.first() + self.assertEqual(event.type, "changed_state") + self.assertEqual( + event.desc, "Statement State changed to Replaced from Active" + ) diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index 0fa1a04b498..60255af856c 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2009-2023, All Rights Reserved +# Copyright The IETF Trust 2009-2025, All Rights Reserved # -*- coding: utf-8 -*- # Copyright (C) 2009 Nokia Corporation and/or its subsidiary(-ies). # All rights reserved. Contact: Pasi Eronen @@ -145,6 +145,7 @@ url(r'^%(name)s/edit/adopt/$' % settings.URL_REGEXPS, views_draft.adopt_draft), 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/state/statement/$' % settings.URL_REGEXPS, views_statement.change_statement_state), url(r'^%(name)s/edit/clearballot/(?P[\w-]+)/$' % settings.URL_REGEXPS, views_ballot.clear_ballot), url(r'^%(name)s/edit/deferballot/$' % settings.URL_REGEXPS, views_ballot.defer_ballot), diff --git a/ietf/doc/views_statement.py b/ietf/doc/views_statement.py index 9dc8c8ad697..4b85c81d838 100644 --- a/ietf/doc/views_statement.py +++ b/ietf/doc/views_statement.py @@ -1,4 +1,5 @@ -# Copyright The IETF Trust 2023, All Rights Reserved +# Copyright The IETF Trust 2023-2025, All Rights Reserved +from django.contrib import messages import debug # pyflakes: ignore @@ -6,10 +7,13 @@ from django import forms from django.conf import settings -from django.http import FileResponse, Http404 +from django.http import FileResponse, Http404, HttpResponseRedirect from django.views.decorators.cache import cache_control from django.shortcuts import get_object_or_404, render, redirect from django.template.loader import render_to_string + +from ietf.doc.forms import ChangeStatementStateForm +from ietf.doc.utils import add_state_change_event from ietf.utils import markdown from django.utils.html import escape @@ -278,3 +282,40 @@ def new_statement(request): } form = NewStatementForm(initial=init) return render(request, "doc/statement/new_statement.html", {"form": form}) + + +@role_required("Secretariat") +def change_statement_state(request, name): + """Change state of a statement Document""" + statement = get_object_or_404( + Document.objects.filter(type_id="statement"), + name=name, + ) + if request.method == "POST": + form = ChangeStatementStateForm(request.POST) + if form.is_valid(): + new_state = form.cleaned_data["state"] + prev_state = statement.get_state() + if new_state == prev_state: + messages.info(request, f"State not changed, remains {prev_state}.") + else: + statement.set_state(new_state) + e = add_state_change_event( + statement, + request.user.person, + prev_state, + new_state, + ) + statement.save_with_history([e]) + messages.success(request, f"State changed to {new_state}.") + return HttpResponseRedirect(statement.get_absolute_url()) + else: + form = ChangeStatementStateForm(initial={"state": statement.get_state()}) + return render( + request, + "doc/statement/change_statement_state.html", + { + "form": form, + "statement": statement, + }, + ) diff --git a/ietf/templates/doc/document_statement.html b/ietf/templates/doc/document_statement.html index 7b9759c3e95..cc3ea5a44c4 100644 --- a/ietf/templates/doc/document_statement.html +++ b/ietf/templates/doc/document_statement.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{# Copyright The IETF Trust 2023, All Rights Reserved #} +{# Copyright The IETF Trust 2023-2025, All Rights Reserved #} {% load origin %} {% load static %} {% load ietf_filters %} @@ -49,7 +49,12 @@ State - + {% if can_manage %} + + Edit + + {% endif %} {% if doc.get_state %} {{ doc.get_state.name }} diff --git a/ietf/templates/doc/statement/change_statement_state.html b/ietf/templates/doc/statement/change_statement_state.html new file mode 100644 index 00000000000..aa5cb934e84 --- /dev/null +++ b/ietf/templates/doc/statement/change_statement_state.html @@ -0,0 +1,22 @@ +{# Copyright The IETF Trust 2025, All Rights Reserved #} +{% extends "base.html" %} +{% load origin %} +{% load django_bootstrap5 %} +{% block title %}Change state for {{ statement }}{% endblock %} +{% block content %} + {% origin %} +

+ Change state +
+ {{ statement }} +

+
+ {% csrf_token %} + {% bootstrap_form form %} + + + Back + +
+{% endblock %} \ No newline at end of file