diff --git a/assets/img/warn_cyan.png b/assets/img/warn_cyan.png deleted file mode 100644 index 3ff1f4b..0000000 Binary files a/assets/img/warn_cyan.png and /dev/null differ diff --git a/assets/fonts/Roboto/LICENSE.txt b/fonts/Roboto/LICENSE.txt similarity index 100% rename from assets/fonts/Roboto/LICENSE.txt rename to fonts/Roboto/LICENSE.txt diff --git a/assets/fonts/Roboto/Roboto-Black.ttf b/fonts/Roboto/Roboto-Black.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-Black.ttf rename to fonts/Roboto/Roboto-Black.ttf diff --git a/assets/fonts/Roboto/Roboto-BlackItalic.ttf b/fonts/Roboto/Roboto-BlackItalic.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-BlackItalic.ttf rename to fonts/Roboto/Roboto-BlackItalic.ttf diff --git a/assets/fonts/Roboto/Roboto-Bold.ttf b/fonts/Roboto/Roboto-Bold.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-Bold.ttf rename to fonts/Roboto/Roboto-Bold.ttf diff --git a/assets/fonts/Roboto/Roboto-BoldItalic.ttf b/fonts/Roboto/Roboto-BoldItalic.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-BoldItalic.ttf rename to fonts/Roboto/Roboto-BoldItalic.ttf diff --git a/assets/fonts/Roboto/Roboto-Italic.ttf b/fonts/Roboto/Roboto-Italic.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-Italic.ttf rename to fonts/Roboto/Roboto-Italic.ttf diff --git a/assets/fonts/Roboto/Roboto-Light.ttf b/fonts/Roboto/Roboto-Light.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-Light.ttf rename to fonts/Roboto/Roboto-Light.ttf diff --git a/assets/fonts/Roboto/Roboto-LightItalic.ttf b/fonts/Roboto/Roboto-LightItalic.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-LightItalic.ttf rename to fonts/Roboto/Roboto-LightItalic.ttf diff --git a/assets/fonts/Roboto/Roboto-Medium.ttf b/fonts/Roboto/Roboto-Medium.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-Medium.ttf rename to fonts/Roboto/Roboto-Medium.ttf diff --git a/assets/fonts/Roboto/Roboto-MediumItalic.ttf b/fonts/Roboto/Roboto-MediumItalic.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-MediumItalic.ttf rename to fonts/Roboto/Roboto-MediumItalic.ttf diff --git a/assets/fonts/Roboto/Roboto-Regular.ttf b/fonts/Roboto/Roboto-Regular.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-Regular.ttf rename to fonts/Roboto/Roboto-Regular.ttf diff --git a/assets/fonts/Roboto/Roboto-Thin.ttf b/fonts/Roboto/Roboto-Thin.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-Thin.ttf rename to fonts/Roboto/Roboto-Thin.ttf diff --git a/assets/fonts/Roboto/Roboto-ThinItalic.ttf b/fonts/Roboto/Roboto-ThinItalic.ttf similarity index 100% rename from assets/fonts/Roboto/Roboto-ThinItalic.ttf rename to fonts/Roboto/Roboto-ThinItalic.ttf diff --git a/pdfs/ballot_demo_2022_08_21T232623.pdf b/pdfs/ballot_demo_2022_08_21T232623.pdf new file mode 100644 index 0000000..a4469f4 Binary files /dev/null and b/pdfs/ballot_demo_2022_08_21T232623.pdf differ diff --git a/assets/data/BallotStudio_16_Edits.JSON b/src/electos/ballotmaker/assets/data/BallotStudio_16_Edits.JSON similarity index 100% rename from assets/data/BallotStudio_16_Edits.JSON rename to src/electos/ballotmaker/assets/data/BallotStudio_16_Edits.JSON diff --git a/assets/data/BallotStudio_7_Formatted.JSON b/src/electos/ballotmaker/assets/data/BallotStudio_7_Formatted.JSON similarity index 100% rename from assets/data/BallotStudio_7_Formatted.JSON rename to src/electos/ballotmaker/assets/data/BallotStudio_7_Formatted.JSON diff --git a/assets/data/JESTONS_PAPARDEV_&_AUG_2021.json b/src/electos/ballotmaker/assets/data/JESTONS_PAPARDEV_&_AUG_2021.json similarity index 100% rename from assets/data/JESTONS_PAPARDEV_&_AUG_2021.json rename to src/electos/ballotmaker/assets/data/JESTONS_PAPARDEV_&_AUG_2021.json diff --git a/assets/data/NIST_sample.json b/src/electos/ballotmaker/assets/data/NIST_sample.json similarity index 100% rename from assets/data/NIST_sample.json rename to src/electos/ballotmaker/assets/data/NIST_sample.json diff --git a/assets/data/june_test_case.json b/src/electos/ballotmaker/assets/data/june_test_case.json similarity index 100% rename from assets/data/june_test_case.json rename to src/electos/ballotmaker/assets/data/june_test_case.json diff --git a/assets/data/nist_sample_election_report.xml b/src/electos/ballotmaker/assets/data/nist_sample_election_report.xml similarity index 100% rename from assets/data/nist_sample_election_report.xml rename to src/electos/ballotmaker/assets/data/nist_sample_election_report.xml diff --git a/assets/img/filled_bubble.png b/src/electos/ballotmaker/assets/img/filled_bubble.png similarity index 100% rename from assets/img/filled_bubble.png rename to src/electos/ballotmaker/assets/img/filled_bubble.png diff --git a/src/electos/ballotmaker/assets/img/warn_cyan.png b/src/electos/ballotmaker/assets/img/warn_cyan.png new file mode 100644 index 0000000..522f02e Binary files /dev/null and b/src/electos/ballotmaker/assets/img/warn_cyan.png differ diff --git a/assets/img/writein.png b/src/electos/ballotmaker/assets/img/writein.png similarity index 100% rename from assets/img/writein.png rename to src/electos/ballotmaker/assets/img/writein.png diff --git a/assets/schema/NIST_V2_election_results_reporting.json b/src/electos/ballotmaker/assets/schema/NIST_V2_election_results_reporting.json similarity index 100% rename from assets/schema/NIST_V2_election_results_reporting.json rename to src/electos/ballotmaker/assets/schema/NIST_V2_election_results_reporting.json diff --git a/src/electos/ballotmaker/ballots/contest_data.py b/src/electos/ballotmaker/ballots/contest_data.py new file mode 100644 index 0000000..e964d27 --- /dev/null +++ b/src/electos/ballotmaker/ballots/contest_data.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass, field +from typing import List + + +@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 = "no_id_provided" + self.title = self._b_measure_con["title"] + self.district = self._b_measure_con["district"] + self.text = self._b_measure_con["text"] + self.choices = self._b_measure_con["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) + + def __post_init__(self): + self.id = self._can_con["id"] + self.title = self._can_con["title"] + self.votes_allowed = self._can_con["votes_allowed"] + self.district = self._can_con["district"] + _candidates = self._can_con["candidates"] + for candidate_data in _candidates: + self.candidates.append(CandidateData(candidate_data)) + + +@dataclass +class CandidateData: + _can_data: dict = field(repr=False) + id: str = "no_id_provided" + name: str = field(init=False) + party: str = field(init=False) + party_abbr: str = field(init=False) + + def __post_init__(self): + self.name = self._can_data["name"] + party_dict = self._can_data["party"] + self.party = party_dict["name"] + self.party_abbr = party_dict["abbreviation"] + + +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 new file mode 100644 index 0000000..ebe3d3e --- /dev/null +++ b/src/electos/ballotmaker/ballots/contest_layout.py @@ -0,0 +1,230 @@ +# format a ballot contest. + +from electos.ballotmaker.ballots.contest_data import ( + BallotMeasureData, + CandidateContestData, +) +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.platypus import Paragraph, Table + +oval_width = 10 +oval_height = 4 + +# define styles +# fill colors +light = PageLayout.light +grey = PageLayout.grey + +# font family info +font_normal = PageLayout.font_normal +font_bold = PageLayout.font_bold +font_size = PageLayout.font_size +normal_lead = PageLayout.normal_lead +border_pad = PageLayout.border_pad / 2 + +# start with the sample styles +styles = getSampleStyleSheet() +normal = styles["Normal"] +h1 = styles["Heading1"] +h2 = styles["Heading2"] + +# define custom styles for contest tables +PageLayout.define_custom_style( + h1, + grey, + border_pad, + font_size + 2, + black, + font_bold, + normal_lead, + sp_before=12, + sp_after=48, + keep_w_next=1, +) +PageLayout.define_custom_style( + h2, + light, + border_pad, + font_size, + black, + font_bold, + normal_lead, + sp_before=12, + sp_after=48, + keep_w_next=1, +) +PageLayout.define_custom_style( + normal, + white, + border_pad, + font_size, + black, + font_normal, + normal_lead, +) + + +def build_contest_list( + title: str, instruction: str, selections: list, text: str = "" +) -> list: + """ + Builds a table with contest header, instructions + and choices + """ + row_1 = [Paragraph(title, h1), ""] + row_2 = [Paragraph(instruction, h2), ""] + contest_list = [row_1, row_2] + if text != "": + contest_list.append([Paragraph(text, normal), ""]) + contest_list.extend(iter(selections)) + return contest_list + + +def build_candidate_table(contest_list): + return Table( + data=contest_list, + colWidths=(oval_width * 3, None), + style=[ + # draw lines below each contestant + ("LINEBELOW", (1, 2), (1, -1), 1, grey), + # format the header + ("BACKGROUND", (0, 0), (1, 0), grey), + ("BACKGROUND", (0, 1), (1, 1), light), + # draw the outer border on top + ("LINEABOVE", (0, 0), (1, 0), 3, black), + ("LINEBEFORE", (0, 0), (0, -1), 1, black), + ("LINEBELOW", (0, -1), (-1, -1), 1, black), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("SPAN", (0, 0), (1, 0)), + ("SPAN", (0, 1), (1, 1)), + # ("FONTSIZE", (1, 2), (-1, -1), 48), + ("TOPPADDING", (0, 2), (-1, -1), 4), + # pad the first cell + ("BOTTOMPADDING", (0, 0), (0, 1), 8), + # pad below each contestant + ("BOTTOMPADDING", (0, 2), (-1, -1), 16), + ], + ) + + +def build_ballot_measure_table(contest_list): + return Table( + data=contest_list, + colWidths=(oval_width * 3, None), + style=[ + # draw lines below each selection + ("LINEBELOW", (1, 2), (1, -1), 1, grey), + # format the header + ("BACKGROUND", (0, 0), (1, 0), grey), + ("BACKGROUND", (0, 1), (1, 1), light), + # draw the outer border on top + ("LINEABOVE", (0, 0), (1, 0), 3, black), + ("LINEBEFORE", (0, 0), (0, -1), 1, black), + ("LINEBELOW", (0, -1), (-1, -1), 1, black), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("SPAN", (0, 0), (-1, 0)), + ("SPAN", (0, 1), (-1, 1)), + ("SPAN", (0, 2), (-1, 2)), + # ("SPAN", (0, 3), (-1, 3)), + # ("SPAN", (0, 4), (1, 1)), + # ("FONTSIZE", (1, 2), (-1, -1), 48), + ("TOPPADDING", (0, 2), (-1, -1), 4), + # pad the first cell + ("BOTTOMPADDING", (0, 0), (0, 1), 8), + # pad below each contestant + ("BOTTOMPADDING", (0, 2), (-1, -1), 16), + ], + ) + + +class SelectionOval(_DrawingEditorMixin, Drawing): + def __init__(self, width=400, height=200, *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 + oval_cy = self.height / 2 + self._add( + self, + Ellipse(oval_cx, oval_cy, oval_width, oval_height), + name="oval", + validate=None, + desc=None, + ) + # self.oval.fillColor = PageLayout.white + self.oval.fillColor = white + # self.oval.strokeColor = PageLayout.black + self.oval.strokeColor = black + self.oval.strokeWidth = 0.5 + + +class CandidateContestLayout: + """ + Generate a candidate contest table flowable + """ + + def __init__(self, contest_data: CandidateContestData): + self.id = contest_data.id + self.title = contest_data.title + self.votes_allowed = contest_data.votes_allowed + if self.votes_allowed > 1: + self.instruct = f"Vote for up to {self.votes_allowed}" + else: + self.instruct = f"Vote for {self.votes_allowed}" + self.candidates = contest_data.candidates + _selections = [] + + oval = SelectionOval() + for candidate in self.candidates: + # add newlines around " and " + # if candidate.find(" and "): + # candidate = candidate.replace(" and ", "
and
") + contest_line = f"{candidate.name}" + if candidate.party_abbr != "": + contest_line += f"
{candidate.party_abbr}" + contest_row = [oval, Paragraph(contest_line, normal)] + _selections.append(contest_row) + # build the contest table, an attribute of the Contest object + + self.contest_list = build_contest_list( + self.title, self.instruct, _selections + ) + self.contest_table = build_candidate_table(self.contest_list) + + +class BallotMeasureLayout: + """ + Generate a candidate contest table flowable + """ + + def __init__(self, contest_data: BallotMeasureData): + self.id = contest_data.id + self.title = contest_data.title + self.instruct = "Vote yes or no" + self.text = contest_data.text + self.choices = contest_data.choices + + oval = SelectionOval() + _selections = [] + for choice in self.choices: + contest_line = f"{choice}" + contest_row = [oval, Paragraph(contest_line, 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) diff --git a/src/electos/ballotmaker/ballots/demo_ballot.py b/src/electos/ballotmaker/ballots/demo_ballot.py new file mode 100644 index 0000000..ce0fed3 --- /dev/null +++ b/src/electos/ballotmaker/ballots/demo_ballot.py @@ -0,0 +1,182 @@ +""" +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.header import header +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, Orbit City", + } + + +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 + 2, f"Sample Ballot for {elect_dict['Name']}", new_line=True + ) + formatted_header += "
" + 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() + width, height = content.wrap(doc.width, doc.topMargin) + content.drawOn(canvas, 0.5 * inch, 10.6 * 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_3.contest_table) + elements.append(layout_4.contest_table) + elements.append(NextPageTemplate("1col")) + elements.append(PageBreak()) + elements.append(layout_5.contest_table) + elements.append(CondPageBreak(c_height * inch)) + 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/files.py b/src/electos/ballotmaker/ballots/files.py new file mode 100644 index 0000000..27e216c --- /dev/null +++ b/src/electos/ballotmaker/ballots/files.py @@ -0,0 +1,63 @@ +# Finds the source directory for the specified package, +# builds paths and confirms the existence of files + +from pathlib import Path + + +class FileTools: + def __init__( + self, file_name="", rel_path="assets/img", package_name="ballotmaker" + ): + self.file_name: str = file_name + self.rel_path: Path = Path(rel_path) + self.ext: str = "" + self.file_found: bool = False + self.package_name: str = package_name + self.package_path: Path = "" + + # get the absolute path to this module + self.code_dir = Path(__file__).parent.resolve() + # TODO: restore test! + # does the package name appear in the code path? + # if self.code_dir.match(self.package_name): + # walk the path upward until the source dir + path_finder = self.code_dir + while path_finder.name != self.package_name: + path_finder = path_finder.parent + self.package_path = path_finder + + # add a relative path to the source directory, if provided + self.full_path = Path(self.package_path, self.rel_path) + + if not self.full_path.is_dir(): + raise FileExistsError(f"Directory doesn't exist: {self.full_path}") + # look for the file, if provided + if self.file_name: + self.abs_path_to_file = Path(self.full_path, self.file_name) + # if the file exists, get the extension and set the file found flag to True + if self.abs_path_to_file.is_file(): + self.ext = self.abs_path_to_file.suffix + self.file_found = True + + +if __name__ == "__main__": # pragma: no cover + print("Default settings:") + file_defaults = FileTools() + print(f"Default code dir = {file_defaults.code_dir}") + print(f"Default package dir = {file_defaults.package_path}") + print(f"Default relative path = {file_defaults.rel_path}") + print(f"Default full path = {file_defaults.full_path}") + print(f"Default file found = {file_defaults.file_found}") + + target_file = "writein.png" + target_dir = "assets/img" + print(f"Check for file {target_file} in directory {target_dir}") + file_check = FileTools(target_file, target_dir) + print(f"File check code dir = {file_check.code_dir}") + print(f"File check full source path = {file_check.package_path}") + print(f"File check file name = {file_check.file_name}") + print(f"File check relative path = {file_check.rel_path}") + print(f"File check full path = {file_check.full_path}") + print(f"File check absolute path to file = {file_check.abs_path_to_file}") + print(f"File check file ext = {file_check.ext}") + print(f"File check file found = {file_check.file_found}") diff --git a/src/electos/ballotmaker/ballots/header.py b/src/electos/ballotmaker/ballots/header.py new file mode 100644 index 0000000..1a54e1a --- /dev/null +++ b/src/electos/ballotmaker/ballots/header.py @@ -0,0 +1,32 @@ +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.platypus import Paragraph + + +def get_election_header() -> dict: + return { + "Name": "General Election", + "StartDate": "2024-11-05", + "EndDate": "2024-11-05", + "ElectionScope": "Port Precinct, The State of Farallon", + } + + +def build_header_text(): + elect_dict = get_election_header() + font_size = 14 + + return f"Sample Ballot for {elect_dict['Name']}" + + +def header(canvas, doc): + width, height = doc.pagesize + styles = getSampleStyleSheet + ptext = build_header_text() + p = Paragraph(ptext, styles["Normal"]) + p.wrapOn(canvas, width, height) + p.drawOn(canvas, 400, 730) + + +if __name__ == "__main__": + header_text = build_header_text() + print(header_text) diff --git a/src/electos/versadm/ballots/images.py b/src/electos/ballotmaker/ballots/images.py similarity index 64% rename from src/electos/versadm/ballots/images.py rename to src/electos/ballotmaker/ballots/images.py index 24a381e..c93aaa0 100644 --- a/src/electos/versadm/ballots/images.py +++ b/src/electos/ballotmaker/ballots/images.py @@ -1,7 +1,7 @@ # images.py # work with images, including embedding images into # Paragraph flowables -from versadm.utils.project_files import ProjectFiles +from electos.ballotmaker.ballots.files import FileTools # from reportlab.platypus import Image from reportlab.lib import utils @@ -20,8 +20,8 @@ def __init__(self, image_name, new_width=240) -> None: self.new_width = new_width self.rel_img_path = "assets/img" self.embed_text = "" - # find the image - image_file = ProjectFiles(self.image_name, self.rel_img_path, PROJECT_NAME) + + image_file = FileTools(self.image_name, self.rel_img_path) self.file_check(image_file) # retrieve the image and measure it self.image_full_path = image_file.abs_path_to_file @@ -30,22 +30,12 @@ def __init__(self, image_name, new_width=240) -> None: aspect = img_height / float(img_width) # resize based on the new width self.new_height = round(new_width * aspect) - self.embed_text = ( - '
' - '
'.format( - round(self.new_height / 2), - self.image_full_path, - new_width, - self.new_height, - ) - ) + self.embed_text = f'
' def file_check(self, image_file): if image_file.file_found is False: - file_error = "File {} not found in {}".format( - self.image_name, self.rel_img_path + file_error = ( + f"File {self.image_name} not found in {self.rel_img_path}" ) raise FileNotFoundError(file_error) diff --git a/src/electos/versadm/ballots/instructions.py b/src/electos/ballotmaker/ballots/instructions.py similarity index 86% rename from src/electos/versadm/ballots/instructions.py rename to src/electos/ballotmaker/ballots/instructions.py index 3a2dcf0..47cb79f 100644 --- a/src/electos/versadm/ballots/instructions.py +++ b/src/electos/ballotmaker/ballots/instructions.py @@ -1,13 +1,12 @@ # instructions.py # Build the ballot instructions - -from page_layout import PageLayout -from images import EmbeddedImage -from reportlab.platypus.flowables import CondPageBreak, PageBreak, Spacer -from reportlab.platypus import Paragraph +from electos.ballotmaker.ballots.images import EmbeddedImage +from electos.ballotmaker.ballots.page_layout import PageLayout from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.units import inch +from reportlab.platypus import Paragraph +from reportlab.platypus.flowables import CondPageBreak, PageBreak, Spacer class Instructions: @@ -71,7 +70,12 @@ def build_instruction_list(): spacing = border_pad / 3 self.instruction_list = [ - (Paragraph(instruct_head, h1)), + ( + Paragraph( + instruct_head, + h1, + ) + ), (Spacer(0, spacing)), (Paragraph(fill_head, h2)), (Paragraph(image1_graf, img_graf)), @@ -127,7 +131,14 @@ def build_instruction_list(): # define our custom styles PageLayout.define_custom_style( - h1, dark, border_pad, font_size + 2, white, font_bold, head_lead + h1, + dark, + border_pad, + font_size + 2, + white, + font_bold, + head_lead, + keep_w_next=True, ) PageLayout.define_custom_style( h2, @@ -137,12 +148,25 @@ def build_instruction_list(): black, font_bold, head_lead, + keep_w_next=True, ) PageLayout.define_custom_style( - normal, light, border_pad, font_size, black, font_normal, normal_lead + normal, + light, + border_pad, + font_size, + black, + font_normal, + normal_lead, ) PageLayout.define_custom_style( - warn_text, light, border_pad, font_size, dark, font_bold, normal_lead + warn_text, + light, + border_pad, + font_size, + dark, + font_bold, + normal_lead, ) PageLayout.define_custom_style( img_graf, diff --git a/src/electos/ballotmaker/ballots/page_layout.py b/src/electos/ballotmaker/ballots/page_layout.py new file mode 100644 index 0000000..e23624c --- /dev/null +++ b/src/electos/ballotmaker/ballots/page_layout.py @@ -0,0 +1,68 @@ +# 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 + +from dataclasses import dataclass + + +@dataclass +class PageLayout: + # use floats for these values + font_family: str = "Helvetica" + margin: float = 0.6 + col_width: float = 2.25 + col_height: float = 9.5 + col_space: float = 0.15 + + # font family info + font_normal: str = "Helvetica" + font_bold: str = "Helvetica-Bold" + font_size: int = 12 + normal_lead: int = 15 + head_lead: int = 20 + border_pad: int = 8 + space_before: int = 12 + space_after: int = 6 + + # define CMYKColor values + # Use floats! (0 - 1) Didn't work with values 0 - 100 + # 100% cyan + dark: tuple = (1, 0, 0, 0) + # light cyan + light: tuple = (0.1, 0, 0, 0) + white: tuple = (0, 0, 0, 0) + black: tuple = (0, 0, 0, 1) + grey: tuple = (0, 0, 0, 0.15) + + bg_color: tuple = white + border_color: tuple = black + keep_w_next = False + + # TODO: Rewrite with *args, **kwargs? + def define_custom_style( + style, + bg_color=bg_color, + border_pad=border_pad, + font_sz=font_size, + txt_color=black, + font_n=font_normal, + line_space=font_size + 1, + sp_before=space_before, + sp_after=space_after, + keep_w_next=keep_w_next, + ): + style.backColor = bg_color + style.borderPadding = border_pad + style.fontSize = font_sz + style.textColor = txt_color + style.fontName = font_n + style.leading = line_space + style.spaceBefore = sp_before + style.spaceAfter = sp_after + style.keepWithNext = keep_w_next + + +if __name__ == "__main__": + print(dir(PageLayout)) diff --git a/src/electos/ballotmaker/cli.py b/src/electos/ballotmaker/cli.py index ec9a832..a1973e6 100644 --- a/src/electos/ballotmaker/cli.py +++ b/src/electos/ballotmaker/cli.py @@ -7,6 +7,7 @@ import typer from electos.ballotmaker import make_ballots, validate_edf +from electos.ballotmaker.ballots.demo_ballot import build_ballot from electos.ballotmaker.constants import NO_ERRORS, PROGRAM_NAME, VERSION EDF_HELP = "EDF file with ballot data (JSON format)" @@ -15,7 +16,7 @@ VERSION_HELP = "Print the version number." # configure logging -logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) +logging.basicConfig(stream=sys.stdout, level=logging.INFO) log = logging.getLogger(__name__) @@ -39,6 +40,14 @@ def main( return NO_ERRORS +@app.command() +def demo(): + """Make ballots from previously extracted EDF data""" + new_ballot_name = build_ballot() + typer.echo(f"Ballot created: {new_ballot_name}") + return NO_ERRORS + + @app.command() def make( edf: Path = typer.Option( diff --git a/src/electos/ballotmaker/demo_data/ballot_lab_data.py b/src/electos/ballotmaker/demo_data/ballot_lab_data.py new file mode 100644 index 0000000..e86dc1c --- /dev/null +++ b/src/electos/ballotmaker/demo_data/ballot_lab_data.py @@ -0,0 +1,268 @@ +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.""" + 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 + + +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 = [] + 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, + "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)} + for _ in candidates + ], + # Leave out offices and parties for now + # "offices": offices, + # "parties": parties, + "write_ins": write_ins, + } + 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) + full_text = text_content(contest.full_text) + result = { + "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/dict_maker.py b/src/electos/ballotmaker/demo_data/dict_maker.py new file mode 100644 index 0000000..0c95e78 --- /dev/null +++ b/src/electos/ballotmaker/demo_data/dict_maker.py @@ -0,0 +1,124 @@ +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/gp_units.py b/src/electos/ballotmaker/demo_data/gp_units.py new file mode 100644 index 0000000..e52b019 --- /dev/null +++ b/src/electos/ballotmaker/demo_data/gp_units.py @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..d729268 --- /dev/null +++ b/src/electos/ballotmaker/demo_data/offices.py @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..d3c2d79 --- /dev/null +++ b/src/electos/ballotmaker/demo_data/party.py @@ -0,0 +1,4 @@ +parties = { + "reckpEKRtLuDdt03n": ("The Hadron Party of Farallon", "HAD"), + "recBiK9LZXeZmmFEg": ("The Lepton Party", "LEP"), +} diff --git a/src/electos/ballotmaker/demo_data/spacetown_data.json b/src/electos/ballotmaker/demo_data/spacetown_data.json new file mode 100644 index 0000000..5d3f652 --- /dev/null +++ b/src/electos/ballotmaker/demo_data/spacetown_data.json @@ -0,0 +1,182 @@ +{ + "ballot_style": "precinct_2_spacetown", + "contests": { + "candidate": [ + { + "id": "recIj8OmzqzzvnDbM", + "title": "Contest for Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "name": "Cosmo Spacely", + "party": { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + }, + { + "name": "Spencer Cogswell", + "party": { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + } + ], + "write_ins": [ + "recqq21kO6HWgpJZV" + ] + }, + { + "id": "recXNb4zPrvC1m6Fr", + "title": "Spaceport Control Board", + "type": "candidate", + "vote_type": "n-of-m", + "votes_allowed": 2, + "district": "Aldrin Space Transport District", + "candidates": [ + { + "name": "Jane Jetson", + "party": { + "name": "", + "abbreviation": "" + } + }, + { + "name": "Harlan Ellis", + "party": { + "name": "", + "abbreviation": "" + } + }, + { + "name": "Rudy Indexer", + "party": { + "name": "", + "abbreviation": "" + } + } + ], + "write_ins": [ + "rec9Eev970VhohqKi", + "recFiGYjGCIyk5LBe" + ] + }, + { + "id": "recthF6jdx5ybBNkC", + "title": "Gadget County School Board", + "type": "candidate", + "vote_type": "n-of-m", + "votes_allowed": 4, + "district": "Gadget County", + "candidates": [ + { + "name": "Sally Smith", + "party": { + "name": "", + "abbreviation": "" + } + }, + { + "name": "Hector Gomez", + "party": { + "name": "", + "abbreviation": "" + } + }, + { + "name": "Rosashawn Davis", + "party": { + "name": "", + "abbreviation": "" + } + }, + { + "name": "Oliver Tsi", + "party": { + "name": "", + "abbreviation": "" + } + }, + { + "name": "Glavin Orotund", + "party": { + "name": "", + "abbreviation": "" + } + } + ], + "write_ins": [ + "recYurH2CLY3SlYS8", + "recI5jfcXIsbAKytC", + "recn9m0o1em7gLahj" + ] + }, + { + "id": "recsoZy7vYhS3lbcK", + "title": "President of the United States", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "United States of America", + "candidates": [ + { + "name": "Anthony Alpha", + "party": { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + }, + { + "name": "Betty Beta", + "party": { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + }, + { + "name": "Gloria Gamma", + "party": { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + }, + { + "name": "David Delta", + "party": { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + } + ], + "write_ins": [ + "recPod2L8VhwagiDl" + ] + } + ], + "ballot_measure": [ + { + "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?" + }, + { + "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." + } + ] + } +} diff --git a/src/electos/ballotmaker/demo_data/spacetown_data.py b/src/electos/ballotmaker/demo_data/spacetown_data.py new file mode 100644 index 0000000..75b2dd4 --- /dev/null +++ b/src/electos/ballotmaker/demo_data/spacetown_data.py @@ -0,0 +1,108 @@ +can_con_4 = { + "id": "recIj8OmzqzzvnDbM", + "title": "Contest for Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "name": "Cosmo Spacely", + "party": {"name": "The Lepton Party", "abbreviation": "LEP"}, + }, + { + "name": "Spencer Cogswell", + "party": { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + }, + ], + "write_ins": ["recqq21kO6HWgpJZV"], +} + +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": [ + {"name": "Jane Jetson", "party": {"name": "", "abbreviation": ""}}, + {"name": "Harlan Ellis", "party": {"name": "", "abbreviation": ""}}, + {"name": "Rudy Indexer", "party": {"name": "", "abbreviation": ""}}, + ], + "write_ins": ["rec9Eev970VhohqKi", "recFiGYjGCIyk5LBe"], +} + +can_con_2 = { + "id": "recthF6jdx5ybBNkC", + "title": "Gadget County School Board", + "type": "candidate", + "vote_type": "n-of-m", + "votes_allowed": 4, + "district": "Gadget County", + "candidates": [ + {"name": "Sally Smith", "party": {"name": "", "abbreviation": ""}}, + {"name": "Hector Gomez", "party": {"name": "", "abbreviation": ""}}, + {"name": "Rosashawn Davis", "party": {"name": "", "abbreviation": ""}}, + {"name": "Oliver Tsi", "party": {"name": "", "abbreviation": ""}}, + {"name": "Glavin Orotund", "party": {"name": "", "abbreviation": ""}}, + ], + "write_ins": [ + "recYurH2CLY3SlYS8", + "recI5jfcXIsbAKytC", + "recn9m0o1em7gLahj", + ], +} + +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": [ + { + "name": "Anthony Alpha", + "party": {"name": "The Lepton Party", "abbreviation": "LEP"}, + }, + { + "name": "Betty Beta", + "party": {"name": "The Lepton Party", "abbreviation": "LEP"}, + }, + { + "name": "Gloria Gamma", + "party": { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + }, + { + "name": "David Delta", + "party": { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + }, + ], + "write_ins": ["recPod2L8VhwagiDl"], +} + +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 new file mode 100644 index 0000000..de3dc23 --- /dev/null +++ b/src/electos/ballotmaker/demo_election_data.py @@ -0,0 +1,23 @@ +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']}") diff --git a/src/electos/ballotmaker/election_data.py b/src/electos/ballotmaker/election_data.py index 0a1b740..5d090fa 100644 --- a/src/electos/ballotmaker/election_data.py +++ b/src/electos/ballotmaker/election_data.py @@ -16,6 +16,7 @@ class ElectionData: # properties retrieved from the EDF edf_error: int = field(init=False) election_report: ElectionReport = field(init=False) + index: ElementIndex = field(init=False) ballot_styles: ElementIndex = field(init=False) ballot_count: int = field(init=False) election_name: str = field(init=False) @@ -23,6 +24,15 @@ class ElectionData: end_date: str = field(init=False) election_type: str = field(init=False) + def get_gp_unit_list(self, gp_unit_ids: list) -> list: + gp_unit_list = [] + for gp_unit_id in gp_unit_ids: + gp_unit = self.index.by_id(gp_unit_id) + gp_unit_name = gp_unit.name.text[0].content + gp_unit_list.append(gp_unit_name) + log.debug(f"GP Unit IDs: {gp_unit_ids}; GP Units: {gp_unit_list}") + return gp_unit_list + def __post_init__(self): # let's assume there are no errors self.edf_error = NO_ERRORS @@ -56,13 +66,15 @@ def __post_init__(self): log.info(f"{self.election_type}") # index the election report to retrieve lists - index = ElementIndex(self.election_report, "ElectionResults") + self.index = ElementIndex(self.election_report, "ElectionResults") # how many ballots? - self.ballot_styles = index.by_type("ElectionResults.BallotStyle") + self.ballot_styles = self.index.by_type("ElectionResults.BallotStyle") # list and count ballots for count, ballot in enumerate(self.ballot_styles, start=1): ballot_value = ballot.external_identifier[0].value - log.info(f"Ballot: {ballot_value}") + ballot_gp_unit_ids = ballot.gp_unit_ids + ballot_gp_units = self.get_gp_unit_list(ballot_gp_unit_ids) + log.info(f"Ballot: {ballot_value}; GP Units: {ballot_gp_units}") self.ballot_count = count log.info(f"Found {self.ballot_count} ballot styles in {self.edf}") diff --git a/src/electos/versadm/ballots/ballot.py b/src/electos/versadm/ballots/ballot.py deleted file mode 100644 index 6773e35..0000000 --- a/src/electos/versadm/ballots/ballot.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -ballot.py -The Ballot Class contains the document specifications, -page templates, and specific pages -""" -from datetime import datetime -from reportlab.platypus import ( - BaseDocTemplate, - Frame, - # Paragraph, - # NextPageTemplate, - # PageBreak, - PageTemplate, -) -from reportlab.lib.units import inch - -# from reportlab.lib.styles import getSampleStyleSheet -from versadm.ballots.contest import Contest -from versadm.ballots.instructions import Instructions -from versadm.ballots.page_layout import PageLayout - - -def build_ballot(): - - # 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 - 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, - ) - - # styles = getSampleStyleSheet() - - # add voting instructions - inst = Instructions() - elements = inst.instruction_list - # add a ballot contest to the second frame (colomn) - contest_1 = Contest() - elements.append(contest_1.contest_table) - - # create PDF filename - # create datestamp string for PDF - now = datetime.now() - date_time = now.strftime("%Y_%m_%dT%H%M%S") - # TODO: Fix path with project_files.py - ballot_name = "pdfs/ballot_{0}.pdf".format(date_time) - - ballot_doc = BaseDocTemplate(ballot_name) - ballot_doc.addPageTemplates( - PageTemplate(id="3col", frames=[left_frame, mid_frame, right_frame]) - ) - ballot_doc.build(elements) - - -if __name__ == "__main__": - build_ballot() diff --git a/src/electos/versadm/ballots/ballot_demo.py b/src/electos/versadm/ballots/ballot_demo.py deleted file mode 100644 index 1811d7d..0000000 --- a/src/electos/versadm/ballots/ballot_demo.py +++ /dev/null @@ -1,79 +0,0 @@ -# ballot_demo.py -# create a demo object with BallotLab classes - -from versadm.ballots.contest import Contest -from versadm.ballots.instructions import Instructions -from versadm.ballots.page_layout import PageLayout -from reportlab.lib.pagesizes import letter -from reportlab.lib.styles import getSampleStyleSheet -from reportlab.lib.units import inch -from reportlab.pdfgen.canvas import Canvas -from reportlab.platypus import Paragraph, Frame -from datetime import datetime - - -def ballot_demo(): - # 1 = True, 0 = FALSE - SHOW_BOUNDARY = 0 - margin = PageLayout.margin - c_width = PageLayout.col_width - c_height = PageLayout.col_height - c_space = PageLayout.col_space - styles = getSampleStyleSheet() - normal = styles["Normal"] - h1 = styles["Heading1"] - # create datestamp string for PDF - now = datetime.now() - date_time = now.strftime("%Y_%m_%dT%H%M%S") - # create the PDF document canvas - ballot_canvas = Canvas( - "pdfs/ballot_demo_{0}.pdf".format(date_time), - pagesize=letter, - enforceColorSpace="CMYK", - ) - # ballot_canvas.setLineCap(0) - # add voting instructions to the first frame (column) - inst = Instructions() - left_column = inst.instruction_list - - # add a ballot contest to the second frame (colomn) - contest_1 = Contest() - contest_table = contest_1.contest_table - mid_column = [contest_table] - # mid_column.append(Paragraph(contest_instruct, normal)) - - right_column = [Paragraph("Contest #2", h1)] - right_column.append(Paragraph("ipsum lorem", normal)) - - left_frame = Frame( - margin * inch, - margin * inch, - width=c_width * inch, - height=c_height * inch, - 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, - showBoundary=SHOW_BOUNDARY, - ) - - left_frame.addFromList(left_column, ballot_canvas) - mid_frame.addFromList(mid_column, ballot_canvas) - right_frame.addFromList(right_column, ballot_canvas) - - ballot_canvas.save() - - -if __name__ == "__main__": - ballot_demo() diff --git a/src/electos/versadm/ballots/contest.py b/src/electos/versadm/ballots/contest.py deleted file mode 100644 index 15d5a67..0000000 --- a/src/electos/versadm/ballots/contest.py +++ /dev/null @@ -1,177 +0,0 @@ -# contest.py -# format a ballot contest. - -from page_layout import PageLayout -from images import EmbeddedImage -from reportlab.platypus import Table -from reportlab.platypus import Paragraph -from reportlab.lib.styles import LineStyle, getSampleStyleSheet -from reportlab.graphics.shapes import Drawing, _DrawingEditorMixin -from reportlab.graphics.shapes import Ellipse -from reportlab.lib.colors import white, black - -oval_width = 10 -oval_height = 4 - - -class SelectionOval(_DrawingEditorMixin, Drawing): - def __init__(self, width=400, height=200, *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 - oval_cy = self.height / 2 - self._add( - self, - Ellipse(oval_cx, oval_cy, oval_width, oval_height), - name="oval", - validate=None, - desc=None, - ) - self.oval.fillColor = white - self.oval.strokeColor = black - self.oval.strokeWidth = 0.5 - - -class Contest: - """ - Ballot Contest class encapsulates - the generation of a ballot contest - table - """ - - def __init__(self): - # set up the page layout settings - self.contest_list = [] - self.contestants = [] - self.contest_title = "" - self.contest_instruct = "" - - def get_contest_data(): - self.contest_title = "President and Vice-President of the United States" - self.contest_instruct = "Vote for 1 pair" - self.contestants = [ - ("Joseph Barchi and Joseph Hallaren", "Blue"), - ("Adam Cramer and Greg Vuocolo", "Yellow"), - ("Daniel Court and Amy Blumhard", "Purple"), - ("Alvin Boone and James Lian", "Orange"), - ("Austin Hildebrand and James Garritty", "Pink"), - ("Martin Patterson and Clay Lariviere", "Gold"), - ("Elizabeth Harp and Antoine Jefferson", "Gray"), - ("Marzena Pazgier and Welton Phelps", "Brown"), - ] - - def build_contest_list(contestants, contestant_party_list): - oval = SelectionOval() - for contestant, party in contestants: - # add newlines around " and " - if contestant.find(" and "): - contestant = contestant.replace(" and ", "
and
") - contest_line = "{}
{}".format(contestant, party) - contest_row = [oval, Paragraph(contest_line, normal)] - contestant_party_list.append(contest_row) - - def build_contest_table(): - """ - Builds a table with contest header, instructions - and choices - """ - # get the contest data from the data source - get_contest_data() - # build the contest header - row_1 = [Paragraph(self.contest_title, h1), ""] - row_2 = [Paragraph(self.contest_instruct, h2), ""] - self.contest_list = [row_1] - self.contest_list.append(row_2) - build_contest_list(self.contestants, self.contest_list) - - # construct and format the contest table - self.contest_table = Table( - data=self.contest_list, - colWidths=(oval_width * 3, None), - style=[ - # draw lines below each contestant - ("LINEBELOW", (1, 2), (1, -1), 1, grey), - # format the header - ("BACKGROUND", (0, 0), (1, 0), grey), - ("BACKGROUND", (0, 1), (1, 1), light), - # draw the outer border on top - ("LINEABOVE", (0, 0), (1, 0), 3, black), - ("LINEBEFORE", (0, 0), (0, -1), 1, black), - ("LINEBELOW", (0, -1), (-1, -1), 1, black), - ("VALIGN", (0, 0), (-1, -1), "TOP"), - ("SPAN", (0, 0), (1, 0)), - ("SPAN", (0, 1), (1, 1)), - # ("FONTSIZE", (1, 2), (-1, -1), 48), - ("TOPPADDING", (0, 2), (-1, -1), 4), - # pad the first cell - ("BOTTOMPADDING", (0, 0), (0, 1), 8), - # pad below each contestant - ("BOTTOMPADDING", (0, 2), (-1, -1), 16), - ], - ) - # self.contest_table._linecmds(["ROUNDEDCORNERS", 1]) - - # define styles - # fill colors - light = PageLayout.light - white = PageLayout.white - black = PageLayout.black - grey = PageLayout.grey - - # font family info - font_normal = PageLayout.font_normal - font_bold = PageLayout.font_bold - font_size = PageLayout.font_size - normal_lead = PageLayout.normal_lead - border_pad = PageLayout.border_pad / 2 - - # image dimensions - col_width = PageLayout.col_width - - # start with the sample styles - styles = getSampleStyleSheet() - normal = styles["Normal"] - h1 = styles["Heading1"] - h2 = styles["Heading2"] - - # define custom styles for contest tables - PageLayout.define_custom_style( - h1, - grey, - border_pad, - font_size + 2, - black, - font_bold, - normal_lead, - sp_before=12, - sp_after=48, - ) - PageLayout.define_custom_style( - h2, - light, - border_pad, - font_size, - black, - font_bold, - normal_lead, - sp_before=12, - sp_after=48, - ) - PageLayout.define_custom_style( - normal, - white, - border_pad, - font_size, - black, - font_normal, - normal_lead, - ) - # build the contest table, an attribute of the Contest object - build_contest_table() - - -if __name__ == "__main__": - contest_1 = Contest() - print(contest_1.contest_list) diff --git a/src/electos/versadm/ballots/data.py b/src/electos/versadm/ballots/data.py deleted file mode 100644 index be3464e..0000000 --- a/src/electos/versadm/ballots/data.py +++ /dev/null @@ -1,137 +0,0 @@ -# data.py -# read and write structured data - -# from posixpath import relpath -from versadm.utils.project_files import ProjectFiles - -# import xmltodict -import json -import jsonschema - -# import pprint -PROJECT_NAME = "ballotlab" - -# error codes -## JSON errors -### JSON file isn't formatted correctly (can't be parsed) -ERR_JSON_FORMAT = 200 -### JSON data didn't validate against the schema -ERR_JSON_SCHEMA = 201 - -# supported_ext_types = [".xml", ".XML"] -# supported_ext_types = [".json", ".JSON", ".xml", ".XML"] -supported_ext_types = [".json", ".JSON"] -# create a string of supported extensions from list -ext_types_str = " ".join(str(item) for item in supported_ext_types) - - -class ElectionData: - """ - Open the specified Election Data File (EDF) - Read data into Python objects. - Read well-formatted json and xml only - Raises RuntimeError for bad data - """ - - def __init__(self, data_file, data_dir, print_rpt=False): - - election_file = ProjectFiles(data_file, data_dir, PROJECT_NAME) - if not election_file.file_found: - msg = "Election data file {} not found in directory {}" - raise RuntimeError(msg.format(str(data_file, data_dir))) - - self.data_file = data_file - self.data_dir = data_dir - self.abs_path_to_data = election_file.abs_path_to_file - self.ext = election_file.ext - - if self.ext not in supported_ext_types: - msg = "Election data must be one of the following file types: " "{}. Got {}" - raise RuntimeError(msg.format(ext_types_str, self.ext)) - - # read data file into Python objects, based on type. - if self.ext in [".xml", ".XML"]: - self.election_rpt = self.parse_xml(self.abs_path_to_data) - elif self.ext in [".json", ".JSON"]: - # let's try to read the file - self.election_rpt = self.parse_json(self.abs_path_to_data) - if self.election_rpt != ERR_JSON_FORMAT: - self.elect_name = self.election_rpt["Election"][0]["Name"] - self.start_date = self.election_rpt["Election"][0]["StartDate"] - self.end_date = self.election_rpt["Election"][0]["EndDate"] - self.elect_type = self.election_rpt["Election"][0]["Type"] - self.gpunits = self.election_rpt["GpUnit"] - ## Disable schema validation code - # if self.validate_json(self.election_rpt): - # # Read Election data from JSON dict, which is - # self.elect_name = self.election_rpt["Election"][0]["Name"] - # self.start_date = self.election_rpt["Election"][0]["StartDate"] - # self.end_date = self.election_rpt["Election"][0]["EndDate"] - # self.elect_type = self.election_rpt["Election"][0]["Type"] - # else: - # print("JSON Schema Error!") - - if self.election_rpt != ERR_JSON_FORMAT: - rpt_title = "Election Report" - self.text_rpt = "{}\n".format(rpt_title) - self.text_rpt += ("=" * len(rpt_title)) + "\n" - - # EDF file info - self.text_rpt += "EDF name: {}\n".format(self.data_file) - self.text_rpt += "Location:\n {}\n".format(self.abs_path_to_data) - # Election contains BallotStyle, Candidate and Contest. - self.text_rpt += "Election name: {}\n".format(self.elect_name) - self.text_rpt += "Election type: {}\n".format(self.elect_type) - self.text_rpt += "Start date: {}\n".format(self.start_date) - self.text_rpt += "End date: {}\n".format(self.end_date) - self.text_rpt += "GPUnits: \n{}\n".format(self.gpunits) - else: - self.text_rpt = "Report can't be generated. Error code:" - - if print_rpt: - print(self.text_rpt) - # pprint.pprint(self.election_rpt) - - # def parse_xml(self, xml_file): - # """ - # parse xml file into JSON-style dict - # """ - # with open(xml_file) as xmlf: - # xml = xmlf.read() - # return xmltodict.parse(xml, dict_constructor=dict) - - def parse_json(self, json_file): - """ - parse json file into dictionary - """ - # read JSON file and perform basic JSON validation - try: - with open(json_file, "r") as jsf: - json_data = json.load(jsf) - return json_data - except json.decoder.JSONDecodeError: - print("JSON file is not well-formed: {}".format(json_file)) - return ERR_JSON_FORMAT - - def validate_json(self, json_data): - json_schema_file = ProjectFiles( - "NIST_V2_election_results_reporting.json", "assets/schema", PROJECT_NAME - ) - json_schema = self.parse_json(json_schema_file.abs_path_to_file) - jsonschema.validate(instance=json_data, schema=json_schema) - # try: - # jsonschema.validate(instance=json_data, schema=json_schema) - # except jsonschema.exceptions.ValidationError as err: - # return ERR_JSON_SCHEMA - # return True - - -if __name__ == "__main__": - # xml_election = ElectionData("nist_sample_election_report.xml", "assets/data") - # json_election = ElectionData("NIST_sample.json", "assets/data/") - json_election = ElectionData( - "BallotStudio_16_Edits.JSON", "assets/data/", print_rpt=True - ) - json_election = ElectionData( - "JESTONS_PAPARDEV_&_AUG_2021.json", "assets/data/", print_rpt=False - ) diff --git a/src/electos/versadm/ballots/page_layout.py b/src/electos/versadm/ballots/page_layout.py deleted file mode 100644 index 944d4d0..0000000 --- a/src/electos/versadm/ballots/page_layout.py +++ /dev/null @@ -1,59 +0,0 @@ -# page_layout.py -# Stores page layout settings in a dictionary - -# customize only what's different from the samples - - -class PageLayout: - # use floats for these values - font_family = "Helvetica" - margin = 0.5 - col_width = 2.25 - col_height = 9 - col_space = 0.25 - - # font family info - font_normal = "Helvetica" - font_bold = "Helvetica-Bold" - font_size = 12 - normal_lead = 15 - head_lead = 20 - border_pad = 8 - space_before = 12 - space_after = 6 - - # define CMYKColor values - # Use floats! (0 - 1) Didn't work with values 0 - 100 - # 100% cyan - dark = (1, 0, 0, 0) - # light cyan - light = (0.1, 0, 0, 0) - white = (0, 0, 0, 0) - black = (0, 0, 0, 1) - grey = (0, 0, 0, 0.15) - - # TODO: Rewrite with *args, **kwargs? - def define_custom_style( - style, - bg_color, - border_pd=border_pad, - font_sz=font_size, - txt_color=black, - font_n=font_normal, - line_space=font_size + 1, - sp_before=space_before, - sp_after=space_after, - ): - style.backColor = bg_color - style.borderColor = bg_color - style.borderPadding = border_pd - style.fontSize = font_sz - style.textColor = txt_color - style.fontName = font_n - style.leading = line_space - style.spaceBefore = sp_before - style.spaceAfter = sp_after - - -if __name__ == "__main__": - print(dir(PageLayout)) diff --git a/tests/test_cli.py b/tests/test_cli.py index df69232..4002197 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -58,3 +58,8 @@ def test_validate(): result = runner.invoke(cli.app, ["validate", "--edf"]) assert result.exit_code == NO_FILE assert "Error: Option" in result.stdout + + +def test_demo(): + result = runner.invoke(cli.app, ["demo"]) + assert result.exit_code == NO_ERRORS