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-release.git
The following commit(s) were added to refs/heads/main by this push:
new ee75105 Make it possible to start a manual vote
ee75105 is described below
commit ee7510597f399d7452f68583af395d61c3bd16ed
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Jul 2 15:13:33 2025 +0100
Make it possible to start a manual vote
---
atr/db/models.py | 2 +-
atr/routes/resolve.py | 1 -
atr/routes/vote.py | 7 ++
atr/routes/voting.py | 137 +++++++++++++---------
atr/templates/check-selected-candidate-forms.html | 5 +-
atr/templates/check-selected-release-info.html | 5 +
atr/templates/voting-selected-revision.html | 16 +--
migrations/versions/0014_2025.07.02_dd73e63e.py | 27 +++++
8 files changed, 132 insertions(+), 68 deletions(-)
diff --git a/atr/db/models.py b/atr/db/models.py
index 3fcba9b..08f01e4 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -660,7 +660,7 @@ class Release(sqlmodel.SQLModel, table=True):
)
votes: list[VoteEntry] = sqlmodel.Field(default_factory=list,
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-
+ vote_manual: bool = sqlmodel.Field(default=False)
vote_started: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
vote_resolved: datetime.datetime | None = sqlmodel.Field(default=None,
sa_column=sqlalchemy.Column(UTCDateTime))
podling_thread_id: str | None = sqlmodel.Field(default=None)
diff --git a/atr/routes/resolve.py b/atr/routes/resolve.py
index 8e8d87c..6dd519d 100644
--- a/atr/routes/resolve.py
+++ b/atr/routes/resolve.py
@@ -162,7 +162,6 @@ async def _resolve_vote(
if revision_number is None:
raise ValueError("Release has no revision number")
await voting.start_vote(
- committee=release.project.committee,
email_to=incubator_vote_address,
permitted_recipients=[incubator_vote_address],
project_name=release.project.name,
diff --git a/atr/routes/vote.py b/atr/routes/vote.py
index 36e5f1d..3699028 100644
--- a/atr/routes/vote.py
+++ b/atr/routes/vote.py
@@ -199,6 +199,13 @@ async def selected_resolve(session:
routes.CommitterSession, project_name: str,
with_release_policy=True,
with_project_release_policy=True,
)
+ if release.vote_manual:
+ raise NotImplementedError("Manual vote process is not implemented yet")
+ # return await template.render(
+ # "vote-resolve-manual.html",
+ # release=release,
+ # )
+
hidden_form = await util.HiddenFieldForm.create_form()
tabulated_votes = None
summary = None
diff --git a/atr/routes/voting.py b/atr/routes/voting.py
index 6a51709..8f8b0d3 100644
--- a/atr/routes/voting.py
+++ b/atr/routes/voting.py
@@ -16,10 +16,12 @@
# under the License.
import datetime
+from typing import Any, Protocol
import aiofiles.os
import asfquart.base as base
import quart
+import quart_wtf.typing as typing
import sqlmodel
import werkzeug.wrappers.response as response
import wtforms
@@ -38,35 +40,20 @@ import atr.user as user
import atr.util as util
-class VoteInitiateForm(util.QuartFormTyped):
- """Form for initiating a release vote."""
+class VoteInitiateFormProtocol(Protocol):
+ """Protocol for the dynamically generated VoteInitiateForm."""
- release_name = wtforms.HiddenField("Release Name")
- mailing_list = wtforms.RadioField(
- "Send vote email to",
- choices=[("[email protected]",
"[email protected]")],
- validators=[wtforms.validators.InputRequired("Mailing list selection
is required")],
- default="[email protected]",
- description="NOTE: The limited options above are provided for testing
purposes."
- " In the finished version of ATR, you will be able to send to your own
specified mailing lists.",
- )
- vote_duration = wtforms.IntegerField(
- "Minimum vote duration",
- validators=[
- wtforms.validators.InputRequired("Vote duration is required"),
- util.validate_vote_duration,
- ],
- default=72,
- description="Minimum number of hours the vote will be open for.",
- )
- subject = wtforms.StringField("Subject",
validators=[wtforms.validators.Optional()])
- body = wtforms.TextAreaField(
- "Body",
- validators=[wtforms.validators.Optional()],
- 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 = wtforms.SubmitField("Send vote email")
+ release_name: wtforms.HiddenField
+ mailing_list: wtforms.RadioField
+ vote_duration: wtforms.IntegerField
+ subject: wtforms.StringField
+ body: wtforms.TextAreaField
+ submit: wtforms.SubmitField
+
+ @property
+ def errors(self) -> dict[str, Any]: ...
+
+ async def validate_on_submit(self) -> bool: ...
@routes.committer("/voting/<project_name>/<version_name>/<revision>",
methods=["GET", "POST"])
@@ -111,7 +98,7 @@ async def selected_revision(
if selected_revision_number is None:
return await session.redirect(compose.selected, error="No revision
found for this release")
- committee = util.unwrap(release.committee)
+ # committee = util.unwrap(release.committee)
permitted_recipients = util.permitted_recipients(session.uid)
if release.release_policy:
min_hours = release.release_policy.min_hours if
(release.release_policy.min_hours is not None) else 72
@@ -119,8 +106,22 @@ async def selected_revision(
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,
@@ -134,7 +135,6 @@ async def selected_revision(
subject_data: str = util.unwrap(form.subject.data)
body_data: str = util.unwrap(form.body.data)
return await start_vote(
- committee,
email_to,
permitted_recipients,
project_name,
@@ -176,15 +176,17 @@ async def selected_revision(
async def _form(
release: models.Release,
+ form_data: typing.FormData | None,
project_name: str,
version_name: str,
permitted_recipients: list[str],
release_policy_mailto_addresses: str,
min_hours: int,
-) -> VoteInitiateForm:
- class SubsetVoteInitiateForm(VoteInitiateForm):
+) -> VoteInitiateFormProtocol:
+ class VoteInitiateForm(util.QuartFormTyped):
"""Form for initiating a release vote."""
+ release_name = wtforms.HiddenField("Release Name")
mailing_list = wtforms.RadioField(
"Send vote email to",
choices=sorted([(recipient, recipient) for recipient in
permitted_recipients]),
@@ -203,6 +205,14 @@ async def _form(
default=min_hours,
description="Minimum number of hours the vote will be open for.",
)
+ subject = wtforms.StringField("Subject",
validators=[wtforms.validators.Optional()])
+ body = wtforms.TextAreaField(
+ "Body",
+ validators=[wtforms.validators.Optional()],
+ 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 = wtforms.SubmitField("Send vote email")
project = release.project
@@ -211,12 +221,9 @@ async def _form(
default_subject = f"[VOTE] Release {project.display_name} {version_name}"
default_body = await construct.start_vote_default(project_name)
- data = (await quart.request.form) if (quart.request.method == "POST") else
None
- if (data or {}).get("hidden_field"):
- raise NotImplementedError("Manual vote process")
-
- form = await SubsetVoteInitiateForm.create_form(
- data=data if (quart.request.method == "POST") else None,
+ # Must use data, not formdata, otherwise everything breaks
+ form = await VoteInitiateForm.create_form(
+ data=form_data if (quart.request.method == "POST") else None,
)
# Set hidden field data explicitly
form.release_name.data = release.name
@@ -245,18 +252,30 @@ async def _promote(
data: db.Session,
release_name: str,
selected_revision_number: str,
+ vote_manual: bool = False,
) -> str | None:
"""Promote a release candidate draft to a new phase."""
# TODO: Use session.release here
release_for_pre_checks = await data.release(name=release_name,
_project=True).demand(
routes.FlashError("Release candidate draft not found")
)
+ project_name = release_for_pre_checks.project.name
+ version_name = release_for_pre_checks.version
+
+ # Check for ongoing tasks
+ ongoing_tasks = await interaction.tasks_ongoing(project_name,
version_name, selected_revision_number)
+ if ongoing_tasks > 0:
+ return "All checks must be completed before starting a vote"
# Verify that it's in the correct phase
# The atomic update below will also check this
if release_for_pre_checks.phase !=
models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
return "This release is not in the candidate draft phase"
+ # Check that the revision number is the latest
+ if release_for_pre_checks.latest_revision_number !=
selected_revision_number:
+ return "The selected revision number does not match the latest
revision number"
+
# Check that there is at least one file in the draft
# This is why we require _project=True above
file_count = await util.number_of_release_files(release_for_pre_checks)
@@ -276,6 +295,8 @@ async def _promote(
)
.values(
phase=models.ReleasePhase.RELEASE_CANDIDATE,
+ vote_started=datetime.datetime.now(datetime.UTC),
+ vote_manual=vote_manual,
)
)
@@ -288,7 +309,6 @@ async def _promote(
async def start_vote(
- committee: models.Committee,
email_to: str,
permitted_recipients: list[str],
project_name: str,
@@ -302,33 +322,16 @@ async def start_vote(
release: models.Release,
promote: bool = True,
):
- if committee is None:
- raise base.ASFQuartException("Release has no associated committee",
errorcode=400)
-
if email_to not in permitted_recipients:
# This will be checked again by tasks/vote.py for extra safety
raise base.ASFQuartException("Invalid mailing list choice",
errorcode=400)
- # Check for ongoing tasks
- ongoing_tasks = await interaction.tasks_ongoing(project_name,
version_name, selected_revision_number)
- if ongoing_tasks > 0:
- return await session.redirect(
- selected_revision,
- project_name=project_name,
- version_name=version_name,
- revision=selected_revision_number,
- error="All checks must be completed before starting a vote.",
- )
-
if promote is True:
- # This sets the phase to RELEASE_CANDIDATE
- error = await _promote(data, release.name, selected_revision_number)
+ # This verifies the state and sets the phase to RELEASE_CANDIDATE
+ error = await _promote(data, release.name, selected_revision_number,
vote_manual=False)
if error:
return await session.redirect(root.index, error=error)
- # Store when the release was put into the voting phase
- release.vote_started = datetime.datetime.now(datetime.UTC)
-
# TODO: We also need to store the duration of the vote
# We can't allow resolution of the vote until the duration has elapsed
# But we allow the user to specify in the form
@@ -364,3 +367,21 @@ async def start_vote(
project_name=project_name,
version_name=version_name,
)
+
+
+async def start_vote_manual(
+ release: models.Release,
+ selected_revision_number: str,
+ session: routes.CommitterSession,
+ data: db.Session,
+) -> response.Response | str:
+ # This verifies the state and sets the phase to RELEASE_CANDIDATE
+ error = await _promote(data, release.name, selected_revision_number,
vote_manual=True)
+ if error:
+ 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,
+ )
diff --git a/atr/templates/check-selected-candidate-forms.html
b/atr/templates/check-selected-candidate-forms.html
index 2fc8d09..b3c5609 100644
--- a/atr/templates/check-selected-candidate-forms.html
+++ b/atr/templates/check-selected-candidate-forms.html
@@ -1,4 +1,7 @@
-{% include "check-selected-vote-email.html" %}
+{% if not release.vote_manual %}
+ {% include "check-selected-vote-email.html" %}
+
+{% endif %}
<h2>Cast your vote</h2>
<div class="card bg-warning-subtle mb-3">
diff --git a/atr/templates/check-selected-release-info.html
b/atr/templates/check-selected-release-info.html
index fc13c01..ef69b45 100644
--- a/atr/templates/check-selected-release-info.html
+++ b/atr/templates/check-selected-release-info.html
@@ -17,6 +17,11 @@
<p>
<strong>Created:</strong> {{ format_datetime(release.created) }}
</p>
+ {% if release.vote_manual %}
+ <p>
+ <strong>Manual vote process:</strong> Yes
+ </p>
+ {% endif %}
{% if (phase == "release_candidate_draft") and revision_time %}
<p>
<strong>Revision:</strong>
diff --git a/atr/templates/voting-selected-revision.html
b/atr/templates/voting-selected-revision.html
index 99ab3ab..6c3490e 100644
--- a/atr/templates/voting-selected-revision.html
+++ b/atr/templates/voting-selected-revision.html
@@ -33,10 +33,6 @@
</p>
</div>
- <div class="p-3 mb-4 bg-warning-subtle border border-warning rounded">
- <strong>Note:</strong> This feature is currently in development. The form
below only sends email to <a
href="https://lists.apache.org/[email protected]">a test
mailing list</a> or yourself.
- </div>
-
{% if keys_warning %}
<div class="p-3 mb-4 bg-warning-subtle border border-warning rounded">
<i class="bi bi-exclamation-triangle-fill"></i>
@@ -49,6 +45,11 @@
{% set revision_number = release.latest_revision_number %}
{% if revision_number and (not manual_vote_process_form) %}
{{ forms.errors_summary(form) }}
+
+ <div class="p-3 mb-4 bg-warning-subtle border border-warning rounded">
+ <strong>Note:</strong> This feature is currently in development. The
form below only sends email to <a
href="https://lists.apache.org/[email protected]">a test
mailing list</a> or yourself.
+ </div>
+
<form method="post"
id="vote-initiate-form"
class="atr-canary py-4 px-5"
@@ -144,9 +145,7 @@
</div>
</form>
{% elif manual_vote_process_form %}
- <p>
- This release has manual vote process enabled. Use the form below to
start a vote. 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 requires you to submit
the vote and vote result thread URLs to proceed.
- </p>
+ <p>This release has the manual vote process enabled. Press the button
below to start a vote.</p>
<form method="post"
action="{{ as_url(routes.voting.selected_revision,
project_name=release.project.name, version_name=release.version,
revision=revision_number) }}"
novalidate>
@@ -155,6 +154,9 @@
<button type="submit" class="btn btn-primary">Start vote</button>
</div>
</form>
+ <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.
+ </p>
{% else %}
<div class="p-3 mb-4 bg-danger-subtle border border-danger rounded">
<i class="bi bi-exclamation-triangle-fill"></i>
diff --git a/migrations/versions/0014_2025.07.02_dd73e63e.py
b/migrations/versions/0014_2025.07.02_dd73e63e.py
new file mode 100644
index 0000000..ebdc6d9
--- /dev/null
+++ b/migrations/versions/0014_2025.07.02_dd73e63e.py
@@ -0,0 +1,27 @@
+"""Add a manual vote property to releases
+
+Revision ID: 0014_2025.07.02_dd73e63e
+Revises: 0013_2025.07.01_721abfcd
+Create Date: 2025-07-02 13:48:32.003582+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0014_2025.07.02_dd73e63e"
+down_revision: str | None = "0013_2025.07.01_721abfcd"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("release", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("vote_manual", sa.Boolean(),
nullable=False, server_default=sa.false()))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("release", schema=None) as batch_op:
+ batch_op.drop_column("vote_manual")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]