Files
edx-platform/lms/djangoapps/shoppingcart/pdf.py
2017-06-11 21:48:06 -04:00

485 lines
18 KiB
Python

"""
Template for PDF Receipt/Invoice Generation
"""
import logging
from django.conf import settings
from django.utils.translation import ugettext as _
from PIL import Image
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
from reportlab.platypus.tables import Table, TableStyle
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from xmodule.modulestore.django import ModuleI18nService
log = logging.getLogger("PDF Generation")
class NumberedCanvas(Canvas):
"""
Canvas child class with auto page-numbering.
"""
def __init__(self, *args, **kwargs):
"""
__init__
"""
Canvas.__init__(self, *args, **kwargs)
self._saved_page_states = []
def insert_page_break(self):
"""
Starts a new page.
"""
self._saved_page_states.append(dict(self.__dict__))
self._startPage()
def current_page_count(self):
"""
Returns the page count in the current pdf document.
"""
return len(self._saved_page_states) + 1
def save(self):
"""
Adds page numbering to each page (page x of y)
"""
num_pages = len(self._saved_page_states)
for state in self._saved_page_states:
self.__dict__.update(state)
if num_pages > 1:
self.draw_page_number(num_pages)
Canvas.showPage(self)
Canvas.save(self)
def draw_page_number(self, page_count):
"""
Draws the String "Page x of y" at the bottom right of the document.
"""
self.setFontSize(7)
self.drawRightString(
200 * mm,
12 * mm,
_("Page {page_number} of {page_count}").format(page_number=self._pageNumber, page_count=page_count)
)
class PDFInvoice(object):
"""
PDF Generation Class
"""
def __init__(self, items_data, item_id, date, is_invoice, total_cost, payment_received, balance):
"""
Accepts the following positional arguments
items_data - A list having the following items for each row.
item_description - String
quantity - Integer
list_price - float
discount - float
item_total - float
id - String
date - datetime
is_invoice - boolean - True (for invoice) or False (for Receipt)
total_cost - float
payment_received - float
balance - float
"""
# From settings
self.currency = settings.PAID_COURSE_REGISTRATION_CURRENCY[1]
self.logo_path = configuration_helpers.get_value("PDF_RECEIPT_LOGO_PATH", settings.PDF_RECEIPT_LOGO_PATH)
self.cobrand_logo_path = configuration_helpers.get_value(
"PDF_RECEIPT_COBRAND_LOGO_PATH", settings.PDF_RECEIPT_COBRAND_LOGO_PATH
)
self.tax_label = configuration_helpers.get_value("PDF_RECEIPT_TAX_ID_LABEL", settings.PDF_RECEIPT_TAX_ID_LABEL)
self.tax_id = configuration_helpers.get_value("PDF_RECEIPT_TAX_ID", settings.PDF_RECEIPT_TAX_ID)
self.footer_text = configuration_helpers.get_value("PDF_RECEIPT_FOOTER_TEXT", settings.PDF_RECEIPT_FOOTER_TEXT)
self.disclaimer_text = configuration_helpers.get_value(
"PDF_RECEIPT_DISCLAIMER_TEXT", settings.PDF_RECEIPT_DISCLAIMER_TEXT,
)
self.billing_address_text = configuration_helpers.get_value(
"PDF_RECEIPT_BILLING_ADDRESS", settings.PDF_RECEIPT_BILLING_ADDRESS
)
self.terms_conditions_text = configuration_helpers.get_value(
"PDF_RECEIPT_TERMS_AND_CONDITIONS", settings.PDF_RECEIPT_TERMS_AND_CONDITIONS
)
self.brand_logo_height = configuration_helpers.get_value(
"PDF_RECEIPT_LOGO_HEIGHT_MM", settings.PDF_RECEIPT_LOGO_HEIGHT_MM
) * mm
self.cobrand_logo_height = configuration_helpers.get_value(
"PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM", settings.PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM
) * mm
# From Context
self.items_data = items_data
self.item_id = item_id
self.date = ModuleI18nService().strftime(date, 'SHORT_DATE')
self.is_invoice = is_invoice
self.total_cost = '{currency}{amount:.2f}'.format(currency=self.currency, amount=total_cost)
self.payment_received = '{currency}{amount:.2f}'.format(currency=self.currency, amount=payment_received)
self.balance = '{currency}{amount:.2f}'.format(currency=self.currency, amount=balance)
# initialize the pdf variables
self.margin = 15 * mm
self.page_width = letter[0]
self.page_height = letter[1]
self.min_clearance = 3 * mm
self.second_page_available_height = ''
self.second_page_start_y_pos = ''
self.first_page_available_height = ''
self.pdf = None
def is_on_first_page(self):
"""
Returns True if it's the first page of the pdf, False otherwise.
"""
return self.pdf.current_page_count() == 1
def generate_pdf(self, file_buffer):
"""
Takes in a buffer and puts the generated pdf into that buffer.
"""
self.pdf = NumberedCanvas(file_buffer, pagesize=letter)
self.draw_border()
y_pos = self.draw_logos()
self.second_page_available_height = y_pos - self.margin - self.min_clearance
self.second_page_start_y_pos = y_pos
y_pos = self.draw_title(y_pos)
self.first_page_available_height = y_pos - self.margin - self.min_clearance
y_pos = self.draw_course_info(y_pos)
y_pos = self.draw_totals(y_pos)
self.draw_footer(y_pos)
self.pdf.insert_page_break()
self.pdf.save()
def draw_border(self):
"""
Draws a big border around the page leaving a margin of 15 mm on each side.
"""
self.pdf.setStrokeColorRGB(0.5, 0.5, 0.5)
self.pdf.setLineWidth(0.353 * mm)
self.pdf.rect(self.margin, self.margin,
self.page_width - (self.margin * 2), self.page_height - (self.margin * 2),
stroke=True, fill=False)
@staticmethod
def load_image(img_path):
"""
Loads an image given a path. An absolute path is assumed.
If the path points to an image file, it loads and returns the Image object, None otherwise.
"""
try:
img = Image.open(img_path)
except IOError, ex:
log.exception('Pdf unable to open the image file: %s', str(ex))
img = None
return img
def draw_logos(self):
"""
Draws logos.
"""
horizontal_padding_from_border = self.margin + 9 * mm
vertical_padding_from_border = 11 * mm
img_y_pos = self.page_height - (
self.margin + vertical_padding_from_border + max(self.cobrand_logo_height, self.brand_logo_height)
)
# Left-Aligned cobrand logo
if self.cobrand_logo_path:
cobrand_img = self.load_image(self.cobrand_logo_path)
if cobrand_img:
img_width = float(cobrand_img.size[0]) / (float(cobrand_img.size[1]) / self.cobrand_logo_height)
self.pdf.drawImage(cobrand_img.filename, horizontal_padding_from_border, img_y_pos, img_width,
self.cobrand_logo_height, mask='auto')
# Right aligned brand logo
if self.logo_path:
logo_img = self.load_image(self.logo_path)
if logo_img:
img_width = float(logo_img.size[0]) / (float(logo_img.size[1]) / self.brand_logo_height)
self.pdf.drawImage(
logo_img.filename,
self.page_width - (horizontal_padding_from_border + img_width),
img_y_pos,
img_width,
self.brand_logo_height,
mask='auto'
)
return img_y_pos - self.min_clearance
def draw_title(self, y_pos):
"""
Draws the title, order/receipt ID and the date.
"""
if self.is_invoice:
title = (_('Invoice'))
id_label = (_('Invoice'))
else:
title = (_('Receipt'))
id_label = (_('Order'))
# Draw Title "RECEIPT" OR "INVOICE"
vertical_padding = 5 * mm
horizontal_padding_from_border = self.margin + 9 * mm
font_size = 21
self.pdf.setFontSize(font_size)
self.pdf.drawString(horizontal_padding_from_border, y_pos - vertical_padding - font_size / 2, title)
y_pos = y_pos - vertical_padding - font_size / 2 - self.min_clearance
horizontal_padding_from_border = self.margin + 11 * mm
font_size = 12
self.pdf.setFontSize(font_size)
y_pos = y_pos - font_size / 2 - vertical_padding
# Draw Order/Invoice No.
self.pdf.drawString(horizontal_padding_from_border, y_pos,
_(u'{id_label} # {item_id}').format(id_label=id_label, item_id=self.item_id))
y_pos = y_pos - font_size / 2 - vertical_padding
# Draw Date
self.pdf.drawString(
horizontal_padding_from_border, y_pos, _(u'Date: {date}').format(date=self.date)
)
return y_pos - self.min_clearance
def draw_course_info(self, y_pos):
"""
Draws the main table containing the data items.
"""
course_items_data = [
['', (_('Description')), (_('Quantity')), (_('List Price\nper item')), (_('Discount\nper item')),
(_('Amount')), '']
]
for row_item in self.items_data:
course_items_data.append([
'',
Paragraph(row_item['item_description'], getSampleStyleSheet()['Normal']),
row_item['quantity'],
'{currency}{list_price:.2f}'.format(list_price=row_item['list_price'], currency=self.currency),
'{currency}{discount:.2f}'.format(discount=row_item['discount'], currency=self.currency),
'{currency}{item_total:.2f}'.format(item_total=row_item['item_total'], currency=self.currency),
''
])
padding_width = 7 * mm
desc_col_width = 60 * mm
qty_col_width = 26 * mm
list_price_col_width = 21 * mm
discount_col_width = 21 * mm
amount_col_width = 40 * mm
course_items_table = Table(
course_items_data,
[
padding_width,
desc_col_width,
qty_col_width,
list_price_col_width,
discount_col_width,
amount_col_width,
padding_width
],
splitByRow=1,
repeatRows=1
)
course_items_table.setStyle(TableStyle([
#List Price, Discount, Amount data items
('ALIGN', (3, 1), (5, -1), 'RIGHT'),
# Amount header
('ALIGN', (5, 0), (5, 0), 'RIGHT'),
# Amount column (header + data items)
('RIGHTPADDING', (5, 0), (5, -1), 7 * mm),
# Quantity, List Price, Discount header
('ALIGN', (2, 0), (4, 0), 'CENTER'),
# Description header
('ALIGN', (1, 0), (1, -1), 'LEFT'),
# Quantity data items
('ALIGN', (2, 1), (2, -1), 'CENTER'),
# Lines below the header and at the end of the table.
('LINEBELOW', (0, 0), (-1, 0), 1.00, '#cccccc'),
('LINEBELOW', (0, -1), (-1, -1), 1.00, '#cccccc'),
# Innergrid around the data rows.
('INNERGRID', (1, 1), (-2, -1), 0.50, '#cccccc'),
# Entire table
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TOPPADDING', (0, 0), (-1, -1), 2 * mm),
('BOTTOMPADDING', (0, 0), (-1, -1), 2 * mm),
('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
]))
rendered_width, rendered_height = course_items_table.wrap(0, 0)
table_left_padding = (self.page_width - rendered_width) / 2
split_tables = course_items_table.split(0, self.first_page_available_height)
if len(split_tables) > 1:
# The entire Table won't fit in the available space and requires splitting.
# Draw the part that can fit, start a new page
# and repeat the process with the rest of the table.
split_table = split_tables[0]
__, rendered_height = split_table.wrap(0, 0)
split_table.drawOn(self.pdf, table_left_padding, y_pos - rendered_height)
self.prepare_new_page()
split_tables = split_tables[1].split(0, self.second_page_available_height)
while len(split_tables) > 1:
split_table = split_tables[0]
__, rendered_height = split_table.wrap(0, 0)
split_table.drawOn(self.pdf, table_left_padding, self.second_page_start_y_pos - rendered_height)
self.prepare_new_page()
split_tables = split_tables[1].split(0, self.second_page_available_height)
split_table = split_tables[0]
__, rendered_height = split_table.wrap(0, 0)
split_table.drawOn(self.pdf, table_left_padding, self.second_page_start_y_pos - rendered_height)
else:
# Table will fit without the need for splitting.
course_items_table.drawOn(self.pdf, table_left_padding, y_pos - rendered_height)
if not self.is_on_first_page():
y_pos = self.second_page_start_y_pos
return y_pos - rendered_height - self.min_clearance
def prepare_new_page(self):
"""
Inserts a new page and includes the border and the logos.
"""
self.pdf.insert_page_break()
self.draw_border()
y_pos = self.draw_logos()
return y_pos
def draw_totals(self, y_pos):
"""
Draws the boxes containing the totals and the tax id.
"""
totals_data = [
[(_('Total')), self.total_cost],
[(_('Payment Received')), self.payment_received],
[(_('Balance')), self.balance]
]
if self.is_invoice:
# only print TaxID if we are generating an Invoice
totals_data.append(
['', '{tax_label}: {tax_id}'.format(tax_label=self.tax_label, tax_id=self.tax_id)]
)
heights = 8 * mm
totals_table = Table(totals_data, 40 * mm, heights)
styles = [
# Styling for the totals table.
('ALIGN', (0, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
# Styling for the Amounts cells
# NOTE: since we are not printing the TaxID for Credit Card
# based receipts, we need to change the cell range for
# these formatting rules
('RIGHTPADDING', (-1, 0), (-1, 2), 7 * mm),
('GRID', (-1, 0), (-1, 2), 3.0, colors.white),
('BACKGROUND', (-1, 0), (-1, 2), '#EEEEEE'),
]
totals_table.setStyle(TableStyle(styles))
__, rendered_height = totals_table.wrap(0, 0)
left_padding = 97 * mm
if y_pos - (self.margin + self.min_clearance) <= rendered_height:
# if space left on page is smaller than the rendered height, render the table on the next page.
self.prepare_new_page()
totals_table.drawOn(self.pdf, self.margin + left_padding, self.second_page_start_y_pos - rendered_height)
return self.second_page_start_y_pos - rendered_height - self.min_clearance
else:
totals_table.drawOn(self.pdf, self.margin + left_padding, y_pos - rendered_height)
return y_pos - rendered_height - self.min_clearance
def draw_footer(self, y_pos):
"""
Draws the footer.
"""
para_style = getSampleStyleSheet()['Normal']
para_style.fontSize = 8
footer_para = Paragraph(self.footer_text.replace("\n", "<br/>"), para_style)
disclaimer_para = Paragraph(self.disclaimer_text.replace("\n", "<br/>"), para_style)
billing_address_para = Paragraph(self.billing_address_text.replace("\n", "<br/>"), para_style)
footer_data = [
['', footer_para],
[(_('Billing Address')), ''],
['', billing_address_para],
[(_('Disclaimer')), ''],
['', disclaimer_para]
]
footer_style = [
# Styling for the entire footer table.
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
('FONTSIZE', (0, 0), (-1, -1), 9),
('TEXTCOLOR', (0, 0), (-1, -1), '#AAAAAA'),
# Billing Address Header styling
('LEFTPADDING', (0, 1), (0, 1), 5 * mm),
# Disclaimer Header styling
('LEFTPADDING', (0, 3), (0, 3), 5 * mm),
('TOPPADDING', (0, 3), (0, 3), 2 * mm),
# Footer Body styling
# ('BACKGROUND', (1, 0), (1, 0), '#EEEEEE'),
# Billing Address Body styling
('BACKGROUND', (1, 2), (1, 2), '#EEEEEE'),
# Disclaimer Body styling
('BACKGROUND', (1, 4), (1, 4), '#EEEEEE'),
]
if self.is_invoice:
terms_conditions_para = Paragraph(self.terms_conditions_text.replace("\n", "<br/>"), para_style)
footer_data.append([(_('TERMS AND CONDITIONS')), ''])
footer_data.append(['', terms_conditions_para])
# TERMS AND CONDITIONS header styling
footer_style.append(('LEFTPADDING', (0, 5), (0, 5), 5 * mm))
footer_style.append(('TOPPADDING', (0, 5), (0, 5), 2 * mm))
# TERMS AND CONDITIONS body styling
footer_style.append(('BACKGROUND', (1, 6), (1, 6), '#EEEEEE'))
footer_table = Table(footer_data, [5 * mm, 176 * mm])
footer_table.setStyle(TableStyle(footer_style))
__, rendered_height = footer_table.wrap(0, 0)
if y_pos - (self.margin + self.min_clearance) <= rendered_height:
self.prepare_new_page()
footer_table.drawOn(self.pdf, self.margin, self.margin + 5 * mm)