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]