atr/routes/voting.py (123 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 asfquart.base as base
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
import atr.routes.compose as compose
import atr.routes.root as root
import atr.routes.vote as vote
import atr.tasks.vote as tasks_vote
import atr.user as user
import atr.util as util
@routes.committer("/voting/<project_name>/<version_name>/<revision>", methods=["GET", "POST"])
async def selected_revision(
session: routes.CommitterSession, project_name: str, version_name: str, revision: str
) -> response.Response | str:
"""Show the vote initiation form for a release."""
await session.check_access(project_name)
async with db.session() as data:
project = await data.project(name=project_name).demand(routes.FlashError("Project not found"))
release = await data.release(project_name=project.name, version=version_name, _committee=True).demand(
routes.FlashError("Release candidate not found")
)
# Check that the user is on the project committee for the release
# TODO: Consider relaxing this to all committers
# Otherwise we must not show the vote form
if not user.is_committee_member(release.committee, session.uid):
return await session.redirect(
compose.selected, error="You must be on the PMC of this project to start a vote"
)
committee = util.unwrap(release.committee)
permitted_recipients = util.permitted_recipients(session.uid)
if release.release_policy:
min_hours = release.release_policy.min_hours
else:
min_hours = 72
class VoteInitiateForm(util.QuartFormTyped):
"""Form for initiating a release vote."""
release_name = wtforms.HiddenField("Release Name")
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",
)
vote_duration = wtforms.IntegerField(
"Minimum vote duration in hours",
validators=[
wtforms.validators.InputRequired("Vote duration is required"),
util.validate_vote_duration,
],
default=min_hours,
)
subject = wtforms.StringField("Subject", validators=[wtforms.validators.Optional()])
body = wtforms.TextAreaField("Body", validators=[wtforms.validators.Optional()])
submit = wtforms.SubmitField("Send vote email")
project = release.project
version = release.version
# The subject can be changed by the user
# TODO: We should consider not allowing the subject to be changed
default_subject = f"[VOTE] Release {project.display_name} {version}"
default_body = await construct.start_vote_default(project_name)
form = await VoteInitiateForm.create_form(
data=await quart.request.form if quart.request.method == "POST" else None,
)
# Set hidden field data explicitly
form.release_name.data = release.name
if quart.request.method == "GET":
form.subject.data = default_subject
form.body.data = default_body
if await form.validate_on_submit():
email_to: str = util.unwrap(form.mailing_list.data)
vote_duration_choice: int = util.unwrap(form.vote_duration.data)
subject_data: str = util.unwrap(form.subject.data)
body_data: str = util.unwrap(form.body.data)
if committee is None:
raise base.ASFQuartException("Release has no associated committee", errorcode=400)
if email_to not in permitted_recipients:
# This will be checked again by tasks/vote.py for extra safety
raise base.ASFQuartException("Invalid mailing list choice", errorcode=400)
# This sets the phase to RELEASE_CANDIDATE
error = await _promote(data, release.name)
if error:
return await session.redirect(root.index, error=error)
# Store when the release was put into the voting phase
release.vote_started = datetime.datetime.now(datetime.UTC)
# TODO: We also need to store the duration of the vote
# We can't allow resolution of the vote until the duration has elapsed
# But we allow the user to specify in the form
# And yet we also have ReleasePolicy.min_hours
# Presumably this sets the default, and the form takes precedence?
# ReleasePolicy.min_hours can also be 0, though
# Create a task for vote initiation
task = models.Task(
status=models.TaskStatus.QUEUED,
task_type=models.TaskType.VOTE_INITIATE,
task_args=tasks_vote.Initiate(
release_name=release.name,
email_to=email_to,
vote_duration=vote_duration_choice,
initiator_id=session.uid,
initiator_fullname=session.fullname,
subject=subject_data,
body=body_data,
).model_dump(),
release_name=release.name,
)
data.add(task)
# Flush to get the task ID
await data.flush()
await data.commit()
# TODO: We should log all outgoing email and the session so that users can confirm
# And can be warned if there was a failure
# (The message should be shown on the vote resolution page)
return await session.redirect(
vote.selected,
success=f"The vote announcement email will soon be sent to {email_to}.",
project_name=project_name,
version_name=version,
)
# For GET requests or failed POST validation
return await quart.render_template(
"voting-selected-revision.html",
release=release,
form=form,
revision=revision,
)
async def _promote(
data: db.Session,
release_name: str,
) -> str | None:
"""Promote a release candidate draft to a new phase."""
# Get the release
# TODO: Use session.release here
release = await data.release(name=release_name, _project=True).demand(
routes.FlashError("Release candidate draft not found")
)
# Verify that it's in the correct phase
if release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
return "This release is not in the candidate draft phase"
# Count how many files are in the source directory
file_count = await util.number_of_release_files(release)
if file_count == 0:
return "This candidate draft is empty, containing no files"
# Promote it to the target phase
# TODO: Obtain a lock for this
# NOTE: The functionality for skipping phases has been removed
release.stage = models.ReleaseStage.RELEASE_CANDIDATE
release.phase = models.ReleasePhase.RELEASE_CANDIDATE
# We updated the release
await data.commit()
return None