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