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 d5b91f5 Add a template variable corresponding to when a vote ends
d5b91f5 is described below
commit d5b91f54aec6e70d3a9f970a2280f3940c5d2eef
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Nov 14 16:02:31 2025 +0000
Add a template variable corresponding to when a vote ends
---
atr/construct.py | 2 +
atr/get/voting.py | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++
atr/models/sql.py | 2 +-
atr/post/draft.py | 4 ++
atr/post/preview.py | 48 ++++++++++++++++++
atr/shared/voting.py | 2 +-
atr/tasks/vote.py | 1 +
7 files changed, 194 insertions(+), 2 deletions(-)
diff --git a/atr/construct.py b/atr/construct.py
index f908bbb..2ed558e 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -41,6 +41,7 @@ class StartVoteOptions:
project_name: str
version_name: str
vote_duration: int
+ vote_end: str
async def announce_release_body(body: str, options: AnnounceReleaseOptions) ->
str:
@@ -138,6 +139,7 @@ async def start_vote_body(body: str, options:
StartVoteOptions) -> str:
body = body.replace("[RELEASE_CHECKLIST]", checklist_content)
body = body.replace("[REVIEW_URL]", review_url)
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)
diff --git a/atr/get/voting.py b/atr/get/voting.py
index 2dd97de..fd686f7 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -17,6 +17,8 @@
import aiofiles.os
+import htpy
+import markupsafe
import atr.blueprints.get as get
import atr.construct as construct
@@ -27,6 +29,7 @@ import atr.get.compose as compose
import atr.get.keys as keys
import atr.htm as htm
import atr.models.sql as sql
+import atr.post as post
import atr.shared as shared
import atr.template as template
import atr.util as util
@@ -139,6 +142,9 @@ async def _render_page(
project_name=release.project.name,
version_name=release.version,
)
+
+ custom_body_widget = _render_body_tabs(default_body)
+
vote_form = form.render(
model_cls=shared.voting.StartVotingForm,
submit_label="Send vote email",
@@ -149,7 +155,138 @@ async def _render_page(
"subject": default_subject,
"body": default_body,
},
+ custom={
+ "body": custom_body_widget,
+ },
)
page.append(vote_form)
+ page.append(_render_javascript(release, min_hours))
return page.collect()
+
+
+def _render_body_tabs(default_body: str) -> htm.Element:
+ """Render the tabbed interface for body editing and preview."""
+
+ tabs_ul = htm.ul("#voteBodyTab.nav.nav-tabs", role="tablist")[
+ htm.li(".nav-item", role="presentation")[
+ htpy.button(
+ "#edit-vote-body-tab.nav-link.active",
+ data_bs_toggle="tab",
+ data_bs_target="#edit-vote-body-pane",
+ type="button",
+ role="tab",
+ aria_controls="edit-vote-body-pane",
+ aria_selected="true",
+ )["Edit"]
+ ],
+ htm.li(".nav-item", role="presentation")[
+ htpy.button(
+ "#text-preview-vote-body-tab.nav-link",
+ data_bs_toggle="tab",
+ data_bs_target="#text-preview-vote-body-pane",
+ type="button",
+ role="tab",
+ aria_controls="text-preview-vote-body-pane",
+ aria_selected="false",
+ )["Text preview"]
+ ],
+ ]
+
+ edit_pane = htm.div("#edit-vote-body-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("#text-preview-vote-body-pane.tab-pane.fade",
role="tabpanel")[
+
htm.pre(".mt-2.p-3.bg-light.border.rounded.font-monospace.overflow-auto")[
+ htm.code("#vote-text-preview-content")["Loading preview..."]
+ ]
+ ]
+
+ tab_content = htm.div("#voteBodyTabContent.tab-content")[edit_pane,
preview_pane]
+
+ return htm.div[tabs_ul, tab_content]
+
+
+def _render_javascript(release, min_hours: int) -> htm.Element:
+ """Render the JavaScript for email preview."""
+ preview_url = util.as_url(
+ post.preview.vote_preview, project_name=release.project.name,
version_name=release.version
+ )
+
+ js_code = f"""
+ document.addEventListener("DOMContentLoaded", () => {{
+ let debounceTimeout;
+ const debounceDelay = 500;
+
+ const bodyTextarea = document.getElementById("body");
+ const voteDurationInput = document.getElementById("vote_duration");
+ const textPreviewContent =
document.getElementById("vote-text-preview-content");
+ const voteForm = document.querySelector("form.atr-canary");
+
+ if (!bodyTextarea || !voteDurationInput || !textPreviewContent ||
!voteForm) {{
+ console.error("Required elements for vote preview not found.
Exiting.");
+ return;
+ }}
+
+ const previewUrl = "{preview_url}";
+ 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;
+ }}
+ const csrfToken = csrfTokenInput.value;
+
+ function fetchAndUpdateVotePreview() {{
+ const bodyContent = bodyTextarea.value;
+ const voteDuration = voteDurationInput.value || "{min_hours}";
+
+ 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}}`;
+ }});
+ }}
+
+ bodyTextarea.addEventListener("input", () => {{
+ clearTimeout(debounceTimeout);
+ debounceTimeout = setTimeout(fetchAndUpdateVotePreview,
debounceDelay);
+ }});
+
+ voteDurationInput.addEventListener("input", () => {{
+ clearTimeout(debounceTimeout);
+ debounceTimeout = setTimeout(fetchAndUpdateVotePreview,
debounceDelay);
+ }});
+
+ fetchAndUpdateVotePreview();
+ }});
+ """
+
+ return htpy.script[markupsafe.Markup(js_code)]
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 99da4d8..5686a78 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -574,7 +574,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.
-This vote will remain open for [DURATION] hours.
+The vote ends after [DURATION] hours at [VOTE_ENDS_UTC].
[RELEASE_CHECKLIST]
Thanks,
diff --git a/atr/post/draft.py b/atr/post/draft.py
index 9a98e80..ad356ac 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -17,6 +17,7 @@
from __future__ import annotations
+import datetime
import pathlib
import aiofiles.os
@@ -261,6 +262,8 @@ async def vote_preview(
project_name = release.project.name
version_name = release.version
vote_duration: int = util.unwrap(form.vote_duration.data)
+ 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,
@@ -270,6 +273,7 @@ async def vote_preview(
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
index f956806..ba42334 100644
--- a/atr/post/preview.py
+++ b/atr/post/preview.py
@@ -15,6 +15,8 @@
# specific language governing permissions and limitations
# under the License.
+import datetime
+
import pydantic
import atr.blueprints.post as post
@@ -28,7 +30,14 @@ 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")
+
+
@post.committer("/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."""
@@ -60,3 +69,42 @@ async def announce_preview(session: web.Committer,
project_name: str, version_na
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/shared/voting.py b/atr/shared/voting.py
index f72b52e..b8025fe 100644
--- a/atr/shared/voting.py
+++ b/atr/shared/voting.py
@@ -33,4 +33,4 @@ class StartVotingForm(form.Form):
default=72,
)
subject: str = form.label("Subject")
- body: str = form.label("Body", widget=form.Widget.TEXTAREA)
+ body: str = form.label("Body", widget=form.Widget.CUSTOM)
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index a9f2d61..0097671 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -113,6 +113,7 @@ async def _initiate_core_logic(args: Initiate) ->
results.Results | None:
project_name=release.project.name,
version_name=release.version,
vote_duration=args.vote_duration,
+ vote_end=vote_end_str,
),
)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]