From f38ced99843e7d1e8d1c32951c16b39cd8087d74 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 20 Aug 2022 18:46:03 -0700 Subject: [PATCH 01/79] Script to convert NIST EDF data into BallotLab data. --- src/electos/ballotmaker/scripts/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/electos/ballotmaker/scripts/README.md diff --git a/src/electos/ballotmaker/scripts/README.md b/src/electos/ballotmaker/scripts/README.md new file mode 100644 index 0000000..9dcb046 --- /dev/null +++ b/src/electos/ballotmaker/scripts/README.md @@ -0,0 +1 @@ +Extract data from NIST EDF models for use by BallotLab. From 88aa5bc419a0a5ae29f162eea0bb11ae92123b08 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 20 Aug 2022 20:40:24 -0700 Subject: [PATCH 02/79] First pass at candidate contests. --- .../ballotmaker/scripts/ballot-lab-data.py | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/electos/ballotmaker/scripts/ballot-lab-data.py diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py new file mode 100644 index 0000000..49f80e0 --- /dev/null +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -0,0 +1,118 @@ +from typing import List, Union + +from electos.datamodels.nist.models.edf import * +from electos.datamodels.nist.indexes import ElementIndex + +OrderedContent = Union[OrderedContest, OrderedHeader] + + +# --- Utilities + + +def text_content(item): + """Return joined lines in internationalized text.""" + assert isinstance(item, InternationalizedText) + text = "\n".join(_.content for _ in item.text) + return text + + +def walk_ordered_content(content: List[OrderedContent]): + for item in content: + if isinstance(item, OrderedContest): + yield item + elif isinstance(item, OrderedHeader): + yield item + yield from walk_ordered_content(item.ordered_content) + else: + raise TypeError(f"Unexpected type: {type(item).__name__}") + + +# --- Ballot Properties + + +def all_ballot_styles(election_report: ElectionReport, index): + for ballot_style in index.by_type("BallotStyle"): + yield ballot_style + + +def ballot_style_name(ballot_style: BallotStyle): + assert len (ballot_style.external_identifier) == 1, \ + "Not ready to handle multiple BallotStyle external IDs" + name = ballot_style.external_identifier[0].value + return name + + +def ballot_style_gp_units(ballot_style: BallotStyle, index): + for id_ in ballot_style.gp_unit_ids: + gp_unit = index.by_id(id_) + yield gp_unit + + +def ballot_style_contests(ballot_style: BallotStyle, index): + for item in walk_ordered_content(ballot_style.ordered_content): + contest = index.by_id(item.contest_id) + yield contest + + +def ballot_style_candidate_contests(ballot_style: BallotStyle, index): + for contest in ballot_style_contests(ballot_style, index): + if not isinstance(contest, CandidateContest): + continue + candidates = [] + for selection in contest.contest_selection: + assert isinstance(selection, CandidateSelection), \ + "Unexpected non-candidate selection: {type(selection).__name__}" + # Ignore write-ins for tier 1 + if selection.is_write_in: + continue + for id_ in selection.candidate_ids: + candidate = index.by_id(id_) + candidates.append(candidate) + yield contest, candidates + + +def candidate_name(candidate: Candidate): + name = text_content(candidate.ballot_name) + return name + + +# --- Main + +import json +import sys +from pathlib import Path + + +def report(root, index): + ballot_styles = list(all_ballot_styles(root, index)) + # Only look at the first index + ballot_style = ballot_styles[0] + name = ballot_style_name(ballot_style) + print("name:", name) + gp_units = ballot_style_gp_units(ballot_style, index) + print("gp units:") + for item in gp_units: + print(f"- {text_content(item.name)}") + print("contests:") + contests = ballot_style_candidate_contests(ballot_style, index) + for contest, candidates in contests: + print(f"- name: {contest.name}") + print(f" type: {contest.vote_variation.value}") + print(f" votes: {contest.votes_allowed}") + print(f" candidates:") + for candidate in candidates: + print(f" - {candidate_name(candidate)}") + + +def main(): + file = Path(sys.argv[1]) + with file.open() as input: + text = input.read() + data = json.loads(text) + edf = ElectionReport(**data) + index = ElementIndex(edf, "ElectionResults") + report(edf, index) + + +if __name__ == '__main__': + main() From 41e188cb75879bb996f1c06c29898029fd94f981 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 12:01:13 -0700 Subject: [PATCH 03/79] Ordered content includes headers; use argparse. --- .../ballotmaker/scripts/ballot-lab-data.py | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 49f80e0..e725ffe 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -16,13 +16,23 @@ def text_content(item): return text -def walk_ordered_content(content: List[OrderedContent]): +def walk_ordered_contests(content: List[OrderedContent]): + """Walk ordered content yielding contests.""" for item in content: if isinstance(item, OrderedContest): yield item elif isinstance(item, OrderedHeader): + yield from walk_ordered_contests(item.ordered_content) + else: + raise TypeError(f"Unexpected type: {type(item).__name__}") + + +def walk_ordered_headers(content: List[OrderedContent]): + """Walk ordered content yielding headers.""" + for item in content: + if isinstance(item, OrderedHeader): yield item - yield from walk_ordered_content(item.ordered_content) + yield from walk_ordered_headers(item.ordered_content) else: raise TypeError(f"Unexpected type: {type(item).__name__}") @@ -36,9 +46,12 @@ def all_ballot_styles(election_report: ElectionReport, index): def ballot_style_name(ballot_style: BallotStyle): - assert len (ballot_style.external_identifier) == 1, \ - "Not ready to handle multiple BallotStyle external IDs" - name = ballot_style.external_identifier[0].value + if ballot_style.external_identifier: + assert len(ballot_style.external_identifier) == 1, \ + "Not ready to handle multiple BallotStyle external IDs" + name = ballot_style.external_identifier[0].value + else: + name = "" return name @@ -49,7 +62,7 @@ def ballot_style_gp_units(ballot_style: BallotStyle, index): def ballot_style_contests(ballot_style: BallotStyle, index): - for item in walk_ordered_content(ballot_style.ordered_content): + for item in walk_ordered_contests(ballot_style.ordered_content): contest = index.by_id(item.contest_id) yield contest @@ -78,8 +91,8 @@ def candidate_name(candidate: Candidate): # --- Main +import argparse import json -import sys from pathlib import Path @@ -105,7 +118,17 @@ def report(root, index): def main(): - file = Path(sys.argv[1]) + parser = argparse.ArgumentParser() + parser.add_argument( + "file", type = Path, + help = "Test case data (JSON)" + ) + parser.add_argument( + "nth", nargs = "?", type = int, default = 1, + help = "Index of the ballot style, starting from 1 (default: 1)" + ) + opts = parser.parse_args() + file = opts.file with file.open() as input: text = input.read() data = json.loads(text) From 25ff3cf685c33f8a1f8edf09a1dd710b70f29355 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 13:30:33 -0700 Subject: [PATCH 04/79] Flags for keeping write-ins, n-of-m contests. --- .../ballotmaker/scripts/ballot-lab-data.py | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index e725ffe..6cecf6f 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -67,16 +67,21 @@ def ballot_style_contests(ballot_style: BallotStyle, index): yield contest -def ballot_style_candidate_contests(ballot_style: BallotStyle, index): +def ballot_style_candidate_contests( + ballot_style: BallotStyle, index, keep_write_ins, keep_n_of_m, **opts +): for contest in ballot_style_contests(ballot_style, index): if not isinstance(contest, CandidateContest): continue + # Ignore N-of-M by default + if not keep_n_of_m and contest.vote_variation == VoteVariation.N_OF_M: + continue candidates = [] for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ "Unexpected non-candidate selection: {type(selection).__name__}" - # Ignore write-ins for tier 1 - if selection.is_write_in: + # Ignore write-ins by default + if not keep_write_ins and selection.is_write_in: continue for id_ in selection.candidate_ids: candidate = index.by_id(id_) @@ -96,10 +101,14 @@ def candidate_name(candidate: Candidate): from pathlib import Path -def report(root, index): +def report(root, index, nth, **opts): + """Generate data needed by BallotLab""" ballot_styles = list(all_ballot_styles(root, index)) - # Only look at the first index - ballot_style = ballot_styles[0] + if not (1 <= nth <= len(ballot_styles)): + print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") + return + nth -= 1 + ballot_style = ballot_styles[nth] name = ballot_style_name(ballot_style) print("name:", name) gp_units = ballot_style_gp_units(ballot_style, index) @@ -107,11 +116,11 @@ def report(root, index): for item in gp_units: print(f"- {text_content(item.name)}") print("contests:") - contests = ballot_style_candidate_contests(ballot_style, index) + contests = ballot_style_candidate_contests(ballot_style, index, **opts) for contest, candidates in contests: print(f"- name: {contest.name}") - print(f" type: {contest.vote_variation.value}") - print(f" votes: {contest.votes_allowed}") + print(f" vote type: {contest.vote_variation.value}") + print(f" votes allowed: {contest.votes_allowed}") print(f" candidates:") for candidate in candidates: print(f" - {candidate_name(candidate)}") @@ -127,14 +136,24 @@ def main(): "nth", nargs = "?", type = int, default = 1, help = "Index of the ballot style, starting from 1 (default: 1)" ) + parser.add_argument( + "--keep-write-ins", action = "store_true", + help = "Process write in candidates", + ) + parser.add_argument( + "--keep-n-of-m", action = "store_true", + help = "Process N-of-M contests", + ) opts = parser.parse_args() file = opts.file + opts = vars(opts) + with file.open() as input: text = input.read() data = json.loads(text) edf = ElectionReport(**data) index = ElementIndex(edf, "ElectionResults") - report(edf, index) + report(edf, index, **opts) if __name__ == '__main__': From 584bb410b3c32a5338001112940f312c5c2f6641 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 15:48:56 -0700 Subject: [PATCH 05/79] Drop 'keep' flags, add '--debug' for exceptions, more doc comments. --- .../ballotmaker/scripts/ballot-lab-data.py | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 6cecf6f..5e1d3ac 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -10,7 +10,7 @@ def text_content(item): - """Return joined lines in internationalized text.""" + """Return joined lines from internationalized text.""" assert isinstance(item, InternationalizedText) text = "\n".join(_.content for _ in item.text) return text @@ -41,6 +41,7 @@ def walk_ordered_headers(content: List[OrderedContent]): def all_ballot_styles(election_report: ElectionReport, index): + """Yield all ballot styles.""" for ballot_style in index.by_type("BallotStyle"): yield ballot_style @@ -62,26 +63,25 @@ def ballot_style_gp_units(ballot_style: BallotStyle, index): def ballot_style_contests(ballot_style: BallotStyle, index): + """Yield the contests of a ballot style.""" for item in walk_ordered_contests(ballot_style.ordered_content): contest = index.by_id(item.contest_id) yield contest -def ballot_style_candidate_contests( - ballot_style: BallotStyle, index, keep_write_ins, keep_n_of_m, **opts -): +def ballot_style_candidate_contests(ballot_style: BallotStyle, index, **opts): for contest in ballot_style_contests(ballot_style, index): if not isinstance(contest, CandidateContest): continue # Ignore N-of-M by default - if not keep_n_of_m and contest.vote_variation == VoteVariation.N_OF_M: + if contest.vote_variation == VoteVariation.N_OF_M: continue candidates = [] for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ "Unexpected non-candidate selection: {type(selection).__name__}" # Ignore write-ins by default - if not keep_write_ins and selection.is_write_in: + if selection.is_write_in: continue for id_ in selection.candidate_ids: candidate = index.by_id(id_) @@ -137,23 +137,24 @@ def main(): help = "Index of the ballot style, starting from 1 (default: 1)" ) parser.add_argument( - "--keep-write-ins", action = "store_true", - help = "Process write in candidates", - ) - parser.add_argument( - "--keep-n-of-m", action = "store_true", - help = "Process N-of-M contests", + "--debug", action = "store_true", + help = "Enable debugging output and stack traces" ) opts = parser.parse_args() file = opts.file opts = vars(opts) - with file.open() as input: - text = input.read() - data = json.loads(text) - edf = ElectionReport(**data) - index = ElementIndex(edf, "ElectionResults") - report(edf, index, **opts) + try: + with file.open() as input: + text = input.read() + data = json.loads(text) + edf = ElectionReport(**data) + index = ElementIndex(edf, "ElectionResults") + report(edf, index, **opts) + except Exception as ex: + if opts["debug"]: + raise ex + print("error:", ex) if __name__ == '__main__': From 43eb184d1de02e1007fe1157bd8bc9deb5fe3d49 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 16:03:35 -0700 Subject: [PATCH 06/79] Significant refactoring of candidate contests. BallotStyles don't drive the contests. --- .../ballotmaker/scripts/ballot-lab-data.py | 109 ++++++++++++------ 1 file changed, 71 insertions(+), 38 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 5e1d3ac..3c5eb3a 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -3,6 +3,13 @@ from electos.datamodels.nist.models.edf import * from electos.datamodels.nist.indexes import ElementIndex + +# --- Base Types +# +# Schema expresses these as union types not subclasses + +Contest = Union[BallotMeasureContest, CandidateContest] + OrderedContent = Union[OrderedContest, OrderedHeader] @@ -46,7 +53,8 @@ def all_ballot_styles(election_report: ElectionReport, index): yield ballot_style -def ballot_style_name(ballot_style: BallotStyle): +def ballot_style_id(ballot_style: BallotStyle): + """Get the text of a ballot style's external identifier if any.""" if ballot_style.external_identifier: assert len(ballot_style.external_identifier) == 1, \ "Not ready to handle multiple BallotStyle external IDs" @@ -69,31 +77,64 @@ def ballot_style_contests(ballot_style: BallotStyle, index): yield contest -def ballot_style_candidate_contests(ballot_style: BallotStyle, index, **opts): - for contest in ballot_style_contests(ballot_style, index): - if not isinstance(contest, CandidateContest): - continue - # Ignore N-of-M by default - if contest.vote_variation == VoteVariation.N_OF_M: - continue - candidates = [] - for selection in contest.contest_selection: - assert isinstance(selection, CandidateSelection), \ - "Unexpected non-candidate selection: {type(selection).__name__}" - # Ignore write-ins by default - if selection.is_write_in: - continue - for id_ in selection.candidate_ids: - candidate = index.by_id(id_) - candidates.append(candidate) - yield contest, candidates - - def candidate_name(candidate: Candidate): + """Get the name of a candidate as it appears on a ballot.""" name = text_content(candidate.ballot_name) return name +def contest_election_district(contest: Contest, index): + """Get the district name of a contest.""" + district = index.by_id(contest.election_district_id) + district = text_content(district.name) + return district + + +# Gather & Extract +# +# Results are data needed for ballot generation. + +def extract_candidate_contest(contest: CandidateContest, index): + """Extract candidate contest information needed for ballots.""" + district = contest_election_district(contest, index) + candidates = [] + write_ins = 0 + 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 += 1 + result = { + "title": contest.name, + "type": "candidate", + "vote_type": contest.vote_variation.value, + "district": district, + "candidates": [candidate_name(_) for _ in candidates], + "write_ins": write_ins, + } + return result + + +def gather_contests(ballot_style: BallotStyle, index): + """Extract all contest information needed for ballots.""" + contests = { + kind: [] for kind in ("candidate", "ballot_measure") + } + for contest in ballot_style_contests(ballot_style, index): + if isinstance(contest, CandidateContest): + entry = extract_candidate_contest(contest, index) + contests["candidate"].append(entry) + else: + # Ignore other contest types + print(f"Skipping contest of type {contest.model__type}") + return contests + + # --- Main import argparse @@ -107,23 +148,15 @@ def report(root, index, nth, **opts): if not (1 <= nth <= len(ballot_styles)): print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") return - nth -= 1 - ballot_style = ballot_styles[nth] - name = ballot_style_name(ballot_style) - print("name:", name) - gp_units = ballot_style_gp_units(ballot_style, index) - print("gp units:") - for item in gp_units: - print(f"- {text_content(item.name)}") - print("contests:") - contests = ballot_style_candidate_contests(ballot_style, index, **opts) - for contest, candidates in contests: - print(f"- name: {contest.name}") - print(f" vote type: {contest.vote_variation.value}") - print(f" votes allowed: {contest.votes_allowed}") - print(f" candidates:") - for candidate in candidates: - print(f" - {candidate_name(candidate)}") + ballot_style = ballot_styles[nth - 1] + data = {} + id_ = ballot_style_id(ballot_style) + data["ballot_style"] = id_ + contests = gather_contests(ballot_style, index) + if not contests: + print(f"No contests found for ballot style: {id_}\n") + data["contests"] = contests + print(json.dumps(data, indent = 4)) def main(): From 24b921f6920d7686ca7bf69c3a696ee439ac782d Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 16:18:58 -0700 Subject: [PATCH 07/79] Add offices and parties to candidate contests. --- .../ballotmaker/scripts/ballot-lab-data.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 3c5eb3a..f5d861a 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -83,6 +83,28 @@ def candidate_name(candidate: Candidate): return name +def candidate_contest_offices(contest: CandidateContest, index): + """Get any offices associated with a candidate contest.""" + offices = [] + if contest.office_ids: + for id_ in contest.office_ids: + office = index.by_id(id_) + name = text_content(office.name) + offices.append(name) + return offices + + +def candidate_contest_parties(contest: CandidateContest, index): + """Get any parties associated with a candidate contest.""" + parties = [] + if contest.primary_party_ids: + for id_ in contest.primary_party_ids: + party = index.by_id(id_) + name = text_content(party.name) + parties.append(name) + return parties + + def contest_election_district(contest: Contest, index): """Get the district name of a contest.""" district = index.by_id(contest.election_district_id) @@ -98,6 +120,8 @@ def extract_candidate_contest(contest: CandidateContest, index): """Extract candidate contest information needed for ballots.""" district = contest_election_district(contest, index) candidates = [] + offices = candidate_contest_offices(contest, index) + parties = candidate_contest_parties(contest, index) write_ins = 0 for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ @@ -115,6 +139,8 @@ def extract_candidate_contest(contest: CandidateContest, index): "vote_type": contest.vote_variation.value, "district": district, "candidates": [candidate_name(_) for _ in candidates], + "offices": offices, + "parties": parties, "write_ins": write_ins, } return result From 9f0ab5520aa775b299219ad3ee12cc5289ae01ab Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 16:45:00 -0700 Subject: [PATCH 08/79] Add ballot measure contests. --- .../ballotmaker/scripts/ballot-lab-data.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index f5d861a..c787ef5 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -146,6 +146,24 @@ def extract_candidate_contest(contest: CandidateContest, index): return result +def extract_ballot_measure_contest(contest: BallotMeasureContest, index): + """Extract ballot measure contest information needed for ballots.""" + choices = [] + for selection in contest.contest_selection: + assert isinstance(selection, BallotMeasureSelection), \ + f"Unexpected non-ballot measure selection: {type(selection).__name__}" + choice = text_content(selection.selection) + choices.append(choice) + district = contest_election_district(contest, index) + result = { + "title": contest.name, + "type": "ballot measure", + "district": district, + "choices": choices, + } + return result + + def gather_contests(ballot_style: BallotStyle, index): """Extract all contest information needed for ballots.""" contests = { @@ -155,6 +173,9 @@ def gather_contests(ballot_style: BallotStyle, index): if isinstance(contest, CandidateContest): entry = extract_candidate_contest(contest, index) contests["candidate"].append(entry) + elif isinstance(contest, BallotMeasureContest): + entry = extract_ballot_measure_contest(contest, index) + contests["ballot_measure"].append(entry) else: # Ignore other contest types print(f"Skipping contest of type {contest.model__type}") From f1fd991d1d22df5e221049a5f636d96502bd0390 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 16:55:09 -0700 Subject: [PATCH 09/79] Add ballot text; don't return offices or parties for candidates. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index c787ef5..22f74c7 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -139,8 +139,9 @@ def extract_candidate_contest(contest: CandidateContest, index): "vote_type": contest.vote_variation.value, "district": district, "candidates": [candidate_name(_) for _ in candidates], - "offices": offices, - "parties": parties, + # Leave out offices and parties for now + # "offices": offices, + # "parties": parties, "write_ins": write_ins, } return result @@ -155,11 +156,13 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): choice = text_content(selection.selection) choices.append(choice) district = contest_election_district(contest, index) + full_text = text_content(contest.full_text) result = { "title": contest.name, "type": "ballot measure", "district": district, "choices": choices, + "text": full_text, } return result From 309db0eca33f4a25d10fabe83d39f1b4a36b06c4 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 17:17:24 -0700 Subject: [PATCH 10/79] Add README for ballot-lab-data script. --- src/electos/ballotmaker/scripts/README.md | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/electos/ballotmaker/scripts/README.md b/src/electos/ballotmaker/scripts/README.md index 9dcb046..d30d018 100644 --- a/src/electos/ballotmaker/scripts/README.md +++ b/src/electos/ballotmaker/scripts/README.md @@ -1 +1,43 @@ Extract data from NIST EDF models for use by BallotLab. + +## Requirements + +- The script requires `nist-datamodels`, using a version that has element indexes. + +## Inputs + +Filenames are of the format `{test-case-source}_{ballot-style-id}.json`. +Note the use of `-` to separate words, and `_` to separate the name parts. + +Inputs are EDF JSON files. Get them from: + +- https://github.com/TrustTheVote-Project/NIST-1500-100-103-examples/blob/main/test_cases/ + +The script is `ballot-lab-data.py`. It takes an EDF test case, and the index of +the ballot style to use (a number from 1 to N, that defaults to 1.). + +To run it: + +- Install `nist-datamodels`, using a version that has element indexes. +- Run: + + python ballot-lab-data.py [] + + e.g. + + python ballot-lab-data.py june_test_case.json 1 + +## Outputs + +- Output is JSON files with contests, grouped by contest type. +- The `VotingVariation` in the EDF is `vote_type` here. +- Write-ins don't affect the candidate list. They are returned as a count. + Presumably they would all be the same and all that's needed is their number. + They can be ignored.` +- The fields were selected to match what is needed for `plurality` candidate + contests and a little extra. We can add other `VoteVariation`s and ballot + measure contests as needed. + +## Complications + +- There are no `Header`s or `OrderedHeader`s in the test cases. From f5090b8e0cd60ab4082c37e3edd4aeb7f2b56ac9 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 18:10:40 -0700 Subject: [PATCH 11/79] Add party names to candidates. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 22f74c7..28648be 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -83,6 +83,13 @@ def candidate_name(candidate: Candidate): return name +def candidate_party(candidate: Candidate, index): + """Get the name 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 "" + return name + + def candidate_contest_offices(contest: CandidateContest, index): """Get any offices associated with a candidate contest.""" offices = [] @@ -138,7 +145,10 @@ def extract_candidate_contest(contest: CandidateContest, index): "type": "candidate", "vote_type": contest.vote_variation.value, "district": district, - "candidates": [candidate_name(_) for _ in candidates], + "candidates": [ + { "name": candidate_name(_), "party": candidate_party(_, index) } + for _ in candidates + ], # Leave out offices and parties for now # "offices": offices, # "parties": parties, From 55b5b384498739f97b1e44af8b3750bc08a96559 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 23 Aug 2022 16:28:58 -0700 Subject: [PATCH 12/79] Add votes allowed on candidates races (even if it's the default). Fixes BallotLab #82. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 28648be..3db154c 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -144,6 +144,8 @@ def extract_candidate_contest(contest: CandidateContest, index): "title": contest.name, "type": "candidate", "vote_type": contest.vote_variation.value, + # 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) } From a262592b06c874e08ddc0df316fd784443277a0d Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 23 Aug 2022 16:31:22 -0700 Subject: [PATCH 13/79] Add party abbreviations to candidates. Fixes BallotLab #83. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 3db154c..a3cf618 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -84,10 +84,15 @@ def candidate_name(candidate: Candidate): def candidate_party(candidate: Candidate, index): - """Get the name of the party of a candidate as it appears on a ballot.""" + """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 "" - return name + abbreviation = text_content(party.abbreviation) if party and party.abbreviation else "" + result = { + "name": name, + "abbreviation": abbreviation, + } + return result def candidate_contest_offices(contest: CandidateContest, index): From 4a2731604891fc44d7a8e6adbb2819eb9bbc9e34 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 23 Aug 2022 16:36:06 -0700 Subject: [PATCH 14/79] Include '@id's with contests. Fixes BallotLab #86. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index a3cf618..b1ea29d 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -146,6 +146,7 @@ def extract_candidate_contest(contest: CandidateContest, index): if selection.is_write_in: write_ins += 1 result = { + "id": contest.model__id, "title": contest.name, "type": "candidate", "vote_type": contest.vote_variation.value, From 15efb15ccb9011b7602dbc01c25507c8b0612013 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 23 Aug 2022 16:48:19 -0700 Subject: [PATCH 15/79] Use 'ContestSelectionId's instead of a count for write-ins. Fixes BallotLab #84. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index b1ea29d..1647203 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -134,7 +134,7 @@ def extract_candidate_contest(contest: CandidateContest, index): candidates = [] offices = candidate_contest_offices(contest, index) parties = candidate_contest_parties(contest, index) - write_ins = 0 + write_ins = [] for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ f"Unexpected non-candidate selection: {type(selection).__name__}" @@ -144,7 +144,7 @@ def extract_candidate_contest(contest: CandidateContest, index): candidate = index.by_id(id_) candidates.append(candidate) if selection.is_write_in: - write_ins += 1 + write_ins.append(selection.model__id) result = { "id": contest.model__id, "title": contest.name, From 4355de7e51f87b28b57979e63141269e2c531eaf Mon Sep 17 00:00:00 2001 From: Ion Y Date: Wed, 24 Aug 2022 13:58:36 -0700 Subject: [PATCH 16/79] Add selection IDs to all candidates, not just write-ins. Updates #84. --- .../ballotmaker/scripts/ballot-lab-data.py | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 1647203..cf667fc 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -95,6 +95,27 @@ def candidate_party(candidate: Candidate, index): return result +def candidate_contest_candidates(contest: CandidateContest, index): + candidates = [] + 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_) + item = { + "id": selection.model__id, + "name": candidate_name(candidate), + "party": candidate_party(candidate, index), + } + candidates.append(item) + if selection.is_write_in: + write_ins.append(selection.model__id) + return candidates, write_ins + + def candidate_contest_offices(contest: CandidateContest, index): """Get any offices associated with a candidate contest.""" offices = [] @@ -131,20 +152,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 = [] 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) + candidates, write_ins = candidate_contest_candidates(contest, index) result = { "id": contest.model__id, "title": contest.name, @@ -153,11 +163,7 @@ 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, From 26deeaffb73cf137ef6fbce4bdaa576f6a92289b Mon Sep 17 00:00:00 2001 From: Ion Y Date: Wed, 24 Aug 2022 15:20:54 -0700 Subject: [PATCH 17/79] Properly set up multi-candidate slates/tickets. Fixes BallotLab #85. This isn't quite right: fails if a slate has candidates from different parties. Deferring on that case for now. --- .../ballotmaker/scripts/ballot-lab-data.py | 53 ++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index cf667fc..cf8ea3f 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -1,3 +1,4 @@ +from itertools import groupby from typing import List, Union from electos.datamodels.nist.models.edf import * @@ -96,23 +97,49 @@ def candidate_party(candidate: Candidate, index): def candidate_contest_candidates(contest: CandidateContest, index): - candidates = [] - write_ins = [] + """Get candidates for contest, grouped by slate/ticket. + + A slate will collect candidate names together, but have a single ID for + the contest selection, and a single party. + + Todo: + Handle the case where candidates on a slate don't share a party. + There's no clear guarantee of a 1:1 relationship between slates and parties. + """ + candidates_solo = [] + # Collect individual candidates 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_) - item = { - "id": selection.model__id, - "name": candidate_name(candidate), - "party": candidate_party(candidate, index), - } - candidates.append(item) - if selection.is_write_in: - write_ins.append(selection.model__id) + if not selection.candidate_ids: + continue + for id_ in selection.candidate_ids: + candidate = index.by_id(id_) + candidate = { + "id": selection.model__id, + "name": candidate_name(candidate), + "party": candidate_party(candidate, index), + } + candidates_solo.append(candidate) + # Group candidates by slate. + # + # Candidates on the same slate will share the 'ContestSelection' ID + # Don't try to collect by party. + candidates_by_slate = [] + for _, slate in groupby(candidates_solo, lambda _: _["id"]): + slate = list(slate) + # Bail out if candidates on a slate don't share a party + assert len({_["party"]["name"] for _ in slate}) == 1, \ + f"Candidates in '{slate[0]['party']['name']}' slate don't all share the same party" + candidate = { + "id": slate[0]["id"], + "name": [candidate["name"] for candidate in slate], + "party": slate[0]["party"], + } + candidates_by_slate.append(candidate) + candidates = candidates_by_slate + write_ins = [_.model__id for _ in contest.contest_selection if _.is_write_in] return candidates, write_ins From fb03e3feda903b91555a5a03b1d72c02f49e8e72 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Wed, 24 Aug 2022 16:16:41 -0700 Subject: [PATCH 18/79] Merge write-ins into candidte contests. Mark write-in status as boolean (matching the contents of the EDF). No issue attached. --- .../ballotmaker/scripts/ballot-lab-data.py | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index cf8ea3f..df20475 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -106,22 +106,20 @@ def candidate_contest_candidates(contest: CandidateContest, index): Handle the case where candidates on a slate don't share a party. There's no clear guarantee of a 1:1 relationship between slates and parties. """ - candidates_solo = [] # Collect individual candidates + candidates_solo = [] 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 not selection.candidate_ids: - continue - for id_ in selection.candidate_ids: - candidate = index.by_id(id_) - candidate = { - "id": selection.model__id, - "name": candidate_name(candidate), - "party": candidate_party(candidate, index), - } - candidates_solo.append(candidate) + result = {} + result["id"] = selection.model__id + if selection.candidate_ids: + for id_ in selection.candidate_ids: + candidate = index.by_id(id_) + result["name"] = candidate_name(candidate) + result["party"] = candidate_party(candidate, index) + result["is_write_in"] = bool(selection.is_write_in) + candidates_solo.append(result) # Group candidates by slate. # # Candidates on the same slate will share the 'ContestSelection' ID @@ -129,18 +127,19 @@ def candidate_contest_candidates(contest: CandidateContest, index): candidates_by_slate = [] for _, slate in groupby(candidates_solo, lambda _: _["id"]): slate = list(slate) - # Bail out if candidates on a slate don't share a party - assert len({_["party"]["name"] for _ in slate}) == 1, \ - f"Candidates in '{slate[0]['party']['name']}' slate don't all share the same party" candidate = { "id": slate[0]["id"], - "name": [candidate["name"] for candidate in slate], - "party": slate[0]["party"], } + if "party" in slate[0]: + assert len({_["party"]["name"] for _ in slate}) == 1, \ + f"Candidates in '{slate[0]['party']['name']}' slate don't all share the same party" + candidate["name"] = [_["name"] for _ in slate] + candidate["party"] = slate[0]["party"] + candidate["write_in"] = slate[0]["is_write_in"] candidates_by_slate.append(candidate) candidates = candidates_by_slate write_ins = [_.model__id for _ in contest.contest_selection if _.is_write_in] - return candidates, write_ins + return candidates def candidate_contest_offices(contest: CandidateContest, index): @@ -181,7 +180,7 @@ def extract_candidate_contest(contest: CandidateContest, index): district = contest_election_district(contest, index) offices = candidate_contest_offices(contest, index) parties = candidate_contest_parties(contest, index) - candidates, write_ins = candidate_contest_candidates(contest, index) + candidates = candidate_contest_candidates(contest, index) result = { "id": contest.model__id, "title": contest.name, @@ -193,7 +192,6 @@ def extract_candidate_contest(contest: CandidateContest, index): "candidates": candidates, # "offices": offices, # "parties": parties, - "write_ins": write_ins, } return result From 1547160a9ec71a34a465deb6fca05480faaec1a3 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 27 Aug 2022 13:23:11 -0700 Subject: [PATCH 19/79] Multi-candidate slates: Fix dropping all candidates before the last one. The error was introduced in the process of folding write-ins into contests. - Remove convoluted extra step that makes it harder to do this correctly. - Candidate names are grouped in arrays. Now do the same for parties. - Use 'is_write_in' for the write-in boolean field. --- .../ballotmaker/scripts/ballot-lab-data.py | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index df20475..64fda85 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -99,46 +99,41 @@ def candidate_party(candidate: Candidate, index): def candidate_contest_candidates(contest: CandidateContest, index): """Get candidates for contest, grouped by slate/ticket. - A slate will collect candidate names together, but have a single ID for - the contest selection, and a single party. + A slate has: + + - A single ID for the contest selection + - Collects candidate names into an array. + - Collects candidate parties into an array. + + Notes: + - There's no clear guarantee of a 1:1 relationship between slates and parties. Todo: - Handle the case where candidates on a slate don't share a party. - There's no clear guarantee of a 1:1 relationship between slates and parties. + - Allow combining shared parties into a single entry. """ # Collect individual candidates - candidates_solo = [] + candidates = [] for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ f"Unexpected non-candidate selection: {type(selection).__name__}" - result = {} - result["id"] = selection.model__id + names = [] + parties = [] if selection.candidate_ids: for id_ in selection.candidate_ids: candidate = index.by_id(id_) - result["name"] = candidate_name(candidate) - result["party"] = candidate_party(candidate, index) - result["is_write_in"] = bool(selection.is_write_in) - candidates_solo.append(result) - # Group candidates by slate. - # - # Candidates on the same slate will share the 'ContestSelection' ID - # Don't try to collect by party. - candidates_by_slate = [] - for _, slate in groupby(candidates_solo, lambda _: _["id"]): - slate = list(slate) - candidate = { - "id": slate[0]["id"], + name = candidate_name(candidate) + if name: + names.append(name) + party = candidate_party(candidate, index) + if party: + parties.append(party) + result = { + "id": selection.model__id, + "name": names, + "party": parties, + "is_write_in": bool(selection.is_write_in) } - if "party" in slate[0]: - assert len({_["party"]["name"] for _ in slate}) == 1, \ - f"Candidates in '{slate[0]['party']['name']}' slate don't all share the same party" - candidate["name"] = [_["name"] for _ in slate] - candidate["party"] = slate[0]["party"] - candidate["write_in"] = slate[0]["is_write_in"] - candidates_by_slate.append(candidate) - candidates = candidates_by_slate - write_ins = [_.model__id for _ in contest.contest_selection if _.is_write_in] + candidates.append(result) return candidates From 78f933aa6ff66be5efb21c8a6b7e872b9c7c95ba Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 27 Aug 2022 13:45:43 -0700 Subject: [PATCH 20/79] Multi-candidate slates: Use party IDs to collapse identical parties. Handles case where candidates in a slate have different parties. --- .../ballotmaker/scripts/ballot-lab-data.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 64fda85..5ef0bd4 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -86,14 +86,16 @@ 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) + # 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 "" abbreviation = text_content(party.abbreviation) if party and party.abbreviation else "" result = { "name": name, "abbreviation": abbreviation, } - return result + return result, id_ def candidate_contest_candidates(contest: CandidateContest, index): @@ -104,12 +106,11 @@ def candidate_contest_candidates(contest: CandidateContest, index): - 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. Notes: - There's no clear guarantee of a 1:1 relationship between slates and parties. - - Todo: - - Allow combining shared parties into a single entry. """ # Collect individual candidates candidates = [] @@ -118,15 +119,17 @@ def candidate_contest_candidates(contest: CandidateContest, index): 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 = candidate_party(candidate, index) - if party: + party, _party_id = candidate_party(candidate, index) + if party and _party_id not in _party_ids: parties.append(party) + _party_ids.add(_party_id) result = { "id": selection.model__id, "name": names, From e65864686426cc66e04723c513b336ff8c749f56 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 27 Aug 2022 13:50:31 -0700 Subject: [PATCH 21/79] Multi-candidate slates: Only collapse party IDs if ALL parties are identical. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 5ef0bd4..29aad1e 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -108,6 +108,7 @@ def candidate_contest_candidates(contest: CandidateContest, index): - 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. @@ -127,9 +128,12 @@ def candidate_contest_candidates(contest: CandidateContest, index): if name: names.append(name) party, _party_id = candidate_party(candidate, index) - if party and _party_id not in _party_ids: - parties.append(party) - _party_ids.add(_party_id) + 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, From d48ddf7f60da822b213695ef7b8ca7c9b6931cd3 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 27 Aug 2022 13:52:59 -0700 Subject: [PATCH 22/79] Drop null fields of parties; use an empty dictionary if a party is missing. --- .../ballotmaker/scripts/ballot-lab-data.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 29aad1e..a7306e1 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -85,16 +85,20 @@ 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.""" + """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 "" - abbreviation = text_content(party.abbreviation) if party and party.abbreviation else "" - result = { - "name": name, - "abbreviation": abbreviation, - } + 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_ From 4d1bfb4188954172848c7ced94e1e9bdc97d7e84 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 27 Aug 2022 15:17:08 -0700 Subject: [PATCH 23/79] Add IDs to ballot measures contests. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index a7306e1..0ddb5d5 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -213,6 +213,7 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): 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, From 8fd93373d252b55ece4fcaf495144e6b72945889 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 29 Aug 2022 16:48:37 -0700 Subject: [PATCH 24/79] Add IDs to ballot measure contest selections. Fixes BallotLab #86. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 0ddb5d5..a1e1718 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -209,7 +209,10 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): assert isinstance(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 = { From 6a88c509d40aa6c21d52531b3dd9ac93c6f29836 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 30 Aug 2022 16:45:08 -0700 Subject: [PATCH 25/79] Update README for 'ballot-lab-data' script. --- src/electos/ballotmaker/scripts/README.md | 49 ++++++++++++----------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/src/electos/ballotmaker/scripts/README.md b/src/electos/ballotmaker/scripts/README.md index d30d018..f840ce5 100644 --- a/src/electos/ballotmaker/scripts/README.md +++ b/src/electos/ballotmaker/scripts/README.md @@ -1,43 +1,44 @@ -Extract data from NIST EDF models for use by BallotLab. +Data to use for BallotLab inputs. The data is extracted from EDF test cases +and constrained to the data model Ballot Lab is using. -## Requirements +Output file naming format is `{test-case-source}_{ballot-style-id}.json`. +Note the use of `-` to separate words, and `_` to separate the name parts. -- The script requires `nist-datamodels`, using a version that has element indexes. +All the current examples are taken from these EDF files: -## Inputs +- https://github.com/TrustTheVote-Project/NIST-1500-100-103-examples/blob/main/test_cases/june_test_case.json -Filenames are of the format `{test-case-source}_{ballot-style-id}.json`. -Note the use of `-` to separate words, and `_` to separate the name parts. +To run it: -Inputs are EDF JSON files. Get them from: +- Install the BallotLab fork and change to the 'edf-data-to-ballot' branch. -- https://github.com/TrustTheVote-Project/NIST-1500-100-103-examples/blob/main/test_cases/ + git clone https://github.com/ion-oset/BallotLab -b edf-data-to-ballot -The script is `ballot-lab-data.py`. It takes an EDF test case, and the index of -the ballot style to use (a number from 1 to N, that defaults to 1.). +- Install the project dependencies: -To run it: + poetry install -- Install `nist-datamodels`, using a version that has element indexes. - Run: - python ballot-lab-data.py [] + python scripts/ballot-lab-data.py e.g. - python ballot-lab-data.py june_test_case.json 1 + python scripts/ballot-lab-data.py june_test_case.json 1 -## Outputs +Structure of output: -- Output is JSON files with contests, grouped by contest type. -- The `VotingVariation` in the EDF is `vote_type` here. -- Write-ins don't affect the candidate list. They are returned as a count. - Presumably they would all be the same and all that's needed is their number. - They can be ignored.` +- Output is a series of contests, grouped by contest type (candidate, ballot + measure) +- Within a contest type order of records is preserved. +- The `VotingVariation` in the EDF is `vote_type` here. It can be filtered. + - `vote_type` of `plurality` is the simplest kind of ballot. + - Ignore `n-of-m` and `ranked-choice` until later. +- Write-ins are integrated into the candidate list. - The fields were selected to match what is needed for `plurality` candidate - contests and a little extra. We can add other `VoteVariation`s and ballot - measure contests as needed. + contests and a little extra. + - To add fields or modify them we should modify `extract_{contest type}_contest`. -## Complications +Notes: -- There are no `Header`s or `OrderedHeader`s in the test cases. +- There are no `Header`s or `OrderedHeader`s in `june_test_case.json`. From db0773e3abff7df111ada183a5976f66985ba49a Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 5 Sep 2022 14:10:16 -0700 Subject: [PATCH 26/79] Update of JSON ballot generator. Significant update of contests: - Include '@id's of ballot measure contests (candidates already happening). - Include '@id's of candidate and ballot measure contest selections. - Updates and bug fixes for write-ins in candidate contests. - Multi-candidate slates: - Combine names of candidates into list. Names are stored in lists even when there is only one candidate. - Combine parties of candidate IFF they are the same. Parties are always stored in lists, and empty if there is no party. --- .../ballotmaker/demo_data/ballot_lab_data.py | 101 ++++++++++++------ 1 file changed, 68 insertions(+), 33 deletions(-) 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, From fb195a001a43f094cce17b6ec49c73e5402558ee Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 5 Sep 2022 14:18:14 -0700 Subject: [PATCH 27/79] Update Spacetown data to match generator updates. --- .../ballotmaker/demo_data/spacetown_data.json | 172 ++++++++++-------- 1 file changed, 100 insertions(+), 72 deletions(-) diff --git a/src/electos/ballotmaker/demo_data/spacetown_data.json b/src/electos/ballotmaker/demo_data/spacetown_data.json index e781b8b..1b24b2b 100644 --- a/src/electos/ballotmaker/demo_data/spacetown_data.json +++ b/src/electos/ballotmaker/demo_data/spacetown_data.json @@ -15,26 +15,32 @@ "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 + "name": [], + "party": [], + "is_write_in": true } ] }, @@ -51,41 +57,42 @@ "name": [ "Jane Jetson" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false }, { "id": "recBnJZEgCKAnfpNo", "name": [ "Harlan Ellis" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false }, { "id": "recwNuOnepWNGz67V", "name": [ "Rudy Indexer" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false }, { "id": "rec9Eev970VhohqKi", - "write_in": true + "name": [], + "party": [], + "is_write_in": true }, { "id": "recFiGYjGCIyk5LBe", - "write_in": true + "name": [], + "party": [], + "is_write_in": true } ] }, @@ -102,67 +109,68 @@ "name": [ "Sally Smith" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false }, { "id": "recigPkqYXXDJEaCE", "name": [ "Hector Gomez" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false }, { "id": "recJvikmG5MrUKzo1", "name": [ "Rosashawn Davis" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false }, { "id": "recvjB3rgfiicf0RP", "name": [ "Oliver Tsi" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false }, { "id": "recbN7UUMaSuOYGQ6", "name": [ "Glavin Orotund" ], - "party": { - "name": "", - "abbreviation": "" - }, - "write_in": false + "party": [ + {} + ], + "is_write_in": false }, { "id": "recYurH2CLY3SlYS8", - "write_in": true + "name": [], + "party": [], + "is_write_in": true }, { "id": "recI5jfcXIsbAKytC", - "write_in": true + "name": [], + "party": [], + "is_write_in": true }, { "id": "recn9m0o1em7gLahj", - "write_in": true + "name": [], + "party": [], + "is_write_in": true } ] }, @@ -176,7 +184,9 @@ "candidates": [ { "id": "recPod2L8VhwagiDl", - "write_in": true + "name": [], + "party": [], + "is_write_in": true }, { "id": "recQK3J9IJq42hz2n", @@ -184,11 +194,13 @@ "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", @@ -196,36 +208,52 @@ "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 } ] } ], "ballot_measure": [ { + "id": "recqPa7AeyufIfd6k", "title": "Air Traffic Control Tax Increase", "type": "ballot measure", "district": "Gadget County", "choices": [ - "Yes", - "No" + { + "id": "recysACFx8cgwomBE", + "choice": "Yes" + }, + { + "id": "recabXA9jzFYRmGXy", + "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": "recWjDBFeafCdklWq", "title": "Constitutional Amendment", "type": "ballot measure", "district": "The State of Farallon", "choices": [ - "Yes", - "No" + { + "id": "rec7mVWjUH6fmDxig", + "choice": "Yes" + }, + { + "id": "reccIHOhUfJgJkqS7", + "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." } ] } -} \ No newline at end of file +} From fe81fbc27b8712ef0da5351bd4019268bf281585 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 5 Sep 2022 14:21:24 -0700 Subject: [PATCH 28/79] Use September test case for Spacetown data. Only updates the JSON file not the custom data file. See: https://github.com/trustthevote-project/nist-1500-100-103-examples/test_cases/september_test_case.json --- .../ballotmaker/demo_data/spacetown_data.json | 172 +++++++++--------- 1 file changed, 86 insertions(+), 86 deletions(-) diff --git a/src/electos/ballotmaker/demo_data/spacetown_data.json b/src/electos/ballotmaker/demo_data/spacetown_data.json index 1b24b2b..1a0f256 100644 --- a/src/electos/ballotmaker/demo_data/spacetown_data.json +++ b/src/electos/ballotmaker/demo_data/spacetown_data.json @@ -3,17 +3,18 @@ "contests": { "candidate": [ { - "id": "recIj8OmzqzzvnDbM", - "title": "Contest for Mayor of Orbit City", + "id": "recsoZy7vYhS3lbcK", + "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": "recQK3J9IJq42hz2n", "name": [ - "Cosmo Spacely" + "Anthony Alpha", + "Betty Beta" ], "party": [ { @@ -24,9 +25,10 @@ "is_write_in": false }, { - "id": "recKD6dBvkNhEU4bg", + "id": "reccUkUdEznfODgeL", "name": [ - "Spencer Cogswell" + "Gloria Gamma", + "David Delta" ], "party": [ { @@ -37,59 +39,7 @@ "is_write_in": false }, { - "id": "recqq21kO6HWgpJZV", - "name": [], - "party": [], - "is_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": [ - {} - ], - "is_write_in": false - }, - { - "id": "recBnJZEgCKAnfpNo", - "name": [ - "Harlan Ellis" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "recwNuOnepWNGz67V", - "name": [ - "Rudy Indexer" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "rec9Eev970VhohqKi", - "name": [], - "party": [], - "is_write_in": true - }, - { - "id": "recFiGYjGCIyk5LBe", + "id": "recPod2L8VhwagiDl", "name": [], "party": [], "is_write_in": true @@ -105,9 +55,9 @@ "district": "Gadget County", "candidates": [ { - "id": "recbxvhKikHJNZYbq", + "id": "recJvikmG5MrUKzo1", "name": [ - "Sally Smith" + "Rosashawn Davis" ], "party": [ {} @@ -125,9 +75,9 @@ "is_write_in": false }, { - "id": "recJvikmG5MrUKzo1", + "id": "recbN7UUMaSuOYGQ6", "name": [ - "Rosashawn Davis" + "Glavin Orotund" ], "party": [ {} @@ -135,9 +85,9 @@ "is_write_in": false }, { - "id": "recvjB3rgfiicf0RP", + "id": "recbxvhKikHJNZYbq", "name": [ - "Oliver Tsi" + "Sally Smith" ], "party": [ {} @@ -145,9 +95,9 @@ "is_write_in": false }, { - "id": "recbN7UUMaSuOYGQ6", + "id": "recvjB3rgfiicf0RP", "name": [ - "Glavin Orotund" + "Oliver Tsi" ], "party": [ {} @@ -175,24 +125,30 @@ ] }, { - "id": "recsoZy7vYhS3lbcK", - "title": "President of the United States", + "id": "recIj8OmzqzzvnDbM", + "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", - "name": [], - "party": [], - "is_write_in": true + "id": "recKD6dBvkNhEU4bg", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + ], + "is_write_in": false }, { - "id": "recQK3J9IJq42hz2n", + "id": "recTKcXLCzRvKB9U0", "name": [ - "Anthony Alpha", - "Betty Beta" + "Cosmo Spacely" ], "party": [ { @@ -203,18 +159,62 @@ "is_write_in": false }, { - "id": "reccUkUdEznfODgeL", + "id": "recqq21kO6HWgpJZV", + "name": [], + "party": [], + "is_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": "recBnJZEgCKAnfpNo", "name": [ - "Gloria Gamma", - "David Delta" + "Harlan Ellis" ], "party": [ - { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD" - } + {} + ], + "is_write_in": false + }, + { + "id": "recwNuOnepWNGz67V", + "name": [ + "Rudy Indexer" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "recvYvTb9hWH7tptb", + "name": [ + "Jane Jetson" + ], + "party": [ + {} ], "is_write_in": false + }, + { + "id": "rec9Eev970VhohqKi", + "name": [], + "party": [], + "is_write_in": true + }, + { + "id": "recFiGYjGCIyk5LBe", + "name": [], + "party": [], + "is_write_in": true } ] } @@ -252,7 +252,7 @@ "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" } ] } From 818f21bcb6723d35b2e7b48ba936e2a48a64488e Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 5 Sep 2022 16:48:26 -0700 Subject: [PATCH 29/79] Update the internal Spacetown data to use the September test case. - Candidate parties are now a list. - 'write_in' -> 'is_write_in' --- .../ballotmaker/ballots/contest_data.py | 11 ++- .../ballotmaker/ballots/contest_layout.py | 2 +- .../ballotmaker/demo_data/spacetown_data.py | 78 ++++++++++--------- 3 files changed, 49 insertions(+), 42 deletions(-) diff --git a/src/electos/ballotmaker/ballots/contest_data.py b/src/electos/ballotmaker/ballots/contest_data.py index 091a44f..811d040 100644 --- a/src/electos/ballotmaker/ballots/contest_data.py +++ b/src/electos/ballotmaker/ballots/contest_data.py @@ -63,17 +63,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/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}, ], } From 9b91232637931a5505b8d7ab2410c97e05c23d57 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 5 Sep 2022 18:16:36 -0700 Subject: [PATCH 30/79] Switch to human readable IDs. These are NOT intended to follow any standard, merely to aid in debugging. There's an ongoing conversation about the format of human readable IDs. --- .../ballotmaker/demo_data/spacetown_data.json | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/electos/ballotmaker/demo_data/spacetown_data.json b/src/electos/ballotmaker/demo_data/spacetown_data.json index 1a0f256..a5635fd 100644 --- a/src/electos/ballotmaker/demo_data/spacetown_data.json +++ b/src/electos/ballotmaker/demo_data/spacetown_data.json @@ -3,7 +3,7 @@ "contests": { "candidate": [ { - "id": "recsoZy7vYhS3lbcK", + "id": "contest-potus", "title": "President of the United States", "type": "candidate", "vote_type": "plurality", @@ -11,7 +11,7 @@ "district": "United States of America", "candidates": [ { - "id": "recQK3J9IJq42hz2n", + "id": "contest-potus--candidate-lepton", "name": [ "Anthony Alpha", "Betty Beta" @@ -25,7 +25,7 @@ "is_write_in": false }, { - "id": "reccUkUdEznfODgeL", + "id": "contest-potus--candidate-hadron", "name": [ "Gloria Gamma", "David Delta" @@ -39,7 +39,7 @@ "is_write_in": false }, { - "id": "recPod2L8VhwagiDl", + "id": "contest-potus--candidate-write-in-1", "name": [], "party": [], "is_write_in": true @@ -47,7 +47,7 @@ ] }, { - "id": "recthF6jdx5ybBNkC", + "id": "contest-gadget-county-school-board", "title": "Gadget County School Board", "type": "candidate", "vote_type": "n-of-m", @@ -55,7 +55,7 @@ "district": "Gadget County", "candidates": [ { - "id": "recJvikmG5MrUKzo1", + "id": "contest-gadget-county-school-board--candidate-rosashawn-davis", "name": [ "Rosashawn Davis" ], @@ -65,7 +65,7 @@ "is_write_in": false }, { - "id": "recigPkqYXXDJEaCE", + "id": "contest-gadget-county-school-board--candidate-hector-gomez", "name": [ "Hector Gomez" ], @@ -75,7 +75,7 @@ "is_write_in": false }, { - "id": "recbN7UUMaSuOYGQ6", + "id": "contest-gadget-county-school-board--candidate-glavin-orotund", "name": [ "Glavin Orotund" ], @@ -85,7 +85,7 @@ "is_write_in": false }, { - "id": "recbxvhKikHJNZYbq", + "id": "contest-gadget-county-school-board--candidate-sally-smith", "name": [ "Sally Smith" ], @@ -95,7 +95,7 @@ "is_write_in": false }, { - "id": "recvjB3rgfiicf0RP", + "id": "contest-gadget-county-school-board--candidate-oliver-tsi", "name": [ "Oliver Tsi" ], @@ -105,19 +105,19 @@ "is_write_in": false }, { - "id": "recYurH2CLY3SlYS8", + "id": "contest-gadget-county-school-board--candidate-write-in-1", "name": [], "party": [], "is_write_in": true }, { - "id": "recI5jfcXIsbAKytC", + "id": "contest-gadget-county-school-board--candidate-write-in-2", "name": [], "party": [], "is_write_in": true }, { - "id": "recn9m0o1em7gLahj", + "id": "contest-gadget-county-school-board--candidate-write-in-3", "name": [], "party": [], "is_write_in": true @@ -125,7 +125,7 @@ ] }, { - "id": "recIj8OmzqzzvnDbM", + "id": "contest-orbit-city-mayor", "title": "Contest for Mayor of Orbit City", "type": "candidate", "vote_type": "plurality", @@ -133,7 +133,7 @@ "district": "Orbit City", "candidates": [ { - "id": "recKD6dBvkNhEU4bg", + "id": "contest-orbit-city-mayor--candidate-spencer-cogswell", "name": [ "Spencer Cogswell" ], @@ -146,7 +146,7 @@ "is_write_in": false }, { - "id": "recTKcXLCzRvKB9U0", + "id": "contest-orbit-city-mayor--candidate-cosmo-spacely", "name": [ "Cosmo Spacely" ], @@ -159,7 +159,7 @@ "is_write_in": false }, { - "id": "recqq21kO6HWgpJZV", + "id": "contest-orbit-city-mayor--candidate-write-in-1", "name": [], "party": [], "is_write_in": true @@ -167,7 +167,7 @@ ] }, { - "id": "recXNb4zPrvC1m6Fr", + "id": "contest-spaceport-control-board", "title": "Spaceport Control Board", "type": "candidate", "vote_type": "n-of-m", @@ -175,7 +175,7 @@ "district": "Aldrin Space Transport District", "candidates": [ { - "id": "recBnJZEgCKAnfpNo", + "id": "contest-spaceport-control-board--candidate-harlan-ellis", "name": [ "Harlan Ellis" ], @@ -185,7 +185,7 @@ "is_write_in": false }, { - "id": "recwNuOnepWNGz67V", + "id": "contest-spaceport-control-board--candidate-rudy-indexer", "name": [ "Rudy Indexer" ], @@ -195,7 +195,7 @@ "is_write_in": false }, { - "id": "recvYvTb9hWH7tptb", + "id": "contest-spaceport-control-board--candidate-jane-jetson", "name": [ "Jane Jetson" ], @@ -205,13 +205,13 @@ "is_write_in": false }, { - "id": "rec9Eev970VhohqKi", + "id": "contest-spaceport-control-board--candidate-write-in-1", "name": [], "party": [], "is_write_in": true }, { - "id": "recFiGYjGCIyk5LBe", + "id": "contest-spaceport-control-board--candidate-write-in-2", "name": [], "party": [], "is_write_in": true @@ -221,34 +221,34 @@ ], "ballot_measure": [ { - "id": "recqPa7AeyufIfd6k", + "id": "ballot-measure-air-traffic-control-tax", "title": "Air Traffic Control Tax Increase", "type": "ballot measure", "district": "Gadget County", "choices": [ { - "id": "recysACFx8cgwomBE", + "id": "ballot-measure-air-traffic-control-tax--yes", "choice": "Yes" }, { - "id": "recabXA9jzFYRmGXy", + "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": "recWjDBFeafCdklWq", + "id": "ballot-measure-helium-balloons", "title": "Constitutional Amendment", "type": "ballot measure", "district": "The State of Farallon", "choices": [ { - "id": "rec7mVWjUH6fmDxig", + "id": "ballot-measure-helium-balloons--yes", "choice": "Yes" }, { - "id": "reccIHOhUfJgJkqS7", + "id": "ballot-measure-helium-balloons--no", "choice": "No" } ], From 983ff8885da09007bb70d0893f4c9f7c63c3ebb0 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Wed, 7 Sep 2022 17:50:33 -0400 Subject: [PATCH 31/79] Clean up docstrings --- src/electos/ballotmaker/ballots/contest_data.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/electos/ballotmaker/ballots/contest_data.py b/src/electos/ballotmaker/ballots/contest_data.py index 091a44f..b72ab77 100644 --- a/src/electos/ballotmaker/ballots/contest_data.py +++ b/src/electos/ballotmaker/ballots/contest_data.py @@ -1,5 +1,4 @@ from dataclasses import dataclass, field -from typing import List @dataclass @@ -58,6 +57,8 @@ def __post_init__(self): @dataclass class CandidateData: + """Retrieve candidate data from a dict""" + _can_data: dict = field(repr=False) id: str = field(init=False) _names: list = field(init=False, repr=False, default_factory=list) From b3f1cb2cc5a8745fa0563c08037b40fbe076af9b Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 6 Sep 2022 20:06:50 -0700 Subject: [PATCH 32/79] Add ballot data for remaining jurisdictions in September test case. --- .../ballotmaker/demo_data/bedrock_data.json | 165 ++++++++++++++ .../ballotmaker/demo_data/downtown_data.json | 207 ++++++++++++++++++ .../ballotmaker/demo_data/spaceport_data.json | 139 ++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 src/electos/ballotmaker/demo_data/bedrock_data.json create mode 100644 src/electos/ballotmaker/demo_data/downtown_data.json create mode 100644 src/electos/ballotmaker/demo_data/spaceport_data.json diff --git a/src/electos/ballotmaker/demo_data/bedrock_data.json b/src/electos/ballotmaker/demo_data/bedrock_data.json new file mode 100644 index 0000000..31dcca7 --- /dev/null +++ b/src/electos/ballotmaker/demo_data/bedrock_data.json @@ -0,0 +1,165 @@ +{ + "ballot_style": "precinct_4_bedrock", + "contests": { + "candidate": [ + { + "id": "contest-potus", + "title": "President of the United States", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "United States of America", + "candidates": [ + { + "id": "contest-potus--candidate-lepton", + "name": [ + "Anthony Alpha", + "Betty Beta" + ], + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + ], + "is_write_in": false + }, + { + "id": "contest-potus--candidate-hadron", + "name": [ + "Gloria Gamma", + "David Delta" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + ], + "is_write_in": false + }, + { + "id": "contest-potus--candidate-write-in-1", + "name": [], + "party": [], + "is_write_in": true + } + ] + }, + { + "id": "contest-gadget-county-school-board", + "title": "Gadget County School Board", + "type": "candidate", + "vote_type": "n-of-m", + "votes_allowed": 4, + "district": "Gadget County", + "candidates": [ + { + "id": "contest-gadget-county-school-board--candidate-rosashawn-davis", + "name": [ + "Rosashawn Davis" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-gadget-county-school-board--candidate-hector-gomez", + "name": [ + "Hector Gomez" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-gadget-county-school-board--candidate-glavin-orotund", + "name": [ + "Glavin Orotund" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-gadget-county-school-board--candidate-sally-smith", + "name": [ + "Sally Smith" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-gadget-county-school-board--candidate-oliver-tsi", + "name": [ + "Oliver Tsi" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-gadget-county-school-board--candidate-write-in-1", + "name": [], + "party": [], + "is_write_in": true + }, + { + "id": "contest-gadget-county-school-board--candidate-write-in-2", + "name": [], + "party": [], + "is_write_in": true + }, + { + "id": "contest-gadget-county-school-board--candidate-write-in-3", + "name": [], + "party": [], + "is_write_in": true + } + ] + } + ], + "ballot_measure": [ + { + "id": "ballot-measure-helium-balloons", + "title": "Constitutional Amendment", + "type": "ballot measure", + "district": "The State of Farallon", + "choices": [ + { + "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.\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" + }, + { + "id": "ballot-measure-air-traffic-control-tax", + "title": "Air Traffic Control Tax Increase", + "type": "ballot measure", + "district": "Gadget County", + "choices": [ + { + "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?" + } + ] + } +} diff --git a/src/electos/ballotmaker/demo_data/downtown_data.json b/src/electos/ballotmaker/demo_data/downtown_data.json new file mode 100644 index 0000000..d8c4c34 --- /dev/null +++ b/src/electos/ballotmaker/demo_data/downtown_data.json @@ -0,0 +1,207 @@ +{ + "ballot_style": "precinct_1_downtown", + "contests": { + "candidate": [ + { + "id": "contest-potus", + "title": "President of the United States", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "United States of America", + "candidates": [ + { + "id": "contest-potus--candidate-lepton", + "name": [ + "Anthony Alpha", + "Betty Beta" + ], + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + ], + "is_write_in": false + }, + { + "id": "contest-potus--candidate-hadron", + "name": [ + "Gloria Gamma", + "David Delta" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + ], + "is_write_in": false + }, + { + "id": "contest-potus--candidate-write-in-1", + "name": [], + "party": [], + "is_write_in": true + } + ] + }, + { + "id": "contest-gadget-county-school-board", + "title": "Gadget County School Board", + "type": "candidate", + "vote_type": "n-of-m", + "votes_allowed": 4, + "district": "Gadget County", + "candidates": [ + { + "id": "contest-gadget-county-school-board--candidate-rosashawn-davis", + "name": [ + "Rosashawn Davis" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-gadget-county-school-board--candidate-hector-gomez", + "name": [ + "Hector Gomez" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-gadget-county-school-board--candidate-glavin-orotund", + "name": [ + "Glavin Orotund" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-gadget-county-school-board--candidate-sally-smith", + "name": [ + "Sally Smith" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-gadget-county-school-board--candidate-oliver-tsi", + "name": [ + "Oliver Tsi" + ], + "party": [ + {} + ], + "is_write_in": false + }, + { + "id": "contest-gadget-county-school-board--candidate-write-in-1", + "name": [], + "party": [], + "is_write_in": true + }, + { + "id": "contest-gadget-county-school-board--candidate-write-in-2", + "name": [], + "party": [], + "is_write_in": true + }, + { + "id": "contest-gadget-county-school-board--candidate-write-in-3", + "name": [], + "party": [], + "is_write_in": true + } + ] + }, + { + "id": "contest-orbit-city-mayor", + "title": "Contest for Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "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": "contest-orbit-city-mayor--candidate-cosmo-spacely", + "name": [ + "Cosmo Spacely" + ], + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + ], + "is_write_in": false + }, + { + "id": "contest-orbit-city-mayor--candidate-write-in-1", + "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": [ + { + "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": [ + { + "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.\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" + } + ] + } +} diff --git a/src/electos/ballotmaker/demo_data/spaceport_data.json b/src/electos/ballotmaker/demo_data/spaceport_data.json new file mode 100644 index 0000000..90b980b --- /dev/null +++ b/src/electos/ballotmaker/demo_data/spaceport_data.json @@ -0,0 +1,139 @@ +{ + "ballot_style": "precinct_3_spaceport", + "contests": { + "candidate": [ + { + "id": "contest-potus", + "title": "President of the United States", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "United States of America", + "candidates": [ + { + "id": "contest-potus--candidate-lepton", + "name": [ + "Anthony Alpha", + "Betty Beta" + ], + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + ], + "is_write_in": false + }, + { + "id": "contest-potus--candidate-hadron", + "name": [ + "Gloria Gamma", + "David Delta" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + ], + "is_write_in": false + }, + { + "id": "contest-potus--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": [ + "Harlan Ellis" + ], + "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": [ + { + "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": [ + { + "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.\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" + } + ] + } +} From 827b2c9efdf21e6394d66b3873c2ae952c67a64a Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 15:46:53 -0700 Subject: [PATCH 33/79] Move 'scripts' to 'data' and rename script. --- src/electos/ballotmaker/{scripts => data}/README.md | 0 src/electos/ballotmaker/{scripts => data}/ballot-lab-data.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/electos/ballotmaker/{scripts => data}/README.md (100%) rename src/electos/ballotmaker/{scripts => data}/ballot-lab-data.py (100%) diff --git a/src/electos/ballotmaker/scripts/README.md b/src/electos/ballotmaker/data/README.md similarity index 100% rename from src/electos/ballotmaker/scripts/README.md rename to src/electos/ballotmaker/data/README.md diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/data/ballot-lab-data.py similarity index 100% rename from src/electos/ballotmaker/scripts/ballot-lab-data.py rename to src/electos/ballotmaker/data/ballot-lab-data.py From 8d6d4318f2ee0d5ad01c2b64e569d97dc31190e0 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 15:55:35 -0700 Subject: [PATCH 34/79] Separate EDF to data into a script and a module. --- .../ballotmaker/data/edf-to-ballot-data.py | 64 +++++++++++++++++++ .../data/{ballot-lab-data.py => extractor.py} | 62 +----------------- 2 files changed, 65 insertions(+), 61 deletions(-) create mode 100644 src/electos/ballotmaker/data/edf-to-ballot-data.py rename src/electos/ballotmaker/data/{ballot-lab-data.py => extractor.py} (83%) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py new file mode 100644 index 0000000..54bc04e --- /dev/null +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -0,0 +1,64 @@ +import argparse +import json +from pathlib import Path + +from electos.datamodels.nist.indexes import ElementIndex +from electos.datamodels.nist.models.edf import ElectionReport + +from electos.ballotmaker.data.extractor import ( + all_ballot_styles, + ballot_style_id, + gather_contests, +) + + +def report(root, index, nth, **opts): + """Generate data needed by BallotLab.""" + ballot_styles = list(all_ballot_styles(root, index)) + if not (1 <= nth <= len(ballot_styles)): + print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") + return + ballot_style = ballot_styles[nth - 1] + data = {} + id_ = ballot_style_id(ballot_style) + data["ballot_style"] = id_ + contests = gather_contests(ballot_style, index) + if not contests: + print(f"No contests found for ballot style: {id_}\n") + data["contests"] = contests + print(json.dumps(data, indent = 4)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "file", type = Path, + help = "Test case data (JSON)" + ) + parser.add_argument( + "nth", nargs = "?", type = int, default = 1, + help = "Index of the ballot style, starting from 1 (default: 1)" + ) + parser.add_argument( + "--debug", action = "store_true", + help = "Enable debugging output and stack traces" + ) + opts = parser.parse_args() + file = opts.file + opts = vars(opts) + + try: + with file.open() as input: + text = input.read() + data = json.loads(text) + edf = ElectionReport(**data) + index = ElementIndex(edf, "ElectionResults") + report(edf, index, **opts) + except Exception as ex: + if opts["debug"]: + raise ex + print("error:", ex) + + +if __name__ == '__main__': + main() diff --git a/src/electos/ballotmaker/data/ballot-lab-data.py b/src/electos/ballotmaker/data/extractor.py similarity index 83% rename from src/electos/ballotmaker/data/ballot-lab-data.py rename to src/electos/ballotmaker/data/extractor.py index a1e1718..1c94274 100644 --- a/src/electos/ballotmaker/data/ballot-lab-data.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -1,8 +1,7 @@ -from itertools import groupby from typing import List, Union -from electos.datamodels.nist.models.edf import * from electos.datamodels.nist.indexes import ElementIndex +from electos.datamodels.nist.models.edf import * # --- Base Types @@ -242,62 +241,3 @@ def gather_contests(ballot_style: BallotStyle, index): # Ignore other contest types print(f"Skipping contest of type {contest.model__type}") return contests - - -# --- Main - -import argparse -import json -from pathlib import Path - - -def report(root, index, nth, **opts): - """Generate data needed by BallotLab""" - ballot_styles = list(all_ballot_styles(root, index)) - if not (1 <= nth <= len(ballot_styles)): - print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") - return - ballot_style = ballot_styles[nth - 1] - data = {} - id_ = ballot_style_id(ballot_style) - data["ballot_style"] = id_ - contests = gather_contests(ballot_style, index) - if not contests: - print(f"No contests found for ballot style: {id_}\n") - data["contests"] = contests - print(json.dumps(data, indent = 4)) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument( - "file", type = Path, - help = "Test case data (JSON)" - ) - parser.add_argument( - "nth", nargs = "?", type = int, default = 1, - help = "Index of the ballot style, starting from 1 (default: 1)" - ) - parser.add_argument( - "--debug", action = "store_true", - help = "Enable debugging output and stack traces" - ) - opts = parser.parse_args() - file = opts.file - opts = vars(opts) - - try: - with file.open() as input: - text = input.read() - data = json.loads(text) - edf = ElectionReport(**data) - index = ElementIndex(edf, "ElectionResults") - report(edf, index, **opts) - except Exception as ex: - if opts["debug"]: - raise ex - print("error:", ex) - - -if __name__ == '__main__': - main() From 2bec0c11c89ee68a76d207d6864ae55536d0ca99 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 15:58:35 -0700 Subject: [PATCH 35/79] Use explicit EDF model imports ballot data extractor. --- src/electos/ballotmaker/data/extractor.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 1c94274..7649630 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -1,7 +1,18 @@ from typing import List, Union from electos.datamodels.nist.indexes import ElementIndex -from electos.datamodels.nist.models.edf import * +from electos.datamodels.nist.models.edf import ( + BallotMeasureContest, + BallotMeasureSelection, + BallotStyle, + Candidate, + CandidateContest, + CandidateSelection, + ElectionReport, + InternationalizedText, + OrderedContest, + OrderedHeader, +) # --- Base Types From 05fac8ffe6161dd159cf9f2b4e3a882cd00a216e Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 16:04:48 -0700 Subject: [PATCH 36/79] Simplify naming of ballot data extractor functions. --- .../ballotmaker/data/edf-to-ballot-data.py | 12 ++++----- src/electos/ballotmaker/data/extractor.py | 26 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index 54bc04e..a7d0634 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -6,23 +6,23 @@ from electos.datamodels.nist.models.edf import ElectionReport from electos.ballotmaker.data.extractor import ( - all_ballot_styles, - ballot_style_id, - gather_contests, + ballot_style_external_id, + extract_ballot_styles, + extract_contests, ) def report(root, index, nth, **opts): """Generate data needed by BallotLab.""" - ballot_styles = list(all_ballot_styles(root, index)) + ballot_styles = list(extract_ballot_styles(root, index)) if not (1 <= nth <= len(ballot_styles)): print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") return ballot_style = ballot_styles[nth - 1] data = {} - id_ = ballot_style_id(ballot_style) + id_ = ballot_style_external_id(ballot_style) data["ballot_style"] = id_ - contests = gather_contests(ballot_style, index) + contests = extract_contests(ballot_style, index) if not contests: print(f"No contests found for ballot style: {id_}\n") data["contests"] = contests diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 7649630..c0bf0b9 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -58,13 +58,7 @@ def walk_ordered_headers(content: List[OrderedContent]): # --- Ballot Properties -def all_ballot_styles(election_report: ElectionReport, index): - """Yield all ballot styles.""" - for ballot_style in index.by_type("BallotStyle"): - yield ballot_style - - -def ballot_style_id(ballot_style: BallotStyle): +def ballot_style_external_id(ballot_style: BallotStyle): """Get the text of a ballot style's external identifier if any.""" if ballot_style.external_identifier: assert len(ballot_style.external_identifier) == 1, \ @@ -187,12 +181,12 @@ def contest_election_district(contest: Contest, index): return district -# Gather & Extract +# --- Extraction # -# Results are data needed for ballot generation. +# Gather and select data needed for ballot generation. def extract_candidate_contest(contest: CandidateContest, index): - """Extract candidate contest information needed for ballots.""" + """Extract candidate contest subset needed for a ballot.""" district = contest_election_district(contest, index) offices = candidate_contest_offices(contest, index) parties = candidate_contest_parties(contest, index) @@ -213,7 +207,7 @@ def extract_candidate_contest(contest: CandidateContest, index): def extract_ballot_measure_contest(contest: BallotMeasureContest, index): - """Extract ballot measure contest information needed for ballots.""" + """Extract ballot measure contest subset needed for a ballot.""" choices = [] for selection in contest.contest_selection: assert isinstance(selection, BallotMeasureSelection), \ @@ -236,8 +230,8 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): return result -def gather_contests(ballot_style: BallotStyle, index): - """Extract all contest information needed for ballots.""" +def extract_contests(ballot_style: BallotStyle, index): + """Extract contest subset needed for ballots.""" contests = { kind: [] for kind in ("candidate", "ballot_measure") } @@ -252,3 +246,9 @@ def gather_contests(ballot_style: BallotStyle, index): # Ignore other contest types print(f"Skipping contest of type {contest.model__type}") return contests + + +def extract_ballot_styles(election_report: ElectionReport, index): + """Extract all ballot styles.""" + for ballot_style in index.by_type("BallotStyle"): + yield ballot_style From 67d3933becdb13fdaca95a69e65510ae5eba1286 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 16:44:36 -0700 Subject: [PATCH 37/79] Data model for ballot measure choices. --- src/electos/ballotmaker/data/models.py | 45 ++++++++++++++++ tests/models/test_ballot_choices.py | 72 ++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/electos/ballotmaker/data/models.py create mode 100644 tests/models/test_ballot_choices.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py new file mode 100644 index 0000000..d8b8ceb --- /dev/null +++ b/src/electos/ballotmaker/data/models.py @@ -0,0 +1,45 @@ +"""Ballot data models.""" + +from dataclasses import dataclass +from typing import List + + +# --- Type assertions +# +# Python dataclasses don't check field types at runtime, but we want to avoid +# errors. + + +def _check_type(instance, field, type_): + """Raise 'TypeError' if 'instance.field' isn't of class 'type'.""" + value = getattr(instance, field) + if not isinstance(value, type_): + raise TypeError( + f"Field '{field}' is not of type '{type_.__name__}': {value}" + ) + + +def _check_type_hint(instance, field, type_): + """Raise 'TypeError' if 'instance.field' isn't of type model 'type'.""" + value = getattr(instance, field) + if not isinstance(value, type_): + raise TypeError( + f"Field '{field}' is not of type '{type_._name}': {value}" + ) + + +# --- Ballot contest data models + + +@dataclass +class BallotChoiceData: + + """Data for ballot measure selections.""" + + id: str + choice: str + + + def __post_init__(self): + _check_type(self, "id", str) + _check_type(self, "choice", str) diff --git a/tests/models/test_ballot_choices.py b/tests/models/test_ballot_choices.py new file mode 100644 index 0000000..8a4179b --- /dev/null +++ b/tests/models/test_ballot_choices.py @@ -0,0 +1,72 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from electos.ballotmaker.data.models import ( + BallotChoiceData, +) + + +# Tests + +BALLOT_CHOICE_TESTS = [ + ( + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + raises_none(), + ), + # Missing id + ( + { + "choice": "yes", + }, + raises(TypeError, match = "required positional argument: 'id'"), + ), + # Missing choice + ( + { + "id": "ballot-measure-1--yes", + }, + raises(TypeError, match = "required positional argument: 'choice'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "2 required positional arguments: 'id' and 'choice'"), + ), + # id is not a string + ( + { + "id": 1, + "choice": "yes", + }, + raises(TypeError, match = "Field 'id' is not of type 'str'"), + ), + # choice is not a string + ( + { + "id": "ballot-measure-1--yes", + "choice": [], + }, + raises(TypeError, match = "Field 'choice' is not of type 'str'"), + ), +] + + +@pytest.mark.parametrize("data, raises", BALLOT_CHOICE_TESTS) +def test_ballot_choice(data, raises): + with raises: + item = BallotChoiceData(**data) + + +def test_ballot_choice_fields(): + data = { + "id": "ballot-measure-1--yes", + "choice": "yes" + } + item = BallotChoiceData(**data) + assert item.id == data["id"] + assert item.choice == data["choice"] From 9921c2d46f54729229423926468cdfd2cf6a7212 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 17:30:11 -0700 Subject: [PATCH 38/79] Data model for ballot measure contests. --- src/electos/ballotmaker/data/models.py | 22 ++ tests/models/test_ballot_contests.py | 287 +++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 tests/models/test_ballot_contests.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index d8b8ceb..472a6fd 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -43,3 +43,25 @@ class BallotChoiceData: def __post_init__(self): _check_type(self, "id", str) _check_type(self, "choice", str) + + +@dataclass +class BallotMeasureContestData: + + """Data for ballot measure contests.""" + + id: str + type: str + title: str + district: str + text: str + choices: List[BallotChoiceData] + + def __post_init__(self): + _check_type(self, "id", str) + _check_type(self, "type", str) + _check_type(self, "title", str) + _check_type(self, "district", str) + _check_type(self, "text", str) + _check_type_hint(self, "choices", List) + self.choices = [BallotChoiceData(**_) for _ in self.choices] diff --git a/tests/models/test_ballot_contests.py b/tests/models/test_ballot_contests.py new file mode 100644 index 0000000..48b1d74 --- /dev/null +++ b/tests/models/test_ballot_contests.py @@ -0,0 +1,287 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from electos.ballotmaker.data.models import ( + BallotChoiceData, + BallotMeasureContestData, +) + + +# Tests + +BALLOT_MEASURE_CONTEST_TESTS = [ + # Two choices + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--no", + "choice": "no", + }, + ], + }, + raises_none(), + ), + # Empty choices + pytest.param( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(ValueError, match = "Insufficient number of ballot choices"), + marks = pytest.mark.xfail(reason = "Empty 'choices' list"), + ), + # Only one choice + pytest.param( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + ], + }, + raises(ValueError, match = "Insufficient number of ballot choices"), + marks = pytest.mark.xfail(reason = "Only one choice in 'choices' list"), + ), + # One choice, duplicated + pytest.param( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + ], + }, + raises(ValueError, match = "Duplicate ballot choices"), + marks = pytest.mark.xfail(reason = "Duplicate ballot choices"), + ), + # Missing id + ( + { + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "required positional argument: 'id'"), + ), + # Missing type + ( + { + "id": "ballot-measure-1", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "required positional argument: 'type'"), + ), + # Missing title + ( + { + "type": "ballot-measure", + "id": "ballot-measure-1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "required positional argument: 'title'"), + ), + # Missing district + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "required positional argument: 'district'"), + ), + # Missing text + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "choices": [], + }, + raises(TypeError, match = "required positional argument: 'text'"), + ), + # Missing choices + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + }, + raises(TypeError, match = "required positional argument: 'choices'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "required positional arguments: 'id', 'type', 'title', 'district', 'text', and 'choices'"), + ), + # id is not a string + ( + { + "id": 1, + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "Field 'id' is not of type 'str'"), + ), + # type is not a string + ( + { + "id": "ballot-measure-1", + "type": 1, + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "Field 'type' is not of type 'str'"), + ), + # title is not a string + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": 2, + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "Field 'title' is not of type 'str'"), + ), + # district is not a string + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": [], + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "Field 'district' is not of type 'str'"), + ), + # text is not a string + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": {}, + "choices": [], + }, + raises(TypeError, match = "Field 'text' is not of type 'str'"), + ), + # choices is not a list + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": {}, + }, + raises(TypeError, match = "Field 'choices' is not of type 'List'"), + ), + # choice is not a dictionary that can convert to a BallotChoiceData + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [{}], + }, + raises(TypeError, match = "missing 2 required positional arguments: 'id' and 'choice'"), + ), +] + + +@pytest.mark.parametrize("data, raises", BALLOT_MEASURE_CONTEST_TESTS) +def test_ballot_measure_contest(data, raises): + with raises: + item = BallotMeasureContestData(**data) + + +def test_ballot_measure_contest_fields(): + data = { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes" + }, + { + "id": "ballot-measure-1--no", + "choice": "no" + }, + ] + } + item = BallotMeasureContestData(**data) + # Scalar fields match + assert item.id == data["id"] + assert item.type == data["type"] + assert item.title == data["title"] + assert item.district == data["district"] + assert item.text == data["text"] + # Not the same type: data model converts each party to an object + assert item.choices != data["choices"] + # Scalar field values + assert item.type == "ballot-measure" + # Lengths and fields are the same. + assert len(item.choices) == len(data["choices"]) + assert all(isinstance(_, BallotChoiceData) for _ in item.choices) + for actual, expected in zip(item.choices, data["choices"]): + assert actual.id == expected["id"] + assert actual.choice == expected["choice"] From 4e7283f2a6bb156f6764e949459052700048ce5d Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 18:09:22 -0700 Subject: [PATCH 39/79] Data model for candidate parties. --- src/electos/ballotmaker/data/models.py | 16 ++++++ tests/models/test_parties.py | 72 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/models/test_parties.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 472a6fd..911713f 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -65,3 +65,19 @@ def __post_init__(self): _check_type(self, "text", str) _check_type_hint(self, "choices", List) self.choices = [BallotChoiceData(**_) for _ in self.choices] + + +# --- Candidate contest data models + + +@dataclass +class PartyData: + + """Data for parties candidates are in.""" + + name: str + abbreviation: str + + def __post_init__(self): + _check_type(self, "name", str) + _check_type(self, "abbreviation", str) diff --git a/tests/models/test_parties.py b/tests/models/test_parties.py new file mode 100644 index 0000000..0cdaf6e --- /dev/null +++ b/tests/models/test_parties.py @@ -0,0 +1,72 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from electos.ballotmaker.data.models import ( + PartyData, +) + + +# Tests + +PARTY_TESTS = [ + ( + { + "name": "Un-Committed Party", + "abbreviation": "UCP", + }, + raises_none(), + ), + # Missing name + ( + { + "abbreviation": "UCP", + }, + raises(TypeError, match = "required positional argument: 'name'"), + ), + # Missing abbreviation + ( + { + "name": "Un-Committed Party", + }, + raises(TypeError, match = "required positional argument: 'abbreviation'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "2 required positional arguments: 'name' and 'abbreviation'"), + ), + # name is not a string + ( + { + "name": 1, + "abbreviation": "UCP", + }, + raises(TypeError, match = "Field 'name' is not of type 'str'"), + ), + # abbreviation is not a string + ( + { + "name": "Un-Committed Party", + "abbreviation": [], + }, + raises(TypeError, match = "Field 'abbreviation' is not of type 'str'"), + ), +] + + +@pytest.mark.parametrize("data, raises", PARTY_TESTS) +def test_party(data, raises): + with raises: + item = PartyData(**data) + + +def test_party_fields(): + data = { + "name": "Un-Committed Party", + "abbreviation": "UCP", + } + item = PartyData(**data) + assert item.name == data["name"] + assert item.abbreviation == data["abbreviation"] From e0994191f959a70ae99b081e928e6a389c14b54c Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 18:59:01 -0700 Subject: [PATCH 40/79] Data model for candidate choices. --- src/electos/ballotmaker/data/models.py | 28 +++ tests/models/test_candidate_choices.py | 314 +++++++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 tests/models/test_candidate_choices.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 911713f..0105696 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -28,6 +28,15 @@ def _check_type_hint(instance, field, type_): ) +def _check_type_list(instance, field, type_): + """Raise 'TypeError' if 'instance.field' contents aren't of type 'type'.""" + values = getattr(instance, field) + if not all(isinstance(value, type_) for value in values): + raise TypeError( + f"Values in field '{field}' are not all of type '{type_.__name__}': {values}" + ) + + # --- Ballot contest data models @@ -81,3 +90,22 @@ class PartyData: def __post_init__(self): _check_type(self, "name", str) _check_type(self, "abbreviation", str) + + +@dataclass +class CandidateChoiceData: + + """Data for candidate contest selections.""" + + id: str + name: List[str] + party: List[PartyData] + is_write_in: bool + + def __post_init__(self): + _check_type(self, "id", str) + _check_type_hint(self, "name", List) + _check_type_list(self, "name", str) + _check_type_hint(self, "party", List) + self.party = [PartyData(**_) for _ in self.party] + _check_type(self, "is_write_in", bool) diff --git a/tests/models/test_candidate_choices.py b/tests/models/test_candidate_choices.py new file mode 100644 index 0000000..a2cfddc --- /dev/null +++ b/tests/models/test_candidate_choices.py @@ -0,0 +1,314 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from electos.ballotmaker.data.models import ( + CandidateChoiceData, + PartyData, +) + + +# Tests + +CANDIDATE_CHOICE_TESTS = [ + # Single candidate, no party + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "party": [], + "is_write_in": False, + }, + raises_none(), + ), + # Multiple candidates, no party + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + "Betty Beta", + ], + "party": [], + "is_write_in": False, + }, + raises_none(), + ), + # Multiple candidates, single party + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + "Betty Beta", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + "is_write_in": False, + }, + raises_none(), + ), + # Multiple candidates, same number of parties + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + "Elizabeth Epsilon", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + { + "name": "Fermion Party", + "abbreviation": "FRM", + }, + ], + "is_write_in": False, + }, + raises_none(), + ), + # Multiple candidates, differing number of parties + # Note: should have the same number of parties iff no. of candidates > 1 + pytest.param( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + "Elizabeth Epsilon", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + { + "name": "Hadron Party", + "abbreviation": "HAD", + }, + { + "name": "Fermion Party", + "abbreviation": "FRM", + }, + ], + "is_write_in": False, + }, + raises(ValueError, match = "Counts of names and parties don't match"), + marks = pytest.mark.xfail(reason = "Mismatched counts of candidate names and parties"), + ), + # 'name' shouldn't be empty if 'is_write_in' is False + pytest.param( + { + "id": "candidate-contest-1--candidate-1", + "name": [], + "party": [], + "is_write_in": False, + }, + raises(ValueError, match = "Candidate name is empty"), + marks = pytest.mark.xfail(reason = "Empty 'name' list"), + ), + # 'name' is empty if 'is_write_in' is True + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [], + "party": [], + "is_write_in": True, + }, + raises_none(), + ), + # 'name' must be empty if 'is_write_in' is True + pytest.param( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "party": [], + "is_write_in": True, + }, + raises(ValueError, match = "Write-in cannot have a 'name'"), + marks = pytest.mark.xfail(reason = "Write-ins can't have candidate names"), + ), + # 'party' must be empty if 'is_write_in' is True + pytest.param( + { + "id": "candidate-contest-1--candidate-1", + "name": [], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + "is_write_in": True, + }, + raises(ValueError, match = "Write-in cannot have a 'party'"), + marks = pytest.mark.xfail(reason = "Write-ins can't have candidate parties"), + ), + # Missing ID + ( + { + "name": [ + "Anthony Alpha", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + "is_write_in": False, + }, + raises(TypeError, match = "required positional argument: 'id'"), + ), + # Missing name + ( + { + "id": "candidate-contest-1--candidate-1", + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + "is_write_in": False, + }, + raises(TypeError, match = "required positional argument: 'name'"), + ), + # Missing party + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "is_write_in": False, + }, + raises(TypeError, match = "required positional argument: 'party'"), + ), + # Missing 'is write-in' + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + }, + raises(TypeError, match = "required positional argument: 'is_write_in'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "required positional arguments: 'id', 'name', 'party', and 'is_write_in'"), + ), + # id is not a string + ( + { + "id": 1, + "name": [ + "Anthony Alpha", + ], + "party": [], + "is_write_in": False, + }, + raises(TypeError, match = "Field 'id' is not of type 'str'"), + ), + # name is not a list + ( + { + "id": "candidate-contest-1--candidate-1", + "name": "Anthony Alpha", + "party": [], + "is_write_in": False, + }, + raises(TypeError, match = "Field 'name' is not of type 'List'"), + ), + # Contents of 'name' are not all strings + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + { "name": "Betty Beta" }, + ], + "party": [], + "is_write_in": False, + }, + raises(TypeError, match = "Values in field 'name' are not all of type 'str'"), + ), + # party is not a list + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "party": "", + "is_write_in": False, + }, + raises(TypeError, match = "Field 'party' is not of type 'List'"), + ), + # 'is write-in' is not a boolean + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "party": [], + "is_write_in": None, + }, + raises(TypeError, match = "Field 'is_write_in' is not of type 'bool'"), + ), +] + + +@pytest.mark.parametrize("data, raises", CANDIDATE_CHOICE_TESTS) +def test_candidate_choice(data, raises): + with raises: + item = CandidateChoiceData(**data) + + +def test_candidate_choice_fields(): + data = { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + "Betty Beta", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + "is_write_in": False, + } + item = CandidateChoiceData(**data) + assert item.id == data["id"] + assert item.name == data["name"] + # Not the same type: data model converts each party to an object + assert item.party != data["party"] + # Lengths and fields are the same. + assert len(item.party) == len(data["party"]) + assert all(isinstance(_, PartyData) for _ in item.party) + for actual, expected in zip(item.party, data["party"]): + assert actual.name == expected["name"] + assert actual.abbreviation == expected["abbreviation"] + assert item.is_write_in == data["is_write_in"] From 5dc5ca8f66a957207792e95035e84a91a1fc68bc Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 19:28:40 -0700 Subject: [PATCH 41/79] Data model for candidate contests. --- src/electos/ballotmaker/data/models.py | 24 ++ tests/models/test_candidate_contests.py | 523 ++++++++++++++++++++++++ 2 files changed, 547 insertions(+) create mode 100644 tests/models/test_candidate_contests.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 0105696..12305c6 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -109,3 +109,27 @@ def __post_init__(self): _check_type_hint(self, "party", List) self.party = [PartyData(**_) for _ in self.party] _check_type(self, "is_write_in", bool) + + +@dataclass +class CandidateContestData: + + """Data for candidate contests.""" + + id: str + type: str + title: str + district: str + vote_type: str + votes_allowed: str + candidates: List[CandidateChoiceData] + + def __post_init__(self): + _check_type(self, "id", str) + _check_type(self, "type", str) + _check_type(self, "title", str) + _check_type(self, "district", str) + _check_type(self, "vote_type", str) + _check_type(self, "votes_allowed", int) + _check_type_hint(self, "candidates", List) + self.candidates = [CandidateChoiceData(**_) for _ in self.candidates] diff --git a/tests/models/test_candidate_contests.py b/tests/models/test_candidate_contests.py new file mode 100644 index 0000000..934dd6c --- /dev/null +++ b/tests/models/test_candidate_contests.py @@ -0,0 +1,523 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from dataclasses import asdict + +from electos.ballotmaker.data.models import ( + CandidateChoiceData, + CandidateContestData, +) + + +# Tests + +CANDIDATE_CONTEST_TESTS = [ + # Single candidate + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + ], + "is_write_in": False, + }, + ], + }, + raises_none(), + ), + # Multiple candidates + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Cosmo Spacely", + ], + "party": [ + { + "name": "The Lepton Partyn", + "abbreviation": "LEP", + }, + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-1--candidate-2", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + ], + "is_write_in": False, + }, + ], + }, + raises_none(), + ), + # Write-in + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--write-in-1", + "name": [], + "party": [], + "is_write_in": True, + }, + ], + }, + raises_none(), + ), + # Candidate + write-in + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + } + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-1--write-in-1", + "name": [], + "party": [], + "is_write_in": True, + }, + ], + }, + raises_none(), + ), + # Empty candidates + pytest.param( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [], + }, + raises(ValueError, match = "Insufficient number of candidates"), + marks = pytest.mark.xfail(reason = "Empty 'candidates' list"), + ), + # Single candidate, duplicated + pytest.param( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + ], + "is_write_in": False, + }, + ], + }, + raises(ValueError, match = "Duplicate candidate"), + marks = pytest.mark.xfail(reason = "Duplicate candidate"), + ), + # Missing id + ( + { + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'id'"), + ), + # Missing type + ( + { + "id": "candidate-contest-1", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'type'"), + ), + # Missing title + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'title'"), + ), + # Missing district + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'district'"), + ), + # Missing vote type + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'vote_type'"), + ), + # Missing votes allowed + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'votes_allowed'"), + ), + # Missing candidates + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + }, + raises(TypeError, match = "required positional argument: 'candidates'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "required positional arguments: 'id', 'type', 'title', 'district', 'vote_type', 'votes_allowed', and 'candidates'"), + ), + # id is not a string + ( + { + "id": 1, + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [], + }, + raises(TypeError, match = "Field 'id' is not of type 'str'"), + ), + # type not a string + ( + { + "id": "candidate-contest-1", + "type": 2, + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [], + }, + raises(TypeError, match = "Field 'type' is not of type 'str'"), + ), + # title is not a string + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": 3, + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [], + }, + raises(TypeError, match = "Field 'title' is not of type 'str'"), + ), + # district is not a string + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": 4, + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [], + }, + raises(TypeError, match = "Field 'district' is not of type 'str'"), + ), + # "vote type" is not a string + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": 5, + "votes_allowed": 1, + "candidates": [], + }, + raises(TypeError, match = "Field 'vote_type' is not of type 'str'"), + ), + # "votes allowed" not an integer + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": "one", + "candidates": [], + }, + raises(TypeError, match = "Field 'votes_allowed' is not of type 'int'"), + ), + # candidates is not a list + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": {}, + }, + raises(TypeError, match = "Field 'candidates' is not of type 'List'"), + ), + # candidate is not a dictionary that can convert to a CandidateChoiceData + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [{}], + }, + raises(TypeError, match = "missing 4 required positional arguments: 'id', 'name', 'party', and 'is_write_in'"), + ), +] + + +@pytest.mark.parametrize("data, raises", CANDIDATE_CONTEST_TESTS) +def test_candidate_contest(data, raises): + with raises: + item = CandidateContestData(**data) + + +def test_candidate_contest_fields(): + data = { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + } + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-1--write-in-1", + "name": [], + "party": [], + "is_write_in": True, + }, + ], + } + item = CandidateContestData(**data) + # Scalar fields match + assert item.id == data["id"] + assert item.type == data["type"] + assert item.title == data["title"] + assert item.vote_type == data["vote_type"] + assert item.votes_allowed == data["votes_allowed"] + assert item.district == data["district"] + # Not the same type: data model converts each party to an object + assert item.candidates != data["candidates"] + # Lengths and fields are the same. + assert len(item.candidates) == len(data["candidates"]) + assert all(isinstance(_, CandidateChoiceData) for _ in item.candidates) + for actual, expected in zip(item.candidates, data["candidates"]): + actual = asdict(actual) + assert actual == expected From a112e03f8e143c50273331bcd152a1d88c8c9e61 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 09:09:48 -0700 Subject: [PATCH 42/79] Simplify test assertions on nested data by comparing dictionaries. --- tests/models/test_ballot_contests.py | 6 ++++-- tests/models/test_candidate_choices.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/models/test_ballot_contests.py b/tests/models/test_ballot_contests.py index 48b1d74..a46d6e3 100644 --- a/tests/models/test_ballot_contests.py +++ b/tests/models/test_ballot_contests.py @@ -3,6 +3,8 @@ from pytest import raises from contextlib import nullcontext as raises_none +from dataclasses import asdict + from electos.ballotmaker.data.models import ( BallotChoiceData, BallotMeasureContestData, @@ -283,5 +285,5 @@ def test_ballot_measure_contest_fields(): assert len(item.choices) == len(data["choices"]) assert all(isinstance(_, BallotChoiceData) for _ in item.choices) for actual, expected in zip(item.choices, data["choices"]): - assert actual.id == expected["id"] - assert actual.choice == expected["choice"] + actual = asdict(actual) + assert actual == expected diff --git a/tests/models/test_candidate_choices.py b/tests/models/test_candidate_choices.py index a2cfddc..e965961 100644 --- a/tests/models/test_candidate_choices.py +++ b/tests/models/test_candidate_choices.py @@ -3,6 +3,8 @@ from pytest import raises from contextlib import nullcontext as raises_none +from dataclasses import asdict + from electos.ballotmaker.data.models import ( CandidateChoiceData, PartyData, @@ -309,6 +311,6 @@ def test_candidate_choice_fields(): assert len(item.party) == len(data["party"]) assert all(isinstance(_, PartyData) for _ in item.party) for actual, expected in zip(item.party, data["party"]): - assert actual.name == expected["name"] - assert actual.abbreviation == expected["abbreviation"] + actual = asdict(actual) + assert actual == expected assert item.is_write_in == data["is_write_in"] From 78738336603966ac39e50456bd34b944d35d87e3 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 09:46:36 -0700 Subject: [PATCH 43/79] Data model for ballot styles. --- src/electos/ballotmaker/data/models.py | 39 +- tests/models/test_ballot_style.py | 491 +++++++++++++++++++++++++ 2 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 tests/models/test_ballot_style.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 12305c6..83b31e4 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -1,7 +1,7 @@ """Ballot data models.""" from dataclasses import dataclass -from typing import List +from typing import List, Union # --- Type assertions @@ -133,3 +133,40 @@ def __post_init__(self): _check_type(self, "votes_allowed", int) _check_type_hint(self, "candidates", List) self.candidates = [CandidateChoiceData(**_) for _ in self.candidates] + + +@dataclass +class BallotStyleData: + + """Date for contests associated with a ballot style.""" + + BALLOT_MEASURE = "ballot measure" + CANDIDATE = "candidate" + + # Note: Don't use separate fields for the types of contests. + # There's no guarantee the types will be clearly separated in an EDF. + # (The NIST SP-1500-100 JSON Schema uses unions too.) + + id: str + scopes: List[str] + contests: List[Union[BallotMeasureContestData, CandidateContestData]] + + def __post_init__(self): + _check_type(self, "id", str) + _check_type_hint(self, "scopes", List) + _check_type_list(self, "scopes", str) + _check_type_hint(self, "contests", List) + contests = [] + for contest in self.contests: + if not isinstance(contest, dict): + raise TypeError(f"Contest is not a dictionary: '{contest}'") + if "type" not in contest: + raise KeyError(f"Contest has no 'type' field: '{contest}'") + if contest["type"] not in (self.BALLOT_MEASURE, self.CANDIDATE): + raise ValueError(f"Unhandled contest type: '{contest['type']}'") + if contest["type"] == self.BALLOT_MEASURE: + contest = BallotMeasureContestData(**contest) + elif contest["type"] == self.CANDIDATE: + contest = CandidateContestData(**contest) + contests.append(contest) + self.contests = contests diff --git a/tests/models/test_ballot_style.py b/tests/models/test_ballot_style.py new file mode 100644 index 0000000..eac2e0b --- /dev/null +++ b/tests/models/test_ballot_style.py @@ -0,0 +1,491 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from dataclasses import asdict + +from electos.ballotmaker.data.models import ( + BallotMeasureContestData, + BallotStyleData, + CandidateContestData, +) + + +# Tests + +BALLOT_STYLE_TESTS = [ + # Single candidate contest + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + raises_none(), + ), + # Single ballot measure contest + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "ballot-measure-1", + "type": "ballot measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--no", + "choice": "no", + }, + ], + }, + ], + }, + raises_none(), + ), + # Multiple contests, conventional order + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + { + "id": "candidate-contest-2", + "type": "candidate", + "title": "President of the United States", + "district": "United States of America", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-2--candidate-1", + "name": [ + "Anthony Alpha", + "Betty Beta" + ], + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-2--candidate-2", + "name": [ + "Gloria Gamma", + "David Delta" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + ], + "is_write_in": False, + }, + ], + }, + { + "id": "ballot-measure-1", + "type": "ballot measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--no", + "choice": "no", + }, + ], + }, + ], + }, + raises_none(), + ), + # Multiple contests, candidates and write-ins intermingled + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + { + "id": "ballot-measure-1", + "type": "ballot measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--no", + "choice": "no", + }, + ], + }, + { + "id": "candidate-contest-2", + "type": "candidate", + "title": "President of the United States", + "district": "United States of America", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-2--candidate-1", + "name": [ + "Anthony Alpha", + "Betty Beta" + ], + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-2--candidate-2", + "name": [ + "Gloria Gamma", + "David Delta" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + ], + "is_write_in": False, + }, + ], + }, + ], + }, + raises_none(), + ), + # Empty contests + pytest.param( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [], + }, + raises(ValueError, match = "'contests' cannot be empty"), + marks = pytest.mark.xfail(reason = "Empty 'contest' list"), + ), + # Missing id + ( + { + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + raises(TypeError, match = "1 required positional argument: 'id'"), + ), + # Missing scopes + ( + { + "id": "precinct_2_spacetown", + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + raises(TypeError, match = "1 required positional argument: 'scopes'"), + ), + # Missing contests + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + }, + raises(TypeError, match = "1 required positional argument: 'contests'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "3 required positional arguments: 'id', 'scopes', and 'contests'"), + ), + # contests is not a list + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": {}, + }, + raises(TypeError, match = "Field 'contests' is not of type 'List'"), + ), + # Contest is not a dict + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [[]], + }, + raises(TypeError, match = "Contest is not a dictionary"), + ), + # Contest has no type + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [{}], + }, + raises(KeyError, match = "Contest has no 'type' field"), + ), + # Unhandled contest type + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [{ "type": "" }], + }, + raises(ValueError, match = "Unhandled contest type: ''"), + ), + # Ballot measure contest + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [{ "type": BallotStyleData.BALLOT_MEASURE }], + }, + raises(TypeError, match = "missing 5 required positional arguments: 'id', 'title', 'district', 'text', and 'choices'"), + ), + # Candidate contest + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [{ "type": BallotStyleData.CANDIDATE }], + }, + raises(TypeError, match = "missing 6 required positional arguments: 'id', 'title', 'district', 'vote_type', 'votes_allowed', and 'candidates'"), + ), +] + + +@pytest.mark.parametrize("data, raises", BALLOT_STYLE_TESTS) +def test_ballot_style(data, raises): + with raises: + item = BallotStyleData(**data) + + +def test_ballot_style_fields(): + data = { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + { + "id": "ballot-measure-1", + "type": "ballot measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--no", + "choice": "no", + }, + ], + }, + ], + } + item = BallotStyleData(**data) + assert item.id == data["id"] + assert item.scopes == data["scopes"] + # Not the same type: data model converts each party to an object + assert item.contests != data["contests"] + # Lengths are the same. + assert len(item.contests) == len(data["contests"]) + # Types are mixed + assert isinstance(item.contests[0], CandidateContestData) + assert isinstance(item.contests[1], BallotMeasureContestData) + for actual, expected in zip(item.contests, data["contests"]): + actual = asdict(actual) + assert actual == expected From 3621f3268a5d0f3820b995597a98e82b65ffb722 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 10:00:42 -0700 Subject: [PATCH 44/79] Data model for elections. --- src/electos/ballotmaker/data/models.py | 24 ++ tests/models/test_elections.py | 371 +++++++++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 tests/models/test_elections.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 83b31e4..20c32f0 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -170,3 +170,27 @@ def __post_init__(self): contest = CandidateContestData(**contest) contests.append(contest) self.contests = contests + + +@dataclass +class ElectionData: + + """Data for elections.""" + + # Dates are not 'datetime' for simplicity and because it's assumed the date + # is formatter correctly. That can be changed. + + name: str + type: str + start_date: str + end_date: str + ballot_styles: List[BallotStyleData] + + + def __post_init__(self): + _check_type(self, "name", str) + _check_type(self, "type", str) + _check_type(self, "start_date", str) + _check_type(self, "end_date", str) + _check_type_hint(self, "ballot_styles", List) + self.ballot_styles = [BallotStyleData(**_) for _ in self.ballot_styles] diff --git a/tests/models/test_elections.py b/tests/models/test_elections.py new file mode 100644 index 0000000..7356a3a --- /dev/null +++ b/tests/models/test_elections.py @@ -0,0 +1,371 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from dataclasses import asdict + +from electos.ballotmaker.data.models import ( + BallotStyleData, + ElectionData, +) + + +# Tests + +ELECTION_TESTS = [ + ( + { + "name": "General Election", + "type": "general", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + }, + raises_none() + ), + # Empty ballot styles + pytest.param( + { + "name": "General Election", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "type": "general", + "ballot_styles": [], + }, + raises(ValueError, match = "Ballot styles are empty"), + marks = pytest.mark.xfail(reason = "Empty 'ballot_styles' list") + ), + # Missing name + ( + { + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "type": "general", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + }, + raises(TypeError, match = "required positional argument: 'name'") + ), + # Missing type + ( + { + "name": "General Election", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + }, + raises(TypeError, match = "required positional argument: 'type'") + ), + # Missing start date + ( + { + "name": "General Election", + "end_date": "2024-11-05", + "type": "general", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + }, + raises(TypeError, match = "required positional argument: 'start_date'") + ), + # Missing end date + ( + { + "name": "General Election", + "start_date": "2024-11-05", + "type": "general", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + }, + raises(TypeError, match = "required positional argument: 'end_date'") + ), + # Missing ballot styles + ( + { + "name": "General Election", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "type": "general", + }, + raises(TypeError, match = "required positional argument: 'ballot_styles'") + ), + # Empty object + ( + {}, + raises(TypeError, match = "5 required positional arguments: 'name', 'type', 'start_date', 'end_date', and 'ballot_styles'") + ), + # name is not a string + ( + { + "name": 1, + "type": "general", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [], + }, + raises(TypeError, match = "Field 'name' is not of type 'str'"), + ), + # type is not a string + ( + { + "name": "General Election", + "type": 2, + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [], + }, + raises(TypeError, match = "Field 'type' is not of type 'str'"), + ), + # 'start date' is not a string + ( + { + "name": "General Election", + "type": "general", + "start_date": 3, + "end_date": "2024-11-05", + "ballot_styles": [], + }, + raises(TypeError, match = "Field 'start_date' is not of type 'str'"), + ), + # 'end date' is not a string + ( + { + "name": "General Election", + "type": "general", + "start_date": "2024-11-05", + "end_date": 4, + "ballot_styles": [], + }, + raises(TypeError, match = "Field 'end_date' is not of type 'str'"), + ), + # 'ballot styles' is not a list + ( + { + "name": "General Election", + "type": "general", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": {}, + }, + raises(TypeError, match = "Field 'ballot_styles' is not of type 'List'"), + ), +] + + +@pytest.mark.parametrize("data, raises", ELECTION_TESTS) +def test_election(data, raises): + with raises: + item = ElectionData(**data) + + +def test_election_fields(): + data = { + "name": "General Election", + "type": "general", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + } + item = ElectionData(**data) + # Scalar fields match + assert item.name == data["name"] + assert item.type == data["type"] + assert item.start_date == data["start_date"] + assert item.end_date == data["end_date"] + # Not the same type: data model converts each party to an object + assert item.ballot_styles != data["ballot_styles"] + # Lengths and fields are the same. + assert len(item.ballot_styles) == len(data["ballot_styles"]) + assert all(isinstance(_, BallotStyleData) for _ in item.ballot_styles) + for actual, expected in zip(item.ballot_styles, data["ballot_styles"]): + actual = asdict(actual) + assert actual == expected From 893af78666cc6da84d34f1375e4e76bc97a9c955 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 10:35:08 -0700 Subject: [PATCH 45/79] Use a base type for contest models. Partly to share fields but primarily for correctness: contests have to be a single list independent of sub-type because that's how EDFs work. Splitting the types into multiple lists would lose the order. --- src/electos/ballotmaker/data/models.py | 59 +++++++++++++++----------- tests/models/test_ballot_style.py | 5 ++- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 20c32f0..5d38cf4 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -1,6 +1,7 @@ """Ballot data models.""" from dataclasses import dataclass +from enum import Enum from typing import List, Union @@ -37,6 +38,31 @@ def _check_type_list(instance, field, type_): ) +# --- Contest model base type + +@dataclass +class ContestData: + + """Shared data for contests.""" + + id: str + type: str + title: str + district: str + + def __post_init__(self): + _check_type(self, "id", str) + _check_type(self, "type", str) + _check_type(self, "title", str) + _check_type(self, "district", str) + + +class ContestType(Enum): + + BALLOT_MEASURE = "ballot measure" + CANDIDATE = "candidate" + + # --- Ballot contest data models @@ -48,29 +74,21 @@ class BallotChoiceData: id: str choice: str - def __post_init__(self): _check_type(self, "id", str) _check_type(self, "choice", str) @dataclass -class BallotMeasureContestData: +class BallotMeasureContestData(ContestData): """Data for ballot measure contests.""" - id: str - type: str - title: str - district: str text: str choices: List[BallotChoiceData] def __post_init__(self): - _check_type(self, "id", str) - _check_type(self, "type", str) - _check_type(self, "title", str) - _check_type(self, "district", str) + super().__post_init__() _check_type(self, "text", str) _check_type_hint(self, "choices", List) self.choices = [BallotChoiceData(**_) for _ in self.choices] @@ -112,23 +130,16 @@ def __post_init__(self): @dataclass -class CandidateContestData: +class CandidateContestData(ContestData): """Data for candidate contests.""" - id: str - type: str - title: str - district: str vote_type: str votes_allowed: str candidates: List[CandidateChoiceData] def __post_init__(self): - _check_type(self, "id", str) - _check_type(self, "type", str) - _check_type(self, "title", str) - _check_type(self, "district", str) + super().__post_init__() _check_type(self, "vote_type", str) _check_type(self, "votes_allowed", int) _check_type_hint(self, "candidates", List) @@ -140,9 +151,6 @@ class BallotStyleData: """Date for contests associated with a ballot style.""" - BALLOT_MEASURE = "ballot measure" - CANDIDATE = "candidate" - # Note: Don't use separate fields for the types of contests. # There's no guarantee the types will be clearly separated in an EDF. # (The NIST SP-1500-100 JSON Schema uses unions too.) @@ -157,16 +165,17 @@ def __post_init__(self): _check_type_list(self, "scopes", str) _check_type_hint(self, "contests", List) contests = [] + contest_types = [_.value for _ in ContestType] for contest in self.contests: if not isinstance(contest, dict): raise TypeError(f"Contest is not a dictionary: '{contest}'") if "type" not in contest: raise KeyError(f"Contest has no 'type' field: '{contest}'") - if contest["type"] not in (self.BALLOT_MEASURE, self.CANDIDATE): + if contest["type"] not in contest_types: raise ValueError(f"Unhandled contest type: '{contest['type']}'") - if contest["type"] == self.BALLOT_MEASURE: + if contest["type"] == ContestType.BALLOT_MEASURE.value: contest = BallotMeasureContestData(**contest) - elif contest["type"] == self.CANDIDATE: + elif contest["type"] == ContestType.CANDIDATE.value: contest = CandidateContestData(**contest) contests.append(contest) self.contests = contests diff --git a/tests/models/test_ballot_style.py b/tests/models/test_ballot_style.py index eac2e0b..82ef6d8 100644 --- a/tests/models/test_ballot_style.py +++ b/tests/models/test_ballot_style.py @@ -9,6 +9,7 @@ BallotMeasureContestData, BallotStyleData, CandidateContestData, + ContestType, ) @@ -403,7 +404,7 @@ "scopes": [ "spacetown-precinct", ], - "contests": [{ "type": BallotStyleData.BALLOT_MEASURE }], + "contests": [{ "type": ContestType.BALLOT_MEASURE.value }], }, raises(TypeError, match = "missing 5 required positional arguments: 'id', 'title', 'district', 'text', and 'choices'"), ), @@ -414,7 +415,7 @@ "scopes": [ "spacetown-precinct", ], - "contests": [{ "type": BallotStyleData.CANDIDATE }], + "contests": [{ "type": ContestType.CANDIDATE.value }], }, raises(TypeError, match = "missing 6 required positional arguments: 'id', 'title', 'district', 'vote_type', 'votes_allowed', and 'candidates'"), ), From df0a1936d22d85515ada876a884ca51fd358d2bc Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Mon, 12 Sep 2022 14:36:37 -0400 Subject: [PATCH 46/79] Add class override for buttonFieldRelative --- samplecode/form_table.py | 62 +++++++++++++++++++ .../ballotmaker/ballots/contest_layout.py | 36 ++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 samplecode/form_table.py diff --git a/samplecode/form_table.py b/samplecode/form_table.py new file mode 100644 index 0000000..0ed6e45 --- /dev/null +++ b/samplecode/form_table.py @@ -0,0 +1,62 @@ +# from https://groups.google.com/g/reportlab-users/c/KRx3oLi34Dc/m/3GGnhy3qCQAJ +# import PyPDF2 +from reportlab.pdfbase import pdfform +from reportlab.platypus import Flowable, SimpleDocTemplate, Table + +# from pprint import pprint + + +class formCheckButton(Flowable): + def __init__(self, title, value="Yes"): + self.title = title + self.value = value + self.width = 16 + self.height = 16 + + def wrap(self, *args): + self.width = args[0] + return (self.width, self.height) + + def draw(self): + self.canv.saveState() + pdfform.buttonFieldRelative( + self.canv, + self.title, + self.value, + 0, + 0, + # including w & h shift the buttons up + # width=self.width, + # height=self.height, + ) + self.canv.restoreState() + + +class createExamplePDFFormFile: + def __init__(self, filename): + data = [] + value = "Yes" + for i in range(10): + title = f"title {i}" + checkbox = formCheckButton(title, value) + data.append([title, checkbox]) + dataTable = Table(data) + print([dataTable]) + doc = SimpleDocTemplate(filename) + doc.build([dataTable]) + + +# class readExamplePDFFormFile: +# def __init__(self, filename): +# f = PyPDF2.PdfFileReader(filename) +# data = f.getFields() +# for title, value in data.items(): +# pprint(value) + + +ORIGINAL_FILE = "OriginalFile.pdf" +EDITED_FILE = "EditedFile.pdf" + +createExamplePDFFormFile(ORIGINAL_FILE) +# readExamplePDFFormFile(ORIGINAL_FILE) +# readExamplePDFFormFile(EDITED_FILE) diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index 68ab251..9411373 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -8,7 +8,8 @@ from reportlab.graphics.shapes import Drawing, Ellipse, _DrawingEditorMixin from reportlab.lib.colors import black, white from reportlab.lib.styles import LineStyle, getSampleStyleSheet -from reportlab.platypus import Paragraph, Table +from reportlab.pdfbase import pdfform +from reportlab.platypus import Flowable, Paragraph, Table oval_width = 10 oval_height = 4 @@ -160,6 +161,32 @@ def __init__(self, width=400, height=200, *args, **kw): self.oval.strokeWidth = sm_line +class formCheckButton(Flowable): + def __init__(self, title, value="Yes"): + self.title = title + self.value = value + self.width = 16 + self.height = 16 + + def wrap(self, *args): + self.width = args[0] + return (self.width, self.height) + + def draw(self): + self.canv.saveState() + pdfform.buttonFieldRelative( + self.canv, + self.title, + self.value, + 0, + 0, + # including w & h shift the buttons up + # width=self.width, + # height=self.height, + ) + self.canv.restoreState() + + class CandidateContestLayout: """ Generate a candidate contest table flowable @@ -176,7 +203,6 @@ def __init__(self, contest_data: CandidateContestData): self.candidates = contest_data.candidates _selections = [] - oval = SelectionOval() for candidate in self.candidates: # add newlines around " and " if candidate.name.find(" and "): @@ -189,7 +215,11 @@ def __init__(self, contest_data: CandidateContestData): contest_line = f"{candidate.name}" if candidate.party_abbr != "": contest_line += f"
{candidate.party_abbr}" - contest_row = [oval, Paragraph(contest_line, normal)] + if True: + vote_mark = formCheckButton(candidate.name, "Yes") + else: + vote_mark = SelectionOval() + contest_row = [vote_mark, Paragraph(contest_line, normal)] _selections.append(contest_row) # build the contest table, an attribute of the Contest object From e03c3bbd78b34ae4fa0604228ef04ccd66615e73 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 13:34:54 -0700 Subject: [PATCH 47/79] Add derived candidate and ballot measure contest properties for ballot styles. --- src/electos/ballotmaker/data/models.py | 15 +++++++++++++++ tests/models/test_ballot_style.py | 2 ++ 2 files changed, 17 insertions(+) diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 5d38cf4..272dd00 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from enum import Enum +from functools import cached_property from typing import List, Union @@ -181,6 +182,20 @@ def __post_init__(self): self.contests = contests + @cached_property + def ballot_measure_contests(self): + return [ + _ for _ in self.contests if _.type == ContestType.BALLOT_MEASURE.value + ] + + + @cached_property + def candidate_contests(self): + return [ + _ for _ in self.contests if _.type == ContestType.CANDIDATE.value + ] + + @dataclass class ElectionData: diff --git a/tests/models/test_ballot_style.py b/tests/models/test_ballot_style.py index 82ef6d8..4de635b 100644 --- a/tests/models/test_ballot_style.py +++ b/tests/models/test_ballot_style.py @@ -487,6 +487,8 @@ def test_ballot_style_fields(): # Types are mixed assert isinstance(item.contests[0], CandidateContestData) assert isinstance(item.contests[1], BallotMeasureContestData) + assert item.candidate_contests == [item.contests[0]] + assert item.ballot_measure_contests == [item.contests[1]] for actual, expected in zip(item.contests, data["contests"]): actual = asdict(actual) assert actual == expected From a7f19c1b6d7b572364fbbfaa3997284083b03b8d Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 16:00:58 -0700 Subject: [PATCH 48/79] Reorder contest fields in ballot data extractor to match ballot data model. Do some minor code cleanup. --- src/electos/ballotmaker/data/extractor.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index c0bf0b9..9813063 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -70,6 +70,7 @@ def ballot_style_external_id(ballot_style: BallotStyle): def ballot_style_gp_units(ballot_style: BallotStyle, index): + """Yield geo-political units for a ballot style.""" for id_ in ballot_style.gp_unit_ids: gp_unit = index.by_id(id_) yield gp_unit @@ -91,7 +92,7 @@ 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. - Drop either field from result if it isn't present. + 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 @@ -193,12 +194,12 @@ def extract_candidate_contest(contest: CandidateContest, index): candidates = candidate_contest_candidates(contest, index) result = { "id": contest.model__id, - "title": contest.name, "type": "candidate", + "title": contest.name, + "district": district, "vote_type": contest.vote_variation.value, # Include even when default is 1: don't require caller to track that. "votes_allowed": contest.votes_allowed, - "district": district, "candidates": candidates, # "offices": offices, # "parties": parties, @@ -221,11 +222,11 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): full_text = text_content(contest.full_text) result = { "id": contest.model__id, - "title": contest.name, "type": "ballot measure", + "title": contest.name, "district": district, - "choices": choices, "text": full_text, + "choices": choices, } return result From 341df82d9f443475268b453f3dfe15fff43c1a64 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 16:18:38 -0700 Subject: [PATCH 49/79] Don't return an empty party dictionary when there is no party. An empty dictionary adds a special case to 'PartyData' construction. --- src/electos/ballotmaker/data/extractor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 9813063..b3d7ab4 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -137,8 +137,9 @@ def candidate_contest_candidates(contest: CandidateContest, index): if name: names.append(name) party, _party_id = candidate_party(candidate, index) - parties.append(party) - _party_ids.add(_party_id) + if party: + 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: From 002e41564449972aac61cfef97638a50c41b135d Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 18:01:13 -0700 Subject: [PATCH 50/79] Re-combine candidate and ballot measure contests in extractor. --- src/electos/ballotmaker/data/edf-to-ballot-data.py | 2 +- src/electos/ballotmaker/data/extractor.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index a7d0634..06feb26 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -22,7 +22,7 @@ def report(root, index, nth, **opts): data = {} id_ = ballot_style_external_id(ballot_style) data["ballot_style"] = id_ - contests = extract_contests(ballot_style, index) + contests = list(extract_contests(ballot_style, index)) if not contests: print(f"No contests found for ballot style: {id_}\n") data["contests"] = contests diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index b3d7ab4..ad025a3 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -234,20 +234,15 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): def extract_contests(ballot_style: BallotStyle, index): """Extract contest subset needed for ballots.""" - contests = { - kind: [] for kind in ("candidate", "ballot_measure") - } for contest in ballot_style_contests(ballot_style, index): if isinstance(contest, CandidateContest): entry = extract_candidate_contest(contest, index) - contests["candidate"].append(entry) elif isinstance(contest, BallotMeasureContest): entry = extract_ballot_measure_contest(contest, index) - contests["ballot_measure"].append(entry) else: # Ignore other contest types print(f"Skipping contest of type {contest.model__type}") - return contests + yield entry def extract_ballot_styles(election_report: ElectionReport, index): From 305929785ec3a7d43aa7ce738beb6660ef2e2256 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Tue, 13 Sep 2022 10:41:40 -0400 Subject: [PATCH 51/79] Add IDs to PDF form --- src/electos/ballotmaker/ballots/contest_layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index 9411373..23a8fa5 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -216,7 +216,7 @@ def __init__(self, contest_data: CandidateContestData): if candidate.party_abbr != "": contest_line += f"
{candidate.party_abbr}" if True: - vote_mark = formCheckButton(candidate.name, "Yes") + vote_mark = formCheckButton(candidate.id, "Yes") else: vote_mark = SelectionOval() contest_row = [vote_mark, Paragraph(contest_line, normal)] From da218dbd608c6bd892d405a88f60ad0b731f8d05 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Tue, 13 Sep 2022 17:32:12 -0400 Subject: [PATCH 52/79] Replace magic numbers with constants --- .../ballotmaker/ballots/contest_layout.py | 124 +++++++++++++----- 1 file changed, 89 insertions(+), 35 deletions(-) diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index 23a8fa5..e3c6966 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -6,13 +6,31 @@ ) from electos.ballotmaker.ballots.page_layout import PageLayout from reportlab.graphics.shapes import Drawing, Ellipse, _DrawingEditorMixin -from reportlab.lib.colors import black, white -from reportlab.lib.styles import LineStyle, getSampleStyleSheet +from reportlab.lib.colors import black, white, yellow +from reportlab.lib.styles import getSampleStyleSheet from reportlab.pdfbase import pdfform from reportlab.platypus import Flowable, Paragraph, Table -oval_width = 10 -oval_height = 4 +OVAL_WIDTH = 13 +OVAL_HEIGHT = 5 + +OVAL_UP = -6 +OVAL_DOWN = 2 +OVAL_INDENT = 3 +SOLID_FILL = 1 + +ONE_LINE = 12 +FOUR_LINES = 48 + +YES = 1 + +CHECKBOX_W = 8 +CHECKBOX_H = 8 +CHECKBOX_X = 3 +CHECKBOX_Y = -12 + +WRITE_IN_W = 100 +WRITE_IN_H = 12 # define styles # fill colors @@ -42,8 +60,8 @@ black, font_bold, normal_lead, - sp_after=48, - keep_w_next=1, + sp_after=FOUR_LINES, + keep_w_next=YES, ) PageLayout.define_custom_style( h2, @@ -53,8 +71,8 @@ black, font_bold, normal_lead, - sp_before=12, - keep_w_next=1, + sp_before=ONE_LINE, + keep_w_next=YES, ) PageLayout.define_custom_style( normal, @@ -86,7 +104,7 @@ def build_contest_list( def build_candidate_table(contest_list): return Table( data=contest_list, - colWidths=(oval_width * 3, None), + colWidths=(OVAL_WIDTH * 3, None), style=[ # draw lines below each contestant ("LINEBELOW", (1, 2), (1, -1), sm_line, grey), @@ -113,7 +131,7 @@ def build_candidate_table(contest_list): def build_ballot_measure_table(contest_list): return Table( data=contest_list, - colWidths=(oval_width * 3, None), + colWidths=(OVAL_WIDTH * 3, None), style=[ # draw lines below each selection ("LINEBELOW", (1, 2), (1, -1), sm_line, grey), @@ -141,22 +159,26 @@ def build_ballot_measure_table(contest_list): class SelectionOval(_DrawingEditorMixin, Drawing): - def __init__(self, width=400, height=200, *args, **kw): + def __init__(self, width=400, height=200, shift_up=False, *args, **kw): Drawing.__init__(self, width, height, *args, **kw) - self.width = oval_width + PageLayout.border_pad - self.height = oval_height + PageLayout.border_pad - oval_cx = self.width / 2 - down_shift = 2 - oval_cy = (self.height / 2) - down_shift + self.width = OVAL_WIDTH + PageLayout.border_pad + self.height = OVAL_HEIGHT + PageLayout.border_pad + if shift_up: + _vertical_shift = OVAL_UP + else: + _vertical_shift = OVAL_DOWN + oval_cx = (self.width / 2) + OVAL_INDENT + oval_cy = (self.height / 2) - _vertical_shift self._add( self, - Ellipse(oval_cx, oval_cy, oval_width, oval_height), + Ellipse(oval_cx, oval_cy, OVAL_WIDTH, OVAL_HEIGHT), name="oval", validate=None, desc=None, ) - self.oval.fillColor = white + self.oval.fillColor = yellow # white + self.oval.fillOpacity = SOLID_FILL self.oval.strokeColor = black self.oval.strokeWidth = sm_line @@ -165,8 +187,10 @@ class formCheckButton(Flowable): def __init__(self, title, value="Yes"): self.title = title self.value = value - self.width = 16 - self.height = 16 + self.x = CHECKBOX_X + self.y = CHECKBOX_Y + self.width = CHECKBOX_W + self.height = CHECKBOX_H def wrap(self, *args): self.width = args[0] @@ -178,11 +202,29 @@ def draw(self): self.canv, self.title, self.value, - 0, - 0, - # including w & h shift the buttons up - # width=self.width, - # height=self.height, + self.x, + self.y, + width=self.width, + height=self.height, + ) + self.canv.restoreState() + + +class formInputField(Flowable): + def __init__(self, id, value=""): + self.id = id + self.value = value + self.width = 0 + self.height = 10 + + def wrap(self, *args): + self.width = args[0] + return (self.width, self.height) + + def draw(self): + self.canv.saveState() + pdfform.textFieldRelative( + self.canv, self.id, 0, 0, WRITE_IN_W, WRITE_IN_H, self.value ) self.canv.restoreState() @@ -209,17 +251,29 @@ def __init__(self, contest_data: CandidateContestData): candidate.name = candidate.name.replace( " and ", "
and
" ) + # make the candidate name bold + contest_text = f"{candidate.name}" + # add party abbreviation in plain text + if candidate.party_abbr != "": + contest_text += f"
{candidate.party_abbr}" + contest_object = [Paragraph(contest_text, normal)] # add line for write ins if candidate.is_write_in: - candidate.name += ("
" * 2) + ("_" * 20) - contest_line = f"{candidate.name}" - if candidate.party_abbr != "": - contest_line += f"
{candidate.party_abbr}" + # contest_text += ("
" * 2) + ("_" * 20) + # Add text field + input_id = f"{candidate.id}_input" + contest_object.append(formInputField(input_id)) + + # add form objects? if True: - vote_mark = formCheckButton(candidate.id, "Yes") - else: - vote_mark = SelectionOval() - contest_row = [vote_mark, Paragraph(contest_line, normal)] + # add check box + vote_mark = [ + SelectionOval(shift_up=True), + formCheckButton(candidate.id, "Yes"), + ] + # else: + # vote_mark = SelectionOval() + contest_row = [vote_mark, contest_object] _selections.append(contest_row) # build the contest table, an attribute of the Contest object @@ -244,8 +298,8 @@ def __init__(self, contest_data: BallotMeasureData): oval = SelectionOval() _selections = [] for choice in self.choices: - contest_line = f"{choice}" - contest_row = [oval, Paragraph(contest_line, normal)] + contest_text = f"{choice}" + contest_row = [oval, Paragraph(contest_text, normal)] _selections.append(contest_row) self.contest_list = build_contest_list( From b173842306590f842fcde1b88e33875ec8f54ce3 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 19:42:20 -0700 Subject: [PATCH 53/79] Re-implement and simplify the ballot extractor interface. - Unify extraction functions: - Use direct field accesses for properties, index for references. - Yield data dictionaries instead of returning lists. - Add in election data. - Entry point is now 'extract_ballot_data', which returns a list. --- .../ballotmaker/data/edf-to-ballot-data.py | 31 ++++------------ src/electos/ballotmaker/data/extractor.py | 36 +++++++++++++++++-- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index 06feb26..56327c0 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -5,27 +5,12 @@ from electos.datamodels.nist.indexes import ElementIndex from electos.datamodels.nist.models.edf import ElectionReport -from electos.ballotmaker.data.extractor import ( - ballot_style_external_id, - extract_ballot_styles, - extract_contests, -) +from electos.ballotmaker.data.extractor import extract_ballot_data -def report(root, index, nth, **opts): +def report(document, index, **opts): """Generate data needed by BallotLab.""" - ballot_styles = list(extract_ballot_styles(root, index)) - if not (1 <= nth <= len(ballot_styles)): - print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") - return - ballot_style = ballot_styles[nth - 1] - data = {} - id_ = ballot_style_external_id(ballot_style) - data["ballot_style"] = id_ - contests = list(extract_contests(ballot_style, index)) - if not contests: - print(f"No contests found for ballot style: {id_}\n") - data["contests"] = contests + data = extract_ballot_data(document, index) print(json.dumps(data, indent = 4)) @@ -35,10 +20,6 @@ def main(): "file", type = Path, help = "Test case data (JSON)" ) - parser.add_argument( - "nth", nargs = "?", type = int, default = 1, - help = "Index of the ballot style, starting from 1 (default: 1)" - ) parser.add_argument( "--debug", action = "store_true", help = "Enable debugging output and stack traces" @@ -51,9 +32,9 @@ def main(): with file.open() as input: text = input.read() data = json.loads(text) - edf = ElectionReport(**data) - index = ElementIndex(edf, "ElectionResults") - report(edf, index, **opts) + document = ElectionReport(**data) + index = ElementIndex(document, "ElectionResults") + report(document, index, **opts) except Exception as ex: if opts["debug"]: raise ex diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index ad025a3..21a3387 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -8,6 +8,7 @@ Candidate, CandidateContest, CandidateSelection, + Election, ElectionReport, InternationalizedText, OrderedContest, @@ -245,7 +246,36 @@ def extract_contests(ballot_style: BallotStyle, index): yield entry -def extract_ballot_styles(election_report: ElectionReport, index): +def extract_ballot_styles(election: Election, index): """Extract all ballot styles.""" - for ballot_style in index.by_type("BallotStyle"): - yield ballot_style + for ballot_style in election.ballot_style: + data = { + "id": ballot_style_external_id(ballot_style), + "scopes": [ _.model__id for _ in ballot_style_gp_units(ballot_style, index) ], + "contests": [ _ for _ in extract_contests(ballot_style, index) ], + } + yield data + + +def extract_election_data(election_report: ElectionReport, index): + """Extract all elections.""" + # In most cases there isn't more than one 'Election' in a report, but the + # standard allows more than one, so handle them. + for election in election_report.election: + data = { + "name": text_content(election.name), + "type": election.type.value, + "start_date": election.start_date.strftime("%Y-%m-%d"), + "end_date": election.end_date.strftime("%Y-%m-%d"), + "ballot_styles": [_ for _ in extract_ballot_styles(election, index)], + } + yield data + + +def extract_ballot_data(election_report: ElectionReport, index: ElementIndex): + """Extract election data. + This is the primary entry point for the extractor. + """ + index = index or ElementIndex(election_report, "ElectionResults") + election_data = [ _ for _ in extract_election_data(election_report, index) ] + return election_data From 5ace8d6123143f666a514b158d55ef3328d616c8 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 20:04:54 -0700 Subject: [PATCH 54/79] Move document and index creation into 'report'. --- src/electos/ballotmaker/data/edf-to-ballot-data.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index 56327c0..0d68c5c 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -8,10 +8,12 @@ from electos.ballotmaker.data.extractor import extract_ballot_data -def report(document, index, **opts): +def report(data, **opts): """Generate data needed by BallotLab.""" - data = extract_ballot_data(document, index) - print(json.dumps(data, indent = 4)) + document = ElectionReport(**data) + index = ElementIndex(document, "ElectionResults") + ballot_data = extract_ballot_data(document, index) + print(json.dumps(ballot_data, indent = 4)) def main(): @@ -32,9 +34,7 @@ def main(): with file.open() as input: text = input.read() data = json.loads(text) - document = ElectionReport(**data) - index = ElementIndex(document, "ElectionResults") - report(document, index, **opts) + report(data, **opts) except Exception as ex: if opts["debug"]: raise ex From 04e0d3e1bf11dc2aefb7509f01895c30ab51773a Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 20:16:45 -0700 Subject: [PATCH 55/79] Make 'ElectionReport' and 'ElementIndex' internal to the extractor. The caller only receives an 'ElectionData' back. --- src/electos/ballotmaker/data/edf-to-ballot-data.py | 9 +++------ src/electos/ballotmaker/data/extractor.py | 10 +++++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index 0d68c5c..0647722 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -1,18 +1,15 @@ import argparse import json +from dataclasses import asdict from pathlib import Path -from electos.datamodels.nist.indexes import ElementIndex -from electos.datamodels.nist.models.edf import ElectionReport - from electos.ballotmaker.data.extractor import extract_ballot_data def report(data, **opts): """Generate data needed by BallotLab.""" - document = ElectionReport(**data) - index = ElementIndex(document, "ElectionResults") - ballot_data = extract_ballot_data(document, index) + ballot_data = extract_ballot_data(data) + ballot_data = [asdict(_) for _ in ballot_data] print(json.dumps(ballot_data, indent = 4)) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 21a3387..e157cc5 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -1,4 +1,4 @@ -from typing import List, Union +from typing import Dict, List, Union from electos.datamodels.nist.indexes import ElementIndex from electos.datamodels.nist.models.edf import ( @@ -272,10 +272,14 @@ def extract_election_data(election_report: ElectionReport, index): yield data -def extract_ballot_data(election_report: ElectionReport, index: ElementIndex): +def extract_ballot_data(data: Dict, index: ElementIndex = None) -> ElectionData: """Extract election data. + This is the primary entry point for the extractor. """ + election_report = ElectionReport(**data) index = index or ElementIndex(election_report, "ElectionResults") - election_data = [ _ for _ in extract_election_data(election_report, index) ] + election_data = [ + ElectionData(**_) for _ in extract_election_data(election_report, index) + ] return election_data From 5350723a2871f53f07c14a193f55f14749c08886 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 20:18:09 -0700 Subject: [PATCH 56/79] Document the entry point of the extractor API. --- src/electos/ballotmaker/data/extractor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index e157cc5..8001a36 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -276,6 +276,15 @@ def extract_ballot_data(data: Dict, index: ElementIndex = None) -> ElectionData: """Extract election data. This is the primary entry point for the extractor. + + Parameters: + data: An EDF / election report dictionary. + index: An ElementIndex. + If empty (the default), create a new index from the election report. + Use this parameter only if there's already an existing index. + + Returns: + Election data model for use in ballot rendering. """ election_report = ElectionReport(**data) index = index or ElementIndex(election_report, "ElectionResults") From 7e2f748d74d9a329dbb615776c74c8623aa84673 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 20:40:18 -0700 Subject: [PATCH 57/79] Turn ballot extractor into a class. - Public API is now 'extract'. - Prefix all internal methods as private. - Prefix utility functions but don't make them methods. - Make the element index an instance variable. - Re-wrap text to fit within line length. Note: In a class method order usually puts public functions at the top. Not doing that here to avoid making the change diff useless. --- .../ballotmaker/data/edf-to-ballot-data.py | 5 +- src/electos/ballotmaker/data/extractor.py | 479 +++++++++--------- 2 files changed, 251 insertions(+), 233 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index 0647722..42963a7 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -3,12 +3,13 @@ from dataclasses import asdict from pathlib import Path -from electos.ballotmaker.data.extractor import extract_ballot_data +from electos.ballotmaker.data.extractor import BallotDataExtractor def report(data, **opts): """Generate data needed by BallotLab.""" - ballot_data = extract_ballot_data(data) + extractor = BallotDataExtractor() + ballot_data = extractor.extract(data) ballot_data = [asdict(_) for _ in ballot_data] print(json.dumps(ballot_data, indent = 4)) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 8001a36..10463e0 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -1,5 +1,6 @@ from typing import Dict, List, Union +from electos.ballotmaker.data.models import ElectionData from electos.datamodels.nist.indexes import ElementIndex from electos.datamodels.nist.models.edf import ( BallotMeasureContest, @@ -28,267 +29,283 @@ # --- Utilities -def text_content(item): +def _text_content(item): """Return joined lines from internationalized text.""" assert isinstance(item, InternationalizedText) text = "\n".join(_.content for _ in item.text) return text -def walk_ordered_contests(content: List[OrderedContent]): +def _walk_ordered_contests(content: List[OrderedContent]): """Walk ordered content yielding contests.""" for item in content: if isinstance(item, OrderedContest): yield item elif isinstance(item, OrderedHeader): - yield from walk_ordered_contests(item.ordered_content) + yield from _walk_ordered_contests(item.ordered_content) else: raise TypeError(f"Unexpected type: {type(item).__name__}") -def walk_ordered_headers(content: List[OrderedContent]): +def _walk_ordered_headers(content: List[OrderedContent]): """Walk ordered content yielding headers.""" for item in content: if isinstance(item, OrderedHeader): yield item - yield from walk_ordered_headers(item.ordered_content) + yield from _walk_ordered_headers(item.ordered_content) else: raise TypeError(f"Unexpected type: {type(item).__name__}") -# --- Ballot Properties +# --- Extractor +class BallotDataExtractor: -def ballot_style_external_id(ballot_style: BallotStyle): - """Get the text of a ballot style's external identifier if any.""" - if ballot_style.external_identifier: - assert len(ballot_style.external_identifier) == 1, \ - "Not ready to handle multiple BallotStyle external IDs" - name = ballot_style.external_identifier[0].value - else: - name = "" - return name + """Extract election data from an EDF.""" -def ballot_style_gp_units(ballot_style: BallotStyle, index): - """Yield geo-political units for a ballot style.""" - for id_ in ballot_style.gp_unit_ids: - gp_unit = index.by_id(id_) - yield gp_unit + def __init__(self): + pass -def ballot_style_contests(ballot_style: BallotStyle, index): - """Yield the contests of a ballot style.""" - for item in walk_ordered_contests(ballot_style.ordered_content): - contest = index.by_id(item.contest_id) - yield contest - - -def candidate_name(candidate: Candidate): - """Get the name of a candidate as it appears on a ballot.""" - name = text_content(candidate.ballot_name) - return name - - -def candidate_party(candidate: Candidate, index): - """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 = [] + def _ballot_style_external_id(self, ballot_style: BallotStyle): + """Get the text of a ballot style's external identifier if any.""" + if ballot_style.external_identifier: + assert len(ballot_style.external_identifier) == 1, \ + "Not ready to handle multiple BallotStyle external IDs" + name = ballot_style.external_identifier[0].value + else: + name = "" + return name + + + def _ballot_style_gp_units(self, ballot_style: BallotStyle): + """Yield geo-political units for a ballot style.""" + for id_ in ballot_style.gp_unit_ids: + gp_unit = self._index.by_id(id_) + yield gp_unit + + + def _ballot_style_contests(self, ballot_style: BallotStyle): + """Yield the contests of a ballot style.""" + for item in _walk_ordered_contests(ballot_style.ordered_content): + contest = self._index.by_id(item.contest_id) + yield contest + + + def _candidate_name(self, candidate: Candidate): + """Get the name of a candidate as it appears on a ballot.""" + name = _text_content(candidate.ballot_name) + return name + + + def _candidate_party(self, candidate: Candidate): + """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 = self._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(self, contest: CandidateContest): + """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/tickets + 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 = self._index.by_id(id_) + name = self._candidate_name(candidate) + if name: + names.append(name) + party, _party_id = self._candidate_party(candidate) + if party: + 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(self, contest: CandidateContest): + """Get any offices associated with a candidate contest.""" + offices = [] + if contest.office_ids: + for id_ in contest.office_ids: + office = self._index.by_id(id_) + name = _text_content(office.name) + offices.append(name) + return offices + + + def _candidate_contest_parties(self, contest: CandidateContest): + """Get any parties associated with a candidate contest.""" 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) - if party: - 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] + if contest.primary_party_ids: + for id_ in contest.primary_party_ids: + party = self._index.by_id(id_) + name = _text_content(party.name) + parties.append(name) + return parties + + + def _contest_election_district(self, contest: Contest): + """Get the district name of a contest.""" + district = self._index.by_id(contest.election_district_id) + district = _text_content(district.name) + return district + + + def _extract_candidate_contest(self, contest: CandidateContest): + """Extract candidate contest subset needed for a ballot.""" + district = self._contest_election_district(contest) + offices = self._candidate_contest_offices(contest) + parties = self._candidate_contest_parties(contest) + candidates = self._candidate_contest_candidates(contest) 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): - """Get any offices associated with a candidate contest.""" - offices = [] - if contest.office_ids: - for id_ in contest.office_ids: - office = index.by_id(id_) - name = text_content(office.name) - offices.append(name) - return offices - - -def candidate_contest_parties(contest: CandidateContest, index): - """Get any parties associated with a candidate contest.""" - parties = [] - if contest.primary_party_ids: - for id_ in contest.primary_party_ids: - party = index.by_id(id_) - name = text_content(party.name) - parties.append(name) - return parties - - -def contest_election_district(contest: Contest, index): - """Get the district name of a contest.""" - district = index.by_id(contest.election_district_id) - district = text_content(district.name) - return district - - -# --- Extraction -# -# Gather and select data needed for ballot generation. - -def extract_candidate_contest(contest: CandidateContest, index): - """Extract candidate contest subset needed for a ballot.""" - district = contest_election_district(contest, index) - offices = candidate_contest_offices(contest, index) - parties = candidate_contest_parties(contest, index) - candidates = candidate_contest_candidates(contest, index) - result = { - "id": contest.model__id, - "type": "candidate", - "title": contest.name, - "district": district, - "vote_type": contest.vote_variation.value, - # Include even when default is 1: don't require caller to track that. - "votes_allowed": contest.votes_allowed, - "candidates": candidates, - # "offices": offices, - # "parties": parties, - } - return result - - -def extract_ballot_measure_contest(contest: BallotMeasureContest, index): - """Extract ballot measure contest subset needed for a ballot.""" - choices = [] - for selection in contest.contest_selection: - assert isinstance(selection, BallotMeasureSelection), \ - f"Unexpected non-ballot measure selection: {type(selection).__name__}" - choice = text_content(selection.selection) - 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, - "type": "ballot measure", - "title": contest.name, - "district": district, - "text": full_text, - "choices": choices, - } - return result - - -def extract_contests(ballot_style: BallotStyle, index): - """Extract contest subset needed for ballots.""" - for contest in ballot_style_contests(ballot_style, index): - if isinstance(contest, CandidateContest): - entry = extract_candidate_contest(contest, index) - elif isinstance(contest, BallotMeasureContest): - entry = extract_ballot_measure_contest(contest, index) - else: - # Ignore other contest types - print(f"Skipping contest of type {contest.model__type}") - yield entry - - -def extract_ballot_styles(election: Election, index): - """Extract all ballot styles.""" - for ballot_style in election.ballot_style: - data = { - "id": ballot_style_external_id(ballot_style), - "scopes": [ _.model__id for _ in ballot_style_gp_units(ballot_style, index) ], - "contests": [ _ for _ in extract_contests(ballot_style, index) ], + "id": contest.model__id, + "type": "candidate", + "title": contest.name, + "district": district, + "vote_type": contest.vote_variation.value, + # Include even when default is 1: don't require caller to track that. + "votes_allowed": contest.votes_allowed, + "candidates": candidates, + # "offices": offices, + # "parties": parties, } - yield data - - -def extract_election_data(election_report: ElectionReport, index): - """Extract all elections.""" - # In most cases there isn't more than one 'Election' in a report, but the - # standard allows more than one, so handle them. - for election in election_report.election: - data = { - "name": text_content(election.name), - "type": election.type.value, - "start_date": election.start_date.strftime("%Y-%m-%d"), - "end_date": election.end_date.strftime("%Y-%m-%d"), - "ballot_styles": [_ for _ in extract_ballot_styles(election, index)], + return result + + + def _extract_ballot_measure_contest(self, contest: BallotMeasureContest): + """Extract ballot measure contest subset needed for a ballot.""" + choices = [] + for selection in contest.contest_selection: + assert isinstance(selection, BallotMeasureSelection), \ + f"Unexpected non-ballot measure selection: {type(selection).__name__}" + choice = _text_content(selection.selection) + choices.append({ + "id": selection.model__id, + "choice": choice, + }) + district = self._contest_election_district(contest) + full_text = _text_content(contest.full_text) + result = { + "id": contest.model__id, + "type": "ballot measure", + "title": contest.name, + "district": district, + "text": full_text, + "choices": choices, } - yield data - - -def extract_ballot_data(data: Dict, index: ElementIndex = None) -> ElectionData: - """Extract election data. - - This is the primary entry point for the extractor. - - Parameters: - data: An EDF / election report dictionary. - index: An ElementIndex. - If empty (the default), create a new index from the election report. - Use this parameter only if there's already an existing index. - - Returns: - Election data model for use in ballot rendering. - """ - election_report = ElectionReport(**data) - index = index or ElementIndex(election_report, "ElectionResults") - election_data = [ - ElectionData(**_) for _ in extract_election_data(election_report, index) - ] - return election_data + return result + + + def _extract_contests(self, ballot_style: BallotStyle): + """Extract contest subset needed for ballots.""" + for contest in self._ballot_style_contests(ballot_style): + if isinstance(contest, CandidateContest): + entry = self._extract_candidate_contest(contest) + elif isinstance(contest, BallotMeasureContest): + entry = self._extract_ballot_measure_contest(contest) + else: + # Ignore other contest types + print(f"Skipping contest of type {contest.model__type}") + yield entry + + + def _extract_ballot_styles(self, election: Election): + """Extract all ballot styles.""" + for ballot_style in election.ballot_style: + data = { + "id": self._ballot_style_external_id(ballot_style), + "scopes": [ + _.model__id + for _ in self._ballot_style_gp_units(ballot_style) + ], + "contests": [ + _ + for _ in self._extract_contests(ballot_style) + ], + } + yield data + + + def _extract_election_data(self, election_report: ElectionReport): + """Extract all elections.""" + # In most cases there isn't more than one 'Election' in a report, but the + # standard allows more than one, so handle them. + for election in election_report.election: + data = { + "name": _text_content(election.name), + "type": election.type.value, + "start_date": election.start_date.strftime("%Y-%m-%d"), + "end_date": election.end_date.strftime("%Y-%m-%d"), + "ballot_styles": [ + _ for _ in self._extract_ballot_styles(election) + ], + } + yield data + + + def extract(self, data: Dict, index: ElementIndex = None) -> ElectionData: + """Extract election data. + + This is the primary entry point for the extractor. + + Parameters: + data: An EDF / election report dictionary. + index: An ElementIndex. + If empty (the default), create a new index from the election report. + Use this parameter only if there's already an existing index. + + Returns: + Election data model for use in ballot rendering. + """ + election_report = ElectionReport(**data) + self._index = index or ElementIndex(election_report, "ElectionResults") + election_data = [ + ElectionData(**_) + for _ in self._extract_election_data(election_report) + ] + return election_data From 1195709847099a378f3dc787187c6985575a5127 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 21:01:43 -0700 Subject: [PATCH 58/79] Rename extractor internal functions start with '_extract'. --- src/electos/ballotmaker/data/extractor.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 10463e0..c4372da 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -196,7 +196,7 @@ def _contest_election_district(self, contest: Contest): return district - def _extract_candidate_contest(self, contest: CandidateContest): + def _candidate_contest_of(self, contest: CandidateContest): """Extract candidate contest subset needed for a ballot.""" district = self._contest_election_district(contest) offices = self._candidate_contest_offices(contest) @@ -217,7 +217,7 @@ def _extract_candidate_contest(self, contest: CandidateContest): return result - def _extract_ballot_measure_contest(self, contest: BallotMeasureContest): + def _ballot_measure_contest_of(self, contest: BallotMeasureContest): """Extract ballot measure contest subset needed for a ballot.""" choices = [] for selection in contest.contest_selection: @@ -241,20 +241,20 @@ def _extract_ballot_measure_contest(self, contest: BallotMeasureContest): return result - def _extract_contests(self, ballot_style: BallotStyle): + def _contests(self, ballot_style: BallotStyle): """Extract contest subset needed for ballots.""" for contest in self._ballot_style_contests(ballot_style): if isinstance(contest, CandidateContest): - entry = self._extract_candidate_contest(contest) + entry = self._candidate_contest_of(contest) elif isinstance(contest, BallotMeasureContest): - entry = self._extract_ballot_measure_contest(contest) + entry = self._ballot_measure_contest_of(contest) else: # Ignore other contest types print(f"Skipping contest of type {contest.model__type}") yield entry - def _extract_ballot_styles(self, election: Election): + def _election_ballot_styles(self, election: Election): """Extract all ballot styles.""" for ballot_style in election.ballot_style: data = { @@ -265,13 +265,13 @@ def _extract_ballot_styles(self, election: Election): ], "contests": [ _ - for _ in self._extract_contests(ballot_style) + for _ in self._contests(ballot_style) ], } yield data - def _extract_election_data(self, election_report: ElectionReport): + def _elections(self, election_report: ElectionReport): """Extract all elections.""" # In most cases there isn't more than one 'Election' in a report, but the # standard allows more than one, so handle them. @@ -282,7 +282,7 @@ def _extract_election_data(self, election_report: ElectionReport): "start_date": election.start_date.strftime("%Y-%m-%d"), "end_date": election.end_date.strftime("%Y-%m-%d"), "ballot_styles": [ - _ for _ in self._extract_ballot_styles(election) + _ for _ in self._election_ballot_styles(election) ], } yield data @@ -306,6 +306,6 @@ def extract(self, data: Dict, index: ElementIndex = None) -> ElectionData: self._index = index or ElementIndex(election_report, "ElectionResults") election_data = [ ElectionData(**_) - for _ in self._extract_election_data(election_report) + for _ in self._elections(election_report) ] return election_data From cf945d55fd72ee5fc6d44e839fdc496b1569f9dd Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Wed, 14 Sep 2022 10:00:13 -0400 Subject: [PATCH 59/79] Update comments --- src/electos/ballotmaker/ballots/contest_layout.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index e3c6966..74df71b 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -52,6 +52,7 @@ h2 = styles["Heading2"] # define custom styles for contest tables +# Contest header PageLayout.define_custom_style( h1, grey, @@ -63,6 +64,7 @@ sp_after=FOUR_LINES, keep_w_next=YES, ) +# Contest instructions PageLayout.define_custom_style( h2, light, @@ -74,6 +76,7 @@ sp_before=ONE_LINE, keep_w_next=YES, ) +# Contest body text PageLayout.define_custom_style( normal, white, From 4b4b7280a60229120c451a47bbc7709396bc3222 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Wed, 14 Sep 2022 10:40:08 -0400 Subject: [PATCH 60/79] Test ballot generation on Linux --- src/electos/ballotmaker/ballots/contest_layout.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index 74df71b..047ee4d 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -167,10 +167,7 @@ def __init__(self, width=400, height=200, shift_up=False, *args, **kw): self.width = OVAL_WIDTH + PageLayout.border_pad self.height = OVAL_HEIGHT + PageLayout.border_pad - if shift_up: - _vertical_shift = OVAL_UP - else: - _vertical_shift = OVAL_DOWN + _vertical_shift = OVAL_UP if shift_up else OVAL_DOWN oval_cx = (self.width / 2) + OVAL_INDENT oval_cy = (self.height / 2) - _vertical_shift self._add( @@ -263,8 +260,8 @@ def __init__(self, contest_data: CandidateContestData): # add line for write ins if candidate.is_write_in: # contest_text += ("
" * 2) + ("_" * 20) - # Add text field - input_id = f"{candidate.id}_input" + # Add text field with ID and suffix + input_id = f"{candidate.id}_text" contest_object.append(formInputField(input_id)) # add form objects? From 0e9de74c8b7c7e20f759a7372d148f092b0d1325 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Wed, 14 Sep 2022 11:18:47 -0400 Subject: [PATCH 61/79] Hide form objects --- .../ballotmaker/ballots/contest_layout.py | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index 047ee4d..e35f3cd 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -24,13 +24,13 @@ YES = 1 -CHECKBOX_W = 8 +CHECKBOX_W = 4 CHECKBOX_H = 8 CHECKBOX_X = 3 CHECKBOX_Y = -12 WRITE_IN_W = 100 -WRITE_IN_H = 12 +WRITE_IN_H = 24 # define styles # fill colors @@ -184,9 +184,10 @@ def __init__(self, width=400, height=200, shift_up=False, *args, **kw): class formCheckButton(Flowable): - def __init__(self, title, value="Yes"): + def __init__(self, title, value="Yes", flags="hidden"): self.title = title self.value = value + self.flags = flags self.x = CHECKBOX_X self.y = CHECKBOX_Y self.width = CHECKBOX_W @@ -198,15 +199,25 @@ def wrap(self, *args): def draw(self): self.canv.saveState() - pdfform.buttonFieldRelative( - self.canv, - self.title, - self.value, - self.x, - self.y, - width=self.width, - height=self.height, + # pdfform.buttonFieldRelative( + # self.canv, + # self.title, + # self.value, + # self.x, + # self.y, + # width=self.width, + # height=self.height, + # ) + + form = self.canv.acroForm + form.checkbox( + name=self.title, + buttonStyle="check", + relative=True, + size=self.width, + # annotationFlags="noview", ) + self.canv.restoreState() @@ -223,9 +234,17 @@ def wrap(self, *args): def draw(self): self.canv.saveState() - pdfform.textFieldRelative( - self.canv, self.id, 0, 0, WRITE_IN_W, WRITE_IN_H, self.value + form = self.canv.acroForm + form.textfield( + name=self.id, + maxlen=60, + height=WRITE_IN_H, + width=WRITE_IN_W, + relative=True, ) + # pdfform.textFieldRelative( + # self.canv, self.id, 0, 0, WRITE_IN_W, WRITE_IN_H, self.value + # ) self.canv.restoreState() @@ -268,8 +287,8 @@ def __init__(self, contest_data: CandidateContestData): if True: # add check box vote_mark = [ - SelectionOval(shift_up=True), formCheckButton(candidate.id, "Yes"), + SelectionOval(shift_up=True), ] # else: # vote_mark = SelectionOval() From 358bffdde1397bc782e9c297e5c36c90b0c9d2b0 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Wed, 14 Sep 2022 13:24:41 -0400 Subject: [PATCH 62/79] Sample code and demo PDFs for testing --- pdfs/ballot_demo_2022_09_13T095513.pdf | Bin 0 -> 223255 bytes pdfs/precinct_2_spacetown.pdf | Bin 0 -> 22003 bytes samplecode/form_reader.py | 9 + samplecode/form_table.py | 17 ++ samplecode/nisty.py | 330 +++++++++++++++++++++++++ samplecode/simple_radios.py | 120 +++++++++ simple_radios.pdf | Bin 0 -> 14811 bytes 7 files changed, 476 insertions(+) create mode 100644 pdfs/ballot_demo_2022_09_13T095513.pdf create mode 100644 pdfs/precinct_2_spacetown.pdf create mode 100644 samplecode/form_reader.py create mode 100644 samplecode/nisty.py create mode 100644 samplecode/simple_radios.py create mode 100644 simple_radios.pdf diff --git a/pdfs/ballot_demo_2022_09_13T095513.pdf b/pdfs/ballot_demo_2022_09_13T095513.pdf new file mode 100644 index 0000000000000000000000000000000000000000..783d002923d725877b6cbc553e9744029e68b74a GIT binary patch literal 223255 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 z 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 literal 0 HcmV?d00001 diff --git a/pdfs/precinct_2_spacetown.pdf b/pdfs/precinct_2_spacetown.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2b635c354ab0094219169883fecfc5ad6e3e4ed7 GIT binary patch literal 22003 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] ] + + Each marker is associated with the id of the selection. + In the write-in case, two 'choices' are generated: one + for the write-in choice (the id of the write-in selection); + the other a text box for the written-in name; this widget + always has an identifier that is the write-in selection id + with '_text' appended (e.g., 'writeInChoice01_text'). + """ + if not self._choices: + for selection in self.contest.contest_selection: + # marker = VoteChoiceRadio(self.id, selection.model__id) + marker = VoteChoiceCheck(self.id, selection.model__id) + + if ( + selection.model__type + == "ElectionResults.BallotMeasureSelection" + ): + self._choices.append( + [marker, selection.selection.text[0].content] + ) + elif selection.is_write_in: + self._choices.append([marker, "write-in"]) + self._choices.append( + [ + "", + WriteInTextBox( + "_".join((selection.model__id, "text")) + ), + ] + ) + else: + candidate_names = [ + string_of( + candidate( + candidate_id, self.edf.election[0] + ).ballot_name + ) + for candidate_id in selection.candidate_ids + ] + label = " and ".join(candidate_names) + self._choices.append([marker, label]) + return self._choices + + +class OrderedContent: + def __init__(self, content, edf): + self.content = content + self.edf = edf + + +class Candidate: + def __init__(self, candidate, edf): + self.candidate = candidate + self.edf = edf + + +style = getSampleStyleSheet()["BodyText"] + + +class VoteChoiceCheck(Flowable): + def __init__(self, contest_id, selection_id): + super().__init__() + self.contest_id = contest_id + self.selection_id = selection_id + self.size = 12 + self.checked = False + self.buttonStyle = "check" + + def draw(self): + self.canv.saveState() + form = self.canv.acroForm + form.checkbox( + name=self.selection_id, + buttonStyle=self.buttonStyle, + relative=True, + size=self.size, + ) + + self.canv.restoreState() + + +class VoteChoiceRadio(Flowable): + def __init__(self, contest_id, selection_id): + super().__init__() + self.name = contest_id + self.value = selection_id + self.buttonStyle = "check" + + def draw(self): + self.canv.saveState() + form = self.canv.acroForm + form.radio(name=self.name, value=self.value, size=12, relative=True) + self.canv.restoreState() + + +class WriteInTextBox(Flowable): + def __init__(self, name): + super().__init__() + self.name = name + + def draw(self): + self.canv.saveState() + form = self.canv.acroForm + form.textfield( + name=self.name, maxlen=60, height=12, width=65, relative=True + ) + self.canv.restoreState() + + +# for_spacely = VoteChoiceRadio("mayor", "spacely") +# for_cogswell = VoteChoiceRadio("mayor", "cogswell") + +# data = [[for_spacely, "Cosmo Spacely"], +# [for_cogswell, "Spencer Cogswell"]] +# Story = [Table(data)] +# doc.build(Story) + +edf_file = "/Users/cwulfman/projects/nisty/tests/test_case_1.json" +mayor_contest_id = "recIj8OmzqzzvnDbM" +maker = BallotMaker(edf_file) +# ballot = maker.ballots[0] +# doc = SimpleDocTemplate("hello.pdf") +# doc.build(ballot.story) + +# ballot.write_election_name() +# ballot.draw_table() +# ballot.build_doc() diff --git a/samplecode/simple_radios.py b/samplecode/simple_radios.py new file mode 100644 index 0000000..fbad5e7 --- /dev/null +++ b/samplecode/simple_radios.py @@ -0,0 +1,120 @@ +# simple_radios.py +# from [Creating Interactive PDF Forms in ReportLab with Python - Mouse Vs Python](https://www.blog.pythonlibrary.org/2018/05/29/creating-interactive-pdf-forms-in-reportlab-with-python/) +from reportlab.lib.colors import blue, green, magenta, pink +from reportlab.pdfbase import pdfform +from reportlab.pdfgen import canvas + + +def create_simple_radios(): + c = canvas.Canvas("simple_radios.pdf") + + c.setFont("Courier", 20) + c.drawCentredString(300, 700, "Radio demo") + c.setFont("Courier", 14) + form = c.acroForm + + c.drawString(10, 650, "Dog:") + form.radio( + name="radio1", + tooltip="Field radio1", + value="value1", + selected=False, + x=110, + y=645, + buttonStyle="check", + borderStyle="solid", + shape="square", + borderColor=magenta, + fillColor=pink, + textColor=blue, + forceBorder=True, + ) + form.radio( + name="radio1", + tooltip="Field radio1", + value="value2", + selected=True, + x=110, + y=645, + buttonStyle="check", + borderStyle="solid", + shape="square", + borderColor=magenta, + fillColor=pink, + textColor=blue, + forceBorder=True, + ) + + c.drawString(10, 600, "Cat:") + form.radio( + name="radio2", + tooltip="Field radio2", + value="value1", + selected=True, + x=110, + y=595, + buttonStyle="cross", + borderStyle="solid", + shape="circle", + borderColor=green, + fillColor=blue, + borderWidth=2, + textColor=pink, + forceBorder=True, + ) + form.radio( + name="radio2", + tooltip="Field radio2", + value="value2", + selected=False, + x=110, + y=595, + buttonStyle="cross", + borderStyle="solid", + shape="circle", + borderColor=green, + fillColor=blue, + borderWidth=2, + textColor=pink, + forceBorder=True, + ) + + c.drawString(10, 550, "Pony:") + form.radio( + name="radio3", + tooltip="Field radio3", + value="value1", + selected=False, + x=110, + y=545, + buttonStyle="star", + borderStyle="bevelled", + shape="square", + borderColor=blue, + fillColor=green, + borderWidth=2, + textColor=magenta, + forceBorder=False, + ) + form.radio( + name="radio3", + tooltip="Field radio3", + value="value2", + selected=True, + x=110, + y=545, + buttonStyle="star", + borderStyle="bevelled", + shape="circle", + borderColor=blue, + fillColor=green, + borderWidth=2, + textColor=magenta, + forceBorder=True, + ) + + c.save() + + +if __name__ == "__main__": + create_simple_radios() diff --git a/simple_radios.pdf b/simple_radios.pdf new file mode 100644 index 0000000000000000000000000000000000000000..128f61f6debd9c7253530363878e28a59ee0a547 GIT binary patch literal 14811 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 Date: Wed, 14 Sep 2022 14:22:40 -0400 Subject: [PATCH 63/79] Test ballots with white ovals --- .../ballotmaker/ballots/contest_layout.py | 42 +++++++------------ .../ballotmaker/ballots/demo_ballot.py | 2 +- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index e35f3cd..a28bfc7 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -16,7 +16,7 @@ OVAL_UP = -6 OVAL_DOWN = 2 -OVAL_INDENT = 3 +OVAL_INDENT = 4 SOLID_FILL = 1 ONE_LINE = 12 @@ -32,6 +32,11 @@ WRITE_IN_W = 100 WRITE_IN_H = 24 +# Hide form widgets +ANNOTATION_FLAGS = "noview hidden" +# Show form widgets +# ANNOTATION_FLAGS = "print" + # define styles # fill colors light = PageLayout.light @@ -177,14 +182,14 @@ def __init__(self, width=400, height=200, shift_up=False, *args, **kw): validate=None, desc=None, ) - self.oval.fillColor = yellow # white + self.oval.fillColor = white # yellow, white or black self.oval.fillOpacity = SOLID_FILL self.oval.strokeColor = black self.oval.strokeWidth = sm_line class formCheckButton(Flowable): - def __init__(self, title, value="Yes", flags="hidden"): + def __init__(self, title, value="Yes", flags="noview"): self.title = title self.value = value self.flags = flags @@ -193,29 +198,20 @@ def __init__(self, title, value="Yes", flags="hidden"): self.width = CHECKBOX_W self.height = CHECKBOX_H + # ensures this element lines up with others in the cell def wrap(self, *args): self.width = args[0] return (self.width, self.height) def draw(self): self.canv.saveState() - # pdfform.buttonFieldRelative( - # self.canv, - # self.title, - # self.value, - # self.x, - # self.y, - # width=self.width, - # height=self.height, - # ) - form = self.canv.acroForm form.checkbox( name=self.title, buttonStyle="check", relative=True, size=self.width, - # annotationFlags="noview", + annotationFlags=ANNOTATION_FLAGS, ) self.canv.restoreState() @@ -241,10 +237,9 @@ def draw(self): height=WRITE_IN_H, width=WRITE_IN_W, relative=True, + y=-7, + annotationFlags=ANNOTATION_FLAGS, ) - # pdfform.textFieldRelative( - # self.canv, self.id, 0, 0, WRITE_IN_W, WRITE_IN_H, self.value - # ) self.canv.restoreState() @@ -283,15 +278,10 @@ def __init__(self, contest_data: CandidateContestData): input_id = f"{candidate.id}_text" contest_object.append(formInputField(input_id)) - # add form objects? - if True: - # add check box - vote_mark = [ - formCheckButton(candidate.id, "Yes"), - SelectionOval(shift_up=True), - ] - # else: - # vote_mark = SelectionOval() + vote_mark = [ + formCheckButton(candidate.id, "Yes"), + SelectionOval(shift_up=True), + ] contest_row = [vote_mark, contest_object] _selections.append(contest_row) # build the contest table, an attribute of the Contest object diff --git a/src/electos/ballotmaker/ballots/demo_ballot.py b/src/electos/ballotmaker/ballots/demo_ballot.py index 62370fa..0d336ad 100644 --- a/src/electos/ballotmaker/ballots/demo_ballot.py +++ b/src/electos/ballotmaker/ballots/demo_ballot.py @@ -77,7 +77,7 @@ def get_election_header() -> dict: "StartDate": "2024-11-05", "EndDate": "2024-11-05", "Type": "general", - "ElectionScope": "Spacetown Precinct, Orbit City", + "ElectionScope": "Spacetown Precinct", } From 1e5b7ffeaba711580274f405b7ceaec92dbb16bb Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Thu, 15 Sep 2022 10:01:05 -0400 Subject: [PATCH 64/79] Add September data to assets --- .../assets/data/september_test_case.json | 1424 +++++++++++++++++ 1 file changed, 1424 insertions(+) create mode 100644 src/electos/ballotmaker/assets/data/september_test_case.json diff --git a/src/electos/ballotmaker/assets/data/september_test_case.json b/src/electos/ballotmaker/assets/data/september_test_case.json new file mode 100644 index 0000000..42a7b05 --- /dev/null +++ b/src/electos/ballotmaker/assets/data/september_test_case.json @@ -0,0 +1,1424 @@ +{ + "@type": "ElectionResults.ElectionReport", + "Format": "precinct-level", + "GeneratedDate": "2022-09-05T09:41:21Z", + "VendorApplicationId": "ElectionReporter", + "Issuer": "TrustTheVote", + "IssuerAbbreviation": "TTV", + "Status": "pre-election", + "SequenceStart": 1, + "SequenceEnd": 1, + "Election": [ + { + "@type": "ElectionResults.Election", + "ElectionScopeId": "recTXCMIfa5VQJju2", + "StartDate": "2024-11-05", + "EndDate": "2024-11-05", + "Type": "general", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "General Election", + "Language": "en" + } + ] + }, + "Contest": [ + { + "@type": "ElectionResults.CandidateContest", + "@id": "recthF6jdx5ybBNkC", + "ExternalIdentifier": [ + { + "@type": "ElectionResults.ExternalIdentifier", + "Type": "other", + "Value": "school_board_contest", + "OtherType": "TTV", + "Label": "school_board_contest" + } + ], + "Name": "Gadget County School Board", + "OfficeIds": [ + "rec1zWmGWlgKKmUO4" + ], + "VoteVariation": "n-of-m", + "VotesAllowed": 4, + "ElectionDistrictId": "recOVSnILnPJ7Dahl", + "ContestSelection": [ + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recJvikmG5MrUKzo1", + "CandidateIds": [ + "rec56yn7vK7Vk5Zbv" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recigPkqYXXDJEaCE", + "CandidateIds": [ + "reccQRyca6A6QpRuo" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recbN7UUMaSuOYGQ6", + "CandidateIds": [ + "recx71NmYNa1DNFIu" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recbxvhKikHJNZYbq", + "CandidateIds": [ + "recOZMDz6uSf5A1NH" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recvjB3rgfiicf0RP", + "CandidateIds": [ + "recSlC9yfxdyUA94v" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recYurH2CLY3SlYS8", + "IsWriteIn": true + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recI5jfcXIsbAKytC", + "IsWriteIn": true + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recn9m0o1em7gLahj", + "IsWriteIn": true + } + ] + }, + { + "@type": "ElectionResults.CandidateContest", + "@id": "recsoZy7vYhS3lbcK", + "ExternalIdentifier": [ + { + "@type": "ElectionResults.ExternalIdentifier", + "Type": "other", + "Value": "https://viaf.org/viaf/129529146", + "OtherType": "viaf", + "Label": "potus_id_viaf" + } + ], + "Name": "President of the United States", + "OfficeIds": [ + "recFr8nr6uAZsD2r8", + "recIR57LPmJ0VvtEo" + ], + "VoteVariation": "plurality", + "VotesAllowed": 1, + "ElectionDistrictId": "recTXCMIfa5VQJju2", + "ContestSelection": [ + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recQK3J9IJq42hz2n", + "CandidateIds": [ + "reczKIKk81RshXkd9", + "recLB4X1sLL2q1bSW" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "reccUkUdEznfODgeL", + "CandidateIds": [ + "recopGzcpflkyhwdN", + "recBEqMDC9w5AnKxh" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recPod2L8VhwagiDl", + "IsWriteIn": true + } + ] + }, + { + "@type": "ElectionResults.CandidateContest", + "@id": "recIj8OmzqzzvnDbM", + "ExternalIdentifier": [ + { + "@type": "ElectionResults.ExternalIdentifier", + "Type": "other", + "Value": "mayoral_contest", + "OtherType": "TTV", + "Label": "mayoral_contest" + } + ], + "Name": "Contest for Mayor of Orbit City", + "OfficeIds": [ + "rec7N0cboW3L1Mv0I" + ], + "VoteVariation": "plurality", + "VotesAllowed": 1, + "ElectionDistrictId": "recfK8xOapcRIeZ2k", + "ContestSelection": [ + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recKD6dBvkNhEU4bg", + "CandidateIds": [ + "recd1n9MadKRsYLUX" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recTKcXLCzRvKB9U0", + "CandidateIds": [ + "recK4Vc1EdfBufEU4" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recqq21kO6HWgpJZV", + "IsWriteIn": true + } + ] + }, + { + "@type": "ElectionResults.CandidateContest", + "@id": "recXNb4zPrvC1m6Fr", + "ExternalIdentifier": [ + { + "@type": "ElectionResults.ExternalIdentifier", + "Type": "other", + "Value": "spaceport_control_board_contest", + "OtherType": "TTV", + "Label": "spaceport_control_board_contest" + } + ], + "Name": "Spaceport Control Board", + "OfficeIds": [ + "recBAG7iuOZ1MER6i" + ], + "VoteVariation": "n-of-m", + "VotesAllowed": 2, + "ElectionDistrictId": "recVN5dRsq4j6QZn3", + "ContestSelection": [ + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recBnJZEgCKAnfpNo", + "CandidateIds": [ + "recWnqiDUAZupjFW8" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recwNuOnepWNGz67V", + "CandidateIds": [ + "recDqtLJ6IqwZJr7k" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recvYvTb9hWH7tptb", + "CandidateIds": [ + "rec72dETaScaK4tqv" + ] + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "rec9Eev970VhohqKi", + "IsWriteIn": true + }, + { + "@type": "ElectionResults.CandidateSelection", + "@id": "recFiGYjGCIyk5LBe", + "IsWriteIn": true + } + ] + }, + { + "@type": "ElectionResults.BallotMeasureContest", + "@id": "recWjDBFeafCdklWq", + "ElectionDistrictId": "rec93s713Yh6ZJT31", + "Name": "Constitutional Amendment", + "FullText": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "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", + "Language": "en" + } + ] + }, + "ContestSelection": [ + { + "@type": "ElectionResults.BallotMeasureSelection", + "@id": "rec7mVWjUH6fmDxig", + "Selection": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Yes", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.BallotMeasureSelection", + "@id": "reccIHOhUfJgJkqS7", + "Selection": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "No", + "Language": "en" + } + ] + } + } + ] + }, + { + "@type": "ElectionResults.BallotMeasureContest", + "@id": "recqPa7AeyufIfd6k", + "ElectionDistrictId": "recOVSnILnPJ7Dahl", + "Name": "Air Traffic Control Tax Increase", + "FullText": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "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?", + "Language": "en" + } + ] + }, + "ContestSelection": [ + { + "@type": "ElectionResults.BallotMeasureSelection", + "@id": "recysACFx8cgwomBE", + "Selection": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Yes", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.BallotMeasureSelection", + "@id": "recabXA9jzFYRmGXy", + "Selection": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "No", + "Language": "en" + } + ] + } + } + ] + } + ], + "BallotStyle": [ + { + "@type": "ElectionResults.BallotStyle", + "GpUnitIds": [ + "recFIehh5Aj0zGTn6" + ], + "OrderedContent": [ + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recsoZy7vYhS3lbcK", + "OrderedContestSelectionIds": [ + "recQK3J9IJq42hz2n", + "reccUkUdEznfODgeL", + "recPod2L8VhwagiDl" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recthF6jdx5ybBNkC", + "OrderedContestSelectionIds": [ + "recJvikmG5MrUKzo1", + "recigPkqYXXDJEaCE", + "recbN7UUMaSuOYGQ6", + "recbxvhKikHJNZYbq", + "recvjB3rgfiicf0RP", + "recYurH2CLY3SlYS8", + "recI5jfcXIsbAKytC", + "recn9m0o1em7gLahj" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recIj8OmzqzzvnDbM", + "OrderedContestSelectionIds": [ + "recKD6dBvkNhEU4bg", + "recTKcXLCzRvKB9U0", + "recqq21kO6HWgpJZV" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recqPa7AeyufIfd6k", + "OrderedContestSelectionIds": [ + "recysACFx8cgwomBE", + "recabXA9jzFYRmGXy" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recWjDBFeafCdklWq", + "OrderedContestSelectionIds": [ + "rec7mVWjUH6fmDxig", + "reccIHOhUfJgJkqS7" + ] + } + ], + "ExternalIdentifier": [ + { + "@type": "ElectionResults.ExternalIdentifier", + "Type": "other", + "Value": "precinct_1_downtown", + "OtherType": "TTV", + "Label": "downtown_precinct_style" + } + ] + }, + { + "@type": "ElectionResults.BallotStyle", + "GpUnitIds": [ + "recSQ3ZpvJlTll1Ve" + ], + "OrderedContent": [ + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recsoZy7vYhS3lbcK", + "OrderedContestSelectionIds": [ + "recQK3J9IJq42hz2n", + "reccUkUdEznfODgeL", + "recPod2L8VhwagiDl" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recthF6jdx5ybBNkC", + "OrderedContestSelectionIds": [ + "recJvikmG5MrUKzo1", + "recigPkqYXXDJEaCE", + "recbN7UUMaSuOYGQ6", + "recbxvhKikHJNZYbq", + "recvjB3rgfiicf0RP", + "recYurH2CLY3SlYS8", + "recI5jfcXIsbAKytC", + "recn9m0o1em7gLahj" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recWjDBFeafCdklWq", + "OrderedContestSelectionIds": [ + "rec7mVWjUH6fmDxig", + "reccIHOhUfJgJkqS7" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recqPa7AeyufIfd6k", + "OrderedContestSelectionIds": [ + "recysACFx8cgwomBE", + "recabXA9jzFYRmGXy" + ] + } + ], + "ExternalIdentifier": [ + { + "@type": "ElectionResults.ExternalIdentifier", + "Type": "other", + "Value": "precinct_4_bedrock", + "OtherType": "TTV", + "Label": "bedrock_precinct_style" + } + ] + }, + { + "@type": "ElectionResults.BallotStyle", + "GpUnitIds": [ + "rec7dCergEa3mzqxy" + ], + "OrderedContent": [ + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recsoZy7vYhS3lbcK", + "OrderedContestSelectionIds": [ + "recQK3J9IJq42hz2n", + "reccUkUdEznfODgeL", + "recPod2L8VhwagiDl" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recXNb4zPrvC1m6Fr", + "OrderedContestSelectionIds": [ + "recBnJZEgCKAnfpNo", + "recwNuOnepWNGz67V", + "recvYvTb9hWH7tptb", + "rec9Eev970VhohqKi", + "recFiGYjGCIyk5LBe" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recqPa7AeyufIfd6k", + "OrderedContestSelectionIds": [ + "recysACFx8cgwomBE", + "recabXA9jzFYRmGXy" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recWjDBFeafCdklWq", + "OrderedContestSelectionIds": [ + "rec7mVWjUH6fmDxig", + "reccIHOhUfJgJkqS7" + ] + } + ], + "ExternalIdentifier": [ + { + "@type": "ElectionResults.ExternalIdentifier", + "Type": "other", + "Value": "precinct_3_spaceport", + "OtherType": "TTV", + "Label": "spaceport_precinct_style" + } + ] + }, + { + "@type": "ElectionResults.BallotStyle", + "GpUnitIds": [ + "recUuJTc3tUIUvgF1" + ], + "OrderedContent": [ + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recsoZy7vYhS3lbcK", + "OrderedContestSelectionIds": [ + "recQK3J9IJq42hz2n", + "reccUkUdEznfODgeL", + "recPod2L8VhwagiDl" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recthF6jdx5ybBNkC", + "OrderedContestSelectionIds": [ + "recJvikmG5MrUKzo1", + "recigPkqYXXDJEaCE", + "recbN7UUMaSuOYGQ6", + "recbxvhKikHJNZYbq", + "recvjB3rgfiicf0RP", + "recYurH2CLY3SlYS8", + "recI5jfcXIsbAKytC", + "recn9m0o1em7gLahj" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recIj8OmzqzzvnDbM", + "OrderedContestSelectionIds": [ + "recKD6dBvkNhEU4bg", + "recTKcXLCzRvKB9U0", + "recqq21kO6HWgpJZV" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recXNb4zPrvC1m6Fr", + "OrderedContestSelectionIds": [ + "recBnJZEgCKAnfpNo", + "recwNuOnepWNGz67V", + "recvYvTb9hWH7tptb", + "rec9Eev970VhohqKi", + "recFiGYjGCIyk5LBe" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recqPa7AeyufIfd6k", + "OrderedContestSelectionIds": [ + "recysACFx8cgwomBE", + "recabXA9jzFYRmGXy" + ] + }, + { + "@type": "ElectionResults.OrderedContest", + "ContestId": "recWjDBFeafCdklWq", + "OrderedContestSelectionIds": [ + "rec7mVWjUH6fmDxig", + "reccIHOhUfJgJkqS7" + ] + } + ], + "ExternalIdentifier": [ + { + "@type": "ElectionResults.ExternalIdentifier", + "Type": "other", + "Value": "precinct_2_spacetown", + "OtherType": "TTV", + "Label": "spacetown_precinct_style" + } + ] + } + ], + "Candidate": [ + { + "@type": "ElectionResults.Candidate", + "@id": "rec56yn7vK7Vk5Zbv", + "PersonId": "recMZnE37A32cUq8t", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Rosashawn Davis", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "reccQRyca6A6QpRuo", + "PersonId": "recSUfKMVoKsAAMkS", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Hector Gomez", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recx71NmYNa1DNFIu", + "PersonId": "recCHZSZuP5uTRqdk", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Glavin Orotund", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recOZMDz6uSf5A1NH", + "PersonId": "recjdr7s1JWS9eB7J", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Sally Smith", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recSlC9yfxdyUA94v", + "PersonId": "rec15uIXhHcriFgTS", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Oliver Tsi", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "reci9iNLcQ3rtAiLf", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Write-in for School Board 1", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recjGuouIexifBZkh", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Write-in for School Board 2", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recrcHPI9yPhcL4HS", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Write-in for School Board 3", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "reczKIKk81RshXkd9", + "PersonId": "rec0YJkyHfiT4PBT6", + "PartyId": "recBiK9LZXeZmmFEg", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Anthony Alpha", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recLB4X1sLL2q1bSW", + "PersonId": "recUSqUnlKb63WT3F", + "PartyId": "recBiK9LZXeZmmFEg", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Betty Beta", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recopGzcpflkyhwdN", + "PersonId": "rec1R5Sy3JzkIC2Di", + "PartyId": "reckpEKRtLuDdt03n", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Gloria Gamma", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recBEqMDC9w5AnKxh", + "PersonId": "rec4Z3OQJv0YY8hhR", + "PartyId": "reckpEKRtLuDdt03n", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "David Delta", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recWq31C2cZmioLYA", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Write-In for President", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recd1n9MadKRsYLUX", + "PersonId": "recS2gdD7iUKUM7ml", + "PartyId": "reckpEKRtLuDdt03n", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Spencer Cogswell", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recK4Vc1EdfBufEU4", + "PersonId": "recDl4dMHupCh8ex8", + "PartyId": "recBiK9LZXeZmmFEg", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Cosmo Spacely", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recV5d7CnhUw5NoOl", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Write-in for Mayor", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recWnqiDUAZupjFW8", + "PersonId": "rec2vW1LMVneqPEXP", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Harlan Ellis", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recDqtLJ6IqwZJr7k", + "PersonId": "rechCFDgeqHybXkSY", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Rudy Indexer", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "rec72dETaScaK4tqv", + "PersonId": "recUoaJjaziRvFTcr", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Jane Jetson", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "rec4qAek7djJBFktw", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Write-in for Control Board 1", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Candidate", + "@id": "recVwhaX6QKaOPSjc", + "BallotName": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Write-in for Control Board 2", + "Language": "en" + } + ] + } + } + ] + } + ], + "GpUnit": [ + { + "@type": "ElectionResults.ReportingUnit", + "@id": "rec7dCergEa3mzqxy", + "Type": "precinct", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Port Precinct", + "Language": "en" + } + ], + "Label": "Port" + } + }, + { + "@type": "ElectionResults.ReportingUnit", + "@id": "rec93s713Yh6ZJT31", + "Type": "state", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "The State of Farallon", + "Language": "en" + } + ], + "Label": "Farallon" + } + }, + { + "@type": "ElectionResults.ReportingUnit", + "@id": "recFIehh5Aj0zGTn6", + "Type": "precinct", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Downtown Precinct", + "Language": "en" + } + ], + "Label": "Downtown" + } + }, + { + "@type": "ElectionResults.ReportingUnit", + "@id": "recOVSnILnPJ7Dahl", + "Type": "county", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Gadget County", + "Language": "en" + } + ], + "Label": "Gadget" + } + }, + { + "@type": "ElectionResults.ReportingUnit", + "@id": "recSQ3ZpvJlTll1Ve", + "Type": "precinct", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Bedrock Precinct", + "Language": "en" + } + ], + "Label": "Bedrock" + } + }, + { + "@type": "ElectionResults.ReportingUnit", + "@id": "recTXCMIfa5VQJju2", + "Type": "country", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "United States of America", + "Language": "en" + } + ], + "Label": "USA" + } + }, + { + "@type": "ElectionResults.ReportingUnit", + "@id": "recUuJTc3tUIUvgF1", + "Type": "precinct", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Spacetown Precinct", + "Language": "en" + } + ], + "Label": "Spacetown" + } + }, + { + "@type": "ElectionResults.ReportingUnit", + "@id": "recVAsRw7BvEIBnTe", + "Type": "school", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Gadget County Unified School District", + "Language": "en" + } + ], + "Label": "Gadget School District" + } + }, + { + "@type": "ElectionResults.ReportingUnit", + "@id": "recVN5dRsq4j6QZn3", + "Type": "municipality", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Aldrin Space Transport District", + "Language": "en" + } + ], + "Label": "Spaceport" + } + }, + { + "@type": "ElectionResults.ReportingUnit", + "@id": "recfK8xOapcRIeZ2k", + "Type": "city", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Orbit City", + "Language": "en" + } + ], + "Label": "Orbit City" + } + } + ], + "Party": [ + { + "@type": "ElectionResults.Party", + "@id": "recBiK9LZXeZmmFEg", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "The Lepton Party", + "Language": "en" + } + ], + "Label": "Leptonican" + }, + "Abbreviation": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "LEP", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Party", + "@id": "reckpEKRtLuDdt03n", + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "The Hadron Party of Farallon", + "Language": "en" + } + ], + "Label": "Hadronicrat" + }, + "Abbreviation": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "HAD", + "Language": "en" + } + ] + } + } + ], + "Office": [ + { + "@type": "ElectionResults.Office", + "@id": "rec1zWmGWlgKKmUO4", + "IsPartisan": false, + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Gadget County School Board", + "Language": "en" + } + ], + "Label": "School Board" + } + }, + { + "@type": "ElectionResults.Office", + "@id": "rec7N0cboW3L1Mv0I", + "IsPartisan": true, + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Mayor of Orbit City", + "Language": "en" + } + ], + "Label": "Mayor" + } + }, + { + "@type": "ElectionResults.Office", + "@id": "recBAG7iuOZ1MER6i", + "IsPartisan": false, + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Spaceport Control Board Member", + "Language": "en" + } + ], + "Label": "Spaceport Control Board Member" + } + }, + { + "@type": "ElectionResults.Office", + "@id": "recFr8nr6uAZsD2r8", + "IsPartisan": true, + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "President of the United States", + "Language": "en" + } + ], + "Label": "POTUS" + } + }, + { + "@type": "ElectionResults.Office", + "@id": "recIR57LPmJ0VvtEo", + "IsPartisan": true, + "Name": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "Vice-President of the United States", + "Language": "en" + } + ], + "Label": "VPOTUS" + } + } + ], + "Person": [ + { + "@type": "ElectionResults.Person", + "@id": "rec0YJkyHfiT4PBT6", + "FirstName": "Anthony", + "LastName": "Alpha", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "senator", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "rec15uIXhHcriFgTS", + "FirstName": "Oliver", + "LastName": "Tsi", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "industrialist", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "rec1R5Sy3JzkIC2Di", + "FirstName": "Gloria", + "LastName": "Gamma", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "doctor", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "rec2vW1LMVneqPEXP", + "FirstName": "Harlan", + "LastName": "Ellis", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "electician", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "rec4Z3OQJv0YY8hhR", + "FirstName": "David", + "LastName": "Delta", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "business owner", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "recCHZSZuP5uTRqdk", + "FirstName": "Glavin", + "LastName": "Orotund", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "baker", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "recDl4dMHupCh8ex8", + "FirstName": "Cosmo", + "LastName": "Spacely", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "magnate", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "recMZnE37A32cUq8t", + "FirstName": "Rosashawn", + "LastName": "Davis", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "writer", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "recS2gdD7iUKUM7ml", + "FirstName": "Spencer", + "LastName": "Cogswell", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "surgeon", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "recSUfKMVoKsAAMkS", + "FirstName": "Hector", + "LastName": "Gomez", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "artist", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "recUSqUnlKb63WT3F", + "FirstName": "Betty", + "LastName": "Beta", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "lawyer", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "recUoaJjaziRvFTcr", + "FirstName": "Jane", + "LastName": "Jetson", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "consultant", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "rechCFDgeqHybXkSY", + "FirstName": "Rudy", + "LastName": "Indexer", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "lawyer", + "Language": "en" + } + ] + } + }, + { + "@type": "ElectionResults.Person", + "@id": "recjdr7s1JWS9eB7J", + "FirstName": "Sally", + "LastName": "Smith", + "Profession": { + "@type": "ElectionResults.InternationalizedText", + "Text": [ + { + "@type": "ElectionResults.LanguageString", + "Content": "professor", + "Language": "en" + } + ] + } + } + ] +} \ No newline at end of file From 35597eae3372985b01e79596bbb21bb241b20ea1 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Thu, 15 Sep 2022 10:25:07 -0400 Subject: [PATCH 65/79] Update validate subcommand to use data extractor --- src/electos/ballotmaker/cli.py | 2 +- .../demo_data/september_demo_data.json | 604 ++++++++++++++++++ src/electos/ballotmaker/validate_edf.py | 21 +- 3 files changed, 622 insertions(+), 5 deletions(-) create mode 100644 src/electos/ballotmaker/demo_data/september_demo_data.json diff --git a/src/electos/ballotmaker/cli.py b/src/electos/ballotmaker/cli.py index a1973e6..13e8ac2 100644 --- a/src/electos/ballotmaker/cli.py +++ b/src/electos/ballotmaker/cli.py @@ -73,7 +73,7 @@ def validate( help=EDF_HELP, ), ): - """Validate data in EDF file""" + """Validate data in EDF file by extracting data needed for ballot generation""" validate_edf_result = validate_edf.validate_edf(edf) if validate_edf_result != NO_ERRORS: log.error( diff --git a/src/electos/ballotmaker/demo_data/september_demo_data.json b/src/electos/ballotmaker/demo_data/september_demo_data.json new file mode 100644 index 0000000..e0cc547 --- /dev/null +++ b/src/electos/ballotmaker/demo_data/september_demo_data.json @@ -0,0 +1,604 @@ +[ + { + "name": "General Election", + "type": "general", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [ + { + "id": "precinct_1_downtown", + "scopes": ["recFIehh5Aj0zGTn6"], + "contests": [ + { + "id": "recsoZy7vYhS3lbcK", + "type": "candidate", + "title": "President of the United States", + "district": "United States of America", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "recQK3J9IJq42hz2n", + "name": ["Anthony Alpha", "Betty Beta"], + "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", + } + ], + "is_write_in": false, + }, + { + "id": "recPod2L8VhwagiDl", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recthF6jdx5ybBNkC", + "type": "candidate", + "title": "Gadget County School Board", + "district": "Gadget County", + "vote_type": "n-of-m", + "votes_allowed": 4, + "candidates": [ + { + "id": "recJvikmG5MrUKzo1", + "name": ["Rosashawn Davis"], + "party": [], + "is_write_in": false, + }, + { + "id": "recigPkqYXXDJEaCE", + "name": ["Hector Gomez"], + "party": [], + "is_write_in": false, + }, + { + "id": "recbN7UUMaSuOYGQ6", + "name": ["Glavin Orotund"], + "party": [], + "is_write_in": false, + }, + { + "id": "recbxvhKikHJNZYbq", + "name": ["Sally Smith"], + "party": [], + "is_write_in": false, + }, + { + "id": "recvjB3rgfiicf0RP", + "name": ["Oliver Tsi"], + "party": [], + "is_write_in": false, + }, + { + "id": "recYurH2CLY3SlYS8", + "name": [], + "party": [], + "is_write_in": true, + }, + { + "id": "recI5jfcXIsbAKytC", + "name": [], + "party": [], + "is_write_in": true, + }, + { + "id": "recn9m0o1em7gLahj", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recIj8OmzqzzvnDbM", + "type": "candidate", + "title": "Contest for Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "recKD6dBvkNhEU4bg", + "name": ["Spencer Cogswell"], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + } + ], + "is_write_in": false, + }, + { + "id": "recTKcXLCzRvKB9U0", + "name": ["Cosmo Spacely"], + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP", + } + ], + "is_write_in": false, + }, + { + "id": "recqq21kO6HWgpJZV", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recqPa7AeyufIfd6k", + "type": "ballot measure", + "title": "Air Traffic Control Tax Increase", + "district": "Gadget County", + "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?", + "choices": [ + {"id": "recysACFx8cgwomBE", "choice": "Yes"}, + {"id": "recabXA9jzFYRmGXy", "choice": "No"}, + ], + }, + { + "id": "recWjDBFeafCdklWq", + "type": "ballot measure", + "title": "Constitutional Amendment", + "district": "The State of Farallon", + "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", + "choices": [ + {"id": "rec7mVWjUH6fmDxig", "choice": "Yes"}, + {"id": "reccIHOhUfJgJkqS7", "choice": "No"}, + ], + }, + ], + }, + { + "id": "precinct_4_bedrock", + "scopes": ["recSQ3ZpvJlTll1Ve"], + "contests": [ + { + "id": "recsoZy7vYhS3lbcK", + "type": "candidate", + "title": "President of the United States", + "district": "United States of America", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "recQK3J9IJq42hz2n", + "name": ["Anthony Alpha", "Betty Beta"], + "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", + } + ], + "is_write_in": false, + }, + { + "id": "recPod2L8VhwagiDl", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recthF6jdx5ybBNkC", + "type": "candidate", + "title": "Gadget County School Board", + "district": "Gadget County", + "vote_type": "n-of-m", + "votes_allowed": 4, + "candidates": [ + { + "id": "recJvikmG5MrUKzo1", + "name": ["Rosashawn Davis"], + "party": [], + "is_write_in": false, + }, + { + "id": "recigPkqYXXDJEaCE", + "name": ["Hector Gomez"], + "party": [], + "is_write_in": false, + }, + { + "id": "recbN7UUMaSuOYGQ6", + "name": ["Glavin Orotund"], + "party": [], + "is_write_in": false, + }, + { + "id": "recbxvhKikHJNZYbq", + "name": ["Sally Smith"], + "party": [], + "is_write_in": false, + }, + { + "id": "recvjB3rgfiicf0RP", + "name": ["Oliver Tsi"], + "party": [], + "is_write_in": false, + }, + { + "id": "recYurH2CLY3SlYS8", + "name": [], + "party": [], + "is_write_in": true, + }, + { + "id": "recI5jfcXIsbAKytC", + "name": [], + "party": [], + "is_write_in": true, + }, + { + "id": "recn9m0o1em7gLahj", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recWjDBFeafCdklWq", + "type": "ballot measure", + "title": "Constitutional Amendment", + "district": "The State of Farallon", + "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", + "choices": [ + {"id": "rec7mVWjUH6fmDxig", "choice": "Yes"}, + {"id": "reccIHOhUfJgJkqS7", "choice": "No"}, + ], + }, + { + "id": "recqPa7AeyufIfd6k", + "type": "ballot measure", + "title": "Air Traffic Control Tax Increase", + "district": "Gadget County", + "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?", + "choices": [ + {"id": "recysACFx8cgwomBE", "choice": "Yes"}, + {"id": "recabXA9jzFYRmGXy", "choice": "No"}, + ], + }, + ], + }, + { + "id": "precinct_3_spaceport", + "scopes": ["rec7dCergEa3mzqxy"], + "contests": [ + { + "id": "recsoZy7vYhS3lbcK", + "type": "candidate", + "title": "President of the United States", + "district": "United States of America", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "recQK3J9IJq42hz2n", + "name": ["Anthony Alpha", "Betty Beta"], + "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", + } + ], + "is_write_in": false, + }, + { + "id": "recPod2L8VhwagiDl", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recXNb4zPrvC1m6Fr", + "type": "candidate", + "title": "Spaceport Control Board", + "district": "Aldrin Space Transport District", + "vote_type": "n-of-m", + "votes_allowed": 2, + "candidates": [ + { + "id": "recBnJZEgCKAnfpNo", + "name": ["Harlan Ellis"], + "party": [], + "is_write_in": false, + }, + { + "id": "recwNuOnepWNGz67V", + "name": ["Rudy Indexer"], + "party": [], + "is_write_in": false, + }, + { + "id": "recvYvTb9hWH7tptb", + "name": ["Jane Jetson"], + "party": [], + "is_write_in": false, + }, + { + "id": "rec9Eev970VhohqKi", + "name": [], + "party": [], + "is_write_in": true, + }, + { + "id": "recFiGYjGCIyk5LBe", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recqPa7AeyufIfd6k", + "type": "ballot measure", + "title": "Air Traffic Control Tax Increase", + "district": "Gadget County", + "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?", + "choices": [ + {"id": "recysACFx8cgwomBE", "choice": "Yes"}, + {"id": "recabXA9jzFYRmGXy", "choice": "No"}, + ], + }, + { + "id": "recWjDBFeafCdklWq", + "type": "ballot measure", + "title": "Constitutional Amendment", + "district": "The State of Farallon", + "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", + "choices": [ + {"id": "rec7mVWjUH6fmDxig", "choice": "Yes"}, + {"id": "reccIHOhUfJgJkqS7", "choice": "No"}, + ], + }, + ], + }, + { + "id": "precinct_2_spacetown", + "scopes": ["recUuJTc3tUIUvgF1"], + "contests": [ + { + "id": "recsoZy7vYhS3lbcK", + "type": "candidate", + "title": "President of the United States", + "district": "United States of America", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "recQK3J9IJq42hz2n", + "name": ["Anthony Alpha", "Betty Beta"], + "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", + } + ], + "is_write_in": false, + }, + { + "id": "recPod2L8VhwagiDl", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recthF6jdx5ybBNkC", + "type": "candidate", + "title": "Gadget County School Board", + "district": "Gadget County", + "vote_type": "n-of-m", + "votes_allowed": 4, + "candidates": [ + { + "id": "recJvikmG5MrUKzo1", + "name": ["Rosashawn Davis"], + "party": [], + "is_write_in": false, + }, + { + "id": "recigPkqYXXDJEaCE", + "name": ["Hector Gomez"], + "party": [], + "is_write_in": false, + }, + { + "id": "recbN7UUMaSuOYGQ6", + "name": ["Glavin Orotund"], + "party": [], + "is_write_in": false, + }, + { + "id": "recbxvhKikHJNZYbq", + "name": ["Sally Smith"], + "party": [], + "is_write_in": false, + }, + { + "id": "recvjB3rgfiicf0RP", + "name": ["Oliver Tsi"], + "party": [], + "is_write_in": false, + }, + { + "id": "recYurH2CLY3SlYS8", + "name": [], + "party": [], + "is_write_in": true, + }, + { + "id": "recI5jfcXIsbAKytC", + "name": [], + "party": [], + "is_write_in": true, + }, + { + "id": "recn9m0o1em7gLahj", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recIj8OmzqzzvnDbM", + "type": "candidate", + "title": "Contest for Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "recKD6dBvkNhEU4bg", + "name": ["Spencer Cogswell"], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + } + ], + "is_write_in": false, + }, + { + "id": "recTKcXLCzRvKB9U0", + "name": ["Cosmo Spacely"], + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP", + } + ], + "is_write_in": false, + }, + { + "id": "recqq21kO6HWgpJZV", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recXNb4zPrvC1m6Fr", + "type": "candidate", + "title": "Spaceport Control Board", + "district": "Aldrin Space Transport District", + "vote_type": "n-of-m", + "votes_allowed": 2, + "candidates": [ + { + "id": "recBnJZEgCKAnfpNo", + "name": ["Harlan Ellis"], + "party": [], + "is_write_in": false, + }, + { + "id": "recwNuOnepWNGz67V", + "name": ["Rudy Indexer"], + "party": [], + "is_write_in": false, + }, + { + "id": "recvYvTb9hWH7tptb", + "name": ["Jane Jetson"], + "party": [], + "is_write_in": false, + }, + { + "id": "rec9Eev970VhohqKi", + "name": [], + "party": [], + "is_write_in": true, + }, + { + "id": "recFiGYjGCIyk5LBe", + "name": [], + "party": [], + "is_write_in": true, + }, + ], + }, + { + "id": "recqPa7AeyufIfd6k", + "type": "ballot measure", + "title": "Air Traffic Control Tax Increase", + "district": "Gadget County", + "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?", + "choices": [ + {"id": "recysACFx8cgwomBE", "choice": "Yes"}, + {"id": "recabXA9jzFYRmGXy", "choice": "No"}, + ], + }, + { + "id": "recWjDBFeafCdklWq", + "type": "ballot measure", + "title": "Constitutional Amendment", + "district": "The State of Farallon", + "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", + "choices": [ + {"id": "rec7mVWjUH6fmDxig", "choice": "Yes"}, + {"id": "reccIHOhUfJgJkqS7", "choice": "No"}, + ], + }, + ], + }, + ], + } +] diff --git a/src/electos/ballotmaker/validate_edf.py b/src/electos/ballotmaker/validate_edf.py index 940f298..e683c0b 100644 --- a/src/electos/ballotmaker/validate_edf.py +++ b/src/electos/ballotmaker/validate_edf.py @@ -1,11 +1,22 @@ +import json import logging +from dataclasses import asdict from pathlib import Path -from electos.ballotmaker.election_data import ElectionData +from electos.ballotmaker.constants import NO_ERRORS +from electos.ballotmaker.data.extractor import BallotDataExtractor log = logging.getLogger(__name__) +def report(data, **opts): + """Generate data needed by BallotLab.""" + extractor = BallotDataExtractor() + ballot_data = extractor.extract(data) + ballot_data = [asdict(_) for _ in ballot_data] + print(json.dumps(ballot_data, indent=4)) + + def validate_edf( _edf: Path, ) -> int: @@ -13,6 +24,8 @@ def validate_edf( Requires: EDF file (JSON format) edf_file: Path, """ - election_data = ElectionData(_edf) - - return election_data.edf_error + with _edf.open() as input: + text = input.read() + data = json.loads(text) + report(data) + return NO_ERRORS From f55562aa00fac187ade0554818c4d600acf7c915 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Thu, 15 Sep 2022 14:56:41 -0400 Subject: [PATCH 66/79] Update validation, test ElectionData class --- src/electos/ballotmaker/demo_ballots.py | 28 +++++++++++++++++++++++++ src/electos/ballotmaker/validate_edf.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/electos/ballotmaker/demo_ballots.py diff --git a/src/electos/ballotmaker/demo_ballots.py b/src/electos/ballotmaker/demo_ballots.py new file mode 100644 index 0000000..dcc0d6e --- /dev/null +++ b/src/electos/ballotmaker/demo_ballots.py @@ -0,0 +1,28 @@ +import json +import logging + +from electos.ballotmaker.ballots.files import FileTools +from electos.ballotmaker.data.extractor import BallotDataExtractor +from electos.ballotmaker.data.models import ElectionData + +log = logging.getLogger(__name__) + +data_file_name = "september_test_case.json" +relative_path = "assets/data" +data_file = FileTools(data_file_name, relative_path) +full_data_path = data_file.abs_path_to_file + + +def get_election_data(): + with full_data_path.open() as input: + text = input.read() + data = json.loads(text) + + extractor = BallotDataExtractor() + election_data = extractor.extract(data) + # assert isinstance(election_data, ElectionData) + print(election_data.ElectionData.name) + + +if __name__ == "__main__": # pragma: no cover + get_election_data() diff --git a/src/electos/ballotmaker/validate_edf.py b/src/electos/ballotmaker/validate_edf.py index e683c0b..c759f74 100644 --- a/src/electos/ballotmaker/validate_edf.py +++ b/src/electos/ballotmaker/validate_edf.py @@ -27,5 +27,5 @@ def validate_edf( with _edf.open() as input: text = input.read() data = json.loads(text) - report(data) + report(data) return NO_ERRORS From 92e9477128975f9bee3871ce0625ef35e79df82b Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Thu, 15 Sep 2022 15:00:36 -0400 Subject: [PATCH 67/79] Add comments with Errors --- src/electos/ballotmaker/demo_ballots.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/electos/ballotmaker/demo_ballots.py b/src/electos/ballotmaker/demo_ballots.py index dcc0d6e..fc6ec45 100644 --- a/src/electos/ballotmaker/demo_ballots.py +++ b/src/electos/ballotmaker/demo_ballots.py @@ -20,8 +20,12 @@ def get_election_data(): extractor = BallotDataExtractor() election_data = extractor.extract(data) - # assert isinstance(election_data, ElectionData) - print(election_data.ElectionData.name) + # Expecting election_data to be an ElectionData object, but + # this produces an AssertionError + assert isinstance(election_data, ElectionData) + # this results in an AttributeError: + # AttributeError: 'list' object has no attribute 'name' + print(election_data.name) if __name__ == "__main__": # pragma: no cover From 7d130ff34510aa285067a90fb78533093e5a163b Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Thu, 15 Sep 2022 16:14:15 -0400 Subject: [PATCH 68/79] New ballot builder code --- .../ballotmaker/ballots/ballot_layout.py | 170 ++++++++++++++++++ .../ballotmaker/ballots/build_ballots.py | 26 +++ src/electos/ballotmaker/demo_ballots.py | 23 ++- 3 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 src/electos/ballotmaker/ballots/ballot_layout.py create mode 100644 src/electos/ballotmaker/ballots/build_ballots.py diff --git a/src/electos/ballotmaker/ballots/ballot_layout.py b/src/electos/ballotmaker/ballots/ballot_layout.py new file mode 100644 index 0000000..e60c570 --- /dev/null +++ b/src/electos/ballotmaker/ballots/ballot_layout.py @@ -0,0 +1,170 @@ +""" +The Demo Ballot module contains the document specifications, +page templates, and specific pages +""" +from datetime import datetime +from functools import partial +from pathlib import Path + +from electos.ballotmaker.ballots.contest_layout import ( + BallotMeasureData, + BallotMeasureLayout, + CandidateContestData, + CandidateContestLayout, +) +from electos.ballotmaker.ballots.instructions import Instructions +from electos.ballotmaker.ballots.page_layout import PageLayout +from electos.ballotmaker.demo_data import spacetown_data +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + BaseDocTemplate, + Frame, + NextPageTemplate, + PageBreak, + PageTemplate, + Paragraph, +) +from reportlab.platypus.flowables import CondPageBreak + +# set up frames +# 1 = True, 0 = FALSE +SHOW_BOUNDARY = 0 +# get page layout settings +margin = PageLayout.margin +c_width = PageLayout.col_width +c_height = PageLayout.col_height +c_space = PageLayout.col_space +# define 3-column layout with header +left_frame = Frame( + margin * inch, + margin * inch, + width=c_width * inch, + height=c_height * inch, + topPadding=0, + showBoundary=SHOW_BOUNDARY, +) +mid_frame = Frame( + (margin + c_width + c_space) * inch, + margin * inch, + width=c_width * inch, + height=c_height * inch, + topPadding=0, + showBoundary=SHOW_BOUNDARY, +) +right_frame = Frame( + (margin + (2 * (c_width + c_space))) * inch, + margin * inch, + width=c_width * inch, + height=c_height * inch, + topPadding=0, + showBoundary=SHOW_BOUNDARY, +) + +one_frame = Frame( + margin * inch, + margin * inch, + width=7 * inch, + height=c_height * inch, + topPadding=0, + showBoundary=SHOW_BOUNDARY, +) + + +def add_header_line(font_size, line_text, new_line=False): + line_end = "
" if new_line else "" + return f"{line_text}{line_end}" + + +def build_header_text(): + elect_dict = get_election_header() + font_size = 12 + formatted_header = add_header_line( + font_size, f"Sample Ballot for {elect_dict['Name']}", new_line=True + ) + formatted_header += add_header_line( + font_size, elect_dict["ElectionScope"], new_line=True + ) + end_date = datetime.fromisoformat(elect_dict["EndDate"]) + formatted_date = end_date.strftime("%B %m, %Y") + formatted_header += add_header_line(font_size, formatted_date) + + return formatted_header + + +def header(canvas, doc, content): + canvas.saveState() + # these variables are used elsewhere by ReportLab + width, height = content.wrap(doc.width, doc.topMargin) + content.drawOn(canvas, PageLayout.margin * inch, 10.75 * inch) + canvas.restoreState() + + +def build_ballot() -> str: + # create PDF filename; include + # datestamp string for PDF + now = datetime.now() + date_time = now.strftime("%Y_%m_%dT%H%M%S") + home_dir = Path.home() + ballot_name = f"{home_dir}/ballot_demo_{date_time}.pdf" + + doc = BaseDocTemplate(ballot_name) + + styles = getSampleStyleSheet() + normal = styles["Normal"] + head_text = build_header_text() + header_content = Paragraph(head_text, normal) + three_column_template = PageTemplate( + id="3col", + frames=[left_frame, mid_frame, right_frame], + onPage=partial(header, content=header_content), + ) + one_column_template = PageTemplate( + id="1col", + frames=[one_frame], + onPage=partial( + header, + content=header_content, + ), + ) + doc.addPageTemplates(three_column_template) + doc.addPageTemplates(one_column_template) + # add a ballot contest to the second frame (colomn) + layout_1 = CandidateContestLayout( + CandidateContestData(spacetown_data.can_con_1) + ) + layout_2 = CandidateContestLayout( + CandidateContestData(spacetown_data.can_con_2) + ) + layout_3 = CandidateContestLayout( + CandidateContestData(spacetown_data.can_con_3) + ) + layout_4 = CandidateContestLayout( + CandidateContestData(spacetown_data.can_con_4) + ) + layout_5 = BallotMeasureLayout( + BallotMeasureData(spacetown_data.ballot_measure_1) + ) + layout_6 = BallotMeasureLayout( + BallotMeasureData(spacetown_data.ballot_measure_2) + ) + elements = [] + # add voting instructions + inst = Instructions() + elements = inst.instruction_list + elements.append(NextPageTemplate("3col")) + elements.append(layout_1.contest_table) + elements.append(layout_2.contest_table) + elements.append(CondPageBreak(c_height * inch)) + elements.append(layout_4.contest_table) + elements.append(layout_3.contest_table) + elements.append(NextPageTemplate("1col")) + elements.append(PageBreak()) + elements.append(layout_5.contest_table) + elements.append(layout_6.contest_table) + doc.build(elements) + return str(ballot_name) + + +if __name__ == "__main__": # pragma: no cover + build_ballot() diff --git a/src/electos/ballotmaker/ballots/build_ballots.py b/src/electos/ballotmaker/ballots/build_ballots.py new file mode 100644 index 0000000..3b9a282 --- /dev/null +++ b/src/electos/ballotmaker/ballots/build_ballots.py @@ -0,0 +1,26 @@ +from datetime import datetime +from pathlib import Path + +from electos.ballotmaker.data.models import ElectionData + + +def get_election_header(election: ElectionData) -> dict: + """extract the shared data for ballot headers""" + name = election.name + end_date = election.end_date + election_type = election.type + return { + "Name": name, + "EndDate": end_date, + "Type": election_type, + } + +def build_ballots(election: ElectionData): + + + now = datetime.now() + date_time = now.strftime("%Y_%m_%dT%H%M%S") + home_dir = Path.home() + + for ballot_data in election.ballot_styles: + pass diff --git a/src/electos/ballotmaker/demo_ballots.py b/src/electos/ballotmaker/demo_ballots.py index fc6ec45..277c5a7 100644 --- a/src/electos/ballotmaker/demo_ballots.py +++ b/src/electos/ballotmaker/demo_ballots.py @@ -7,25 +7,32 @@ log = logging.getLogger(__name__) +# get the EDF file from the assets directory, +# a "known-good" source of election data data_file_name = "september_test_case.json" relative_path = "assets/data" data_file = FileTools(data_file_name, relative_path) full_data_path = data_file.abs_path_to_file -def get_election_data(): +def get_election_data() -> ElectionData: with full_data_path.open() as input: text = input.read() data = json.loads(text) extractor = BallotDataExtractor() - election_data = extractor.extract(data) - # Expecting election_data to be an ElectionData object, but - # this produces an AssertionError - assert isinstance(election_data, ElectionData) - # this results in an AttributeError: - # AttributeError: 'list' object has no attribute 'name' - print(election_data.name) + election_report = extractor.extract(data) + # because we're hard-coding the EDF file, we know it only + # contains data for one election! + election_data = election_report[0] + # assert isinstance(election_data, ElectionData) + log.info(f"Generating demo ballots for {election_data.name}") + return election_data + + +def make_election_ballots(election: ElectionData): + + pass if __name__ == "__main__": # pragma: no cover From 850f6e5dd68a0e0c48698ab03004d21bcbe5653a Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Fri, 16 Sep 2022 10:22:09 -0400 Subject: [PATCH 69/79] Disable tests for validate command --- tests/test_cli.py | 26 +++++++++---------- ...validate_edf.py => x_test_validate_edf.py} | 0 2 files changed, 13 insertions(+), 13 deletions(-) rename tests/{test_validate_edf.py => x_test_validate_edf.py} (100%) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4002197..c37a0a8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -45,19 +45,19 @@ def test_make(): assert "Error: Option" in result.stdout -def test_validate(): - # bypass mandatory CLI option to force error - # assert cli.validate(edf=None) == NO_FILE - # any old path will satisfy current tests - assert cli.validate(imaginary_file) == NO_FILE - # check CLI errors: no options for validate - result = runner.invoke(cli.app, ["validate"]) - assert result.exit_code == NO_FILE - assert "Error: Missing option" in result.stdout - # check CLI errors: no edf filename provided - result = runner.invoke(cli.app, ["validate", "--edf"]) - assert result.exit_code == NO_FILE - assert "Error: Option" in result.stdout +# def test_validate(): +# # bypass mandatory CLI option to force error +# # assert cli.validate(edf=None) == NO_FILE +# # any old path will satisfy current tests +# assert cli.validate(imaginary_file) == NO_FILE +# # check CLI errors: no options for validate +# result = runner.invoke(cli.app, ["validate"]) +# assert result.exit_code == NO_FILE +# assert "Error: Missing option" in result.stdout +# # check CLI errors: no edf filename provided +# result = runner.invoke(cli.app, ["validate", "--edf"]) +# assert result.exit_code == NO_FILE +# assert "Error: Option" in result.stdout def test_demo(): diff --git a/tests/test_validate_edf.py b/tests/x_test_validate_edf.py similarity index 100% rename from tests/test_validate_edf.py rename to tests/x_test_validate_edf.py From 2d1221df1d63044d9a3be0179b7b84392de013a2 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Fri, 16 Sep 2022 13:14:30 -0400 Subject: [PATCH 70/79] Generate 4 demo ballots with headers --- .../ballotmaker/ballots/ballot_layout.py | 85 +++++++++---------- .../ballotmaker/ballots/build_ballots.py | 21 ++++- src/electos/ballotmaker/demo_ballots.py | 41 +++++---- 3 files changed, 81 insertions(+), 66 deletions(-) diff --git a/src/electos/ballotmaker/ballots/ballot_layout.py b/src/electos/ballotmaker/ballots/ballot_layout.py index e60c570..6459f6f 100644 --- a/src/electos/ballotmaker/ballots/ballot_layout.py +++ b/src/electos/ballotmaker/ballots/ballot_layout.py @@ -1,7 +1,8 @@ """ -The Demo Ballot module contains the document specifications, +The Ballot Layout module contains the document specifications, page templates, and specific pages """ +import logging from datetime import datetime from functools import partial from pathlib import Path @@ -14,7 +15,7 @@ ) from electos.ballotmaker.ballots.instructions import Instructions from electos.ballotmaker.ballots.page_layout import PageLayout -from electos.ballotmaker.demo_data import spacetown_data +from electos.ballotmaker.data.models import BallotStyleData from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.units import inch from reportlab.platypus import ( @@ -27,6 +28,8 @@ ) from reportlab.platypus.flowables import CondPageBreak +logging.getLogger(__name__) + # set up frames # 1 = True, 0 = FALSE SHOW_BOUNDARY = 0 @@ -71,21 +74,20 @@ ) -def add_header_line(font_size, line_text, new_line=False): +def add_header_line( + font_size: int, line_text: str, new_line: bool = False +) -> str: line_end = "
" if new_line else "" return f"{line_text}{line_end}" -def build_header_text(): - elect_dict = get_election_header() +def build_header_text(election_dict: dict, scope: str) -> str: font_size = 12 formatted_header = add_header_line( - font_size, f"Sample Ballot for {elect_dict['Name']}", new_line=True - ) - formatted_header += add_header_line( - font_size, elect_dict["ElectionScope"], new_line=True + font_size, f"Sample Ballot for {election_dict['Name']}", new_line=True ) - end_date = datetime.fromisoformat(elect_dict["EndDate"]) + formatted_header += add_header_line(font_size, scope, new_line=True) + end_date = datetime.fromisoformat(election_dict["EndDate"]) formatted_date = end_date.strftime("%B %m, %Y") formatted_header += add_header_line(font_size, formatted_date) @@ -100,19 +102,26 @@ def header(canvas, doc, content): canvas.restoreState() -def build_ballot() -> str: - # create PDF filename; include - # datestamp string for PDF +def build_ballot( + ballot_data: BallotStyleData, election_header: dict, output_dir: Path +) -> str: + # create PDF filename now = datetime.now() date_time = now.strftime("%Y_%m_%dT%H%M%S") - home_dir = Path.home() - ballot_name = f"{home_dir}/ballot_demo_{date_time}.pdf" + ballot_label = ballot_data.id + ballot_scope_count = len(ballot_data.scopes) + if ballot_scope_count > 1: + raise NotImplementedError( + f"Multiple ballot scopes currently unsupported. Found {ballot_scope_count} ballot scopes." + ) + ballot_scope = ballot_data.scopes[0] + ballot_name = f"{output_dir}/{ballot_label}_{date_time}.pdf" doc = BaseDocTemplate(ballot_name) styles = getSampleStyleSheet() normal = styles["Normal"] - head_text = build_header_text() + head_text = build_header_text(election_header, ballot_scope) header_content = Paragraph(head_text, normal) three_column_template = PageTemplate( id="3col", @@ -130,41 +139,23 @@ def build_ballot() -> str: doc.addPageTemplates(three_column_template) doc.addPageTemplates(one_column_template) # add a ballot contest to the second frame (colomn) - layout_1 = CandidateContestLayout( - CandidateContestData(spacetown_data.can_con_1) - ) - layout_2 = CandidateContestLayout( - CandidateContestData(spacetown_data.can_con_2) - ) - layout_3 = CandidateContestLayout( - CandidateContestData(spacetown_data.can_con_3) - ) - layout_4 = CandidateContestLayout( - CandidateContestData(spacetown_data.can_con_4) - ) - layout_5 = BallotMeasureLayout( - BallotMeasureData(spacetown_data.ballot_measure_1) - ) - layout_6 = BallotMeasureLayout( - BallotMeasureData(spacetown_data.ballot_measure_2) - ) + # layout_1 = CandidateContestLayout( + # CandidateContestData(spacetown_data.can_con_1) + # ) + elements = [] # add voting instructions inst = Instructions() elements = inst.instruction_list elements.append(NextPageTemplate("3col")) - elements.append(layout_1.contest_table) - elements.append(layout_2.contest_table) - elements.append(CondPageBreak(c_height * inch)) - elements.append(layout_4.contest_table) - elements.append(layout_3.contest_table) - elements.append(NextPageTemplate("1col")) - elements.append(PageBreak()) - elements.append(layout_5.contest_table) - elements.append(layout_6.contest_table) + # elements.append(layout_1.contest_table) + # elements.append(layout_2.contest_table) + # elements.append(CondPageBreak(c_height * inch)) + # elements.append(layout_4.contest_table) + # elements.append(layout_3.contest_table) + # elements.append(NextPageTemplate("1col")) + # elements.append(PageBreak()) + # elements.append(layout_5.contest_table) + # elements.append(layout_6.contest_table) doc.build(elements) return str(ballot_name) - - -if __name__ == "__main__": # pragma: no cover - build_ballot() diff --git a/src/electos/ballotmaker/ballots/build_ballots.py b/src/electos/ballotmaker/ballots/build_ballots.py index 3b9a282..5c0ee67 100644 --- a/src/electos/ballotmaker/ballots/build_ballots.py +++ b/src/electos/ballotmaker/ballots/build_ballots.py @@ -1,8 +1,13 @@ +import logging from datetime import datetime from pathlib import Path +from electos.ballotmaker.ballots.ballot_layout import build_ballot +from electos.ballotmaker.constants import PROGRAM_NAME from electos.ballotmaker.data.models import ElectionData +logging.getLogger(__name__) + def get_election_header(election: ElectionData) -> dict: """extract the shared data for ballot headers""" @@ -14,13 +19,23 @@ def get_election_header(election: ElectionData) -> dict: "EndDate": end_date, "Type": election_type, } - -def build_ballots(election: ElectionData): +def build_ballots(election: ElectionData): + + # create the directories needed now = datetime.now() date_time = now.strftime("%Y_%m_%dT%H%M%S") home_dir = Path.home() + program_dir = Path(home_dir, PROGRAM_NAME) + new_ballot_dir = Path(program_dir, date_time) + logging.info(f"New ballots will be saved in {new_ballot_dir}") + Path(new_ballot_dir).mkdir(parents=True, exist_ok=False) + logging.info("Output directory created.") + election_header = get_election_header(election) for ballot_data in election.ballot_styles: - pass + logging.info(f"Generating ballot for {ballot_data.id}") + new_ballot_name = build_ballot( + ballot_data, election_header, new_ballot_dir + ) diff --git a/src/electos/ballotmaker/demo_ballots.py b/src/electos/ballotmaker/demo_ballots.py index 277c5a7..253010a 100644 --- a/src/electos/ballotmaker/demo_ballots.py +++ b/src/electos/ballotmaker/demo_ballots.py @@ -1,22 +1,36 @@ import json import logging +from electos.ballotmaker.ballots.build_ballots import build_ballots from electos.ballotmaker.ballots.files import FileTools from electos.ballotmaker.data.extractor import BallotDataExtractor from electos.ballotmaker.data.models import ElectionData -log = logging.getLogger(__name__) -# get the EDF file from the assets directory, -# a "known-good" source of election data -data_file_name = "september_test_case.json" -relative_path = "assets/data" -data_file = FileTools(data_file_name, relative_path) -full_data_path = data_file.abs_path_to_file +def main(): + # set up logging for ballot creation + # format output and point to log + logging.getLogger(__name__) + logging.basicConfig( + stream=None, + level=logging.INFO, + format="{asctime} - {message}", + style="{", + ) + # get the EDF file from the assets directory, + # a "known-good" source of election data + data_file_name = "september_test_case.json" + relative_path = "assets/data" + data_file = FileTools(data_file_name, relative_path) + full_data_path = data_file.abs_path_to_file -def get_election_data() -> ElectionData: - with full_data_path.open() as input: + build_ballots(get_election_data(full_data_path)) + + +def get_election_data(edf_file: str) -> ElectionData: + logging.info(f"Using EDF {edf_file}") + with edf_file.open() as input: text = input.read() data = json.loads(text) @@ -26,14 +40,9 @@ def get_election_data() -> ElectionData: # contains data for one election! election_data = election_report[0] # assert isinstance(election_data, ElectionData) - log.info(f"Generating demo ballots for {election_data.name}") + logging.info(f"Found ballots for {election_data.name}") return election_data -def make_election_ballots(election: ElectionData): - - pass - - if __name__ == "__main__": # pragma: no cover - get_election_data() + main() From aa643a20790f19eb99d5e754e4a9660193cd2e7d Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Fri, 16 Sep 2022 13:30:07 -0400 Subject: [PATCH 71/79] updated scopes extraction --- src/electos/ballotmaker/data/extractor.py | 59 +++++++++-------------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index c4372da..57a8111 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -16,7 +16,6 @@ OrderedHeader, ) - # --- Base Types # # Schema expresses these as union types not subclasses @@ -59,46 +58,42 @@ def _walk_ordered_headers(content: List[OrderedContent]): # --- Extractor + class BallotDataExtractor: """Extract election data from an EDF.""" - def __init__(self): pass - def _ballot_style_external_id(self, ballot_style: BallotStyle): """Get the text of a ballot style's external identifier if any.""" if ballot_style.external_identifier: - assert len(ballot_style.external_identifier) == 1, \ - "Not ready to handle multiple BallotStyle external IDs" + assert ( + len(ballot_style.external_identifier) == 1 + ), "Not ready to handle multiple BallotStyle external IDs" name = ballot_style.external_identifier[0].value else: name = "" return name - def _ballot_style_gp_units(self, ballot_style: BallotStyle): """Yield geo-political units for a ballot style.""" for id_ in ballot_style.gp_unit_ids: gp_unit = self._index.by_id(id_) yield gp_unit - def _ballot_style_contests(self, ballot_style: BallotStyle): """Yield the contests of a ballot style.""" for item in _walk_ordered_contests(ballot_style.ordered_content): contest = self._index.by_id(item.contest_id) yield contest - def _candidate_name(self, candidate: Candidate): """Get the name of a candidate as it appears on a ballot.""" name = _text_content(candidate.ballot_name) return name - def _candidate_party(self, candidate: Candidate): """Get the name and abbreviation of the party of a candidate as it appears on a ballot. @@ -109,7 +104,9 @@ def _candidate_party(self, candidate: Candidate): party = self._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 + _text_content(party.abbreviation) + if party and party.abbreviation + else None ) result = {} if name: @@ -118,7 +115,6 @@ def _candidate_party(self, candidate: Candidate): result["abbreviation"] = abbreviation return result, id_ - def _candidate_contest_candidates(self, contest: CandidateContest): """Get candidates for contest, grouped by slate/ticket. @@ -138,8 +134,9 @@ def _candidate_contest_candidates(self, contest: CandidateContest): # Collect individual candidates candidates = [] for selection in contest.contest_selection: - assert isinstance(selection, CandidateSelection), \ - f"Unexpected non-candidate selection: {type(selection).__name__}" + assert isinstance( + selection, CandidateSelection + ), f"Unexpected non-candidate selection: {type(selection).__name__}" names = [] parties = [] _party_ids = set() @@ -161,12 +158,11 @@ def _candidate_contest_candidates(self, contest: CandidateContest): "id": selection.model__id, "name": names, "party": parties, - "is_write_in": bool(selection.is_write_in) + "is_write_in": bool(selection.is_write_in), } candidates.append(result) return candidates - def _candidate_contest_offices(self, contest: CandidateContest): """Get any offices associated with a candidate contest.""" offices = [] @@ -177,7 +173,6 @@ def _candidate_contest_offices(self, contest: CandidateContest): offices.append(name) return offices - def _candidate_contest_parties(self, contest: CandidateContest): """Get any parties associated with a candidate contest.""" parties = [] @@ -188,14 +183,12 @@ def _candidate_contest_parties(self, contest: CandidateContest): parties.append(name) return parties - def _contest_election_district(self, contest: Contest): """Get the district name of a contest.""" district = self._index.by_id(contest.election_district_id) district = _text_content(district.name) return district - def _candidate_contest_of(self, contest: CandidateContest): """Extract candidate contest subset needed for a ballot.""" district = self._contest_election_district(contest) @@ -216,18 +209,20 @@ def _candidate_contest_of(self, contest: CandidateContest): } return result - def _ballot_measure_contest_of(self, contest: BallotMeasureContest): """Extract ballot measure contest subset needed for a ballot.""" choices = [] for selection in contest.contest_selection: - assert isinstance(selection, BallotMeasureSelection), \ - f"Unexpected non-ballot measure selection: {type(selection).__name__}" + assert isinstance( + selection, BallotMeasureSelection + ), f"Unexpected non-ballot measure selection: {type(selection).__name__}" choice = _text_content(selection.selection) - choices.append({ - "id": selection.model__id, - "choice": choice, - }) + choices.append( + { + "id": selection.model__id, + "choice": choice, + } + ) district = self._contest_election_district(contest) full_text = _text_content(contest.full_text) result = { @@ -240,7 +235,6 @@ def _ballot_measure_contest_of(self, contest: BallotMeasureContest): } return result - def _contests(self, ballot_style: BallotStyle): """Extract contest subset needed for ballots.""" for contest in self._ballot_style_contests(ballot_style): @@ -253,24 +247,19 @@ def _contests(self, ballot_style: BallotStyle): print(f"Skipping contest of type {contest.model__type}") yield entry - def _election_ballot_styles(self, election: Election): """Extract all ballot styles.""" for ballot_style in election.ballot_style: data = { "id": self._ballot_style_external_id(ballot_style), "scopes": [ - _.model__id + _text_content(self._index.by_id(_.model__id).name) for _ in self._ballot_style_gp_units(ballot_style) ], - "contests": [ - _ - for _ in self._contests(ballot_style) - ], + "contests": [_ for _ in self._contests(ballot_style)], } yield data - def _elections(self, election_report: ElectionReport): """Extract all elections.""" # In most cases there isn't more than one 'Election' in a report, but the @@ -287,7 +276,6 @@ def _elections(self, election_report: ElectionReport): } yield data - def extract(self, data: Dict, index: ElementIndex = None) -> ElectionData: """Extract election data. @@ -305,7 +293,6 @@ def extract(self, data: Dict, index: ElementIndex = None) -> ElectionData: election_report = ElectionReport(**data) self._index = index or ElementIndex(election_report, "ElectionResults") election_data = [ - ElectionData(**_) - for _ in self._elections(election_report) + ElectionData(**_) for _ in self._elections(election_report) ] return election_data From d9eccfc74345e24d2e6fcd5ee0e35c8d1f65ad48 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Fri, 16 Sep 2022 20:50:33 -0400 Subject: [PATCH 72/79] Generate 4 ballots --- .../ballotmaker/ballots/ballot_layout.py | 53 +++++++---- .../ballotmaker/ballots/contest_layout.py | 92 ++++++++++++------- 2 files changed, 94 insertions(+), 51 deletions(-) diff --git a/src/electos/ballotmaker/ballots/ballot_layout.py b/src/electos/ballotmaker/ballots/ballot_layout.py index 6459f6f..ff7c8e2 100644 --- a/src/electos/ballotmaker/ballots/ballot_layout.py +++ b/src/electos/ballotmaker/ballots/ballot_layout.py @@ -8,9 +8,7 @@ from pathlib import Path from electos.ballotmaker.ballots.contest_layout import ( - BallotMeasureData, BallotMeasureLayout, - CandidateContestData, CandidateContestLayout, ) from electos.ballotmaker.ballots.instructions import Instructions @@ -30,6 +28,8 @@ logging.getLogger(__name__) +CANDIDATE = "candidate" +BALLOT_MEASURE = "ballot measure" # set up frames # 1 = True, 0 = FALSE SHOW_BOUNDARY = 0 @@ -138,24 +138,45 @@ def build_ballot( ) doc.addPageTemplates(three_column_template) doc.addPageTemplates(one_column_template) - # add a ballot contest to the second frame (colomn) - # layout_1 = CandidateContestLayout( - # CandidateContestData(spacetown_data.can_con_1) - # ) + + candidate_contests = [] + ballot_measures = [] + # get contests + for count, contest in enumerate(ballot_data.contests, start=1): + title = contest.title + con_type = contest.type + logging.info(f"Found contest: {title} - {con_type}") + if con_type == CANDIDATE: + candidate_contests.append(contest) + elif con_type == BALLOT_MEASURE: + ballot_measures.append(contest) + else: + raise ValueError(f"Unknown contest type: {con_type}") + logging.info(f"Total: {count} contests.") elements = [] # add voting instructions inst = Instructions() - elements = inst.instruction_list elements.append(NextPageTemplate("3col")) - # elements.append(layout_1.contest_table) - # elements.append(layout_2.contest_table) - # elements.append(CondPageBreak(c_height * inch)) - # elements.append(layout_4.contest_table) - # elements.append(layout_3.contest_table) - # elements.append(NextPageTemplate("1col")) - # elements.append(PageBreak()) - # elements.append(layout_5.contest_table) - # elements.append(layout_6.contest_table) + elements = inst.instruction_list + + # add candidate contests + for can_con_count, candidate_contest in enumerate( + candidate_contests, start=1 + ): + candidate_layout = CandidateContestLayout( + candidate_contest + ).contest_table + elements.append(candidate_layout) + # insert column break after every 2 contests + if (can_con_count % 2 == 0) and (can_con_count < 4): + elements.append(CondPageBreak(c_height * inch)) + logging.info(f"Added {can_con_count} candidate contests.") + elements.append(NextPageTemplate("1col")) + elements.append(PageBreak()) + for measures, ballot_measure in enumerate(ballot_measures, start=1): + ballot_layout = BallotMeasureLayout(ballot_measure).contest_table + elements.append(ballot_layout) + logging.info(f"Added {measures} ballot measures.") doc.build(elements) return str(ballot_name) diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index a28bfc7..062292e 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -1,16 +1,19 @@ # format a ballot contest. +import logging -from electos.ballotmaker.ballots.contest_data import ( - BallotMeasureData, +from electos.ballotmaker.ballots.page_layout import PageLayout +from electos.ballotmaker.data.models import ( + BallotMeasureContestData, CandidateContestData, ) -from electos.ballotmaker.ballots.page_layout import PageLayout from reportlab.graphics.shapes import Drawing, Ellipse, _DrawingEditorMixin from reportlab.lib.colors import black, white, yellow from reportlab.lib.styles import getSampleStyleSheet from reportlab.pdfbase import pdfform from reportlab.platypus import Flowable, Paragraph, Table +logging.getLogger(__name__) + OVAL_WIDTH = 13 OVAL_HEIGHT = 5 @@ -28,6 +31,7 @@ CHECKBOX_H = 8 CHECKBOX_X = 3 CHECKBOX_Y = -12 +CHECKBOX_STATE = "Off" # "Yes" or "Off" WRITE_IN_W = 100 WRITE_IN_H = 24 @@ -35,7 +39,9 @@ # Hide form widgets ANNOTATION_FLAGS = "noview hidden" # Show form widgets -# ANNOTATION_FLAGS = "print" +ANNOTATION_FLAGS = "print" + +BALLOT_MEASURE_INSTRUCT = "Vote yes or no" # define styles # fill colors @@ -256,30 +262,48 @@ def __init__(self, contest_data: CandidateContestData): self.instruct = f"Vote for up to {self.votes_allowed}" else: self.instruct = f"Vote for {self.votes_allowed}" - self.candidates = contest_data.candidates + logging.info(f"Candidate contest: {self.title}") + self.candidate_choices = contest_data.candidates _selections = [] - for candidate in self.candidates: - # add newlines around " and " - if candidate.name.find(" and "): - candidate.name = candidate.name.replace( - " and ", "
and
" - ) - # make the candidate name bold - contest_text = f"{candidate.name}" - # add party abbreviation in plain text - if candidate.party_abbr != "": - contest_text += f"
{candidate.party_abbr}" - contest_object = [Paragraph(contest_text, normal)] + for candidate_choice in self.candidate_choices: + # add line for write ins - if candidate.is_write_in: - # contest_text += ("
" * 2) + ("_" * 20) + if candidate_choice.is_write_in: + logging.info( + f"Found write-in candidate: {candidate_choice.id}" + ) + contest_text = "or write in:" + # contest_text = ("
" * 2) + ("_" * 20) + contest_object = [Paragraph(contest_text, normal)] # Add text field with ID and suffix - input_id = f"{candidate.id}_text" + input_id = f"{candidate_choice.id}_text" contest_object.append(formInputField(input_id)) + else: + contest_text = "" + for candidate_count, name in enumerate( + candidate_choice.name, start=1 + ): + if candidate_count > 1: + contest_text += "
and
" + # make the candidate name bold + contest_text += f"{name}" + # add party abbreviation in plain text + party_count = len(candidate_choice.party) + if party_count == 1: + contest_text += ( + f"
{candidate_choice.party[0].abbreviation}" + ) + elif party_count > 1: + raise NotImplementedError( + "Multiple party tickets not supported (parties" + ) + + logging.info(f"Ticket: {contest_text}") + contest_object = [Paragraph(contest_text, normal)] vote_mark = [ - formCheckButton(candidate.id, "Yes"), + formCheckButton(candidate_choice.id, CHECKBOX_STATE), SelectionOval(shift_up=True), ] contest_row = [vote_mark, contest_object] @@ -297,30 +321,28 @@ class BallotMeasureLayout: Generate a candidate contest table flowable """ - def __init__(self, contest_data: BallotMeasureData): + def __init__(self, contest_data: BallotMeasureContestData): self.id = contest_data.id self.title = contest_data.title - self.instruct = "Vote yes or no" + self.instruct = BALLOT_MEASURE_INSTRUCT self.text = contest_data.text self.choices = contest_data.choices + logging.info(f"Ballot measure: {self.title}") oval = SelectionOval() _selections = [] - for choice in self.choices: - contest_text = f"{choice}" - contest_row = [oval, Paragraph(contest_text, normal)] + for choose in self.choices: + choice_text = f"{choose.choice}" + logging.info(f"Choice: {choice_text} (ID = {choose.id})") + + vote_mark = [ + formCheckButton(choose.id, CHECKBOX_STATE), + SelectionOval(shift_up=True), + ] + contest_row = [vote_mark, Paragraph(choice_text, normal)] _selections.append(contest_row) self.contest_list = build_contest_list( self.title, self.instruct, _selections, self.text ) self.contest_table = build_ballot_measure_table(self.contest_list) - - -if __name__ == "__main__": # pragma: no cover - from electos.ballotmaker.demo_data import spacetown_data - - contest_1 = CandidateContestData(spacetown_data.can_con_1) - print(contest_1.candidates) - layout_1 = CandidateContestLayout(contest_1) - print(layout_1.contest_list) From 52da16ea462a6d05aec6898d6abeadf8f2c592cb Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Fri, 16 Sep 2022 21:29:29 -0400 Subject: [PATCH 73/79] Update CLI to run demo --- .../ballotmaker/ballots/build_ballots.py | 3 +- .../ballotmaker/ballots/demo_ballot.py | 180 ------------------ src/electos/ballotmaker/cli.py | 7 +- src/electos/ballotmaker/demo_ballots.py | 2 +- 4 files changed, 6 insertions(+), 186 deletions(-) delete mode 100644 src/electos/ballotmaker/ballots/demo_ballot.py diff --git a/src/electos/ballotmaker/ballots/build_ballots.py b/src/electos/ballotmaker/ballots/build_ballots.py index 5c0ee67..1edee24 100644 --- a/src/electos/ballotmaker/ballots/build_ballots.py +++ b/src/electos/ballotmaker/ballots/build_ballots.py @@ -21,7 +21,7 @@ def get_election_header(election: ElectionData) -> dict: } -def build_ballots(election: ElectionData): +def build_ballots(election: ElectionData) -> Path: # create the directories needed now = datetime.now() @@ -39,3 +39,4 @@ def build_ballots(election: ElectionData): new_ballot_name = build_ballot( ballot_data, election_header, new_ballot_dir ) + return new_ballot_dir diff --git a/src/electos/ballotmaker/ballots/demo_ballot.py b/src/electos/ballotmaker/ballots/demo_ballot.py deleted file mode 100644 index 0d336ad..0000000 --- a/src/electos/ballotmaker/ballots/demo_ballot.py +++ /dev/null @@ -1,180 +0,0 @@ -""" -The Demo Ballot module contains the document specifications, -page templates, and specific pages -""" -from datetime import datetime -from functools import partial -from pathlib import Path - -from electos.ballotmaker.ballots.contest_layout import ( - BallotMeasureData, - BallotMeasureLayout, - CandidateContestData, - CandidateContestLayout, -) -from electos.ballotmaker.ballots.instructions import Instructions -from electos.ballotmaker.ballots.page_layout import PageLayout -from electos.ballotmaker.demo_data import spacetown_data -from reportlab.lib.styles import getSampleStyleSheet -from reportlab.lib.units import inch -from reportlab.platypus import ( - BaseDocTemplate, - Frame, - NextPageTemplate, - PageBreak, - PageTemplate, - Paragraph, -) -from reportlab.platypus.flowables import CondPageBreak - -# set up frames -# 1 = True, 0 = FALSE -SHOW_BOUNDARY = 0 -# get page layout settings -margin = PageLayout.margin -c_width = PageLayout.col_width -c_height = PageLayout.col_height -c_space = PageLayout.col_space -# define 3-column layout with header -left_frame = Frame( - margin * inch, - margin * inch, - width=c_width * inch, - height=c_height * inch, - topPadding=0, - showBoundary=SHOW_BOUNDARY, -) -mid_frame = Frame( - (margin + c_width + c_space) * inch, - margin * inch, - width=c_width * inch, - height=c_height * inch, - topPadding=0, - showBoundary=SHOW_BOUNDARY, -) -right_frame = Frame( - (margin + (2 * (c_width + c_space))) * inch, - margin * inch, - width=c_width * inch, - height=c_height * inch, - topPadding=0, - showBoundary=SHOW_BOUNDARY, -) - -one_frame = Frame( - margin * inch, - margin * inch, - width=7 * inch, - height=c_height * inch, - topPadding=0, - showBoundary=SHOW_BOUNDARY, -) - - -def get_election_header() -> dict: - return { - "Name": "General Election", - "StartDate": "2024-11-05", - "EndDate": "2024-11-05", - "Type": "general", - "ElectionScope": "Spacetown Precinct", - } - - -def add_header_line(font_size, line_text, new_line=False): - line_end = "
" if new_line else "" - return f"{line_text}{line_end}" - - -def build_header_text(): - elect_dict = get_election_header() - font_size = 12 - formatted_header = add_header_line( - font_size, f"Sample Ballot for {elect_dict['Name']}", new_line=True - ) - formatted_header += add_header_line( - font_size, elect_dict["ElectionScope"], new_line=True - ) - end_date = datetime.fromisoformat(elect_dict["EndDate"]) - formatted_date = end_date.strftime("%B %m, %Y") - formatted_header += add_header_line(font_size, formatted_date) - - return formatted_header - - -def header(canvas, doc, content): - canvas.saveState() - # these variables are used elsewhere by ReportLab - width, height = content.wrap(doc.width, doc.topMargin) - content.drawOn(canvas, PageLayout.margin * inch, 10.75 * inch) - canvas.restoreState() - - -def build_ballot() -> str: - # create PDF filename; include - # datestamp string for PDF - now = datetime.now() - date_time = now.strftime("%Y_%m_%dT%H%M%S") - home_dir = Path.home() - ballot_name = f"{home_dir}/ballot_demo_{date_time}.pdf" - - doc = BaseDocTemplate(ballot_name) - - styles = getSampleStyleSheet() - normal = styles["Normal"] - head_text = build_header_text() - header_content = Paragraph(head_text, normal) - three_column_template = PageTemplate( - id="3col", - frames=[left_frame, mid_frame, right_frame], - onPage=partial(header, content=header_content), - ) - one_column_template = PageTemplate( - id="1col", - frames=[one_frame], - onPage=partial( - header, - content=header_content, - ), - ) - doc.addPageTemplates(three_column_template) - doc.addPageTemplates(one_column_template) - # add a ballot contest to the second frame (colomn) - layout_1 = CandidateContestLayout( - CandidateContestData(spacetown_data.can_con_1) - ) - layout_2 = CandidateContestLayout( - CandidateContestData(spacetown_data.can_con_2) - ) - layout_3 = CandidateContestLayout( - CandidateContestData(spacetown_data.can_con_3) - ) - layout_4 = CandidateContestLayout( - CandidateContestData(spacetown_data.can_con_4) - ) - layout_5 = BallotMeasureLayout( - BallotMeasureData(spacetown_data.ballot_measure_1) - ) - layout_6 = BallotMeasureLayout( - BallotMeasureData(spacetown_data.ballot_measure_2) - ) - elements = [] - # add voting instructions - inst = Instructions() - elements = inst.instruction_list - elements.append(NextPageTemplate("3col")) - elements.append(layout_1.contest_table) - elements.append(layout_2.contest_table) - elements.append(CondPageBreak(c_height * inch)) - elements.append(layout_4.contest_table) - elements.append(layout_3.contest_table) - elements.append(NextPageTemplate("1col")) - elements.append(PageBreak()) - elements.append(layout_5.contest_table) - elements.append(layout_6.contest_table) - doc.build(elements) - return str(ballot_name) - - -if __name__ == "__main__": # pragma: no cover - build_ballot() diff --git a/src/electos/ballotmaker/cli.py b/src/electos/ballotmaker/cli.py index 13e8ac2..2926489 100644 --- a/src/electos/ballotmaker/cli.py +++ b/src/electos/ballotmaker/cli.py @@ -6,8 +6,7 @@ from typing import Optional import typer -from electos.ballotmaker import make_ballots, validate_edf -from electos.ballotmaker.ballots.demo_ballot import build_ballot +from electos.ballotmaker import demo_ballots, make_ballots, validate_edf from electos.ballotmaker.constants import NO_ERRORS, PROGRAM_NAME, VERSION EDF_HELP = "EDF file with ballot data (JSON format)" @@ -43,8 +42,8 @@ def main( @app.command() def demo(): """Make ballots from previously extracted EDF data""" - new_ballot_name = build_ballot() - typer.echo(f"Ballot created: {new_ballot_name}") + ballot_output_dir = demo_ballots.main() + typer.echo(f"Ballots created in output directory: {ballot_output_dir}") return NO_ERRORS diff --git a/src/electos/ballotmaker/demo_ballots.py b/src/electos/ballotmaker/demo_ballots.py index 253010a..743b38f 100644 --- a/src/electos/ballotmaker/demo_ballots.py +++ b/src/electos/ballotmaker/demo_ballots.py @@ -25,7 +25,7 @@ def main(): data_file = FileTools(data_file_name, relative_path) full_data_path = data_file.abs_path_to_file - build_ballots(get_election_data(full_data_path)) + return build_ballots(get_election_data(full_data_path)) def get_election_data(edf_file: str) -> ElectionData: From 13da497611c70722074d3c08c7124ae824bb980b Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Sun, 18 Sep 2022 13:49:53 -0400 Subject: [PATCH 74/79] First round of code cleanup --- src/electos/ballotmaker/ballots/ballot_layout.py | 13 ++++++++----- src/electos/ballotmaker/ballots/contest_data.py | 7 +++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/electos/ballotmaker/ballots/ballot_layout.py b/src/electos/ballotmaker/ballots/ballot_layout.py index ff7c8e2..eab7014 100644 --- a/src/electos/ballotmaker/ballots/ballot_layout.py +++ b/src/electos/ballotmaker/ballots/ballot_layout.py @@ -81,13 +81,15 @@ def add_header_line( return f"{line_text}{line_end}" -def build_header_text(election_dict: dict, scope: str) -> str: +def build_header_text(election_header: dict, scope: str) -> str: font_size = 12 formatted_header = add_header_line( - font_size, f"Sample Ballot for {election_dict['Name']}", new_line=True + font_size, + f"Sample Ballot for {election_header['Name']}", + new_line=True, ) formatted_header += add_header_line(font_size, scope, new_line=True) - end_date = datetime.fromisoformat(election_dict["EndDate"]) + end_date = datetime.fromisoformat(election_header["EndDate"]) formatted_date = end_date.strftime("%B %m, %Y") formatted_header += add_header_line(font_size, formatted_date) @@ -121,8 +123,8 @@ def build_ballot( styles = getSampleStyleSheet() normal = styles["Normal"] - head_text = build_header_text(election_header, ballot_scope) - header_content = Paragraph(head_text, normal) + header_text = build_header_text(election_header, ballot_scope) + header_content = Paragraph(header_text, normal) three_column_template = PageTemplate( id="3col", frames=[left_frame, mid_frame, right_frame], @@ -139,6 +141,7 @@ def build_ballot( doc.addPageTemplates(three_column_template) doc.addPageTemplates(one_column_template) + # TODO: use ballot_data.candidate_contests & .ballot_measures instead candidate_contests = [] ballot_measures = [] # get contests diff --git a/src/electos/ballotmaker/ballots/contest_data.py b/src/electos/ballotmaker/ballots/contest_data.py index 964f5fe..8a41276 100644 --- a/src/electos/ballotmaker/ballots/contest_data.py +++ b/src/electos/ballotmaker/ballots/contest_data.py @@ -1,3 +1,5 @@ +# TODO: this module is no longer needed: remove! + from dataclasses import dataclass, field @@ -71,8 +73,9 @@ def __post_init__(self): self.id = self._can_data.get("id", "") self._names = self._can_data.get("name", []) _party_list = self._can_data.get("party", []) - assert 0 <= len(_party_list) <= 1, \ - f"Multiple parties for a slate/ticket not handled: {_party_list}" + 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", "") From 12a238058c56510c2c5c3fbf90a91f08ef5dd0b9 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Sun, 18 Sep 2022 17:28:39 -0400 Subject: [PATCH 75/79] Code cleanup and comments --- src/electos/ballotmaker/ballots/contest_layout.py | 2 +- src/electos/ballotmaker/ballots/page_layout.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index 062292e..87d8deb 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -296,7 +296,7 @@ def __init__(self, contest_data: CandidateContestData): ) elif party_count > 1: raise NotImplementedError( - "Multiple party tickets not supported (parties" + f"Multiple party tickets not supported (parties found: {party_count})" ) logging.info(f"Ticket: {contest_text}") diff --git a/src/electos/ballotmaker/ballots/page_layout.py b/src/electos/ballotmaker/ballots/page_layout.py index 280b3fb..73af776 100644 --- a/src/electos/ballotmaker/ballots/page_layout.py +++ b/src/electos/ballotmaker/ballots/page_layout.py @@ -1,8 +1,6 @@ # page_layout.py # Stores page layout settings in a class -# TODO: refactor as a dict or dataclass - -# customize only what's different from the samples +# customize only what's different from the Reportlab samples from dataclasses import dataclass @@ -33,6 +31,7 @@ class PageLayout: dark: tuple = (1, 0, 0, 0) # light cyan light: tuple = (0.1, 0, 0, 0) + # TODO: Are these next three needed, or redundant? white: tuple = (0, 0, 0, 0) black: tuple = (0, 0, 0, 1) grey: tuple = (0, 0, 0, 0.15) @@ -42,6 +41,7 @@ class PageLayout: keep_w_next = False # TODO: Rewrite with *args, **kwargs? + # TODO: Can this be simplified? def define_custom_style( style, bg_color=bg_color, From 5a3a03e546ffdb5acd10ed6db73d5099cf73b55e Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Sun, 18 Sep 2022 17:33:13 -0400 Subject: [PATCH 76/79] Hide widgets, remove unused data class --- .../ballotmaker/ballots/contest_data.py | 101 ------------------ .../ballotmaker/ballots/contest_layout.py | 2 +- 2 files changed, 1 insertion(+), 102 deletions(-) delete mode 100644 src/electos/ballotmaker/ballots/contest_data.py diff --git a/src/electos/ballotmaker/ballots/contest_data.py b/src/electos/ballotmaker/ballots/contest_data.py deleted file mode 100644 index 8a41276..0000000 --- a/src/electos/ballotmaker/ballots/contest_data.py +++ /dev/null @@ -1,101 +0,0 @@ -# TODO: this module is no longer needed: remove! - -from dataclasses import dataclass, field - - -@dataclass -class BallotMeasureData: - """Retrieve ballot measure contest data from a dict""" - - _b_measure_con: dict = field(repr=False) - id: str = field(init=False) - title: str = field(init=False) - district: str = field(init=False) - text: str = field(init=False) - choices: list = field(default_factory=list, init=False, repr=True) - - def __post_init__(self): - self.id = self._b_measure_con.get("id", "") - self.title = self._b_measure_con.get("title", "") - self.district = self._b_measure_con.get("district", "") - self.text = self._b_measure_con.get("text", "") - self.choices = self._b_measure_con.get("choices", []) - # for choice_data in _choices: - # self.choices.append(ChoiceData(choice_data)) - - -@dataclass -class ChoiceData: - _choice_data: dict = field(repr=False) - id: str = "no_id_provided" - label: str = field(init=False) - - def __post_init__(self): - self.label = "no label provided" - - -@dataclass -class CandidateContestData: - """Retrieve candidate contest data from a dict""" - - _can_con: dict = field(repr=False) - # fields retrieved from the dict - id: str = field(init=False) - title: str = field(init=False) - votes_allowed: int = field(init=False) - district: str = field(init=False) - candidates: list = field(default_factory=list, init=False, repr=True) - - # use dict.get() to assign defaults if key is missing - def __post_init__(self): - self.id = self._can_con.get("id", "") - self.title = self._can_con.get("title", "") - self.votes_allowed = self._can_con.get("votes_allowed", 0) - self.district = self._can_con.get("district", "") - _candidates = self._can_con.get("candidates", []) - for candidate_data in _candidates: - self.candidates.append(CandidateData(candidate_data)) - - -@dataclass -class CandidateData: - """Retrieve candidate data from a dict""" - - _can_data: dict = field(repr=False) - id: str = field(init=False) - _names: list = field(init=False, repr=False, default_factory=list) - party: str = field(init=False) - party_abbr: str = 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_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.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): - # append " and " only if more than one name - if count > 0: - self.name += " and " - self.name += can_name - - -if __name__ == "__main__": # pragma: no cover - from electos.ballotmaker.demo_data import spacetown_data - - can_con_data_1 = CandidateContestData(spacetown_data.can_con_1) - print(can_con_data_1) - can_con_data_2 = CandidateContestData(spacetown_data.can_con_2) - print(can_con_data_2) - b_measure_data_1 = BallotMeasureData(spacetown_data.ballot_measure_1) - print(b_measure_data_1) diff --git a/src/electos/ballotmaker/ballots/contest_layout.py b/src/electos/ballotmaker/ballots/contest_layout.py index 87d8deb..bbcb346 100644 --- a/src/electos/ballotmaker/ballots/contest_layout.py +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -39,7 +39,7 @@ # Hide form widgets ANNOTATION_FLAGS = "noview hidden" # Show form widgets -ANNOTATION_FLAGS = "print" +# ANNOTATION_FLAGS = "print" BALLOT_MEASURE_INSTRUCT = "Vote yes or no" From 12196c166d7073da02936d49e52df713982d9c1a Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Tue, 20 Sep 2022 11:55:07 -0400 Subject: [PATCH 77/79] Add TODOs and backlinks --- src/electos/ballotmaker/ballots/ballot_layout.py | 8 +++++++- src/electos/ballotmaker/ballots/build_ballots.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/electos/ballotmaker/ballots/ballot_layout.py b/src/electos/ballotmaker/ballots/ballot_layout.py index eab7014..ac08466 100644 --- a/src/electos/ballotmaker/ballots/ballot_layout.py +++ b/src/electos/ballotmaker/ballots/ballot_layout.py @@ -28,8 +28,12 @@ logging.getLogger(__name__) +# TODO: use enums in ContestType: https://github.com/TrustTheVote-Project/BallotLab/pull/113#discussion_r973606562 +# Also: see line 147 CANDIDATE = "candidate" BALLOT_MEASURE = "ballot measure" + + # set up frames # 1 = True, 0 = FALSE SHOW_BOUNDARY = 0 @@ -141,7 +145,8 @@ def build_ballot( doc.addPageTemplates(three_column_template) doc.addPageTemplates(one_column_template) - # TODO: use ballot_data.candidate_contests & .ballot_measures instead + # TODO: use ballot_data.candidate_contests & .ballot_measures instead. + # See thread for details: https://github.com/TrustTheVote-Project/BallotLab/pull/113#discussion_r973608016 candidate_contests = [] ballot_measures = [] # get contests @@ -174,6 +179,7 @@ def build_ballot( # insert column break after every 2 contests if (can_con_count % 2 == 0) and (can_con_count < 4): elements.append(CondPageBreak(c_height * inch)) + # TODO: write more informative log message, see: https://github.com/TrustTheVote-Project/BallotLab/pull/113#discussion_r973608278 logging.info(f"Added {can_con_count} candidate contests.") elements.append(NextPageTemplate("1col")) elements.append(PageBreak()) diff --git a/src/electos/ballotmaker/ballots/build_ballots.py b/src/electos/ballotmaker/ballots/build_ballots.py index 1edee24..e22066d 100644 --- a/src/electos/ballotmaker/ballots/build_ballots.py +++ b/src/electos/ballotmaker/ballots/build_ballots.py @@ -24,13 +24,17 @@ def get_election_header(election: ElectionData) -> dict: def build_ballots(election: ElectionData) -> Path: # create the directories needed + # TODO: clean up datetime code: https://github.com/TrustTheVote-Project/BallotLab/pull/113#discussion_r973609852 now = datetime.now() date_time = now.strftime("%Y_%m_%dT%H%M%S") home_dir = Path.home() program_dir = Path(home_dir, PROGRAM_NAME) new_ballot_dir = Path(program_dir, date_time) logging.info(f"New ballots will be saved in {new_ballot_dir}") + # TODO: Use original EDF file name (no ext) in output dir + # See: https://github.com/TrustTheVote-Project/BallotLab/pull/113#discussion_r973776838 Path(new_ballot_dir).mkdir(parents=True, exist_ok=False) + # TODO: list actual dir in log output: https://github.com/TrustTheVote-Project/BallotLab/pull/113#discussion_r973610221 logging.info("Output directory created.") election_header = get_election_header(election) From 2cfd38da5842b9bfb901c2e8a14602f570312622 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Thu, 22 Sep 2022 10:38:04 -0400 Subject: [PATCH 78/79] Increment version number and CHANGELOG to close Milestone 2 (#118) * Update version to 0.1.3 upon milestone completion * Update CHANGELOG.md Co-authored-by: Ion Y <78449689+ion-oset@users.noreply.github.com> * Update CHANGELOG.md Co-authored-by: Ion Y <78449689+ion-oset@users.noreply.github.com> * Update CHANGELOG.md Co-authored-by: Ion Y <78449689+ion-oset@users.noreply.github.com> * Use short dash in header * Remove unneeded files Co-authored-by: Ion Y <78449689+ion-oset@users.noreply.github.com> --- CHANGELOG.md | 12 +- pyproject.toml | 2 +- src/electos/ballotmaker/constants.py | 2 +- .../ballotmaker/demo_data/ballot_lab_data.py | 303 --------- .../ballotmaker/demo_data/bedrock_data.json | 165 ----- .../ballotmaker/demo_data/dict_maker.py | 124 ---- .../ballotmaker/demo_data/downtown_data.json | 207 ------ src/electos/ballotmaker/demo_data/gp_units.py | 12 - src/electos/ballotmaker/demo_data/offices.py | 7 - src/electos/ballotmaker/demo_data/party.py | 4 - .../demo_data/september_demo_data.json | 604 ------------------ .../ballotmaker/demo_data/spaceport_data.json | 139 ---- .../ballotmaker/demo_data/spacetown_data.json | 259 -------- .../ballotmaker/demo_data/spacetown_data.py | 148 ----- src/electos/ballotmaker/demo_election_data.py | 23 - 15 files changed, 13 insertions(+), 1998 deletions(-) delete mode 100644 src/electos/ballotmaker/demo_data/ballot_lab_data.py delete mode 100644 src/electos/ballotmaker/demo_data/bedrock_data.json delete mode 100644 src/electos/ballotmaker/demo_data/dict_maker.py delete mode 100644 src/electos/ballotmaker/demo_data/downtown_data.json delete mode 100644 src/electos/ballotmaker/demo_data/gp_units.py delete mode 100644 src/electos/ballotmaker/demo_data/offices.py delete mode 100644 src/electos/ballotmaker/demo_data/party.py delete mode 100644 src/electos/ballotmaker/demo_data/september_demo_data.json delete mode 100644 src/electos/ballotmaker/demo_data/spaceport_data.json delete mode 100644 src/electos/ballotmaker/demo_data/spacetown_data.json delete mode 100644 src/electos/ballotmaker/demo_data/spacetown_data.py delete mode 100644 src/electos/ballotmaker/demo_election_data.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dda86f8..dff0e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,19 @@ A version history of BallotLab. +## 0.1.3 - September 20, 2022 + +Completed [Milestone 2. Generate ballot PDFs with embedded form objects Milestone](https://github.com/TrustTheVote-Project/BallotLab/milestone/2) + +* Prototype check boxes to track coordinates of vote marking ovals +* Generate ballots with hidden, embedded form objects to track coordinates and ids of vote mark ovals +* Generate ballots from all four (4) precincts in the September 2022 EDF +* Incorporate new NISTLib data access layer (includes corresponding test harness) +* Additional layout enhancements and refinements + ## 0.1.2 - September 7, 2022 -Complete Milestone 1: flat file PDF. Notable features include: +Completed milestone 1: [Generate flat PDFs from Jetsons EDF](https://github.com/TrustTheVote-Project/BallotLab/milestone/1) * Multi-candidate tickets (including contest section IDs) * Write-ins (also with contest selection IDs) diff --git a/pyproject.toml b/pyproject.toml index e258f9f..3e11f9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "BallotMaker" -version = "0.1.1" +version = "0.1.3" description = "Generate PDF ballots from EDF file; part of ElectOS Versa." authors = ["Neil Johnson "] license = "OSET Public License" diff --git a/src/electos/ballotmaker/constants.py b/src/electos/ballotmaker/constants.py index 6739b61..ca19e08 100644 --- a/src/electos/ballotmaker/constants.py +++ b/src/electos/ballotmaker/constants.py @@ -4,7 +4,7 @@ import errno PROGRAM_NAME = "BallotMaker" -VERSION = "0.1.2" # incremented September 7, 2022 +VERSION = "0.1.3" # incremented September 20, 2022 NO_ERRORS = 0 NO_FILE = errno.ENOENT diff --git a/src/electos/ballotmaker/demo_data/ballot_lab_data.py b/src/electos/ballotmaker/demo_data/ballot_lab_data.py deleted file mode 100644 index 7a3fc60..0000000 --- a/src/electos/ballotmaker/demo_data/ballot_lab_data.py +++ /dev/null @@ -1,303 +0,0 @@ -from typing import List, Union - -from electos.datamodels.nist.indexes import ElementIndex -from electos.datamodels.nist.models.edf import * - -# --- Base Types -# -# Schema expresses these as union types not subclasses - -Contest = Union[BallotMeasureContest, CandidateContest] - -OrderedContent = Union[OrderedContest, OrderedHeader] - - -# --- Utilities - - -def text_content(item): - """Return joined lines from internationalized text.""" - assert isinstance(item, InternationalizedText) - text = "\n".join(_.content for _ in item.text) - return text - - -def walk_ordered_contests(content: List[OrderedContent]): - """Walk ordered content yielding contests.""" - for item in content: - if isinstance(item, OrderedContest): - yield item - elif isinstance(item, OrderedHeader): - yield from walk_ordered_contests(item.ordered_content) - else: - raise TypeError(f"Unexpected type: {type(item).__name__}") - - -def walk_ordered_headers(content: List[OrderedContent]): - """Walk ordered content yielding headers.""" - for item in content: - if isinstance(item, OrderedHeader): - yield item - yield from walk_ordered_headers(item.ordered_content) - else: - raise TypeError(f"Unexpected type: {type(item).__name__}") - - -# --- Ballot Properties - - -def all_ballot_styles(election_report: ElectionReport, index): - """Yield all ballot styles.""" - for ballot_style in index.by_type("BallotStyle"): - yield ballot_style - - -def ballot_style_id(ballot_style: BallotStyle): - """Get the text of a ballot style's external identifier if any.""" - if ballot_style.external_identifier: - assert ( - len(ballot_style.external_identifier) == 1 - ), "Not ready to handle multiple BallotStyle external IDs" - name = ballot_style.external_identifier[0].value - else: - name = "" - return name - - -def ballot_style_gp_units(ballot_style: BallotStyle, index): - for id_ in ballot_style.gp_unit_ids: - gp_unit = index.by_id(id_) - yield gp_unit - - -def ballot_style_contests(ballot_style: BallotStyle, index): - """Yield the contests of a ballot style.""" - for item in walk_ordered_contests(ballot_style.ordered_content): - contest = index.by_id(item.contest_id) - yield contest - - -def candidate_name(candidate: Candidate): - """Get the name of a candidate as it appears on a ballot.""" - name = text_content(candidate.ballot_name) - return name - - -def candidate_party(candidate: Candidate, index): - """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): - """Get any offices associated with a candidate contest.""" - offices = [] - if contest.office_ids: - for id_ in contest.office_ids: - office = index.by_id(id_) - name = text_content(office.name) - offices.append(name) - return offices - - -def candidate_contest_parties(contest: CandidateContest, index): - """Get any parties associated with a candidate contest.""" - parties = [] - if contest.primary_party_ids: - for id_ in contest.primary_party_ids: - party = index.by_id(id_) - name = text_content(party.name) - parties.append(name) - return parties - - -def contest_election_district(contest: Contest, index): - """Get the district name of a contest.""" - district = index.by_id(contest.election_district_id) - district = text_content(district.name) - return district - - -# Gather & Extract -# -# Results are data needed for ballot generation. - - -def extract_candidate_contest(contest: CandidateContest, index): - """Extract candidate contest information needed for ballots.""" - district = contest_election_district(contest, index) - candidates = candidate_contest_candidates(contest, index) - offices = candidate_contest_offices(contest, index) - parties = candidate_contest_parties(contest, index) - result = { - "id": contest.model__id, - "title": contest.name, - "type": "candidate", - "vote_type": contest.vote_variation.value, - # Include even when default is 1: don't require caller to track that. - "votes_allowed": contest.votes_allowed, - "district": district, - "candidates": candidates, - # "offices": offices, - # "parties": parties, - } - return result - - -def extract_ballot_measure_contest(contest: BallotMeasureContest, index): - """Extract ballot measure contest information needed for ballots.""" - choices = [] - for selection in contest.contest_selection: - assert isinstance( - selection, BallotMeasureSelection - ), f"Unexpected non-ballot measure selection: {type(selection).__name__}" - choice = text_content(selection.selection) - 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, - "choices": choices, - "text": full_text, - } - return result - - -def gather_contests(ballot_style: BallotStyle, index): - """Extract all contest information needed for ballots.""" - contests = {kind: [] for kind in ("candidate", "ballot_measure")} - for contest in ballot_style_contests(ballot_style, index): - if isinstance(contest, CandidateContest): - entry = extract_candidate_contest(contest, index) - contests["candidate"].append(entry) - elif isinstance(contest, BallotMeasureContest): - entry = extract_ballot_measure_contest(contest, index) - contests["ballot_measure"].append(entry) - else: - # Ignore other contest types - print(f"Skipping contest of type {contest.model__type}") - return contests - - -# --- Main - -import argparse -import json -from pathlib import Path - - -def report(root, index, nth, **opts): - """Generate data needed by BallotLab""" - ballot_styles = list(all_ballot_styles(root, index)) - if not (1 <= nth <= len(ballot_styles)): - print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") - return - ballot_style = ballot_styles[nth - 1] - data = {} - id_ = ballot_style_id(ballot_style) - data["ballot_style"] = id_ - contests = gather_contests(ballot_style, index) - if not contests: - print(f"No contests found for ballot style: {id_}\n") - data["contests"] = contests - print(json.dumps(data, indent=4)) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("file", type=Path, help="Test case data (JSON)") - parser.add_argument( - "nth", - nargs="?", - type=int, - default=1, - help="Index of the ballot style, starting from 1 (default: 1)", - ) - parser.add_argument( - "--debug", - action="store_true", - help="Enable debugging output and stack traces", - ) - opts = parser.parse_args() - file = opts.file - opts = vars(opts) - - try: - with file.open() as input: - text = input.read() - data = json.loads(text) - edf = ElectionReport(**data) - index = ElementIndex(edf, "ElectionResults") - report(edf, index, **opts) - except Exception as ex: - if opts["debug"]: - raise ex - print("error:", ex) - - -if __name__ == "__main__": - main() diff --git a/src/electos/ballotmaker/demo_data/bedrock_data.json b/src/electos/ballotmaker/demo_data/bedrock_data.json deleted file mode 100644 index 31dcca7..0000000 --- a/src/electos/ballotmaker/demo_data/bedrock_data.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "ballot_style": "precinct_4_bedrock", - "contests": { - "candidate": [ - { - "id": "contest-potus", - "title": "President of the United States", - "type": "candidate", - "vote_type": "plurality", - "votes_allowed": 1, - "district": "United States of America", - "candidates": [ - { - "id": "contest-potus--candidate-lepton", - "name": [ - "Anthony Alpha", - "Betty Beta" - ], - "party": [ - { - "name": "The Lepton Party", - "abbreviation": "LEP" - } - ], - "is_write_in": false - }, - { - "id": "contest-potus--candidate-hadron", - "name": [ - "Gloria Gamma", - "David Delta" - ], - "party": [ - { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD" - } - ], - "is_write_in": false - }, - { - "id": "contest-potus--candidate-write-in-1", - "name": [], - "party": [], - "is_write_in": true - } - ] - }, - { - "id": "contest-gadget-county-school-board", - "title": "Gadget County School Board", - "type": "candidate", - "vote_type": "n-of-m", - "votes_allowed": 4, - "district": "Gadget County", - "candidates": [ - { - "id": "contest-gadget-county-school-board--candidate-rosashawn-davis", - "name": [ - "Rosashawn Davis" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-hector-gomez", - "name": [ - "Hector Gomez" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-glavin-orotund", - "name": [ - "Glavin Orotund" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-sally-smith", - "name": [ - "Sally Smith" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-oliver-tsi", - "name": [ - "Oliver Tsi" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-write-in-1", - "name": [], - "party": [], - "is_write_in": true - }, - { - "id": "contest-gadget-county-school-board--candidate-write-in-2", - "name": [], - "party": [], - "is_write_in": true - }, - { - "id": "contest-gadget-county-school-board--candidate-write-in-3", - "name": [], - "party": [], - "is_write_in": true - } - ] - } - ], - "ballot_measure": [ - { - "id": "ballot-measure-helium-balloons", - "title": "Constitutional Amendment", - "type": "ballot measure", - "district": "The State of Farallon", - "choices": [ - { - "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.\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" - }, - { - "id": "ballot-measure-air-traffic-control-tax", - "title": "Air Traffic Control Tax Increase", - "type": "ballot measure", - "district": "Gadget County", - "choices": [ - { - "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?" - } - ] - } -} diff --git a/src/electos/ballotmaker/demo_data/dict_maker.py b/src/electos/ballotmaker/demo_data/dict_maker.py deleted file mode 100644 index 0c95e78..0000000 --- a/src/electos/ballotmaker/demo_data/dict_maker.py +++ /dev/null @@ -1,124 +0,0 @@ -import json -from pathlib import Path -from pprint import pprint - -from electos.datamodels.nist.indexes.element_index import ElementIndex -from electos.datamodels.nist.models.edf import ElectionReport - -edf = Path("/Users/neil/repos/BallotLabFork/tests/june_test_case.json") -edf_data = json.loads(edf.read_text()) -election_report = ElectionReport(**edf_data) -index = ElementIndex(election_report, "ElectionResults") - -candidates = {} -gp_units = {} -offices = {} -parties = {} -people = {} - - -def get_gp_units_dict(gp_units: dict) -> int: - for count, gp_unit in enumerate( - index.by_type("ElectionResults.ReportingUnit"), start=1 - ): - gp_unit_id = gp_unit.model__id - gp_unit_name = gp_unit.name.text[0].content - # gp_unit_type = gp_unit.type - # print(f" '{gp_unit_id}': '{gp_unit_name}'") - gp_units[gp_unit_id] = gp_unit_name - return count - - -def get_offices_dict(offices: dict) -> int: - for count, office in enumerate( - index.by_type("ElectionResults.Office"), start=1 - ): - office_id = office.model__id - office_name = office.name.text[0].content - print(f" '{office_id}': '{office_name}'") - offices[office_id] = office_name - return count - - -def get_parties_dict(parties: dict) -> int: - for count, party in enumerate( - index.by_type("ElectionResults.Party"), start=1 - ): - party_id = party.model__id - party_name = party.name.text[0].content - party_abbr = party.abbreviation.text[0].content - party_value = (party_name, party_abbr) - print(f" '{party_id}': ('{party_name}, {party_abbr})'") - parties[party_id] = party_value - return count - - -def get_people_dict(people: dict) -> int: - for count, person in enumerate( - index.by_type("ElectionResults.Person"), start=1 - ): - person_id = person.model__id - first_name = person.first_name - last_name = person.last_name - print(f" '{person_id}': {first_name} {last_name},") - people[person_id] = f"{first_name} {last_name}" - return count - - -def get_candidate_dict(candidates: dict) -> int: - for count, candidate in enumerate( - index.by_type("ElectionResults.Candidate"), start=1 - ): - candidate_id = candidate.model__id - candidate_ballot_name = candidate.ballot_name.text[0].content - print(f" '{candidate_id}': {candidate_ballot_name},") - candidates[candidate_id] = candidate_ballot_name - return count - - -print("# Dictionary of GPUnit = id: name") -print(f"Found {get_gp_units_dict(gp_units)} GPUnits:") -pprint(gp_units) - -print("# Dictionary of Office = id: name") -print(f"Found {get_offices_dict(offices)} offices:") -pprint(offices) - -print("# Dictionary of Party = id: (name, abbreviation)") -print(f"Found {get_parties_dict(parties)} parties:") -pprint(parties) - -print("# Dictionary of People = id: firstname lastname") -print(f"Found {get_people_dict(people)} people:") -pprint(people) - -print("# Dictionary of Candidate") -print(f"Found {get_candidate_dict(candidates)} candidate:") -pprint(candidates) - -print("# Dictionary of CandidateContest") -for candidate_contest in index.by_type("ElectionResults.CandidateContest"): - vote_variation = candidate_contest.vote_variation.value - if vote_variation == "n-of-m": - continue - can_contest_id = candidate_contest.model__id - can_contest_name = candidate_contest.name - # office_ids could contain multiple items - office_ids = candidate_contest.office_ids - contest_offices = [offices[id] for id in office_ids] - votes_allowed = candidate_contest.votes_allowed - election_district = gp_units[candidate_contest.election_district_id] - contest_index = ElementIndex(candidate_contest, "ElectionResults") - print( - f" '{can_contest_id}': ('{can_contest_name}','{contest_offices}', '{vote_variation}', {votes_allowed}, '{election_district}')," - ) - print(contest_index) - for content_selection in contest_index.by_type( - "ElectionResults.CandidateSelection" - ): - print(content_selection.name) - contest_id = content_selection.model__id - - candidate_ids = content_selection.candidate_ids - candidate_names = [candidates[c_id] for c_id in candidate_ids] - print(f"{contest_id} {candidate_names}") diff --git a/src/electos/ballotmaker/demo_data/downtown_data.json b/src/electos/ballotmaker/demo_data/downtown_data.json deleted file mode 100644 index d8c4c34..0000000 --- a/src/electos/ballotmaker/demo_data/downtown_data.json +++ /dev/null @@ -1,207 +0,0 @@ -{ - "ballot_style": "precinct_1_downtown", - "contests": { - "candidate": [ - { - "id": "contest-potus", - "title": "President of the United States", - "type": "candidate", - "vote_type": "plurality", - "votes_allowed": 1, - "district": "United States of America", - "candidates": [ - { - "id": "contest-potus--candidate-lepton", - "name": [ - "Anthony Alpha", - "Betty Beta" - ], - "party": [ - { - "name": "The Lepton Party", - "abbreviation": "LEP" - } - ], - "is_write_in": false - }, - { - "id": "contest-potus--candidate-hadron", - "name": [ - "Gloria Gamma", - "David Delta" - ], - "party": [ - { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD" - } - ], - "is_write_in": false - }, - { - "id": "contest-potus--candidate-write-in-1", - "name": [], - "party": [], - "is_write_in": true - } - ] - }, - { - "id": "contest-gadget-county-school-board", - "title": "Gadget County School Board", - "type": "candidate", - "vote_type": "n-of-m", - "votes_allowed": 4, - "district": "Gadget County", - "candidates": [ - { - "id": "contest-gadget-county-school-board--candidate-rosashawn-davis", - "name": [ - "Rosashawn Davis" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-hector-gomez", - "name": [ - "Hector Gomez" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-glavin-orotund", - "name": [ - "Glavin Orotund" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-sally-smith", - "name": [ - "Sally Smith" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-oliver-tsi", - "name": [ - "Oliver Tsi" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-write-in-1", - "name": [], - "party": [], - "is_write_in": true - }, - { - "id": "contest-gadget-county-school-board--candidate-write-in-2", - "name": [], - "party": [], - "is_write_in": true - }, - { - "id": "contest-gadget-county-school-board--candidate-write-in-3", - "name": [], - "party": [], - "is_write_in": true - } - ] - }, - { - "id": "contest-orbit-city-mayor", - "title": "Contest for Mayor of Orbit City", - "type": "candidate", - "vote_type": "plurality", - "votes_allowed": 1, - "district": "Orbit City", - "candidates": [ - { - "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": "contest-orbit-city-mayor--candidate-cosmo-spacely", - "name": [ - "Cosmo Spacely" - ], - "party": [ - { - "name": "The Lepton Party", - "abbreviation": "LEP" - } - ], - "is_write_in": false - }, - { - "id": "contest-orbit-city-mayor--candidate-write-in-1", - "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": [ - { - "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": [ - { - "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.\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" - } - ] - } -} diff --git a/src/electos/ballotmaker/demo_data/gp_units.py b/src/electos/ballotmaker/demo_data/gp_units.py deleted file mode 100644 index e52b019..0000000 --- a/src/electos/ballotmaker/demo_data/gp_units.py +++ /dev/null @@ -1,12 +0,0 @@ -gp_units = { - "rec7dCergEa3mzqxy": "Port Precinct", - "rec93s713Yh6ZJT31": "The State of Farallon", - "recFIehh5Aj0zGTn6": "Downtown Precinct", - "recOVSnILnPJ7Dahl": "Gadget County", - "recSQ3ZpvJlTll1Ve": "Bedrock Precinct", - "recTXCMIfa5VQJju2": "United States of America", - "recUuJTc3tUIUvgF1": "Spacetown Precinct", - "recVAsRw7BvEIBnTe": "Gadget County Unified School District", - "recVN5dRsq4j6QZn3": "Aldrin Space Transport District", - "recfK8xOapcRIeZ2k": "Orbit City", -} diff --git a/src/electos/ballotmaker/demo_data/offices.py b/src/electos/ballotmaker/demo_data/offices.py deleted file mode 100644 index d729268..0000000 --- a/src/electos/ballotmaker/demo_data/offices.py +++ /dev/null @@ -1,7 +0,0 @@ -offices = { - "rec7N0cboW3L1Mv0I": ("Mayor of Orbit City", True), - "recBAG7iuOZ1MER6i": ("Spaceport Control Board Member", False), - "rec1zWmGWlgKKmUO4": ("Gadget County School Board", False), - "recFr8nr6uAZsD2r8": ("President of the United States", True), - "recIR57LPmJ0VvtEo": ("Vice-President of the United States", True), -} diff --git a/src/electos/ballotmaker/demo_data/party.py b/src/electos/ballotmaker/demo_data/party.py deleted file mode 100644 index d3c2d79..0000000 --- a/src/electos/ballotmaker/demo_data/party.py +++ /dev/null @@ -1,4 +0,0 @@ -parties = { - "reckpEKRtLuDdt03n": ("The Hadron Party of Farallon", "HAD"), - "recBiK9LZXeZmmFEg": ("The Lepton Party", "LEP"), -} diff --git a/src/electos/ballotmaker/demo_data/september_demo_data.json b/src/electos/ballotmaker/demo_data/september_demo_data.json deleted file mode 100644 index e0cc547..0000000 --- a/src/electos/ballotmaker/demo_data/september_demo_data.json +++ /dev/null @@ -1,604 +0,0 @@ -[ - { - "name": "General Election", - "type": "general", - "start_date": "2024-11-05", - "end_date": "2024-11-05", - "ballot_styles": [ - { - "id": "precinct_1_downtown", - "scopes": ["recFIehh5Aj0zGTn6"], - "contests": [ - { - "id": "recsoZy7vYhS3lbcK", - "type": "candidate", - "title": "President of the United States", - "district": "United States of America", - "vote_type": "plurality", - "votes_allowed": 1, - "candidates": [ - { - "id": "recQK3J9IJq42hz2n", - "name": ["Anthony Alpha", "Betty Beta"], - "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", - } - ], - "is_write_in": false, - }, - { - "id": "recPod2L8VhwagiDl", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recthF6jdx5ybBNkC", - "type": "candidate", - "title": "Gadget County School Board", - "district": "Gadget County", - "vote_type": "n-of-m", - "votes_allowed": 4, - "candidates": [ - { - "id": "recJvikmG5MrUKzo1", - "name": ["Rosashawn Davis"], - "party": [], - "is_write_in": false, - }, - { - "id": "recigPkqYXXDJEaCE", - "name": ["Hector Gomez"], - "party": [], - "is_write_in": false, - }, - { - "id": "recbN7UUMaSuOYGQ6", - "name": ["Glavin Orotund"], - "party": [], - "is_write_in": false, - }, - { - "id": "recbxvhKikHJNZYbq", - "name": ["Sally Smith"], - "party": [], - "is_write_in": false, - }, - { - "id": "recvjB3rgfiicf0RP", - "name": ["Oliver Tsi"], - "party": [], - "is_write_in": false, - }, - { - "id": "recYurH2CLY3SlYS8", - "name": [], - "party": [], - "is_write_in": true, - }, - { - "id": "recI5jfcXIsbAKytC", - "name": [], - "party": [], - "is_write_in": true, - }, - { - "id": "recn9m0o1em7gLahj", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recIj8OmzqzzvnDbM", - "type": "candidate", - "title": "Contest for Mayor of Orbit City", - "district": "Orbit City", - "vote_type": "plurality", - "votes_allowed": 1, - "candidates": [ - { - "id": "recKD6dBvkNhEU4bg", - "name": ["Spencer Cogswell"], - "party": [ - { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD", - } - ], - "is_write_in": false, - }, - { - "id": "recTKcXLCzRvKB9U0", - "name": ["Cosmo Spacely"], - "party": [ - { - "name": "The Lepton Party", - "abbreviation": "LEP", - } - ], - "is_write_in": false, - }, - { - "id": "recqq21kO6HWgpJZV", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recqPa7AeyufIfd6k", - "type": "ballot measure", - "title": "Air Traffic Control Tax Increase", - "district": "Gadget County", - "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?", - "choices": [ - {"id": "recysACFx8cgwomBE", "choice": "Yes"}, - {"id": "recabXA9jzFYRmGXy", "choice": "No"}, - ], - }, - { - "id": "recWjDBFeafCdklWq", - "type": "ballot measure", - "title": "Constitutional Amendment", - "district": "The State of Farallon", - "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", - "choices": [ - {"id": "rec7mVWjUH6fmDxig", "choice": "Yes"}, - {"id": "reccIHOhUfJgJkqS7", "choice": "No"}, - ], - }, - ], - }, - { - "id": "precinct_4_bedrock", - "scopes": ["recSQ3ZpvJlTll1Ve"], - "contests": [ - { - "id": "recsoZy7vYhS3lbcK", - "type": "candidate", - "title": "President of the United States", - "district": "United States of America", - "vote_type": "plurality", - "votes_allowed": 1, - "candidates": [ - { - "id": "recQK3J9IJq42hz2n", - "name": ["Anthony Alpha", "Betty Beta"], - "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", - } - ], - "is_write_in": false, - }, - { - "id": "recPod2L8VhwagiDl", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recthF6jdx5ybBNkC", - "type": "candidate", - "title": "Gadget County School Board", - "district": "Gadget County", - "vote_type": "n-of-m", - "votes_allowed": 4, - "candidates": [ - { - "id": "recJvikmG5MrUKzo1", - "name": ["Rosashawn Davis"], - "party": [], - "is_write_in": false, - }, - { - "id": "recigPkqYXXDJEaCE", - "name": ["Hector Gomez"], - "party": [], - "is_write_in": false, - }, - { - "id": "recbN7UUMaSuOYGQ6", - "name": ["Glavin Orotund"], - "party": [], - "is_write_in": false, - }, - { - "id": "recbxvhKikHJNZYbq", - "name": ["Sally Smith"], - "party": [], - "is_write_in": false, - }, - { - "id": "recvjB3rgfiicf0RP", - "name": ["Oliver Tsi"], - "party": [], - "is_write_in": false, - }, - { - "id": "recYurH2CLY3SlYS8", - "name": [], - "party": [], - "is_write_in": true, - }, - { - "id": "recI5jfcXIsbAKytC", - "name": [], - "party": [], - "is_write_in": true, - }, - { - "id": "recn9m0o1em7gLahj", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recWjDBFeafCdklWq", - "type": "ballot measure", - "title": "Constitutional Amendment", - "district": "The State of Farallon", - "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", - "choices": [ - {"id": "rec7mVWjUH6fmDxig", "choice": "Yes"}, - {"id": "reccIHOhUfJgJkqS7", "choice": "No"}, - ], - }, - { - "id": "recqPa7AeyufIfd6k", - "type": "ballot measure", - "title": "Air Traffic Control Tax Increase", - "district": "Gadget County", - "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?", - "choices": [ - {"id": "recysACFx8cgwomBE", "choice": "Yes"}, - {"id": "recabXA9jzFYRmGXy", "choice": "No"}, - ], - }, - ], - }, - { - "id": "precinct_3_spaceport", - "scopes": ["rec7dCergEa3mzqxy"], - "contests": [ - { - "id": "recsoZy7vYhS3lbcK", - "type": "candidate", - "title": "President of the United States", - "district": "United States of America", - "vote_type": "plurality", - "votes_allowed": 1, - "candidates": [ - { - "id": "recQK3J9IJq42hz2n", - "name": ["Anthony Alpha", "Betty Beta"], - "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", - } - ], - "is_write_in": false, - }, - { - "id": "recPod2L8VhwagiDl", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recXNb4zPrvC1m6Fr", - "type": "candidate", - "title": "Spaceport Control Board", - "district": "Aldrin Space Transport District", - "vote_type": "n-of-m", - "votes_allowed": 2, - "candidates": [ - { - "id": "recBnJZEgCKAnfpNo", - "name": ["Harlan Ellis"], - "party": [], - "is_write_in": false, - }, - { - "id": "recwNuOnepWNGz67V", - "name": ["Rudy Indexer"], - "party": [], - "is_write_in": false, - }, - { - "id": "recvYvTb9hWH7tptb", - "name": ["Jane Jetson"], - "party": [], - "is_write_in": false, - }, - { - "id": "rec9Eev970VhohqKi", - "name": [], - "party": [], - "is_write_in": true, - }, - { - "id": "recFiGYjGCIyk5LBe", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recqPa7AeyufIfd6k", - "type": "ballot measure", - "title": "Air Traffic Control Tax Increase", - "district": "Gadget County", - "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?", - "choices": [ - {"id": "recysACFx8cgwomBE", "choice": "Yes"}, - {"id": "recabXA9jzFYRmGXy", "choice": "No"}, - ], - }, - { - "id": "recWjDBFeafCdklWq", - "type": "ballot measure", - "title": "Constitutional Amendment", - "district": "The State of Farallon", - "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", - "choices": [ - {"id": "rec7mVWjUH6fmDxig", "choice": "Yes"}, - {"id": "reccIHOhUfJgJkqS7", "choice": "No"}, - ], - }, - ], - }, - { - "id": "precinct_2_spacetown", - "scopes": ["recUuJTc3tUIUvgF1"], - "contests": [ - { - "id": "recsoZy7vYhS3lbcK", - "type": "candidate", - "title": "President of the United States", - "district": "United States of America", - "vote_type": "plurality", - "votes_allowed": 1, - "candidates": [ - { - "id": "recQK3J9IJq42hz2n", - "name": ["Anthony Alpha", "Betty Beta"], - "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", - } - ], - "is_write_in": false, - }, - { - "id": "recPod2L8VhwagiDl", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recthF6jdx5ybBNkC", - "type": "candidate", - "title": "Gadget County School Board", - "district": "Gadget County", - "vote_type": "n-of-m", - "votes_allowed": 4, - "candidates": [ - { - "id": "recJvikmG5MrUKzo1", - "name": ["Rosashawn Davis"], - "party": [], - "is_write_in": false, - }, - { - "id": "recigPkqYXXDJEaCE", - "name": ["Hector Gomez"], - "party": [], - "is_write_in": false, - }, - { - "id": "recbN7UUMaSuOYGQ6", - "name": ["Glavin Orotund"], - "party": [], - "is_write_in": false, - }, - { - "id": "recbxvhKikHJNZYbq", - "name": ["Sally Smith"], - "party": [], - "is_write_in": false, - }, - { - "id": "recvjB3rgfiicf0RP", - "name": ["Oliver Tsi"], - "party": [], - "is_write_in": false, - }, - { - "id": "recYurH2CLY3SlYS8", - "name": [], - "party": [], - "is_write_in": true, - }, - { - "id": "recI5jfcXIsbAKytC", - "name": [], - "party": [], - "is_write_in": true, - }, - { - "id": "recn9m0o1em7gLahj", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recIj8OmzqzzvnDbM", - "type": "candidate", - "title": "Contest for Mayor of Orbit City", - "district": "Orbit City", - "vote_type": "plurality", - "votes_allowed": 1, - "candidates": [ - { - "id": "recKD6dBvkNhEU4bg", - "name": ["Spencer Cogswell"], - "party": [ - { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD", - } - ], - "is_write_in": false, - }, - { - "id": "recTKcXLCzRvKB9U0", - "name": ["Cosmo Spacely"], - "party": [ - { - "name": "The Lepton Party", - "abbreviation": "LEP", - } - ], - "is_write_in": false, - }, - { - "id": "recqq21kO6HWgpJZV", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recXNb4zPrvC1m6Fr", - "type": "candidate", - "title": "Spaceport Control Board", - "district": "Aldrin Space Transport District", - "vote_type": "n-of-m", - "votes_allowed": 2, - "candidates": [ - { - "id": "recBnJZEgCKAnfpNo", - "name": ["Harlan Ellis"], - "party": [], - "is_write_in": false, - }, - { - "id": "recwNuOnepWNGz67V", - "name": ["Rudy Indexer"], - "party": [], - "is_write_in": false, - }, - { - "id": "recvYvTb9hWH7tptb", - "name": ["Jane Jetson"], - "party": [], - "is_write_in": false, - }, - { - "id": "rec9Eev970VhohqKi", - "name": [], - "party": [], - "is_write_in": true, - }, - { - "id": "recFiGYjGCIyk5LBe", - "name": [], - "party": [], - "is_write_in": true, - }, - ], - }, - { - "id": "recqPa7AeyufIfd6k", - "type": "ballot measure", - "title": "Air Traffic Control Tax Increase", - "district": "Gadget County", - "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?", - "choices": [ - {"id": "recysACFx8cgwomBE", "choice": "Yes"}, - {"id": "recabXA9jzFYRmGXy", "choice": "No"}, - ], - }, - { - "id": "recWjDBFeafCdklWq", - "type": "ballot measure", - "title": "Constitutional Amendment", - "district": "The State of Farallon", - "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", - "choices": [ - {"id": "rec7mVWjUH6fmDxig", "choice": "Yes"}, - {"id": "reccIHOhUfJgJkqS7", "choice": "No"}, - ], - }, - ], - }, - ], - } -] diff --git a/src/electos/ballotmaker/demo_data/spaceport_data.json b/src/electos/ballotmaker/demo_data/spaceport_data.json deleted file mode 100644 index 90b980b..0000000 --- a/src/electos/ballotmaker/demo_data/spaceport_data.json +++ /dev/null @@ -1,139 +0,0 @@ -{ - "ballot_style": "precinct_3_spaceport", - "contests": { - "candidate": [ - { - "id": "contest-potus", - "title": "President of the United States", - "type": "candidate", - "vote_type": "plurality", - "votes_allowed": 1, - "district": "United States of America", - "candidates": [ - { - "id": "contest-potus--candidate-lepton", - "name": [ - "Anthony Alpha", - "Betty Beta" - ], - "party": [ - { - "name": "The Lepton Party", - "abbreviation": "LEP" - } - ], - "is_write_in": false - }, - { - "id": "contest-potus--candidate-hadron", - "name": [ - "Gloria Gamma", - "David Delta" - ], - "party": [ - { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD" - } - ], - "is_write_in": false - }, - { - "id": "contest-potus--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": [ - "Harlan Ellis" - ], - "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": [ - { - "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": [ - { - "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.\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" - } - ] - } -} diff --git a/src/electos/ballotmaker/demo_data/spacetown_data.json b/src/electos/ballotmaker/demo_data/spacetown_data.json deleted file mode 100644 index a5635fd..0000000 --- a/src/electos/ballotmaker/demo_data/spacetown_data.json +++ /dev/null @@ -1,259 +0,0 @@ -{ - "ballot_style": "precinct_2_spacetown", - "contests": { - "candidate": [ - { - "id": "contest-potus", - "title": "President of the United States", - "type": "candidate", - "vote_type": "plurality", - "votes_allowed": 1, - "district": "United States of America", - "candidates": [ - { - "id": "contest-potus--candidate-lepton", - "name": [ - "Anthony Alpha", - "Betty Beta" - ], - "party": [ - { - "name": "The Lepton Party", - "abbreviation": "LEP" - } - ], - "is_write_in": false - }, - { - "id": "contest-potus--candidate-hadron", - "name": [ - "Gloria Gamma", - "David Delta" - ], - "party": [ - { - "name": "The Hadron Party of Farallon", - "abbreviation": "HAD" - } - ], - "is_write_in": false - }, - { - "id": "contest-potus--candidate-write-in-1", - "name": [], - "party": [], - "is_write_in": true - } - ] - }, - { - "id": "contest-gadget-county-school-board", - "title": "Gadget County School Board", - "type": "candidate", - "vote_type": "n-of-m", - "votes_allowed": 4, - "district": "Gadget County", - "candidates": [ - { - "id": "contest-gadget-county-school-board--candidate-rosashawn-davis", - "name": [ - "Rosashawn Davis" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-hector-gomez", - "name": [ - "Hector Gomez" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-glavin-orotund", - "name": [ - "Glavin Orotund" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-sally-smith", - "name": [ - "Sally Smith" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-oliver-tsi", - "name": [ - "Oliver Tsi" - ], - "party": [ - {} - ], - "is_write_in": false - }, - { - "id": "contest-gadget-county-school-board--candidate-write-in-1", - "name": [], - "party": [], - "is_write_in": true - }, - { - "id": "contest-gadget-county-school-board--candidate-write-in-2", - "name": [], - "party": [], - "is_write_in": true - }, - { - "id": "contest-gadget-county-school-board--candidate-write-in-3", - "name": [], - "party": [], - "is_write_in": true - } - ] - }, - { - "id": "contest-orbit-city-mayor", - "title": "Contest for Mayor of Orbit City", - "type": "candidate", - "vote_type": "plurality", - "votes_allowed": 1, - "district": "Orbit City", - "candidates": [ - { - "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": "contest-orbit-city-mayor--candidate-cosmo-spacely", - "name": [ - "Cosmo Spacely" - ], - "party": [ - { - "name": "The Lepton Party", - "abbreviation": "LEP" - } - ], - "is_write_in": false - }, - { - "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": [ - "Harlan Ellis" - ], - "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": [ - { - "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": [ - { - "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.\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" - } - ] - } -} diff --git a/src/electos/ballotmaker/demo_data/spacetown_data.py b/src/electos/ballotmaker/demo_data/spacetown_data.py deleted file mode 100644 index e28f70b..0000000 --- a/src/electos/ballotmaker/demo_data/spacetown_data.py +++ /dev/null @@ -1,148 +0,0 @@ -can_con_4 = { - "id": "recIj8OmzqzzvnDbM", - "title": "Contest for Mayor of Orbit City", - "type": "candidate", - "vote_type": "plurality", - "votes_allowed": 1, - "district": "Orbit City", - "candidates": [ - { - "id": "recTKcXLCzRvKB9U0", - "name": ["Cosmo Spacely"], - "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", - }, - ], - "is_write_in": False, - }, - {"id": "recqq21kO6HWgpJZV", "is_write_in": True}, - ], -} - -can_con_3 = { - "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": "", "abbreviation": ""}], - "is_write_in": False, - }, - { - "id": "recBnJZEgCKAnfpNo", - "name": ["Harlan Ellis"], - "party": [{"name": "", "abbreviation": ""}], - "is_write_in": False, - }, - { - "id": "recwNuOnepWNGz67V", - "name": ["Rudy Indexer"], - "party": [{"name": "", "abbreviation": ""}], - "is_write_in": False, - }, - {"id": "rec9Eev970VhohqKi", "is_write_in": True}, - {"id": "recFiGYjGCIyk5LBe", "is_write_in": True}, - ], -} - -can_con_2 = { - "id": "recthF6jdx5ybBNkC", - "title": "Gadget County School Board", - "type": "candidate", - "vote_type": "n-of-m", - "votes_allowed": 4, - "district": "Gadget County", - "candidates": [ - { - "id": "recbxvhKikHJNZYbq", - "name": ["Sally Smith"], - "party": [{"name": "", "abbreviation": ""}], - "is_write_in": False, - }, - { - "id": "recigPkqYXXDJEaCE", - "name": ["Hector Gomez"], - "party": [{"name": "", "abbreviation": ""}], - "is_write_in": False, - }, - { - "id": "recJvikmG5MrUKzo1", - "name": ["Rosashawn Davis"], - "party": [{"name": "", "abbreviation": ""}], - "is_write_in": False, - }, - { - "id": "recvjB3rgfiicf0RP", - "name": ["Oliver Tsi"], - "party": [{"name": "", "abbreviation": ""}], - "is_write_in": False, - }, - { - "id": "recbN7UUMaSuOYGQ6", - "name": ["Glavin Orotund"], - "party": [{"name": "", "abbreviation": ""}], - "is_write_in": False, - }, - {"id": "recYurH2CLY3SlYS8", "is_write_in": True}, - {"id": "recI5jfcXIsbAKytC", "is_write_in": True}, - {"id": "recn9m0o1em7gLahj", "is_write_in": True}, - ], -} - -can_con_1 = { - "id": "recsoZy7vYhS3lbcK", - "title": "President of the United States", - "type": "candidate", - "vote_type": "plurality", - "votes_allowed": 1, - "district": "United States of America", - "candidates": [ - { - "id": "recQK3J9IJq42hz2n", - "name": ["Anthony Alpha", "Betty Beta"], - "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", - }, - ], - "is_write_in": False, - }, - {"id": "recPod2L8VhwagiDl", "is_write_in": True}, - ], -} - -ballot_measure_1 = { - "title": "Constitutional Amendment", - "type": "ballot measure", - "district": "The State of Farallon", - "choices": ["Yes", "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.", -} - -ballot_measure_2 = { - "title": "Air Traffic Control Tax Increase", - "type": "ballot measure", - "district": "Gadget County", - "choices": ["Yes", "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?", -} diff --git a/src/electos/ballotmaker/demo_election_data.py b/src/electos/ballotmaker/demo_election_data.py deleted file mode 100644 index de3dc23..0000000 --- a/src/electos/ballotmaker/demo_election_data.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging -from dataclasses import dataclass, field - -log = logging.getLogger(__name__) - - -def get_election_header() -> dict: - return { - "Name": "General Election", - "StartDate": "2024-11-05", - "EndDate": "2024-11-05", - "Type": "general", - "ElectionScope": "United States of America", - } - - -@dataclass -class DemoElectionData: - election_header: dict = field(init=False) - - def __post_init__(self): - self.election_header = get_election_header() - log.debug(f"Election Name: {self.election_header['Name']}") From 45f06bac33db746b429f3a2108d7259bc8e8126b Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Thu, 22 Sep 2022 10:49:27 -0400 Subject: [PATCH 79/79] Remove test output PDF from repo root --- simple_radios.pdf | Bin 14811 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 simple_radios.pdf diff --git a/simple_radios.pdf b/simple_radios.pdf deleted file mode 100644 index 128f61f6debd9c7253530363878e28a59ee0a547..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14811 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