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-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new e4aebed  Move writes from the interaction module to the storage 
interface
e4aebed is described below

commit e4aebedecb5b7e38a96f5e64c21b688db1cea1cd
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Oct 12 13:28:10 2025 +0100

    Move writes from the interaction module to the storage interface
---
 atr/blueprints/api/api.py       |   6 ++-
 atr/db/interaction.py           |  89 -------------------------------
 atr/routes/vote.py              |   4 +-
 atr/routes/voting.py            |   6 ++-
 atr/storage/__init__.py         |  14 +++++
 atr/storage/writers/__init__.py |   2 +
 atr/storage/writers/cache.py    | 113 ++++++++++++++++++++++++++++++++++++++++
 atr/storage/writers/release.py  |  74 ++++++++++++++++++++++++++
 atr/storage/writers/vote.py     |   6 +--
 playwright/test.py              |   2 +-
 10 files changed, 219 insertions(+), 97 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index ba6415e..e672c70 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -1230,9 +1230,13 @@ async def vote_tabulate(data: 
models.api.VoteTabulateArgs) -> DictResponse:
     if latest_vote_task is None:
         raise exceptions.NotFound("No vote task found")
     task_mid = interaction.task_mid_get(latest_vote_task)
-    archive_url = await interaction.task_archive_url_cached(task_mid)
+
+    async with storage.write() as write:
+        wagp = write.as_general_public()
+        archive_url = await wagp.cache.get_message_archive_url(task_mid)
     if archive_url is None:
         raise exceptions.NotFound("No archive URL found")
+
     thread_id = archive_url.split("/")[-1]
     committee = await tabulate.vote_committee(thread_id, release)
     details = await tabulate.vote_details(committee, thread_id, release)
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 90dd784..3641583 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -16,7 +16,6 @@
 # under the License.
 
 import contextlib
-import datetime
 import enum
 from collections.abc import AsyncGenerator, Sequence
 from typing import Any, Final
@@ -170,66 +169,6 @@ 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_latest_vote_task(release: sql.Release) -> sql.Task | None:
     """Find the most recent VOTE_INITIATE task for this release."""
     disallowed_statuses = [sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE]
@@ -283,34 +222,6 @@ async def releases_in_progress(project: sql.Project) -> 
list[sql.Release]:
     return drafts + cands + prevs
 
 
-async def task_archive_url_cached(task_mid: str | None) -> str | None:
-    if task_mid in _THREAD_URLS_FOR_DEVELOPMENT:
-        return _THREAD_URLS_FOR_DEVELOPMENT[task_mid]
-
-    if task_mid is None:
-        return None
-    if "@" not in task_mid:
-        return None
-
-    async with db.session() as data:
-        url = await data.ns_text_get(
-            "mid-url-cache",
-            task_mid,
-        )
-        if url is not None:
-            return url
-
-        url = await util.task_archive_url(task_mid)
-        if url is not None:
-            await data.ns_text_set(
-                "mid-url-cache",
-                task_mid,
-                url,
-            )
-
-    return url
-
-
 def task_mid_get(latest_vote_task: sql.Task) -> str | None:
     if util.is_dev_environment():
         import atr.db.interaction as interaction
diff --git a/atr/routes/vote.py b/atr/routes/vote.py
index ace6331..634ecda 100644
--- a/atr/routes/vote.py
+++ b/atr/routes/vote.py
@@ -74,7 +74,9 @@ async def selected(session: route.CommitterSession, 
project_name: str, version_n
 
         # Move task_mid_get here?
         task_mid = interaction.task_mid_get(latest_vote_task)
-        archive_url = await interaction.task_archive_url_cached(task_mid)
+        async with storage.write() as write:
+            wagp = write.as_general_public()
+            archive_url = await wagp.cache.get_message_archive_url(task_mid)
 
     # Special form for the [ Resolve vote ] button, to make it POST
     hidden_form = await forms.Hidden.create_form()
diff --git a/atr/routes/voting.py b/atr/routes/voting.py
index ab020b9..b5c94c1 100644
--- a/atr/routes/voting.py
+++ b/atr/routes/voting.py
@@ -117,8 +117,10 @@ async def start_vote_manual(
     session: route.CommitterSession,
     data: db.Session,
 ) -> response.Response | str:
-    # This verifies the state and sets the phase to RELEASE_CANDIDATE
-    error = await interaction.promote_release(data, release.name, 
selected_revision_number, vote_manual=True)
+    async with storage.write(session.uid) as write:
+        wacp = await 
write.as_project_committee_participant(release.project_name)
+        # This verifies the state and sets the phase to RELEASE_CANDIDATE
+        error = await wacp.release.promote_to_candidate(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/__init__.py b/atr/storage/__init__.py
index 86b1dc4..a8cd78c 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -136,6 +136,7 @@ class Read:
 class WriteAsGeneralPublic(WriteAs):
     def __init__(self, write: Write, data: db.Session):
         self.announce = writers.announce.GeneralPublic(write, self, data)
+        self.cache = writers.cache.GeneralPublic(write, self, data)
         self.checks = writers.checks.GeneralPublic(write, self, data)
         self.keys = writers.keys.GeneralPublic(write, self, data)
         self.policy = writers.policy.GeneralPublic(write, self, data)
@@ -152,6 +153,7 @@ class WriteAsFoundationCommitter(WriteAsGeneralPublic):
         # TODO: We need a definitive list of ASF UIDs
         self.__asf_uid = write.authorisation.asf_uid
         self.announce = writers.announce.FoundationCommitter(write, self, data)
+        self.cache = writers.cache.FoundationCommitter(write, self, data)
         self.checks = writers.checks.FoundationCommitter(write, self, data)
         self.keys = writers.keys.FoundationCommitter(write, self, data)
         self.policy = writers.policy.FoundationCommitter(write, self, data)
@@ -174,6 +176,7 @@ class 
WriteAsCommitteeParticipant(WriteAsFoundationCommitter):
         self.__asf_uid = write.authorisation.asf_uid
         self.__committee_name = committee_name
         self.announce = writers.announce.CommitteeParticipant(write, self, 
data, committee_name)
+        self.cache = writers.cache.CommitteeParticipant(write, self, data, 
committee_name)
         self.checks = writers.checks.CommitteeParticipant(write, self, data, 
committee_name)
         self.keys = writers.keys.CommitteeParticipant(write, self, data, 
committee_name)
         self.policy = writers.policy.CommitteeParticipant(write, self, data, 
committee_name)
@@ -200,6 +203,7 @@ class WriteAsCommitteeMember(WriteAsCommitteeParticipant):
         self.__asf_uid = write.authorisation.asf_uid
         self.__committee_name = committee_name
         self.announce = writers.announce.CommitteeMember(write, self, data, 
committee_name)
+        self.cache = writers.cache.CommitteeMember(write, self, data, 
committee_name)
         self.checks = writers.checks.CommitteeMember(write, self, data, 
committee_name)
         self.distributions = writers.distributions.CommitteeMember(write, 
self, data, committee_name)
         self.keys = writers.keys.CommitteeMember(write, self, data, 
committee_name)
@@ -308,6 +312,16 @@ class Write:
             return outcome.Error(e)
         return outcome.Result(wafa)
 
+    def as_general_public(self) -> WriteAsGeneralPublic:
+        return self.as_general_public_outcome().result_or_raise()
+
+    def as_general_public_outcome(self) -> 
outcome.Outcome[WriteAsGeneralPublic]:
+        try:
+            wagp = WriteAsGeneralPublic(self, self.__data)
+        except Exception as e:
+            return outcome.Error(e)
+        return outcome.Result(wagp)
+
     # async def as_key_owner(self) -> types.Outcome[WriteAsKeyOwner]:
     #     ...
 
diff --git a/atr/storage/writers/__init__.py b/atr/storage/writers/__init__.py
index 907ed19..829c1be 100644
--- a/atr/storage/writers/__init__.py
+++ b/atr/storage/writers/__init__.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import atr.storage.writers.announce as announce
+import atr.storage.writers.cache as cache
 import atr.storage.writers.checks as checks
 import atr.storage.writers.distributions as distributions
 import atr.storage.writers.keys as keys
@@ -29,6 +30,7 @@ import atr.storage.writers.vote as vote
 
 __all__ = [
     "announce",
+    "cache",
     "checks",
     "distributions",
     "keys",
diff --git a/atr/storage/writers/cache.py b/atr/storage/writers/cache.py
new file mode 100644
index 0000000..d77811c
--- /dev/null
+++ b/atr/storage/writers/cache.py
@@ -0,0 +1,113 @@
+# 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.
+
+from __future__ import annotations
+
+import atr.db as db
+import atr.storage as storage
+import atr.util as util
+
+# TODO: This probably shouldn't be a cache.py module
+# We should name these modules by functionality, not by mechanism
+# But it's not clear where get_message_archive_url should go
+# Maybe tasks.py? messages.py?
+
+
+class GeneralPublic:
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsGeneralPublic,
+        data: db.Session,
+    ) -> None:
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        self.__asf_uid = write.authorisation.asf_uid
+
+    async def get_message_archive_url(self, task_mid: str | None) -> str | 
None:
+        if task_mid is None:
+            return None
+        if "@" not in task_mid:
+            return None
+
+        url = await self.__data.ns_text_get(
+            "mid-url-cache",
+            task_mid,
+        )
+        if url is not None:
+            return url
+
+        url = await util.task_archive_url(task_mid)
+        if url is not None:
+            await self.__data.ns_text_set(
+                "mid-url-cache",
+                task_mid,
+                url,
+            )
+
+        return url
+
+
+class FoundationCommitter(GeneralPublic):
+    def __init__(self, write: storage.Write, write_as: 
storage.WriteAsFoundationCommitter, data: db.Session) -> None:
+        super().__init__(write, write_as, data)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
+
+
+class CommitteeParticipant(FoundationCommitter):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsCommitteeParticipant,
+        data: db.Session,
+        committee_name: str,
+    ) -> None:
+        super().__init__(write, write_as, data)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
+        self.__committee_name = committee_name
+
+
+class CommitteeMember(CommitteeParticipant):
+    def __init__(
+        self,
+        write: storage.Write,
+        write_as: storage.WriteAsCommitteeMember,
+        data: db.Session,
+        committee_name: str,
+    ) -> None:
+        super().__init__(write, write_as, data, committee_name)
+        self.__write = write
+        self.__write_as = write_as
+        self.__data = data
+        asf_uid = write.authorisation.asf_uid
+        if asf_uid is None:
+            raise storage.AccessError("No ASF UID")
+        self.__asf_uid = asf_uid
+        self.__committee_name = committee_name
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 408b97a..96d6669 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -28,6 +28,8 @@ from typing import TYPE_CHECKING, Final
 
 import aiofiles.os
 import aioshutil
+import sqlalchemy
+import sqlmodel
 
 import atr.analysis as analysis
 import atr.db as db
@@ -262,6 +264,66 @@ class CommitteeParticipant(FoundationCommitter):
         creation_error = str(creating.failed) if (creating.failed is not None) 
else None
         return creation_error, moved_files_names, skipped_files_names
 
+    async def promote_to_candidate(
+        self,
+        release_name: str,
+        selected_revision_number: str,
+        vote_manual: bool = False,
+    ) -> str | None:
+        """Promote a release candidate draft to a new phase."""
+        release_for_pre_checks = await self.__data.release(name=release_name, 
_project=True).demand(
+            storage.AccessError("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 self.__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
+        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
+        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
+        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 self.__data.execute(stmt)
+        if result.rowcount != 1:
+            await self.__data.rollback()
+            return "A newer revision appeared, please refresh and try again."
+        await self.__data.commit()
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            release_name=release_name,
+            selected_revision_number=selected_revision_number,
+            vote_manual=vote_manual,
+        )
+        return None
+
     async def remove_rc_tags(self, project_name: str, version_name: str) -> 
tuple[str | None, int, list[str]]:
         description = "Remove RC tags from paths via web interface"
         error_messages: list[str] = []
@@ -598,6 +660,18 @@ class CommitteeParticipant(FoundationCommitter):
                 if f == source_file_rel:
                     moved_files_names.append(f.name)
 
+    async def __tasks_ongoing(self, project_name: str, version_name: str, 
revision_number: str | None = None) -> int:
+        tasks = sqlmodel.select(sqlalchemy.func.count()).select_from(sql.Task)
+        query = tasks.where(
+            sql.Task.project_name == project_name,
+            sql.Task.version_name == version_name,
+            sql.Task.revision_number
+            == (sql.RELEASE_LATEST_REVISION_NUMBER if (revision_number is 
None) else revision_number),
+            
sql.validate_instrumented_attribute(sql.Task.status).in_([sql.TaskStatus.QUEUED,
 sql.TaskStatus.ACTIVE]),
+        )
+        result = await self.__data.execute(query)
+        return result.scalar_one()
+
 
 class CommitteeMember(CommitteeParticipant):
     def __init__(
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 6d1c373..d856b34 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -156,8 +156,8 @@ class CommitteeParticipant(FoundationCommitter):
 
         if promote is True:
             # This verifies the state and sets the phase to RELEASE_CANDIDATE
-            error = await interaction.promote_release(
-                self.__data, release.name, selected_revision_number, 
vote_manual=False
+            error = await self.__write_as.release.promote_to_candidate(
+                release.name, selected_revision_number, vote_manual=False
             )
             if error:
                 raise storage.AccessError(error)
@@ -302,7 +302,7 @@ class CommitteeMember(CommitteeParticipant):
             # Then we automatically start the Incubator PMC vote
             # TODO: Note on the resolve vote page that resolving the Project 
PPMC vote starts the Incubator PMC vote
             task_mid = interaction.task_mid_get(latest_vote_task)
-            archive_url = await interaction.task_archive_url_cached(task_mid)
+            archive_url = await 
self.__write_as.cache.get_message_archive_url(task_mid)
             if archive_url is None:
                 raise ValueError("No archive URL found for podling vote")
             thread_id = archive_url.split("/")[-1]
diff --git a/playwright/test.py b/playwright/test.py
index 3023914..80e8b54 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -963,7 +963,7 @@ def test_ssh_01_add_key(page: sync_api.Page, credentials: 
Credentials) -> None:
     go_to_path(page, "/committees")
 
     logging.info("Navigating to Your Public Keys page")
-    page.locator('a[href="/keys"]:has-text("Manage keys")').click()
+    page.locator('a[href="/keys"]:has-text("Public keys")').click()
     wait_for_path(page, "/keys")
     logging.info("Navigated to Your Public Keys page")
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to