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)
......@@ -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'
......
......@@ -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