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

commit 172c66d17af900f037383ab667cf9303836099fd
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/docs/user-interface.md              |   2 +-
 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 ++++-------
 15 files changed, 319 insertions(+), 664 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/docs/user-interface.md b/atr/docs/user-interface.md
index adba78b..e5f901a 100644
--- a/atr/docs/user-interface.md
+++ b/atr/docs/user-interface.md
@@ -146,7 +146,7 @@ async def add(session: web.Committer, add_openpgp_key_form: 
shared.keys.AddOpenP
     return await session.redirect(get.keys.keys)
 ```
 
-The [`form.validate`](/ref/atr/form.py:validate) function should only be 
called manually when the request comes from JavaScript, as in 
[`announce_preview`](/ref/atr/post/preview.py:announce_preview). It takes the 
form class, the form data dictionary, and an optional context dictionary. If 
validation succeeds, it returns an instance of your form class with validated 
data. If validation fails, it raises a `pydantic.ValidationError`.
+The [`form.validate`](/ref/atr/form.py:validate) function should only be 
called manually when the request comes from JavaScript, as in 
[`body_preview`](/ref/atr/post/voting.py:body_preview). It takes the form 
class, the form data dictionary, and an optional context dictionary. If 
validation succeeds, it returns an instance of your form class with validated 
data. If validation fails, it raises a `pydantic.ValidationError`.
 
 The error handling uses 
[`form.flash_error_data`](/ref/atr/form.py:flash_error_data) to prepare error 
information for display, and 
[`form.flash_error_summary`](/ref/atr/form.py:flash_error_summary) to create a 
user-friendly summary of all validation errors.
 
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