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)
