@@ -84,19 +84,67 @@ def candidate_name(candidate: Candidate):
84
84
85
85
86
86
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
100
148
101
149
102
150
def candidate_contest_offices (contest : CandidateContest , index ):
@@ -136,21 +184,9 @@ def contest_election_district(contest: Contest, index):
136
184
def extract_candidate_contest (contest : CandidateContest , index ):
137
185
"""Extract candidate contest information needed for ballots."""
138
186
district = contest_election_district (contest , index )
139
- candidates = []
187
+ candidates = candidate_contest_candidates ( contest , index )
140
188
offices = candidate_contest_offices (contest , index )
141
189
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 )
154
190
result = {
155
191
"id" : contest .model__id ,
156
192
"title" : contest .name ,
@@ -159,14 +195,9 @@ def extract_candidate_contest(contest: CandidateContest, index):
159
195
# Include even when default is 1: don't require caller to track that.
160
196
"votes_allowed" : contest .votes_allowed ,
161
197
"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 ,
167
199
# "offices": offices,
168
200
# "parties": parties,
169
- "write_ins" : write_ins ,
170
201
}
171
202
return result
172
203
@@ -179,10 +210,14 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index):
179
210
selection , BallotMeasureSelection
180
211
), f"Unexpected non-ballot measure selection: { type (selection ).__name__ } "
181
212
choice = text_content (selection .selection )
182
- choices .append (choice )
213
+ choices .append ({
214
+ "id" : selection .model__id ,
215
+ "choice" : choice ,
216
+ })
183
217
district = contest_election_district (contest , index )
184
218
full_text = text_content (contest .full_text )
185
219
result = {
220
+ "id" : contest .model__id ,
186
221
"title" : contest .name ,
187
222
"type" : "ballot measure" ,
188
223
"district" : district ,
0 commit comments