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-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new 960fe72 Move API voting code to the storage interface
960fe72 is described below
commit 960fe7254df55a3c7b5ba15b1764bdbcb9648f24
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Sep 5 14:03:52 2025 +0100
Move API voting code to the storage interface
---
atr/blueprints/api/api.py | 73 ++++++++++-------------------------------
atr/db/interaction.py | 61 ++++++++++++++++++++++++++++++++++
atr/routes/voting.py | 66 ++-----------------------------------
atr/storage/writers/vote.py | 80 +++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 161 insertions(+), 119 deletions(-)
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 8a239f3..b89e044 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -44,12 +44,10 @@ import atr.routes.announce as announce
import atr.routes.resolve as resolve
import atr.routes.start as start
import atr.routes.vote as vote
-import atr.routes.voting as voting
import atr.storage as storage
import atr.storage.outcome as outcome
import atr.storage.types as types
import atr.tabulate as tabulate
-import atr.tasks.vote as tasks_vote
import atr.user as user
import atr.util as util
@@ -1096,6 +1094,7 @@ async def users_list() -> DictResponse:
).model_dump(), 200
+# TODO: Add endpoints to allow users to vote
@api.BLUEPRINT.route("/vote/resolve", methods=["POST"])
@jwtoken.require
@quart_schema.security_scheme([{"BearerAuth": []}])
@@ -1108,26 +1107,9 @@ async def vote_resolve(data: models.api.VoteResolveArgs)
-> DictResponse:
A vote can be resolved by passing or failing.
"""
asf_uid = _jwt_asf_uid()
-
- async with db.session() as db_data:
- release_name = sql.release_name(data.project, data.version)
- release = await db_data.release(name=release_name, _project=True,
_committee=True).demand(exceptions.NotFound())
- if release.project.committee is None:
- raise exceptions.NotFound("Project has no committee")
- _committee_member_or_admin(release.project.committee, asf_uid)
-
- release = await db_data.merge(release)
- match data.resolution:
- case "passed":
- release.phase = sql.ReleasePhase.RELEASE_PREVIEW
- description = "Create a preview revision from the last
candidate draft"
- async with revision.create_and_manage(
- data.project, release.version, asf_uid,
description=description
- ) as _creating:
- pass
- case "failed":
- release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
- await db_data.commit()
+ async with storage.write(asf_uid) as write:
+ wacm = await write.as_project_committee_member(data.project)
+ await wacm.vote.resolve(data.project, data.version, data.resolution)
return models.api.VoteResolveResults(
endpoint="/vote/resolve",
success=True,
@@ -1149,40 +1131,21 @@ async def vote_start(data: models.api.VoteStartArgs) ->
DictResponse:
if data.email_to not in permitted_recipients:
raise exceptions.Forbidden("Invalid mailing list choice")
- async with db.session() as db_data:
- release_name = sql.release_name(data.project, data.version)
- release = await db_data.release(name=release_name, _project=True,
_committee=True).demand(exceptions.NotFound())
- if release.project.committee is None:
- raise exceptions.NotFound("Project has no committee")
- _committee_member_or_admin(release.project.committee, asf_uid)
+ try:
+ async with storage.write(asf_uid) as write:
+ wacm = await write.as_project_committee_member(data.project)
+ task = await wacm.vote.start(
+ data.project,
+ data.version,
+ data.revision,
+ data.email_to,
+ data.vote_duration,
+ data.subject,
+ data.body,
+ )
+ except storage.AccessError as e:
+ raise exceptions.BadRequest(str(e))
- revision_exists = await db_data.revision(release_name=release_name,
number=data.revision).get()
- if revision_exists is None:
- raise exceptions.NotFound(f"Revision '{data.revision}' does not
exist")
-
- error = await voting.promote_release(db_data, release_name,
data.revision, vote_manual=False)
- if error:
- raise exceptions.BadRequest(error)
-
- # TODO: Move this into a function in routes/voting.py
- task = sql.Task(
- status=sql.TaskStatus.QUEUED,
- task_type=sql.TaskType.VOTE_INITIATE,
- task_args=tasks_vote.Initiate(
- release_name=release_name,
- email_to=data.email_to,
- vote_duration=data.vote_duration,
- initiator_id=asf_uid,
- initiator_fullname=asf_uid,
- subject=data.subject,
- body=data.body,
- ).model_dump(),
- asf_uid=asf_uid,
- project_name=data.project,
- version_name=data.version,
- )
- db_data.add(task)
- await db_data.commit()
return models.api.VoteStartResults(
endpoint="/vote/start",
task=task,
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 292e7fa..376a55a 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -16,6 +16,7 @@
# under the License.
import contextlib
+import datetime
import enum
import pathlib
from collections.abc import AsyncGenerator, Sequence
@@ -115,6 +116,66 @@ async def previews(project: sql.Project) ->
list[sql.Release]:
return await releases_by_phase(project, sql.ReleasePhase.RELEASE_PREVIEW)
+async def promote_release(
+ data: db.Session,
+ release_name: str,
+ selected_revision_number: str,
+ vote_manual: bool = False,
+) -> str | None:
+ """Promote a release candidate draft to a new phase."""
+ # TODO: Use session.release here
+ release_for_pre_checks = await data.release(name=release_name,
_project=True).demand(
+ InteractionError("Release candidate draft not found")
+ )
+ project_name = release_for_pre_checks.project.name
+ version_name = release_for_pre_checks.version
+
+ # Check for ongoing tasks
+ ongoing_tasks = await tasks_ongoing(project_name, version_name,
selected_revision_number)
+ if ongoing_tasks > 0:
+ return "All checks must be completed before starting a vote"
+
+ # Verify that it's in the correct phase
+ # The atomic update below will also check this
+ if release_for_pre_checks.phase !=
sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ return "This release is not in the candidate draft phase"
+
+ # Check that the revision number is the latest
+ if release_for_pre_checks.latest_revision_number !=
selected_revision_number:
+ return "The selected revision number does not match the latest
revision number"
+
+ # Check that there is at least one file in the draft
+ # This is why we require _project=True above
+ file_count = await util.number_of_release_files(release_for_pre_checks)
+ if file_count == 0:
+ return "This candidate draft is empty, containing no files"
+
+ # Promote it to RELEASE_CANDIDATE
+ # NOTE: We previously allowed skipping phases, but removed that
functionality
+ # We don't need a lock here because we use an atomic update
+ via = sql.validate_instrumented_attribute
+ stmt = (
+ sqlmodel.update(sql.Release)
+ .where(
+ via(sql.Release.name) == release_name,
+ via(sql.Release.phase) == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ sql.latest_revision_number_query() == selected_revision_number,
+ )
+ .values(
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ vote_started=datetime.datetime.now(datetime.UTC),
+ vote_manual=vote_manual,
+ )
+ )
+
+ result = await data.execute(stmt)
+ if result.rowcount != 1:
+ await data.rollback()
+ return "A newer revision appeared, please refresh and try again."
+ await data.commit()
+ return None
+
+
async def release_delete(
release_name: str, phase: db.Opt[sql.ReleasePhase] = db.NOT_SET,
include_downloads: bool = True
) -> None:
diff --git a/atr/routes/voting.py b/atr/routes/voting.py
index 0222ed9..1599e69 100644
--- a/atr/routes/voting.py
+++ b/atr/routes/voting.py
@@ -15,13 +15,11 @@
# specific language governing permissions and limitations
# under the License.
-import datetime
import aiofiles.os
import asfquart.base as base
import quart
import quart_wtf.typing as typing
-import sqlmodel
import werkzeug.wrappers.response as response
import atr.construct as construct
@@ -176,66 +174,6 @@ async def selected_revision(
)
-async def promote_release(
- data: db.Session,
- release_name: str,
- selected_revision_number: str,
- vote_manual: bool = False,
-) -> str | None:
- """Promote a release candidate draft to a new phase."""
- # TODO: Use session.release here
- release_for_pre_checks = await data.release(name=release_name,
_project=True).demand(
- routes.FlashError("Release candidate draft not found")
- )
- project_name = release_for_pre_checks.project.name
- version_name = release_for_pre_checks.version
-
- # Check for ongoing tasks
- ongoing_tasks = await interaction.tasks_ongoing(project_name,
version_name, selected_revision_number)
- if ongoing_tasks > 0:
- return "All checks must be completed before starting a vote"
-
- # Verify that it's in the correct phase
- # The atomic update below will also check this
- if release_for_pre_checks.phase !=
sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
- return "This release is not in the candidate draft phase"
-
- # Check that the revision number is the latest
- if release_for_pre_checks.latest_revision_number !=
selected_revision_number:
- return "The selected revision number does not match the latest
revision number"
-
- # Check that there is at least one file in the draft
- # This is why we require _project=True above
- file_count = await util.number_of_release_files(release_for_pre_checks)
- if file_count == 0:
- return "This candidate draft is empty, containing no files"
-
- # Promote it to RELEASE_CANDIDATE
- # NOTE: We previously allowed skipping phases, but removed that
functionality
- # We don't need a lock here because we use an atomic update
- via = sql.validate_instrumented_attribute
- stmt = (
- sqlmodel.update(sql.Release)
- .where(
- via(sql.Release.name) == release_name,
- via(sql.Release.phase) == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
- sql.latest_revision_number_query() == selected_revision_number,
- )
- .values(
- phase=sql.ReleasePhase.RELEASE_CANDIDATE,
- vote_started=datetime.datetime.now(datetime.UTC),
- vote_manual=vote_manual,
- )
- )
-
- result = await data.execute(stmt)
- if result.rowcount != 1:
- await data.rollback()
- return "A newer revision appeared, please refresh and try again."
- await data.commit()
- return None
-
-
async def start_vote(
email_to: str,
permitted_recipients: list[str],
@@ -257,7 +195,7 @@ async def start_vote(
if promote is True:
# This verifies the state and sets the phase to RELEASE_CANDIDATE
- error = await promote_release(data, release.name,
selected_revision_number, vote_manual=False)
+ error = await interaction.promote_release(data, release.name,
selected_revision_number, vote_manual=False)
if error:
return await session.redirect(root.index, error=error)
@@ -306,7 +244,7 @@ async def start_vote_manual(
data: db.Session,
) -> response.Response | str:
# This verifies the state and sets the phase to RELEASE_CANDIDATE
- error = await promote_release(data, release.name,
selected_revision_number, vote_manual=True)
+ error = await interaction.promote_release(data, release.name,
selected_revision_number, vote_manual=True)
if error:
return await session.redirect(root.index, error=error)
return await session.redirect(
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 28c125d..d096c22 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -18,8 +18,15 @@
# Removing this will cause circular imports
from __future__ import annotations
+from typing import Literal
+
import atr.db as db
+import atr.db.interaction as interaction
+import atr.models.sql as sql
+import atr.revision as revision
import atr.storage as storage
+import atr.tasks.vote as tasks_vote
+import atr.user as user
class GeneralPublic:
@@ -83,3 +90,76 @@ class CommitteeMember(CommitteeParticipant):
raise storage.AccessError("No ASF UID")
self.__asf_uid = asf_uid
self.__committee_name = committee_name
+
+ async def resolve(self, project_name: str, version_name: str, resolution:
Literal["passed", "failed"]) -> None:
+ release_name = sql.release_name(project_name, version_name)
+ release = await self.__data.release(name=release_name, _project=True,
_committee=True).demand(
+ storage.AccessError("Release not found")
+ )
+ if release.project.committee is None:
+ raise storage.AccessError("Project has no committee")
+ self.__committee_member_or_admin(release.project.committee,
self.__asf_uid)
+
+ release = await self.__data.merge(release)
+ match resolution:
+ case "passed":
+ release.phase = sql.ReleasePhase.RELEASE_PREVIEW
+ description = "Create a preview revision from the last
candidate draft"
+ async with revision.create_and_manage(
+ project_name, release.version, self.__asf_uid,
description=description
+ ) as _creating:
+ pass
+ case "failed":
+ release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ await self.__data.commit()
+
+ async def start(
+ self,
+ project_name: str,
+ version_name: str,
+ revision_number: str,
+ email_to: str,
+ vote_duration: int,
+ subject: str,
+ body: str,
+ ) -> sql.Task:
+ release_name = sql.release_name(project_name, version_name)
+ release = await self.__data.release(name=release_name, _project=True,
_committee=True).demand(
+ storage.AccessError("Release not found")
+ )
+ if release.project.committee is None:
+ raise storage.AccessError("Project has no committee")
+ self.__committee_member_or_admin(release.project.committee,
self.__asf_uid)
+
+ revision_exists = await
self.__data.revision(release_name=release_name, number=revision_number).get()
+ if revision_exists is None:
+ raise storage.AccessError(f"Revision '{revision_number}' does not
exist")
+
+ error = await interaction.promote_release(self.__data, release_name,
revision_number, vote_manual=False)
+ if error:
+ raise storage.AccessError(error)
+
+ # TODO: Move this into a function in routes/voting.py
+ task = sql.Task(
+ status=sql.TaskStatus.QUEUED,
+ task_type=sql.TaskType.VOTE_INITIATE,
+ task_args=tasks_vote.Initiate(
+ release_name=release_name,
+ email_to=email_to,
+ vote_duration=vote_duration,
+ initiator_id=self.__asf_uid,
+ initiator_fullname=self.__asf_uid,
+ subject=subject,
+ body=body,
+ ).model_dump(),
+ asf_uid=self.__asf_uid,
+ project_name=project_name,
+ version_name=version_name,
+ )
+ self.__data.add(task)
+ await self.__data.commit()
+ return task
+
+ def __committee_member_or_admin(self, committee: sql.Committee, asf_uid:
str) -> None:
+ if not (user.is_committee_member(committee, asf_uid) or
user.is_admin(asf_uid)):
+ raise storage.AccessError("You do not have permission to perform
this action")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]