atr/tasks/vote.py (91 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 json
import logging
import time
from typing import Any, Final
import pydantic
import atr.construct as construct
import atr.db as db
import atr.mail as mail
import atr.tasks.checks as checks
import atr.util as util
# Configure detailed logging
_LOGGER: Final = logging.getLogger(__name__)
_LOGGER.setLevel(logging.DEBUG)
class Initiate(pydantic.BaseModel):
"""Arguments for the task to start a vote."""
release_name: str = pydantic.Field(..., description="The name of the release to vote on")
email_to: str = pydantic.Field(..., description="The mailing list address to send the vote email to")
vote_duration: int = pydantic.Field(..., description="Duration of the vote in hours")
initiator_id: str = pydantic.Field(..., description="ASF ID of the vote initiator")
initiator_fullname: str = pydantic.Field(..., description="Full name of the vote initiator")
subject: str = pydantic.Field(..., description="Subject line for the vote email")
body: str = pydantic.Field(..., description="Body content for the vote email")
class VoteInitiationError(Exception): ...
@checks.with_model(Initiate)
async def initiate(args: Initiate) -> str | None:
"""Initiate a vote for a release."""
try:
result_data = await _initiate_core_logic(args)
return json.dumps(result_data)
except VoteInitiationError as e:
_LOGGER.error(f"Vote initiation failed: {e}")
raise
except Exception as e:
_LOGGER.exception(f"Unexpected error during vote initiation: {e}")
raise
async def _initiate_core_logic(args: Initiate) -> dict[str, Any]:
"""Get arguments, create an email, and then send it to the recipient."""
_LOGGER.info("Starting initiate_core")
# Validate arguments
if not (args.email_to.endswith("@apache.org") or args.email_to.endswith(".apache.org")):
_LOGGER.error(f"Invalid destination email address: {args.email_to}")
raise VoteInitiationError("Invalid destination email address")
async with db.session() as data:
release = await data.release(name=args.release_name, _project=True, _committee=True).demand(
VoteInitiationError(f"Release {args.release_name} not found")
)
# Calculate vote end date
vote_duration_hours = args.vote_duration
vote_start = datetime.datetime.now(datetime.UTC)
vote_end = vote_start + datetime.timedelta(hours=vote_duration_hours)
# Format dates for email
vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
# Load and set DKIM key
try:
await mail.set_secret_key_default()
except Exception as e:
error_msg = f"Failed to load DKIM key: {e}"
_LOGGER.error(error_msg)
raise VoteInitiationError(error_msg)
# Get PMC and project details
if release.committee is None:
error_msg = "Release has no associated committee"
_LOGGER.error(error_msg)
raise VoteInitiationError(error_msg)
# Construct email
subject = args.subject
# Perform substitutions in the body
body = await construct.start_vote_body(
args.body,
construct.StartVoteOptions(
asfuid=args.initiator_id,
fullname=args.initiator_fullname,
project_name=release.project.name,
version_name=release.version,
vote_duration=args.vote_duration,
),
)
permitted_recipients = util.permitted_recipients(args.initiator_id)
if args.email_to not in permitted_recipients:
raise VoteInitiationError("Invalid mailing list choice")
# Create mail message
message = mail.Message(
email_sender=f"{args.initiator_id}@apache.org",
email_recipient=args.email_to,
subject=subject,
body=body,
)
# Send the email
try:
mid = await mail.send(message)
except Exception:
_LOGGER.exception(f"Failed to send vote email to {args.email_to}:")
# This is here for falling through, for debugging
mid = f"{int(time.time())}@example.invalid"
# Remove this "raise" to fall through
raise
else:
_LOGGER.info(f"Vote email sent successfully to {args.email_to}")
return {
"message": "Vote announcement email sent successfully",
"email_to": args.email_to,
"vote_end": vote_end_str,
"subject": subject,
"mid": mid,
}