Merge pull request #6429 from edx/afzaledx/WL-173
Generate/attach PDF receipts and invoices to shopping cart transactions
This commit is contained in:
@@ -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)
|
||||
|
||||
468
lms/djangoapps/shoppingcart/pdf.py
Normal file
468
lms/djangoapps/shoppingcart/pdf.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""
|
||||
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)
|
||||
@@ -118,11 +118,27 @@ class CyberSource2Test(TestCase):
|
||||
# Check the signature
|
||||
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'
|
||||
|
||||
235
lms/djangoapps/shoppingcart/tests/test_pdf.py
Normal file
235
lms/djangoapps/shoppingcart/tests/test_pdf.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user