diff --git a/src/electos/ballotmaker/ballots/contest_data.py b/src/electos/ballotmaker/ballots/contest_data.py index b72ab77..964f5fe 100644 --- a/src/electos/ballotmaker/ballots/contest_data.py +++ b/src/electos/ballotmaker/ballots/contest_data.py @@ -64,17 +64,20 @@ class CandidateData: _names: list = field(init=False, repr=False, default_factory=list) party: str = field(init=False) party_abbr: str = field(init=False) - write_in: bool = field(init=False) + is_write_in: bool = field(init=False) name: str = field(init=True, default="") def __post_init__(self): self.id = self._can_data.get("id", "") self._names = self._can_data.get("name", []) - _party_dict = self._can_data.get("party", {}) + _party_list = self._can_data.get("party", []) + assert 0 <= len(_party_list) <= 1, \ + f"Multiple parties for a slate/ticket not handled: {_party_list}" + _party_dict = _party_list[0] if len(_party_list) == 1 else {} self.party = _party_dict.get("name", "") self.party_abbr = _party_dict.get("abbreviation", "") - self.write_in = self._can_data.get("write_in") - if self.write_in: + self.is_write_in = self._can_data.get("is_write_in") + if self.is_write_in: self.name = "or write in:" else: for count, can_name in enumerate(self._names): diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index 0df73b8..68ab251 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -184,7 +184,7 @@ def __init__(self, contest_data: CandidateContestData): " and ", "
and
" ) # add line for write ins - if candidate.write_in: + if candidate.is_write_in: candidate.name += ("
" * 2) + ("_" * 20) contest_line = f"{candidate.name}" if candidate.party_abbr != "": diff --git a/src/electos/ballotmaker/demo_data/ballot_lab_data.py b/src/electos/ballotmaker/demo_data/ballot_lab_data.py index e86dc1c..7a3fc60 100644 --- a/src/electos/ballotmaker/demo_data/ballot_lab_data.py +++ b/src/electos/ballotmaker/demo_data/ballot_lab_data.py @@ -84,19 +84,67 @@ def candidate_name(candidate: Candidate): def candidate_party(candidate: Candidate, index): - """Get the name and abbreviation of the party of a candidate as it appears on a ballot.""" - party = index.by_id(candidate.party_id) - name = text_content(party.name) if party else "" - abbreviation = ( - text_content(party.abbreviation) - if party and party.abbreviation - else "" - ) - result = { - "name": name, - "abbreviation": abbreviation, - } - return result + """Get the name and abbreviation of the party of a candidate as it appears on a ballot. + + Drop either field from result if it isn't present. + """ + # Note: party ID is returned to allow de-duplicating parties in callers. + id_ = candidate.party_id + party = index.by_id(id_) + name = text_content(party.name) if party else None + abbreviation = text_content(party.abbreviation) if party and party.abbreviation else None + result = {} + if name: + result["name"] = name + if abbreviation: + result["abbreviation"] = abbreviation + return result, id_ + + +def candidate_contest_candidates(contest: CandidateContest, index): + """Get candidates for contest, grouped by slate/ticket. + + A slate has: + + - A single ID for the contest selection + - Collects candidate names into an array. + - Collects candidate parties into an array. + - If all candidates in a race share a single party they are combined into + one entry in the array. + - If any candidates differ from the others, parties are listed separately. + + Notes: + - There's no clear guarantee of a 1:1 relationship between slates and parties. + """ + # Collect individual candidates + candidates = [] + for selection in contest.contest_selection: + assert isinstance(selection, CandidateSelection), \ + f"Unexpected non-candidate selection: {type(selection).__name__}" + names = [] + parties = [] + _party_ids = set() + if selection.candidate_ids: + for id_ in selection.candidate_ids: + candidate = index.by_id(id_) + name = candidate_name(candidate) + if name: + names.append(name) + party, _party_id = candidate_party(candidate, index) + parties.append(party) + _party_ids.add(_party_id) + # If there's only one party ID, all candidates share the same party. + # If there's any divergence track them all individually. + if len(_party_ids) == 1: + parties = parties[:1] + result = { + "id": selection.model__id, + "name": names, + "party": parties, + "is_write_in": bool(selection.is_write_in) + } + candidates.append(result) + return candidates def candidate_contest_offices(contest: CandidateContest, index): @@ -136,21 +184,9 @@ def contest_election_district(contest: Contest, index): def extract_candidate_contest(contest: CandidateContest, index): """Extract candidate contest information needed for ballots.""" district = contest_election_district(contest, index) - candidates = [] + candidates = candidate_contest_candidates(contest, index) offices = candidate_contest_offices(contest, index) parties = candidate_contest_parties(contest, index) - write_ins = [] - for selection in contest.contest_selection: - assert isinstance( - selection, CandidateSelection - ), f"Unexpected non-candidate selection: {type(selection).__name__}" - # Write-ins have no candidate IDs - if selection.candidate_ids: - for id_ in selection.candidate_ids: - candidate = index.by_id(id_) - candidates.append(candidate) - if selection.is_write_in: - write_ins.append(selection.model__id) result = { "id": contest.model__id, "title": contest.name, @@ -159,14 +195,9 @@ def extract_candidate_contest(contest: CandidateContest, index): # Include even when default is 1: don't require caller to track that. "votes_allowed": contest.votes_allowed, "district": district, - "candidates": [ - {"name": candidate_name(_), "party": candidate_party(_, index)} - for _ in candidates - ], - # Leave out offices and parties for now + "candidates": candidates, # "offices": offices, # "parties": parties, - "write_ins": write_ins, } return result @@ -179,10 +210,14 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): selection, BallotMeasureSelection ), f"Unexpected non-ballot measure selection: {type(selection).__name__}" choice = text_content(selection.selection) - choices.append(choice) + choices.append({ + "id": selection.model__id, + "choice": choice, + }) district = contest_election_district(contest, index) full_text = text_content(contest.full_text) result = { + "id": contest.model__id, "title": contest.name, "type": "ballot measure", "district": district, diff --git a/src/electos/ballotmaker/demo_data/spacetown_data.json b/src/electos/ballotmaker/demo_data/spacetown_data.json index e781b8b..a5635fd 100644 --- a/src/electos/ballotmaker/demo_data/spacetown_data.json +++ b/src/electos/ballotmaker/demo_data/spacetown_data.json @@ -3,94 +3,51 @@ "contests": { "candidate": [ { - "id": "recIj8OmzqzzvnDbM", - "title": "Contest for Mayor of Orbit City", + "id": "contest-potus", + "title": "President of the United States", "type": "candidate", "vote_type": "plurality", "votes_allowed": 1, - "district": "Orbit City", + "district": "United States of America", "candidates": [ { - "id": "recTKcXLCzRvKB9U0", + "id": "contest-potus--candidate-lepton", "name": [ - "Cosmo Spacely" - ], - "party": { - "name": "The Lepton Party", - "abbreviation": "LEP" - }, - "write_in": false - }, - { - "id": "recKD6dBvkNhEU4bg", - "name": [ - "Spencer Cogswell" + "Anthony Alpha", + "Betty Beta" ], - "party": { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD" - }, - "write_in": false - }, - { - "id": "recqq21kO6HWgpJZV", - "write_in": true - } - ] - }, - { - "id": "recXNb4zPrvC1m6Fr", - "title": "Spaceport Control Board", - "type": "candidate", - "vote_type": "n-of-m", - "votes_allowed": 2, - "district": "Aldrin Space Transport District", - "candidates": [ - { - "id": "recvYvTb9hWH7tptb", - "name": [ - "Jane Jetson" + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP" + } ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "is_write_in": false }, { - "id": "recBnJZEgCKAnfpNo", + "id": "contest-potus--candidate-hadron", "name": [ - "Harlan Ellis" + "Gloria Gamma", + "David Delta" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false - }, - { - "id": "recwNuOnepWNGz67V", - "name": [ - "Rudy Indexer" + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false - }, - { - "id": "rec9Eev970VhohqKi", - "write_in": true + "is_write_in": false }, { - "id": "recFiGYjGCIyk5LBe", - "write_in": true + "id": "contest-potus--candidate-write-in-1", + "name": [], + "party": [], + "is_write_in": true } ] }, { - "id": "recthF6jdx5ybBNkC", + "id": "contest-gadget-county-school-board", "title": "Gadget County School Board", "type": "candidate", "vote_type": "n-of-m", @@ -98,134 +55,205 @@ "district": "Gadget County", "candidates": [ { - "id": "recbxvhKikHJNZYbq", + "id": "contest-gadget-county-school-board--candidate-rosashawn-davis", "name": [ - "Sally Smith" + "Rosashawn Davis" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false }, { - "id": "recigPkqYXXDJEaCE", + "id": "contest-gadget-county-school-board--candidate-hector-gomez", "name": [ "Hector Gomez" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false }, { - "id": "recJvikmG5MrUKzo1", + "id": "contest-gadget-county-school-board--candidate-glavin-orotund", "name": [ - "Rosashawn Davis" + "Glavin Orotund" + ], + "party": [ + {} ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "is_write_in": false }, { - "id": "recvjB3rgfiicf0RP", + "id": "contest-gadget-county-school-board--candidate-sally-smith", "name": [ - "Oliver Tsi" + "Sally Smith" + ], + "party": [ + {} ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "is_write_in": false }, { - "id": "recbN7UUMaSuOYGQ6", + "id": "contest-gadget-county-school-board--candidate-oliver-tsi", "name": [ - "Glavin Orotund" + "Oliver Tsi" + ], + "party": [ + {} ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "is_write_in": false }, { - "id": "recYurH2CLY3SlYS8", - "write_in": true + "id": "contest-gadget-county-school-board--candidate-write-in-1", + "name": [], + "party": [], + "is_write_in": true }, { - "id": "recI5jfcXIsbAKytC", - "write_in": true + "id": "contest-gadget-county-school-board--candidate-write-in-2", + "name": [], + "party": [], + "is_write_in": true }, { - "id": "recn9m0o1em7gLahj", - "write_in": true + "id": "contest-gadget-county-school-board--candidate-write-in-3", + "name": [], + "party": [], + "is_write_in": true } ] }, { - "id": "recsoZy7vYhS3lbcK", - "title": "President of the United States", + "id": "contest-orbit-city-mayor", + "title": "Contest for Mayor of Orbit City", "type": "candidate", "vote_type": "plurality", "votes_allowed": 1, - "district": "United States of America", + "district": "Orbit City", "candidates": [ { - "id": "recPod2L8VhwagiDl", - "write_in": true + "id": "contest-orbit-city-mayor--candidate-spencer-cogswell", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + ], + "is_write_in": false }, { - "id": "recQK3J9IJq42hz2n", + "id": "contest-orbit-city-mayor--candidate-cosmo-spacely", "name": [ - "Anthony Alpha", - "Betty Beta" + "Cosmo Spacely" ], - "party": { - "name": "The Lepton Party", - "abbreviation": "LEP" - }, - "write_in": false + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + ], + "is_write_in": false }, { - "id": "reccUkUdEznfODgeL", + "id": "contest-orbit-city-mayor--candidate-write-in-1", + "name": [], + "party": [], + "is_write_in": true + } + ] + }, + { + "id": "contest-spaceport-control-board", + "title": "Spaceport Control Board", + "type": "candidate", + "vote_type": "n-of-m", + "votes_allowed": 2, + "district": "Aldrin Space Transport District", + "candidates": [ + { + "id": "contest-spaceport-control-board--candidate-harlan-ellis", "name": [ - "Gloria Gamma", - "David Delta" + "Harlan Ellis" ], - "party": { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-spaceport-control-board--candidate-rudy-indexer", + "name": [ + "Rudy Indexer" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-spaceport-control-board--candidate-jane-jetson", + "name": [ + "Jane Jetson" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-spaceport-control-board--candidate-write-in-1", + "name": [], + "party": [], + "is_write_in": true + }, + { + "id": "contest-spaceport-control-board--candidate-write-in-2", + "name": [], + "party": [], + "is_write_in": true } ] } ], "ballot_measure": [ { + "id": "ballot-measure-air-traffic-control-tax", "title": "Air Traffic Control Tax Increase", "type": "ballot measure", "district": "Gadget County", "choices": [ - "Yes", - "No" + { + "id": "ballot-measure-air-traffic-control-tax--yes", + "choice": "Yes" + }, + { + "id": "ballot-measure-air-traffic-control-tax--no", + "choice": "No" + } ], "text": "Shall Gadget County increase its sales tax from 1% to 1.1% for the purpose of raising additional revenue to fund expanded air traffic control operations?" }, { + "id": "ballot-measure-helium-balloons", "title": "Constitutional Amendment", "type": "ballot measure", "district": "The State of Farallon", "choices": [ - "Yes", - "No" + { + "id": "ballot-measure-helium-balloons--yes", + "choice": "Yes" + }, + { + "id": "ballot-measure-helium-balloons--no", + "choice": "No" + } ], - "text": "Do you approve amending the Constitution to legalize the controlled use of helium balloons? Only adults at least 21 years of age could use helium. The State commission created to oversee the State's medical helium program would also oversee the new, personal use helium market.Helium balloons would be subject to the State sales tax. If authorized by the Legislature, a municipality may pass a local ordinance to charge a local tax on helium balloons." + "text": "Do you approve amending the Constitution to legalize the controlled use of helium balloons? Only adults at least 21 years of age could use helium. The State commission created to oversee the State's medical helium program would also oversee the new, personal use helium market.Helium balloons would be subject to the State sales tax. If authorized by the Legislature, a municipality may pass a local ordinance to charge a local tax on helium balloons.\nAll forms of helium are authorized for personal use or commercial balloon usage. Use of deuterium and tritium for energy generation will be licensed by the State commission. Use of deuterium and tritium for uncontrolled fusion reactions is strictly prohibited, and constitutes a Class A felony under State Criminal code section 4.222 K.\n" } ] } -} \ No newline at end of file +} diff --git a/src/electos/ballotmaker/demo_data/spacetown_data.py b/src/electos/ballotmaker/demo_data/spacetown_data.py index 66a4b7e..e28f70b 100644 --- a/src/electos/ballotmaker/demo_data/spacetown_data.py +++ b/src/electos/ballotmaker/demo_data/spacetown_data.py @@ -9,19 +9,21 @@ { "id": "recTKcXLCzRvKB9U0", "name": ["Cosmo Spacely"], - "party": {"name": "The Lepton Party", "abbreviation": "LEP"}, - "write_in": False, + "party": [{"name": "The Lepton Party", "abbreviation": "LEP"}], + "is_write_in": False, }, { "id": "recKD6dBvkNhEU4bg", "name": ["Spencer Cogswell"], - "party": { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD", - }, - "write_in": False, + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + ], + "is_write_in": False, }, - {"id": "recqq21kO6HWgpJZV", "write_in": True}, + {"id": "recqq21kO6HWgpJZV", "is_write_in": True}, ], } @@ -36,23 +38,23 @@ { "id": "recvYvTb9hWH7tptb", "name": ["Jane Jetson"], - "party": {"name": "", "abbreviation": ""}, - "write_in": False, + "party": [{"name": "", "abbreviation": ""}], + "is_write_in": False, }, { "id": "recBnJZEgCKAnfpNo", "name": ["Harlan Ellis"], - "party": {"name": "", "abbreviation": ""}, - "write_in": False, + "party": [{"name": "", "abbreviation": ""}], + "is_write_in": False, }, { "id": "recwNuOnepWNGz67V", "name": ["Rudy Indexer"], - "party": {"name": "", "abbreviation": ""}, - "write_in": False, + "party": [{"name": "", "abbreviation": ""}], + "is_write_in": False, }, - {"id": "rec9Eev970VhohqKi", "write_in": True}, - {"id": "recFiGYjGCIyk5LBe", "write_in": True}, + {"id": "rec9Eev970VhohqKi", "is_write_in": True}, + {"id": "recFiGYjGCIyk5LBe", "is_write_in": True}, ], } @@ -67,36 +69,36 @@ { "id": "recbxvhKikHJNZYbq", "name": ["Sally Smith"], - "party": {"name": "", "abbreviation": ""}, - "write_in": False, + "party": [{"name": "", "abbreviation": ""}], + "is_write_in": False, }, { "id": "recigPkqYXXDJEaCE", "name": ["Hector Gomez"], - "party": {"name": "", "abbreviation": ""}, - "write_in": False, + "party": [{"name": "", "abbreviation": ""}], + "is_write_in": False, }, { "id": "recJvikmG5MrUKzo1", "name": ["Rosashawn Davis"], - "party": {"name": "", "abbreviation": ""}, - "write_in": False, + "party": [{"name": "", "abbreviation": ""}], + "is_write_in": False, }, { "id": "recvjB3rgfiicf0RP", "name": ["Oliver Tsi"], - "party": {"name": "", "abbreviation": ""}, - "write_in": False, + "party": [{"name": "", "abbreviation": ""}], + "is_write_in": False, }, { "id": "recbN7UUMaSuOYGQ6", "name": ["Glavin Orotund"], - "party": {"name": "", "abbreviation": ""}, - "write_in": False, + "party": [{"name": "", "abbreviation": ""}], + "is_write_in": False, }, - {"id": "recYurH2CLY3SlYS8", "write_in": True}, - {"id": "recI5jfcXIsbAKytC", "write_in": True}, - {"id": "recn9m0o1em7gLahj", "write_in": True}, + {"id": "recYurH2CLY3SlYS8", "is_write_in": True}, + {"id": "recI5jfcXIsbAKytC", "is_write_in": True}, + {"id": "recn9m0o1em7gLahj", "is_write_in": True}, ], } @@ -111,19 +113,21 @@ { "id": "recQK3J9IJq42hz2n", "name": ["Anthony Alpha", "Betty Beta"], - "party": {"name": "The Lepton Party", "abbreviation": "LEP"}, - "write_in": False, + "party": [{"name": "The Lepton Party", "abbreviation": "LEP"}], + "is_write_in": False, }, { "id": "reccUkUdEznfODgeL", "name": ["Gloria Gamma", "David Delta"], - "party": { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD", - }, - "write_in": False, + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + ], + "is_write_in": False, }, - {"id": "recPod2L8VhwagiDl", "write_in": True}, + {"id": "recPod2L8VhwagiDl", "is_write_in": True}, ], }