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 d963229 Remove template interfaces from the vote and announce forms
d963229 is described below
commit d963229851f460de4d155f354af5de34bf051187
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/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 ++++-------
14 files changed, 318 insertions(+), 663 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/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]