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]