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]

Reply via email to