This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit 682ff1796efcc73b01ce2edc53e57925625ae953
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Mar 24 17:30:23 2026 +0000

    Add a form to tally any vote
---
 atr/get/user.py    |  16 ++++++++
 atr/post/user.py   | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 atr/shared/user.py |   4 ++
 3 files changed, 126 insertions(+)

diff --git a/atr/get/user.py b/atr/get/user.py
index 2f3f3541..61653451 100644
--- a/atr/get/user.py
+++ b/atr/get/user.py
@@ -86,3 +86,19 @@ async def cache_get(session: web.Committer, _user_cache: 
Literal["user/cache"])
         block.append(cache_form)
 
     return await template.blank("Session cache management", 
content=block.collect())
+
+
[email protected]
+async def tally(session: web.Committer, _user_tally: Literal["user/tally"]) -> 
str:
+    """
+    URL: /user/tally
+    """
+    block = htm.Block()
+    block.h1["Vote tally"]
+    block.p["Enter a lists.apache.org thread URL or thread ID to count the 
votes in the thread."]
+    tally_form = form.render(
+        model_cls=shared.user.TallyForm,
+        submit_label="Count votes",
+    )
+    block.append(tally_form)
+    return await template.blank("Vote tally", content=block.collect())
diff --git a/atr/post/user.py b/atr/post/user.py
index 06ec280a..ffc93747 100644
--- a/atr/post/user.py
+++ b/atr/post/user.py
@@ -19,8 +19,13 @@ from typing import Literal
 import quart
 
 import atr.blueprints.post as post
+import atr.form as form
 import atr.get as get
+import atr.htm as htm
+import atr.models as models
 import atr.shared as shared
+import atr.tabulate as tabulate
+import atr.template as template
 import atr.util as util
 import atr.web as web
 
@@ -44,6 +49,52 @@ async def session_post(
     return await session.redirect(get.user.cache_get)
 
 
[email protected]
+async def tally(
+    session: web.Committer,
+    _user_tally: Literal["user/tally"],
+    tally_form: shared.user.TallyForm,
+) -> str:
+    """
+    URL: /user/tally
+    """
+    thread_id = _extract_thread_id(tally_form.thread)
+
+    block = htm.Block()
+    block.h1["Vote tally"]
+
+    tally_resubmit = form.render(
+        model_cls=shared.user.TallyForm,
+        submit_label="Count votes",
+        defaults={"thread": tally_form.thread},
+    )
+    block.append(tally_resubmit)
+
+    if not thread_id:
+        block.div(".alert.alert-danger")["Please enter a thread URL or ID."]
+        return await template.blank("Vote tally", content=block.collect())
+
+    try:
+        _start_unixtime, tabulated_votes = await tabulate.votes(None, 
thread_id)
+    except (util.FetchError, ValueError) as e:
+        block.div(".alert.alert-danger")[str(e)]
+        return await template.blank("Vote tally", content=block.collect())
+
+    if not tabulated_votes:
+        block.p["No votes found in this thread."]
+        return await template.blank("Vote tally", content=block.collect())
+
+    summary = tabulate.vote_summary(tabulated_votes)
+
+    block.h2["Votes"]
+    _render_votes_table(block, tabulated_votes)
+
+    block.h2["Summary"]
+    _render_summary_table(block, summary)
+
+    return await template.blank("Vote tally", content=block.collect())
+
+
 async def _cache_session(session: web.Committer) -> None:
     cache_data = await util.session_cache_read()
 
@@ -73,3 +124,58 @@ async def _delete_session_cache(session: web.Committer) -> 
None:
     if session.uid in cache_data:
         del cache_data[session.uid]
         await util.session_cache_write(cache_data)
+
+
+def _extract_thread_id(value: str) -> str:
+    value = value.strip().rstrip("/")
+    marker = "lists.apache.org/thread/"
+    index = value.find(marker)
+    if index >= 0:
+        return value[index + len(marker) :]
+    return value
+
+
+def _render_summary_table(block: htm.Block, summary: dict[str, int]) -> None:
+    thead = htm.thead[htm.tr[htm.th["Category"], htm.th["Yes"], htm.th["No"], 
htm.th["Abstain"], htm.th["Total"]]]
+    tbody = htm.Block(htm.tbody)
+    for label, prefix in [("Binding", "binding"), ("Non-binding", 
"non_binding"), ("Unknown", "unknown")]:
+        total = summary[f"{prefix}_votes"]
+        if total == 0:
+            continue
+        tbody.append(
+            htm.tr[
+                htm.td[label],
+                htm.td[str(summary[f"{prefix}_votes_yes"])],
+                htm.td[str(summary[f"{prefix}_votes_no"])],
+                htm.td[str(summary[f"{prefix}_votes_abstain"])],
+                htm.td[str(total)],
+            ]
+        )
+    block.table(".table.table-striped")[thead, tbody.collect()]
+
+
+def _render_votes_table(block: htm.Block, tabulated_votes: dict[str, 
models.tabulate.VoteEmail]) -> None:
+    thead = htm.thead[
+        htm.tr[
+            htm.th["UID or email"],
+            htm.th(".text-center")["Vote"],
+            htm.th(".text-center")["Status"],
+            htm.th["Quotation"],
+        ]
+    ]
+    tbody = htm.Block(htm.tbody)
+    for vote_email in tabulated_votes.values():
+        vote_class = ""
+        if vote_email.vote == models.tabulate.Vote.YES:
+            vote_class = ".atr-green"
+        elif vote_email.vote == models.tabulate.Vote.NO:
+            vote_class = ".atr-red"
+        tbody.append(
+            htm.tr[
+                htm.td(".atr-nowrap")[vote_email.asf_uid_or_email],
+                
htm.td(f".atr-nowrap.text-center{vote_class}")[vote_email.vote.value],
+                htm.td(".atr-nowrap.text-center")[vote_email.status.value],
+                htm.td[vote_email.quotation],
+            ]
+        )
+    block.table(".table.table-striped")[thead, tbody.collect()]
diff --git a/atr/shared/user.py b/atr/shared/user.py
index 8e321f8f..d3e07064 100644
--- a/atr/shared/user.py
+++ b/atr/shared/user.py
@@ -31,6 +31,10 @@ class DeleteCacheForm(form.Empty):
     variant: DELETE = form.value(DELETE)
 
 
+class TallyForm(form.Form):
+    thread: str = form.label("Thread URL or ID")
+
+
 type UserCacheForm = Annotated[
     CacheUserForm | DeleteCacheForm,
     form.DISCRIMINATOR,


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

Reply via email to