From ec477a88d580ee15eb7253c576ccb0c7aa32da31 Mon Sep 17 00:00:00 2001 From: tison Date: Wed, 22 Jul 2026 23:31:14 +0800 Subject: [PATCH 1/3] Show ASF IDs on STV ballots --- v3/server/bin/create-election.py | 2 + v3/server/bin/election.yaml.sample | 12 ++--- v3/server/bin/tally.py | 50 +++++++++++++------ v3/server/pages.py | 13 +++-- v3/server/templates/vote-on.ezt | 7 +-- v3/steve/election.py | 10 ++-- v3/steve/vtypes/stv.py | 60 +++++++++++++++++----- v3/tests/check_coverage.py | 11 ++-- v3/tests/test_stv.py | 80 ++++++++++++++++++++++++++++++ 9 files changed, 195 insertions(+), 50 deletions(-) create mode 100644 v3/tests/test_stv.py diff --git a/v3/server/bin/create-election.py b/v3/server/bin/create-election.py index edf2e0c..5656a63 100755 --- a/v3/server/bin/create-election.py +++ b/v3/server/bin/create-election.py @@ -30,6 +30,7 @@ import steve.election import steve.persondb +import steve.vtypes.stv _LOGGER = logging.getLogger(__name__) @@ -64,6 +65,7 @@ def validate_issue(issue): ) if not isinstance(kv['seats'], int) or kv['seats'] <= 0: raise ValueError('STV seats must be a positive integer') + steve.vtypes.stv.get_candidates(kv) return issue diff --git a/v3/server/bin/election.yaml.sample b/v3/server/bin/election.yaml.sample index 9d2d4f9..00e2a24 100644 --- a/v3/server/bin/election.yaml.sample +++ b/v3/server/bin/election.yaml.sample @@ -18,12 +18,12 @@ issues: # Required: List of voting issues description: "Rank candidates for board seats using STV." vtype: "stv" kv: # Required for STV: Key-value metadata - version: 1 # Format version - labelmap: # Candidate mappings (label -> name) - a: "Alice" - b: "Bob" - c: "Carlos" - d: "David" + version: 2 # Format version + labelmap: # Candidate mappings (label -> [ASF ID, name]) + a: [alice, "Alice"] + b: [bob, "Bob"] + c: [carlos, "Carlos"] + d: [david, "David"] seats: 3 # Number of seats to elect record: # Required: List of eligible voter PIDs (must exist in person database) diff --git a/v3/server/bin/tally.py b/v3/server/bin/tally.py index 6811a3f..0aeb66f 100755 --- a/v3/server/bin/tally.py +++ b/v3/server/bin/tally.py @@ -62,14 +62,16 @@ def list_elections(db_fname, spy_on_open): issue_count = len(election.list_issues()) # Fetch person count using existing get_voters_for_email method person_count = len(election.get_voters_for_email()) - elections.append(edict( - eid=eid, - title=metadata.title, - close_at=metadata.close_at, - state=metadata.state, - issue_count=issue_count, - person_count=person_count - )) + elections.append( + edict( + eid=eid, + title=metadata.title, + close_at=metadata.close_at, + state=metadata.state, + issue_count=issue_count, + person_count=person_count, + ) + ) # Sort by close_at descending (most recent first) elections.sort(key=lambda x: x.close_at or 0, reverse=True) @@ -88,11 +90,15 @@ def select_election(elections): print('Available elections (sorted by close date, most recent first):') for i, election in enumerate(elections, 1): close_str = ( - datetime.datetime.fromtimestamp(election.close_at).strftime('%Y-%m-%d %H:%M') + datetime.datetime.fromtimestamp(election.close_at).strftime( + '%Y-%m-%d %H:%M' + ) if election.close_at else 'N/A' ) - print(f'{i}. {election.eid} - {election.title} (Closed: {close_str}, State: {election.state}, Issues: {election.issue_count}, Eligible: {election.person_count})') + print( + f'{i}. {election.eid} - {election.title} (Closed: {close_str}, State: {election.state}, Issues: {election.issue_count}, Eligible: {election.person_count})' + ) while True: try: @@ -112,7 +118,7 @@ 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: _LOGGER.error('No issues to tally in this election.') @@ -146,10 +152,16 @@ def tally_election(election, issue_id, output_format): raise # Fail hard if output_format == 'json': - print(json.dumps(edict(version=RESULTS_VERSION, - results=results, - voters=sorted(all_voters), - ), 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"]})') @@ -223,4 +235,10 @@ def main(spy_on_open, election_id, issue_id, db_fname, output_format): _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) + main( + args.spy_on_open_elections, + args.election_id, + args.issue_id, + args.db_path, + args.output, + ) diff --git a/v3/server/pages.py b/v3/server/pages.py index 167ade6..0b9a977 100644 --- a/v3/server/pages.py +++ b/v3/server/pages.py @@ -36,6 +36,7 @@ import steve.election import steve.crypto import steve.persondb +import steve.vtypes.stv APP = asfquart.APP _LOGGER = logging.getLogger(__name__) @@ -56,14 +57,18 @@ T_BAD_IID = APP.load_template(TEMPLATES / 'e_bad_iid.ezt') T_BAD_PID = APP.load_template(TEMPLATES / 'e_bad_pid.ezt') + def rewrite_description(issue): """Rewrite issue description: wrap in
 and convert doc:filename to links."""
     import re
+
     desc = issue.description
+
     # Replace doc:filename with  link
     def repl(match):
         filename = match.group(1)
         return f'{filename}'
+
     desc = re.sub(r'doc:([^\s]+)', repl, desc)
     # Wrap in 
     issue.description = f'
{desc}
' @@ -294,11 +299,13 @@ async def vote_on_page(election): issues_stv = [i for i in all_issues if i.vtype == 'stv'] issues_stv.sort(key=lambda i: i.title) - # Add seats, labelmap, and candidates to STV issues from KV + # Add seats and normalized candidates to STV issues from KV for issue in issues_stv: issue.seats = issue.kv.get('seats', 0) - issue.labelmap = edict(issue.kv.get('labelmap', {})) - issue.candidates = [{'label': k, 'name': v} for k, v in issue.labelmap.items()] + candidates = steve.vtypes.stv.get_candidates(issue.kv) + issue.candidates = [ + {'label': label, **candidate} for label, candidate in candidates.items() + ] # Shuffle candidates to prevent bias towards the first listed candidate random.shuffle(issue.candidates) diff --git a/v3/server/templates/vote-on.ezt b/v3/server/templates/vote-on.ezt index bca8093..819938d 100644 --- a/v3/server/templates/vote-on.ezt +++ b/v3/server/templates/vote-on.ezt @@ -330,13 +330,13 @@ // ── Candidate data embedded by server ──────────────────────────────────── // Each STV issue emits its candidates here so the modal can populate them - // without any XHR. Format: { iid: [{label, name}, ...], ... } + // without any XHR. Format: { iid: [{label, asfid, name}, ...], ... } const STV_CANDIDATES = { [for issues][is issues.vtype "stv"] "[issues.iid]": { seats: [issues.seats], title: "[issues.title]", candidates: [ -[for issues.candidates] { label: "[issues.candidates.label]", name: "[issues.candidates.name]" }, +[for issues.candidates] { label: "[issues.candidates.label]", asfid: "[issues.candidates.asfid]", name: "[issues.candidates.name]" }, [end] ] }, [end][end] }; @@ -392,8 +392,9 @@ div.className = 'stv-item'; div.dataset.label = candidate.label; if (rank) div.dataset.rank = rank; + const asfid = candidate.asfid ? ' (' + escapeHtml(candidate.asfid) + ')' : ''; div.innerHTML = '' - + '' + escapeHtml(candidate.name) + ''; + + '' + escapeHtml(candidate.name) + asfid + ''; // Double-click to move between panels div.addEventListener('dblclick', () => moveItem(div)); return div; diff --git a/v3/steve/election.py b/v3/steve/election.py index ac8ec3b..83f0d2f 100644 --- a/v3/steve/election.py +++ b/v3/steve/election.py @@ -94,8 +94,8 @@ def open(self, pdb): salt = crypto.gen_salt() opened_key = crypto.gen_opened_key(edata, salt) - #print('SALT:', salt) - #print('KEY:', opened_key) + # print('SALT:', salt) + # print('KEY:', opened_key) self.c_open.perform(salt, opened_key, self.eid) def gather_election_data(self, pdb): @@ -315,7 +315,7 @@ def tally_issue(self, iid): md = self._all_metadata(self.S_CLOSED) ### TBD: we need a param to "spy" on Open elections - #md = self._all_metadata() + # md = self._all_metadata() # Need the issue TYPE issue = self.q_get_issue.first_row(iid) @@ -406,8 +406,8 @@ def is_tampered(self, pdb): opened_key = crypto.gen_opened_key(edata, md.salt) # print('EDATA:', edata) - #print('SALT:', md.salt) - #print('KEY:', opened_key) + # print('SALT:', md.salt) + # print('KEY:', opened_key) # The computed key should be unchanged. return opened_key != md.opened_key diff --git a/v3/steve/vtypes/stv.py b/v3/steve/vtypes/stv.py index 03f85a1..64e0ca9 100644 --- a/v3/steve/vtypes/stv.py +++ b/v3/steve/vtypes/stv.py @@ -37,6 +37,37 @@ def load_stv(): stv_tool = load_stv() +def get_candidates(kv): + """Return normalized candidate details keyed by ballot label.""" + + version = kv.get('version', 1) + labelmap = kv['labelmap'] + + if version == 1: + candidates = {} + for label, name in labelmap.items(): + if not isinstance(name, str): + raise ValueError(f'STV v1 candidate {label!r} must be a name') + candidates[label] = {'asfid': '', 'name': name} + elif version == 2: + candidates = {} + for label, value in labelmap.items(): + if not isinstance(value, (list, tuple)) or len(value) != 2: + raise ValueError( + f'STV v2 candidate {label!r} must be an [asfid, name] pair' + ) + asfid, name = value + if not isinstance(asfid, str) or not isinstance(name, str): + raise ValueError( + f'STV v2 candidate {label!r} must contain string values' + ) + candidates[label] = {'asfid': asfid, 'name': name} + else: + raise ValueError(f'Unsupported STV KV version: {version}') + + return candidates + + def tally(votestrings, kv): """ Run the STV tally process. @@ -44,25 +75,29 @@ def tally(votestrings, kv): votestrings: List of strings, each representing a voter's preferences as comma-separated labels (e.g., 'a,b,c' for votes in order of preference). Labels must match keys in kv['labelmap']. kv: Dict containing STV configuration. - - 'version': Integer version of the kv format (currently 1). - - 'labelmap': Dict mapping single-character labels to candidate names (e.g., {'a': 'Alice'}). + - 'version': Integer version of the kv format. + - 'labelmap': Dict mapping labels to candidate details. Version 1 maps + labels directly to names; version 2 maps labels to [asfid, name] pairs. - 'seats': Integer number of seats to elect. """ # Trim the incoming votestrings: no empty strings: - trimmed = [ s for vs in votestrings if (s := vs.strip()) ] + trimmed = [s for vs in votestrings if (s := vs.strip())] - # kv['labelmap'] should be: LABEL: NAME - # for example: { 'a': 'John Doe', } - labelmap = kv['labelmap'] - revmap = { v: k for k, v in labelmap.items() } + candidates = get_candidates(kv) + labelmap = {label: candidate['name'] for label, candidate in candidates.items()} + revmap = {name: label for label, name in labelmap.items()} seats = kv['seats'] # Remap all votestrings from comma-separated label strings into sequences of NAMEs. # Split on commas, strip whitespace, and filter out empty parts. votes = [ - [labelmap[l] for label in vs.split(',') if (l := label.strip())] + [ + labelmap[ballot_label] + for label in vs.split(',') + if (ballot_label := label.strip()) + ] for vs in trimmed ] @@ -76,11 +111,12 @@ def tally(votestrings, kv): ) data = { # LABEL: ELECTED-BOOL - 'candidates': { revmap[cand.name]: (cand.status == stv_tool.ELECTED) - for cand in results.l }, - + 'candidates': { + revmap[cand.name]: (cand.status == stv_tool.ELECTED) for cand in results.l + }, # Carry the input configuration and votestrings into the result. - 'labelmap': labelmap, + 'version': kv.get('version', 1), + 'labelmap': kv['labelmap'], 'seats': seats, 'votestrings': trimmed, } diff --git a/v3/tests/check_coverage.py b/v3/tests/check_coverage.py index a5800a4..a3ba215 100755 --- a/v3/tests/check_coverage.py +++ b/v3/tests/check_coverage.py @@ -69,13 +69,14 @@ def touch_every_line(): None, 'stv', { + 'version': 2, 'seats': 3, 'labelmap': { - 'a': 'Alice', - 'b': 'Bob', - 'c': 'Carlos', - 'd': 'David', - 'e': 'Eve', + 'a': ['alice', 'Alice'], + 'b': ['bob', 'Bob'], + 'c': ['carlos', 'Carlos'], + 'd': ['david', 'David'], + 'e': ['eve', 'Eve'], }, }, ) diff --git a/v3/tests/test_stv.py b/v3/tests/test_stv.py new file mode 100644 index 0000000..edf7f1e --- /dev/null +++ b/v3/tests/test_stv.py @@ -0,0 +1,80 @@ +# 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 types +import unittest +from unittest import mock + +import steve.vtypes.stv + + +class CandidateTests(unittest.TestCase): + def test_version_one_candidate_has_no_asfid(self): + candidates = steve.vtypes.stv.get_candidates({'labelmap': {'a': 'Alice'}}) + + self.assertEqual(candidates, {'a': {'asfid': '', 'name': 'Alice'}}) + + def test_version_two_candidate_includes_asfid(self): + candidates = steve.vtypes.stv.get_candidates( + {'version': 2, 'labelmap': {'a': ['alice', 'Alice']}} + ) + + self.assertEqual(candidates, {'a': {'asfid': 'alice', 'name': 'Alice'}}) + + def test_version_two_candidate_requires_pair(self): + with self.assertRaisesRegex(ValueError, r'\[asfid, name\] pair'): + steve.vtypes.stv.get_candidates( + {'version': 2, 'labelmap': {'a': ['alice']}} + ) + + def test_unknown_version_is_rejected(self): + with self.assertRaisesRegex(ValueError, 'Unsupported STV KV version: 3'): + steve.vtypes.stv.get_candidates({'version': 3, 'labelmap': {}}) + + def test_tally_uses_names_from_all_candidate_versions(self): + def run_stv(names, votes, seats): + self.assertEqual(names, ['Alice', 'Bob']) + self.assertEqual(votes, [['Alice', 'Bob']]) + self.assertEqual(seats, 1) + return types.SimpleNamespace( + l=[ + types.SimpleNamespace( + name='Alice', status=steve.vtypes.stv.stv_tool.ELECTED + ), + types.SimpleNamespace( + name='Bob', status=steve.vtypes.stv.stv_tool.ELIMINATED + ), + ] + ) + + labelmaps = { + 1: {'a': 'Alice', 'b': 'Bob'}, + 2: {'a': ['alice', 'Alice'], 'b': ['bob', 'Bob']}, + } + for version, labelmap in labelmaps.items(): + with self.subTest(version=version): + kv = {'version': version, 'labelmap': labelmap, 'seats': 1} + with mock.patch.object(steve.vtypes.stv.stv_tool, 'run_stv', run_stv): + _, data = steve.vtypes.stv.tally(['a,b'], kv) + + self.assertEqual(data['candidates'], {'a': True, 'b': False}) + self.assertEqual(data['version'], version) + self.assertEqual(data['labelmap'], labelmap) + + +if __name__ == '__main__': + unittest.main() From 26af9960cf9ee96049aa67eef66b3847cc231429 Mon Sep 17 00:00:00 2001 From: tison Date: Thu, 23 Jul 2026 02:26:40 +0800 Subject: [PATCH 2/3] Update GitHub Actions to meet allowlist Signed-off-by: tison --- .github/workflows/linting.yml | 2 +- .github/workflows/type-tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 8435610..aa5d4c4 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -26,7 +26,7 @@ jobs: with: python-version-file: v3/pyproject.toml - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v9.0.0 - name: Install dependencies working-directory: v3 run: | diff --git a/.github/workflows/type-tests.yml b/.github/workflows/type-tests.yml index 5d36a29..5ae9c47 100644 --- a/.github/workflows/type-tests.yml +++ b/.github/workflows/type-tests.yml @@ -26,7 +26,7 @@ jobs: with: python-version-file: v3/pyproject.toml - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v9.0.0 - name: Install dependencies working-directory: v3 # Set the working directory to v3 where pyproject.toml is located run: | From 7a8ca09078a7ead1f87c7346bd8a740133b8fe51 Mon Sep 17 00:00:00 2001 From: tison Date: Thu, 23 Jul 2026 02:31:24 +0800 Subject: [PATCH 3/3] Address comments Signed-off-by: tison --- .github/workflows/linting.yml | 2 +- .github/workflows/type-tests.yml | 2 +- v3/server/templates/vote-on.ezt | 2 +- v3/steve/vtypes/stv.py | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index aa5d4c4..1794d7c 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -26,7 +26,7 @@ jobs: with: python-version-file: v3/pyproject.toml - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install dependencies working-directory: v3 run: | diff --git a/.github/workflows/type-tests.yml b/.github/workflows/type-tests.yml index 5ae9c47..037347b 100644 --- a/.github/workflows/type-tests.yml +++ b/.github/workflows/type-tests.yml @@ -26,7 +26,7 @@ jobs: with: python-version-file: v3/pyproject.toml - name: Install uv - uses: astral-sh/setup-uv@v9.0.0 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install dependencies working-directory: v3 # Set the working directory to v3 where pyproject.toml is located run: | diff --git a/v3/server/templates/vote-on.ezt b/v3/server/templates/vote-on.ezt index 819938d..c4c11ba 100644 --- a/v3/server/templates/vote-on.ezt +++ b/v3/server/templates/vote-on.ezt @@ -330,7 +330,7 @@ // ── Candidate data embedded by server ──────────────────────────────────── // Each STV issue emits its candidates here so the modal can populate them - // without any XHR. Format: { iid: [{label, asfid, name}, ...], ... } + // without any XHR. Format: { iid: {seats, title, candidates: [{label, asfid, name}, ...]}, ... } const STV_CANDIDATES = { [for issues][is issues.vtype "stv"] "[issues.iid]": { seats: [issues.seats], diff --git a/v3/steve/vtypes/stv.py b/v3/steve/vtypes/stv.py index 64e0ca9..1b9d0ca 100644 --- a/v3/steve/vtypes/stv.py +++ b/v3/steve/vtypes/stv.py @@ -41,7 +41,9 @@ def get_candidates(kv): """Return normalized candidate details keyed by ballot label.""" version = kv.get('version', 1) - labelmap = kv['labelmap'] + labelmap = kv.get('labelmap') + if not isinstance(labelmap, dict): + raise ValueError('STV kv must contain a "labelmap" mapping') if version == 1: candidates = {}