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 067027f  Make the form to announce a release more type safe
067027f is described below

commit 067027f6f992266048a528ec9a60ac077dfe7a64
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Nov 13 11:45:23 2025 +0000

    Make the form to announce a release more type safe
---
 atr/get/announce.py                  | 338 +++++++++++++++++++++++++++++++++--
 atr/post/announce.py                 |  81 +++------
 atr/shared/announce.py               |  60 ++++---
 atr/shared/distribution.py           |   1 +
 atr/templates/announce-selected.html | 275 ----------------------------
 5 files changed, 386 insertions(+), 369 deletions(-)

diff --git a/atr/get/announce.py b/atr/get/announce.py
index 3ecdecd..b3ab3dd 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -16,12 +16,19 @@
 # under the License.
 
 
+import htpy
+import markupsafe
+
 # TODO: Improve upon the routes_release pattern
 import atr.blueprints.get as get
 import atr.config as config
 import atr.construct as construct
+import atr.form as form
+import atr.htm as htm
 import atr.models.sql as sql
+import atr.post as post
 import atr.shared as shared
+import atr.shared.distribution as distribution
 import atr.template as template
 import atr.util as util
 import atr.web as web
@@ -35,38 +42,339 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
     release = await session.release(
         project_name, version_name, with_committee=True, 
phase=sql.ReleasePhase.RELEASE_PREVIEW
     )
-    announce_form = await 
shared.announce.create_form(util.permitted_announce_recipients(session.uid))
-    # Hidden fields
-    announce_form.preview_name.data = release.name
-    # There must be a revision to announce
-    announce_form.preview_revision.data = release.unwrap_revision_number
 
     # Variables used in defaults for subject and body
     project_display_name = release.project.display_name or release.project.name
 
     # The subject cannot be changed by the user
-    announce_form.subject.data = f"[ANNOUNCE] {project_display_name} 
{version_name} released"
+    default_subject = f"[ANNOUNCE] {project_display_name} {version_name} 
released"
     # The body can be changed, either from VoteTemplate or from the form
-    announce_form.body.data = await 
construct.announce_release_default(project_name)
+    default_body = await construct.announce_release_default(project_name)
+
     # The download path suffix can be changed
     # The defaults depend on whether the project is top level or not
     if (committee := release.project.committee) is None:
         raise ValueError("Release has no committee")
     top_level_project = release.project.name == util.unwrap(committee.name)
     # These defaults are as per #136, but we allow the user to change the 
result
-    announce_form.download_path_suffix.data = (
-        "/" if top_level_project else 
f"/{release.project.name}-{release.version}/"
-    )
+    default_download_path_suffix = "/" if top_level_project else 
f"/{release.project.name}-{release.version}/"
+
     # This must NOT end with a "/"
     description_download_prefix = f"https://{config.get().APP_HOST}/downloads"
     if committee.is_podling:
         description_download_prefix += "/incubator"
     description_download_prefix += f"/{committee.name}"
-    announce_form.download_path_suffix.description = f"The URL will be 
{description_download_prefix} plus this suffix"
 
-    return await template.render(
-        "announce-selected.html",
+    permitted_recipients = util.permitted_announce_recipients(session.uid)
+    mailing_list_choices = sorted([(recipient, recipient) for recipient in 
permitted_recipients])
+
+    content = await _render_page(
         release=release,
-        announce_form=announce_form,
-        user_tests_address=util.USER_TESTS_ADDRESS,
+        mailing_list_choices=mailing_list_choices,
+        default_subject=default_subject,
+        default_body=default_body,
+        default_download_path_suffix=default_download_path_suffix,
+        download_path_description=f"The URL will be 
{description_download_prefix} plus this suffix",
+    )
+
+    return await template.blank(
+        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,
     )
+
+
+async def _render_page(
+    release: sql.Release,
+    mailing_list_choices: list[tuple[str, str]],
+    default_subject: str,
+    default_body: str,
+    default_download_path_suffix: str,
+    download_path_description: str,
+) -> htm.Element:
+    """Render the announce page."""
+    page = htm.Block()
+
+    page_styles = """
+        .page-preview-meta-item::after {
+            content: "•";
+            margin-left: 1rem;
+            color: #ccc;
+        }
+        .page-preview-meta-item:last-child::after {
+            content: none;
+        }
+    """
+    page.style[markupsafe.Markup(page_styles)]
+
+    distribution.html_nav_phase(page, release.project.name, release.version, 
staging=False)
+
+    page.h1[
+        "Announce ",
+        htm.strong[release.project.short_display_name],
+        " ",
+        htm.em[release.version],
+    ]
+    page.append(_render_release_card(release))
+    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_mailing_list_widget = 
_render_mailing_list_with_warning(mailing_list_choices, util.USER_TESTS_ADDRESS)
+
+    # Custom widget for download_path_suffix with custom documentation
+    download_path_widget = 
_render_download_path_field(default_download_path_suffix, 
download_path_description)
+
+    defaults_dict = {
+        "revision_number": release.unwrap_revision_number,
+        "subject": default_subject,
+        "body": default_body,
+    }
+
+    form.render_block(
+        page,
+        model_cls=shared.announce.AnnounceForm,
+        action=util.as_url(post.announce.selected, 
project_name=release.project.name, version_name=release.version),
+        submit_label="Send announcement email",
+        defaults=defaults_dict,
+        custom={
+            "body": custom_body_widget,
+            "mailing_list": custom_mailing_list_widget,
+            "download_path_suffix": download_path_widget,
+        },
+        form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
+        border=True,
+        wider_widgets=True,
+    )
+
+    page.append(_render_javascript(release, download_path_description))
+    return page.collect()
+
+
+def _render_release_card(release: sql.Release) -> htm.Element:
+    """Render the release information card."""
+    card = htm.div(f"#{release.name}.card.mb-4.shadow-sm")[
+        htm.div(".card-header.bg-light")[htm.h3(".card-title.mb-0")["About 
this release preview"]],
+        htm.div(".card-body")[
+            htm.div(".d-flex.flex-wrap.gap-3.pb-1.text-secondary.fs-6")[
+                htm.span(".page-preview-meta-item")[f"Revision: 
{release.latest_revision_number}"],
+                htm.span(".page-preview-meta-item")[f"Created: 
{release.created.strftime('%Y-%m-%d %H:%M:%S UTC')}"],
+            ],
+        ],
+    ]
+    return card
+
+
+def _render_body_tabs(default_body: str) -> htm.Element:
+    """Render the tabbed interface for body editing and preview."""
+
+    tabs_ul = htm.ul("#announceBodyTab.nav.nav-tabs", role="tablist")[
+        htm.li(".nav-item", role="presentation")[
+            htpy.button(
+                "#edit-announce-body-tab.nav-link.active",
+                data_bs_toggle="tab",
+                data_bs_target="#edit-announce-body-pane",
+                type="button",
+                role="tab",
+                aria_controls="edit-announce-body-pane",
+                aria_selected="true",
+            )["Edit"]
+        ],
+        htm.li(".nav-item", role="presentation")[
+            htpy.button(
+                "#text-preview-announce-body-tab.nav-link",
+                data_bs_toggle="tab",
+                data_bs_target="#text-preview-announce-body-pane",
+                type="button",
+                role="tab",
+                aria_controls="text-preview-announce-body-pane",
+                aria_selected="false",
+            )["Text preview"]
+        ],
+    ]
+
+    edit_pane = htm.div("#edit-announce-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-announce-body-pane.tab-pane.fade", 
role="tabpanel")[
+        
htm.pre(".mt-2.p-3.bg-light.border.rounded.font-monospace.overflow-auto")[
+            htm.code("#announce-text-preview-content")["Loading preview..."]
+        ]
+    ]
+
+    tab_content = htm.div("#announceBodyTabContent.tab-content")[edit_pane, 
preview_pane]
+
+    return htm.div[tabs_ul, tab_content]
+
+
+def _render_mailing_list_with_warning(choices: list[tuple[str, str]], 
default_value: str) -> htm.Element:
+    """Render the mailing list radio buttons with a warning card."""
+    container = htm.Block(htm.div)
+
+    # Radio buttons
+    radio_container = htm.div(".d-flex.flex-wrap.gap-2.mb-3")
+    radio_buttons = []
+    for value, label in choices:
+        radio_id = f"mailing_list_{value}"
+        radio_attrs = {
+            "type": "radio",
+            "name": "mailing_list",
+            "value": value,
+        }
+        if value == default_value:
+            radio_attrs["checked"] = ""
+
+        radio_buttons.append(
+            htm.div(".form-check")[
+                htpy.input(f"#{radio_id}.form-check-input", **radio_attrs),
+                htpy.label(".form-check-label", for_=radio_id)[label],
+            ]
+        )
+    container.append(radio_container[radio_buttons])
+
+    # Warning card
+    warning_card = htm.div(".card.bg-warning-subtle.mb-3")[
+        htm.span(".card-body.p-3")[
+            htpy.i(".bi.bi-exclamation-triangle.me-1"),
+            htm.strong["TODO: "],
+            "The limited options above are provided for testing purposes. In 
the finished version of ATR, "
+            "you will be able to send to your own specified mailing lists.",
+        ]
+    ]
+    container.append(warning_card)
+
+    return container.collect()
+
+
+def _render_download_path_field(default_value: str, description: str) -> 
htm.Element:
+    """Render the download path suffix field with custom help text."""
+    return htm.div[
+        htpy.input(
+            "#download_path_suffix.form-control",
+            type="text",
+            name="download_path_suffix",
+            value=default_value,
+        ),
+        htm.div(".form-text.text-muted.mt-2")[description],
+    ]
+
+
+def _render_javascript(release: sql.Release, download_path_description: str) 
-> htm.Element:
+    """Render the JavaScript for email preview and path validation."""
+    preview_url = util.as_url(
+        post.preview.announce_preview, project_name=release.project.name, 
version_name=release.version
+    )
+    base_text = (
+        download_path_description.split(" plus this suffix")[0]
+        if " plus this suffix" in download_path_description
+        else download_path_description
+    )
+
+    js_code = f"""
+        document.addEventListener("DOMContentLoaded", () => {{
+            let debounceTimeout;
+            const debounceDelay = 500;
+
+            const bodyTextarea = document.getElementById("body");
+            const textPreviewContent = 
document.getElementById("announce-text-preview-content");
+            const announceForm = document.querySelector("form.atr-canary");
+
+            if (!bodyTextarea || !textPreviewContent || !announceForm) {{
+                console.error("Required elements for announce preview not 
found. Exiting.");
+                return;
+            }}
+
+            const previewUrl = "{preview_url}";
+            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;
+
+            function fetchAndUpdateAnnouncePreview() {{
+                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}}`;
+                    }});
+            }}
+
+            bodyTextarea.addEventListener("input", () => {{
+                clearTimeout(debounceTimeout);
+                debounceTimeout = setTimeout(fetchAndUpdateAnnouncePreview, 
debounceDelay);
+            }});
+
+            fetchAndUpdateAnnouncePreview();
+
+            const pathInput = document.getElementById("download_path_suffix");
+            const pathHelpText = pathInput ? 
pathInput.parentElement.querySelector(".form-text") : null;
+
+            if (pathInput && pathHelpText) {{
+                const baseText = "{base_text}";
+                let pathDebounce;
+
+                function 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.substring(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();
+            }}
+        }});
+    """
+
+    return htpy.script[markupsafe.Markup(js_code)]
diff --git a/atr/post/announce.py b/atr/post/announce.py
index 3c68862..873799b 100644
--- a/atr/post/announce.py
+++ b/atr/post/announce.py
@@ -17,56 +17,48 @@
 
 from __future__ import annotations
 
-import quart
-
 # TODO: Improve upon the routes_release pattern
 import atr.blueprints.post as post
+import atr.get as get
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
-import atr.template as template
 import atr.util as util
 import atr.web as web
 
 
-class AnnounceError(Exception):
-    """Exception for announce errors."""
-
-
 @post.committer("/announce/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: 
str) -> str | web.WerkzeugResponse:
[email protected](shared.announce.AnnounceForm)
+async def selected(
+    session: web.Committer, announce_form: shared.announce.AnnounceForm, 
project_name: str, version_name: str
+) -> web.WerkzeugResponse:
     """Handle the announcement form submission and promote the preview to 
release."""
-    import atr.get as get
-
     await session.check_access(project_name)
 
     permitted_recipients = util.permitted_announce_recipients(session.uid)
-    announce_form = await shared.announce.create_form(
-        permitted_recipients,
-        data=await quart.request.form,
-    )
-
-    if not (await announce_form.validate_on_submit()):
-        error_message = "Invalid submission"
-        if announce_form.errors:
-            error_details = "; ".join([f"{field}: {', '.join(errs)}" for 
field, errs in announce_form.errors.items()])
-            error_message = f"{error_message}: {error_details}"
 
-        # Render the page again, with errors
-        release: sql.Release = await session.release(
-            project_name, version_name, with_committee=True, 
phase=sql.ReleasePhase.RELEASE_PREVIEW
+    # Validate that the recipient is permitted
+    if announce_form.mailing_list not in permitted_recipients:
+        return await session.form_error(
+            "mailing_list",
+            f"You are not permitted to send announcements to 
{announce_form.mailing_list}",
         )
-        await quart.flash(error_message, "error")
-        return await template.render("announce-selected.html", 
release=release, announce_form=announce_form)
 
-    recipient = str(announce_form.mailing_list.data)
-    if recipient not in permitted_recipients:
-        raise AnnounceError(f"You are not permitted to send announcements to 
{recipient}")
+    # Get the release to find the revision number
+    release = await session.release(
+        project_name, version_name, with_committee=True, 
phase=sql.ReleasePhase.RELEASE_PREVIEW
+    )
+    preview_revision_number = release.unwrap_revision_number
 
-    subject = str(announce_form.subject.data)
-    body = str(announce_form.body.data)
-    preview_revision_number = str(announce_form.preview_revision.data)
-    download_path_suffix = _download_path_suffix_validated(announce_form)
+    # Validate that the revision number matches
+    if announce_form.revision_number != preview_revision_number:
+        return await session.redirect(
+            get.announce.selected,
+            error=f"The release has been updated since you loaded the form. "
+            f"Please review the current revision ({preview_revision_number}) 
and submit the form again.",
+            project_name=project_name,
+            version_name=version_name,
+        )
 
     try:
         async with storage.write_as_project_committee_member(project_name, 
session) as wacm:
@@ -74,10 +66,10 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
                 project_name,
                 version_name,
                 preview_revision_number,
-                recipient,
-                subject,
-                body,
-                download_path_suffix,
+                announce_form.mailing_list,
+                announce_form.subject,
+                announce_form.body,
+                announce_form.download_path_suffix,
                 session.uid,
                 session.fullname,
             )
@@ -92,20 +84,3 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
         success="Preview successfully announced",
         project_name=project_name,
     )
-
-
-def _download_path_suffix_validated(announce_form: shared.announce.Form) -> 
str:
-    download_path_suffix = str(announce_form.download_path_suffix.data)
-    if (".." in download_path_suffix) or ("//" in download_path_suffix):
-        raise ValueError("Download path suffix must not contain .. or //")
-    if download_path_suffix.startswith("./"):
-        download_path_suffix = download_path_suffix[1:]
-    elif download_path_suffix == ".":
-        download_path_suffix = "/"
-    if not download_path_suffix.startswith("/"):
-        download_path_suffix = "/" + download_path_suffix
-    if not download_path_suffix.endswith("/"):
-        download_path_suffix = download_path_suffix + "/"
-    if "/." in download_path_suffix:
-        raise ValueError("Download path suffix must not contain /.")
-    return download_path_suffix
diff --git a/atr/shared/announce.py b/atr/shared/announce.py
index 7d3eb03..e844841 100644
--- a/atr/shared/announce.py
+++ b/atr/shared/announce.py
@@ -15,35 +15,43 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from typing import Any
+import pydantic
 
-import atr.forms as forms
-import atr.util as util
+import atr.form as form
 
 
-class Form(forms.Typed):
+class AnnounceForm(form.Form):
     """Form for announcing a release preview."""
 
-    preview_name = forms.hidden()
-    preview_revision = forms.hidden()
-    mailing_list = forms.radio("Send vote email to")
-    download_path_suffix = forms.optional("Download path suffix")
-    confirm_announce = forms.boolean("Confirm")
-    subject = forms.optional("Subject")
-    body = forms.textarea("Body", optional=True)
-    submit = forms.submit("Send announcement email")
-
-
-async def create_form(permitted_recipients: list[str], *, data: dict[str, Any] 
| None = None) -> Form:
-    """Create and return an instance of the announce form."""
-
-    mailing_list_choices: forms.Choices = sorted([(recipient, recipient) for 
recipient in permitted_recipients])
-    mailing_list_default = util.USER_TESTS_ADDRESS
-
-    form_instance = await Form.create_form(data=data)
-    forms.choices(
-        form_instance.mailing_list,
-        mailing_list_choices,
-        mailing_list_default,
+    revision_number: str = form.label("Revision number", 
widget=form.Widget.HIDDEN)
+    mailing_list: str = form.label(
+        "Send vote email to",
+        widget=form.Widget.CUSTOM,
     )
-    return form_instance
+    subject: str = form.label("Subject")
+    body: str = form.label("Body", widget=form.Widget.CUSTOM)
+    download_path_suffix: str = form.label("Download path suffix", 
widget=form.Widget.CUSTOM)
+    confirm_announce: form.Bool = form.label("Confirm")
+
+    @pydantic.field_validator("download_path_suffix")
+    @classmethod
+    def validate_and_normalize_download_path_suffix(cls, suffix: str) -> str:
+        if (".." in suffix) or ("//" in suffix):
+            raise ValueError("Download path suffix must not contain .. or //")
+        if suffix.startswith("./"):
+            suffix = suffix[1:]
+        elif suffix == ".":
+            suffix = "/"
+        if not suffix.startswith("/"):
+            suffix = "/" + suffix
+        if not suffix.endswith("/"):
+            suffix = suffix + "/"
+        if "/." in suffix:
+            raise ValueError("Download path suffix must not contain /.")
+        return suffix
+
+    @pydantic.model_validator(mode="after")
+    def validate_confirm(self) -> "AnnounceForm":
+        if not self.confirm_announce:
+            raise ValueError("You must confirm the announcement by checking 
the box")
+        return self
diff --git a/atr/shared/distribution.py b/atr/shared/distribution.py
index 68e8c88..b84ca13 100644
--- a/atr/shared/distribution.py
+++ b/atr/shared/distribution.py
@@ -119,6 +119,7 @@ def html_nav(container: htm.Block, back_url: str, 
back_anchor: str, phase: Phase
     container.append(block)
 
 
+# TODO: Move this to a more appropriate module
 def html_nav_phase(block: htm.Block, project: str, version: str, staging: 
bool) -> None:
     label: Phase
     route, label = (get.compose.selected, "COMPOSE")
diff --git a/atr/templates/announce-selected.html 
b/atr/templates/announce-selected.html
deleted file mode 100644
index 4ec4cfa..0000000
--- a/atr/templates/announce-selected.html
+++ /dev/null
@@ -1,275 +0,0 @@
-{% extends "layouts/base.html" %}
-
-{% block title %}
-  Announce and distribute {{ release.project.display_name }} {{ 
release.version }} ~ ATR
-{% endblock title %}
-
-{% block description %}
-  Announce and distribute {{ release.project.display_name }} {{ 
release.version }} as a release.
-{% endblock description %}
-
-{% block stylesheets %}
-  {{ super() }}
-  <style>
-      .page-preview-meta-item::after {
-          content: "•";
-          margin-left: 1rem;
-          color: #ccc;
-      }
-
-      .page-preview-meta-item:last-child::after {
-          content: none;
-      }
-  </style>
-{% endblock stylesheets %}
-
-{% block content %}
-  <p class="d-flex justify-content-between align-items-center">
-    <a href="{{ as_url(get.finish.selected, project_name=release.project.name, 
version_name=release.version) }}"
-       class="atr-back-link">← Back to Finish {{ release.short_display_name 
}}</a>
-    <span>
-      <span class="atr-phase-symbol-other">①</span>
-      <span class="atr-phase-arrow">→</span>
-      <span class="atr-phase-symbol-other">②</span>
-      <span class="atr-phase-arrow">→</span>
-      <strong class="atr-phase-three atr-phase-symbol">③</strong>
-      <span class="atr-phase-three atr-phase-label">FINISH</span>
-    </span>
-  </p>
-
-  <h1>
-    Announce <strong>{{ release.project.short_display_name }}</strong> <em>{{ 
release.version }}</em>
-  </h1>
-
-  <div id="{{ release.name }}" class="card mb-4 shadow-sm">
-    <div class="card-header bg-light">
-      <h3 class="card-title mb-0">About this release preview</h3>
-    </div>
-    <div class="card-body">
-      <div class="d-flex flex-wrap gap-3 pb-1 text-secondary fs-6">
-        <span class="page-preview-meta-item">Revision: {{ 
release.latest_revision_number }}</span>
-        <span class="page-preview-meta-item">Created: {{ 
release.created.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
-      </div>
-      <!--
-      <div>
-        <a title="Show files for {{ release.name }}" href="{{ 
as_url(get.preview.view, project_name=release.project.name, 
version_name=release.version) }}" class="btn btn-sm btn-secondary">
-          <i class="bi bi-archive"></i>
-          Show files
-        </a>
-      </div>
-      -->
-    </div>
-  </div>
-
-  <h2>Announce this release</h2>
-
-  <p>This form will send an announcement to the ASF {{ user_tests_address }} 
mailing list.</p>
-
-  <form method="post"
-        id="announce-release-form"
-        action="{{ as_url(post.announce.selected, 
project_name=release.project.name, version_name=release.version) }}"
-        class="atr-canary py-4 px-5 mb-4 border rounded">
-    {{ announce_form.hidden_tag() }}
-
-    <div class="row mb-3 pb-3 border-bottom">
-      {{ forms.label(announce_form.mailing_list, col="sm3-high") }}
-      <div class="col-md-9">
-        <div class="d-flex flex-wrap gap-2 mb-3">
-          {% for subfield in announce_form.mailing_list %}
-            <div class="form-check">
-              {{ forms.widget(subfield, classes="form-check-input") }}
-              {{ forms.label(subfield, classes="form-check-label") }}
-            </div>
-          {% endfor %}
-        </div>
-        {% if announce_form.mailing_list.errors %}
-          {{ forms.errors(announce_form.mailing_list, 
classes="invalid-feedback d-block") }}
-        {% endif %}
-        <div class="card bg-warning-subtle mb-3">
-          <span class="card-body p-3">
-            <i class="bi bi-exclamation-triangle me-1"></i>
-            <strong>TODO:</strong> The limited options above are provided for 
testing purposes. In the finished version of ATR, you will be able to send to 
your own specified mailing lists.
-          </span>
-        </div>
-      </div>
-    </div>
-
-    <div class="row mb-3 pb-3 border-bottom">
-      {{ forms.label(announce_form.subject, col="sm3") }}
-      <div class="col-md-9">
-        {{ forms.widget(announce_form.subject) }}
-        {{ forms.errors(announce_form.subject, classes="invalid-feedback 
d-block") }}
-      </div>
-    </div>
-    <div class="row mb-3 pb-3 border-bottom">
-      {{ forms.label(announce_form.body, col="sm3") }}
-      <div class="col-md-9">
-        <ul class="nav nav-tabs" id="announceBodyTab" role="tablist">
-          <li class="nav-item" role="presentation">
-            <button class="nav-link active"
-                    id="edit-announce-body-tab"
-                    data-bs-toggle="tab"
-                    data-bs-target="#edit-announce-body-pane"
-                    type="button"
-                    role="tab"
-                    aria-controls="edit-announce-body-pane"
-                    aria-selected="true">Edit</button>
-          </li>
-          <li class="nav-item" role="presentation">
-            <button class="nav-link"
-                    id="text-preview-announce-body-tab"
-                    data-bs-toggle="tab"
-                    data-bs-target="#text-preview-announce-body-pane"
-                    type="button"
-                    role="tab"
-                    aria-controls="text-preview-announce-body-pane"
-                    aria-selected="false">Text preview</button>
-          </li>
-        </ul>
-        <div class="tab-content" id="announceBodyTabContent">
-          <div class="tab-pane fade show active"
-               id="edit-announce-body-pane"
-               role="tabpanel"
-               aria-labelledby="edit-announce-body-tab">
-            {{ forms.widget(announce_form.body, classes="form-control 
font-monospace mt-2", rows="12") }}
-            {{ forms.errors(announce_form.body, classes="invalid-feedback 
d-block") }}
-          </div>
-          <div class="tab-pane fade"
-               id="text-preview-announce-body-pane"
-               role="tabpanel"
-               aria-labelledby="text-preview-announce-body-tab">
-            <pre class="mt-2 p-3 bg-light border rounded font-monospace 
overflow-auto"><code id="announce-text-preview-content">Loading 
preview...</code></pre>
-          </div>
-        </div>
-      </div>
-    </div>
-    <div class="row mb-3 pb-3 border-bottom">
-      {{ forms.label(announce_form.download_path_suffix, col="sm3") }}
-      <div class="col-md-9">
-        {{ forms.widget(announce_form.download_path_suffix) }}
-        {{ forms.errors(announce_form.download_path_suffix) }}
-        {{ forms.description(announce_form.download_path_suffix) }}
-      </div>
-    </div>
-    <div class="row mb-3">
-      <div class="col-md-9 offset-md-3">
-        <div class="form-check">
-          {{ forms.widget(announce_form.confirm_announce, 
classes="form-check-input") }}
-          {{ forms.label(announce_form.confirm_announce, 
classes="form-check-label") }}
-        </div>
-        {{ forms.errors(announce_form.confirm_announce, classes="text-danger 
small mt-1") }}
-      </div>
-    </div>
-    <div class="row">
-      <div class="col-md-9 offset-md-3">{{ announce_form.submit(class_='btn 
btn-primary') }}</div>
-    </div>
-  </form>
-{% endblock content %}
-
-{% block javascripts %}
-  {{ super() }}
-  <script>
-      document.addEventListener("DOMContentLoaded", () => {
-          let debounceTimeout;
-          const debounceDelay = 500;
-
-          const bodyTextarea = document.getElementById("body");
-          const textPreviewContent = 
document.getElementById("announce-text-preview-content");
-          const announceForm = 
document.getElementById("announce-release-form");
-
-          if (!bodyTextarea || !textPreviewContent || !announceForm) {
-              console.error("Required elements for announce preview not found. 
Exiting.");
-              return;
-          }
-
-          const previewUrl = "{{ as_url(post.preview.announce_preview, 
project_name=release.project.name, version_name=release.version) }}";
-          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;
-
-          function fetchAndUpdateAnnouncePreview() {
-              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}`;
-                  });
-          }
-
-          bodyTextarea.addEventListener("input", () => {
-              clearTimeout(debounceTimeout);
-              debounceTimeout = setTimeout(fetchAndUpdateAnnouncePreview, 
debounceDelay);
-          });
-
-          fetchAndUpdateAnnouncePreview();
-
-          const pathInput = document.getElementById("download_path_suffix");
-          const pathHelpText = 
document.getElementById("download_path_suffix-help");
-
-          if (pathInput && pathHelpText) {
-              const initialText = pathHelpText.textContent;
-              if (initialText.includes(" plus this suffix")) {
-                  const baseText = initialText.substring(0, 
initialText.indexOf(" plus this suffix"));
-                  let pathDebounce;
-
-                  // This must match the validation code in announce.py
-                  function 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.substring(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();
-              }
-          }
-      });
-  </script>
-{% endblock javascripts %}


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


Reply via email to