diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 8435610..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@v6 + 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 5d36a29..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@v6 + 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/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..c4c11ba 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: {seats, title, candidates: [{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..1b9d0ca 100644
--- a/v3/steve/vtypes/stv.py
+++ b/v3/steve/vtypes/stv.py
@@ -37,6 +37,39 @@ 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.get('labelmap')
+ if not isinstance(labelmap, dict):
+ raise ValueError('STV kv must contain a "labelmap" mapping')
+
+ 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 +77,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 +113,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()