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