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]