485 lines
18 KiB
Python
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)
|