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 345e161 Add separate routes for manually starting a vote
345e161 is described below
commit 345e161a16497f50c540e4f77566652b88287e28
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Nov 7 20:10:49 2025 +0000
Add separate routes for manually starting a vote
---
atr/form.py | 14 ++++--
atr/get/__init__.py | 2 +
atr/get/manual.py | 107 +++++++++++++++++++++++++++++++++++++++++++++
atr/post/__init__.py | 2 +
atr/post/manual.py | 62 ++++++++++++++++++++++++++
atr/shared/__init__.py | 2 +
atr/shared/distribution.py | 2 +-
atr/shared/manual.py | 68 ++++++++++++++++++++++++++++
8 files changed, 255 insertions(+), 4 deletions(-)
diff --git a/atr/form.py b/atr/form.py
index 6736468..06744e4 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -191,6 +191,10 @@ async def render(
if action is None:
action = quart.request.path
+ is_empty_form = model_cls is Empty
+ if is_empty_form and (form_classes == ".atr-canary"):
+ form_classes = ""
+
flash_error_data: dict[str, Any] = _get_flash_error_data() if
use_error_data else {}
field_rows: list[htm.Element] = []
@@ -223,9 +227,13 @@ async def render(
if cancel_url:
cancel_link = htpy.a(href=cancel_url, class_="btn btn-link
text-secondary")["Cancel"]
submit_div_contents.append(cancel_link)
- submit_div = htm.div(".col-sm-9.offset-sm-3")
- submit_row = htm.div(".row")[submit_div[submit_div_contents]]
- form_children.append(submit_row)
+
+ if is_empty_form:
+ form_children.extend(submit_div_contents)
+ else:
+ submit_div = htm.div(".col-sm-9.offset-sm-3")
+ submit_row = htm.div(".row")[submit_div[submit_div_contents]]
+ form_children.append(submit_row)
return htm.form(form_classes, action=action, method="post",
enctype="multipart/form-data")[form_children]
diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 95baae7..0273f3c 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -31,6 +31,7 @@ import atr.get.file as file
import atr.get.finish as finish
import atr.get.ignores as ignores
import atr.get.keys as keys
+import atr.get.manual as manual
import atr.get.preview as preview
import atr.get.projects as projects
import atr.get.published as published
@@ -64,6 +65,7 @@ __all__ = [
"finish",
"ignores",
"keys",
+ "manual",
"preview",
"projects",
"published",
diff --git a/atr/get/manual.py b/atr/get/manual.py
new file mode 100644
index 0000000..7cb5b16
--- /dev/null
+++ b/atr/get/manual.py
@@ -0,0 +1,107 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import atr.blueprints.get as get
+import atr.db as db
+import atr.form as form
+import atr.get.compose as compose
+import atr.htm as htm
+import atr.post.manual as post_manual
+import atr.shared.distribution as distribution
+import atr.shared.manual as shared_manual
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+
[email protected]("/manual/<project_name>/<version_name>/<revision>")
+async def selected_revision(
+ session: web.Committer, project_name: str, version_name: str, revision: str
+) -> web.WerkzeugResponse | str:
+ await session.check_access(project_name)
+
+ async with db.session() as data:
+ match await shared_manual.validated_release(session, project_name,
version_name, revision, data):
+ 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
+
+ content = await _render_page(release=release, revision=revision)
+
+ return await template.blank(
+ title=f"Start manual vote on {release.project.short_display_name}
{release.version}", content=content
+ )
+
+
+async def _render_page(release, revision: str) -> htm.Element:
+ page = htm.Block()
+
+ back_link_url = util.as_url(
+ compose.selected,
+ project_name=release.project.name,
+ version_name=release.version,
+ )
+ distribution.html_nav(
+ page,
+ back_link_url,
+ f"Compose {release.short_display_name}",
+ "COMPOSE",
+ )
+
+ page.h1(".mb-4")[
+ "Start manual vote 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")[
+ "This release has the manual vote process enabled. "
+ "Press the button below to promote this release to candidate
status."
+ ]
+ ]
+
+ page.p[
+ "Once the vote is started, you must manually send the vote email to
the appropriate mailing list, "
+ "wait for the vote to complete, and then manually advance the release
to the next phase. "
+ "The ATR will then require you to submit the vote and vote result
thread URLs to proceed."
+ ]
+
+ cancel_url = util.as_url(compose.selected,
project_name=release.project.name, version_name=release.version)
+ manual_form = await form.render(
+ model_cls=form.Empty,
+ submit_label="Start manual vote",
+ cancel_url=cancel_url,
+ action=util.as_url(
+ post_manual.selected_revision,
+ project_name=release.project.name,
+ version_name=release.version,
+ revision=revision,
+ ),
+ )
+
+ page.append(manual_form)
+
+ return page.collect()
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index c014cf3..4d5209c 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -24,6 +24,7 @@ import atr.post.draft as draft
import atr.post.finish as finish
import atr.post.ignores as ignores
import atr.post.keys as keys
+import atr.post.manual as manual
import atr.post.preview as preview
import atr.post.projects as projects
import atr.post.resolve as resolve
@@ -47,6 +48,7 @@ __all__ = [
"finish",
"ignores",
"keys",
+ "manual",
"preview",
"projects",
"resolve",
diff --git a/atr/post/manual.py b/atr/post/manual.py
new file mode 100644
index 0000000..25a5919
--- /dev/null
+++ b/atr/post/manual.py
@@ -0,0 +1,62 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import atr.blueprints.post as post
+import atr.db as db
+import atr.get.vote as vote
+import atr.shared.manual as shared_manual
+import atr.storage as storage
+import atr.web as web
+
+
[email protected]("/manual/<project_name>/<version_name>/<revision>")
[email protected]()
+async def selected_revision(
+ session: web.Committer, project_name: str, version_name: str, revision: str
+) -> web.WerkzeugResponse | str:
+ await session.check_access(project_name)
+
+ async with db.session() as data:
+ match await shared_manual.validated_release(session, project_name,
version_name, revision, data):
+ case str() as error:
+ return await session.redirect(
+ vote.selected,
+ error=error,
+ project_name=project_name,
+ version_name=version_name,
+ )
+ case (release, _committee):
+ pass
+
+ async with storage.write(session) as write:
+ wacp = await
write.as_project_committee_participant(release.project_name)
+ error = await wacp.release.promote_to_candidate(release.name,
revision, vote_manual=True)
+
+ if error:
+ return await session.redirect(
+ vote.selected,
+ error=error,
+ project_name=project_name,
+ version_name=version_name,
+ )
+
+ return await session.redirect(
+ vote.selected,
+ success="The manual vote process has been started.",
+ project_name=project_name,
+ version_name=version_name,
+ )
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
index 1426353..e8a07e1 100644
--- a/atr/shared/__init__.py
+++ b/atr/shared/__init__.py
@@ -30,6 +30,7 @@ import atr.shared.draft as draft
import atr.shared.finish as finish
import atr.shared.ignores as ignores
import atr.shared.keys as keys
+import atr.shared.manual as manual
import atr.shared.projects as projects
import atr.shared.resolve as resolve
import atr.shared.start as start
@@ -191,6 +192,7 @@ __all__ = [
"finish",
"ignores",
"keys",
+ "manual",
"projects",
"resolve",
"start",
diff --git a/atr/shared/distribution.py b/atr/shared/distribution.py
index 2d26fc4..fc1b22a 100644
--- a/atr/shared/distribution.py
+++ b/atr/shared/distribution.py
@@ -90,7 +90,7 @@ class FormProjectVersion:
# TODO: Move this to an appropriate module
def html_nav(container: htm.Block, back_url: str, back_anchor: str, phase:
Phase) -> None:
classes = ".d-flex.justify-content-between.align-items-center"
- block = htm.Block(htm.p(classes))
+ block = htm.Block(htm.p(classes=classes))
block.a(".atr-back-link", href=back_url)[f"← Back to {back_anchor}"]
span = htm.Block(htm.span)
diff --git a/atr/shared/manual.py b/atr/shared/manual.py
new file mode 100644
index 0000000..21c917d
--- /dev/null
+++ b/atr/shared/manual.py
@@ -0,0 +1,68 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.models.sql as sql
+import atr.user as user
+import atr.util as util
+import atr.web as web
+
+
+async def validated_release(
+ session: web.Committer,
+ project_name: str,
+ version_name: str,
+ revision: str,
+ data: db.Session,
+) -> tuple[sql.Release, sql.Committee] | str:
+ """Validate release for manual vote and return (release, committee) or
error message."""
+ 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 "No revision found for this release"
+
+ if selected_revision_number != revision:
+ return "This revision does not match the revision you are voting on"
+
+ committee = release.committee
+ if committee is None:
+ return "The committee for this release was not found"
+
+ if not release.project.policy_manual_vote:
+ return "This release does not have manual vote mode enabled"
+
+ if release.project.policy_strict_checking:
+ if await interaction.has_failing_checks(release, revision,
caller_data=data):
+ return "This release candidate draft has errors. Please fix the
errors before starting a vote."
+
+ if not (user.is_committee_member(committee, session.uid) or
user.is_admin(session.uid)):
+ return "You must be on the PMC of this project to start a vote"
+
+ has_files = await util.has_files(release)
+ if not has_files:
+ return "This release candidate draft has no files yet. Please add some
files before starting a vote."
+
+ return release, committee
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]