# Copyright 2019 Ecosoft Co., Ltd (http://ecosoft.co.th/) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) import base64 import os from ast import literal_eval from os.path import join as opj from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError from odoo.modules.module import get_module_path from . import common as co class XLSXTemplate(models.Model): """Master Data for XLSX Templates - Excel Template - Import/Export Meta Data (dict text) - Default values, etc. """ _name = "xlsx.template" _description = "Excel template file and instruction" _order = "name" name = fields.Char(string="Template Name", required=True) res_model = fields.Char( string="Resource Model", help="The database object this attachment will be attached to.", ) fname = fields.Char(string="File Name") gname = fields.Char( string="Group Name", help="Multiple template of same model, can belong to same group,\n" "result in multiple template selection", ) description = fields.Char() input_instruction = fields.Text( string="Instruction (Input)", help="This is used to construct instruction in tab Import/Export", ) instruction = fields.Text( compute="_compute_output_instruction", help="Instruction on how to import/export, prepared by system.", ) datas = fields.Binary(string="File Content") to_csv = fields.Boolean( string="Convert to CSV?", default=False, help="Convert file into CSV format on export", ) csv_delimiter = fields.Char( string="CSV Delimiter", size=1, default=",", required=True, help="Optional for CSV, default is comma.", ) csv_extension = fields.Char( string="CSV File Extension", size=5, default="csv", required=True, help="Optional for CSV, default is .csv", ) csv_quote = fields.Boolean( string="CSV Quoting", default=True, help="Optional for CSV, default is full quoting.", ) export_ids = fields.One2many( comodel_name="xlsx.template.export", inverse_name="template_id" ) import_ids = fields.One2many( comodel_name="xlsx.template.import", inverse_name="template_id" ) post_import_hook = fields.Char( string="Post Import Function Hook", help="Call a function after successful import, i.e.,\n" "${object.post_import_do_something()}", ) show_instruction = fields.Boolean( string="Show Output", default=False, help="This is the computed instruction based on tab Import/Export,\n" "to be used by xlsx import/export engine", ) redirect_action = fields.Many2one( comodel_name="ir.actions.act_window", string="Return Action", domain=[("type", "=", "ir.actions.act_window")], help="Optional action, redirection after finish import operation", ) # Utilities export_action_id = fields.Many2one( comodel_name="ir.actions.act_window", ondelete="set null", ) import_action_id = fields.Many2one( comodel_name="ir.actions.act_window", ondelete="set null", ) use_report_wizard = fields.Boolean( string="Easy Reporting", help="Use common report wizard model, instead of create specific model", ) result_model_id = fields.Many2one( comodel_name="ir.model", string="Report Model", help="When use commone wizard, choose the result model", ) result_field = fields.Char( compute="_compute_result_field", ) report_menu_id = fields.Many2one( comodel_name="ir.ui.menu", string="Report Menu", readonly=True, ) report_action_id = fields.Many2one( comodel_name="ir.actions.report", string="Report Action", ) def _compute_result_field(self): for rec in self: rec.result_field = ( ("x_%s_results" % rec.id) if rec.result_model_id else False ) @api.constrains("redirect_action", "res_model") def _check_action_model(self): for rec in self: if ( rec.res_model and rec.redirect_action and rec.res_model != rec.redirect_action.res_model ): raise ValidationError( _("The selected redirect action is " "not for model %s") % rec.res_model ) @api.model def load_xlsx_template(self, template_ids, addon=False): for template in self.browse(template_ids): if not addon: addon = list(template.get_external_id().values())[0].split(".")[0] addon_path = get_module_path(addon) file_path = False for root, _dirs, files in os.walk(addon_path): for name in files: if name == template.fname: file_path = os.path.abspath(opj(root, name)) if file_path: template.datas = base64.b64encode(open(file_path, "rb").read()) return True @api.model_create_multi def create(self, vals_list): res = super().create(vals_list) for rec in res: if rec.input_instruction: rec._compute_input_export_instruction() rec._compute_input_import_instruction() rec._compute_input_post_import_hook() if rec.result_model_id: rec._update_result_field_common_wizard() rec._update_result_export_ids() return res def write(self, vals): res = super().write(vals) if vals.get("input_instruction"): for rec in self: rec._compute_input_export_instruction() rec._compute_input_import_instruction() rec._compute_input_post_import_hook() if vals.get("result_model_id"): for rec in self: rec._update_result_field_common_wizard() rec._update_result_export_ids() return res def unlink(self): self.env["ir.model.fields"].search( [ ("model", "=", "report.xlsx.wizard"), ("name", "=", self.mapped("result_field")), ] ).unlink() return super().unlink() def _update_result_field_common_wizard(self): self.ensure_one() _model = self.env["ir.model"].search([("model", "=", "report.xlsx.wizard")]) _model.ensure_one() _field = self.env["ir.model.fields"].search( [("model", "=", "report.xlsx.wizard"), ("name", "=", self.result_field)] ) if not _field: _field = self.env["ir.model.fields"].create( { "model_id": _model.id, "name": self.result_field, "field_description": "Results", "ttype": "many2many", "relation": self.result_model_id.model, "store": False, "depends": "res_model", } ) else: _field.ensure_one() _field.write({"relation": self.result_model_id.model}) _field.compute = """ self['{}'] = self.env['{}'].search(self.safe_domain(self.domain)) """.format( self.result_field, self.result_model_id.model, ) def _update_result_export_ids(self): self.ensure_one() results = self.env["xlsx.template.export"].search( [("template_id", "=", self.id), ("row_field", "=", self.result_field)] ) if not results: self.export_ids.unlink() self.write( { "export_ids": [ (0, 0, {"sequence": 10, "section_type": "sheet", "sheet": 1}), ( 0, 0, { "sequence": 20, "section_type": "row", "row_field": self.result_field, }, ), ( 0, 0, { "sequence": 30, "section_type": "data", "excel_cell": "A1", "field_name": "id", }, ), ], } ) @api.onchange("use_report_wizard") def _onchange_use_report_wizard(self): self.res_model = "report.xlsx.wizard" if self.use_report_wizard else False self.redirect_action = False def _compute_input_export_instruction(self): self = self.with_context(compute_from_input=True) for rec in self: # Export Instruction input_dict = literal_eval(rec.input_instruction.strip()) rec.export_ids.unlink() export_dict = input_dict.get("__EXPORT__") if not export_dict: continue export_lines = [] sequence = 0 # Sheet for sheet, rows in export_dict.items(): sequence += 1 vals = { "sequence": sequence, "section_type": "sheet", "sheet": str(sheet), } export_lines.append((0, 0, vals)) # Rows for row_field, lines in rows.items(): sequence += 1 is_cont = False if "_CONT_" in row_field: is_cont = True row_field = row_field.replace("_CONT_", "") is_extend = False if "_EXTEND_" in row_field: is_extend = True row_field = row_field.replace("_EXTEND_", "") vals = { "sequence": sequence, "section_type": (row_field == "_HEAD_" and "head" or "row"), "row_field": row_field, "is_cont": is_cont, "is_extend": is_extend, } export_lines.append((0, 0, vals)) for excel_cell, field_name in lines.items(): sequence += 1 vals = { "sequence": sequence, "section_type": "data", "excel_cell": excel_cell, "field_name": field_name, } export_lines.append((0, 0, vals)) rec.write({"export_ids": export_lines}) def _compute_input_import_instruction(self): self = self.with_context(compute_from_input=True) for rec in self: # Import Instruction input_dict = literal_eval(rec.input_instruction.strip()) rec.import_ids.unlink() import_dict = input_dict.get("__IMPORT__") if not import_dict: continue import_lines = [] sequence = 0 # Sheet for sheet, rows in import_dict.items(): sequence += 1 vals = { "sequence": sequence, "section_type": "sheet", "sheet": str(sheet), } import_lines.append((0, 0, vals)) # Rows for row_field, lines in rows.items(): sequence += 1 no_delete = False if "_NODEL_" in row_field: no_delete = True row_field = row_field.replace("_NODEL_", "") vals = { "sequence": sequence, "section_type": (row_field == "_HEAD_" and "head" or "row"), "row_field": row_field, "no_delete": no_delete, } import_lines.append((0, 0, vals)) for excel_cell, field_name in lines.items(): sequence += 1 vals = { "sequence": sequence, "section_type": "data", "excel_cell": excel_cell, "field_name": field_name, } import_lines.append((0, 0, vals)) rec.write({"import_ids": import_lines}) def _compute_input_post_import_hook(self): self = self.with_context(compute_from_input=True) for rec in self: # Import Instruction input_dict = literal_eval(rec.input_instruction.strip()) rec.post_import_hook = input_dict.get("__POST_IMPORT__") def _compose_field_name(self, line): field_name = line.field_name or "" field_name += line.field_cond or "" field_name += line.style or "" field_name += line.style_cond or "" if line.is_sum: field_name += "@{sum}" return field_name def _compute_output_instruction(self): """From database, compute back to dictionary""" for rec in self: inst_dict = {} prev_sheet = False prev_row = False # Export Instruction itype = "__EXPORT__" inst_dict[itype] = {} for line in rec.export_ids: if line.section_type == "sheet": sheet = co.isinteger(line.sheet) and int(line.sheet) or line.sheet sheet_dict = {sheet: {}} inst_dict[itype].update(sheet_dict) prev_sheet = sheet continue if line.section_type in ("head", "row"): row_field = line.row_field if line.section_type == "row" and line.is_cont: row_field = "_CONT_%s" % row_field if line.section_type == "row" and line.is_extend: row_field = "_EXTEND_%s" % row_field row_dict = {row_field: {}} inst_dict[itype][prev_sheet].update(row_dict) prev_row = row_field continue if line.section_type == "data": excel_cell = line.excel_cell field_name = self._compose_field_name(line) cell_dict = {excel_cell: field_name} inst_dict[itype][prev_sheet][prev_row].update(cell_dict) continue # Import Instruction itype = "__IMPORT__" inst_dict[itype] = {} for line in rec.import_ids: if line.section_type == "sheet": sheet = co.isinteger(line.sheet) and int(line.sheet) or line.sheet sheet_dict = {sheet: {}} inst_dict[itype].update(sheet_dict) prev_sheet = sheet continue if line.section_type in ("head", "row"): row_field = line.row_field if line.section_type == "row" and line.no_delete: row_field = "_NODEL_%s" % row_field row_dict = {row_field: {}} inst_dict[itype][prev_sheet].update(row_dict) prev_row = row_field continue if line.section_type == "data": excel_cell = line.excel_cell field_name = line.field_name or "" field_name += line.field_cond or "" cell_dict = {excel_cell: field_name} inst_dict[itype][prev_sheet][prev_row].update(cell_dict) continue itype = "__POST_IMPORT__" inst_dict[itype] = False if rec.post_import_hook: inst_dict[itype] = rec.post_import_hook rec.instruction = inst_dict def add_export_action(self): self.ensure_one() vals = { "name": "Export Excel", "res_model": "export.xlsx.wizard", "binding_model_id": self.env["ir.model"] .search([("model", "=", self.res_model)]) .id, "binding_type": "action", "target": "new", "view_mode": "form", "context": """ {'template_domain': [('res_model', '=', '%s'), ('fname', '=', '%s'), ('gname', '=', False)]} """ % (self.res_model, self.fname), } action = self.env["ir.actions.act_window"].create(vals) self.export_action_id = action def remove_export_action(self): self.ensure_one() if self.export_action_id: self.export_action_id.unlink() def add_import_action(self): self.ensure_one() vals = { "name": "Import Excel", "res_model": "import.xlsx.wizard", "binding_model_id": self.env["ir.model"] .search([("model", "=", self.res_model)]) .id, "binding_type": "action", "target": "new", "view_mode": "form", "context": """ {'template_domain': [('res_model', '=', '%s'), ('fname', '=', '%s'), ('gname', '=', False)]} """ % (self.res_model, self.fname), } action = self.env["ir.actions.act_window"].create(vals) self.import_action_id = action def remove_import_action(self): self.ensure_one() if self.import_action_id: self.import_action_id.unlink() def add_report_menu(self): self.ensure_one() if not self.fname: raise UserError(_("No file content!")) # Create report action vals = { "name": self.name, "report_type": "excel", "model": "report.xlsx.wizard", "report_name": self.fname, "report_file": self.fname, } report_action = self.env["ir.actions.report"].create(vals) self.report_action_id = report_action # Create window action vals = { "name": self.name, "res_model": "report.xlsx.wizard", "binding_type": "action", "target": "new", "view_mode": "form", "context": { "report_action_id": report_action.id, "default_res_model": self.result_model_id.model, }, } action = self.env["ir.actions.act_window"].create(vals) # Create menu vals = { "name": self.name, "action": "{},{}".format(action._name, action.id), } menu = self.env["ir.ui.menu"].create(vals) self.report_menu_id = menu def remove_report_menu(self): self.ensure_one() if self.report_action_id: self.report_action_id.unlink() if self.report_menu_id: self.report_menu_id.action.unlink() self.report_menu_id.unlink() class XLSXTemplateImport(models.Model): _name = "xlsx.template.import" _description = "Detailed of how excel data will be imported" _order = "sequence" template_id = fields.Many2one( comodel_name="xlsx.template", string="XLSX Template", index=True, ondelete="cascade", readonly=True, ) sequence = fields.Integer(default=10) sheet = fields.Char() section_type = fields.Selection( [("sheet", "Sheet"), ("head", "Head"), ("row", "Row"), ("data", "Data")], required=True, ) row_field = fields.Char(help="If section type is row, this field is required") no_delete = fields.Boolean( default=False, help="By default, all rows will be deleted before import.\n" "Select No Delete, otherwise", ) excel_cell = fields.Char(string="Cell") field_name = fields.Char(string="Field") field_cond = fields.Char(string="Field Cond.") @api.model_create_multi def create(self, vals_list): for vals in vals_list: vals = self._extract_field_name(vals) return super().create(vals_list) @api.model def _extract_field_name(self, vals): if self._context.get("compute_from_input") and vals.get("field_name"): field_name, field_cond = co.get_field_condition(vals["field_name"]) field_cond = field_cond and "${%s}" % (field_cond or "") or False vals.update({"field_name": field_name, "field_cond": field_cond}) return vals class XLSXTemplateExport(models.Model): _name = "xlsx.template.export" _description = "Detailed of how excel data will be exported" _order = "sequence" template_id = fields.Many2one( comodel_name="xlsx.template", string="XLSX Template", index=True, ondelete="cascade", readonly=True, ) sequence = fields.Integer(default=10) sheet = fields.Char() section_type = fields.Selection( [("sheet", "Sheet"), ("head", "Head"), ("row", "Row"), ("data", "Data")], required=True, ) row_field = fields.Char(help="If section type is row, this field is required") is_cont = fields.Boolean( string="Continue", default=False, help="Continue data rows after last data row" ) is_extend = fields.Boolean( string="Extend", default=False, help="Extend a blank row after filling each record, to extend the footer", ) excel_cell = fields.Char(string="Cell") field_name = fields.Char(string="Field") field_cond = fields.Char(string="Field Cond.") is_sum = fields.Boolean(string="Sum", default=False) style = fields.Char(string="Default Style") style_cond = fields.Char(string="Style w/Cond.") @api.model_create_multi def create(self, vals_list): for vals in vals_list: vals = self._extract_field_name(vals) return super().create(vals_list) @api.model def _extract_field_name(self, vals): if self._context.get("compute_from_input") and vals.get("field_name"): field_name, field_cond = co.get_field_condition(vals["field_name"]) field_cond = field_cond or 'value or ""' field_name, style = co.get_field_style(field_name) field_name, style_cond = co.get_field_style_cond(field_name) field_name, func = co.get_field_aggregation(field_name) vals.update( { "field_name": field_name, "field_cond": "${%s}" % (field_cond or ""), "style": "#{%s}" % (style or ""), "style_cond": "#?%s?" % (style_cond or ""), "is_sum": func == "sum" and True or False, } ) return vals