This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new 29f8054  Make the form to start a vote more type safe
29f8054 is described below

commit 29f805480c3d0ca60f15a417afd406babfa94c64
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Nov 9 12:00:04 2025 +0000

    Make the form to start a vote more type safe
---
 atr/get/voting.py    | 129 +++++++++++++++++++++++-
 atr/post/voting.py   |  63 +++++++++++-
 atr/shared/voting.py | 277 +++------------------------------------------------
 playwright/test.py   |   2 +-
 4 files changed, 204 insertions(+), 267 deletions(-)

diff --git a/atr/get/voting.py b/atr/get/voting.py
index 2334696..b55b543 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -16,8 +16,20 @@
 # under the License.
 
 
+import aiofiles.os
+
 import atr.blueprints.get as get
+import atr.construct as construct
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.form as form
+import atr.get.compose as compose
+import atr.get.keys as keys
+import atr.htm as htm
+import atr.models.sql as sql
 import atr.shared as shared
+import atr.template as template
+import atr.util as util
 import atr.web as web
 
 
@@ -25,4 +37,119 @@ import atr.web as web
 async def selected_revision(
     session: web.Committer, project_name: str, version_name: str, revision: str
 ) -> web.WerkzeugResponse | str:
-    return await shared.voting.selected_revision(session, project_name, 
version_name, revision)
+    await session.check_access(project_name)
+
+    async with db.session() as data:
+        match await interaction.release_ready_for_vote(
+            session, project_name, version_name, revision, data, 
manual_vote=False
+        ):
+            case str() as error:
+                return await session.redirect(
+                    compose.selected,
+                    error=error,
+                    project_name=project_name,
+                    version_name=version_name,
+                    revision=revision,
+                )
+            case (release, committee):
+                pass
+
+        permitted_recipients = util.permitted_voting_recipients(session.uid, 
committee.name)
+
+        min_hours = 72
+        if release.release_policy and (release.release_policy.min_hours is not 
None):
+            min_hours = release.release_policy.min_hours
+
+        # TODO: Add the draft revision number or tag to the subject
+        default_subject = f"[VOTE] Release {release.project.display_name} 
{release.version}"
+        default_body = await construct.start_vote_default(project_name)
+
+        keys_warning = await _check_keys_warning(committee)
+
+        content = await _render_page(
+            release=release,
+            permitted_recipients=permitted_recipients,
+            default_subject=default_subject,
+            default_body=default_body,
+            min_hours=min_hours,
+            keys_warning=keys_warning,
+        )
+
+        return await template.blank(
+            title=f"Start voting on {release.project.short_display_name} 
{release.version}", content=content
+        )
+
+
+async def _check_keys_warning(committee: sql.Committee) -> bool:
+    if committee.is_podling:
+        keys_file_path = util.get_downloads_dir() / "incubator" / 
committee.name / "KEYS"
+    else:
+        keys_file_path = util.get_downloads_dir() / committee.name / "KEYS"
+
+    return not await aiofiles.os.path.isfile(keys_file_path)
+
+
+async def _render_page(
+    release,
+    permitted_recipients: list[str],
+    default_subject: str,
+    default_body: str,
+    min_hours: int,
+    keys_warning: bool,
+) -> htm.Element:
+    page = htm.Block()
+
+    back_link_url = util.as_url(
+        compose.selected,
+        project_name=release.project.name,
+        version_name=release.version,
+    )
+    shared.distribution.html_nav(
+        page,
+        back_link_url,
+        f"Compose {release.short_display_name}",
+        "COMPOSE",
+    )
+
+    page.h1(".mb-4")[
+        "Start voting on ",
+        htm.strong[release.project.short_display_name],
+        " ",
+        htm.em[release.version],
+    ]
+
+    page.div(".px-3.py-4.mb-4.bg-light.border.rounded")[
+        htm.p(".mb-0")[
+            "Starting a vote for this draft release will cause an email to be 
sent to the appropriate mailing list, "
+            "and advance the draft to the VOTE phase. Please note that this 
feature is currently in development."
+        ]
+    ]
+
+    if keys_warning:
+        keys_url = util.as_url(keys.keys) + 
f"#committee-{release.committee.name}"
+        page.div(".p-3.mb-4.bg-warning-subtle.border.border-warning.rounded")[
+            htm.strong["Warning: "],
+            "The KEYS file is missing. Please autogenerate one on the ",
+            htm.a(href=keys_url)["KEYS page"],
+            ".",
+        ]
+
+    cancel_url = util.as_url(
+        compose.selected,
+        project_name=release.project.name,
+        version_name=release.version,
+    )
+    vote_form = await form.render(
+        model_cls=shared.voting.StartVotingForm,
+        submit_label="Send vote email",
+        cancel_url=cancel_url,
+        defaults={
+            "mailing_list": permitted_recipients,
+            "vote_duration": min_hours,
+            "subject": default_subject,
+            "body": default_body,
+        },
+    )
+    page.append(vote_form)
+
+    return page.collect()
diff --git a/atr/post/voting.py b/atr/post/voting.py
index 11ab5be..77798f4 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -15,15 +15,72 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from __future__ import annotations
 
 import atr.blueprints.post as post
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.get as get
+import atr.log as log
 import atr.shared as shared
+import atr.storage as storage
+import atr.util as util
 import atr.web as web
 
 
 @post.committer("/voting/<project_name>/<version_name>/<revision>")
[email protected](shared.voting.StartVotingForm)
 async def selected_revision(
-    session: web.Committer, project_name: str, version_name: str, revision: str
+    session: web.Committer,
+    start_voting_form: shared.voting.StartVotingForm,
+    project_name: str,
+    version_name: str,
+    revision: str,
 ) -> web.WerkzeugResponse | str:
-    """Show the vote initiation form for a release."""
-    return await shared.voting.selected_revision(session, project_name, 
version_name, revision)
+    await session.check_access(project_name)
+
+    async with db.session() as data:
+        match await interaction.release_ready_for_vote(
+            session, project_name, version_name, revision, data, 
manual_vote=False
+        ):
+            case str() as error:
+                return await session.redirect(
+                    get.compose.selected,
+                    error=error,
+                    project_name=project_name,
+                    version_name=version_name,
+                    revision=revision,
+                )
+            case (release, committee):
+                pass
+
+        permitted_recipients = util.permitted_voting_recipients(session.uid, 
committee.name)
+        if start_voting_form.mailing_list not in permitted_recipients:
+            return await session.form_error(
+                "mailing_list",
+                f"Invalid mailing list selection: 
{start_voting_form.mailing_list}",
+            )
+
+        async with storage.write_as_committee_participant(committee.name) as 
wacp:
+            _task = await wacp.vote.start(
+                start_voting_form.mailing_list,
+                project_name,
+                version_name,
+                revision,
+                start_voting_form.vote_duration,
+                start_voting_form.subject,
+                start_voting_form.body,
+                session.uid,
+                session.fullname,
+                release=release,
+                promote=True,
+                permitted_recipients=permitted_recipients,
+            )
+
+        log.info(f"Vote email will be sent to: 
{start_voting_form.mailing_list}")
+        return await session.redirect(
+            get.vote.selected,
+            success=f"The vote announcement email will soon be sent to 
{start_voting_form.mailing_list}.",
+            project_name=project_name,
+            version_name=version_name,
+        )
diff --git a/atr/shared/voting.py b/atr/shared/voting.py
index 4a25a64..f72b52e 100644
--- a/atr/shared/voting.py
+++ b/atr/shared/voting.py
@@ -15,269 +15,22 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import atr.form as form
 
-import aiofiles.os
-import asfquart.base as base
-import quart
-import quart_wtf.typing as typing
 
-import atr.construct as construct
-import atr.db as db
-import atr.db.interaction as interaction
-import atr.forms as forms
-import atr.get.compose as compose
-import atr.get.vote as vote
-import atr.log as log
-import atr.models.sql as sql
-import atr.storage as storage
-import atr.template as template
-import atr.user as user
-import atr.util as util
-import atr.web as web
-
-
-class VoteInitiateForm(forms.Typed):
-    """Form for initiating a release vote."""
-
-    release_name = forms.hidden()
-    mailing_list = forms.radio("Send vote email to")
-    vote_duration = forms.integer(
-        "Minimum vote duration", default=72, description="Minimum number of 
hours the vote will be open for."
-    )
-    subject = forms.optional("Subject")
-    body = forms.textarea(
-        "Body",
-        description="Edit the vote email content as needed. Placeholders like 
[KEY_FINGERPRINT],"
-        " [DURATION], [REVIEW_URL], and [YOUR_ASF_ID] will be filled in 
automatically when the email is sent.",
-    )
-    submit = forms.submit("Send vote email")
-
-
-async def selected_revision(
-    session: web.Committer, project_name: str, version_name: str, revision: str
-) -> web.WerkzeugResponse | str:
-    """Show the vote initiation form for a release."""
-    await session.check_access(project_name)
-
-    async with db.session() as data:
-        release = await session.release(
-            project_name,
-            version_name,
-            data=data,
-            with_project=True,
-            with_committee=True,
-            with_project_release_policy=True,
-        )
-        selected_revision_number = release.latest_revision_number
-        if selected_revision_number is None:
-            return await session.redirect(compose.selected, error="No revision 
found for this release")
-        if revision != selected_revision_number:
-            return await session.redirect(
-                compose.selected, error="The selected revision does not match 
the revision you are voting on"
-            )
-        response_or_form = await _selected_revision_data(
-            release, project_name, version_name, selected_revision_number, 
data, session
-        )
-        if not isinstance(response_or_form, VoteInitiateForm):
-            return response_or_form
-        form = response_or_form
-
-    keys_warning = await _keys_warning(release)
-    manual_vote_process_form = None
-    if release.project.policy_manual_vote:
-        manual_vote_process_form = await forms.Hidden.create_form()
-        manual_vote_process_form.hidden_field.data = selected_revision_number
-    has_files = await util.has_files(release)
-    if not has_files:
-        return await session.redirect(
-            compose.selected,
-            error="This release candidate draft has no files yet. Please add 
some files before starting a vote.",
-            project_name=project_name,
-            version_name=version_name,
-        )
-
-    # For GET requests or failed POST validation
-    return await template.render(
-        "voting-selected-revision.html",
-        release=release,
-        form=form,
-        revision=revision,
-        keys_warning=keys_warning,
-        manual_vote_process_form=manual_vote_process_form,
-        user_tests_address=util.USER_TESTS_ADDRESS,
-    )
-
-
-async def start_vote_manual(
-    release: sql.Release,
-    selected_revision_number: str,
-    session: web.Committer,
-    _data: db.Session,
-) -> web.WerkzeugResponse | str:
-    async with storage.write(session) as write:
-        wacp = await 
write.as_project_committee_participant(release.project_name)
-        # This verifies the state and sets the phase to RELEASE_CANDIDATE
-        error = await wacp.release.promote_to_candidate(release.name, 
selected_revision_number, vote_manual=True)
-    if error:
-        import atr.get.root as root
-
-        return await session.redirect(root.index, error=error)
-    return await session.redirect(
-        vote.selected,
-        success="The manual vote process has been started.",
-        project_name=release.project.name,
-        version_name=release.version,
-    )
-
-
-async def _form(
-    release: sql.Release,
-    form_data: typing.FormData | None,
-    project_name: str,
-    version_name: str,
-    permitted_recipients: list[str],
-    release_policy_mailto_addresses: str,
-    # TODO: Restore the use of min_hours
-    min_hours: int,
-) -> VoteInitiateForm:
-    project = release.project
-
-    # The subject can be changed by the user
-    # TODO: We should consider not allowing the subject to be changed
-    default_subject = f"[VOTE] Release {project.display_name} {version_name}"
-    default_body = await construct.start_vote_default(project_name)
-
-    # Must use data, not formdata, otherwise everything breaks
-    form = await VoteInitiateForm.create_form(
-        data=form_data if (quart.request.method == "POST") else None,
+class StartVotingForm(form.Form):
+    mailing_list: str = form.label(
+        "Send vote email to",
+        "Note: The options to send to the user-tests "
+        "mailing list and yourself are provided for "
+        "testing purposes only, and will not be "
+        "available in the finished version of ATR.",
+        widget=form.Widget.RADIO,
     )
-
-    # Set defaults
-    choices: forms.Choices = sorted([(recipient, recipient) for recipient in 
permitted_recipients])
-    if quart.request.method == "GET":
-        # Defaults for GET requests
-        form.subject.data = default_subject
-        form.body.data = default_body
-        # Choices and defaults for mailing list
-        forms.choices(form.mailing_list, choices, 
default=util.USER_TESTS_ADDRESS)
-    else:
-        forms.choices(form.mailing_list, choices)
-    # Hidden field
-    form.release_name.data = release.name
-    # Description
-    form.mailing_list.description = """\
-NOTE: The options to send to the user-tests mailing
-list and yourself are provided for testing purposes
-only, and will not be available in the finished
-version of ATR."""
-
-    return form
-
-
-async def _keys_warning(
-    release: sql.Release,
-) -> bool:
-    """Return a warning about keys if there are any issues."""
-    if release.committee is None:
-        raise base.ASFQuartException("Release has no associated committee", 
errorcode=400)
-
-    if release.committee.is_podling:
-        keys_file_path = util.get_downloads_dir() / "incubator" / 
release.committee.name / "KEYS"
-    else:
-        keys_file_path = util.get_downloads_dir() / release.committee.name / 
"KEYS"
-    return not await aiofiles.os.path.isfile(keys_file_path)
-
-
-async def _selected_revision_data(
-    release: sql.Release,
-    project_name: str,
-    version_name: str,
-    revision: str,
-    data: db.Session,
-    session: web.Committer,
-) -> web.WerkzeugResponse | str | VoteInitiateForm:
-    committee = release.committee
-    if committee is None:
-        raise base.ASFQuartException("Release has no associated committee", 
errorcode=400)
-
-    if release.project.policy_strict_checking:
-        if await interaction.has_failing_checks(release, revision, 
caller_data=data):
-            return await session.redirect(
-                compose.selected,
-                error="This release candidate draft has errors. Please fix the 
errors before starting a vote.",
-                project_name=project_name,
-                version_name=version_name,
-                revision=revision,
-            )
-
-    # Check that the user is on the project committee for the release
-    # TODO: Consider relaxing this to all committers
-    # Otherwise we must not show the vote form
-    if not (user.is_committee_member(committee, session.uid) or 
user.is_admin(session.uid)):
-        return await session.redirect(
-            compose.selected,
-            error="You must be on the PMC of this project to start a vote",
-            project_name=project_name,
-            version_name=version_name,
-            revision=revision,
-        )
-
-    # committee = util.unwrap(release.committee)
-    permitted_recipients = util.permitted_voting_recipients(session.uid, 
committee.name)
-    if release.release_policy:
-        min_hours = release.release_policy.min_hours if 
(release.release_policy.min_hours is not None) else 72
-    else:
-        min_hours = 72
-    release_policy_mailto_addresses = ", 
".join(release.project.policy_mailto_addresses)
-
-    form_data = (await quart.request.form) if (quart.request.method == "POST") 
else None
-    hidden_field = (form_data or {}).get("hidden_field")
-    if isinstance(hidden_field, str):
-        # This hidden_field is set to selected_revision_number
-        # It's manual_vote_process_form.hidden_field.data in selected_revision
-        selected_revision_number = hidden_field
-        return await start_vote_manual(
-            release,
-            selected_revision_number,
-            session,
-            data,
-        )
-
-    form = await _form(
-        release,
-        form_data,
-        project_name,
-        version_name,
-        permitted_recipients,
-        release_policy_mailto_addresses,
-        min_hours,
+    vote_duration: form.Int = form.label(
+        "Minimum vote duration",
+        "Minimum number of hours the vote will be open for.",
+        default=72,
     )
-
-    if await form.validate_on_submit():
-        email_to: str = util.unwrap(form.mailing_list.data)
-        log.info(f"voting.selected_revision: email to: {email_to}")
-        vote_duration_choice: int = util.unwrap(form.vote_duration.data)
-        subject_data: str = util.unwrap(form.subject.data)
-        body_data: str = util.unwrap(form.body.data)
-        async with storage.write_as_committee_participant(committee.name) as 
wacp:
-            _task = await wacp.vote.start(
-                email_to,
-                project_name,
-                version_name,
-                revision,
-                vote_duration_choice,
-                subject_data,
-                body_data,
-                session.uid,
-                session.fullname,
-                release=release,
-                promote=True,
-                permitted_recipients=permitted_recipients,
-            )
-        return await session.redirect(
-            vote.selected,
-            success=f"The vote announcement email will soon be sent to 
{email_to}.",
-            project_name=project_name,
-            version_name=version_name,
-        )
-    return form
+    subject: str = form.label("Subject")
+    body: str = form.label("Body", widget=form.Widget.TEXTAREA)
diff --git a/playwright/test.py b/playwright/test.py
index e8b3334..1c2f01b 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -190,7 +190,7 @@ def lifecycle_04_start_vote(page: sync_api.Page, 
credentials: Credentials, versi
     logging.info(f"Current URL: {page.url}")
 
     logging.info("Locating and activating the button to prepare the vote 
email")
-    submit_button_locator = page.locator('input[type="submit"][value="Send 
vote email"]')
+    submit_button_locator = page.get_by_role("button", name="Send vote email")
     sync_api.expect(submit_button_locator).to_be_enabled()
     submit_button_locator.click()
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to