# Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 import binascii import contextlib from datetime import datetime from itertools import chain from xml.etree import ElementTree from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.fields import Domain from odoo.tools import SQL class DocumentsDocument(models.Model): _inherit = 'documents.document' # once we parsed the XML to know if a PDF is embedded inside, # we store that information so we don't need to parse it again has_embedded_pdf = fields.Boolean('Has Embedded PDF', compute='_compute_has_embedded_pdf', store=True) @api.depends('has_embedded_pdf') def _compute_thumbnail(self): """Compute the thumbnail and thumbnail status. If the XML invoices contain an embedded PDF, the thumbnail / thumbnail_status must have the same behavior as a standard PDF. """ xml_documents = self.filtered(lambda doc: doc.has_embedded_pdf) xml_documents.thumbnail = False xml_documents.thumbnail_status = 'client_generated' super(DocumentsDocument, self - xml_documents)._compute_thumbnail() @api.depends('checksum') def _compute_has_embedded_pdf(self): for document in self: document.has_embedded_pdf = bool(document._extract_pdf_from_xml()) def _extract_pdf_from_xml(self): """Parse the XML file and return the PDF content if one is found. For some invoice files (in the XML format), we can have a PDF embedded inside in base 64. We want to be able to preview it in documents. We support the UBL format > https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice """ self.ensure_one() if not self.mimetype or not self.raw: return False if not (self.mimetype.endswith('/xml') or (self.mimetype == 'text/plain' and self.name.lower().endswith('.xml'))): return False try: xml_file_content = self.with_context(bin_size=False).raw.decode() except UnicodeDecodeError: return False # quick filters, to not parse the XML most of the cases if "EmbeddedDocumentBinaryObject" not in xml_file_content and "Attachment" not in xml_file_content: return False try: tree = ElementTree.fromstring(xml_file_content) except ElementTree.ParseError: return False attachment_nodes = tree.iterfind('.//{*}EmbeddedDocumentBinaryObject') attachment_nodes = chain(attachment_nodes, tree.iterfind('.//{*}Attachment')) for attachment_node in attachment_nodes: if len(attachment_node): # the node has children continue with contextlib.suppress(TypeError, binascii.Error): # check file header in case many file are embedded in the XML if (pdf_attachment_content := base64.b64decode(attachment_node.text + "====")).startswith(b'%PDF-'): return pdf_attachment_content return False def account_create_account_move(self, move_type, journal_id=None, partner_id=None, skip_activities=False): if not skip_activities: for record in self: record.activity_ids.action_feedback(feedback="completed") if any(document.type == 'folder' for document in self): raise UserError(_('You can not create account move on folder.')) if journal_id is None: company_journals = self.env['account.journal'].search([ *self.env['account.journal']._check_company_domain(self.env.company), ]) if move_type == 'statement': journal_id = company_journals.filtered(lambda journal: journal.type == 'bank')[:1] else: journal_id = self.env['account.move']._get_suitable_journal_ids(move_type)[:1] elif isinstance(journal_id, int): journal_id = self.env['account.journal'].browse(journal_id) move = None invoices = self.env['account.move'] # 'entry' are outside of document loop because the actions # returned could be differents (cfr. l10n_be_soda) if move_type == 'entry': return journal_id.create_document_from_attachment(attachment_ids=self.attachment_id.ids) for document in self: partner = partner_id or document.partner_id if document.res_model == 'account.move' and document.res_id: move = self.env['account.move'].browse(document.res_id) else: creation_context = {'default_move_type': move_type} if move_type in ('in_invoice', 'in_refund') and partner and 'property_purchase_currency_id' in partner: supplier_currency = partner.with_company(document.company_id).property_purchase_currency_id if supplier_currency: creation_context['default_currency_id'] = supplier_currency.id move = journal_id\ .with_context(**creation_context)\ ._create_document_from_attachment(attachment_ids=document.attachment_id.id) if partner: move.partner_id = partner if move.statement_line_id: move['suspense_statement_line_id'] = move.statement_line_id.id invoices |= move # When running an action on several documents, this method is called in a # loop (because of the "multi" server action). When it is the case, we try # to redirect to a list of all created invoices instead of just the last # one, using the context. action_name_ref = { 'in_invoice': self.env._("Vendor Bills"), 'in_refund': self.env._("Vendor Refunds"), 'in_receipt': self.env._("Vendor Receipts"), 'out_invoice': self.env._("Customer Invoices"), 'out_refund': self.env._("Customer Credit Notes"), } context = dict(self.env.context, default_move_type=move_type) documents_active_ids = context.get('documents_active_ids') if context.get('active_model') != 'documents.document' or not documents_active_ids: invoice_ids = invoices.ids else: invoice_ids = self.browse(documents_active_ids).mapped('res_id') action = { 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'name': action_name_ref[move_type], 'view_id': False, 'view_mode': 'list', 'views': [(False, "list"), (False, "form")], 'domain': [('id', 'in', invoice_ids)], 'context': context, } if len(invoice_ids) == 1: record = move or invoices[0] view_id = record.get_formview_id() if record else False action.update({ 'view_mode': 'form', 'views': [(view_id, "form")], 'res_id': invoice_ids[0], 'view_id': view_id, }) return action def account_create_account_bank_statement(self, journal_id=None): # It is not possible to link the doc # to the newly created entry as they can be more than one. But importing # many times the same bank statement is later checked. default_journal = journal_id or self.env['account.journal'].search([ *self.env['account.journal']._check_company_domain(self.env.company), ('type', '=', 'bank'), ], limit=1) if not default_journal: error_msg = self.env['account.journal']._build_no_journal_error_msg(self.env.company.display_name, ['bank']) raise UserError(error_msg) return default_journal.create_document_from_attachment(attachment_ids=self.attachment_id.ids) @api.model def _get_base_server_actions_domain(self): return Domain.AND([ super()._get_base_server_actions_domain(), Domain.OR([ [('state', '!=', 'documents_account_record_create')], [('documents_account_journal_id', '=', False)], [('documents_account_journal_id.company_id', '=', self.env.company.id)], ]) ]) @api.model def _ensure_account_documents_exist(self): self._load_records([ { "xml_id": "documents.document_finance_folder", "noupdate": True, "values": { "active": True, "type": "folder", "access_internal": "edit", "name": _("Finance"), "sequence": 10, } } ]) self._load_records([ { "xml_id": "documents.document_finance_taxes_folder", "noupdate": True, "values": { "active": True, "type": "folder", "access_internal": "edit", "folder_id": self.env.ref("documents.document_finance_folder").id, "name": _("Taxes"), "sequence": 120, } } ]) self._load_records([ { "xml_id": "documents.document_finance_annual_closing_folder", "noupdate": True, "values": { "active": True, "type": "folder", "access_internal": "edit", "folder_id": self.env.ref("documents.document_finance_folder").id, "name": _("Annual Closing"), "sequence": 130, } }, ]) self._load_records([ { "xml_id": "documents.document_finance_annual_closing_year_current_folder", "noupdate": True, "values": { "active": True, "type": "folder", "access_internal": "edit", "folder_id": self.env.ref('documents.document_finance_annual_closing_folder').id, "name": str(datetime.now().year), "sequence": 200, } }, ]) def _get_gc_clear_bin_domain(self): query_folder_id = self.env['documents.account.folder.setting']._search( [('folder_id', '!=', False)] ).select('folder_id') return Domain.AND([ super()._get_gc_clear_bin_domain(), [('id', 'not in', SQL("(%s)", query_folder_id))], ])