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]

Reply via email to