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 2d68602614db5d0153bb8d4e101a46e3afaedfcd
Author: Greg Stein <[email protected]>
AuthorDate: Thu Mar 5 01:26:30 2026 -0600

    Improve tallying options and storage.
    
    * --output json now includes all issues into one dict, and a list of
      all those who voted (not on what or how; just "seen")
    * --issue_id now allows tallying a single issue for testing/speed
    * improve some logging; switch some stuff from print()
    * Election.tally_issue() returns the list of voters seen, so support
      the above JSON output feature
---
 v3/server/bin/tally.py | 61 ++++++++++++++++++++++++++++++++++++++++----------
 v3/steve/election.py   | 14 ++++++++++--
 2 files changed, 61 insertions(+), 14 deletions(-)

diff --git a/v3/server/bin/tally.py b/v3/server/bin/tally.py
index 7884d15..6331797 100755
--- a/v3/server/bin/tally.py
+++ b/v3/server/bin/tally.py
@@ -27,11 +27,17 @@ import sys
 import steve.election
 import steve.persondb
 
+from easydict import EasyDict as edict
+
 _LOGGER = logging.getLogger(__name__)
 
 THIS_DIR = pathlib.Path(__file__).resolve().parent
 DEFAULT_DB_FNAME = THIS_DIR.parent / 'steve.db'
 
+# Self-annotate the format of a JSON output file.
+RESULTS_VERSION = 1
+# Future: document format changes across the versions.
+
 
 def list_elections(db_fname, spy_on_open):
     """
@@ -86,31 +92,48 @@ def select_election(elections):
             print("Please enter a number or 'q'.")
 
 
-def tally_election(election, output_format):
+def tally_election(election, issue_id, 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.')
+        _LOGGER.error('No issues to tally in this election.')
         return
 
+    # Does the user want to just tally a single issue? (faster)
+    if issue_id:
+        issues = [issue for issue in issues if issue.iid == issue_id]
+        if not issues:
+            _LOGGER.error(f'Issue {issue_id} was not found.')
+            return
+        _LOGGER.info(f'Tallying one issue: {issue_id}')
+
+    if len(issues) > 1:
+        _LOGGER.info(f'Talling {len(issues)} issues ...')
+
+    all_voters = set()
     results = {}
     for issue in issues:
         try:
-            tally_result = election.tally_issue(issue.iid)
+            tally_result, issue_voters = election.tally_issue(issue.iid)
             results[issue.iid] = {
                 'title': issue.title,
                 'vtype': issue.vtype,
                 'human_result': tally_result[0],
                 'supporting_data': tally_result[1],
             }
+            all_voters.update(issue_voters)
         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))
+        print(json.dumps(edict(version=RESULTS_VERSION,
+                               results=results,
+                               voters=sorted(all_voters),
+                               ), indent=2))
     else:  # text
         for iid, data in results.items():
             print(f'Issue {iid}: {data["title"]} ({data["vtype"]})')
@@ -119,19 +142,25 @@ def tally_election(election, output_format):
             print('-' * 40)
 
 
-def main(spy_on_open, election_id, db_fname, output_format):
+def main(spy_on_open, election_id, issue_id, db_fname, output_format):
     """
     Main function to run the tally script.
     """
-    if election_id:
-        election = steve.election.Election(db_fname, election_id)
-    else:
+    if issue_id:
+        db = steve.election.Election.open_database(db_fname)
+        issue = db.q_get_issue.first_row(issue_id)
+        if not issue:
+            raise steve.election.IssueNotFound(issue_id)
+        election_id = issue.eid
+        db.conn.close()
+    elif not election_id:
         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)
+
+    election = steve.election.Election(db_fname, election_id)
 
     # Check for tampering
     pdb = steve.persondb.PersonDB.open(db_fname)
@@ -140,7 +169,7 @@ def main(spy_on_open, election_id, db_fname, output_format):
         sys.exit(1)
 
     # Proceed with tally
-    tally_election(election, output_format)
+    tally_election(election, issue_id, output_format)
 
 
 if __name__ == '__main__':
@@ -157,7 +186,11 @@ if __name__ == '__main__':
     )
     parser.add_argument(
         '--election-id',
-        help='Specify election ID to tally directly (skips interactive 
selection).',
+        help='Specify Election ID to tally directly (skips interactive 
selection).',
+    )
+    parser.add_argument(
+        '--issue-id',
+        help='Specify an Issue ID to tally directly (skips interactive 
selection).',
     )
     parser.add_argument(
         '--db-path', default=str(DEFAULT_DB_FNAME), help='Path to the database 
file.'
@@ -170,4 +203,8 @@ if __name__ == '__main__':
     )
     args = parser.parse_args()
 
-    main(args.spy_on_open_elections, args.election_id, args.db_path, 
args.output)
+    if args.election_id and args.issue_id:
+        _LOGGER.error('ISSUE_ID implies an ELECTION_ID; do not set both.')
+        sys.exit(1)
+
+    main(args.spy_on_open_elections, args.election_id, args.issue_id, 
args.db_path, args.output)
diff --git a/v3/steve/election.py b/v3/steve/election.py
index f99bc3b..89a4b40 100644
--- a/v3/steve/election.py
+++ b/v3/steve/election.py
@@ -314,9 +314,15 @@ class Election:
         # The Election should be closed.
         md = self._all_metadata(self.S_CLOSED)
 
+        ### TBD: we need a param to "spy" on Open elections
+        #md = self._all_metadata()
+
         # Need the issue TYPE
         issue = self.q_get_issue.first_row(iid)
 
+        # Accumulate PID values for each person who voted on IID.
+        voters = set()
+
         # Accumulate all MOST-RECENT votes for Issue IID.
         votes = []
 
@@ -340,6 +346,10 @@ class Election:
             if row is None:
                 continue
 
+            # There is a vote by this PID. Record the voter.
+            voters.add(mayvote.pid)
+
+            # Get the original votestring using the token/salt.
             votestring = crypto.decrypt_votestring(
                 vote_token,
                 mayvote.salt,
@@ -352,9 +362,9 @@ class Election:
         #  superfluous. But it certainly should not hurt.
         crypto.shuffle(votes)  # in-place
 
-        # Perform the tally, and return the results.
+        # Perform the tally, and return the results and voters.
         m = vtypes.vtype_module(issue.type)
-        return m.tally(votes, self.json2kv(issue.kv))
+        return m.tally(votes, self.json2kv(issue.kv)), voters
 
     def has_voted_upon(self, pid):
         "Return {ISSUE-ID: BOOL} stating what has been voted upon."

Reply via email to