Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extraction code validates, but tests fail #113

Merged
merged 13 commits into from
Sep 20, 2022
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
"""
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

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 electos.ballotmaker.data.models import BallotStyleData
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
Expand All @@ -27,6 +26,10 @@
)
from reportlab.platypus.flowables import CondPageBreak

logging.getLogger(__name__)

CANDIDATE = "candidate"
BALLOT_MEASURE = "ballot measure"
stratofax marked this conversation as resolved.
Show resolved Hide resolved
# set up frames
# 1 = True, 0 = FALSE
SHOW_BOUNDARY = 0
Expand Down Expand Up @@ -71,31 +74,20 @@
)


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):
def add_header_line(
font_size: int, line_text: str, new_line: bool = False
) -> str:
line_end = "<br />" if new_line else ""
return f"<font size={font_size}><b>{line_text}</b></font>{line_end}"


def build_header_text():
elect_dict = get_election_header()
def build_header_text(election_dict: dict, scope: str) -> str:
stratofax marked this conversation as resolved.
Show resolved Hide resolved
font_size = 12
formatted_header = add_header_line(
font_size, f"Sample Ballot for {elect_dict['Name']}", new_line=True
font_size, f"Sample Ballot for {election_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_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)

Expand All @@ -110,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)
stratofax marked this conversation as resolved.
Show resolved Hide resolved
header_content = Paragraph(head_text, normal)
three_column_template = PageTemplate(
id="3col",
Expand All @@ -139,42 +138,45 @@ 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)
)
stratofax marked this conversation as resolved.
Show resolved Hide resolved

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.")

stratofax marked this conversation as resolved.
Show resolved Hide resolved
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 = 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.")
stratofax marked this conversation as resolved.
Show resolved Hide resolved
elements.append(NextPageTemplate("1col"))
elements.append(PageBreak())
elements.append(layout_5.contest_table)
elements.append(layout_6.contest_table)
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.")
stratofax marked this conversation as resolved.
Show resolved Hide resolved
doc.build(elements)
return str(ballot_name)


if __name__ == "__main__": # pragma: no cover
build_ballot()
42 changes: 42 additions & 0 deletions src/electos/ballotmaker/ballots/build_ballots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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"""
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) -> Path:

# create the directories needed
now = datetime.now()
date_time = now.strftime("%Y_%m_%dT%H%M%S")
stratofax marked this conversation as resolved.
Show resolved Hide resolved
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.")
stratofax marked this conversation as resolved.
Show resolved Hide resolved
election_header = get_election_header(election)

for ballot_data in election.ballot_styles:
logging.info(f"Generating ballot for {ballot_data.id}")
new_ballot_name = build_ballot(
ballot_data, election_header, new_ballot_dir
)
return new_ballot_dir
92 changes: 57 additions & 35 deletions src/electos/ballotmaker/ballots/contest_layout.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -28,14 +31,17 @@
CHECKBOX_H = 8
CHECKBOX_X = 3
CHECKBOX_Y = -12
CHECKBOX_STATE = "Off" # "Yes" or "Off"

WRITE_IN_W = 100
WRITE_IN_H = 24

# 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
Expand Down Expand Up @@ -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 ", "<br />and<br />"
)
# make the candidate name bold
contest_text = f"<b>{candidate.name}</b>"
# add party abbreviation in plain text
if candidate.party_abbr != "":
contest_text += f"<br />{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 += ("<br />" * 2) + ("_" * 20)
if candidate_choice.is_write_in:
logging.info(
f"Found write-in candidate: {candidate_choice.id}"
)
contest_text = "<b>or write in:</b>"
# contest_text = ("<br />" * 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 += "<br />and<br />"
# make the candidate name bold
contest_text += f"<b>{name}</b>"
# add party abbreviation in plain text
party_count = len(candidate_choice.party)
if party_count == 1:
contest_text += (
f"<br />{candidate_choice.party[0].abbreviation}"
)
elif party_count > 1:
raise NotImplementedError(
"Multiple party tickets not supported (parties"
stratofax marked this conversation as resolved.
Show resolved Hide resolved
)

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]
Expand All @@ -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"<b>{choice}</b>"
contest_row = [oval, Paragraph(contest_text, normal)]
for choose in self.choices:
choice_text = f"<b>{choose.choice}</b>"
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)
Loading