Files

377 lines
12 KiB
Python
Raw Permalink Normal View History

2023-09-14 13:10:41 +02:00
# 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
2025-10-07 08:19:57 +02:00
import trytond.config as config
2023-09-14 13:10:41 +02:00
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'],
})
2023-09-14 13:10:41 +02:00
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'),
2023-09-14 13:10:41 +02:00
'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'),
2023-09-14 13:10:41 +02:00
'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')
2023-09-14 13:10:41 +02:00
@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
2023-09-14 13:10:41 +02:00
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')
2023-09-14 13:10:41 +02:00
document = cls(
active=active,
name=name,
2023-09-14 13:10:41 +02:00
company=rule.document_incoming_company,
data=data,
2023-09-14 13:10:41 +02:00
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'),
2023-09-14 13:10:41 +02:00
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")