From 35597eae3372985b01e79596bbb21bb241b20ea1 Mon Sep 17 00:00:00 2001 From: Neil Johnson Date: Thu, 15 Sep 2022 10:25:07 -0400 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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)