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 d963229  Remove template interfaces from the vote and announce forms
d963229 is described below

commit d963229851f460de4d155f354af5de34bf051187
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Jan 1 16:48:06 2026 +0000

    Remove template interfaces from the vote and announce forms
---
 atr/construct.py                        | 108 +++++++++++------------
 atr/get/announce.py                     |  51 +++++++----
 atr/get/voting.py                       |  48 ++++++----
 atr/models/sql.py                       |   2 +-
 atr/post/__init__.py                    |   2 -
 atr/post/draft.py                       |  42 ---------
 atr/post/preview.py                     | 110 -----------------------
 atr/post/voting.py                      |  33 +++++++
 atr/render.py                           | 101 ---------------------
 atr/static/js/src/announce-preview.js   | 147 -------------------------------
 atr/static/js/src/vote-body-duration.js | 151 ++++++++++++++++++++++++++++++++
 atr/static/js/src/vote-preview.js       | 121 -------------------------
 atr/storage/writers/announce.py         |  12 ---
 atr/storage/writers/vote.py             |  53 ++++-------
 14 files changed, 318 insertions(+), 663 deletions(-)

diff --git a/atr/construct.py b/atr/construct.py
index 40c709f..4205a64 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -23,7 +23,6 @@ import quart
 
 import atr.config as config
 import atr.db as db
-import atr.db.interaction as interaction
 import atr.models.sql as sql
 import atr.util as util
 
@@ -41,7 +40,6 @@ 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"}),
     ("YOUR_ASF_ID", "Your Apache UID", {"announce", "vote"}),
     ("YOUR_FULL_NAME", "Your full name", {"announce", "vote"}),
 ]
@@ -53,6 +51,7 @@ class AnnounceReleaseOptions:
     fullname: str
     project_name: str
     version_name: str
+    revision_number: str
 
 
 @dataclasses.dataclass
@@ -61,11 +60,22 @@ class StartVoteOptions:
     fullname: str
     project_name: str
     version_name: str
+    revision_number: str
     vote_duration: int
-    vote_end: str
 
 
-async def announce_release_body(body: str, options: AnnounceReleaseOptions) -> 
str:
+async def announce_release_default(project_name: str) -> str:
+    async with db.session() as data:
+        project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE, _release_policy=True).demand(
+            RuntimeError(f"Project {project_name} not found")
+        )
+
+    return project.policy_announce_release_template
+
+
+async def announce_release_subject_and_body(
+    subject: str, body: str, options: AnnounceReleaseOptions
+) -> tuple[str, str]:
     # NOTE: The present module is imported by routes
     # Therefore this must be done here to avoid a circular import
     import atr.get as get
@@ -87,9 +97,11 @@ async def announce_release_body(body: str, options: 
AnnounceReleaseOptions) -> s
             raise RuntimeError(f"Release {options.project_name} 
{options.version_name} has no committee")
         committee = release.committee
 
-        latest_rev = await interaction.latest_revision(release, 
caller_data=data)
-        revision_number = latest_rev.number if latest_rev else ""
-        revision_tag = latest_rev.tag if (latest_rev and latest_rev.tag) else 
""
+        revision = await data.revision(release_name=release.name, 
number=options.revision_number).get()
+        revision_number = revision.number if revision else ""
+        revision_tag = revision.tag if (revision and revision.tag) else ""
+
+    project_display_name = release.project.short_display_name if 
release.project else options.project_name
 
     routes_file_selected = get.file.selected
     download_path = util.as_url(
@@ -98,33 +110,21 @@ async def announce_release_body(body: str, options: 
AnnounceReleaseOptions) -> s
     # TODO: This download_url should probably be for the proxy download 
directory, not the ATR view
     download_url = f"https://{host}{download_path}";
 
+    # Perform substitutions in the subject
+    subject = subject.replace("{{PROJECT}}", project_display_name)
+    subject = subject.replace("{{VERSION}}", options.version_name)
+
     # Perform substitutions in the body
     body = body.replace("{{COMMITTEE}}", committee.display_name)
     body = body.replace("{{DOWNLOAD_URL}}", download_url)
-    body = body.replace("{{PROJECT}}", options.project_name)
+    body = body.replace("{{PROJECT}}", project_display_name)
     body = body.replace("{{REVISION}}", revision_number)
     body = body.replace("{{TAG}}", revision_tag)
     body = body.replace("{{VERSION}}", options.version_name)
     body = body.replace("{{YOUR_ASF_ID}}", options.asfuid)
     body = body.replace("{{YOUR_FULL_NAME}}", options.fullname)
 
-    return body
-
-
-async def announce_release_default(project_name: str) -> str:
-    async with db.session() as data:
-        project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE, _release_policy=True).demand(
-            RuntimeError(f"Project {project_name} not found")
-        )
-
-    return project.policy_announce_release_template
-
-
-def announce_release_subject(subject: str, options: AnnounceReleaseOptions) -> 
str:
-    subject = subject.replace("{{PROJECT}}", options.project_name)
-    subject = subject.replace("{{VERSION}}", options.version_name)
-
-    return subject
+    return subject, body
 
 
 async def announce_release_subject_default(project_name: str) -> str:
@@ -176,7 +176,16 @@ def checklist_template_variables() -> list[tuple[str, 
str]]:
     return [(name, desc) for (name, desc, contexts) in TEMPLATE_VARIABLES if 
"checklist" in contexts]
 
 
-async def start_vote_body(body: str, options: StartVoteOptions) -> str:
+async def start_vote_default(project_name: str) -> str:
+    async with db.session() as data:
+        project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE, _release_policy=True).demand(
+            RuntimeError(f"Project {project_name} not found")
+        )
+
+    return project.policy_start_vote_template
+
+
+async def start_vote_subject_and_body(subject: str, body: str, options: 
StartVoteOptions) -> tuple[str, str]:
     import atr.get.checklist as checklist
     import atr.get.vote as vote
 
@@ -192,9 +201,9 @@ async def start_vote_body(body: str, options: 
StartVoteOptions) -> str:
             raise RuntimeError(f"Release {options.project_name} 
{options.version_name} has no committee")
         committee = release.committee
 
-        latest_rev = await interaction.latest_revision(release, 
caller_data=data)
-        revision_number = latest_rev.number if latest_rev else ""
-        revision_tag = latest_rev.tag if (latest_rev and latest_rev.tag) else 
""
+        revision = await data.revision(release_name=release.name, 
number=options.revision_number).get()
+        revision_number = revision.number if revision else ""
+        revision_tag = revision.tag if (revision and revision.tag) else ""
 
     try:
         host = quart.request.host
@@ -207,7 +216,7 @@ async def start_vote_body(body: str, options: 
StartVoteOptions) -> str:
     checklist_url = f"https://{host}{checklist_path}";
     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_short_display_name = release.project.short_display_name if 
release.project else options.project_name
+    project_display_name = release.project.short_display_name if 
release.project else options.project_name
 
     # 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
@@ -233,51 +242,32 @@ async def start_vote_body(body: str, options: 
StartVoteOptions) -> str:
             project=release.project,
             version_name=options.version_name,
             committee=committee,
-            revision=latest_rev,
+            revision=revision,
         )
 
+    # Perform substitutions in the subject
+    subject = subject.replace("{{COMMITTEE}}", committee.display_name)
+    subject = subject.replace("{{PROJECT}}", project_display_name)
+    subject = subject.replace("{{REVISION}}", revision_number)
+    subject = subject.replace("{{TAG}}", revision_tag)
+    subject = subject.replace("{{VERSION}}", options.version_name)
+
     # Perform substitutions in the body
     # TODO: Handle the DURATION == 0 case
     body = body.replace("{{CHECKLIST_URL}}", checklist_url)
     body = body.replace("{{COMMITTEE}}", committee.display_name)
     body = body.replace("{{DURATION}}", str(options.vote_duration))
     body = body.replace("{{KEYS_FILE}}", keys_file or "(Sorry, the KEYS file 
is missing!)")
-    body = body.replace("{{PROJECT}}", project_short_display_name)
+    body = body.replace("{{PROJECT}}", project_display_name)
     body = body.replace("{{RELEASE_CHECKLIST}}", checklist_content)
     body = body.replace("{{REVIEW_URL}}", review_url)
     body = body.replace("{{REVISION}}", revision_number)
     body = body.replace("{{TAG}}", revision_tag)
     body = body.replace("{{VERSION}}", options.version_name)
-    body = body.replace("{{VOTE_ENDS_UTC}}", options.vote_end)
     body = body.replace("{{YOUR_ASF_ID}}", options.asfuid)
     body = body.replace("{{YOUR_FULL_NAME}}", options.fullname)
 
-    return body
-
-
-async def start_vote_default(project_name: str) -> str:
-    async with db.session() as data:
-        project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE, _release_policy=True).demand(
-            RuntimeError(f"Project {project_name} not found")
-        )
-
-    return project.policy_start_vote_template
-
-
-def start_vote_subject(
-    subject: str,
-    options: StartVoteOptions,
-    revision_number: str,
-    revision_tag: str,
-    committee_name: str,
-) -> str:
-    subject = subject.replace("{{COMMITTEE}}", committee_name)
-    subject = subject.replace("{{PROJECT}}", options.project_name)
-    subject = subject.replace("{{REVISION}}", revision_number)
-    subject = subject.replace("{{TAG}}", revision_tag)
-    subject = subject.replace("{{VERSION}}", options.version_name)
-
-    return subject
+    return subject, body
 
 
 async def start_vote_subject_default(project_name: str) -> str:
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 2c2d5c8..4e04fc8 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -24,6 +24,7 @@ import atr.blueprints.get as get
 import atr.config as config
 import atr.construct as construct
 import atr.form as form
+import atr.get.projects as projects
 import atr.htm as htm
 import atr.models.sql as sql
 import atr.post as post
@@ -43,18 +44,29 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
         project_name, version_name, with_committee=True, 
phase=sql.ReleasePhase.RELEASE_PREVIEW
     )
 
+    latest_revision_number = release.latest_revision_number
+    if latest_revision_number is None:
+        return await session.redirect(
+            projects.view,
+            error="No revisions exist for this release.",
+            name=project_name,
+        )
+
     # Get the templates from the release policy
     default_subject_template = await 
construct.announce_release_subject_default(project_name)
-    default_body = await construct.announce_release_default(project_name)
+    default_body_template = await 
construct.announce_release_default(project_name)
 
-    # Expand the subject template
+    # Expand the templates
     options = construct.AnnounceReleaseOptions(
         asfuid=session.uid,
         fullname=session.fullname,
-        project_name=release.project.display_name or project_name,
+        project_name=project_name,
         version_name=version_name,
+        revision_number=latest_revision_number,
+    )
+    default_subject, default_body = await 
construct.announce_release_subject_and_body(
+        default_subject_template, default_body_template, options
     )
-    default_subject = 
construct.announce_release_subject(default_subject_template, options)
 
     # The download path suffix can be changed
     # The defaults depend on whether the project is top level or not
@@ -86,13 +98,26 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
         title=f"Announce and distribute {release.project.display_name} 
{release.version}",
         description=f"Announce and distribute {release.project.display_name} 
{release.version} as a release.",
         content=content,
-        javascripts=["announce-confirm", "announce-preview", "copy-variable"],
+        javascripts=["announce-confirm"],
     )
 
 
-def _render_body_tabs(default_body: str) -> htm.Element:
-    """Render the tabbed interface for body editing and preview."""
-    return render.body_tabs("announce-body", default_body, 
construct.announce_template_variables())
+def _render_body_field(default_body: str, project_name: str) -> htm.Element:
+    """Render the body textarea with a link to edit the template."""
+    textarea = htpy.textarea(
+        "#body.form-control.font-monospace",
+        name="body",
+        rows="12",
+    )[default_body]
+
+    settings_url = util.as_url(projects.view, name=project_name) + 
"#announce_release_template"
+    link = htm.div(".form-text.text-muted.mt-2")[
+        "To edit the template, go to the ",
+        htm.a(href=settings_url)["project settings"],
+        ".",
+    ]
+
+    return htm.div[textarea, link]
 
 
 def _render_download_path_field(default_value: str, description: str) -> 
htm.Element:
@@ -183,8 +208,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 widget for body tabs and mailing list with warning
-    custom_body_widget = _render_body_tabs(default_body)
+    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)
 
     # Custom widget for download_path_suffix with custom documentation
@@ -196,10 +220,6 @@ async def _render_page(
         "body": default_body,
     }
 
-    preview_url = util.as_url(
-        post.preview.announce_preview, project_name=release.project.name, 
version_name=release.version
-    )
-
     form.render_block(
         page,
         model_cls=shared.announce.AnnounceForm,
@@ -216,9 +236,6 @@ async def _render_page(
         wider_widgets=True,
     )
 
-    # TODO: Would be better if we could add data-preview-url to the form
-    page.append(htpy.div("#announce-config.d-none", 
data_preview_url=preview_url))
-
     return page.collect()
 
 
diff --git a/atr/get/voting.py b/atr/get/voting.py
index e5e9f13..cc334d9 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -26,6 +26,7 @@ import atr.db.interaction as interaction
 import atr.form as form
 import atr.get.compose as compose
 import atr.get.keys as keys
+import atr.get.projects as projects
 import atr.htm as htm
 import atr.models.sql as sql
 import atr.post as post
@@ -63,29 +64,26 @@ async def selected_revision(
         if release.release_policy and (release.release_policy.min_hours is not 
None):
             min_hours = release.release_policy.min_hours
 
-        revision_obj = await data.revision(release_name=release.name, 
number=revision).get()
-        revision_number = str(revision)
-        revision_tag = revision_obj.tag if (revision_obj and revision_obj.tag) 
else ""
-
         default_subject_template = await 
construct.start_vote_subject_default(project_name)
-        default_body = await construct.start_vote_default(project_name)
+        default_body_template = await 
construct.start_vote_default(project_name)
 
         options = construct.StartVoteOptions(
             asfuid=session.uid,
             fullname=session.fullname,
-            project_name=release.project.display_name or project_name,
+            project_name=project_name,
             version_name=release.version,
+            revision_number=revision,
             vote_duration=min_hours,
-            vote_end="",
         )
-        default_subject = construct.start_vote_subject(
-            default_subject_template, options, revision_number, revision_tag, 
committee.display_name
+        default_subject, default_body = await 
construct.start_vote_subject_and_body(
+            default_subject_template, default_body_template, options
         )
 
         keys_warning = await _check_keys_warning(committee)
 
         content = await _render_page(
             release=release,
+            revision_number=revision,
             permitted_recipients=permitted_recipients,
             default_subject=default_subject,
             default_body=default_body,
@@ -96,7 +94,7 @@ async def selected_revision(
         return await template.blank(
             title=f"Start voting on {release.project.short_display_name} 
{release.version}",
             content=content,
-            javascripts=["copy-variable", "vote-preview"],
+            javascripts=["vote-body-duration"],
         )
 
 
@@ -109,13 +107,27 @@ async def _check_keys_warning(committee: sql.Committee) 
-> bool:
     return not await aiofiles.os.path.isfile(keys_file_path)
 
 
-def _render_body_tabs(default_body: str) -> htm.Element:
-    """Render the tabbed interface for body editing and preview."""
-    return render.body_tabs("vote-body", default_body, 
construct.vote_template_variables())
+def _render_body_field(default_body: str, project_name: str) -> htm.Element:
+    """Render the body textarea with a link to edit the template."""
+    textarea = htpy.textarea(
+        "#body.form-control.font-monospace",
+        name="body",
+        rows="12",
+    )[default_body]
+
+    settings_url = util.as_url(projects.view, name=project_name) + 
"#start_vote_template"
+    link = htm.div(".form-text.text-muted.mt-2")[
+        "To edit the template, go to the ",
+        htm.a(href=settings_url)["project settings"],
+        ".",
+    ]
+
+    return htm.div[textarea, link]
 
 
 async def _render_page(
     release,
+    revision_number: str,
     permitted_recipients: list[str],
     default_subject: str,
     default_body: str,
@@ -165,7 +177,7 @@ async def _render_page(
         version_name=release.version,
     )
 
-    custom_body_widget = _render_body_tabs(default_body)
+    custom_body_widget = _render_body_field(default_body, release.project.name)
 
     vote_form = form.render(
         model_cls=shared.voting.StartVotingForm,
@@ -184,9 +196,11 @@ async def _render_page(
     page.append(vote_form)
 
     preview_url = util.as_url(
-        post.preview.vote_preview, project_name=release.project.name, 
version_name=release.version
+        post.voting.body_preview,
+        project_name=release.project.name,
+        version_name=release.version,
+        revision_number=revision_number,
     )
-    # TODO: It would be better to have these attributes on the form
-    page.append(htpy.div("#vote-config.d-none", data_preview_url=preview_url, 
data_min_hours=str(min_hours)))
+    page.append(htpy.div("#vote-body-config.d-none", 
data_preview_url=preview_url))
 
     return page.collect()
diff --git a/atr/models/sql.py b/atr/models/sql.py
index e2ddadc..659468f 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -584,7 +584,7 @@ Please review the release candidate and vote accordingly.
 
 You can vote on ATR at the URL above, or manually by replying to this email.
 
-The vote ends after {{DURATION}} hours at {{VOTE_ENDS_UTC}}.
+The vote is open for {{DURATION}} hours.
 
 {{RELEASE_CHECKLIST}}
 Thanks,
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index 2fad021..5885f7a 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -24,7 +24,6 @@ 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
 import atr.post.revisions as revisions
@@ -47,7 +46,6 @@ __all__ = [
     "ignores",
     "keys",
     "manual",
-    "preview",
     "projects",
     "resolve",
     "revisions",
diff --git a/atr/post/draft.py b/atr/post/draft.py
index dc81f94..952f498 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -17,15 +17,12 @@
 
 from __future__ import annotations
 
-import datetime
-
 import aiofiles.os
 import aioshutil
 import asfquart.base as base
 import quart
 
 import atr.blueprints.post as post
-import atr.construct as construct
 import atr.db.interaction as interaction
 import atr.form as form
 import atr.get as get
@@ -37,12 +34,6 @@ import atr.util as util
 import atr.web as web
 
 
-class VotePreviewForm(form.Form):
-    body: str = form.label("Body", widget=form.Widget.TEXTAREA)
-    # Note: this does not provide any vote duration validation; this simply 
displays a preview to the user
-    vote_duration: form.Int = form.label("Vote duration")
-
-
 @post.committer("/compose/<project_name>/<version_name>")
 @post.empty()
 async def delete(session: web.Committer, project_name: str, version_name: str) 
-> web.WerkzeugResponse:
@@ -216,36 +207,3 @@ async def sbomgen(session: web.Committer, project_name: 
str, version_name: str,
         project_name=project_name,
         version_name=version_name,
     )
-
-
[email protected]("/draft/vote/preview/<project_name>/<version_name>")
[email protected](VotePreviewForm)
-async def vote_preview(
-    session: web.Committer, vote_preview_form: VotePreviewForm, project_name: 
str, version_name: str
-) -> web.QuartResponse | web.WerkzeugResponse | str:
-    """Show the vote email preview for a release."""
-
-    release = await session.release(project_name, version_name)
-    if release.committee is None:
-        raise web.FlashError("Release has no associated committee")
-
-    form_body: str = vote_preview_form.body
-    asfuid = session.uid
-    project_name = release.project.name
-    version_name = release.version
-    vote_duration: int = vote_preview_form.vote_duration
-    vote_end = datetime.datetime.now(datetime.UTC) + 
datetime.timedelta(hours=vote_duration)
-    vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
-
-    body = await construct.start_vote_body(
-        form_body,
-        construct.StartVoteOptions(
-            asfuid=asfuid,
-            fullname=session.fullname,
-            project_name=project_name,
-            version_name=version_name,
-            vote_duration=vote_duration,
-            vote_end=vote_end_str,
-        ),
-    )
-    return web.TextResponse(body)
diff --git a/atr/post/preview.py b/atr/post/preview.py
deleted file mode 100644
index ba42334..0000000
--- a/atr/post/preview.py
+++ /dev/null
@@ -1,110 +0,0 @@
-# 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 datetime
-
-import pydantic
-
-import atr.blueprints.post as post
-import atr.construct as construct
-import atr.form as form
-import atr.log as log
-import atr.web as web
-
-
-class AnnouncePreviewForm(form.Form):
-    body: str = form.label("Body", widget=form.Widget.TEXTAREA)
-
-
-class VotePreviewForm(form.Form):
-    body: str = form.label("Body", widget=form.Widget.TEXTAREA)
-    duration: form.Int = form.label("Vote duration")
-
-
[email protected]("/preview/announce/<project_name>/<version_name>")
-# Do not add a post.form decorator here because this is requested from 
JavaScript
-# TODO We could perhaps add a parameter to the decorator
-async def announce_preview(session: web.Committer, project_name: str, 
version_name: str) -> web.QuartResponse:
-    """Generate a preview of the announcement email body from JavaScript."""
-
-    form_data = await form.quart_request()
-
-    try:
-        # Because this is requested from JavaScript, we validate manually
-        # Otherwise errors redirect back to a page which does not exist
-        validated_form = form.validate(AnnouncePreviewForm, form_data)
-        if not isinstance(validated_form, AnnouncePreviewForm):
-            raise ValueError("Invalid form data")
-    except pydantic.ValidationError as e:
-        errors = e.errors()
-        error_details = "; ".join([f"{err['loc'][0]}: {err['msg']}" for err in 
errors])
-        return web.TextResponse(f"Error: Invalid preview request: 
{error_details}", status=400)
-
-    try:
-        # Construct options and generate body
-        options = construct.AnnounceReleaseOptions(
-            asfuid=session.uid,
-            fullname=session.fullname,
-            project_name=project_name,
-            version_name=version_name,
-        )
-        preview_body = await 
construct.announce_release_body(validated_form.body, options)
-
-        return web.TextResponse(preview_body)
-
-    except Exception as e:
-        log.exception("Error generating announcement preview:")
-        return web.TextResponse(f"Error generating preview: {e!s}", status=500)
-
-
[email protected]("/preview/vote/<project_name>/<version_name>")
-# Do not add a post.form decorator here because this is requested from 
JavaScript
-async def vote_preview(session: web.Committer, project_name: str, 
version_name: str) -> web.QuartResponse:
-    """Generate a preview of the vote email body from JavaScript."""
-
-    form_data = await form.quart_request()
-
-    try:
-        # Because this is requested from JavaScript, we validate manually
-        # Otherwise errors redirect back to a page which does not exist
-        validated_form = form.validate(VotePreviewForm, form_data)
-        if not isinstance(validated_form, VotePreviewForm):
-            raise ValueError("Invalid form data")
-    except pydantic.ValidationError as e:
-        errors = e.errors()
-        error_details = "; ".join([f"{err['loc'][0]}: {err['msg']}" for err in 
errors])
-        return web.TextResponse(f"Error: Invalid preview request: 
{error_details}", status=400)
-
-    try:
-        vote_end = datetime.datetime.now(datetime.UTC) + 
datetime.timedelta(hours=validated_form.duration)
-        vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
-
-        options = construct.StartVoteOptions(
-            asfuid=session.uid,
-            fullname=session.fullname,
-            project_name=project_name,
-            version_name=version_name,
-            vote_duration=validated_form.duration,
-            vote_end=vote_end_str,
-        )
-        preview_body = await construct.start_vote_body(validated_form.body, 
options)
-
-        return web.TextResponse(preview_body)
-
-    except Exception as e:
-        log.exception("Error generating vote preview:")
-        return web.TextResponse(f"Error generating preview: {e!s}", status=500)
diff --git a/atr/post/voting.py b/atr/post/voting.py
index 77798f4..2d8fdb1 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -18,8 +18,10 @@
 from __future__ import annotations
 
 import atr.blueprints.post as post
+import atr.construct as construct
 import atr.db as db
 import atr.db.interaction as interaction
+import atr.form as form
 import atr.get as get
 import atr.log as log
 import atr.shared as shared
@@ -28,6 +30,10 @@ import atr.util as util
 import atr.web as web
 
 
+class BodyPreviewForm(form.Form):
+    vote_duration: form.Int = form.label("Vote duration")
+
+
 @post.committer("/voting/<project_name>/<version_name>/<revision>")
 @post.form(shared.voting.StartVotingForm)
 async def selected_revision(
@@ -84,3 +90,30 @@ 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/render.py b/atr/render.py
index 4329e15..18a528a 100644
--- a/atr/render.py
+++ b/atr/render.py
@@ -17,8 +17,6 @@
 
 from typing import Literal
 
-import htpy
-
 import atr.get as get
 import atr.htm as htm
 import atr.util as util
@@ -26,57 +24,6 @@ import atr.util as util
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
-def body_tabs(
-    tab_id_prefix: str,
-    default_body: str,
-    template_variables: list[tuple[str, str]],
-) -> htm.Element:
-    tabs = htm.Block(htm.ul(f"#{tab_id_prefix}-tab.nav.nav-tabs", 
role="tablist"))
-    tabs.li(".nav-item", role="presentation")[
-        htpy.button(
-            f"#edit-{tab_id_prefix}-tab.nav-link.active",
-            data_bs_toggle="tab",
-            data_bs_target=f"#edit-{tab_id_prefix}-pane",
-            type="button",
-            role="tab",
-            aria_controls=f"edit-{tab_id_prefix}-pane",
-            aria_selected="true",
-        )["Edit"]
-    ]
-    tabs.li(".nav-item", role="presentation")[
-        htpy.button(
-            f"#preview-{tab_id_prefix}-tab.nav-link",
-            data_bs_toggle="tab",
-            data_bs_target=f"#preview-{tab_id_prefix}-pane",
-            type="button",
-            role="tab",
-            aria_controls=f"preview-{tab_id_prefix}-pane",
-            aria_selected="false",
-        )["Text preview"]
-    ]
-    tabs.append(_variables_tab_button(tab_id_prefix))
-
-    edit_pane = 
htm.div(f"#edit-{tab_id_prefix}-pane.tab-pane.fade.show.active", 
role="tabpanel")[
-        htpy.textarea(
-            "#body.form-control.font-monospace.mt-2",
-            name="body",
-            rows="12",
-        )[default_body]
-    ]
-
-    preview_pane = htm.div(f"#preview-{tab_id_prefix}-pane.tab-pane.fade", 
role="tabpanel")[
-        
htm.pre(".mt-2.p-3.bg-light.border.rounded.font-monospace.overflow-auto")[
-            htm.code(f"#{tab_id_prefix}-preview-content")["Loading preview..."]
-        ]
-    ]
-
-    variables_pane = _variables_tab(tab_id_prefix, template_variables)
-
-    tab_content = 
htm.div(f"#{tab_id_prefix}-tab-content.tab-content")[edit_pane, preview_pane, 
variables_pane]
-
-    return htm.div[tabs.collect(), tab_content]
-
-
 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=classes)
@@ -122,51 +69,3 @@ def html_nav_phase(block: htm.Block, project: str, version: 
str, staging: bool)
         back_anchor=f"{label.title()} {project} {version}",
         phase=label,
     )
-
-
-def _variables_tab(
-    tab_id_prefix: str,
-    template_variables: list[tuple[str, str]],
-) -> htm.Element:
-    variable_rows = []
-    for name, description in template_variables:
-        variable_rows.append(
-            htm.tr[
-                htm.td(".font-monospace.text-nowrap")[f"{{{{{name}}}}}"],
-                htm.td[description],
-                htm.td(".text-end")[
-                    htpy.button(
-                        ".btn.btn-sm.btn-outline-secondary.copy-var-btn",
-                        type="button",
-                        data_variable=f"{{{{{name}}}}}",
-                    )["Copy"]
-                ],
-            ]
-        )
-
-    variables_table = htm.table(".table.table-sm.mt-2")[
-        htm.thead[
-            htm.tr[
-                htm.th["Variable"],
-                htm.th["Description"],
-                htm.th[""],
-            ]
-        ],
-        htm.tbody[*variable_rows],
-    ]
-
-    return htm.div(f"#{tab_id_prefix}-variables-pane.tab-pane.fade", 
role="tabpanel")[variables_table]
-
-
-def _variables_tab_button(tab_id_prefix: str) -> htm.Element:
-    return htm.li(".nav-item", role="presentation")[
-        htpy.button(
-            f"#{tab_id_prefix}-variables-tab.nav-link",
-            data_bs_toggle="tab",
-            data_bs_target=f"#{tab_id_prefix}-variables-pane",
-            type="button",
-            role="tab",
-            aria_controls=f"{tab_id_prefix}-variables-pane",
-            aria_selected="false",
-        )["Variables"]
-    ]
diff --git a/atr/static/js/src/announce-preview.js 
b/atr/static/js/src/announce-preview.js
deleted file mode 100644
index 4e7f751..0000000
--- a/atr/static/js/src/announce-preview.js
+++ /dev/null
@@ -1,147 +0,0 @@
-/*
- *  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.
- */
-
-function fetchAnnouncePreview(
-       previewUrl,
-       csrfToken,
-       bodyTextarea,
-       textPreviewContent,
-) {
-       const bodyContent = bodyTextarea.value;
-
-       fetch(previewUrl, {
-               method: "POST",
-               headers: {
-                       "Content-Type": "application/x-www-form-urlencoded",
-                       "X-CSRFToken": csrfToken,
-               },
-               body: new URLSearchParams({
-                       body: bodyContent,
-                       csrf_token: csrfToken,
-               }),
-       })
-               .then((response) => {
-                       if (!response.ok) {
-                               return response.text().then((text) => {
-                                       throw new Error(`HTTP error 
${response.status}: ${text}`);
-                               });
-                       }
-                       return response.text();
-               })
-               .then((previewText) => {
-                       textPreviewContent.textContent = previewText;
-               })
-               .catch((error) => {
-                       console.error("Error fetching email preview:", error);
-                       textPreviewContent.textContent = `Error loading 
preview:\n${error.message}`;
-               });
-}
-
-function initAnnouncePreview() {
-       let debounceTimeout;
-       const debounceDelay = 500;
-
-       const bodyTextarea = document.getElementById("body");
-       const textPreviewContent = document.getElementById(
-               "announce-body-preview-content",
-       );
-       const announceForm = document.querySelector("form.atr-canary");
-       const configElement = document.getElementById("announce-config");
-
-       if (!bodyTextarea || !textPreviewContent || !announceForm) {
-               console.error("Required elements for announce preview not 
found. Exiting.");
-               return;
-       }
-
-       const previewUrl = configElement ? configElement.dataset.previewUrl : 
null;
-       const csrfTokenInput = 
announceForm.querySelector('input[name="csrf_token"]');
-
-       if (!previewUrl || !csrfTokenInput) {
-               console.error(
-                       "Required data attributes or CSRF token not found for 
announce preview.",
-               );
-               return;
-       }
-       const csrfToken = csrfTokenInput.value;
-
-       const doFetch = () =>
-               fetchAnnouncePreview(
-                       previewUrl,
-                       csrfToken,
-                       bodyTextarea,
-                       textPreviewContent,
-               );
-
-       bodyTextarea.addEventListener("input", () => {
-               clearTimeout(debounceTimeout);
-               debounceTimeout = setTimeout(doFetch, debounceDelay);
-       });
-
-       doFetch();
-}
-
-function initDownloadPathValidation() {
-       const pathInput = document.getElementById("download_path_suffix");
-       const pathHelpText = pathInput
-               ? pathInput.parentElement.querySelector(".form-text")
-               : null;
-
-       if (!pathInput || !pathHelpText) {
-               return;
-       }
-
-       const baseText = pathHelpText.dataset.baseText || "";
-       let pathDebounce;
-
-       const updatePathHelpText = () => {
-               let suffix = pathInput.value;
-               if (suffix.includes("..") || suffix.includes("//")) {
-                       pathHelpText.textContent =
-                               "Download path suffix must not contain .. or 
//";
-                       return;
-               }
-               if (suffix.startsWith("./")) {
-                       suffix = suffix.slice(1);
-               } else if (suffix === ".") {
-                       suffix = "/";
-               }
-               if (!suffix.startsWith("/")) {
-                       suffix = `/${suffix}`;
-               }
-               if (!suffix.endsWith("/")) {
-                       suffix = `${suffix}/`;
-               }
-               if (suffix.includes("/.")) {
-                       pathHelpText.textContent = "Download path suffix must 
not contain /.";
-                       return;
-               }
-               pathHelpText.textContent = baseText + suffix;
-       };
-
-       pathInput.addEventListener("input", () => {
-               clearTimeout(pathDebounce);
-               pathDebounce = setTimeout(updatePathHelpText, 10);
-       });
-       updatePathHelpText();
-}
-
-document.addEventListener("DOMContentLoaded", () => {
-       initAnnouncePreview();
-       initDownloadPathValidation();
-});
diff --git a/atr/static/js/src/vote-body-duration.js 
b/atr/static/js/src/vote-body-duration.js
new file mode 100644
index 0000000..37f2446
--- /dev/null
+++ b/atr/static/js/src/vote-body-duration.js
@@ -0,0 +1,151 @@
+/*
+ *  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.
+ */
+
+function createWarningDiv() {
+       const warningDiv = document.createElement("div");
+       warningDiv.className = "alert alert-warning mt-2 d-none";
+       warningDiv.innerHTML =
+               "<strong>Note:</strong> The vote duration cannot be changed 
because the message body has been customised. " +
+               '<br><button type="button" class="btn btn-sm 
btn-outline-secondary mt-2" id="discard-body-changes">Discard changes</button>';
+       return warningDiv;
+}
+
+function createModifiedStateUpdater(
+       bodyTextarea,
+       durationInput,
+       warningDiv,
+       state,
+) {
+       return function updateModifiedState() {
+               const currentlyModified = bodyTextarea.value !== 
state.pristineBody;
+
+               if (currentlyModified !== state.isModified) {
+                       state.isModified = currentlyModified;
+
+                       if (state.isModified) {
+                               durationInput.readOnly = true;
+                               durationInput.classList.add("bg-light");
+                               warningDiv.classList.remove("d-none");
+                       } else {
+                               durationInput.readOnly = false;
+                               durationInput.classList.remove("bg-light");
+                               warningDiv.classList.add("d-none");
+                       }
+               }
+       };
+}
+
+function createBodyFetcher(previewUrl, csrfToken, bodyTextarea, state) {
+       return async function fetchNewBody(duration) {
+               try {
+                       const formData = new FormData();
+                       formData.append("vote_duration", duration);
+                       if (csrfToken) {
+                               formData.append("csrf_token", csrfToken);
+                       }
+
+                       const response = await fetch(previewUrl, {
+                               method: "POST",
+                               body: formData,
+                       });
+
+                       if (!response.ok) {
+                               console.error("Failed to fetch new body:", 
response.statusText);
+                               return;
+                       }
+
+                       const newBody = await response.text();
+                       if (state.isModified) {
+                               return;
+                       }
+                       bodyTextarea.value = newBody;
+                       state.pristineBody = bodyTextarea.value;
+               } catch (error) {
+                       console.error("Error fetching new body:", error);
+               }
+       };
+}
+
+function attachEventListeners(
+       bodyTextarea,
+       durationInput,
+       discardButton,
+       state,
+       updateModifiedState,
+       fetchNewBody,
+) {
+       bodyTextarea.addEventListener("input", updateModifiedState);
+
+       durationInput.addEventListener("change", () => {
+               if (!state.isModified) {
+                       fetchNewBody(durationInput.value);
+               }
+       });
+
+       discardButton.addEventListener("click", () => {
+               bodyTextarea.value = state.pristineBody;
+               updateModifiedState();
+       });
+}
+
+function initVoteBodyDuration() {
+       const config = document.getElementById("vote-body-config");
+       if (!config) {
+               return;
+       }
+
+       const previewUrl = config.dataset.previewUrl;
+       const csrfToken = 
document.querySelector('input[name="csrf_token"]')?.value;
+       const bodyTextarea = document.getElementById("body");
+       const durationInput = document.getElementById("vote_duration");
+
+       if (!bodyTextarea || !durationInput || !previewUrl) {
+               return;
+       }
+
+       const state = { pristineBody: bodyTextarea.value, isModified: false };
+
+       const warningDiv = createWarningDiv();
+       bodyTextarea.parentNode.append(warningDiv);
+
+       const discardButton = document.getElementById("discard-body-changes");
+       const updateModifiedState = createModifiedStateUpdater(
+               bodyTextarea,
+               durationInput,
+               warningDiv,
+               state,
+       );
+       const fetchNewBody = createBodyFetcher(
+               previewUrl,
+               csrfToken,
+               bodyTextarea,
+               state,
+       );
+
+       attachEventListeners(
+               bodyTextarea,
+               durationInput,
+               discardButton,
+               state,
+               updateModifiedState,
+               fetchNewBody,
+       );
+}
+
+document.addEventListener("DOMContentLoaded", initVoteBodyDuration);
diff --git a/atr/static/js/src/vote-preview.js 
b/atr/static/js/src/vote-preview.js
deleted file mode 100644
index c8176c4..0000000
--- a/atr/static/js/src/vote-preview.js
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- *  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.
- */
-
-function getVotePreviewElements() {
-       const bodyTextarea = document.getElementById("body");
-       const voteDurationInput = document.getElementById("vote_duration");
-       const textPreviewContent = document.getElementById(
-               "vote-body-preview-content",
-       );
-       const voteForm = document.querySelector("form.atr-canary");
-       const configElement = document.getElementById("vote-config");
-
-       if (!bodyTextarea || !voteDurationInput || !textPreviewContent || 
!voteForm) {
-               console.error("Required elements for vote preview not found. 
Exiting.");
-               return null;
-       }
-
-       const previewUrl = configElement ? configElement.dataset.previewUrl : 
null;
-       const minHours = configElement ? configElement.dataset.minHours : "72";
-       const csrfTokenInput = 
voteForm.querySelector('input[name="csrf_token"]');
-
-       if (!previewUrl || !csrfTokenInput) {
-               console.error(
-                       "Required data attributes or CSRF token not found for 
vote preview.",
-               );
-               return null;
-       }
-
-       return {
-               bodyTextarea,
-               voteDurationInput,
-               textPreviewContent,
-               previewUrl,
-               minHours,
-               csrfToken: csrfTokenInput.value,
-       };
-}
-
-function createPreviewFetcher(elements) {
-       const {
-               bodyTextarea,
-               voteDurationInput,
-               textPreviewContent,
-               previewUrl,
-               minHours,
-               csrfToken,
-       } = elements;
-
-       return function fetchAndUpdateVotePreview() {
-               const bodyContent = bodyTextarea.value;
-               const voteDuration = voteDurationInput.value || minHours;
-
-               fetch(previewUrl, {
-                       method: "POST",
-                       headers: {
-                               "Content-Type": 
"application/x-www-form-urlencoded",
-                               "X-CSRFToken": csrfToken,
-                       },
-                       body: new URLSearchParams({
-                               body: bodyContent,
-                               duration: voteDuration,
-                               csrf_token: csrfToken,
-                       }),
-               })
-                       .then((response) => {
-                               if (!response.ok) {
-                                       return response.text().then((text) => {
-                                               throw new Error(`HTTP error 
${response.status}: ${text}`);
-                                       });
-                               }
-                               return response.text();
-                       })
-                       .then((previewText) => {
-                               textPreviewContent.textContent = previewText;
-                       })
-                       .catch((error) => {
-                               console.error("Error fetching email preview:", 
error);
-                               textPreviewContent.textContent = `Error loading 
preview:\n${error.message}`;
-                       });
-       };
-}
-
-function setupVotePreviewListeners(elements, fetchPreview) {
-       let debounceTimeout;
-       const debounceDelay = 500;
-
-       elements.bodyTextarea.addEventListener("input", () => {
-               clearTimeout(debounceTimeout);
-               debounceTimeout = setTimeout(fetchPreview, debounceDelay);
-       });
-
-       elements.voteDurationInput.addEventListener("input", () => {
-               clearTimeout(debounceTimeout);
-               debounceTimeout = setTimeout(fetchPreview, debounceDelay);
-       });
-}
-
-document.addEventListener("DOMContentLoaded", () => {
-       const elements = getVotePreviewElements();
-       if (!elements) return;
-
-       const fetchPreview = createPreviewFetcher(elements);
-       setupVotePreviewListeners(elements, fetchPreview);
-       fetchPreview();
-});
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index fc43b97..7fed019 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -111,8 +111,6 @@ class CommitteeMember(CommitteeParticipant):
         asf_uid: str,
         fullname: str,
     ) -> None:
-        import atr.construct as construct
-
         if recipient not in util.permitted_announce_recipients(asf_uid):
             raise storage.AccessError(f"You are not permitted to send 
announcements to {recipient}")
 
@@ -134,16 +132,6 @@ class CommitteeMember(CommitteeParticipant):
         if (committee := release.project.committee) is None:
             raise storage.AccessError("Release has no committee")
 
-        body = await construct.announce_release_body(
-            body,
-            options=construct.AnnounceReleaseOptions(
-                asfuid=asf_uid,
-                fullname=fullname,
-                project_name=project_name,
-                version_name=version_name,
-            ),
-        )
-
         # 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 f188a68..b4bd5e9 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -18,7 +18,6 @@
 # Removing this will cause circular imports
 from __future__ import annotations
 
-import datetime
 from typing import Literal
 
 import atr.construct as construct
@@ -169,34 +168,6 @@ class CommitteeParticipant(FoundationCommitter):
         # Presumably this sets the default, and the form takes precedence?
         # ReleasePolicy.min_hours can also be 0, though
 
-        # Calculate vote end time for template substitution
-        vote_start = datetime.datetime.now(datetime.UTC)
-        vote_end = vote_start + datetime.timedelta(hours=vote_duration_choice)
-        vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
-
-        options = construct.StartVoteOptions(
-            asfuid=asf_uid,
-            fullname=asf_fullname,
-            project_name=project_name,
-            version_name=version_name,
-            vote_duration=vote_duration_choice,
-            vote_end=vote_end_str,
-        )
-
-        # Get revision tag for subject substitution
-        revision_obj = await self.__data.revision(release_name=release.name, 
number=selected_revision_number).get()
-        revision_tag = revision_obj.tag if (revision_obj and revision_obj.tag) 
else ""
-
-        # Get committee name for subject substitution
-        committee_name = release.committee.display_name if release.committee 
else ""
-
-        # Perform template substitutions before passing to task
-        # This must be done here and not in the task because we need 
util.as_url
-        subject_substituted = construct.start_vote_subject(
-            subject_data, options, selected_revision_number, revision_tag, 
committee_name
-        )
-        body_substituted = await construct.start_vote_body(body_data, options)
-
         # Create a task for vote initiation
         task = sql.Task(
             status=sql.TaskStatus.QUEUED,
@@ -207,8 +178,8 @@ class CommitteeParticipant(FoundationCommitter):
                 vote_duration=vote_duration_choice,
                 initiator_id=asf_uid,
                 initiator_fullname=asf_fullname,
-                subject=subject_substituted,
-                body=body_substituted,
+                subject=subject_data,
+                body=body_data,
             ).model_dump(),
             asf_uid=asf_uid,
             project_name=project_name,
@@ -349,6 +320,20 @@ class CommitteeMember(CommitteeParticipant):
             revision_number = release.latest_revision_number
             if revision_number is None:
                 raise ValueError("Release has no revision number")
+            vote_duration = latest_vote_task.task_args["vote_duration"]
+            subject_template = await 
construct.start_vote_subject_default(release.project.name)
+            body_template = await 
construct.start_vote_default(release.project.name)
+            options = construct.StartVoteOptions(
+                asfuid=self.__asf_uid,
+                fullname=asf_fullname,
+                project_name=release.project.name,
+                version_name=release.version,
+                revision_number=revision_number,
+                vote_duration=vote_duration,
+            )
+            subject_data, body_data = await 
construct.start_vote_subject_and_body(
+                subject_template, body_template, options
+            )
             await self.start(
                 email_to=incubator_vote_address,
                 permitted_recipients=[incubator_vote_address],
@@ -357,9 +342,9 @@ class CommitteeMember(CommitteeParticipant):
                 selected_revision_number=revision_number,
                 asf_uid=self.__asf_uid,
                 asf_fullname=asf_fullname,
-                
vote_duration_choice=latest_vote_task.task_args["vote_duration"],
-                subject_data=await 
construct.start_vote_subject_default(release.project.name),
-                body_data=await 
construct.start_vote_default(release.project.name),
+                vote_duration_choice=vote_duration,
+                subject_data=subject_data,
+                body_data=body_data,
                 release=release,
                 promote=False,
             )


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

Reply via email to