From bddaf359f1d9b8bf1ea717fe7da10cf8289b7b91 Mon Sep 17 00:00:00 2001 From: Alexis de Lattre Date: Thu, 16 Jan 2020 00:35:48 +0100 Subject: [PATCH] Release 1.8: new tool bin/facturx-webservice (uses Flask) The new tool facturx-webservice implements a REST webservice using Flask to generate a Factur-X PDF invoice via a simple POST request. Works on python3 only. New argument 'attachments' for generate_facturx_from_file() which replaces argument additional_attachments: - Possibility to set a filename for the attachment different from filename of the filepath - Possibility to set creation dates for attachments - Update script facturx-pdfgen to use the new attachments argument --- LICENSE | 2 +- README.rst | 9 ++++ bin/facturx-pdfgen | 18 +++---- bin/facturx-webservice | 119 +++++++++++++++++++++++++++++++++++++++++ facturx/_version.py | 2 +- facturx/facturx.py | 82 ++++++++++++++++++++-------- setup.py | 4 +- 7 files changed, 201 insertions(+), 35 deletions(-) create mode 100755 bin/facturx-webservice diff --git a/LICENSE b/LICENSE index 5671a12..802a8e4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2016-2017, Alexis de Lattre +Copyright (c) 2016-2020, Alexis de Lattre All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.rst b/README.rst index 39d485b..4967c18 100644 --- a/README.rst +++ b/README.rst @@ -49,6 +49,7 @@ Several command line tools are provided with this lib: * **facturx-pdfgen**: generate a Factur-X PDF invoice from a regular PDF invoice and an XML file * **facturx-pdfextractxml**: extract the Factur-X XML file from a Factur-X PDF invoice * **facturx-xmlcheck**: check a Factur-X XML file against the official Factur-X XML Schema Definition +* **facturx-webservice**: implements a REST webservice to generate a Factur-X PDF invoice from a regular PDF and an XML file (python3 only, requires Flask) All these commande line tools have a **-h** option that explains how to use them and shows all the available options. @@ -65,6 +66,14 @@ Contributors Changelog ========= +* Version 1.8 dated 2020-01-16 + + * New tool facturx-webservice which implements a REST webservice using Flask to generate a Factur-X PDF invoice via a simple POST request. + * New argument 'attachments' for generate_facturx_from_file() which replaces argument additional_attachments: + * Possibility to set a filename for the attachment different from filename of the filepath + * Possibility to set creation dates for attachments + * Update script facturx-pdfgen to use the new attachments argument + * Version 1.7 dated 2020-01-13 * Fix bug in release 1.6 in XMP: variables were not replaced by their real value diff --git a/bin/facturx-pdfgen b/bin/facturx-pdfgen index 80e3975..1f47e18 100755 --- a/bin/facturx-pdfgen +++ b/bin/facturx-pdfgen @@ -1,17 +1,17 @@ #! /usr/bin/python # -*- coding: utf-8 -*- -# Copyright 2017-2019 Alexis de Lattre +# Copyright 2017-2020 Alexis de Lattre from optparse import OptionParser import sys from facturx import generate_facturx_from_file from facturx.facturx import logger import logging -from os.path import isfile, isdir +from os.path import isfile, isdir, basename __author__ = "Alexis de Lattre " -__date__ = "May 2018" -__version__ = "0.4" +__date__ = "January 2020" +__version__ = "0.5" options = [ {'names': ('-l', '--log-level'), 'dest': 'log_level', @@ -114,18 +114,18 @@ def main(options, arguments): logger.error( 'File %s already exists. Exit.', facturx_pdf_filename) sys.exit(1) - additional_attachments = {} + attachments = {} for additional_attachment_filename in additional_attachment_filenames: - additional_attachments[additional_attachment_filename] = '' # desc + attachments[basename(additional_attachment_filename)] = { + 'filepath': additional_attachment_filename} try: # The important line of code is below ! generate_facturx_from_file( pdf_filename, xml_file, check_xsd=check_xsd, facturx_level=options.facturx_level, pdf_metadata=pdf_metadata, - output_pdf_file=facturx_pdf_filename, - additional_attachments=additional_attachments) + output_pdf_file=facturx_pdf_filename, attachments=attachments) except Exception as e: - logger.error(e) + logger.error('Factur-x lib call failed. Error: %s', e) sys.exit(1) if __name__ == '__main__': diff --git a/bin/facturx-webservice b/bin/facturx-webservice new file mode 100755 index 0000000..2dcb185 --- /dev/null +++ b/bin/facturx-webservice @@ -0,0 +1,119 @@ +#!/usr/bin/python3 +# Copyright 2020, Alexis de Lattre +# Published under the BSD licence +# REST webservice to generated Factur-X invoice +# Sample client request: +# curl -X POST -F 'pdf=@/home/alexis/invoice_test.pdf' +# -F 'xml=@/home/alexis/factur-x.xml' -o result_facturx.pdf +# http://localhost:5000/generate_facturx + +from flask import Flask, request, send_file +from tempfile import NamedTemporaryFile +from facturx import generate_facturx_from_file +from facturx.facturx import logger as fxlogger +from optparse import OptionParser +import logging +from logging.handlers import RotatingFileHandler + +MAX_ATTACHMENTS = 3 # TODO make it a cmd line option +app = Flask(__name__) + + +@app.route('/generate_facturx', methods=['POST']) +def generate_facturx(): + app.logger.debug('request.files=%s', request.files) + attachments = {} + for i in range(MAX_ATTACHMENTS): + attach_key = 'attachment%d' % (i + 1) + if request.files.get(attach_key): + with NamedTemporaryFile(prefix='fx-api-attach-') as attach_file: + request.files[attach_key].save(attach_file.name) + attach_file.seek(0) + attachments[request.files[attach_key].filename] = { + 'filedata': attach_file.read(), + } + attach_file.close() + + with NamedTemporaryFile(prefix='fx-api-xml-', suffix='.xml') as xml_file: + request.files['xml'].save(xml_file.name) + app.logger.debug('xml_file.name=%s', xml_file.name) + xml_file.seek(0) + xml_byte = xml_file.read() + xml_file.close() + res = '' + with NamedTemporaryFile(prefix='fx-api-pdf-', suffix='.pdf') as pdf_file: + with NamedTemporaryFile( + prefix='fx-api-outpdf-', suffix='.pdf') as output_pdf_file: + request.files['pdf'].save(pdf_file.name) + app.logger.debug('pdf_file.name=%s', pdf_file.name) + app.logger.debug('output_pdf_file.name=%s', output_pdf_file.name) + app.logger.debug('attachments keys=%s', attachments.keys()) + generate_facturx_from_file( + pdf_file, xml_byte, output_pdf_file=output_pdf_file.name, + attachments=attachments) + output_pdf_file.seek(0) + res = send_file(output_pdf_file.name, as_attachment=True) + app.logger.info( + 'Factur-X invoice successfully returned by webservice') + output_pdf_file.close() + pdf_file.close() + return res + + +if __name__ == '__main__': + usage = "Usage: facturx_webservice.py [options]" + epilog = "Script written by Alexis de Lattre. "\ + "Published under the BSD licence." + description = "This is a Flask application that exposes a REST "\ + "webservice to generate a Factur-X invoice from a PDF file and an "\ + "XML file." + options_def = [ + {'names': ('-s', '--host'), 'dest': 'host', 'type': 'string', + 'action': 'store', 'default': '127.0.0.1', + 'help': "The hostname to listen on. Defaults to '127.0.0.1': " + "the webservice will only accept connexions from localhost. Use " + "'0.0.0.0' to have the webservice available from a remote host (but " + "it is recommended to listen on localhost and use an HTTPS proxy to " + "listen to remote connexions)."}, + {'names': ('-p', '--port'), 'dest': 'port', 'type': 'int', + 'action': 'store', 'default': 5000, + 'help': "Port on which the webservice listens. You can select " + "any port between 1024 and 65535. Default port is 5000."}, + {'names': ('-d', '--debug'), 'dest': 'debug', + 'action': 'store_true', 'default': False, + 'help': "Enable debug mode."}, + {'names': ('-l', '--logfile'), 'dest': 'logfile', 'type': 'string', + 'action': 'store', 'default': False, + 'help': "Logs to a file instead of stdout."}, + {'names': ('-n', '--loglevel'), 'dest': 'loglevel', 'type': 'string', + 'action': 'store', 'default': 'info', + 'help': "Log level. Possible values: critical, error, warning, " + "info (default), debug."}, + ] + parser = OptionParser(usage=usage, epilog=epilog, description=description) + for option in options_def: + param = option['names'] + del option['names'] + parser.add_option(*param, **option) + options, arguments = parser.parse_args() + if options.logfile: + formatter = logging.Formatter( + "[%(asctime)s] %(levelname)s %(message)s") + handler = RotatingFileHandler(options.logfile) + if options.loglevel == 'debug': + level = logging.DEBUG + elif options.loglevel == 'critical': + level = logging.CRITICAL + elif options.loglevel == 'warning': + level = logging.WARNING + elif options.loglevel == 'error': + level = logging.ERROR + else: + level = logging.INFO + handler.setLevel(level) + handler.setFormatter(formatter) + fxlogger.setLevel(level) + fxlogger.addHandler(handler) + app.logger.addHandler(handler) + app.logger.info('Start webservice to generate Factur-X invoices') + app.run(debug=options.debug, port=options.port, host=options.host) diff --git a/facturx/_version.py b/facturx/_version.py index 218f431..03de3f6 100644 --- a/facturx/_version.py +++ b/facturx/_version.py @@ -1 +1 @@ -__version__ = '1.7' +__version__ = '1.8' diff --git a/facturx/facturx.py b/facturx/facturx.py index fb037cd..437d935 100644 --- a/facturx/facturx.py +++ b/facturx/facturx.py @@ -436,19 +436,23 @@ def _prepare_pdf_metadata_xml(facturx_level, pdf_metadata): def _filespec_additional_attachments( - pdf_filestream, name_arrayobj_cdict, file_dict, file_bin): - filename = file_dict['filename'] + pdf_filestream, name_arrayobj_cdict, file_dict, filename): logger.debug('_filespec_additional_attachments filename=%s', filename) - mod_date_pdf = _get_pdf_timestamp(file_dict['mod_date']) - md5sum = hashlib.md5(file_bin).hexdigest() + md5sum = hashlib.md5(file_dict['filedata']).hexdigest() md5sum_obj = createStringObject(md5sum) params_dict = DictionaryObject({ NameObject('/CheckSum'): md5sum_obj, - NameObject('/ModDate'): createStringObject(mod_date_pdf), - NameObject('/Size'): NameObject(str(len(file_bin))), + NameObject('/Size'): NameObject(str(len(file_dict['filedata']))), }) + # creation date and modification date are optional + if isinstance(file_dict.get('modification_datetime'), datetime): + mod_date_pdf = _get_pdf_timestamp(file_dict['modification_datetime']) + params_dict[NameObject('/ModDate')] = createStringObject(mod_date_pdf) + if isinstance(file_dict.get('creation_datetime'), datetime): + creation_date_pdf = _get_pdf_timestamp(file_dict['creation_datetime']) + params_dict[NameObject('/CreationDate')] = createStringObject(creation_date_pdf) file_entry = DecodedStreamObject() - file_entry.setData(file_bin) + file_entry.setData(file_dict['filedata']) file_mimetype = mimetypes.guess_type(filename)[0] if not file_mimetype: file_mimetype = 'application/octet-stream' @@ -465,7 +469,7 @@ def _filespec_additional_attachments( fname_obj = createStringObject(filename) filespec_dict = DictionaryObject({ NameObject("/AFRelationship"): NameObject("/Unspecified"), - NameObject("/Desc"): createStringObject(file_dict.get('desc', '')), + NameObject("/Desc"): createStringObject(file_dict.get('description', '')), NameObject("/Type"): NameObject("/Filespec"), NameObject("/F"): fname_obj, NameObject("/EF"): ef_dict, @@ -515,9 +519,9 @@ def _facturx_update_metadata_add_attachment( }) filespec_obj = pdf_filestream._addObject(filespec_dict) name_arrayobj_cdict = {fname_obj: filespec_obj} - for attach_bin, attach_dict in additional_attachments.items(): + for attach_filename, attach_dict in additional_attachments.items(): _filespec_additional_attachments( - pdf_filestream, name_arrayobj_cdict, attach_dict, attach_bin) + pdf_filestream, name_arrayobj_cdict, attach_dict, attach_filename) logger.debug('name_arrayobj_cdict=%s', name_arrayobj_cdict) name_arrayobj_content_sort = list( sorted(name_arrayobj_cdict.items(), key=lambda x: x[0])) @@ -734,7 +738,7 @@ def generate_facturx_from_binary( def generate_facturx_from_file( pdf_invoice, facturx_xml, facturx_level='autodetect', check_xsd=True, pdf_metadata=None, output_pdf_file=None, - additional_attachments=None): + additional_attachments=None, attachments=None): """ Generate a Factur-X invoice from a regular PDF invoice and a factur-X XML file. The method uses a file as input (regular PDF invoice) and re-writes @@ -771,10 +775,16 @@ def generate_facturx_from_file( :type pdf_metadata: dict :param output_pdf_file: File Path to the output Factur-X PDF file :type output_pdf_file: string or unicode - :param additional_attachments: Specify the other files that you want to - embed in the PDF file. It is a dict where keys are filepath and value - is the description of the file (as unicode or string). - :type additional_attachments: dict + :param attachments: Specify the other files that you want to + embed in the PDF file. It is a dict where key is the filename and value + is a dict. In this dict, keys are 'filepath' (value is the full file path) + or 'filedata' (value is the encoded file), + 'description' (text description, optional) and + 'modification_datetime' (modification date and time as datetime object, optional). + 'creation_datetime' (creation date and time as datetime object, optional). + :type attachments: dict + :param additional_attachments: DEPRECATED. Use attachments instead. + Undocumented. :return: Returns True. This method re-writes the input PDF invoice file, unless if the output_pdf_file is provided. :rtype: bool @@ -830,20 +840,47 @@ def generate_facturx_from_file( "The second argument of the method generate_facturx must be " "either a string, an etree.Element() object or a file " "(it is a %s)." % type(facturx_xml)) - additional_attachments_read = {} - if additional_attachments: + # The additional_attachments arg is deprecated + if attachments is None: + attachments = {} + if additional_attachments and not attachments: + logger.warning( + 'The argument additional_attachments is deprecated. ' + 'It will be removed in future versions. Use the argument ' + 'attachments instead.') for attach_filepath, attach_desc in additional_attachments.items(): filename = os.path.basename(attach_filepath) mod_timestamp = os.path.getmtime(attach_filepath) mod_dt = datetime.fromtimestamp(mod_timestamp) with open(attach_filepath, 'rb') as fa: fa.seek(0) - additional_attachments_read[fa.read()] = { - 'filename': filename, - 'desc': attach_desc, - 'mod_date': mod_dt, + attachments[filename] = { + 'filedata': fa.read(), + 'description': attach_desc, + 'modification_datetime': mod_dt, } fa.close() + if attachments: + for filename, fadict in attachments.items(): + if filename in [FACTURX_FILENAME] + ZUGFERD_FILENAMES: + logger.warning( + 'You cannot provide as attachment a file named %s. ' + 'This file will NOT be attached.', filename) + attachments.pop(filename) + continue + if fadict.get('filepath') and not fadict.get('filedata'): + with open(fadict['filepath'], 'rb') as fa: + fa.seek(0) + fadict['filedata'] = fa.read() + fa.close() + + # As explained here + # https://stackoverflow.com/questions/237079/how-to-get-file-creation-modification-date-times-in-python + # creation date is not easy to get. + # So we only implement getting the modification date + if not fadict.get('modification_datetime'): + mod_timestamp = os.path.getmtime(fadict['filepath']) + fadict['modification_datetime'] = datetime.fromtimestamp(mod_timestamp) if pdf_metadata is None: if xml_root is None: xml_root = etree.fromstring(xml_string) @@ -877,8 +914,7 @@ def generate_facturx_from_file( # else : generate some ? _facturx_update_metadata_add_attachment( new_pdf_filestream, xml_string, pdf_metadata, facturx_level, - output_intents=output_intents, - additional_attachments=additional_attachments_read) + output_intents=output_intents, additional_attachments=attachments) if output_pdf_file: with open(output_pdf_file, 'wb') as output_f: new_pdf_filestream.write(output_f) diff --git a/setup.py b/setup.py index e9cc78e..872ba31 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,8 @@ setup( scripts=[ 'bin/facturx-pdfgen', 'bin/facturx-pdfextractxml', - 'bin/facturx-xmlcheck'], + 'bin/facturx-xmlcheck', + 'bin/facturx-webservice', + ], zip_safe=False, )