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]

Reply via email to