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]