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 e75588a  Add an API endpoint to tabulate votes
e75588a is described below

commit e75588a61e2f78cdf8f9ce3c2cea4a617add6dae
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Jul 24 19:36:18 2025 +0100

    Add an API endpoint to tabulate votes
---
 atr/blueprints/api/api.py | 210 ++++++++++++++++++++++++++--------------------
 atr/models/__init__.py    |   4 +-
 atr/models/api.py         |  14 +++-
 atr/models/tabulate.py    |  65 ++++++++++++++
 atr/routes/resolve.py     |  36 ++++----
 atr/tabulate.py           | 126 ++++++++++++++--------------
 6 files changed, 283 insertions(+), 172 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index e497b05..67d37b1 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -41,10 +41,13 @@ import atr.revision as revision
 import atr.routes as routes
 import atr.routes.announce as announce
 import atr.routes.keys as keys
+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.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
@@ -97,10 +100,10 @@ async def checks_list(project: str, version: str) -> 
DictResponse:
     async with db.session() as data:
         release_name = sql.release_name(project, version)
         check_results = await 
data.check_result(release_name=release_name).all()
-        return models.api.ChecksListResults(
-            endpoint="/checks/list",
-            checks=check_results,
-        ).model_dump(), 200
+    return models.api.ChecksListResults(
+        endpoint="/checks/list",
+        checks=check_results,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/checks/list/<project>/<version>/<revision>")
@@ -123,10 +126,10 @@ async def checks_list_revision(project: str, version: 
str, revision: str) -> Dic
             raise exceptions.NotFound(f"Revision '{revision}' does not exist 
for release '{project}-{version}'")
 
         check_results = await data.check_result(release_name=release_name, 
revision_number=revision).all()
-        return models.api.ChecksListResults(
-            endpoint="/checks/list",
-            checks=check_results,
-        ).model_dump(), 200
+    return models.api.ChecksListResults(
+        endpoint="/checks/list",
+        checks=check_results,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/checks/ongoing/<project>/<version>")
@@ -167,10 +170,10 @@ async def committees(name: str) -> DictResponse:
     _simple_check(name)
     async with db.session() as data:
         committee = await 
data.committee(name=name).demand(exceptions.NotFound())
-        return models.api.CommitteesResults(
-            endpoint="/committees",
-            committee=committee,
-        ).model_dump(), 200
+    return models.api.CommitteesResults(
+        endpoint="/committees",
+        committee=committee,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/committees/keys/<name>")
@@ -180,10 +183,10 @@ async def committees_keys(name: str) -> DictResponse:
     _simple_check(name)
     async with db.session() as data:
         committee = await data.committee(name=name, 
_public_signing_keys=True).demand(exceptions.NotFound())
-        return models.api.CommitteesKeysResults(
-            endpoint="/committees/keys",
-            keys=committee.public_signing_keys,
-        ).model_dump(), 200
+    return models.api.CommitteesKeysResults(
+        endpoint="/committees/keys",
+        keys=committee.public_signing_keys,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/committees/list")
@@ -192,10 +195,10 @@ async def committees_list() -> DictResponse:
     """List all committees in the database."""
     async with db.session() as data:
         committees = await data.committee().all()
-        return models.api.CommitteesListResults(
-            endpoint="/committees/list",
-            committees=committees,
-        ).model_dump(), 200
+    return models.api.CommitteesListResults(
+        endpoint="/committees/list",
+        committees=committees,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/committees/projects/<name>")
@@ -205,10 +208,10 @@ async def committees_projects(name: str) -> DictResponse:
     _simple_check(name)
     async with db.session() as data:
         committee = await data.committee(name=name, 
_projects=True).demand(exceptions.NotFound())
-        return models.api.CommitteesProjectsResults(
-            endpoint="/committees/projects",
-            projects=committee.projects,
-        ).model_dump(), 200
+    return models.api.CommitteesProjectsResults(
+        endpoint="/committees/projects",
+        projects=committee.projects,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/draft/delete", methods=["POST"])
@@ -287,11 +290,11 @@ async def keys_endpoint(query_args: models.api.KeysQuery) 
-> DictResponse:
         count = (
             await 
data.execute(sqlalchemy.select(sqlalchemy.func.count(via(sql.PublicSigningKey.fingerprint))))
         ).scalar_one()
-        return models.api.KeysResults(
-            endpoint="/keys",
-            data=paged_keys,
-            count=count,
-        ).model_dump(), 200
+    return models.api.KeysResults(
+        endpoint="/keys",
+        data=paged_keys,
+        count=count,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/keys/add", methods=["POST"])
@@ -358,10 +361,10 @@ async def keys_committee(committee: str) -> DictResponse:
     async with db.session() as data:
         committee_object = await data.committee(name=committee, 
_public_signing_keys=True).demand(exceptions.NotFound())
         keys = committee_object.public_signing_keys
-        return models.api.KeysCommitteeResults(
-            endpoint="/keys/committee",
-            keys=keys,
-        ).model_dump(), 200
+    return models.api.KeysCommitteeResults(
+        endpoint="/keys/committee",
+        keys=keys,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/keys/get/<fingerprint>")
@@ -371,10 +374,10 @@ async def keys_get(fingerprint: str) -> DictResponse:
     _simple_check(fingerprint)
     async with db.session() as data:
         key = await 
data.public_signing_key(fingerprint=fingerprint.lower()).demand(exceptions.NotFound())
-        return models.api.KeysGetResults(
-            endpoint="/keys/get",
-            key=key,
-        ).model_dump(), 200
+    return models.api.KeysGetResults(
+        endpoint="/keys/get",
+        key=key,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/keys/upload", methods=["POST"])
@@ -439,10 +442,10 @@ async def keys_user(asf_uid: str) -> DictResponse:
     _simple_check(asf_uid)
     async with db.session() as data:
         keys = await data.public_signing_key(apache_uid=asf_uid).all()
-        return models.api.KeysUserResults(
-            endpoint="/keys/user",
-            keys=keys,
-        ).model_dump(), 200
+    return models.api.KeysUserResults(
+        endpoint="/keys/user",
+        keys=keys,
+    ).model_dump(), 200
 
 
 # TODO: Call this release/paths
@@ -475,10 +478,10 @@ async def project(name: str) -> DictResponse:
     _simple_check(name)
     async with db.session() as data:
         project = await data.project(name=name).demand(exceptions.NotFound())
-        return models.api.ProjectResults(
-            endpoint="/project",
-            project=project,
-        ).model_dump(), 200
+    return models.api.ProjectResults(
+        endpoint="/project",
+        project=project,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/project/releases/<name>")
@@ -488,10 +491,10 @@ async def project_releases(name: str) -> DictResponse:
     _simple_check(name)
     async with db.session() as data:
         releases = await data.release(project_name=name).all()
-        return models.api.ProjectReleasesResults(
-            endpoint="/project/releases",
-            releases=releases,
-        ).model_dump(), 200
+    return models.api.ProjectReleasesResults(
+        endpoint="/project/releases",
+        releases=releases,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/projects")
@@ -501,10 +504,10 @@ async def projects() -> DictResponse:
     # TODO: Add pagination?
     async with db.session() as data:
         projects = await data.project().all()
-        return models.api.ProjectsResults(
-            endpoint="/projects",
-            projects=projects,
-        ).model_dump(), 200
+    return models.api.ProjectsResults(
+        endpoint="/projects",
+        projects=projects,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/releases")
@@ -538,11 +541,11 @@ async def releases(query_args: models.api.ReleasesQuery) 
-> DictResponse:
 
         count = (await data.execute(count_stmt)).scalar_one()
 
-        return models.api.ReleasesResults(
-            endpoint="/releases",
-            data=paged_releases,
-            count=count,
-        ).model_dump(), 200
+    return models.api.ReleasesResults(
+        endpoint="/releases",
+        data=paged_releases,
+        count=count,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/releases/create", methods=["POST"])
@@ -617,11 +620,11 @@ async def releases_project(project: str, query_args: 
models.api.ReleasesProjectQ
         )
         count = (await data.execute(count_stmt)).scalar_one()
 
-        return models.api.ReleasesProjectResults(
-            endpoint="/releases/project",
-            data=paged_releases,
-            count=count,
-        ).model_dump(), 200
+    return models.api.ReleasesProjectResults(
+        endpoint="/releases/project",
+        data=paged_releases,
+        count=count,
+    ).model_dump(), 200
 
 
 # TODO: If we validate as sql.Release, quart_schema silently corrupts 
latest_revision_number to None
@@ -634,10 +637,10 @@ async def releases_version(project: str, version: str) -> 
DictResponse:
     async with db.session() as data:
         release_name = sql.release_name(project, version)
         release = await 
data.release(name=release_name).demand(exceptions.NotFound())
-        return models.api.ReleasesVersionResults(
-            endpoint="/releases/version",
-            release=release,
-        ).model_dump(), 200
+    return models.api.ReleasesVersionResults(
+        endpoint="/releases/version",
+        release=release,
+    ).model_dump(), 200
 
 
 # TODO: Rename this to revisions? I.e. /revisions/<project>/<version>
@@ -649,10 +652,10 @@ async def releases_revisions(project: str, version: str) 
-> DictResponse:
     async with db.session() as data:
         release_name = sql.release_name(project, version)
         revisions = await data.revision(release_name=release_name).all()
-        return models.api.ReleasesRevisionsResults(
-            endpoint="/releases/revisions",
-            revisions=revisions,
-        ).model_dump(), 200
+    return models.api.ReleasesRevisionsResults(
+        endpoint="/releases/revisions",
+        revisions=revisions,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/revisions/<project>/<version>")
@@ -722,11 +725,11 @@ async def ssh_list(asf_uid: str, query_args: 
models.api.SshListQuery) -> DictRes
         count_stmt = 
sqlalchemy.select(sqlalchemy.func.count(via(sql.SSHKey.fingerprint)))
         count = (await data.execute(count_stmt)).scalar_one()
 
-        return models.api.SshListResults(
-            endpoint="/ssh/list",
-            data=paged_keys,
-            count=count,
-        ).model_dump(), 200
+    return models.api.SshListResults(
+        endpoint="/ssh/list",
+        data=paged_keys,
+        count=count,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/tasks")
@@ -746,11 +749,11 @@ async def tasks(query_args: models.api.TasksQuery) -> 
DictResponse:
         if query_args.status:
             count_statement = count_statement.where(via(sql.Task.status) == 
query_args.status)
         count = (await data.execute(count_statement)).scalar_one()
-        return models.api.TasksResults(
-            endpoint="/tasks",
-            data=paged_tasks,
-            count=count,
-        ).model_dump(), 200
+    return models.api.TasksResults(
+        endpoint="/tasks",
+        data=paged_tasks,
+        count=count,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/users/list")
@@ -781,10 +784,10 @@ async def users_list() -> DictResponse:
 
         users = pat_uids | ssh_uids | public_signing_uids | revision_uids
         users -= {None}
-        return models.api.UsersListResults(
-            endpoint="/users/list",
-            users=sorted(users),
-        ).model_dump(), 200
+    return models.api.UsersListResults(
+        endpoint="/users/list",
+        users=sorted(users),
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/vote/resolve", methods=["POST"])
@@ -868,10 +871,39 @@ async def vote_start(data: models.api.VoteStartArgs) -> 
DictResponse:
         )
         db_data.add(task)
         await db_data.commit()
-        return models.api.VoteStartResults(
-            endpoint="/vote/start",
-            task=task,
-        ).model_dump(), 201
+    return models.api.VoteStartResults(
+        endpoint="/vote/start",
+        task=task,
+    ).model_dump(), 201
+
+
[email protected]("/vote/tabulate", methods=["POST"])
[email protected]
+@quart_schema.security_scheme([{"BearerAuth": []}])
+@quart_schema.validate_request(models.api.VoteTabulateArgs)
+@quart_schema.validate_response(models.api.VoteTabulateResults, 200)
+async def vote_tabulate(data: models.api.VoteTabulateArgs) -> DictResponse:
+    # 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_release_policy=True).demand(
+            exceptions.NotFound(f"Release {release_name} not found"),
+        )
+
+    latest_vote_task = await resolve.release_latest_vote_task(release)
+    if latest_vote_task is None:
+        raise exceptions.NotFound("No vote task found")
+    task_mid = resolve.task_mid_get(latest_vote_task)
+    archive_url = await vote.task_archive_url_cached(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)
+    return models.api.VoteTabulateResults(
+        endpoint="/vote/tabulate",
+        details=details,
+    ).model_dump(), 200
 
 
 @api.BLUEPRINT.route("/upload", methods=["POST"])
diff --git a/atr/models/__init__.py b/atr/models/__init__.py
index 63e3704..559a7d2 100644
--- a/atr/models/__init__.py
+++ b/atr/models/__init__.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from . import api, helpers, results, schema, sql
+from . import api, helpers, results, schema, sql, tabulate
 
 # If we use .__name__, pyright gives a warning
-__all__ = ["api", "helpers", "results", "schema", "sql"]
+__all__ = ["api", "helpers", "results", "schema", "sql", "tabulate"]
diff --git a/atr/models/api.py b/atr/models/api.py
index 127baf4..40d49c5 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -21,7 +21,7 @@ from typing import Annotated, Any, Literal, TypeVar
 
 import pydantic
 
-from . import schema, sql
+from . import schema, sql, tabulate
 
 T = TypeVar("T")
 
@@ -340,6 +340,16 @@ class VoteStartResults(schema.Strict):
     task: sql.Task
 
 
+class VoteTabulateArgs(schema.Strict):
+    project: str
+    version: str
+
+
+class VoteTabulateResults(schema.Strict):
+    endpoint: Literal["/vote/tabulate"] = schema.Field(alias="endpoint")
+    details: tabulate.VoteDetails
+
+
 class UploadArgs(schema.Strict):
     project: str
     version: str
@@ -389,6 +399,7 @@ Results = Annotated[
     | UsersListResults
     | VoteResolveResults
     | VoteStartResults
+    | VoteTabulateResults
     | UploadResults,
     schema.Field(discriminator="endpoint"),
 ]
@@ -440,4 +451,5 @@ validate_tasks = validator(TasksResults)
 validate_users_list = validator(UsersListResults)
 validate_vote_resolve = validator(VoteResolveResults)
 validate_vote_start = validator(VoteStartResults)
+validate_vote_tabulate = validator(VoteTabulateResults)
 validate_upload = validator(UploadResults)
diff --git a/atr/models/tabulate.py b/atr/models/tabulate.py
new file mode 100644
index 0000000..e58b035
--- /dev/null
+++ b/atr/models/tabulate.py
@@ -0,0 +1,65 @@
+# 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 enum
+
+import pydantic
+
+from . import schema
+
+
+class Vote(enum.Enum):
+    YES = "Yes"
+    NO = "No"
+    ABSTAIN = "-"
+    UNKNOWN = "?"
+
+
+class VoteStatus(enum.Enum):
+    BINDING = "Binding"
+    COMMITTER = "Committer"
+    CONTRIBUTOR = "Contributor"
+    UNKNOWN = "Unknown"
+
+
+class VoteEmail(schema.Strict):
+    asf_uid_or_email: str
+    from_email: str
+    status: VoteStatus
+    asf_eid: str
+    iso_datetime: str
+    vote: Vote
+    quotation: str
+    updated: bool
+
+    @pydantic.field_validator("status", mode="before")
+    @classmethod
+    def status_to_enum(cls, v):
+        return VoteStatus(v) if isinstance(v, str) else v
+
+    @pydantic.field_validator("vote", mode="before")
+    @classmethod
+    def vote_to_enum(cls, v):
+        return Vote(v) if isinstance(v, str) else v
+
+
+class VoteDetails(schema.Strict):
+    start_unixtime: int | None
+    votes: dict[str, VoteEmail]
+    summary: dict[str, int]
+    passed: bool
+    outcome: str
diff --git a/atr/routes/resolve.py b/atr/routes/resolve.py
index 612e23a..eaaca2a 100644
--- a/atr/routes/resolve.py
+++ b/atr/routes/resolve.py
@@ -231,10 +231,7 @@ async def tabulated_selected_post(session: 
routes.CommitterSession, project_name
         raise RuntimeError("This page is for tabulated votes only")
 
     hidden_form = await util.HiddenFieldForm.create_form()
-    tabulated_votes = None
-    summary = None
-    passed = None
-    outcome = None
+    details = None
     committee = None
     thread_id = None
     archive_url = None
@@ -249,32 +246,39 @@ async def tabulated_selected_post(session: 
routes.CommitterSession, project_name
             except util.FetchError as e:
                 fetch_error = f"Failed to fetch thread metadata: {e}"
             else:
-                start_unixtime, tabulated_votes = await 
tabulate.votes(committee, thread_id)
-                summary = tabulate.vote_summary(tabulated_votes)
-                passed, outcome = tabulate.vote_outcome(release, 
start_unixtime, tabulated_votes)
+                details = await tabulate.vote_details(committee, thread_id, 
release)
         else:
             fetch_error = "The vote thread could not yet be found."
     resolve_form = await ResolveVoteForm.create_form()
     if (
         (committee is None)
-        or (tabulated_votes is None)
-        or (summary is None)
-        or (passed is None)
-        or (outcome is None)
+        or (details is None)
+        or (details.votes is None)
+        or (details.summary is None)
+        or (details.passed is None)
+        or (details.outcome is None)
         or (thread_id is None)
     ):
         resolve_form.email_body.render_kw = {"rows": 12}
     else:
         resolve_form.email_body.data = tabulate.vote_resolution(
-            committee, release, tabulated_votes, summary, passed, outcome, 
full_name, asf_uid, thread_id
+            committee,
+            release,
+            details.votes,
+            details.summary,
+            details.passed,
+            details.outcome,
+            full_name,
+            asf_uid,
+            thread_id,
         )
-        resolve_form.vote_result.data = "passed" if passed else "failed"
+        resolve_form.vote_result.data = "passed" if details.passed else 
"failed"
     return await template.render(
         "resolve-tabulated.html",
         release=release,
-        tabulated_votes=tabulated_votes,
-        summary=summary,
-        outcome=outcome,
+        tabulated_votes=details.votes if details is not None else {},
+        summary=details.summary if details is not None else {},
+        outcome=details.outcome if details is not None else "",
         resolve_form=resolve_form,
         fetch_error=fetch_error,
         archive_url=archive_url,
diff --git a/atr/tabulate.py b/atr/tabulate.py
index c73d690..f5c8505 100644
--- a/atr/tabulate.py
+++ b/atr/tabulate.py
@@ -15,43 +15,18 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import enum
 import time
 from collections.abc import Generator
 
 import atr.db as db
 import atr.log as log
-import atr.models.schema as schema
-import atr.models.sql as sql
+import atr.models as models
 import atr.util as util
 
 
-class Vote(enum.Enum):
-    YES = "Yes"
-    NO = "No"
-    ABSTAIN = "-"
-    UNKNOWN = "?"
-
-
-class VoteStatus(enum.Enum):
-    BINDING = "Binding"
-    COMMITTER = "Committer"
-    CONTRIBUTOR = "Contributor"
-    UNKNOWN = "Unknown"
-
-
-class VoteEmail(schema.Strict):
-    asf_uid_or_email: str
-    from_email: str
-    status: VoteStatus
-    asf_eid: str
-    iso_datetime: str
-    vote: Vote
-    quotation: str
-    updated: bool
-
-
-async def votes(committee: sql.Committee | None, thread_id: str) -> tuple[int 
| None, dict[str, VoteEmail]]:
+async def votes(
+    committee: models.sql.Committee | None, thread_id: str
+) -> tuple[int | None, dict[str, models.tabulate.VoteEmail]]:
     """Tabulate votes."""
     start = time.perf_counter_ns()
     email_to_uid = await util.email_to_uid_map()
@@ -74,7 +49,7 @@ async def votes(committee: sql.Committee | None, thread_id: 
str) -> tuple[int |
             status = await _vote_status(asf_uid, list_raw, committee)
         else:
             asf_uid_or_email = from_email_lower
-            status = VoteStatus.UNKNOWN
+            status = models.tabulate.VoteStatus.UNKNOWN
 
         if start_unixtime is None:
             epoch = msg.get("epoch", "")
@@ -96,10 +71,10 @@ async def votes(committee: sql.Committee | None, thread_id: 
str) -> tuple[int |
         if len(castings) == 1:
             vote_cast = castings[0][0]
         else:
-            vote_cast = Vote.UNKNOWN
+            vote_cast = models.tabulate.Vote.UNKNOWN
         quotation = " // ".join([c[1] for c in castings])
 
-        vote_email = VoteEmail(
+        vote_email = models.tabulate.VoteEmail(
             asf_uid_or_email=asf_uid_or_email,
             from_email=from_email_lower,
             status=status,
@@ -117,7 +92,7 @@ async def votes(committee: sql.Committee | None, thread_id: 
str) -> tuple[int |
     return start_unixtime, tabulated_votes
 
 
-async def vote_committee(thread_id: str, release: sql.Release) -> 
sql.Committee | None:
+async def vote_committee(thread_id: str, release: models.sql.Release) -> 
models.sql.Committee | None:
     committee = None
     if release.project is not None:
         committee = release.project.committee
@@ -131,8 +106,23 @@ async def vote_committee(thread_id: str, release: 
sql.Release) -> sql.Committee
     return committee
 
 
+async def vote_details(
+    committee: models.sql.Committee | None, thread_id: str, release: 
models.sql.Release
+) -> models.tabulate.VoteDetails:
+    start_unixtime, tabulated_votes = await votes(committee, thread_id)
+    summary = vote_summary(tabulated_votes)
+    passed, outcome = vote_outcome(release, start_unixtime, tabulated_votes)
+    return models.tabulate.VoteDetails(
+        start_unixtime=start_unixtime,
+        votes=tabulated_votes,
+        summary=summary,
+        passed=passed,
+        outcome=outcome,
+    )
+
+
 def vote_outcome(
-    release: sql.Release, start_unixtime: int | None, tabulated_votes: 
dict[str, VoteEmail]
+    release: models.sql.Release, start_unixtime: int | None, tabulated_votes: 
dict[str, models.tabulate.VoteEmail]
 ) -> tuple[bool, str]:
     now = int(time.time())
     duration_hours = 0
@@ -150,20 +140,20 @@ def vote_outcome(
     binding_plus_one = 0
     binding_minus_one = 0
     for vote_email in tabulated_votes.values():
-        if vote_email.status != VoteStatus.BINDING:
+        if vote_email.status != models.tabulate.VoteStatus.BINDING:
             continue
-        if vote_email.vote == Vote.YES:
+        if vote_email.vote == models.tabulate.Vote.YES:
             binding_plus_one += 1
-        elif vote_email.vote == Vote.NO:
+        elif vote_email.vote == models.tabulate.Vote.NO:
             binding_minus_one += 1
 
     return _vote_outcome_format(duration_hours_remaining, binding_plus_one, 
binding_minus_one)
 
 
 def vote_resolution(
-    committee: sql.Committee,
-    release: sql.Release,
-    tabulated_votes: dict[str, VoteEmail],
+    committee: models.sql.Committee,
+    release: models.sql.Release,
+    tabulated_votes: dict[str, models.tabulate.VoteEmail],
     summary: dict[str, int],
     passed: bool,
     outcome: str,
@@ -179,7 +169,7 @@ def vote_resolution(
     )
 
 
-def vote_summary(tabulated_votes: dict[str, VoteEmail]) -> dict[str, int]:
+def vote_summary(tabulated_votes: dict[str, models.tabulate.VoteEmail]) -> 
dict[str, int]:
     result = {
         "binding_votes": 0,
         "binding_votes_yes": 0,
@@ -196,12 +186,12 @@ def vote_summary(tabulated_votes: dict[str, VoteEmail]) 
-> dict[str, int]:
     }
 
     for vote_email in tabulated_votes.values():
-        if vote_email.status == VoteStatus.BINDING:
+        if vote_email.status == models.tabulate.VoteStatus.BINDING:
             result["binding_votes"] += 1
             result["binding_votes_yes"] += 1 if (vote_email.vote.value == 
"Yes") else 0
             result["binding_votes_no"] += 1 if (vote_email.vote.value == "No") 
else 0
             result["binding_votes_abstain"] += 1 if (vote_email.vote.value == 
"Abstain") else 0
-        elif vote_email.status in {VoteStatus.COMMITTER, 
VoteStatus.CONTRIBUTOR}:
+        elif vote_email.status in {models.tabulate.VoteStatus.COMMITTER, 
models.tabulate.VoteStatus.CONTRIBUTOR}:
             result["non_binding_votes"] += 1
             result["non_binding_votes_yes"] += 1 if (vote_email.vote.value == 
"Yes") else 0
             result["non_binding_votes_no"] += 1 if (vote_email.vote.value == 
"No") else 0
@@ -231,7 +221,7 @@ def _vote_break(line: str) -> bool:
     return False
 
 
-def _vote_castings(body: str) -> list[tuple[Vote, str]]:
+def _vote_castings(body: str) -> list[tuple[models.tabulate.Vote, str]]:
     castings = []
     for line in body.split("\n"):
         if _vote_continue(line):
@@ -247,11 +237,11 @@ def _vote_castings(body: str) -> list[tuple[Vote, str]]:
             # Confusing result
             continue
         if plus_one:
-            castings.append((Vote.YES, line))
+            castings.append((models.tabulate.Vote.YES, line))
         elif minus_one:
-            castings.append((Vote.NO, line))
+            castings.append((models.tabulate.Vote.NO, line))
         elif zero:
-            castings.append((Vote.ABSTAIN, line))
+            castings.append((models.tabulate.Vote.ABSTAIN, line))
     return castings
 
 
@@ -308,9 +298,9 @@ def _vote_outcome_format(
 
 
 def _vote_resolution_body(
-    committee: sql.Committee,
-    release: sql.Release,
-    tabulated_votes: dict[str, VoteEmail],
+    committee: models.sql.Committee,
+    release: models.sql.Release,
+    tabulated_votes: dict[str, models.tabulate.VoteEmail],
     summary: dict[str, int],
     passed: bool,
     outcome: str,
@@ -346,8 +336,10 @@ def _vote_resolution_body(
     yield f"{full_name} ({asf_uid})"
 
 
-def _vote_resolution_body_votes(tabulated_votes: dict[str, VoteEmail], 
summary: dict[str, int]) -> Generator[str]:
-    yield from _vote_resolution_votes(tabulated_votes, {VoteStatus.BINDING})
+def _vote_resolution_body_votes(
+    tabulated_votes: dict[str, models.tabulate.VoteEmail], summary: dict[str, 
int]
+) -> Generator[str]:
+    yield from _vote_resolution_votes(tabulated_votes, 
{models.tabulate.VoteStatus.BINDING})
 
     binding_total = summary["binding_votes"]
     were_word = "was" if (binding_total == 1) else "were"
@@ -361,11 +353,15 @@ def _vote_resolution_body_votes(tabulated_votes: 
dict[str, VoteEmail], summary:
     yield f"Of these binding votes, {binding_yes} were +1, {binding_no} were 
-1, and {binding_abstain} were 0."
     yield ""
 
-    yield from _vote_resolution_votes(tabulated_votes, {VoteStatus.COMMITTER})
-    yield from _vote_resolution_votes(tabulated_votes, 
{VoteStatus.CONTRIBUTOR, VoteStatus.UNKNOWN})
+    yield from _vote_resolution_votes(tabulated_votes, 
{models.tabulate.VoteStatus.COMMITTER})
+    yield from _vote_resolution_votes(
+        tabulated_votes, {models.tabulate.VoteStatus.CONTRIBUTOR, 
models.tabulate.VoteStatus.UNKNOWN}
+    )
 
 
-def _vote_resolution_votes(tabulated_votes: dict[str, VoteEmail], statuses: 
set[VoteStatus]) -> Generator[str]:
+def _vote_resolution_votes(
+    tabulated_votes: dict[str, models.tabulate.VoteEmail], statuses: 
set[models.tabulate.VoteStatus]
+) -> Generator[str]:
     header: str | None = f"The {' and '.join(status.value.lower() for status 
in statuses)} votes were cast as follows:"
     for vote_email in tabulated_votes.values():
         if vote_email.status not in statuses:
@@ -375,13 +371,13 @@ def _vote_resolution_votes(tabulated_votes: dict[str, 
VoteEmail], statuses: set[
             yield ""
             header = None
         match vote_email.vote:
-            case Vote.YES:
+            case models.tabulate.Vote.YES:
                 symbol = "+1"
-            case Vote.NO:
+            case models.tabulate.Vote.NO:
                 symbol = "-1"
-            case Vote.ABSTAIN:
+            case models.tabulate.Vote.ABSTAIN:
                 symbol = "0"
-            case Vote.UNKNOWN:
+            case models.tabulate.Vote.UNKNOWN:
                 symbol = "?"
         user_info = vote_email.asf_uid_or_email
         status = vote_email.status.value.lower()
@@ -392,8 +388,10 @@ def _vote_resolution_votes(tabulated_votes: dict[str, 
VoteEmail], statuses: set[
         yield ""
 
 
-async def _vote_status(asf_uid: str, list_raw: str, committee: sql.Committee | 
None) -> VoteStatus:
-    status = VoteStatus.UNKNOWN
+async def _vote_status(
+    asf_uid: str, list_raw: str, committee: models.sql.Committee | None
+) -> models.tabulate.VoteStatus:
+    status = models.tabulate.VoteStatus.UNKNOWN
 
     if util.is_dev_environment():
         committee_label = list_raw.split(".apache.org", 1)[0].split(".", 1)[-1]
@@ -401,9 +399,9 @@ async def _vote_status(asf_uid: str, list_raw: str, 
committee: sql.Committee | N
             committee = await data.committee(name=committee_label).get()
     if committee is not None:
         if asf_uid in committee.committee_members:
-            status = VoteStatus.BINDING
+            status = models.tabulate.VoteStatus.BINDING
         elif asf_uid in committee.committers:
-            status = VoteStatus.COMMITTER
+            status = models.tabulate.VoteStatus.COMMITTER
         else:
-            status = VoteStatus.CONTRIBUTOR
+            status = models.tabulate.VoteStatus.CONTRIBUTOR
     return status


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

Reply via email to