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:
parent
d5b22cd32f
commit
bddaf359f1
2
LICENSE
2
LICENSE
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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__':
|
||||
|
|
|
@ -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)
|
|
@ -1 +1 @@
|
|||
__version__ = '1.7'
|
||||
__version__ = '1.8'
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue