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
commit 172c66d17af900f037383ab667cf9303836099fd 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/docs/user-interface.md | 2 +- 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 ++++------- 15 files changed, 319 insertions(+), 664 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/docs/user-interface.md b/atr/docs/user-interface.md index adba78b..e5f901a 100644 --- a/atr/docs/user-interface.md +++ b/atr/docs/user-interface.md @@ -146,7 +146,7 @@ async def add(session: web.Committer, add_openpgp_key_form: shared.keys.AddOpenP return await session.redirect(get.keys.keys) ``` -The [`form.validate`](/ref/atr/form.py:validate) function should only be called manually when the request comes from JavaScript, as in [`announce_preview`](/ref/atr/post/preview.py:announce_preview). It takes the form class, the form data dictionary, and an optional context dictionary. If validation succeeds, it returns an instance of your form class with validated data. If validation fails, it raises a `pydantic.ValidationError`. +The [`form.validate`](/ref/atr/form.py:validate) function should only be called manually when the request comes from JavaScript, as in [`body_preview`](/ref/atr/post/voting.py:body_preview). It takes the form class, the form data dictionary, and an optional context dictionary. If validation succeeds, it returns an instance of your form class with validated data. If validation fails, it raises a `pydantic.ValidationError`. The error handling uses [`form.flash_error_data`](/ref/atr/form.py:flash_error_data) to prepare error information for display, and [`form.flash_error_summary`](/ref/atr/form.py:flash_error_summary) to create a user-friendly summary of all validation errors. 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]
