This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-atr-experiments.git


The following commit(s) were added to refs/heads/main by this push:
     new 7bc5fa7  Add a form to initiate a vote
7bc5fa7 is described below

commit 7bc5fa75ec10f9e4cb0a2ed64ce38d6652698d71
Author: Sean B. Palmer <s...@miscoranda.com>
AuthorDate: Thu Mar 6 20:58:16 2025 +0200

    Add a form to initiate a vote
---
 atr/db/service.py                   |  35 ++++-
 atr/mail.py                         |  50 ++++++-
 atr/routes/release.py               | 118 +++++++++++++++++
 atr/server.py                       |   3 +
 atr/tasks/mailtest.py               |  32 +++++
 atr/tasks/vote.py                   | 253 ++++++++++++++++++++++++++++++++++++
 atr/templates/candidate-review.html |   2 +
 atr/templates/release-vote.html     | 218 +++++++++++++++++++++++++++++++
 atr/worker.py                       |   2 +
 9 files changed, 708 insertions(+), 5 deletions(-)

diff --git a/atr/db/service.py b/atr/db/service.py
index 8a0c29d..aaa2de8 100644
--- a/atr/db/service.py
+++ b/atr/db/service.py
@@ -16,11 +16,14 @@
 # under the License.
 
 from collections.abc import Sequence
+from typing import cast
 
 from sqlalchemy import func
+from sqlalchemy.orm import selectinload
+from sqlalchemy.orm.attributes import InstrumentedAttribute
 from sqlmodel import select
 
-from atr.db.models import PMC, Task
+from atr.db.models import PMC, ProductLine, Release, Task
 
 from . import create_async_db_session
 
@@ -40,6 +43,36 @@ async def get_pmcs() -> Sequence[PMC]:
         return pmcs
 
 
+async def get_release_by_key(storage_key: str) -> Release | None:
+    """Get a release by its storage key."""
+    async with create_async_db_session() as db_session:
+        # Get the release with its PMC and product line
+        query = (
+            select(Release)
+            .where(Release.storage_key == storage_key)
+            .options(selectinload(cast(InstrumentedAttribute[PMC], 
Release.pmc)))
+            .options(selectinload(cast(InstrumentedAttribute[ProductLine], 
Release.product_line)))
+        )
+        result = await db_session.execute(query)
+        return result.scalar_one_or_none()
+
+
+def get_release_by_key_sync(storage_key: str) -> Release | None:
+    """Synchronous version of get_release_by_key for use in background 
tasks."""
+    from atr.db import create_sync_db_session
+
+    with create_sync_db_session() as session:
+        # Get the release with its PMC and product line
+        query = (
+            select(Release)
+            .where(Release.storage_key == storage_key)
+            .options(selectinload(cast(InstrumentedAttribute[PMC], 
Release.pmc)))
+            .options(selectinload(cast(InstrumentedAttribute[ProductLine], 
Release.product_line)))
+        )
+        result = session.execute(query)
+        return result.scalar_one_or_none()
+
+
 async def get_tasks(limit: int, offset: int) -> tuple[Sequence[Task], int]:
     """Returns a list of Tasks based on limit and offset values together with 
the total count."""
 
diff --git a/atr/mail.py b/atr/mail.py
index 18a1914..321ec86 100644
--- a/atr/mail.py
+++ b/atr/mail.py
@@ -56,6 +56,19 @@ class ArtifactEvent:
         self.token = token
 
 
+class VoteEvent:
+    """Data class to represent a release vote event."""
+
+    def __init__(
+        self, release_key: str, email_recipient: str, subject: str, body: str, 
vote_end: datetime.datetime
+    ) -> None:
+        self.release_key = release_key
+        self.email_recipient = email_recipient
+        self.subject = subject
+        self.body = body
+        self.vote_end = vote_end
+
+
 def split_address(addr: str) -> tuple[str, str]:
     """Split an email address into local and domain parts."""
     parts = addr.split("@", 1)
@@ -64,15 +77,41 @@ def split_address(addr: str) -> tuple[str, str]:
     return parts[0], parts[1]
 
 
-def send(event: ArtifactEvent) -> None:
-    """Send an email notification about an artifact."""
+def validate_recipient(to_addr: str) -> None:
+    # Ensure recipient is @apache.org or @tooling.apache.org
+    _, domain = split_address(to_addr)
+    if domain not in ("apache.org", "tooling.apache.org"):
+        error_msg = f"Email recipient must be @apache.org or 
@tooling.apache.org, got {to_addr}"
+        logging.error(error_msg)
+        raise ValueError(error_msg)
+
+
+def send(event: ArtifactEvent | VoteEvent) -> None:
+    """Send an email notification about an artifact or a vote."""
     logging.info(f"Sending email for event: {event}")
     from_addr = global_email_contact
     to_addr = event.email_recipient
+    validate_recipient(to_addr)
+
     # UUID4 is entirely random, with no timestamp nor namespace
     # It does have 6 version and variant bits, so only 122 bits are random
     mid = f"<{uuid.uuid4()}@{global_domain}>"
-    msg_text = f"""
+
+    # Different message format depending on event type
+    if isinstance(event, VoteEvent):
+        msg_text = f"""
+From: {from_addr}
+To: {to_addr}
+Subject: {event.subject}
+Date: {formatdate(localtime=True)}
+Message-ID: {mid}
+
+{event.body}
+"""
+    else:
+        # ArtifactEvent
+        # This was just for testing
+        msg_text = f"""
 From: {from_addr}
 To: {to_addr}
 Subject: {event.artifact_name}
@@ -99,9 +138,11 @@ If you have any questions, please reply to this email.
 
     try:
         send_many(from_addr, [to_addr], msg_text)
-        logging.info(f"sent to {to_addr}")
     except Exception as e:
         logging.error(f"send error: {e}")
+        raise e
+    else:
+        logging.info(f"sent to {to_addr}")
 
     elapsed = time.perf_counter() - start
     logging.info(f" send_many took {elapsed:.3f}s")
@@ -190,6 +231,7 @@ class LoggingSMTP(smtplib.SMTP):
 def send_one(mx_host: str, from_addr: str, to_addr: str, msg_reader: StringIO) 
-> None:
     """Send an email to a single recipient via the ASF mail relay."""
     default_timeout_seconds = 30
+    validate_recipient(to_addr)
 
     try:
         # Connect to the ASF mail relay
diff --git a/atr/routes/release.py b/atr/routes/release.py
index 78967f3..de2659d 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -40,6 +40,7 @@ from atr.db.models import (
     Task,
     TaskStatus,
 )
+from atr.db.service import get_release_by_key
 from atr.routes import FlashError, app_route, get_form, package_files_delete
 from atr.util import get_release_storage_dir
 
@@ -157,3 +158,120 @@ async def release_bulk_status(task_id: int) -> str | 
Response:
                     return redirect(url_for("root_candidate_review"))
 
     return await render_template("release-bulk.html", task=task, 
release=release, TaskStatus=TaskStatus)
+
+
+@app_route("/release/vote", methods=["GET", "POST"])
+@require(Requirements.committer)
+async def root_release_vote() -> Response | str:
+    """Show the vote initiation form for a release."""
+
+    session = await session_read()
+    if session is None:
+        raise ASFQuartException("Not authenticated", errorcode=401)
+
+    release_key = request.args.get("release_key", "")
+    form = None
+    if request.method == "POST":
+        form = await get_form(request)
+        release_key = form.get("release_key", "")
+
+    if not release_key:
+        await flash("No release key provided", "error")
+        return redirect(url_for("root_candidate_review"))
+
+    release = await get_release_by_key(release_key)
+    if release is None:
+        await flash(f"Release with key {release_key} not found", "error")
+        return redirect(url_for("root_candidate_review"))
+
+    # If POST, process the form and create a vote_initiate task
+    if (request.method == "POST") and (form is not None):
+        # Extract form data
+        mailing_list = form.get("mailing_list", "dev")
+        vote_duration = form.get("vote_duration", "72")
+        # These fields are just for testing, we'll do something better in the 
real UI
+        gpg_key_id = form.get("gpg_key_id", "")
+        commit_hash = form.get("commit_hash", "")
+        if release.pmc is None:
+            raise ASFQuartException("Release has no associated PMC", 
errorcode=400)
+
+        # Prepare email recipient
+        email_to = f"{mailing_list}@{release.pmc.project_name}.apache.org"
+
+        # Create a task for vote initiation
+        task = Task(
+            status=TaskStatus.QUEUED,
+            task_type="vote_initiate",
+            task_args=[
+                release_key,
+                email_to,
+                vote_duration,
+                gpg_key_id,
+                commit_hash,
+                session.uid,
+            ],
+        )
+        async with create_async_db_session() as db_session:
+            db_session.add(task)
+            # Flush to get the task ID
+            await db_session.flush()
+            await db_session.commit()
+
+            await flash(
+                f"Vote initiation task queued as task #{task.id}. You'll 
receive an email confirmation when complete.",
+                "success",
+            )
+            return redirect(url_for("root_candidate_review"))
+
+    # For GET
+    return await render_template(
+        "release-vote.html",
+        release=release,
+        email_preview=generate_vote_email_preview(release),
+    )
+
+
+def generate_vote_email_preview(release: Release) -> str:
+    """Generate a preview of the vote email."""
+    version = release.version
+
+    # Get PMC details
+    if release.pmc is None:
+        raise ASFQuartException("Release has no associated PMC", errorcode=400)
+    pmc_name = release.pmc.project_name
+    pmc_display = release.pmc.display_name
+
+    # Get product information
+    product_name = release.product_line.product_name if release.product_line 
else "Unknown"
+
+    # Create email subject
+    subject = f"[VOTE] Release Apache {pmc_display} {product_name} {version}"
+
+    # Create email body
+    body = f"""Hello {pmc_name},
+
+I'd like to call a vote on releasing the following artifacts as
+Apache {pmc_display} {product_name} {version}.
+
+The release candidate can be found at:
+
+https://apache.example.org/{pmc_name}/{product_name}-{version}/
+
+The release artifacts are signed with my GPG key, [KEY_ID].
+
+The artifacts were built from commit:
+
+[COMMIT_HASH]
+
+Please review the release candidate and vote accordingly.
+
+[ ] +1 Release this package
+[ ] +0 Abstain
+[ ] -1 Do not release this package (please provide specific comments)
+
+This vote will remain open for at least 72 hours.
+
+Thanks,
+[YOUR_NAME]
+"""
+    return f"{subject}\n\n{body}"
diff --git a/atr/server.py b/atr/server.py
index 59a3c0f..fc3075d 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -25,6 +25,7 @@ from typing import Any
 from blockbuster import BlockBuster
 from quart import render_template
 from quart_schema import OpenAPIProvider, QuartSchema
+from werkzeug.exceptions import NotFound
 from werkzeug.routing import Rule
 
 import asfquart
@@ -179,6 +180,8 @@ def create_app(app_config: type[AppConfig]) -> QuartApp:
     # Add a global error handler to show helpful error messages with tracebacks
     @app.errorhandler(Exception)
     async def handle_any_exception(error: Exception) -> Any:
+        if isinstance(error, NotFound):
+            return await render_template("error.html", error="404 Not Found", 
traceback="", status_code=404), 404
         import traceback
 
         tb = traceback.format_exc()
diff --git a/atr/tasks/mailtest.py b/atr/tasks/mailtest.py
index 1b362d0..72c7f27 100644
--- a/atr/tasks/mailtest.py
+++ b/atr/tasks/mailtest.py
@@ -96,7 +96,10 @@ def send(args: list[str]) -> tuple[str, str | None, 
tuple[Any, ...]]:
 
 def send_core(args_list: list[str]) -> tuple[str, str | None, tuple[Any, ...]]:
     """Send a test email."""
+    import asyncio
+
     import atr.mail
+    from atr.db.service import get_pmc_by_name
 
     logger.info("Starting send_core")
     try:
@@ -119,6 +122,35 @@ def send_core(args_list: list[str]) -> tuple[str, str | 
None, tuple[Any, ...]]:
             f"Args parsed successfully: artifact_name={args.artifact_name}, 
email_recipient={args.email_recipient}"
         )
 
+        # Check if the recipient is allowed
+        # They must be a PMC member of tooling or d...@tooling.apache.org
+        email_recipient = args.email_recipient
+        local_part, domain = email_recipient.split("@", 1)
+
+        # Allow d...@tooling.apache.org
+        if email_recipient != "d...@tooling.apache.org":
+            # Must be a PMC member of tooling
+            # Since get_pmc_by_name is async, we need to run it in an event 
loop
+            # TODO: We could make a sync version
+            tooling_pmc = asyncio.run(get_pmc_by_name("tooling"))
+
+            if not tooling_pmc:
+                error_msg = "Tooling PMC not found in database"
+                logger.error(error_msg)
+                return "FAILED", error_msg, tuple()
+
+            if domain != "apache.org":
+                error_msg = f"Email domain must be apache.org, got {domain}"
+                logger.error(error_msg)
+                return "FAILED", error_msg, tuple()
+
+            if local_part not in tooling_pmc.pmc_members:
+                error_msg = f"Email recipient {local_part} is not a member of 
the tooling PMC"
+                logger.error(error_msg)
+                return "FAILED", error_msg, tuple()
+
+            logger.info(f"Recipient {email_recipient} is a tooling PMC member, 
allowed")
+
         # Load and set DKIM key
         try:
             project_root = 
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
new file mode 100644
index 0000000..1227bf5
--- /dev/null
+++ b/atr/tasks/vote.py
@@ -0,0 +1,253 @@
+# 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
+import os
+from dataclasses import dataclass
+from datetime import UTC
+from typing import Any
+
+# Configure detailed logging
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+# Create file handler for tasks-vote.log
+file_handler = logging.FileHandler("tasks-vote.log")
+file_handler.setLevel(logging.DEBUG)
+
+# Create formatter with detailed information
+formatter = logging.Formatter(
+    "[%(asctime)s.%(msecs)03d] [%(process)d] [%(levelname)s] 
[%(name)s:%(funcName)s:%(lineno)d] %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+file_handler.setFormatter(formatter)
+logger.addHandler(file_handler)
+# Ensure parent loggers don't duplicate messages
+logger.propagate = False
+
+logger.info("Vote module imported")
+
+
+@dataclass
+class Args:
+    """Arguments for the vote_initiate task."""
+
+    release_key: str
+    email_to: str
+    vote_duration: str
+    gpg_key_id: str
+    commit_hash: str
+    initiator_id: str
+
+    @staticmethod
+    def from_list(args: list[str]) -> "Args":
+        """Parse task arguments."""
+        logger.debug(f"Parsing arguments: {args}")
+
+        if len(args) != 6:
+            logger.error(f"Invalid number of arguments: {len(args)}, expected 
6")
+            raise ValueError("Invalid number of arguments")
+
+        release_key = args[0]
+        email_to = args[1]
+        vote_duration = args[2]
+        gpg_key_id = args[3]
+        commit_hash = args[4]
+        initiator_id = args[5]
+
+        # Type checking
+        for arg_name, arg_value in [
+            ("release_key", release_key),
+            ("email_to", email_to),
+            ("vote_duration", vote_duration),
+            ("gpg_key_id", gpg_key_id),
+            ("commit_hash", commit_hash),
+            ("initiator_id", initiator_id),
+        ]:
+            if not isinstance(arg_value, str):
+                logger.error(f"{arg_name} must be a string, got 
{type(arg_value)}")
+                raise ValueError(f"{arg_name} must be a string")
+
+        logger.debug("All argument validations passed")
+
+        args_obj = Args(
+            release_key=release_key,
+            email_to=email_to,
+            vote_duration=vote_duration,
+            gpg_key_id=gpg_key_id,
+            commit_hash=commit_hash,
+            initiator_id=initiator_id,
+        )
+
+        logger.info(f"Args object created: {args_obj}")
+        return args_obj
+
+
+def initiate(args: list[str]) -> tuple[str, str | None, tuple[Any, ...]]:
+    """Initiate a vote for a release."""
+    logger.info(f"Initiating vote with args: {args}")
+    try:
+        logger.debug("Delegating to initiate_core function")
+        status, error, result = initiate_core(args)
+        logger.info(f"Vote initiation completed with status: {status}")
+        return status, error, result
+    except Exception as e:
+        logger.exception(f"Error in initiate function: {e}")
+        return "FAILED", str(e), tuple()
+
+
+def initiate_core(args_list: list[str]) -> tuple[str, str | None, tuple[Any, 
...]]:
+    """Get arguments, create an email, and then send it to the recipient."""
+    import atr.mail
+    from atr.db.service import get_release_by_key_sync
+
+    test_recipients = ["sbp"]
+    logger.info("Starting initiate_core")
+    try:
+        # Configure root logger to also write to our log file
+        # This ensures logs from mail.py, using the root logger, are captured
+        root_logger = logging.getLogger()
+        # Check whether our file handler is already added, to avoid duplicates
+        has_our_handler = any(
+            (isinstance(h, logging.FileHandler) and 
h.baseFilename.endswith("tasks-vote.log"))
+            for h in root_logger.handlers
+        )
+        if not has_our_handler:
+            # Add our file handler to the root logger
+            root_logger.addHandler(file_handler)
+            logger.info("Added file handler to root logger to capture mail.py 
logs")
+
+        logger.debug(f"Parsing arguments: {args_list}")
+        args = Args.from_list(args_list)
+        logger.info(f"Args parsed successfully: {args}")
+
+        # Get the release information
+        release = get_release_by_key_sync(args.release_key)
+        if not release:
+            error_msg = f"Release with key {args.release_key} not found"
+            logger.error(error_msg)
+            return "FAILED", error_msg, tuple()
+
+        # GPG key ID, just for testing the UI
+        gpg_key_id = args.gpg_key_id
+
+        # Calculate vote end date
+        vote_duration_hours = int(args.vote_duration)
+        vote_start = datetime.datetime.now(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:
+            project_root = 
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+            dkim_path = os.path.join(project_root, "state", "dkim.private")
+
+            with open(dkim_path) as f:
+                dkim_key = f.read()
+                atr.mail.set_secret_key(dkim_key.strip())
+                logger.info("DKIM key loaded and set successfully")
+        except Exception as e:
+            error_msg = f"Failed to load DKIM key: {e}"
+            logger.error(error_msg)
+            return "FAILED", error_msg, tuple()
+
+        # Get PMC and product details
+        if release.pmc is None:
+            error_msg = "Release has no associated PMC"
+            logger.error(error_msg)
+            return "FAILED", error_msg, tuple()
+
+        pmc_name = release.pmc.project_name
+        pmc_display = release.pmc.display_name
+        product_name = release.product_line.product_name if 
release.product_line else "Unknown"
+        version = release.version
+
+        # Create email subject
+        subject = f"[VOTE] Release Apache {pmc_display} {product_name} 
{version}"
+
+        # Create email body with initiator ID
+        body = f"""Hello {pmc_name},
+
+I'd like to call a vote on releasing the following artifacts as
+Apache {pmc_display} {product_name} {version}.
+
+The release candidate can be found at:
+
+https://apache.example.org/{pmc_name}/{product_name}-{version}/
+
+The release artifacts are signed with my GPG key, {gpg_key_id}.
+
+The artifacts were built from commit:
+
+{args.commit_hash}
+
+Please review the release candidate and vote accordingly.
+
+[ ] +1 Release this package
+[ ] +0 Abstain
+[ ] -1 Do not release this package (please provide specific comments)
+
+This vote will remain open until {vote_end_str} ({vote_duration_hours} hours).
+
+Thanks,
+{args.initiator_id}
+"""
+
+        # Store the original recipient for logging
+        original_recipient = args.email_to
+        # Only one test recipient is required for now
+        test_recipient = test_recipients[0] + "@apache.org"
+        logger.info(f"TEMPORARY: Overriding recipient from 
{original_recipient} to {test_recipient}")
+
+        # Create mail event with test recipient
+        # Use test account instead of actual PMC list
+        event = atr.mail.VoteEvent(
+            release_key=args.release_key,
+            email_recipient=test_recipient,
+            subject=subject,
+            body=body,
+            vote_end=vote_end,
+        )
+
+        # Send the email
+        atr.mail.send(event)
+        logger.info(
+            f"Vote email sent successfully to test account {test_recipient} 
(would have been {original_recipient})"
+        )
+
+        # TODO: Update release status to indicate a vote is in progress
+        # This would involve updating the database with the vote details 
somehow
+        return (
+            "COMPLETED",
+            None,
+            (
+                {
+                    "message": "Vote initiated successfully (sent to test 
account)",
+                    "original_email_to": original_recipient,
+                    "actual_email_to": test_recipient,
+                    "vote_end": vote_end_str,
+                    "subject": subject,
+                },
+            ),
+        )
+
+    except Exception as e:
+        logger.exception(f"Error in initiate_core: {e}")
+        return "FAILED", str(e), tuple()
diff --git a/atr/templates/candidate-review.html 
b/atr/templates/candidate-review.html
index 46a9894..d0d8fb4 100644
--- a/atr/templates/candidate-review.html
+++ b/atr/templates/candidate-review.html
@@ -235,6 +235,8 @@
               Delete release
             </button>
           </form>
+          <a class="verify-link"
+             href="{{ url_for('root_release_vote', 
release_key=release.storage_key) }}">Start vote</a>
         </div>
       </div>
 
diff --git a/atr/templates/release-vote.html b/atr/templates/release-vote.html
new file mode 100644
index 0000000..3beb5ed
--- /dev/null
+++ b/atr/templates/release-vote.html
@@ -0,0 +1,218 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  Start release vote ~ ATR
+{% endblock title %}
+
+{% block description %}
+  Initiate a vote for a release candidate.
+{% endblock description %}
+
+{% block stylesheets %}
+  {{ super() }}
+  <style>
+      .vote-container {
+          max-width: 1000px;
+          margin: 0 auto;
+      }
+
+      .form-group {
+          margin-bottom: 1.5rem;
+      }
+
+      .form-table {
+          width: 100%;
+          margin-bottom: 1.5rem;
+      }
+
+      .form-table th {
+          width: 200px;
+          text-align: right;
+          padding-right: 1rem;
+          vertical-align: top;
+          font-weight: 500;
+      }
+
+      .form-table td {
+          vertical-align: top;
+      }
+
+      .form-table label {
+          border-bottom: none;
+          padding-bottom: 0;
+      }
+
+      .radio-group {
+          display: flex;
+          gap: 1.5rem;
+      }
+
+      .radio-option {
+          display: flex;
+          align-items: center;
+          gap: 0.5rem;
+      }
+
+      .submit-button {
+          padding: 0.5rem 1rem;
+          background: #004477;
+          color: white;
+          border: none;
+          border-radius: 4px;
+          cursor: pointer;
+          font-weight: 500;
+          font-size: 1rem;
+          margin-top: 1rem;
+      }
+
+      .submit-button:hover {
+          background: #003366;
+      }
+
+      .cancel-link {
+          margin-left: 1rem;
+          color: #666;
+          text-decoration: none;
+      }
+
+      .cancel-link:hover {
+          text-decoration: underline;
+      }
+
+      .release-info {
+          border: 1px solid #ddd;
+          border-radius: 4px;
+          padding: 1rem;
+          margin-bottom: 1.5rem;
+          background-color: #f8f8f8;
+      }
+
+      .email-preview {
+          border: 1px solid #ddd;
+          border-radius: 4px;
+          padding: 1rem;
+          margin-top: 1rem;
+          background-color: #fff;
+          font-family: monospace;
+          white-space: pre-wrap;
+          overflow-x: auto;
+      }
+
+      .preview-header {
+          font-weight: 600;
+          margin-bottom: 0.5rem;
+      }
+
+      .warning-text {
+          color: #856404;
+          background-color: #fff3cd;
+          border: 1px solid #ffeeba;
+          border-radius: 4px;
+          padding: 1rem;
+          margin-bottom: 1.5rem;
+      }
+
+      select,
+      input[type="text"] {
+          padding: 0.375rem;
+          border: 1px solid #ced4da;
+          border-radius: 0.25rem;
+          width: 100%;
+          max-width: 600px;
+      }
+  </style>
+{% endblock stylesheets %}
+
+{% block content %}
+  <div class="vote-container">
+    <h1>Start release vote</h1>
+
+    <div class="release-info">
+      <h3>
+        {{ release.pmc.display_name }} - {{ release.product_line.product_name 
if release.product_line else "Unknown" }} {{ release.version }}
+      </h3>
+      <p>Initiating a vote for this release candidate will prepare an email to 
be sent to the appropriate mailing list.</p>
+    </div>
+
+    <div class="warning-text">
+      <strong>Note:</strong> This feature is currently in development. The 
form below only sends email to a test account.
+    </div>
+
+    <form method="post"
+          action="{{ url_for('root_release_vote') }}"
+          class="striking">
+      <input type="hidden" name="release_key" value="{{ release.storage_key 
}}" />
+
+      <table class="form-table">
+        <tbody>
+          <tr>
+            <th>
+              <label>Send vote email to:</label>
+            </th>
+            <td>
+              <div class="radio-group">
+                <div class="radio-option">
+                  <input type="radio" id="list_dev" name="mailing_list" 
value="dev" checked />
+                  <label for="list_dev">dev@{{ release.pmc.project_name 
}}.apache.org</label>
+                </div>
+                <div class="radio-option">
+                  <input type="radio" id="list_private" name="mailing_list" 
value="private" />
+                  <label for="list_private">private@{{ 
release.pmc.project_name }}.apache.org</label>
+                </div>
+              </div>
+            </td>
+          </tr>
+
+          <tr>
+            <th>
+              <label for="vote_duration">Vote duration:</label>
+            </th>
+            <td>
+              <select id="vote_duration" name="vote_duration" 
class="form-select">
+                <option value="72">72 hours (minimum)</option>
+                <option value="120">5 days</option>
+                <option value="168">7 days</option>
+              </select>
+            </td>
+          </tr>
+
+          <tr>
+            <th>
+              <label for="gpg_key_id">Your GPG key ID:</label>
+            </th>
+            <td>
+              <input type="text"
+                     id="gpg_key_id"
+                     name="gpg_key_id"
+                     class="form-control"
+                     placeholder="e.g., 0x1A2B3C4D" />
+            </td>
+          </tr>
+
+          <tr>
+            <th>
+              <label for="commit_hash">Commit hash:</label>
+            </th>
+            <td>
+              <input type="text"
+                     id="commit_hash"
+                     name="commit_hash"
+                     class="form-control"
+                     placeholder="Git commit hash used for this release" />
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      <div class="form-group">
+        <label class="preview-header">Email Preview:</label>
+        <div class="email-preview">{{ email_preview }}</div>
+      </div>
+
+      <div class="form-actions">
+        <button type="submit" class="submit-button">Prepare Vote Email</button>
+        <a href="{{ url_for('root_candidate_review') }}" 
class="cancel-link">Cancel</a>
+      </div>
+    </form>
+  </div>
+{% endblock content %}
diff --git a/atr/worker.py b/atr/worker.py
index edd0007..dbc2c8f 100644
--- a/atr/worker.py
+++ b/atr/worker.py
@@ -397,6 +397,7 @@ def task_process(task_id: int, task_type: str, task_args: 
str) -> None:
     # We need to move the other tasks into atr.tasks
     from atr.tasks.bulk import download as bulk_download
     from atr.tasks.mailtest import send as mailtest_send
+    from atr.tasks.vote import initiate as vote_initiate
 
     _LOGGER.info(f"Processing task {task_id} ({task_type}) with args 
{task_args}")
     try:
@@ -414,6 +415,7 @@ def task_process(task_id: int, task_type: str, task_args: 
str) -> None:
             "generate_cyclonedx_sbom": task_generate_cyclonedx_sbom,
             "package_bulk_download": bulk_download,
             "mailtest_send": mailtest_send,
+            "vote_initiate": vote_initiate,
         }
 
         handler = task_handlers.get(task_type)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscr...@tooling.apache.org
For additional commands, e-mail: commits-h...@tooling.apache.org

Reply via email to