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
This commit is contained in:
Alexis de Lattre 2020-01-16 00:35:48 +01:00
parent d5b22cd32f
commit bddaf359f1
7 changed files with 201 additions and 35 deletions

View File

@ -1,4 +1,4 @@
Copyright (c) 2016-2017, Alexis de Lattre <alexis.delattre@akretion.com>
Copyright (c) 2016-2020, Alexis de Lattre <alexis.delattre@akretion.com>
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@ -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

View File

@ -1,17 +1,17 @@
#! /usr/bin/python
# -*- coding: utf-8 -*-
# Copyright 2017-2019 Alexis de Lattre <alexis.delattre@akretion.com>
# Copyright 2017-2020 Alexis de Lattre <alexis.delattre@akretion.com>
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 <alexis.delattre@akretion.com>"
__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__':

119
bin/facturx-webservice Executable file
View File

@ -0,0 +1,119 @@
#!/usr/bin/python3
# Copyright 2020, Alexis de Lattre <alexis.delattre@akretion.com>
# 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)

View File

@ -1 +1 @@
__version__ = '1.7'
__version__ = '1.8'

View File

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

View File

@ -39,6 +39,8 @@ setup(
scripts=[
'bin/facturx-pdfgen',
'bin/facturx-pdfextractxml',
'bin/facturx-xmlcheck'],
'bin/facturx-xmlcheck',
'bin/facturx-webservice',
],
zip_safe=False,
)