Skip to content

Commit

Permalink
Exported PI invoices as PDFs
Browse files Browse the repository at this point in the history
The PI-specific dataframes will first be converted to HTML
tables using Jinja templates, and then converted to PDFs using
the Selenium Chrome driver.

A html template folder has been added, and the test cases
for the PI-specific invoice will now both check whether the
dataframe is formatted correctly and if the PDFs are
correctly generated.
  • Loading branch information
QuanMPhm committed Jan 2, 2025
1 parent e8ad7d8 commit 172d6da
Show file tree
Hide file tree
Showing 4 changed files with 315 additions and 55 deletions.
144 changes: 136 additions & 8 deletions process_report/invoices/pi_specific_invoice.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import os
from dataclasses import dataclass
import base64
import tempfile

import pandas
from jinja2 import Environment, FileSystemLoader
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.print_page_options import PrintOptions

import process_report.invoices.invoice as invoice
import process_report.util as util


TEMPLATE_DIR_PATH = "process_report/templates"


@dataclass
class PIInvoice(invoice.Invoice):
"""
Expand All @@ -15,6 +24,21 @@ class PIInvoice(invoice.Invoice):
- NewPICreditProcessor
"""

TOTAL_COLUMN_LIST = [
invoice.COST_FIELD,
invoice.CREDIT_FIELD,
invoice.BALANCE_FIELD,
]

DOLLAR_COLUMN_LIST = [
invoice.RATE_FIELD,
invoice.GROUP_BALANCE_FIELD,
invoice.COST_FIELD,
invoice.GROUP_BALANCE_USED_FIELD,
invoice.CREDIT_FIELD,
invoice.BALANCE_FIELD,
]

export_columns_list = [
invoice.INVOICE_DATE_FIELD,
invoice.PROJECT_FIELD,
Expand All @@ -37,30 +61,134 @@ class PIInvoice(invoice.Invoice):
invoice.BALANCE_FIELD,
]

@staticmethod
def _get_chrome_driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless")
return webdriver.Chrome(options=options)

def _prepare(self):
self.data = self.data[
self.data[invoice.IS_BILLABLE_FIELD] & ~self.data[invoice.MISSING_PI_FIELD]
]
self.pi_list = self.data[invoice.PI_FIELD].unique()

def _get_pi_dataframe(self, data, pi):
def add_dollar_sign(data):
if pandas.isna(data):
return data
else:
return "$" + str(data)

pi_projects = data[data[invoice.PI_FIELD] == pi].copy().reset_index(drop=True)

# Remove prepay group data if it's empty
if pandas.isna(pi_projects[invoice.GROUP_NAME_FIELD]).all():
pi_projects = pi_projects.drop(
[
invoice.GROUP_NAME_FIELD,
invoice.GROUP_INSTITUTION_FIELD,
invoice.GROUP_BALANCE_FIELD,
invoice.GROUP_BALANCE_USED_FIELD,
],
axis=1,
)

# Add a row containing sums for certain columns
column_sums = list()
sum_columns_list = list()
for column_name in self.TOTAL_COLUMN_LIST:
if column_name in pi_projects.columns:
column_sums.append(pi_projects[column_name].sum())
sum_columns_list.append(column_name)
pi_projects.loc[
len(pi_projects)
] = None # Adds a new row to end of dataframe initialized with None
pi_projects.loc[pi_projects.index[-1], invoice.INVOICE_DATE_FIELD] = "Total"
pi_projects.loc[pi_projects.index[-1], sum_columns_list] = column_sums

# Add dollar sign to certain columns
for column_name in self.DOLLAR_COLUMN_LIST:
if column_name in pi_projects.columns:
pi_projects[column_name] = pi_projects[column_name].apply(
add_dollar_sign
)

pi_projects.fillna("", inplace=True)

return pi_projects

def export(self):
def _export_pi_invoice(pi):
if pandas.isna(pi):
return
pi_projects = export_data[export_data[invoice.PI_FIELD] == pi]
pi_instituition = pi_projects[invoice.INSTITUTION_FIELD].iat[0]
pi_projects.to_csv(
f"{self.name}/{pi_instituition}_{pi} {self.invoice_month}.csv"
def _create_html_invoice():
environment = Environment(loader=FileSystemLoader(TEMPLATE_DIR_PATH))
template = environment.get_template("pi_invoice.html")
content = template.render(
data=pi_dataframe,
)

with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".html"
) as f:
f.write(content)
return f.name

def _create_pdf_invoice():
driver = self._get_chrome_driver()
driver.get("file://" + invoice_html_path)

"""
To ensure the HTML invoice table always fit the page when
printed to PDF, certain things are done below. Before my
explanation, some context is needed:
When printing to pdf, the Chrome driver picks the default
page size of A4, which is 8.27in x 11.69in. In CSS, the length
units include pixels and inches, and it seems 1 inch
equals 96 pixels. This explains the constants below.
In order to ensure the invoice table fits along the width
(since we’re printing landscape) of the A4 page, we want
to scale the table down.
This scaling value should be = width of A4 / length of table.
"""
A4_WIDTH_INCHES = 11.69
PX_PER_INCH = 96
invoice_table = driver.find_element(by=By.TAG_NAME, value="table")
px_width = float(
invoice_table.value_of_css_property("width").split("px")[0]
)
scale_value = A4_WIDTH_INCHES / (px_width / PX_PER_INCH)

print_options = PrintOptions()
print_options.orientation = "landscape"
print_options.scale = scale_value
print_options.shrink_to_fit = True

pdf_str = driver.print_page(print_options=print_options)
pdf = base64.b64decode(pdf_str)
with open(
f"{self.name}/{pi_instituition}_{pi}_{self.invoice_month}.pdf", "wb"
) as f:
f.write(pdf)

export_data = self._filter_columns()

if not os.path.exists(
self.name
): # self.name is name of folder storing invoices
os.mkdir(self.name)

for pi in self.pi_list:
_export_pi_invoice(pi)
if pandas.isna(pi):
continue

pi_dataframe = self._get_pi_dataframe(export_data, pi)
pi_instituition = pi_dataframe[invoice.INSTITUTION_FIELD].iat[0]

invoice_html_path = _create_html_invoice()
_create_pdf_invoice()
os.remove(invoice_html_path)

def export_s3(self, s3_bucket):
def _export_s3_pi_invoice(pi_invoice):
Expand Down
59 changes: 59 additions & 0 deletions process_report/templates/pi_invoice.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
</head>

<style>
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
table {
font-family: arial, sans-serif;
border-collapse: collapse;
width: 100%;
}
td, th {
border: 1px solid #8d8d8d;
text-align: left;
padding: 8px;
}
th {
text-align: center;
}
tr:nth-child(even) {
background-color: #dddddd;
}
tr:last-child {
background-color: #dddddd;
font-weight: bold;
}
</style>

<body>
<table>
<tr>
{% for col in data.columns %}
<th>{{col}}</th>
{% endfor %}
</tr>

{% for i, row in data.iterrows() %}
<tr>
{% for field in row %}
{% if i == data.index[-1] %}
{% if field %}
<th>{{field}}</th>
{% else %}
<td style="border-width: 0;"></td>
{% endif %}
{% else %}
<td>{{field}}</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</table>
</body>
</html>
Loading

0 comments on commit 172d6da

Please sign in to comment.