Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/linting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/type-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 2 additions & 0 deletions v3/server/bin/create-election.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

import steve.election
import steve.persondb
import steve.vtypes.stv

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -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


Expand Down
12 changes: 6 additions & 6 deletions v3/server/bin/election.yaml.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 34 additions & 16 deletions v3/server/bin/tally.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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.')
Expand Down Expand Up @@ -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"]})')
Expand Down Expand Up @@ -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,
)
13 changes: 10 additions & 3 deletions v3/server/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import steve.election
import steve.crypto
import steve.persondb
import steve.vtypes.stv

APP = asfquart.APP
_LOGGER = logging.getLogger(__name__)
Expand All @@ -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 <pre> and convert doc:filename to links."""
import re

desc = issue.description

# Replace doc:filename with <a> link
def repl(match):
filename = match.group(1)
return f'<a href="/docs/{issue.iid}/{filename}">{filename}</a>'

desc = re.sub(r'doc:([^\s]+)', repl, desc)
# Wrap in <pre>
issue.description = f'<pre>{desc}</pre>'
Expand Down Expand Up @@ -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)

Expand Down
7 changes: 4 additions & 3 deletions v3/server/templates/vote-on.ezt
Original file line number Diff line number Diff line change
Expand Up @@ -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] };
Expand Down Expand Up @@ -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 = '<span class="drag-handle bi bi-grip-vertical"></span>'
+ '<span class="cand-name">' + escapeHtml(candidate.name) + '</span>';
+ '<span class="cand-name">' + escapeHtml(candidate.name) + asfid + '</span>';
// Double-click to move between panels
div.addEventListener('dblclick', () => moveItem(div));
return div;
Expand Down
10 changes: 5 additions & 5 deletions v3/steve/election.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
62 changes: 50 additions & 12 deletions v3/steve/vtypes/stv.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,32 +37,69 @@ 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.

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()}
Comment on lines +89 to +91

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a new feature that we may handle in a follow-up PR.


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
]

Expand All @@ -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,
}
Expand Down
11 changes: 6 additions & 5 deletions v3/tests/check_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
},
)
Expand Down
Loading
Loading