Commit a8ebf6da by Afzal Wali Committed by Muhammad Shoaib

Added the reportlab requirement to base.txt

Added receipts_pdf.py

Used Paragraph for displaying a large body of text.

added the table

Line breaks in the para text. font size adjusted.

Improved the main table (alignments) and totals (converted to a table as well)

Converted the footer into a table, and allowed for pagination.

Added pagination to item data table.

Handled wrapping of long descriptions into multiple lines.

email attachment for both invoice and receipt

added the currency from the settings

Removed magic numeric literals and added meaningful variables.

Added initial set of substitutions from configuration

add defining logo paths via configuration

Removed font dependencies. Will use the system default fonts which appear good enough to me.

Alignment adjustments as per suggestions.

Fixed the pep8 violations. Added comments to styling

added the decimal points to the price values

Cleanup. Docstrings.

i18n the text in the pdf file

fix pep8/pylint issues

Changed the amounts from string to float.

Overrode the 'pdf_receipt_display_name' property in the OrderItem subclass Donation.

used the PaidCourseRegistration instead of the parent OrderItem to avoid course_id related exceptions.

quality fixes

added  the test cases for the pdf

made the changes in the pdf suggested by griff

updated the pdf tests to assert the pdf content

used the pdfminor library

fix quality issues

made the changes suggested by Will

added the text file that says "pdf file not available. please contact support"
 in case pdf fails to attach in the email
parent 477031e1
......@@ -41,6 +41,7 @@ from shoppingcart.models import (
RegistrationCodeRedemption, Order, CouponRedemption,
PaidCourseRegistration, Coupon, Invoice, CourseRegistrationCode
)
from shoppingcart.pdf import PDFInvoice
from student.models import (
CourseEnrollment, CourseEnrollmentAllowed, NonExistentCourseError
)
......@@ -3296,6 +3297,25 @@ class TestCourseRegistrationCodes(ModuleStoreTestCase):
self.assertTrue(body.startswith(EXPECTED_CSV_HEADER))
self.assertEqual(len(body.split('\n')), 11)
def test_pdf_file_throws_exception(self):
"""
test to mock the pdf file generation throws an exception
when generating registration codes.
"""
generate_code_url = reverse(
'generate_registration_codes', kwargs={'course_id': self.course.id.to_deprecated_string()}
)
data = {
'total_registration_codes': 9, 'company_name': 'Group Alpha', 'company_contact_name': 'Test@company.com',
'company_contact_email': 'Test@company.com', 'sale_price': 122.45, 'recipient_name': 'Test123',
'recipient_email': 'test@123.com', 'address_line_1': 'Portland Street', 'address_line_2': '',
'address_line_3': '', 'city': '', 'state': '', 'zip': '', 'country': '',
'customer_reference_number': '123A23F', 'internal_reference': '', 'invoice': ''
}
with patch.object(PDFInvoice, 'generate_pdf', side_effect=Exception):
response = self.client.post(generate_code_url, data)
self.assertEqual(response.status_code, 200, response.content)
def test_get_codes_with_sale_invoice(self):
"""
Test to generate a response of all the course registration codes
......
......@@ -1231,6 +1231,12 @@ def generate_registration_codes(request, course_id):
dashboard=reverse('dashboard')
)
try:
pdf_file = sale_invoice.generate_pdf_invoice(course, course_price, int(quantity), float(sale_price))
except Exception: # pylint: disable=broad-except
log.exception('Exception at creating pdf file.')
pdf_file = None
from_address = microsite.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
context = {
'invoice': sale_invoice,
......@@ -1277,6 +1283,11 @@ def generate_registration_codes(request, course_id):
email.to = [recipient]
email.attach(u'RegistrationCodes.csv', csv_file.getvalue(), 'text/csv')
email.attach(u'Invoice.txt', invoice_attachment, 'text/plain')
if pdf_file is not None:
email.attach(u'Invoice.pdf', pdf_file.getvalue(), 'application/pdf')
else:
file_buffer = StringIO.StringIO(_('pdf download unavailable right now, please contact support.'))
email.attach(u'pdf_unavailable.txt', file_buffer.getvalue(), 'text/plain')
email.send()
return registration_codes_csv("Registration_Codes.csv", registration_codes)
......
......@@ -5,12 +5,12 @@ from datetime import datetime
from datetime import timedelta
from decimal import Decimal
import analytics
from io import BytesIO
import pytz
import logging
import smtplib
import StringIO
import csv
from courseware.courses import get_course_by_id
from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors
from django.dispatch import receiver
from django.db import models
......@@ -25,19 +25,17 @@ from django.core.urlresolvers import reverse
from model_utils.managers import InheritanceManager
from model_utils.models import TimeStampedModel
from django.core.mail.message import EmailMessage
from xmodule.modulestore.django import modulestore
from eventtracking import tracker
from courseware.courses import get_course_by_id
from config_models.models import ConfigurationModel
from course_modes.models import CourseMode
from edxmako.shortcuts import render_to_string
from student.models import CourseEnrollment, UNENROLL_DONE
from eventtracking import tracker
from util.query import use_read_replica_if_available
from xmodule_django.models import CourseKeyField
from verify_student.models import SoftwareSecurePhotoVerification
from .exceptions import (
InvalidCartItem,
PurchasedCallbackException,
......@@ -49,8 +47,9 @@ from .exceptions import (
UnexpectedOrderItemStatus,
ItemNotFoundInCartException
)
from microsite_configuration import microsite
from shoppingcart.pdf import PDFInvoice
log = logging.getLogger("shoppingcart")
......@@ -288,6 +287,41 @@ class Order(models.Model):
self.save()
return old_to_new_id_map
def generate_pdf_receipt(self, order_items):
"""
Generates the pdf receipt for the given order_items
and returns the pdf_buffer.
"""
items_data = []
for item in order_items:
if item.list_price is not None:
discount_price = item.list_price - item.unit_cost
price = item.list_price
else:
discount_price = 0
price = item.unit_cost
item_total = item.qty * item.unit_cost
items_data.append({
'item_description': item.pdf_receipt_display_name,
'quantity': item.qty,
'list_price': price,
'discount': discount_price,
'item_total': item_total
})
pdf_buffer = BytesIO()
PDFInvoice(
items_data=items_data,
item_id=str(self.id), # pylint: disable=no-member
date=self.purchase_time,
is_invoice=False,
total_cost=self.total_cost,
payment_received=self.total_cost,
balance=0
).generate_pdf(pdf_buffer)
return pdf_buffer
def generate_registration_codes_csv(self, orderitems, site_name):
"""
this function generates the csv file
......@@ -308,7 +342,7 @@ class Order(models.Model):
return csv_file, course_info
def send_confirmation_emails(self, orderitems, is_order_type_business, csv_file, site_name, courses_info):
def send_confirmation_emails(self, orderitems, is_order_type_business, csv_file, pdf_file, site_name, courses_info):
"""
send confirmation e-mail
"""
......@@ -370,6 +404,11 @@ class Order(models.Model):
if csv_file:
email.attach(u'RegistrationCodesRedemptionUrls.csv', csv_file.getvalue(), 'text/csv')
if pdf_file is not None:
email.attach(u'Receipt.pdf', pdf_file.getvalue(), 'application/pdf')
else:
file_buffer = StringIO.StringIO(_('pdf download unavailable right now, please contact support.'))
email.attach(u'pdf_not_available.txt', file_buffer.getvalue(), 'text/plain')
email.send()
except (smtplib.SMTPException, BotoServerError): # sadly need to handle diff. mail backends individually
log.error('Failed sending confirmation e-mail for order %d', self.id) # pylint: disable=no-member
......@@ -436,7 +475,16 @@ class Order(models.Model):
#
csv_file, courses_info = self.generate_registration_codes_csv(orderitems, site_name)
self.send_confirmation_emails(orderitems, self.order_type == OrderTypes.BUSINESS, csv_file, site_name, courses_info)
try:
pdf_file = self.generate_pdf_receipt(orderitems)
except Exception: # pylint: disable=broad-except
log.exception('Exception at creating pdf file.')
pdf_file = None
self.send_confirmation_emails(
orderitems, self.order_type == OrderTypes.BUSINESS,
csv_file, pdf_file, site_name, courses_info
)
self._emit_order_event('Completed Order', orderitems)
def refund(self):
......@@ -679,6 +727,22 @@ class OrderItem(TimeStampedModel):
"""
return ''
@property
def pdf_receipt_display_name(self):
"""
How to display this item on a PDF printed receipt file.
This can be overridden by the subclasses of OrderItem
"""
course_key = getattr(self, 'course_id', None)
if course_key:
course = get_course_by_id(course_key, depth=0)
return course.display_name
else:
raise Exception(
"Not Implemented. OrderItems that are not Course specific should have"
" a overridden pdf_receipt_display_name property"
)
def analytics_data(self):
"""Simple function used to construct analytics data for the OrderItem.
......@@ -733,6 +797,33 @@ class Invoice(models.Model):
customer_reference_number = models.CharField(max_length=63, null=True)
is_valid = models.BooleanField(default=True)
def generate_pdf_invoice(self, course, course_price, quantity, sale_price):
"""
Generates the pdf invoice for the given course
and returns the pdf_buffer.
"""
discount_per_item = float(course_price) - sale_price / quantity
list_price = course_price - discount_per_item
items_data = [{
'item_description': course.display_name,
'quantity': quantity,
'list_price': list_price,
'discount': discount_per_item,
'item_total': quantity * list_price
}]
pdf_buffer = BytesIO()
PDFInvoice(
items_data=items_data,
item_id=str(self.id), # pylint: disable=no-member
date=datetime.now(pytz.utc),
is_invoice=True,
total_cost=float(self.total_amount),
payment_received=0,
balance=float(self.total_amount)
).generate_pdf(pdf_buffer)
return pdf_buffer
class CourseRegistrationCode(models.Model):
"""
......@@ -1606,3 +1697,10 @@ class Donation(OrderItem):
data['name'] = settings.PLATFORM_NAME
data['category'] = settings.PLATFORM_NAME
return data
@property
def pdf_receipt_display_name(self):
"""
How to display this item on a PDF printed receipt file.
"""
return self._line_item_description(course_id=self.course_id)
"""
Template for PDF Receipt/Invoice Generation
"""
from PIL import Image
import logging
from reportlab.lib import colors
from django.conf import settings
from django.utils.translation import ugettext as _
from reportlab.pdfgen.canvas import Canvas
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import mm
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import Paragraph
from reportlab.platypus.tables import Table, TableStyle
from microsite_configuration import microsite
from xmodule.modulestore.django import ModuleI18nService
log = logging.getLogger("PDF Generation")
class NumberedCanvas(Canvas): # pylint: disable=abstract-method
"""
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 = microsite.get_value("PDF_RECEIPT_LOGO_PATH", settings.PDF_RECEIPT_LOGO_PATH)
self.cobrand_logo_path = microsite.get_value(
"PDF_RECEIPT_COBRAND_LOGO_PATH", settings.PDF_RECEIPT_COBRAND_LOGO_PATH
)
self.tax_label = microsite.get_value("PDF_RECEIPT_TAX_ID_LABEL", settings.PDF_RECEIPT_TAX_ID_LABEL)
self.tax_id = microsite.get_value("PDF_RECEIPT_TAX_ID", settings.PDF_RECEIPT_TAX_ID)
self.footer_text = microsite.get_value("PDF_RECEIPT_FOOTER_TEXT", settings.PDF_RECEIPT_FOOTER_TEXT)
self.disclaimer_text = microsite.get_value("PDF_RECEIPT_DISCLAIMER_TEXT", settings.PDF_RECEIPT_DISCLAIMER_TEXT)
self.billing_address_text = microsite.get_value(
"PDF_RECEIPT_BILLING_ADDRESS", settings.PDF_RECEIPT_BILLING_ADDRESS
)
self.terms_conditions_text = microsite.get_value(
"PDF_RECEIPT_TERMS_AND_CONDITIONS", settings.PDF_RECEIPT_TERMS_AND_CONDITIONS
)
self.brand_logo_height = microsite.get_value(
"PDF_RECEIPT_LOGO_HEIGHT_MM", settings.PDF_RECEIPT_LOGO_HEIGHT_MM
) * mm
self.cobrand_logo_height = microsite.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
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
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],
['', '{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)
totals_table.setStyle(TableStyle([
# 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
('RIGHTPADDING', (-1, 0), (-1, -2), 7 * mm),
('GRID', (-1, 0), (-1, -2), 3.0, colors.white),
('BACKGROUND', (-1, 0), (-1, -2), '#EEEEEE'),
]))
__, 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)
......@@ -119,10 +119,26 @@ class CyberSource2Test(TestCase):
self.assertEqual(params['signature'], self._signature(params))
# We patch the purchased callback because
# we're using the OrderItem base class, which throws an exception
# when item doest not have a course id associated
@patch.object(OrderItem, 'purchased_callback')
def test_process_payment_raises_exception(self, purchased_callback): # pylint: disable=unused-argument
self.order.clear()
OrderItem.objects.create(
order=self.order,
user=self.user,
unit_cost=self.COST,
line_cost=self.COST,
)
params = self._signed_callback_params(self.order.id, self.COST, self.COST)
process_postpay_callback(params)
# We patch the purchased callback because
# (a) we're using the OrderItem base class, which doesn't implement this method, and
# (b) we want to verify that the method gets called on success.
@patch.object(OrderItem, 'purchased_callback')
def test_process_payment_success(self, purchased_callback):
@patch.object(OrderItem, 'pdf_receipt_display_name')
def test_process_payment_success(self, pdf_receipt_display_name, purchased_callback): # pylint: disable=unused-argument
# Simulate a callback from CyberSource indicating that payment was successful
params = self._signed_callback_params(self.order.id, self.COST, self.COST)
result = process_postpay_callback(params)
......@@ -201,7 +217,8 @@ class CyberSource2Test(TestCase):
self.assertIn(u"you have cancelled this transaction", result['error_html'])
@patch.object(OrderItem, 'purchased_callback')
def test_process_no_credit_card_digits(self, callback):
@patch.object(OrderItem, 'pdf_receipt_display_name')
def test_process_no_credit_card_digits(self, pdf_receipt_display_name, purchased_callback): # pylint: disable=unused-argument
# Use a credit card number with no digits provided
params = self._signed_callback_params(
self.order.id, self.COST, self.COST,
......@@ -238,7 +255,8 @@ class CyberSource2Test(TestCase):
self.assertIn(u"did not return a required parameter", result['error_html'])
@patch.object(OrderItem, 'purchased_callback')
def test_sign_then_verify_unicode(self, purchased_callback):
@patch.object(OrderItem, 'pdf_receipt_display_name')
def test_sign_then_verify_unicode(self, pdf_receipt_display_name, purchased_callback): # pylint: disable=unused-argument
params = self._signed_callback_params(
self.order.id, self.COST, self.COST,
first_name=u'\u2699'
......
"""
Tests for Pdf file
"""
from datetime import datetime
from django.test.utils import override_settings
from django.conf import settings
import unittest
from io import BytesIO
from shoppingcart.pdf import PDFInvoice
from shoppingcart.utils import parse_pages
PDF_RECEIPT_DISCLAIMER_TEXT = "THE SITE AND ANY INFORMATION, CONTENT OR SERVICES MADE AVAILABLE ON OR THROUGH " \
"THE SITE ARE PROVIDED \"AS IS\" AND \"AS AVAILABLE\" WITHOUT WARRANTY OF ANY KIND (EXPRESS, IMPLIED OR" \
" OTHERWISE), INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A " \
"PARTICULAR PURPOSE AND NON-INFRINGEMENT, EXCEPT INSOFAR AS ANY SUCH IMPLIED WARRANTIES MAY NOT BE DISCLAIMED" \
" UNDER APPLICABLE LAW."
PDF_RECEIPT_BILLING_ADDRESS = "edX\n141 Portland St.\n9th Floor\nCambridge,\nMA 02139"
PDF_RECEIPT_FOOTER_TEXT = "EdX offers online courses that include opportunities for professor-to-student and" \
" student-to-student interactivity, individual assessment of a student's work and, for students who demonstrate" \
" their mastery of subjects, a certificate of achievement or other acknowledgment."
PDF_RECEIPT_TAX_ID = "46-0807740"
PDF_RECEIPT_TAX_ID_LABEL = "edX Tax ID"
PDF_RECEIPT_TERMS_AND_CONDITIONS = "Enrollments:\nEnrollments must be completed within 7 full days from the course " \
"start date.\nPayment Terms:\nPayment is due immediately. Preferred method of payment is wire transfer. Full " \
"instructions and remittance details will be included on your official invoice. Please note that our terms are " \
"net zero. For questions regarding payment instructions or extensions, please contact " \
"onlinex-registration@mit.edu and include the words \"payment question\" in your subject line.\nCancellations:" \
"\nCancellation requests must be submitted to onlinex-registration@mit.edu 14 days prior to the course start " \
"date to be eligible for a refund. If you submit a cancellation request within 14 days prior to the course start " \
"date, you will not be eligible for a refund. Please see our Terms of Service page for full details." \
"\nSubstitutions:\nThe MIT Professional Education Online X Programs office must receive substitution requests " \
"before the course start date in order for the request to be considered. Please email " \
"onlinex-registration@mit.edu to request a substitution.Please see our Terms of Service page for our detailed " \
"policies, including terms and conditions of use."
class TestPdfFile(unittest.TestCase):
"""
Unit test cases for pdf file generation
"""
def setUp(self):
self.items_data = [self.get_item_data(1)]
self.item_id = '1'
self.date = datetime.now()
self.is_invoice = False
self.total_cost = 1000
self.payment_received = 1000
self.balance = 0
self.pdf_buffer = BytesIO()
def get_item_data(self, index, discount=0):
"""
return the dictionary with the dummy data
"""
return {
'item_description': 'Course %s Description' % index,
'quantity': index,
'list_price': 10,
'discount': discount,
'item_total': 10
}
@override_settings(
PDF_RECEIPT_DISCLAIMER_TEXT=PDF_RECEIPT_DISCLAIMER_TEXT,
PDF_RECEIPT_BILLING_ADDRESS=PDF_RECEIPT_BILLING_ADDRESS,
PDF_RECEIPT_FOOTER_TEXT=PDF_RECEIPT_FOOTER_TEXT,
PDF_RECEIPT_TAX_ID=PDF_RECEIPT_TAX_ID,
PDF_RECEIPT_TAX_ID_LABEL=PDF_RECEIPT_TAX_ID_LABEL,
PDF_RECEIPT_TERMS_AND_CONDITIONS=PDF_RECEIPT_TERMS_AND_CONDITIONS,
)
def test_pdf_receipt_configured_generation(self):
PDFInvoice(
items_data=self.items_data,
item_id=self.item_id,
date=self.date,
is_invoice=self.is_invoice,
total_cost=self.total_cost,
payment_received=self.payment_received,
balance=self.balance
).generate_pdf(self.pdf_buffer)
pdf_content = parse_pages(self.pdf_buffer, 'test_pass')
self.assertTrue(any('Receipt' in s for s in pdf_content))
self.assertTrue(any(str(self.total_cost) in s for s in pdf_content))
self.assertTrue(any(str(self.payment_received) in s for s in pdf_content))
self.assertTrue(any(str(self.balance) in s for s in pdf_content))
self.assertTrue(any('edX Tax ID' in s for s in pdf_content))
# PDF_RECEIPT_TERMS_AND_CONDITIONS not displayed in the receipt pdf
self.assertFalse(any(
'Enrollments:\nEnrollments must be completed within 7 full days from the course'
' start date.\nPayment Terms:\nPayment is due immediately.' in s for s in pdf_content
))
self.assertTrue(any('edX\n141 Portland St.\n9th Floor\nCambridge,\nMA 02139' in s for s in pdf_content))
def test_pdf_receipt_not_configured_generation(self):
PDFInvoice(
items_data=self.items_data,
item_id=self.item_id,
date=self.date,
is_invoice=self.is_invoice,
total_cost=self.total_cost,
payment_received=self.payment_received,
balance=self.balance
).generate_pdf(self.pdf_buffer)
pdf_content = parse_pages(self.pdf_buffer, 'test_pass')
self.assertTrue(any('Receipt' in s for s in pdf_content))
self.assertTrue(any(settings.PDF_RECEIPT_DISCLAIMER_TEXT in s for s in pdf_content))
self.assertTrue(any(settings.PDF_RECEIPT_BILLING_ADDRESS in s for s in pdf_content))
self.assertTrue(any(settings.PDF_RECEIPT_FOOTER_TEXT in s for s in pdf_content))
# PDF_RECEIPT_TERMS_AND_CONDITIONS not displayed in the receipt pdf
self.assertFalse(any(settings.PDF_RECEIPT_TERMS_AND_CONDITIONS in s for s in pdf_content))
@override_settings(
PDF_RECEIPT_DISCLAIMER_TEXT=PDF_RECEIPT_DISCLAIMER_TEXT,
PDF_RECEIPT_BILLING_ADDRESS=PDF_RECEIPT_BILLING_ADDRESS,
PDF_RECEIPT_FOOTER_TEXT=PDF_RECEIPT_FOOTER_TEXT,
PDF_RECEIPT_TAX_ID=PDF_RECEIPT_TAX_ID,
PDF_RECEIPT_TAX_ID_LABEL=PDF_RECEIPT_TAX_ID_LABEL,
PDF_RECEIPT_TERMS_AND_CONDITIONS=PDF_RECEIPT_TERMS_AND_CONDITIONS,
)
def test_pdf_receipt_file_item_data_pagination(self):
for i in range(2, 50):
self.items_data.append(self.get_item_data(i))
PDFInvoice(
items_data=self.items_data,
item_id=self.item_id,
date=self.date,
is_invoice=self.is_invoice,
total_cost=self.total_cost,
payment_received=self.payment_received,
balance=self.balance
).generate_pdf(self.pdf_buffer)
pdf_content = parse_pages(self.pdf_buffer, 'test_pass')
self.assertTrue(any('Receipt' in s for s in pdf_content))
self.assertTrue(any('Page 3 of 3' in s for s in pdf_content))
def test_pdf_receipt_file_totals_pagination(self):
for i in range(2, 48):
self.items_data.append(self.get_item_data(i))
PDFInvoice(
items_data=self.items_data,
item_id=self.item_id,
date=self.date,
is_invoice=self.is_invoice,
total_cost=self.total_cost,
payment_received=self.payment_received,
balance=self.balance
).generate_pdf(self.pdf_buffer)
pdf_content = parse_pages(self.pdf_buffer, 'test_pass')
self.assertTrue(any('Receipt' in s for s in pdf_content))
self.assertTrue(any('Page 3 of 3' in s for s in pdf_content))
@override_settings(PDF_RECEIPT_LOGO_PATH='wrong path')
def test_invalid_image_path(self):
PDFInvoice(
items_data=self.items_data,
item_id=self.item_id,
date=self.date,
is_invoice=self.is_invoice,
total_cost=self.total_cost,
payment_received=self.payment_received,
balance=self.balance
).generate_pdf(self.pdf_buffer)
pdf_content = parse_pages(self.pdf_buffer, 'test_pass')
self.assertTrue(any('Receipt' in s for s in pdf_content))
def test_pdf_receipt_file_footer_pagination(self):
for i in range(2, 44):
self.items_data.append(self.get_item_data(i))
PDFInvoice(
items_data=self.items_data,
item_id=self.item_id,
date=self.date,
is_invoice=self.is_invoice,
total_cost=self.total_cost,
payment_received=self.payment_received,
balance=self.balance
).generate_pdf(self.pdf_buffer)
pdf_content = parse_pages(self.pdf_buffer, 'test_pass')
self.assertTrue(any('Receipt' in s for s in pdf_content))
@override_settings(
PDF_RECEIPT_DISCLAIMER_TEXT=PDF_RECEIPT_DISCLAIMER_TEXT,
PDF_RECEIPT_BILLING_ADDRESS=PDF_RECEIPT_BILLING_ADDRESS,
PDF_RECEIPT_FOOTER_TEXT=PDF_RECEIPT_FOOTER_TEXT,
PDF_RECEIPT_TAX_ID=PDF_RECEIPT_TAX_ID,
PDF_RECEIPT_TAX_ID_LABEL=PDF_RECEIPT_TAX_ID_LABEL,
PDF_RECEIPT_TERMS_AND_CONDITIONS=PDF_RECEIPT_TERMS_AND_CONDITIONS,
)
def test_pdf_invoice_with_settings_from_patch(self):
self.is_invoice = True
PDFInvoice(
items_data=self.items_data,
item_id=self.item_id,
date=self.date,
is_invoice=self.is_invoice,
total_cost=self.total_cost,
payment_received=self.payment_received,
balance=self.balance
).generate_pdf(self.pdf_buffer)
pdf_content = parse_pages(self.pdf_buffer, 'test_pass')
self.assertTrue(any('46-0807740' in s for s in pdf_content))
self.assertTrue(any('Invoice' in s for s in pdf_content))
self.assertTrue(any(str(self.total_cost) in s for s in pdf_content))
self.assertTrue(any(str(self.payment_received) in s for s in pdf_content))
self.assertTrue(any(str(self.balance) in s for s in pdf_content))
self.assertTrue(any('edX Tax ID' in s for s in pdf_content))
self.assertTrue(any(
'Enrollments:\nEnrollments must be completed within 7 full'
' days from the course start date.\nPayment Terms:\nPayment'
' is due immediately.' in s for s in pdf_content))
def test_pdf_invoice_with_default_settings(self):
self.is_invoice = True
PDFInvoice(
items_data=self.items_data,
item_id=self.item_id,
date=self.date,
is_invoice=self.is_invoice,
total_cost=self.total_cost,
payment_received=self.payment_received,
balance=self.balance
).generate_pdf(self.pdf_buffer)
pdf_content = parse_pages(self.pdf_buffer, 'test_pass')
self.assertTrue(any(settings.PDF_RECEIPT_TAX_ID in s for s in pdf_content))
self.assertTrue(any('Invoice' in s for s in pdf_content))
self.assertTrue(any(settings.PDF_RECEIPT_TERMS_AND_CONDITIONS in s for s in pdf_content))
......@@ -4,6 +4,12 @@ Utility methods for the Shopping Cart app
from django.conf import settings
from microsite_configuration import microsite
from pdfminer.pdfparser import PDFParser
from pdfminer.pdfdocument import PDFDocument
from pdfminer.pdfinterp import PDFResourceManager, PDFPageInterpreter
from pdfminer.converter import PDFPageAggregator
from pdfminer.pdfpage import PDFPage
from pdfminer.layout import LAParams, LTTextBox, LTTextLine, LTFigure
def is_shopping_cart_enabled():
......@@ -22,3 +28,47 @@ def is_shopping_cart_enabled():
)
return (enable_paid_course_registration and enable_shopping_cart)
def parse_pages(pdf_buffer, password):
"""
With an PDF buffer object, get the pages, parse each one, and return the entire pdf text
"""
# Create a PDF parser object associated with the file object.
parser = PDFParser(pdf_buffer)
# Create a PDF document object that stores the document structure.
# Supply the password for initialization.
document = PDFDocument(parser, password)
resource_manager = PDFResourceManager()
la_params = LAParams()
device = PDFPageAggregator(resource_manager, laparams=la_params)
interpreter = PDFPageInterpreter(resource_manager, device)
text_content = [] # a list of strings, each representing text collected from each page of the doc
for page in PDFPage.create_pages(document):
interpreter.process_page(page)
# receive the LTPage object for this page
layout = device.get_result()
# layout is an LTPage object which may contain
# child objects like LTTextBox, LTFigure, LTImage, etc.
text_content.append(parse_lt_objects(layout._objs)) # pylint: disable=protected-access
return text_content
def parse_lt_objects(lt_objects):
"""
Iterate through the list of LT* objects and capture the text data contained in each object
"""
text_content = []
for lt_object in lt_objects:
if isinstance(lt_object, LTTextBox) or isinstance(lt_object, LTTextLine):
# text
text_content.append(lt_object.get_text().encode('utf-8'))
elif isinstance(lt_object, LTFigure):
# LTFigure objects are containers for other LT* objects, so recurse through the children
text_content.append(parse_lt_objects(lt_object._objs)) # pylint: disable=protected-access
return '\n'.join(text_content)
......@@ -478,3 +478,17 @@ INVOICE_PAYMENT_INSTRUCTIONS = ENV_TOKENS.get('INVOICE_PAYMENT_INSTRUCTIONS', IN
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT)
# PDF RECEIPT/INVOICE OVERRIDES
PDF_RECEIPT_TAX_ID = ENV_TOKENS.get('PDF_RECEIPT_TAX_ID', PDF_RECEIPT_TAX_ID)
PDF_RECEIPT_FOOTER_TEXT = ENV_TOKENS.get('PDF_RECEIPT_FOOTER_TEXT', PDF_RECEIPT_FOOTER_TEXT)
PDF_RECEIPT_DISCLAIMER_TEXT = ENV_TOKENS.get('PDF_RECEIPT_DISCLAIMER_TEXT', PDF_RECEIPT_DISCLAIMER_TEXT)
PDF_RECEIPT_BILLING_ADDRESS = ENV_TOKENS.get('PDF_RECEIPT_BILLING_ADDRESS', PDF_RECEIPT_BILLING_ADDRESS)
PDF_RECEIPT_TERMS_AND_CONDITIONS = ENV_TOKENS.get('PDF_RECEIPT_TERMS_AND_CONDITIONS', PDF_RECEIPT_TERMS_AND_CONDITIONS)
PDF_RECEIPT_TAX_ID_LABEL = ENV_TOKENS.get('PDF_RECEIPT_TAX_ID_LABEL', PDF_RECEIPT_TAX_ID_LABEL)
PDF_RECEIPT_LOGO_PATH = ENV_TOKENS.get('PDF_RECEIPT_LOGO_PATH', PDF_RECEIPT_LOGO_PATH)
PDF_RECEIPT_COBRAND_LOGO_PATH = ENV_TOKENS.get('PDF_RECEIPT_COBRAND_LOGO_PATH', PDF_RECEIPT_COBRAND_LOGO_PATH)
PDF_RECEIPT_LOGO_HEIGHT_MM = ENV_TOKENS.get('PDF_RECEIPT_LOGO_HEIGHT_MM', PDF_RECEIPT_LOGO_HEIGHT_MM)
PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = ENV_TOKENS.get(
'PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM', PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM
)
......@@ -1970,3 +1970,17 @@ API_DATE_FORMAT = '%Y-%m-%d'
# for Student Notes we would like to avoid too frequent token refreshes (default is 30 seconds)
if FEATURES['ENABLE_EDXNOTES']:
OAUTH_ID_TOKEN_EXPIRATION = 60 * 60
# Configuration used for generating PDF Receipts/Invoices
PDF_RECEIPT_TAX_ID = 'add here'
PDF_RECEIPT_FOOTER_TEXT = 'add your own specific footer text here'
PDF_RECEIPT_DISCLAIMER_TEXT = 'add your own specific disclaimer text here'
PDF_RECEIPT_BILLING_ADDRESS = 'add your own billing address here with appropriate line feed characters'
PDF_RECEIPT_TERMS_AND_CONDITIONS = 'add your own terms and conditions'
PDF_RECEIPT_TAX_ID_LABEL = 'Tax ID'
PDF_RECEIPT_LOGO_PATH = PROJECT_ROOT + '/static/images/openedx-logo-tag.png'
# Height of the Logo in mm
PDF_RECEIPT_LOGO_HEIGHT_MM = 12
PDF_RECEIPT_COBRAND_LOGO_PATH = PROJECT_ROOT + '/static/images/default-theme/logo.png'
# Height of the Co-brand Logo in mm
PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = 12
......@@ -88,6 +88,12 @@ django-ratelimit-backend==0.6
unicodecsv==0.9.4
django-require==1.0.6
# Used for shopping cart's pdf invoice/receipt generation
reportlab==3.1.44
# Used for extracting/parsing pdf text
pdfminer==20140328
# Used for development operation
watchdog==0.7.1
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment