diff --git a/README.md b/README.md index 2a3a77f5..12be9bac 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ Install system dependencies (method depends on OS): - libjpeg (for pdf generation) - Debian: libjpeg-dev - FreeBSD: graphics/jpeg-turbo +- wkhtmltopdf (also for pdf generation): + - Debian: wkhtmltopdf + - FreeBSD: converters/wkhtmltopdf ### Python packages Install pip packages: diff --git a/src/bornhack/environment_settings.py.dist b/src/bornhack/environment_settings.py.dist index 6a235013..e307c18f 100644 --- a/src/bornhack/environment_settings.py.dist +++ b/src/bornhack/environment_settings.py.dist @@ -47,6 +47,7 @@ COINIFY_API_SECRET='{{ coinify_api_secret }}' COINIFY_IPN_SECRET='{{ coinify_ipn_secret }}' # shop settings +PDF_LETTERHEAD_FILENAME='bornhack-2017_test_letterhead.pdf' BANKACCOUNT_IBAN='{{ iban }}' BANKACCOUNT_SWIFTBIC='{{ swiftbic }}' BANKACCOUNT_REG='{{ regno }}' diff --git a/src/camps/models.py b/src/camps/models.py index 74d3d37f..055fa963 100644 --- a/src/camps/models.py +++ b/src/camps/models.py @@ -11,7 +11,6 @@ import logging logger = logging.getLogger("bornhack.%s" % __name__) - class Camp(CreatedUpdatedModel, UUIDModel): class Meta: verbose_name = 'Camp' diff --git a/src/shop/invoiceworker.py b/src/shop/invoiceworker.py index 8d60db13..8b1227a8 100644 --- a/src/shop/invoiceworker.py +++ b/src/shop/invoiceworker.py @@ -1,36 +1,40 @@ from django.core.files import File -from django.conf import settings from django.utils import timezone from shop.pdf import generate_pdf_letter from shop.email import send_invoice_email, send_creditnote_email from shop.models import Order, CustomOrder, Invoice, CreditNote from decimal import Decimal -import logging +import logging, importlib +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger('bornhack.%s' % __name__) -def run_invoice_worker(): - logging.basicConfig(level=logging.INFO) - logger = logging.getLogger(__name__) +def do_work(): + """ + The invoice worker creates Invoice objects for shop orders and + for custom orders. It also generates PDF files for Invoice objects + that have no PDF. It also emails invoices for shop orders. + """ + ############################################################### # check if we need to generate any invoices for shop orders for order in Order.objects.filter(paid=True, invoice__isnull=True): # generate invoice for this Order Invoice.objects.create(order=order) logger.info('Generated Invoice object for %s' % order) + + ############################################################### # check if we need to generate any invoices for custom orders for customorder in CustomOrder.objects.filter(invoice__isnull=True): # generate invoice for this CustomOrder Invoice.objects.create(customorder=customorder) logger.info('Generated Invoice object for %s' % customorder) + + ############################################################### # check if we need to generate any pdf invoices for invoice in Invoice.objects.filter(pdf=''): - # put the dict with data for the pdf together - formatdict = { - 'invoice': invoice, - } - # generate the pdf try: if invoice.customorder: @@ -40,26 +44,63 @@ def run_invoice_worker(): pdffile = generate_pdf_letter( filename=invoice.filename, template=template, - formatdict=formatdict, + formatdict={ + 'invoice': invoice, + }, ) logger.info('Generated pdf for invoice %s' % invoice) except Exception as E: - logger.error('ERROR: Unable to generate PDF file for invoice #%s. Error: %s' % (invoice.pk, E)) - continue - - # so, do we have a pdf? - if not pdffile: - logger.error('ERROR: Unable to generate PDF file for invoice #%s' % invoice.pk) + logger.exception('Unable to generate PDF file for invoice #%s. Error: %s' % (invoice.pk, E)) continue # update invoice object with the file invoice.pdf.save(invoice.filename, File(pdffile)) invoice.save() + ############################################################### # check if we need to send out any invoices (only for shop orders, and only where pdf has been generated) for invoice in Invoice.objects.filter(order__isnull=False, sent_to_customer=False).exclude(pdf=''): + logger.info("found unmailed Invoice object: %s" % invoice) # send the email if send_invoice_email(invoice=invoice): + invoice.sent_to_customer=True + invoice.save() logger.info('OK: Invoice email sent to %s' % invoice.order.user.email) + else: + logger.error('Unable to send invoice email for order %s to %s' % (invoice.order.pk, invoice.order.user.email)) + + + ############################################################### + # check if we need to generate any pdf creditnotes? + for creditnote in CreditNote.objects.filter(pdf=''): + # generate the pdf + try: + pdffile = generate_pdf_letter( + filename=creditnote.filename, + template='pdf/creditnote.html', + formatdict={ + 'creditnote': creditnote, + }, + ) + logger.info('Generated pdf for creditnote %s' % creditnote) + except Exception as E: + logger.exception('Unable to generate PDF file for creditnote #%s. Error: %s' % (creditnote.pk, E)) + continue + + # update creditnote object with the file + creditnote.pdf.save(creditnote.filename, File(pdffile)) + creditnote.save() + + + ############################################################### + # check if we need to send out any creditnotes (only where pdf has been generated) + for creditnote in CreditNote.objects.filter(sent_to_customer=False).exclude(pdf=''): + # send the email + if send_creditnote_email(creditnote=creditnote): + logger.info('OK: Creditnote email sent to %s' % creditnote.user.email) + creditnote.sent_to_customer=True + creditnote.save() + else: + logger.error('Unable to send creditnote email for creditnote %s to %s' % (creditnote.pk, creditnote.user.email)) diff --git a/src/shop/management/commands/invoice-worker.py b/src/shop/management/commands/invoice-worker.py deleted file mode 100644 index 7ff6d6a6..00000000 --- a/src/shop/management/commands/invoice-worker.py +++ /dev/null @@ -1,46 +0,0 @@ -from django.core.management.base import BaseCommand -from shop import invoiceworker -from time import sleep -import signal, sys -import logging - - -class Command(BaseCommand): - args = 'none' - help = 'Generate invoices and credit notes, and email invoices that have not been sent yet' - exit = False - - def __init__(self): - logging.basicConfig(level=logging.INFO) - self.logger = logging.getLogger('bornhack.%s' % __name__) - - def reload_worker_code(self, signum, frame): - self.logger.info("Reloading shop.invoiceworker module...") - reload(invoiceworker) - self.logger.info("Done reloading shop.invoiceworker module") - - def clean_exit(self, signum, frame): - self.logger.info("SIGTERM received, exiting gracefully soon...") - self.exit = True - - def handle(self, *args, **options): - self.logger.info("Connecting signals...") - signal.signal(signal.SIGHUP, self.reload_worker_code) - signal.signal(signal.SIGTERM, self.clean_exit) - signal.signal(signal.SIGINT, self.clean_exit) - - self.logger.info("Entering main loop...") - while True: - # run invoiceworker - invoiceworker.run_invoice_worker() - - # sleep for 60 seconds, but check sys.exit every second - i = 0 - while i < 60: - if self.exit: - self.logger.info("Graceful exit requested, calling sys.exit(0) now") - sys.exit(0) - else: - i += 1 - sleep(1) - diff --git a/src/shop/pdf.py b/src/shop/pdf.py index 0e152f18..ebad4b6d 100644 --- a/src/shop/pdf.py +++ b/src/shop/pdf.py @@ -1,16 +1,21 @@ +from django.contrib.auth.models import AnonymousUser from wkhtmltopdf.views import PDFTemplateResponse from PyPDF2 import PdfFileWriter, PdfFileReader from django.test.client import RequestFactory from django.conf import settings -import io -import logging +import io, logging logger = logging.getLogger("bornhack.%s" % __name__) def generate_pdf_letter(filename, template, formatdict): + # conjure up a fake request for PDFTemplateResponse + request = RequestFactory().get('/') + request.user = AnonymousUser() + request.session = {} + ### produce text-only PDF from template pdfgenerator = PDFTemplateResponse( - request=RequestFactory().get('/'), + request=request, template=template, context=formatdict, cmd_options={ @@ -18,7 +23,7 @@ def generate_pdf_letter(filename, template, formatdict): 'margin-bottom': 50, }, ) - textonlypdf = io.StringIO() + textonlypdf = io.BytesIO() textonlypdf.write(pdfgenerator.rendered_content) ### create a blank pdf to work with @@ -28,7 +33,7 @@ def generate_pdf_letter(filename, template, formatdict): pdfreader = PdfFileReader(textonlypdf) ### get watermark from watermark file - watermark = PdfFileReader(open(settings.LETTERHEAD_PDF_PATH, 'rb')) + watermark = PdfFileReader(open("%s/pdf/%s" % (settings.STATICFILES_DIRS[0], settings.PDF_LETTERHEAD_FILENAME), 'rb')) ### add the watermark to all pages for pagenum in range(pdfreader.getNumPages()): @@ -42,12 +47,13 @@ def generate_pdf_letter(filename, template, formatdict): finalpdf.addPage(page) ### save the generated pdf to the archive - with open(settings.PDF_ARCHIVE_PATH+filename, 'wb') as fh: + fullpath = settings.PDF_ARCHIVE_PATH+filename + with open(fullpath, 'wb') as fh: finalpdf.write(fh) - logger.info('Saved pdf to archive: %s' % settings.PDF_ARCHIVE_PATH+filename) + logger.info('Saved pdf to archive: %s' % fullpath) ### return a file object with the data - returnfile = io.StringIO() + returnfile = io.BytesIO() finalpdf.write(returnfile) return returnfile diff --git a/src/shop/templates/pdf/invoice.html b/src/shop/templates/pdf/invoice.html index 2f7e2de9..65515620 100644 --- a/src/shop/templates/pdf/invoice.html +++ b/src/shop/templates/pdf/invoice.html @@ -23,13 +23,16 @@ Name Quantity - + Price - + Total + +  + {% for order_product in invoice.order.orderproductrelation_set.all %} - + {{ order_product.product.name }} {{ order_product.quantity }} @@ -39,19 +42,24 @@ {{ order_product.total|currency }} {% endfor %} +  + - Danish VAT (25%) + Included Danish VAT (25%) {{ invoice.order.vat|currency }} - Total + Invoice Total {{ invoice.order.total|currency }} + +   +

The order has been paid in full.

@@ -66,6 +74,6 @@

Note: Danish law grants everyone a 14 days 'cooling-off' period for internet purchases, in which the customer can regret the purchase for any reason (including just changing your mind). In case you regret this purchase -please contact us on info@bornhack.dk for a full refund. This is possible until +please contact us on info@bornhack.dk for a full refund. This is possible until {{ invoice.regretdate|date:"b jS, Y" }}. Please see our General Terms & Conditions on our website for more information on order cancellation.

diff --git a/src/shop/templates/product_detail.html b/src/shop/templates/product_detail.html index 99301e27..9274905f 100644 --- a/src/shop/templates/product_detail.html +++ b/src/shop/templates/product_detail.html @@ -21,7 +21,7 @@

Price
{{ product.price|currency }}
- ~{{ product.price|approxeur }} + (~{{ product.price|approxeur }})


diff --git a/src/static_src/pdf/bornhack-2016_letterhead.odt b/src/static_src/pdf/bornhack-2016_letterhead.odt new file mode 100644 index 00000000..2daa0d59 Binary files /dev/null and b/src/static_src/pdf/bornhack-2016_letterhead.odt differ diff --git a/src/static_src/pdf/bornhack_2016_letterhead.pdf b/src/static_src/pdf/bornhack-2016_letterhead.pdf similarity index 100% rename from src/static_src/pdf/bornhack_2016_letterhead.pdf rename to src/static_src/pdf/bornhack-2016_letterhead.pdf diff --git a/src/static_src/pdf/bornhack_2016_test_letterhead.odt b/src/static_src/pdf/bornhack-2016_test_letterhead.odt similarity index 100% rename from src/static_src/pdf/bornhack_2016_test_letterhead.odt rename to src/static_src/pdf/bornhack-2016_test_letterhead.odt diff --git a/src/static_src/pdf/bornhack_2016_test_letterhead.pdf b/src/static_src/pdf/bornhack-2016_test_letterhead.pdf similarity index 100% rename from src/static_src/pdf/bornhack_2016_test_letterhead.pdf rename to src/static_src/pdf/bornhack-2016_test_letterhead.pdf diff --git a/src/static_src/pdf/bornhack-2017_letterhead.odt b/src/static_src/pdf/bornhack-2017_letterhead.odt new file mode 100644 index 00000000..884ae00d Binary files /dev/null and b/src/static_src/pdf/bornhack-2017_letterhead.odt differ diff --git a/src/static_src/pdf/bornhack-2017_letterhead.pdf b/src/static_src/pdf/bornhack-2017_letterhead.pdf new file mode 100644 index 00000000..ba90a0c7 Binary files /dev/null and b/src/static_src/pdf/bornhack-2017_letterhead.pdf differ diff --git a/src/static_src/pdf/bornhack-2017_test_letterhead.odt b/src/static_src/pdf/bornhack-2017_test_letterhead.odt new file mode 100644 index 00000000..9c7f9c6f Binary files /dev/null and b/src/static_src/pdf/bornhack-2017_test_letterhead.odt differ diff --git a/src/static_src/pdf/bornhack-2017_test_letterhead.pdf b/src/static_src/pdf/bornhack-2017_test_letterhead.pdf new file mode 100644 index 00000000..ecea8b5f Binary files /dev/null and b/src/static_src/pdf/bornhack-2017_test_letterhead.pdf differ diff --git a/src/static_src/pdf/bornhack_2016_letterhead.odt b/src/static_src/pdf/bornhack_2016_letterhead.odt deleted file mode 100644 index eedeace4..00000000 Binary files a/src/static_src/pdf/bornhack_2016_letterhead.odt and /dev/null differ diff --git a/src/utils/management/commands/runworker.py b/src/utils/management/commands/runworker.py new file mode 100644 index 00000000..3dc784a0 --- /dev/null +++ b/src/utils/management/commands/runworker.py @@ -0,0 +1,68 @@ +from django.core.management.base import BaseCommand +from time import sleep +import signal, sys +import logging, importlib + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger('bornhack.%s' % __name__) + + +class Command(BaseCommand): + args = 'none' + help = 'Run a worker. Takes the worker module as the first positional argument and calls the do_work() function in it. Optional argumends can be seen with -h / --help' + exit_now = False + + + def add_arguments(self, parser): + parser.add_argument( + 'workermodule', + type=str, + help='The dotted path to the module which contains the do_work() function to call periodically.' + ) + parser.add_argument( + '--sleep', + type=int, + default=60, + help='The number of seconds to sleep between calls' + ) + + def reload_worker(self, signum, frame): + # we exit when we receive a HUP (expecting uwsgi or another supervisor to restart this worker) + # this is more reliable than using importlib.reload to reload the workermodule code, since that + # doesn't reload imports inside the worker module. + logger.info("Signal %s (SIGHUP) received, exiting gracefully..." % signum) + self.exit_now = True + + + def clean_exit(self, signum, frame): + logger.info("Signal %s (INT or TERM) received, exiting gracefully..." % signum) + self.exit_now = True + + + def handle(self, *args, **options): + logger.info("Importing worker module...") + self.workermodule = importlib.import_module(options['workermodule']) + if not hasattr(self.workermodule, 'do_work'): + logger.error("module %s must have a do_work() method to call") + sys.exit(1) + + logger.info("Connecting signals...") + signal.signal(signal.SIGHUP, self.reload_worker) + signal.signal(signal.SIGTERM, self.clean_exit) + signal.signal(signal.SIGINT, self.clean_exit) + + logger.info("Entering main loop...") + while True: + # run worker code + getattr(self.workermodule, 'do_work')() + + # sleep for N seconds before calling worker code again + i = 0 + while i < options['sleep']: + # but check self.exit_now every second + if self.exit_now: + sys.exit(0) + else: + i += 1 + sleep(1) +