first commit
This commit is contained in:
376
modules/document_incoming/document.py
Normal file
376
modules/document_incoming/document.py
Normal file
@@ -0,0 +1,376 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
|
||||
import mimetypes
|
||||
from collections import defaultdict
|
||||
from io import BytesIO
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
import trytond.config as config
|
||||
from trytond.i18n import gettext
|
||||
from trytond.model import (
|
||||
DeactivableMixin, ModelSingleton, ModelSQL, ModelView, Workflow, fields)
|
||||
from trytond.pool import Pool
|
||||
from trytond.pyson import Eval, If
|
||||
from trytond.transaction import Transaction
|
||||
from trytond.wizard import Button, StateTransition, StateView, Wizard
|
||||
|
||||
from .exceptions import DocumentIncomingSplitError
|
||||
|
||||
if config.getboolean('document_incoming', 'filestore', default=True):
|
||||
file_id = 'file_id'
|
||||
store_prefix = config.get(
|
||||
'document_incoming', 'store_prefix', default=None)
|
||||
else:
|
||||
file_id = store_prefix = None
|
||||
|
||||
|
||||
class IncomingConfiguration(ModelSingleton, ModelSQL, ModelView):
|
||||
__name__ = 'document.incoming.configuration'
|
||||
|
||||
|
||||
class Incoming(DeactivableMixin, Workflow, ModelSQL, ModelView):
|
||||
__name__ = 'document.incoming'
|
||||
|
||||
_states = {
|
||||
'readonly': Eval('state') != 'draft',
|
||||
}
|
||||
|
||||
name = fields.Char("Name", required=True, states=_states)
|
||||
company = fields.Many2One('company.company', "Company", states=_states)
|
||||
data = fields.Binary(
|
||||
"Data", filename='name',
|
||||
file_id=file_id, store_prefix=store_prefix,
|
||||
states={
|
||||
'required': ~Eval('children'),
|
||||
'readonly': _states['readonly'],
|
||||
})
|
||||
parsed_data = fields.Dict(None, "Parsed Data", readonly=True)
|
||||
file_id = fields.Char("File ID", readonly=True)
|
||||
mime_type = fields.Function(
|
||||
fields.Char("MIME Type"), 'on_change_with_mime_type')
|
||||
type = fields.Selection([
|
||||
(None, ""),
|
||||
('document_incoming', "Unknown"),
|
||||
], "Type",
|
||||
states={
|
||||
'required': Eval('active', True) & (Eval('state') == 'done'),
|
||||
'readonly': _states['readonly'],
|
||||
})
|
||||
source = fields.Char("Source", states=_states)
|
||||
parent = fields.Many2One(
|
||||
'document.incoming', "Parent", readonly=True,
|
||||
domain=[
|
||||
('active', '=', False),
|
||||
],
|
||||
states={
|
||||
'invisible': ~Eval('parent'),
|
||||
})
|
||||
children = fields.One2Many(
|
||||
'document.incoming', 'parent', "Children", readonly=True,
|
||||
states={
|
||||
'invisible': ~Eval('children'),
|
||||
})
|
||||
result = fields.Reference(
|
||||
"Result", selection='get_results', readonly=True,
|
||||
states={
|
||||
'required': Eval('active', True) & (Eval('state') == 'done'),
|
||||
'invisible': ~Eval('result'),
|
||||
})
|
||||
state = fields.Selection([
|
||||
('draft', "Draft"),
|
||||
('processing', "Processing"),
|
||||
('done', "Done"),
|
||||
('cancelled', "Cancelled"),
|
||||
], "State", required=True, readonly=True)
|
||||
|
||||
del _states
|
||||
|
||||
@classmethod
|
||||
def __setup__(cls):
|
||||
super().__setup__()
|
||||
cls._transitions |= {
|
||||
('draft', 'processing'),
|
||||
('draft', 'cancelled'),
|
||||
('processing', 'processing'),
|
||||
('processing', 'done'),
|
||||
('processing', 'draft'),
|
||||
('cancelled', 'draft'),
|
||||
}
|
||||
cls._buttons.update(
|
||||
cancel={
|
||||
'invisible': Eval('state') != 'draft',
|
||||
'depends': ['state'],
|
||||
},
|
||||
draft={
|
||||
'invisible': ~Eval('state').in_(['processing', 'cancelled']),
|
||||
'depends': ['state'],
|
||||
},
|
||||
split_wizard={
|
||||
'invisible': (
|
||||
(Eval('state') != 'draft')
|
||||
| ~Eval('mime_type').in_(cls._split_mime_types())),
|
||||
'depends': ['state', 'mime_type'],
|
||||
},
|
||||
process={
|
||||
'pre_validate': [
|
||||
If(~Eval('type'),
|
||||
('type', '!=', None),
|
||||
()),
|
||||
],
|
||||
'invisible': Eval('state') != 'draft',
|
||||
'depends': ['state'],
|
||||
},
|
||||
proceed={
|
||||
'invisible': Eval('state') != 'processing',
|
||||
'depends': ['state'],
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def __register__(cls, module):
|
||||
super().__register__(module)
|
||||
table_h = cls.__table_handler__(module)
|
||||
|
||||
# Migration from 7.6: remove not null on data
|
||||
table_h.not_null_action('data', action='remove')
|
||||
|
||||
@fields.depends('name')
|
||||
def on_change_with_mime_type(self, name=None):
|
||||
if self.name:
|
||||
type, _ = mimetypes.guess_type(self.name)
|
||||
return type
|
||||
|
||||
@classmethod
|
||||
def get_results(cls):
|
||||
pool = Pool()
|
||||
IrModel = pool.get('ir.model')
|
||||
get_name = IrModel.get_name
|
||||
models = cls._get_results()
|
||||
return [(None, '')] + [(m, get_name(m)) for m in models]
|
||||
|
||||
@classmethod
|
||||
def _get_results(cls):
|
||||
return {'document.incoming'}
|
||||
|
||||
@classmethod
|
||||
def default_state(cls):
|
||||
return 'draft'
|
||||
|
||||
@classmethod
|
||||
def view_attributes(cls):
|
||||
process_states = cls._buttons['process'].copy()
|
||||
process_states['invisible'] = Eval('state') != 'draft'
|
||||
return super().view_attributes() + [
|
||||
('/form//button[@name="process"]', 'states', process_states),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
@ModelView.button
|
||||
@Workflow.transition('cancelled')
|
||||
def cancel(cls, documents):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@ModelView.button
|
||||
@Workflow.transition('draft')
|
||||
def draft(cls, documents):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@ModelView.button_action(
|
||||
'document_incoming.wizard_document_incoming_split')
|
||||
def split_wizard(cls, documents):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _split_mime_types(cls):
|
||||
return ['application/pdf']
|
||||
|
||||
@classmethod
|
||||
def from_inbound_email(cls, email_, rule):
|
||||
def sanitize_name(value):
|
||||
for forbidden_char in cls.name.forbidden_chars:
|
||||
value = value.replace(forbidden_char, ' ')
|
||||
return value
|
||||
|
||||
message = email_.as_dict()
|
||||
active = not message.get('attachments')
|
||||
data = message.get('text', message.get('html'))
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
name = sanitize_name(message.get('subject') or 'No Subject')
|
||||
document = cls(
|
||||
active=active,
|
||||
name=name,
|
||||
company=rule.document_incoming_company,
|
||||
data=data,
|
||||
type=rule.document_incoming_type if active else None,
|
||||
source='inbound_email',
|
||||
)
|
||||
children = []
|
||||
for attachment in message.get('attachments', []):
|
||||
child = cls(
|
||||
name=sanitize_name(attachment['filename'] or 'data.bin'),
|
||||
company=rule.document_incoming_company,
|
||||
data=attachment['data'],
|
||||
type=rule.document_incoming_type,
|
||||
source='inbound_email')
|
||||
children.append(child)
|
||||
document.children = children
|
||||
document.save()
|
||||
return document
|
||||
|
||||
@classmethod
|
||||
@ModelView.button
|
||||
@Workflow.transition('processing')
|
||||
def process(cls, documents, with_children=False):
|
||||
transaction = Transaction()
|
||||
context = transaction.context
|
||||
with transaction.set_context(
|
||||
queue_batch=context.get('queue_batch', True)):
|
||||
cls.__queue__.proceed(documents, with_children=with_children)
|
||||
|
||||
@classmethod
|
||||
@ModelView.button
|
||||
@Workflow.transition('done')
|
||||
def proceed(cls, documents, with_children=False):
|
||||
pool = Pool()
|
||||
Attachment = pool.get('ir.attachment')
|
||||
results = defaultdict(list)
|
||||
attachments = []
|
||||
for document in documents:
|
||||
if document.result or not document.active:
|
||||
continue
|
||||
document.result = getattr(document, f'_process_{document.type}')()
|
||||
results[document.result.__class__].append(document.result)
|
||||
attachment = Attachment(
|
||||
name=document.name,
|
||||
resource=document.result,
|
||||
type='data',
|
||||
data=document.data)
|
||||
attachments.append(attachment)
|
||||
for kls, records in results.items():
|
||||
kls.save(records)
|
||||
cls.save(documents)
|
||||
Attachment.save(attachments)
|
||||
|
||||
if with_children:
|
||||
children = list(filter(
|
||||
lambda d: d.type,
|
||||
(c for d in documents for c in d.children)))
|
||||
if children:
|
||||
cls.process(children, with_children=True)
|
||||
|
||||
def _process_document_incoming(self):
|
||||
self.active = False
|
||||
self.save()
|
||||
document, = self.__class__.copy([self], default={
|
||||
'type': None,
|
||||
'parent': self.id,
|
||||
})
|
||||
return document
|
||||
|
||||
@classmethod
|
||||
def copy(cls, documents, default=None):
|
||||
default = default.copy() if default is not None else {}
|
||||
default.setdefault('result')
|
||||
default.setdefault('parsed_data')
|
||||
default.setdefault('children')
|
||||
return super().copy(documents, default=default)
|
||||
|
||||
|
||||
def iter_pages(expression, size):
|
||||
ranges = set()
|
||||
for pages in expression.split(','):
|
||||
pages = pages.split('-')
|
||||
if not len(pages):
|
||||
continue
|
||||
if not pages[0]:
|
||||
pages[0] = 1
|
||||
if not pages[-1]:
|
||||
pages[-1] = size
|
||||
pages = list(map(int, filter(None, pages)))
|
||||
ranges.add((
|
||||
min(max(min(pages) - 1, 0), size),
|
||||
min(max(max(pages), 0), size)))
|
||||
ranges = sorted(ranges)
|
||||
|
||||
def iter_():
|
||||
last = 0
|
||||
for start, end in ranges:
|
||||
if last != start:
|
||||
yield range(last, start)
|
||||
yield range(start, end)
|
||||
last = end
|
||||
if last != size:
|
||||
yield range(last, size)
|
||||
return iter_()
|
||||
|
||||
|
||||
class IncomingSplit(Wizard):
|
||||
__name__ = 'document.incoming.split'
|
||||
|
||||
start = StateView(
|
||||
'document.incoming.split.start',
|
||||
'document_incoming.document_incoming_split_start_view_form', [
|
||||
Button("Cancel", 'end', 'tryton-cancel'),
|
||||
Button("Split", 'split', 'tryton-ok', default=True),
|
||||
])
|
||||
split = StateTransition()
|
||||
|
||||
def default_start(self, fields):
|
||||
if self.record.mime_type == 'application/pdf':
|
||||
reader = PdfReader(BytesIO(self.record.data))
|
||||
if len(reader.pages) == 1:
|
||||
pages = '1'
|
||||
else:
|
||||
pages = '1-%d' % len(reader.pages)
|
||||
else:
|
||||
pages = ''
|
||||
return {
|
||||
'data': self.record.data,
|
||||
'pages': pages,
|
||||
}
|
||||
|
||||
def transition_split(self):
|
||||
pool = Pool()
|
||||
Document = pool.get('document.incoming')
|
||||
if self.record.active and self.record.mime_type == 'application/pdf':
|
||||
self.record.active = False
|
||||
self.record.save()
|
||||
reader = PdfReader(BytesIO(self.record.data))
|
||||
try:
|
||||
iter_ = iter_pages(self.start.pages, len(reader.pages))
|
||||
except ValueError as exception:
|
||||
raise DocumentIncomingSplitError(gettext(
|
||||
'document_incoming.msg_document_split_invalid_pages',
|
||||
expression=self.start.pages,
|
||||
exception=exception)) from exception
|
||||
for pages in iter_:
|
||||
writer = PdfWriter()
|
||||
for i in pages:
|
||||
page = reader.pages[i]
|
||||
writer.add_page(page)
|
||||
data = BytesIO()
|
||||
writer.write(data)
|
||||
Document.copy([self.record], default={
|
||||
'active': True,
|
||||
'data': data.getvalue(),
|
||||
'parent': self.record.id,
|
||||
})
|
||||
return 'end'
|
||||
|
||||
def end(self):
|
||||
return 'reload'
|
||||
|
||||
|
||||
class IncomingSplitStart(ModelView):
|
||||
__name__ = 'document.incoming.split.start'
|
||||
|
||||
data = fields.Binary("Data", readonly=True)
|
||||
pages = fields.Char(
|
||||
"Pages", required=True,
|
||||
help="List pages to split.\n"
|
||||
"Ex: 1-3,4,5-6")
|
||||
Reference in New Issue
Block a user