Skip to content

Commit 7b88323

Browse files
authored
Merge pull request #106 from ion-oset/september-ballot
Use the September test case instead of the June test case, with order updated to match what's expected. Only handles Spacetown. Human-readable IDs for ease of testing (not part of any agreed upon scheme).
2 parents 983ff88 + 9b91232 commit 7b88323

File tree

5 files changed

+281
-211
lines changed

5 files changed

+281
-211
lines changed

src/electos/ballotmaker/ballots/contest_data.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,20 @@ class CandidateData:
6464
_names: list = field(init=False, repr=False, default_factory=list)
6565
party: str = field(init=False)
6666
party_abbr: str = field(init=False)
67-
write_in: bool = field(init=False)
67+
is_write_in: bool = field(init=False)
6868
name: str = field(init=True, default="")
6969

7070
def __post_init__(self):
7171
self.id = self._can_data.get("id", "")
7272
self._names = self._can_data.get("name", [])
73-
_party_dict = self._can_data.get("party", {})
73+
_party_list = self._can_data.get("party", [])
74+
assert 0 <= len(_party_list) <= 1, \
75+
f"Multiple parties for a slate/ticket not handled: {_party_list}"
76+
_party_dict = _party_list[0] if len(_party_list) == 1 else {}
7477
self.party = _party_dict.get("name", "")
7578
self.party_abbr = _party_dict.get("abbreviation", "")
76-
self.write_in = self._can_data.get("write_in")
77-
if self.write_in:
79+
self.is_write_in = self._can_data.get("is_write_in")
80+
if self.is_write_in:
7881
self.name = "or write in:"
7982
else:
8083
for count, can_name in enumerate(self._names):

src/electos/ballotmaker/ballots/contest_layout.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ def __init__(self, contest_data: CandidateContestData):
184184
" and ", "<br />and<br />"
185185
)
186186
# add line for write ins
187-
if candidate.write_in:
187+
if candidate.is_write_in:
188188
candidate.name += ("<br />" * 2) + ("_" * 20)
189189
contest_line = f"<b>{candidate.name}</b>"
190190
if candidate.party_abbr != "":

src/electos/ballotmaker/demo_data/ballot_lab_data.py

Lines changed: 68 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -84,19 +84,67 @@ def candidate_name(candidate: Candidate):
8484

8585

8686
def candidate_party(candidate: Candidate, index):
87-
"""Get the name and abbreviation of the party of a candidate as it appears on a ballot."""
88-
party = index.by_id(candidate.party_id)
89-
name = text_content(party.name) if party else ""
90-
abbreviation = (
91-
text_content(party.abbreviation)
92-
if party and party.abbreviation
93-
else ""
94-
)
95-
result = {
96-
"name": name,
97-
"abbreviation": abbreviation,
98-
}
99-
return result
87+
"""Get the name and abbreviation of the party of a candidate as it appears on a ballot.
88+
89+
Drop either field from result if it isn't present.
90+
"""
91+
# Note: party ID is returned to allow de-duplicating parties in callers.
92+
id_ = candidate.party_id
93+
party = index.by_id(id_)
94+
name = text_content(party.name) if party else None
95+
abbreviation = text_content(party.abbreviation) if party and party.abbreviation else None
96+
result = {}
97+
if name:
98+
result["name"] = name
99+
if abbreviation:
100+
result["abbreviation"] = abbreviation
101+
return result, id_
102+
103+
104+
def candidate_contest_candidates(contest: CandidateContest, index):
105+
"""Get candidates for contest, grouped by slate/ticket.
106+
107+
A slate has:
108+
109+
- A single ID for the contest selection
110+
- Collects candidate names into an array.
111+
- Collects candidate parties into an array.
112+
- If all candidates in a race share a single party they are combined into
113+
one entry in the array.
114+
- If any candidates differ from the others, parties are listed separately.
115+
116+
Notes:
117+
- There's no clear guarantee of a 1:1 relationship between slates and parties.
118+
"""
119+
# Collect individual candidates
120+
candidates = []
121+
for selection in contest.contest_selection:
122+
assert isinstance(selection, CandidateSelection), \
123+
f"Unexpected non-candidate selection: {type(selection).__name__}"
124+
names = []
125+
parties = []
126+
_party_ids = set()
127+
if selection.candidate_ids:
128+
for id_ in selection.candidate_ids:
129+
candidate = index.by_id(id_)
130+
name = candidate_name(candidate)
131+
if name:
132+
names.append(name)
133+
party, _party_id = candidate_party(candidate, index)
134+
parties.append(party)
135+
_party_ids.add(_party_id)
136+
# If there's only one party ID, all candidates share the same party.
137+
# If there's any divergence track them all individually.
138+
if len(_party_ids) == 1:
139+
parties = parties[:1]
140+
result = {
141+
"id": selection.model__id,
142+
"name": names,
143+
"party": parties,
144+
"is_write_in": bool(selection.is_write_in)
145+
}
146+
candidates.append(result)
147+
return candidates
100148

101149

102150
def candidate_contest_offices(contest: CandidateContest, index):
@@ -136,21 +184,9 @@ def contest_election_district(contest: Contest, index):
136184
def extract_candidate_contest(contest: CandidateContest, index):
137185
"""Extract candidate contest information needed for ballots."""
138186
district = contest_election_district(contest, index)
139-
candidates = []
187+
candidates = candidate_contest_candidates(contest, index)
140188
offices = candidate_contest_offices(contest, index)
141189
parties = candidate_contest_parties(contest, index)
142-
write_ins = []
143-
for selection in contest.contest_selection:
144-
assert isinstance(
145-
selection, CandidateSelection
146-
), f"Unexpected non-candidate selection: {type(selection).__name__}"
147-
# Write-ins have no candidate IDs
148-
if selection.candidate_ids:
149-
for id_ in selection.candidate_ids:
150-
candidate = index.by_id(id_)
151-
candidates.append(candidate)
152-
if selection.is_write_in:
153-
write_ins.append(selection.model__id)
154190
result = {
155191
"id": contest.model__id,
156192
"title": contest.name,
@@ -159,14 +195,9 @@ def extract_candidate_contest(contest: CandidateContest, index):
159195
# Include even when default is 1: don't require caller to track that.
160196
"votes_allowed": contest.votes_allowed,
161197
"district": district,
162-
"candidates": [
163-
{"name": candidate_name(_), "party": candidate_party(_, index)}
164-
for _ in candidates
165-
],
166-
# Leave out offices and parties for now
198+
"candidates": candidates,
167199
# "offices": offices,
168200
# "parties": parties,
169-
"write_ins": write_ins,
170201
}
171202
return result
172203

@@ -179,10 +210,14 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index):
179210
selection, BallotMeasureSelection
180211
), f"Unexpected non-ballot measure selection: {type(selection).__name__}"
181212
choice = text_content(selection.selection)
182-
choices.append(choice)
213+
choices.append({
214+
"id": selection.model__id,
215+
"choice": choice,
216+
})
183217
district = contest_election_district(contest, index)
184218
full_text = text_content(contest.full_text)
185219
result = {
220+
"id": contest.model__id,
186221
"title": contest.name,
187222
"type": "ballot measure",
188223
"district": district,

0 commit comments

Comments
 (0)