diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 10789d3743..b67497ec1b 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -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 diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index a2270e17a9..dc0291e9f4 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 4f592bcefb..4ce199aaff 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -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) diff --git a/lms/djangoapps/shoppingcart/pdf.py b/lms/djangoapps/shoppingcart/pdf.py new file mode 100644 index 0000000000..16de5fd0d6 --- /dev/null +++ b/lms/djangoapps/shoppingcart/pdf.py @@ -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", "
"), para_style) + disclaimer_para = Paragraph(self.disclaimer_text.replace("\n", "
"), para_style) + billing_address_para = Paragraph(self.billing_address_text.replace("\n", "
"), 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", "
"), 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) diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py index 34c308f5b3..c92aa698ab 100644 --- a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource2.py @@ -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' diff --git a/lms/djangoapps/shoppingcart/tests/test_pdf.py b/lms/djangoapps/shoppingcart/tests/test_pdf.py new file mode 100644 index 0000000000..99c85e91f9 --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests/test_pdf.py @@ -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)) diff --git a/lms/djangoapps/shoppingcart/utils.py b/lms/djangoapps/shoppingcart/utils.py index de469f29be..9c193066b8 100644 --- a/lms/djangoapps/shoppingcart/utils.py +++ b/lms/djangoapps/shoppingcart/utils.py @@ -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) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index b21b0e9ebe..70257e889c 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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 +) diff --git a/lms/envs/common.py b/lms/envs/common.py index 95bdaa01e4..179a9a82e0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 56140cb670..770f09597b 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -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