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 b5a7cda  Allow the date and time a vote closes to be added to subjects
b5a7cda is described below

commit b5a7cda8fe31d44e228d9051b6791ddd99b80bc1
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Jan 1 20:44:14 2026 +0000

    Allow the date and time a vote closes to be added to subjects
---
 atr/construct.py                | 11 ++++++
 atr/get/announce.py             | 27 ++++++++++++++-
 atr/get/voting.py               | 27 ++++++++++++++-
 atr/post/announce.py            | 12 ++++++-
 atr/post/voting.py              | 75 ++++++++++++++++++++++++++---------------
 atr/shared/announce.py          |  3 +-
 atr/shared/voting.py            |  3 +-
 atr/storage/writers/announce.py | 22 ++++++++++--
 atr/storage/writers/vote.py     | 14 +++++---
 atr/tasks/vote.py               |  2 +-
 10 files changed, 156 insertions(+), 40 deletions(-)

diff --git a/atr/construct.py b/atr/construct.py
index 4205a64..55e8a68 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -16,6 +16,8 @@
 # under the License.
 
 import dataclasses
+import datetime
+import hashlib
 from typing import Literal
 
 import aiofiles.os
@@ -40,6 +42,7 @@ TEMPLATE_VARIABLES: list[tuple[str, str, set[Context]]] = [
     ("REVISION", "Revision number", {"announce", "checklist", "vote", 
"vote_subject"}),
     ("TAG", "Revision tag, if set", {"announce", "checklist", "vote", 
"vote_subject"}),
     ("VERSION", "Version name", {"announce", "announce_subject", "checklist", 
"vote", "vote_subject"}),
+    ("VOTE_ENDS_UTC", "Vote end date and time in UTC", {"vote_subject"}),
     ("YOUR_ASF_ID", "Your Apache UID", {"announce", "vote"}),
     ("YOUR_FULL_NAME", "Your full name", {"announce", "vote"}),
 ]
@@ -217,6 +220,8 @@ async def start_vote_subject_and_body(subject: str, body: 
str, options: StartVot
     review_path = util.as_url(vote.selected, 
project_name=options.project_name, version_name=options.version_name)
     review_url = f"https://{host}{review_path}";
     project_display_name = release.project.short_display_name if 
release.project else options.project_name
+    vote_end = datetime.datetime.now(datetime.UTC) + 
datetime.timedelta(hours=options.vote_duration)
+    vote_end_str = f"{vote_end.day} {vote_end.strftime('%b %H:%M')} UTC"
 
     # NOTE: The /downloads/ directory is served by the proxy front end, not by 
ATR
     # Therefore there is no route handler, so we have to construct the URL 
manually
@@ -251,6 +256,7 @@ async def start_vote_subject_and_body(subject: str, body: 
str, options: StartVot
     subject = subject.replace("{{REVISION}}", revision_number)
     subject = subject.replace("{{TAG}}", revision_tag)
     subject = subject.replace("{{VERSION}}", options.version_name)
+    subject = subject.replace("{{VOTE_ENDS_UTC}}", vote_end_str)
 
     # Perform substitutions in the body
     # TODO: Handle the DURATION == 0 case
@@ -279,6 +285,11 @@ async def start_vote_subject_default(project_name: str) -> 
str:
     return project.policy_start_vote_subject
 
 
+def template_hash(template: str) -> str:
+    """Compute a hash of a template for verification."""
+    return hashlib.sha256(template.encode()).hexdigest()
+
+
 def vote_subject_template_variables() -> list[tuple[str, str]]:
     return [(name, desc) for (name, desc, contexts) in TEMPLATE_VARIABLES if 
"vote_subject" in contexts]
 
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 4e04fc8..e6c45f1 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -55,6 +55,7 @@ async def selected(session: web.Committer, project_name: str, 
version_name: str)
     # Get the templates from the release policy
     default_subject_template = await 
construct.announce_release_subject_default(project_name)
     default_body_template = await 
construct.announce_release_default(project_name)
+    subject_template_hash = construct.template_hash(default_subject_template)
 
     # Expand the templates
     options = construct.AnnounceReleaseOptions(
@@ -89,6 +90,7 @@ async def selected(session: web.Committer, project_name: str, 
version_name: str)
         release=release,
         mailing_list_choices=mailing_list_choices,
         default_subject=default_subject,
+        subject_template_hash=subject_template_hash,
         default_body=default_body,
         default_download_path_suffix=default_download_path_suffix,
         download_path_description=f"The URL will be 
{description_download_prefix} plus this suffix",
@@ -177,6 +179,7 @@ async def _render_page(
     release: sql.Release,
     mailing_list_choices: list[tuple[str, str]],
     default_subject: str,
+    subject_template_hash: str,
     default_body: str,
     default_download_path_suffix: str,
     download_path_description: str,
@@ -208,6 +211,7 @@ async def _render_page(
     page.h2["Announce this release"]
     page.p[f"This form will send an announcement to the ASF 
{util.USER_TESTS_ADDRESS} mailing list."]
 
+    custom_subject_widget = _render_subject_field(default_subject, 
release.project.name)
     custom_body_widget = _render_body_field(default_body, release.project.name)
     custom_mailing_list_widget = 
_render_mailing_list_with_warning(mailing_list_choices, util.USER_TESTS_ADDRESS)
 
@@ -216,7 +220,7 @@ async def _render_page(
 
     defaults_dict = {
         "revision_number": release.unwrap_revision_number,
-        "subject": default_subject,
+        "subject_template_hash": subject_template_hash,
         "body": default_body,
     }
 
@@ -227,6 +231,7 @@ async def _render_page(
         submit_label="Send announcement email",
         defaults=defaults_dict,
         custom={
+            "subject": custom_subject_widget,
             "body": custom_body_widget,
             "mailing_list": custom_mailing_list_widget,
             "download_path_suffix": download_path_widget,
@@ -251,3 +256,23 @@ def _render_release_card(release: sql.Release) -> 
htm.Element:
         ],
     ]
     return card
+
+
+def _render_subject_field(default_subject: str, project_name: str) -> 
htm.Element:
+    settings_url = util.as_url(projects.view, name=project_name) + 
"#announce_release_subject"
+    return htm.div[
+        htpy.input(
+            type="text",
+            name="subject",
+            id="subject",
+            value=default_subject,
+            readonly=True,
+            **{"class": "form-control bg-light"},
+        ),
+        htm.div(".form-text.text-muted.mt-2")[
+            "The subject is computed from the template when the email is sent. 
",
+            "To edit the template, go to the ",
+            htm.a(href=settings_url)["project settings"],
+            ".",
+        ],
+    ]
diff --git a/atr/get/voting.py b/atr/get/voting.py
index cc334d9..83eea19 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -66,6 +66,7 @@ async def selected_revision(
 
         default_subject_template = await 
construct.start_vote_subject_default(project_name)
         default_body_template = await 
construct.start_vote_default(project_name)
+        subject_template_hash = 
construct.template_hash(default_subject_template)
 
         options = construct.StartVoteOptions(
             asfuid=session.uid,
@@ -86,6 +87,7 @@ async def selected_revision(
             revision_number=revision,
             permitted_recipients=permitted_recipients,
             default_subject=default_subject,
+            subject_template_hash=subject_template_hash,
             default_body=default_body,
             min_hours=min_hours,
             keys_warning=keys_warning,
@@ -130,6 +132,7 @@ async def _render_page(
     revision_number: str,
     permitted_recipients: list[str],
     default_subject: str,
+    subject_template_hash: str,
     default_body: str,
     min_hours: int,
     keys_warning: bool,
@@ -177,6 +180,7 @@ async def _render_page(
         version_name=release.version,
     )
 
+    custom_subject_widget = _render_subject_field(default_subject, 
release.project.name)
     custom_body_widget = _render_body_field(default_body, release.project.name)
 
     vote_form = form.render(
@@ -186,10 +190,11 @@ async def _render_page(
         defaults={
             "mailing_list": permitted_recipients,
             "vote_duration": min_hours,
-            "subject": default_subject,
+            "subject_template_hash": subject_template_hash,
             "body": default_body,
         },
         custom={
+            "subject": custom_subject_widget,
             "body": custom_body_widget,
         },
     )
@@ -204,3 +209,23 @@ async def _render_page(
     page.append(htpy.div("#vote-body-config.d-none", 
data_preview_url=preview_url))
 
     return page.collect()
+
+
+def _render_subject_field(default_subject: str, project_name: str) -> 
htm.Element:
+    settings_url = util.as_url(projects.view, name=project_name) + 
"#start_vote_subject"
+    return htm.div[
+        htpy.input(
+            type="text",
+            name="subject",
+            id="subject",
+            value=default_subject,
+            readonly=True,
+            **{"class": "form-control bg-light"},
+        ),
+        htm.div(".form-text.text-muted.mt-2")[
+            "The subject is computed from the template when the email is sent. 
",
+            "To edit the template, go to the ",
+            htm.a(href=settings_url)["project settings"],
+            ".",
+        ],
+    ]
diff --git a/atr/post/announce.py b/atr/post/announce.py
index 56d2417..3606802 100644
--- a/atr/post/announce.py
+++ b/atr/post/announce.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 # TODO: Improve upon the routes_release pattern
 import atr.blueprints.post as post
+import atr.construct as construct
 import atr.get as get
 import atr.models.sql as sql
 import atr.shared as shared
@@ -60,6 +61,15 @@ async def selected(
             version_name=version_name,
         )
 
+    # Validate that the subject template hasn't changed
+    subject_template = await 
construct.announce_release_subject_default(project_name)
+    current_hash = construct.template_hash(subject_template)
+    if current_hash != announce_form.subject_template_hash:
+        return await session.form_error(
+            "subject_template_hash",
+            "The subject template has been modified since you loaded the form. 
Please reload and try again.",
+        )
+
     try:
         async with storage.write_as_project_committee_member(project_name, 
session) as wacm:
             await wacm.announce.release(
@@ -67,7 +77,7 @@ async def selected(
                 version_name,
                 preview_revision_number,
                 announce_form.mailing_list,
-                announce_form.subject,
+                announce_form.subject_template_hash,
                 announce_form.body,
                 announce_form.download_path_suffix,
                 session.uid,
diff --git a/atr/post/voting.py b/atr/post/voting.py
index 2d8fdb1..a543079 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -34,6 +34,33 @@ class BodyPreviewForm(form.Form):
     vote_duration: form.Int = form.label("Vote duration")
 
 
[email protected]("/voting/body/preview/<project_name>/<version_name>/<revision_number>")
[email protected](BodyPreviewForm)
+async def body_preview(
+    session: web.Committer,
+    preview_form: BodyPreviewForm,
+    project_name: str,
+    version_name: str,
+    revision_number: str,
+) -> web.QuartResponse:
+    await session.check_access(project_name)
+
+    default_subject_template = await 
construct.start_vote_subject_default(project_name)
+    default_body_template = await construct.start_vote_default(project_name)
+
+    options = construct.StartVoteOptions(
+        asfuid=session.uid,
+        fullname=session.fullname,
+        project_name=project_name,
+        version_name=version_name,
+        revision_number=revision_number,
+        vote_duration=preview_form.vote_duration,
+    )
+    _, body = await 
construct.start_vote_subject_and_body(default_subject_template, 
default_body_template, options)
+
+    return web.TextResponse(body)
+
+
 @post.committer("/voting/<project_name>/<version_name>/<revision>")
 @post.form(shared.voting.StartVotingForm)
 async def selected_revision(
@@ -67,6 +94,25 @@ async def selected_revision(
                 f"Invalid mailing list selection: 
{start_voting_form.mailing_list}",
             )
 
+        subject_template = await 
construct.start_vote_subject_default(project_name)
+        current_hash = construct.template_hash(subject_template)
+        if current_hash != start_voting_form.subject_template_hash:
+            return await session.form_error(
+                "subject_template_hash",
+                "The subject template has been modified since you loaded the 
form. Please reload and try again.",
+            )
+
+        # Substitute the subject template (must be done here, not in task, as 
it requires app context)
+        options = construct.StartVoteOptions(
+            asfuid=session.uid,
+            fullname=session.fullname,
+            project_name=project_name,
+            version_name=version_name,
+            revision_number=revision,
+            vote_duration=start_voting_form.vote_duration,
+        )
+        subject, _ = await 
construct.start_vote_subject_and_body(subject_template, "", options)
+
         async with storage.write_as_committee_participant(committee.name) as 
wacp:
             _task = await wacp.vote.start(
                 start_voting_form.mailing_list,
@@ -74,7 +120,7 @@ async def selected_revision(
                 version_name,
                 revision,
                 start_voting_form.vote_duration,
-                start_voting_form.subject,
+                subject,
                 start_voting_form.body,
                 session.uid,
                 session.fullname,
@@ -90,30 +136,3 @@ async def selected_revision(
             project_name=project_name,
             version_name=version_name,
         )
-
-
[email protected]("/voting/body/preview/<project_name>/<version_name>/<revision_number>")
[email protected](BodyPreviewForm)
-async def body_preview(
-    session: web.Committer,
-    preview_form: BodyPreviewForm,
-    project_name: str,
-    version_name: str,
-    revision_number: str,
-) -> web.QuartResponse:
-    await session.check_access(project_name)
-
-    default_subject_template = await 
construct.start_vote_subject_default(project_name)
-    default_body_template = await construct.start_vote_default(project_name)
-
-    options = construct.StartVoteOptions(
-        asfuid=session.uid,
-        fullname=session.fullname,
-        project_name=project_name,
-        version_name=version_name,
-        revision_number=revision_number,
-        vote_duration=preview_form.vote_duration,
-    )
-    _, body = await 
construct.start_vote_subject_and_body(default_subject_template, 
default_body_template, options)
-
-    return web.TextResponse(body)
diff --git a/atr/shared/announce.py b/atr/shared/announce.py
index 7d208fd..d565804 100644
--- a/atr/shared/announce.py
+++ b/atr/shared/announce.py
@@ -30,7 +30,8 @@ class AnnounceForm(form.Form):
         "Send vote email to",
         widget=form.Widget.CUSTOM,
     )
-    subject: str = form.label("Subject")
+    subject: str = form.label("Subject", widget=form.Widget.CUSTOM)
+    subject_template_hash: str = form.label("Subject template hash", 
widget=form.Widget.HIDDEN)
     body: str = form.label("Body", widget=form.Widget.CUSTOM)
     download_path_suffix: str = form.label("Download path suffix", 
widget=form.Widget.CUSTOM)
     confirm_announce: Literal["CONFIRM"] = form.label(
diff --git a/atr/shared/voting.py b/atr/shared/voting.py
index b8025fe..eb14347 100644
--- a/atr/shared/voting.py
+++ b/atr/shared/voting.py
@@ -32,5 +32,6 @@ class StartVotingForm(form.Form):
         "Minimum number of hours the vote will be open for.",
         default=72,
     )
-    subject: str = form.label("Subject")
+    subject: str = form.label("Subject", widget=form.Widget.CUSTOM)
+    subject_template_hash: str = form.label("Subject template hash", 
widget=form.Widget.HIDDEN)
     body: str = form.label("Body", widget=form.Widget.CUSTOM)
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 7fed019..7a5f66a 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -27,6 +27,7 @@ import aiofiles.os
 import aioshutil
 import sqlmodel
 
+import atr.construct as construct
 import atr.db as db
 import atr.models.sql as sql
 import atr.storage as storage
@@ -99,13 +100,13 @@ class CommitteeMember(CommitteeParticipant):
         self.__asf_uid = asf_uid
         self.__committee_name = committee_name
 
-    async def release(
+    async def release(  # noqa: C901
         self,
         project_name: str,
         version_name: str,
         preview_revision_number: str,
         recipient: str,
-        subject: str,
+        subject_template_hash: str | None,
         body: str,
         download_path_suffix: str,
         asf_uid: str,
@@ -132,6 +133,23 @@ class CommitteeMember(CommitteeParticipant):
         if (committee := release.project.committee) is None:
             raise storage.AccessError("Release has no committee")
 
+        # Fetch the current subject template and verify the hash
+        subject_template = await 
construct.announce_release_subject_default(project_name)
+        if subject_template_hash is not None:
+            current_hash = construct.template_hash(subject_template)
+            if current_hash != subject_template_hash:
+                raise storage.AccessError("Subject template has been modified 
since the form was loaded")
+
+        # Substitute the subject template
+        options = construct.AnnounceReleaseOptions(
+            asfuid=asf_uid,
+            fullname=fullname,
+            project_name=project_name,
+            version_name=version_name,
+            revision_number=preview_revision_number,
+        )
+        subject, _ = await 
construct.announce_release_subject_and_body(subject_template, "", options)
+
         # Prepare paths for file operations
         unfinished_revisions_path = util.release_directory_base(release)
         unfinished_path = unfinished_revisions_path / 
release.unwrap_revision_number
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index b4bd5e9..bd4bd87 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -24,6 +24,7 @@ import atr.construct as construct
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.log as log
+import atr.models.results as results
 import atr.models.sql as sql
 import atr.storage as storage
 import atr.tasks.message as message
@@ -90,7 +91,12 @@ class CommitteeParticipant(FoundationCommitter):
             return "", "No vote thread found."
 
         # Construct the reply email
-        original_subject = latest_vote_task.task_args["subject"]
+        vote_result = latest_vote_task.result
+        if vote_result is None:
+            return "", "Vote task has not completed yet."
+        if not isinstance(vote_result, results.VoteInitiate):
+            return "", "Vote task result is not a VoteInitiate result."
+        original_subject = vote_result.subject
 
         # Arguments for the task to cast a vote
         email_recipient = latest_vote_task.task_args["email_to"]
@@ -131,7 +137,7 @@ class CommitteeParticipant(FoundationCommitter):
         version_name: str,
         selected_revision_number: str,
         vote_duration_choice: int,
-        subject_data: str,
+        subject: str,
         body_data: str,
         asf_uid: str,
         asf_fullname: str,
@@ -178,7 +184,7 @@ class CommitteeParticipant(FoundationCommitter):
                 vote_duration=vote_duration_choice,
                 initiator_id=asf_uid,
                 initiator_fullname=asf_fullname,
-                subject=subject_data,
+                subject=subject,
                 body=body_data,
             ).model_dump(),
             asf_uid=asf_uid,
@@ -343,7 +349,7 @@ class CommitteeMember(CommitteeParticipant):
                 asf_uid=self.__asf_uid,
                 asf_fullname=asf_fullname,
                 vote_duration_choice=vote_duration,
-                subject_data=subject_data,
+                subject=subject_data,
                 body_data=body_data,
                 release=release,
                 promote=False,
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index f53906d..9c39481 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -100,7 +100,7 @@ async def _initiate_core_logic(args: Initiate) -> 
results.Results | None:
         log.error(error_msg)
         raise VoteInitiationError(error_msg)
 
-    # The body has already been substituted by the route handler
+    # The subject and body have already been substituted by the route handler
     subject = args.subject
     body = args.body
 


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

Reply via email to