atr/routes/announce.py (167 lines of code) (raw):

# 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 logging from typing import TYPE_CHECKING, Any, Protocol import aiofiles.os import aioshutil import quart import werkzeug.wrappers.response as response import wtforms import atr.construct as construct import atr.db as db import atr.db.models as models import atr.routes as routes # TODO: Improve upon the routes_release pattern import atr.routes.release as routes_release import atr.tasks.message as message import atr.util as util if TYPE_CHECKING: import pathlib class AnnounceFormProtocol(Protocol): """Protocol for the dynamically generated AnnounceForm.""" preview_name: wtforms.HiddenField preview_revision: wtforms.HiddenField mailing_list: wtforms.RadioField confirm_announce: wtforms.BooleanField subject: wtforms.StringField body: wtforms.TextAreaField submit: wtforms.SubmitField @property def errors(self) -> dict[str, Any]: ... async def validate_on_submit(self) -> bool: ... class DeleteForm(util.QuartFormTyped): """Form for deleting a release preview.""" preview_name = wtforms.StringField( "Preview name", validators=[wtforms.validators.InputRequired("Preview name is required")] ) confirm_delete = wtforms.StringField( "Confirmation", validators=[ wtforms.validators.InputRequired("Confirmation is required"), wtforms.validators.Regexp("^DELETE$", message="Please type DELETE to confirm"), ], ) submit = wtforms.SubmitField("Delete preview") @routes.committer("/announce/<project_name>/<version_name>") async def selected(session: routes.CommitterSession, project_name: str, version_name: str) -> str | response.Response: """Allow the user to announce a release preview.""" await session.check_access(project_name) release = await session.release( project_name, version_name, with_committee=True, phase=models.ReleasePhase.RELEASE_PREVIEW ) announce_form = await _create_announce_form_instance(util.permitted_recipients(session.uid)) # Hidden fields announce_form.preview_name.data = release.name announce_form.preview_revision.data = release.unwrap_revision # Variables used in defaults for subject and body project_display_name = release.project.display_name or release.project.name # The subject cannot be changed by the user announce_form.subject.data = f"[ANNOUNCE] {project_display_name} {version_name} released" # The body can be changed, either from VoteTemplate or from the form announce_form.body.data = await construct.announce_release_default(project_name) return await quart.render_template("announce-selected.html", release=release, announce_form=announce_form) @routes.committer("/announce/<project_name>/<version_name>", methods=["POST"]) async def selected_post( session: routes.CommitterSession, project_name: str, version_name: str ) -> str | response.Response: """Handle the announcement form submission and promote the preview to release.""" await session.check_access(project_name) permitted_recipients = util.permitted_recipients(session.uid) announce_form = await _create_announce_form_instance(permitted_recipients, data=await quart.request.form) if not (await announce_form.validate_on_submit()): error_message = "Invalid submission" if announce_form.errors: error_details = "; ".join([f"{field}: {', '.join(errs)}" for field, errs in announce_form.errors.items()]) error_message = f"{error_message}: {error_details}" # Render the page again, with errors release = await session.release( project_name, version_name, with_committee=True, phase=models.ReleasePhase.RELEASE_PREVIEW ) await quart.flash(error_message, "error") return await quart.render_template("announce-selected.html", release=release, announce_form=announce_form) subject = str(announce_form.subject.data) body = str(announce_form.body.data) source: str = "" target: str = "" source_base: pathlib.Path | None = None async with db.session() as data: try: release = await session.release( project_name, version_name, phase=models.ReleasePhase.RELEASE_PREVIEW, data=data ) test_list = "user-tests" recipient = f"{test_list}@tooling.apache.org" if recipient not in util.permitted_recipients(session.uid): return await session.redirect( selected, error=f"You are not permitted to send announcements to {recipient}", project_name=project_name, version_name=version_name, ) body = await construct.announce_release_body( body, options=construct.AnnounceReleaseOptions( asfuid=session.uid, fullname=session.fullname, project_name=project_name, version_name=version_name, ), ) task = models.Task( status=models.TaskStatus.QUEUED, task_type=models.TaskType.MESSAGE_SEND, task_args=message.Send( email_sender=f"{session.uid}@apache.org", email_recipient=recipient, subject=subject, body=body, in_reply_to=None, ).model_dump(), release_name=release.name, ) data.add(task) # Prepare paths for file operations source_base = util.release_directory_base(release) source = str(source_base / release.unwrap_revision) # TODO: We should update only if the announcement email was sent # That would require moving this, and the filesystem operations, into a task release.phase = models.ReleasePhase.RELEASE release.revision = None release.released = datetime.datetime.now(datetime.UTC) await data.commit() # This must come after updating the release object target = str(util.release_directory(release)) if await aiofiles.os.path.exists(target): raise routes.FlashError("Release already exists") except (routes.FlashError, Exception) as e: logging.exception("Error during release announcement, database phase:") return await session.redirect( selected, error=f"Error announcing preview: {e!s}", project_name=project_name, version_name=version_name, ) try: await aioshutil.move(source, target) if source_base: await aioshutil.rmtree(str(source_base)) # type: ignore[call-arg] except Exception as e: logging.exception("Error during release announcement, file system phase:") return await session.redirect( selected, error=f"Database updated, but error moving files: {e!s}. Manual cleanup needed.", project_name=project_name, version_name=version_name, ) routes_release_finished = routes_release.finished # type: ignore[has-type] return await session.redirect( routes_release_finished, success="Preview successfully announced", project_name=project_name, ) async def _create_announce_form_instance( permitted_recipients: list[str], *, data: dict[str, Any] | None = None ) -> AnnounceFormProtocol: """Create and return an instance of the AnnounceForm.""" class AnnounceForm(util.QuartFormTyped): """Form for announcing a release preview.""" preview_name = wtforms.HiddenField() preview_revision = wtforms.HiddenField() mailing_list = wtforms.RadioField( "Send vote email to", choices=sorted([(recipient, recipient) for recipient in permitted_recipients]), validators=[wtforms.validators.InputRequired("Mailing list selection is required")], default="user-tests@tooling.apache.org", ) confirm_announce = wtforms.BooleanField( "Confirm", validators=[wtforms.validators.DataRequired("You must confirm to proceed with announcement")], ) subject = wtforms.StringField("Subject", validators=[wtforms.validators.Optional()]) body = wtforms.TextAreaField("Body", validators=[wtforms.validators.Optional()]) submit = wtforms.SubmitField("Send announcement email") form_instance = await AnnounceForm.create_form(data=data) return form_instance