From a8ebf6da7fcf1c52a63c4ac1e3a1234d75579e1a Mon Sep 17 00:00:00 2001 From: Afzal Wali Date: Fri, 19 Dec 2014 16:15:34 +0500 Subject: [PATCH] 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 --- lms/djangoapps/instructor/tests/test_api.py | 20 + lms/djangoapps/instructor/views/api.py | 11 + lms/djangoapps/shoppingcart/models.py | 114 ++++- lms/djangoapps/shoppingcart/pdf.py | 468 ++++++++++++++++++ .../processors/tests/test_CyberSource2.py | 24 +- lms/djangoapps/shoppingcart/tests/test_pdf.py | 235 +++++++++ lms/djangoapps/shoppingcart/utils.py | 50 ++ lms/envs/aws.py | 14 + lms/envs/common.py | 14 + requirements/edx/base.txt | 6 + 10 files changed, 945 insertions(+), 11 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/pdf.py create mode 100644 lms/djangoapps/shoppingcart/tests/test_pdf.py 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