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

gstein pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/steve.git

commit 26c7d5406ffb5f02bf93e6696d1ee1503c8a57c7
Author: Greg Stein <[email protected]>
AuthorDate: Sat Feb 21 23:52:43 2026 -0600

    feat: implement tally script for election issues
    
    Co-authored-by: aider (openrouter/x-ai/grok-code-fast-1) <[email protected]>
---
 v3/server/bin/tally.md |  47 +++++++++++++++++
 v3/server/bin/tally.py | 141 +++++++++++++++++++++++++++++++++++++++++++++----
 2 files changed, 179 insertions(+), 9 deletions(-)

diff --git a/v3/server/bin/tally.md b/v3/server/bin/tally.md
new file mode 100644
index 0000000..9da5f32
--- /dev/null
+++ b/v3/server/bin/tally.md
@@ -0,0 +1,47 @@
+# Tally Script Runbook
+
+## Overview
+The `tally.py` script is used by administrators to tally votes for all issues 
in a selected election. It is designed to be run interactively from the command 
line on the server. Results are output in text or JSON format.
+
+## Usage
+Run the script with `uv run --script tally.py [options]`.
+
+### Options
+- `--spy-on-open-elections`: Allow tallying of open elections. This is a long 
flag to ensure intentional use. Use with extreme caution, as it may expose 
incomplete or sensitive data.
+- `--election-id <EID>`: Specify the election ID directly to skip interactive 
selection.
+- `--db-path <PATH>`: Path to the database file (default: `../steve.db` 
relative to the script).
+- `--output <FORMAT>`: Output format, either `text` (default) or `json`.
+
+### Interactive Mode
+If `--election-id` is not provided:
+1. The script lists available elections (closed by default, or open if 
`--spy-on-open-elections` is used).
+2. Elections are sorted by close date (most recent first).
+3. The admin is prompted to select an election by number.
+4. Type 'q' to quit without selecting.
+
+### Process
+1. If tampering is detected (via `election.is_tampered()`), the script fails 
with an error.
+2. For each issue in the election, votes are tallied using 
`election.tally_issue()`.
+3. Results are output in the specified format.
+4. Any exception during tallying causes the script to fail hard (no recovery).
+
+## Output Formats
+- **Text**: Human-readable format with issue details, results, and supporting 
data.
+- **JSON**: Structured data for integration with other tools.
+
+## Security and Concerns
+- **Misuse**: This script can reveal vote results prematurely if used with 
`--spy-on-open-elections`. There are no built-in restrictions on who can run 
it—ensure only trusted admins have access. Consider OS-level permissions or 
environment checks.
+- **Performance**: Tallying is intentionally slow due to cryptographic 
operations to protect voter privacy. Do not expect web-like response times; run 
in a background process if needed.
+- **Logging**: No internal logging is performed, as it could be tampered with. 
Results are only output to stdout/stderr.
+- **Tampering**: Elections are checked for tampering before tallying. If 
tampered, the script exits with an error.
+- **Errors**: All exceptions are allowed to propagate and crash the script. 
Fix issues and retry.
+
+## Examples
+- Tally a specific closed election: `./tally.py --election-id ABC123`
+- Spy on an open election: `./tally.py --spy-on-open-elections --election-id 
DEF456`
+- Interactive with JSON output: `./tally.py --output json`
+
+## TODO
+- Add unit and integration tests for tallying logic, edge cases (e.g., no 
votes, mixed vtypes), and CLI behavior.
+- Consider adding progress indicators for large elections.
+- Evaluate automating tally runs post-election closure.
diff --git a/v3/server/bin/tally.py b/v3/server/bin/tally.py
index 3546edb..baec8d2 100755
--- a/v3/server/bin/tally.py
+++ b/v3/server/bin/tally.py
@@ -21,7 +21,8 @@ import argparse
 import datetime
 import pathlib
 import logging
-import yaml
+import json
+import sys
 
 import steve.election
 import steve.persondb
@@ -29,22 +30,144 @@ import steve.persondb
 _LOGGER = logging.getLogger(__name__)
 
 THIS_DIR = pathlib.Path(__file__).resolve().parent
-DB_FNAME = THIS_DIR.parent / 'steve.db'
+DEFAULT_DB_FNAME = THIS_DIR.parent / 'steve.db'
 
 
-def main(spy: bool):
-    ### show list of elections (only close; and open if SPY)
-    ### select an election
-    ### provide a tally per issue
-    pass
+def list_elections(db_fname, spy_on_open):
+    """
+    List elections available for tallying.
+    Returns a list of (eid, title, close_at, state) tuples, sorted by close_at 
descending.
+    Includes closed elections, or open ones if spy_on_open is True.
+    """
+    # Get all elections owned by a dummy PID (since owned_elections doesn't 
filter by state)
+    # This is a hack; ideally, add a query for all elections with states.
+    # For now, fetch all owned by a non-existent PID to get all elections.
+    all_elections = steve.election.Election.owned_elections(db_fname, 
'dummy_pid')
+    
+    elections = []
+    for e in all_elections:
+        election = steve.election.Election(db_fname, e.eid)
+        state = election.get_state()
+        if state == steve.election.Election.S_CLOSED or (spy_on_open and state 
== steve.election.Election.S_OPEN):
+            elections.append((e.eid, e.title, e.close_at, state))
+    
+    # Sort by close_at descending (most recent first)
+    elections.sort(key=lambda x: x[2] or 0, reverse=True)
+    return elections
+
+
+def select_election(elections):
+    """
+    Interactively prompt the admin to select an election from the list.
+    Returns the selected eid, or None if none available.
+    """
+    if not elections:
+        print("No elections available for tallying.")
+        return None
+    
+    print("Available elections (sorted by close date, most recent first):")
+    for i, (eid, title, close_at, state) in enumerate(elections, 1):
+        close_str = 
datetime.datetime.fromtimestamp(close_at).strftime('%Y-%m-%d %H:%M') if 
close_at else 'N/A'
+        print(f"{i}. {eid} - {title} (Closed: {close_str}, State: {state})")
+    
+    while True:
+        try:
+            choice = input("Select an election by number (or 'q' to quit): 
").strip()
+            if choice.lower() == 'q':
+                return None
+            idx = int(choice) - 1
+            if 0 <= idx < len(elections):
+                return elections[idx][0]
+            else:
+                print("Invalid choice. Try again.")
+        except ValueError:
+            print("Please enter a number or 'q'.")
+
+
+def tally_election(election, output_format):
+    """
+    Tally all issues in the given election and output results.
+    """
+    issues = election.list_issues()
+    if not issues:
+        print("No issues to tally in this election.")
+        return
+    
+    results = {}
+    for issue in issues:
+        try:
+            tally_result = election.tally_issue(issue.iid)
+            results[issue.iid] = {
+                'title': issue.title,
+                'vtype': issue.vtype,
+                'human_result': tally_result[0],
+                'supporting_data': tally_result[1]
+            }
+        except Exception as e:
+            print(f"Error tallying issue {issue.iid}: {e}")
+            raise  # Fail hard
+    
+    if output_format == 'json':
+        print(json.dumps(results, indent=2))
+    else:  # text
+        for iid, data in results.items():
+            print(f"Issue {iid}: {data['title']} ({data['vtype']})")
+            print(f"Result: {data['human_result']}")
+            print(f"Details: {data['supporting_data']}")
+            print("-" * 40)
+
+
+def main(spy_on_open, election_id, db_fname, output_format):
+    """
+    Main function to run the tally script.
+    """
+    if election_id:
+        election = steve.election.Election(db_fname, election_id)
+    else:
+        elections = list_elections(db_fname, spy_on_open)
+        election_id = select_election(elections)
+        if not election_id:
+            print("No election selected. Exiting.")
+            return
+        election = steve.election.Election(db_fname, election_id)
+    
+    # Check for tampering
+    pdb = steve.persondb.PersonDB.open(db_fname)
+    if election.is_tampered(pdb):
+        print(f"Error: Election {election_id} has been tampered with. Cannot 
proceed.")
+        sys.exit(1)
+    
+    # Proceed with tally
+    tally_election(election, output_format)
 
 
 if __name__ == '__main__':
     logging.basicConfig(level=logging.INFO)
 
     parser = argparse.ArgumentParser(
-        formatter_class=argparse.ArgumentDefaultsHelpFormatter
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+        description="Tally votes for all issues in a closed election (or open 
if --spy-on-open-elections is used)."
+    )
+    parser.add_argument(
+        '--spy-on-open-elections',
+        action='store_true',
+        help='Allow tallying of open elections (use with caution).'
+    )
+    parser.add_argument(
+        '--election-id',
+        help='Specify election ID to tally directly (skips interactive 
selection).'
+    )
+    parser.add_argument(
+        '--db-path',
+        default=str(DEFAULT_DB_FNAME),
+        help='Path to the database file.'
+    )
+    parser.add_argument(
+        '--output',
+        choices=['text', 'json'],
+        default='text',
+        help='Output format for results.'
     )
     args = parser.parse_args()
 
-    main(False)
+    main(args.spy_on_open_elections, args.election_id, args.db_path, 
args.output)

Reply via email to