atr/routes/vote.py (147 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 json
import logging
import os
import httpx
import quart
import werkzeug.wrappers.response as response
import wtforms
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.resolve as resolve
import atr.tasks.message as message
import atr.util as util
class CastVoteForm(util.QuartFormTyped):
"""Form for casting a vote."""
vote_value = wtforms.RadioField(
"Your vote",
choices=[("+1", "+1 (Binding)"), ("0", "0"), ("-1", "-1 (Binding)")],
validators=[wtforms.validators.InputRequired("A vote value (+1, 0, -1) is required.")],
)
vote_comment = wtforms.TextAreaField("Comment (optional)", validators=[wtforms.validators.Optional()])
submit = wtforms.SubmitField("Submit vote")
@routes.committer("/vote/<project_name>/<version_name>", measure_performance=False)
async def selected(session: routes.CommitterSession, project_name: str, version_name: str) -> response.Response | str:
"""Show the contents of the release candidate draft."""
await session.check_access(project_name)
release = await session.release(
project_name, version_name, with_committee=True, with_tasks=True, phase=models.ReleasePhase.RELEASE_CANDIDATE
)
latest_vote_task = resolve.release_latest_vote_task(release)
archive_url = None
task_mid = None
if latest_vote_task is not None:
# Move task_mid_get here?
task_mid = resolve.task_mid_get(latest_vote_task)
archive_url = await _task_archive_url_cached(task_mid)
if ("LOCAL_DEBUG" in os.environ) and (latest_vote_task is not None):
logging.warning("LOCAL_DEBUG: Setting vote task to completed")
latest_vote_task.status = models.TaskStatus.COMPLETED
latest_vote_task.result = [json.dumps({"mid": "818a44a3-6984-4aba-a650-834e86780b43@apache.org"})]
form = await CastVoteForm.create_form()
return await compose.check(
session, release, task_mid=task_mid, form=form, archive_url=archive_url, vote_task=latest_vote_task
)
@routes.committer("/vote/<project_name>/<version_name>", methods=["POST"])
async def selected_post(session: routes.CommitterSession, project_name: str, version_name: str) -> response.Response:
"""Handle submission of a vote."""
await session.check_access(project_name)
form = await CastVoteForm.create_form(data=await quart.request.form)
if await form.validate_on_submit():
# Ensure the release exists and is in the correct phase
release = await session.release(
project_name, version_name, with_tasks=True, phase=models.ReleasePhase.RELEASE_CANDIDATE
)
vote = str(form.vote_value.data)
comment = str(form.vote_comment.data)
email_recipient, error_message = await _send_vote(session, release, vote, comment)
if error_message:
return await session.redirect(
selected, project_name=project_name, version_name=version_name, error=error_message
)
success_message = f"Sending your vote to {email_recipient}."
return await session.redirect(
selected, project_name=project_name, version_name=version_name, success=success_message
)
else:
error_message = "Invalid vote submission"
if form.errors:
error_details = "; ".join([f"{field}: {', '.join(errs)}" for field, errs in form.errors.items()])
error_message = f"{error_message}: {error_details}"
return await session.redirect(
selected, project_name=project_name, version_name=version_name, error=error_message
)
async def _send_vote(
session: routes.CommitterSession,
release: models.Release,
vote: str,
comment: str,
) -> tuple[str, str]:
# Get the email thread
latest_vote_task = resolve.release_latest_vote_task(release)
if latest_vote_task is None:
return "", "No vote task found."
vote_thread_mid = resolve.task_mid_get(latest_vote_task)
if vote_thread_mid is None:
return "", "No vote thread found."
# Construct the reply email
original_subject = latest_vote_task.task_args["subject"]
# Arguments for the task to cast a vote
email_recipient = latest_vote_task.task_args["email_to"]
email_sender = f"{session.uid}@apache.org"
subject = f"Re: {original_subject}"
body = [f"{vote.lower()} ({session.uid}) {session.fullname}"]
if comment:
body.append(f"{comment}")
# Only include the signature if there is a comment
body.append(f"-- \n{session.fullname} ({session.uid})")
body_text = "\n\n".join(body)
in_reply_to = vote_thread_mid
task = models.Task(
status=models.TaskStatus.QUEUED,
task_type=models.TaskType.MESSAGE_SEND,
task_args=message.Send(
email_sender=email_sender,
email_recipient=email_recipient,
subject=subject,
body=body_text,
in_reply_to=in_reply_to,
).model_dump(),
release_name=release.name,
)
async with db.session() as data:
data.add(task)
await data.flush()
await data.commit()
return email_recipient, ""
async def _task_archive_url(task_mid: str) -> str | None:
if "@" not in task_mid:
return None
# TODO: This List ID will be dynamic when we allow posting to arbitrary lists
lid = "user-tests.tooling.apache.org"
url = f"https://lists.apache.org/api/email.lua?id=%3C{task_mid}%3E&listid=%3C{lid}%3E"
try:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
email_data = response.json()
mid = email_data["mid"]
if not isinstance(mid, str):
return None
return "https://lists.apache.org/thread/" + mid
except Exception:
logging.exception("Failed to get archive URL for task %s", task_mid)
return None
async def _task_archive_url_cached(task_mid: str | None) -> str | None:
if "LOCAL_DEBUG" in os.environ:
return "https://lists.apache.org/thread/619hn4x796mh3hkk3kxg1xnl48dy2s64"
if task_mid is None:
return None
if "@" not in task_mid:
return None
async with db.session() as data:
url = await data.ns_text_get(
"mid-url-cache",
task_mid,
)
if url is not None:
return url
url = await _task_archive_url(task_mid)
if url is not None:
await data.ns_text_set(
"mid-url-cache",
task_mid,
url,
)
return url