diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8d46d21c49..6e01f734b9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -105,7 +105,7 @@ repos: rev: 2.5.2 hooks: - id: setuptools-odoo-make-default - - repo: https://gitlab.com/pycqa/flake8 + - repo: https://github.com/pycqa/flake8 rev: 3.7.9 hooks: - id: flake8 diff --git a/report_dynamic/README.rst b/report_dynamic/README.rst new file mode 100644 index 0000000000..6bae1529ff --- /dev/null +++ b/report_dynamic/README.rst @@ -0,0 +1,32 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +================= +Report Dynamic +================= + +Generate dynamic contracts based on Building Blocks/Section + +Installation +============ +* Just Install + +Configuration +============= +* No configurations needed + +Credits +======= + +Contributors +------------ + +* Sunflower IT + + + +Maintainer +---------- + +This module is maintained by Sunflower IT diff --git a/report_dynamic/__init__.py b/report_dynamic/__init__.py new file mode 100644 index 0000000000..19d478d060 --- /dev/null +++ b/report_dynamic/__init__.py @@ -0,0 +1,5 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models +from . import utils +from . import wizards diff --git a/report_dynamic/__manifest__.py b/report_dynamic/__manifest__.py new file mode 100644 index 0000000000..f2749ab0ae --- /dev/null +++ b/report_dynamic/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2022 Sunflower IT +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Report Dynamic", + "version": "13.0.1.0.0", + "category": "Report", + "author": "Sunflower IT, Therp BV, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/reporting-engine", + "license": "AGPL-3", + "development_status": "Alpha", + "summary": "Dynamic Report Builder", + "depends": ["base", "web_boolean_button"], + "data": [ + "security/res_groups.xml", + "security/ir_rule.xml", + "security/ir.model.access.csv", + "data/res_users.xml", + "data/report_dynamic_alias.xml", + "report/report_dynamic_report.xml", + "views/report_dynamic.xml", + "views/report_dynamic_section.xml", + "views/report_dynamic_alias.xml", + "wizards/wizard_lock_report.xml", + "wizards/wizard_report_dynamic.xml", + ], + "maintainers": ["thomaspaulb"], + "demo": ["demo/demo.xml"], + "installable": True, +} diff --git a/report_dynamic/data/report_dynamic_alias.xml b/report_dynamic/data/report_dynamic_alias.xml new file mode 100644 index 0000000000..6475d0642e --- /dev/null +++ b/report_dynamic/data/report_dynamic_alias.xml @@ -0,0 +1,29 @@ + + + + [H1val] + ${h.value} + + + [H2val] + ${h.child.value} + + + [H3val] + ${h.child.child.value} + + + [H1] + ${h.next} + + + [H2] + ${h.value}.${h.child.next} + + + [H3] + ${h.value}.${h.child.value}.${h.child.child.next} + + diff --git a/report_dynamic/data/res_users.xml b/report_dynamic/data/res_users.xml new file mode 100644 index 0000000000..7b36cd3846 --- /dev/null +++ b/report_dynamic/data/res_users.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/report_dynamic/demo/demo.xml b/report_dynamic/demo/demo.xml new file mode 100644 index 0000000000..004a13a3cc --- /dev/null +++ b/report_dynamic/demo/demo.xml @@ -0,0 +1,70 @@ + + + + + Template for report + + True + + + + Demo report + + + + + + A paragraph + True + Some Black content + object.name.startswith("D") + + + + Not a paragraph + False + + ${object.name} + [H1] # 1 + ${page} + [H2] # 1.1 + ${page} + [H2] # 1.2 + ${page} + [H2] # 1.3 + + [H3] # 1.3.1 + ${page} + [H3] # 1.3.2 + ${page} + [H3] # 1.3.3 + ${page} + [H3] # 1.3.4 + ${page} + [H1] # 2 + ${page} + [H2] # 2.1 + ${page} + [H2] # 2.2 + ${page} + [H3] # 2.2.1 + ${page} + [H1val].[H2val].[H3val] # 2.2.1 + + object.name.endswith("r") + + + + + Black + White + + + ${object.name} + Name: ${object.name} + + + + + + diff --git a/report_dynamic/models/__init__.py b/report_dynamic/models/__init__.py new file mode 100644 index 0000000000..8119b165e1 --- /dev/null +++ b/report_dynamic/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import report_dynamic +from . import report_dynamic_section +from . import report_dynamic_alias diff --git a/report_dynamic/models/report_dynamic.py b/report_dynamic/models/report_dynamic.py new file mode 100644 index 0000000000..1c9c46a473 --- /dev/null +++ b/report_dynamic/models/report_dynamic.py @@ -0,0 +1,429 @@ +# Copyright 2022 Sunflower IT +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.safe_eval import safe_eval + + +class ReportDynamic(models.Model): + _name = "report.dynamic" + _description = "Dynamically create reports" + + name = fields.Char(required=True) + real_model_id = fields.Many2one( + comodel_name="ir.model", domain="[('transient', '=', False)]" + ) + model_id = fields.Many2one( + comodel_name="ir.model", + compute="_compute_model_id", + inverse="_inverse_model_id", + store=True, + ) + # Inform the user about configured model_id + # in template + model_model = fields.Char(related="model_id.model", string="Tech name of model_id") + res_id = fields.Integer(copy=False) + resource_ref = fields.Reference( + string="Target record", + selection="_selection_target_model", + compute="_compute_resource_ref", + inverse="_inverse_resource_ref", + ) + render_resource_ref = fields.Reference( + selection="_selection_target_model", compute="_compute_render_resource_ref" + ) + wrapper_report_id = fields.Many2one( + comodel_name="ir.ui.view", domain="[('type', '=', 'qweb')]" + ) + template_id = fields.Many2one( + comodel_name="report.dynamic", + domain="[('is_template', '=', True)]", + copy=False, + default=lambda self: self.env.company.external_report_layout_id, + ) + report_ids = fields.One2many( + comodel_name="report.dynamic", + inverse_name="template_id", + domain="[('is_template', '=', False)]", + copy=False, + ) + documentation = fields.Text(default="Documentation placeholder", readonly=True) + condition_domain_global = fields.Char( + string="Global domain condition", default="[]" + ) + active = fields.Boolean(default=True) + is_template = fields.Boolean() + lock_date = fields.Date(readonly=True) + field_ids = fields.Many2many( + comodel_name="ir.model.fields", + relation="contextual_field_rel", + column1="contextual_id", + column2="field_id", + string="Fields", + ) + window_action_exists = fields.Boolean(compute="_compute_window_action_exists") + group_by_record_name = fields.Char( + compute="_compute_group_by_record_name", + store=True, + help="Computed field for grouping by record name in search view", + ) + report_ids = fields.One2many( + comodel_name="report.dynamic", inverse_name="template_id", copy=False + ) + report_count = fields.Integer(string="Reports", compute="_compute_report_count") + section_ids = fields.One2many( + comodel_name="report.dynamic.section", inverse_name="report_id", copy=True + ) + section_count = fields.Integer(string="Sections", compute="_compute_section_count") + preview_res_id = fields.Integer(compute="_compute_preview_res_id") + preview_res_id_display_name = fields.Char(compute="_compute_preview_res_id") + + _sql_constraints = [ + ( + "template_check", + """ + CHECK( + (is_template = 'f' and template_id is not null) or + (is_template = 't' and template_id is null) + ) + """, + "A report should always have a template, but a template cannot have one.", + ), + ( + "res_id_check", + """ + CHECK( + (is_template = 'f' and res_id is not null) or + (is_template = 't' and res_id is null) + ) + """, + "A report should always relate to a record, but a template cannot have one.", + ), + ] + + @api.depends("is_template", "template_id.model_id", "real_model_id") + def _compute_model_id(self): + for rec in self: + if rec.is_template: + rec.model_id = rec.real_model_id + else: + rec.model_id = rec.template_id.model_id + + def _inverse_model_id(self): + for rec in self: + if rec.is_template: + rec.real_model_id = rec.model_id + + @api.depends("resource_ref", "is_template", "preview_res_id") + def _compute_render_resource_ref(self): + for rec in self: + if rec.is_template and rec.preview_res_id and rec.model_id: + rec.render_resource_ref = "%s,%s" % ( + rec.model_id.model, + rec.preview_res_id, + ) + elif not rec.is_template: + rec.render_resource_ref = rec.resource_ref + else: + rec.render_resource_ref = False + + @api.model + def _selection_target_model(self): + """ These models can be a target for a dynamic report template """ + models = self.env["ir.model"].search([("transient", "=", False)]) + return [(model.model, model.name) for model in models] + + @api.model + def _get_sample_record(self, model): + """ Returns any record of given model """ + return self.env[model].search([], limit=1) + + @api.constrains("model_id", "res_id", "template_id") + def _prevent_broken_models(self): + """ Prevents user from selecting broken models """ + for rec in self: + model = rec.model_id.model + if not model: + continue + try: + self._get_sample_record(model) + except Exception as e: + raise ValidationError( + _("Model %s is not applicable for report. Reason: %s") + % (model, str(e)) + ) + + @api.onchange("template_id") + def _onchange_template_id(self): + """ When template is chosen, define section_ids """ + for report in self: + if not report.is_template and report.template_id and not report.section_ids: + report.section_ids = report.template_id.section_ids + + @api.onchange("model_id") + def _onchange_model_id(self): + self.ensure_one() + res = {} + model = self.model_id.model + if not model: + return res + try: + self.env[model].search_read([], limit=1) + except Exception as e: + res["warning"] = { + "message": _("Model %s is not applicable for report. Reason: %s") + % (model, str(e)) + } + self.model_id = self._origin.model_id.id + return res + + @api.depends("model_id", "res_id", "template_id") + def _compute_resource_ref(self): + for rec in self: + if rec.is_template or not rec.model_id: + rec.resource_ref = False + continue + sample_record = self._get_sample_record(rec.model_id.model) + rec.resource_ref = "%s,%s" % ( + rec.model_id.model, + rec.res_id or sample_record.id, + ) + + def _inverse_resource_ref(self): + for rec in self: + if rec.resource_ref: + rec.res_id = rec.resource_ref.id + rec.model_id = self.env["ir.model"]._get(rec.resource_ref._name) + else: + rec.res_id = None + + def get_window_actions(self): + return self.env["ir.actions.act_window"].search( + [ + ("res_model", "=", "wizard.report.dynamic"), + ("binding_model_id", "=", self.model_id.id), + ] + ) + + def _compute_window_action_exists(self): + for rec in self: + rec.window_action_exists = bool(rec.get_window_actions()) + + @api.depends("resource_ref") + def _compute_group_by_record_name(self): + for rec in self: + rec.group_by_record_name = "" + if rec.is_template or not rec.resource_ref: + continue + rec.group_by_record_name = rec.resource_ref.display_name + + def get_template_xml_id(self): + self.ensure_one() + if not self.wrapper_report_id: + # return a default + return "web.external_layout" + record = self.env["ir.model.data"].search( + [("model", "=", "ir.ui.view"), ("res_id", "=", self.wrapper_report_id.id)], + limit=1, + ) + return "{}.{}".format(record.module, record.name) + + @api.depends("section_ids") + def _compute_section_count(self): + for rec in self: + rec.section_count = len(rec.section_ids) + + def action_view_sections(self): + self.ensure_one() + return { + "name": _("Sections"), + "type": "ir.actions.act_window", + "res_model": "report.dynamic.section", + "view_mode": "tree,form", + "target": "current", + "context": {"default_report_id": self.id}, + "domain": [("id", "in", self.section_ids.ids)], + } + + @api.depends("report_ids") + def _compute_report_count(self): + for rec in self: + rec.report_count = len(rec.report_ids) + + def action_view_reports(self): + self.ensure_one() + return { + "name": _("Reports"), + "type": "ir.actions.act_window", + "res_model": "report.dynamic", + "view_mode": "tree,form", + "target": "current", + "context": { + "default_is_template": False, + "is_template": False, + "default_template_id": self.id, + }, + "domain": [("id", "in", self.report_ids.ids)], + } + + def action_wizard_lock_report(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": "wizard.lock.report", + "view_mode": "form", + "target": "new", + } + + def _compute_preview_res_id(self): + "" + for tpl in self: + domain = safe_eval(tpl.condition_domain_global or "[]") + record = self.env[self.model_id.model].search(domain)[:1] + tpl.preview_res_id = record.id + if "display_name" in record._fields: + tpl.preview_res_id_display_name = record.display_name + continue + tpl.preview_res_id_display_name = _("Not Available.") + + def action_preview_content(self): + self.ensure_one() + if not self.is_template: + raise ValidationError(_("Can only use preview for templates")) + if not self.preview_res_id: + raise ValidationError( + _("Looking for a random record matching the domain, but did not find") + ) + action = self.env.ref("report_dynamic.report_dynamic_document_preview").read( + [] + )[0] + return action + + def action_quick_view_content(self): + self.ensure_one() + if self.is_template: + raise ValidationError(_("Can only use quick view for reports")) + if not self.resource_ref: + raise ValidationError(_("Needs a record for previewing")) + action = self.env.ref("report_dynamic.report_dynamic_document_preview").read( + [] + )[0] + return action + + # Override create() and write() to keep + # resource_ref always the same with template + # even if template.resource_ref=False + @api.model + def create(self, values): + records = super().create(values) + for rec in records: + if rec.template_id.resource_ref and not rec.res_id: + rec.resource_ref = rec.template_id.resource_ref + # Give a default to wrapper_report_id when + # user sets template_id + rec.wrapper_report_id = rec._get_wrapper_report_id(rec.template_id) + return records + + def action_duplicate_as_template(self): + self.ensure_one() + if self.is_template: + raise ValidationError( + _("This is not a report, you cannot create a template from it") + ) + action = self.env.ref("report_dynamic.report_dynamic_template_action").read()[0] + action["context"] = dict(self.env.context) + action["context"]["form_view_initial_mode"] = "edit" + action["views"] = [ + (self.env.ref("report_dynamic.report_dynamic_form").id, "form") + ] + action["res_id"] = self.copy( + { + "model_id": self.model_id.id, + "is_template": True, + "template_id": False, + "lock_date": False, + "resource_ref": False, + "name": _("New template based on report: %s") % (self.name,), + } + ).id + return action + + @api.constrains("report_ids", "is_template") + def _constrain_template_status(self): + """ Disallow revoking template status of a template with children """ + if any(rec.report_ids and not rec.is_template for rec in self): + raise ValidationError( + _( + "You cannot switch this template because " + "it has reports connected to it" + ) + ) + + def _forbid_model_change(self, values): + """ Disallow changing model of a template with children """ + if "model_id" in values and self.mapped("report_ids"): + raise ValidationError( + _( + "You cannot change model for this template because " + "it has reports connected to it" + ) + ) + + def write(self, values): + self._forbid_model_change(values) + ret = super().write(values) + # Set default wrapper_report and resource_ref when user sets template_id + if values.get("template_id"): + for rec in self: + rec.resource_ref = rec.template_id.resource_ref + rec.wrapper_report_id = rec._get_wrapper_report_id(rec.template_id) + return ret + + def unlink(self): + for rec in self: + if not rec.is_template: + continue + if rec.window_action_exists: + rec.unlink_action() + return super().unlink() + + def _get_wrapper_report_id(self, template): + self.ensure_one() + return template.wrapper_report_id or self.env.company.external_report_layout_id + + # Contextual action for dynamic reports + def create_action(self): + self.ensure_one() + if self.window_action_exists: + return + if not self.model_id: + return + self.env["ir.actions.act_window"].sudo().create( + { + "name": "Dynamic Reporting", + "type": "ir.actions.act_window", + "res_model": "wizard.report.dynamic", + "context": "{'mass_report_object' : %d}" % (self.id), + "domain": [("model_id", "=", self.model_id.id)], + "view_mode": "form", + "target": "new", + "binding_type": "action", + "binding_model_id": self.model_id.id, + } + ) + + def unlink_action(self): + """ Anyone can delete this action """ + # to delete the action, not only admin + recs = ( + self.env["ir.actions.act_window"] + .sudo() + .search( + [ + ("res_model", "=", "wizard.report.dynamic"), + ("binding_model_id", "=", self.model_id.id), + ] + ) + ) + if recs: + recs.unlink() diff --git a/report_dynamic/models/report_dynamic_alias.py b/report_dynamic/models/report_dynamic_alias.py new file mode 100644 index 0000000000..4708dda5e1 --- /dev/null +++ b/report_dynamic/models/report_dynamic_alias.py @@ -0,0 +1,18 @@ +# Copyright 2022 Sunflower IT +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class ReportDynamicAlias(models.Model): + _name = "report.dynamic.alias" + _description = "Replace expressions before rendering" + + expression_from = fields.Char( + required=True, help="Look for this in report_id.section_ids.content" + ) + expression_to = fields.Char( + required=True, help="Replace with this in report_id.section_ids.content" + ) + is_active = fields.Boolean( + string="Active", default=True, help="To use the record when prerendering" + ) diff --git a/report_dynamic/models/report_dynamic_section.py b/report_dynamic/models/report_dynamic_section.py new file mode 100644 index 0000000000..4cd9d15aa7 --- /dev/null +++ b/report_dynamic/models/report_dynamic_section.py @@ -0,0 +1,227 @@ +# Copyright 2022 Sunflower IT +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import copy +import traceback + +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError +from odoo.tools import safe_eval + +from ..utils import Header + +try: + from jinja2.sandbox import SandboxedEnvironment + + mako_template_env = SandboxedEnvironment( + block_start_string="<%", + block_end_string="%>", + variable_start_string="${", + variable_end_string="}", + comment_start_string="<%doc>", + comment_end_string="", + line_statement_prefix="%", + line_comment_prefix="##", + trim_blocks=True, # do not output newline after blocks + autoescape=True, # XML/HTML automatic escaping + ) + # Let's keep these in case they are needed + # in the future + mako_template_env.globals.update( + { + "str": str, + "len": len, + "abs": abs, + "min": min, + "max": max, + "sum": sum, + "filter": filter, + "map": map, + "round": round, + "page": "

", + } + ) + mako_safe_template_env = copy.copy(mako_template_env) + mako_safe_template_env.autoescape = False +except ImportError: + pass + + +class ReportDynamicSection(models.Model): + _name = "report.dynamic.section" + _description = "Section blocks for report.dynamic" + + _order = "sequence" + + name = fields.Char() + sequence = fields.Integer(string="Sequence", default=10) + content = fields.Html(string="Content") + dynamic_content = fields.Html( + compute="_compute_dynamic_content", string="Dynamic Content" + ) + report_id = fields.Many2one( + comodel_name="report.dynamic", string="Report", ondelete="cascade" + ) + resource_ref = fields.Reference(related="report_id.render_resource_ref") + res_id = fields.Integer(related="report_id.res_id") + resource_ref_model_id = fields.Many2one( + comodel_name="ir.model", related="report_id.model_id" + ) + + # Dynamic field editor + field_id = fields.Many2one(comodel_name="ir.model.fields", string="Field") + sub_object_id = fields.Many2one(comodel_name="ir.model", string="Sub-model") + sub_model_object_field_id = fields.Many2one( + comodel_name="ir.model.fields", string="Sub-field" + ) + default_value = fields.Char(string="Default Value") + copyvalue = fields.Char(string="Placeholder Expression") + is_paragraph = fields.Boolean( + default=True, string="Paragraph", help="To highlight lines" + ) + condition_python = fields.Text( + string="Python Condition", help="Condition for rendering section" + ) + condition_domain = fields.Char(string="Domain Condition", default="[]") + condition_python_preview = fields.Char( + string="Preview", compute="_compute_condition_python_preview" + ) + model_id_model = fields.Char( + string="Model _description", related="report_id.model_id.model" + ) + + @api.onchange("field_id", "sub_model_object_field_id", "default_value") + def onchange_copyvalue(self): + self.sub_object_id = False + self.copyvalue = False + if self.field_id and not self.field_id.relation: + self.copyvalue = "${{object.{} or {}}}".format( + self.field_id.name, self._get_proper_default_value() + ) + self.sub_model_object_field_id = False + if self.field_id and self.field_id.relation: + self.sub_object_id = self.env["ir.model"].search( + [("model", "=", self.field_id.relation)] + )[0] + if self.sub_model_object_field_id: + self.copyvalue = "${{object.{}.{} or {}}}".format( + self.field_id.name, + self.sub_model_object_field_id.name, + self._get_proper_default_value(), + ) + + # this then needs to be an onchange, since user + # should be able to see preview after setting + # a condition, directly. + @api.onchange("condition_python") + def _compute_condition_python_preview(self): + """Compute condition and preview""" + for rec in self: + rec.condition_python_preview = False + if not (rec.resource_ref_model_id and rec.res_id and rec.resource_ref): + continue + try: + # Check if there are any syntax errors etc + rec.condition_python_preview = rec._eval_condition_python() + except Exception as e: + # and show debug info + rec.condition_python_preview = str(e) + continue + + def _eval_condition_python(self): + if not self.condition_python: + return True + condition_python = (self.condition_python or "").strip() + record = self.resource_ref + return record and safe_eval(condition_python, {"object": record}) + + def _eval_condition_domain(self): + condition_domain = (self.condition_domain or "[]").strip() + record = self.resource_ref + return record and record.filtered_domain(safe_eval(condition_domain)) + + def _get_proper_default_value(self): + self.ensure_one() + is_num = self.field_id.ttype in ("integer", "float") + value = 0 if is_num else "''" + if self.default_value: + if is_num: + value = "{}" + else: + value = "'{}'" + value = value.format(self.default_value) + return value + + # compute the dynamic content for jinja expression + def _compute_dynamic_content(self): + # a parent with two children + h = self._get_header_object() + for rec in self: + try: + if not (rec._eval_condition_python() and rec._eval_condition_domain()): + rec.dynamic_content = "" + continue + prerendered_content = rec._prerender() + content = rec._render_template( + prerendered_content, + rec.resource_ref_model_id.model, + rec.report_id.preview_res_id + if rec.report_id.is_template + else rec.resource_ref.id, + datas={"h": h}, + ) + rec.dynamic_content = content + except Exception: + rec.dynamic_content = "

%s
" % (traceback.format_exc()) + + def _get_header_object(self): + h = Header(child=Header(child=Header())) + return h + + def _prerender(self): + """Substitute expressions using report.dynamic.alias records""" + self.ensure_one() + content = self.content + for alias in self.env["report.dynamic.alias"].search( + [("is_active", "=", True)] + ): + if alias.expression_from not in content: + continue + content = content.replace(alias.expression_from, alias.expression_to) + return content + + @api.model + def _render_template(self, template_txt, model, res_ids, datas=False): + """ + Render input provided by user, for report and preview + It is an edited version of mail.template._render_template() + """ + if isinstance(res_ids, int): + res_ids = [res_ids] + if datas and not isinstance(datas, dict): + raise UserError(_("datas argument is not a proper dict")) + results = dict.fromkeys(res_ids, u"") + # try to load the template + mako_env = mako_safe_template_env + template = mako_env.from_string(tools.ustr(template_txt)) + records = self.env[model].browse( + it for it in res_ids if it + ) # filter to avoid browsing [None] + res_to_rec = dict.fromkeys(res_ids, None) + for record in records: + res_to_rec[record.id] = record + # prepare template variables + variables = {"ctx": self._context} # context kw would clash with mako internals + if datas: + variables.update(datas) + for res_id, record in res_to_rec.items(): + variables["object"] = record + try: + render_result = template.render(variables) + except Exception: + render_result = ( + "
Section {} could not be rendered {}
" + ).format(self.name or "(no name set)", traceback.format_exc()) + if render_result == u"False": + render_result = u"" + results[res_id] = render_result + return results[res_ids[0]] or results diff --git a/report_dynamic/readme/CONTRIBUTORS.rst b/report_dynamic/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..04baf7ec0e --- /dev/null +++ b/report_dynamic/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Tom Blauwendraat +* Nikos Tsirintanis diff --git a/report_dynamic/readme/DESCRIPTION.rst b/report_dynamic/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..72cb8ef06f --- /dev/null +++ b/report_dynamic/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Generate dynamic reports for any applicable model, based on Building Blocks/Sections diff --git a/report_dynamic/readme/INSTALL.rst b/report_dynamic/readme/INSTALL.rst new file mode 100644 index 0000000000..7d5eedb618 --- /dev/null +++ b/report_dynamic/readme/INSTALL.rst @@ -0,0 +1 @@ +* Just Install diff --git a/report_dynamic/readme/ROADMAP.rst b/report_dynamic/readme/ROADMAP.rst new file mode 100644 index 0000000000..e86020c578 --- /dev/null +++ b/report_dynamic/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* Enhance criteria for making models selectable as a target (currently all non-transient) +* Investigate if One2many relations can be rendered diff --git a/report_dynamic/readme/USAGE.rst b/report_dynamic/readme/USAGE.rst new file mode 100644 index 0000000000..0c19db5ea1 --- /dev/null +++ b/report_dynamic/readme/USAGE.rst @@ -0,0 +1,6 @@ +To use this module, you need to: + +1. Create a report template by selecting an appropriate model +2. Enable generating reports by clicking the Create Report Action on top right of the template form view +3. Add sections, paragraphs, domains, and overall create a report, +4. From the Action drop-down menu on a form or tree view select some records and launch the report wizard. Generate reports for each selected record using the template in step 3. diff --git a/report_dynamic/report/report_dynamic_report.xml b/report_dynamic/report/report_dynamic_report.xml new file mode 100644 index 0000000000..84d7480927 --- /dev/null +++ b/report_dynamic/report/report_dynamic_report.xml @@ -0,0 +1,47 @@ + + + + Report Dynamic + report.dynamic + + qweb-pdf + report_dynamic.report_dynamic_document_document + report_dynamic.report_dynamic_document_document + list,form + + + Report Dynamic Preview + report.dynamic + + qweb-html + report_dynamic.report_dynamic_document_document + report_dynamic.report_dynamic_document_document + list,form + + + diff --git a/report_dynamic/security/ir.model.access.csv b/report_dynamic/security/ir.model.access.csv new file mode 100644 index 0000000000..8092986b92 --- /dev/null +++ b/report_dynamic/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_report_dynamic_section_read,Read access on report_dynamic section to employees,model_report_dynamic_section,base.group_user,1,0,0,0 +access_report_dynamic_templ_crud,Full access on report_dynamic template records,model_report_dynamic,report_dynamic.group_report_dynamic_template_editors,1,1,1,1 +access_report_dynamic_report_crud,Full access on report_dynamic report records,model_report_dynamic,report_dynamic.group_report_dynamic_report_editors,1,1,1,1 +access_report_dynamic_report_r,Read access on report_dynamic report records,model_report_dynamic,report_dynamic.group_report_dynamic_report_users,1,0,0,0 +access_report_dynamic_section_full,Full access on report_dynamic section,model_report_dynamic_section,base.group_system,1,1,1,1 +access_report_dynamic_section_editors,Editor access on report_dynamic section,model_report_dynamic_section,report_dynamic.group_report_dynamic_report_editors,1,1,1,1 +access_report_dynamic_alias_full,Full access on report dynamic alias,model_report_dynamic_alias,base.group_system,1,1,1,1 diff --git a/report_dynamic/security/ir_rule.xml b/report_dynamic/security/ir_rule.xml new file mode 100644 index 0000000000..e7a84130b1 --- /dev/null +++ b/report_dynamic/security/ir_rule.xml @@ -0,0 +1,32 @@ + + + + + CRUD report_dynamic + + [('is_template','=', False)] + + + + + CRUD report_dynamic templates + + [(1,'=', 1)] + + + + CRUD report_dynamic reports + + [('is_template','=', False)] + + + + R report_dynamic reports + + [('is_template','=', False)] + + + diff --git a/report_dynamic/security/res_groups.xml b/report_dynamic/security/res_groups.xml new file mode 100644 index 0000000000..469136d8b5 --- /dev/null +++ b/report_dynamic/security/res_groups.xml @@ -0,0 +1,15 @@ + + + + Edit dynamic report template + + + + Edit dynamic report + + + + View dynamic report + + + diff --git a/report_dynamic/static/description/icon.png b/report_dynamic/static/description/icon.png new file mode 100644 index 0000000000..b540778f21 Binary files /dev/null and b/report_dynamic/static/description/icon.png differ diff --git a/report_dynamic/tests/__init__.py b/report_dynamic/tests/__init__.py new file mode 100644 index 0000000000..cd1e6600c4 --- /dev/null +++ b/report_dynamic/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_report_dynamic +from . import test_report_dynamic_section diff --git a/report_dynamic/tests/test_report_dynamic.py b/report_dynamic/tests/test_report_dynamic.py new file mode 100644 index 0000000000..c9b2092cef --- /dev/null +++ b/report_dynamic/tests/test_report_dynamic.py @@ -0,0 +1,155 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from odoo import fields +from odoo.exceptions import ValidationError +from odoo.tests import common + + +class TestWizardReportDynamic(common.SavepointCase): + @classmethod + def setUpClass(cls): + super(TestWizardReportDynamic, cls).setUpClass() + cls.partner_wood_corner = cls.env.ref("base.res_partner_1") + cls.partner_deco_addict = cls.env.ref("base.res_partner_2") + cls.rd_obj = cls.env["report.dynamic"] + cls.rd_template = cls.rd_obj.create( + { + "name": "Template for report", + "model_id": cls.env.ref("base.model_res_partner").id, + "is_template": True, + } + ) + cls.rd_report = cls.rd_obj.create( + { + "name": "Demo report", + "template_id": cls.rd_template.id, + "resource_ref": cls.env.ref("base.res_partner_1"), + } + ) + cls.rd_template2 = cls.rd_obj.create( + { + "name": "Template without_children", + "model_id": cls.env.ref("base.model_res_partner").id, + "is_template": True, + } + ) + cls.section1 = cls.env["report.dynamic.section"].create( + {"report_id": cls.rd_template.id} + ) + + def test_create_report(self): + """ Just a regular report creation from template """ + template = self.rd_template + report = self.rd_obj.new() + report.template_id = template.id + report._onchange_template_id() + self.assertTrue(report.section_ids) + self.assertEquals(report.section_count, template.section_count) + self.assertNotEquals(report.section_ids, template.section_ids) + self.assertTrue(template.section_ids) + + # now make a template from this report + action = report.action_duplicate_as_template() + template = self.rd_obj.browse(action["res_id"]) + self.assertTrue(template.section_ids) + self.assertEquals(template.model_id, report.model_id) + self.assertEquals(template.section_count, report.section_count) + self.assertNotEquals(template.section_ids, report.section_ids) + + def test_action_view_reports(self): + action = self.rd_template.action_view_reports() + self.assertEquals(action["domain"][0][2], [self.rd_report.id]) + + def test_write_resource_ref(self): + """ Test inverse write on resource_ref """ + res_partner_model = self.env.ref("base.model_res_partner") + self.rd_report.write( + { + "resource_ref": "{},{}".format( + res_partner_model.model, self.partner_wood_corner.id + ) + } + ) + self.assertEquals(self.rd_report.res_id, self.rd_report.resource_ref.id) + self.assertEquals(self.rd_report.model_id, res_partner_model) + + def test_forbid_template_change(self): + """ Test that you can't switch a template with reports connected to it """ + with self.assertRaises(ValidationError): + self.rd_template.write({"is_template": False}) + + def test_forbid_model_change(self): + """ Test that you can't change a template's model with reports connected to it """ + with self.assertRaises(ValidationError): + self.rd_template.write( + { + "model_id": self.env["ir.model"].search( + [("id", "!=", self.rd_template.model_id.id)], limit=1 + ) + } + ) + + def test_window_action(self): + """ Tests things related to window actions """ + # Action should not exist. + self.assertFalse(self.rd_template.window_action_exists) + self.rd_template.create_action() + self.rd_template._compute_window_action_exists() + self.assertTrue(self.rd_template.window_action_exists) + # Call create_action again, and see that nothing really happens + self.rd_template.create_action() + self.assertEqual(len(self.rd_template.get_window_actions()), 1) + # unlink action + self.rd_template.unlink_action() + self.assertFalse(self.rd_template.window_action_exists) + self.rd_template.create_action() + # unlink the reports and see that window action is there + self.rd_report.unlink() + self.assertTrue(self.rd_template.window_action_exists) + # unlink the template and see that the action is gone + this_model = self.rd_template.model_id + self.rd_template.unlink() + self.assertFalse( + self.env["ir.actions.act_window"].search( + [ + ("res_model", "=", "wizard.report.dynamic"), + ("binding_model_id", "=", this_model.id), + ] + ) + ) + + def test_wizards(self): + wiz_model = self.env["wizard.report.dynamic"] + # TODO emulate form + wiz = wiz_model.create({"template_id": self.rd_template.id}) + ctx = { + "active_model": "res.partner", + "active_ids": [self.partner_wood_corner.id], + } + action = wiz.with_context(ctx).action_generate_reports() + report_id = action.get("domain")[0][2] + report = self.env["report.dynamic"].browse(report_id) + + self.assertEquals(len(report), 1) + self.assertEquals(report.res_id, self.partner_wood_corner.id) + self.assertEquals(report.template_id, self.rd_template) + + # unlocked + self.assertEquals(report.lock_date, False) + # lock + wiz_lock = self.env["wizard.lock.report"].create({"report_id": report.id}) + wiz_lock.action_lock_report() + self.assertEquals(report.lock_date, fields.Date.today()) + + def test_default_wrapper(self): + self.rd_template.wrapper_report_id = False + self.assertEqual(self.rd_template.get_template_xml_id(), "web.external_layout") + + def test_preview_record(self): + self.rd_template.condition_domain_global = [ + ("name", "=", self.partner_wood_corner.name) + ] + self.assertEqual(self.rd_template.preview_res_id, self.partner_wood_corner.id) + self.assertEqual( + self.rd_template.preview_res_id_display_name, + self.partner_wood_corner.display_name, + ) diff --git a/report_dynamic/tests/test_report_dynamic_section.py b/report_dynamic/tests/test_report_dynamic_section.py new file mode 100644 index 0000000000..dee65486be --- /dev/null +++ b/report_dynamic/tests/test_report_dynamic_section.py @@ -0,0 +1,121 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from odoo.tests import common + + +class TestWizardReportDynamicSection(common.SavepointCase): + @classmethod + def setUp(cls): + super(TestWizardReportDynamicSection, cls).setUpClass() + cls.rd_obj = cls.env["report.dynamic"] + cls.partner_wood_corner = cls.env.ref("base.res_partner_1") + cls.partner_deco_addict = cls.env.ref("base.res_partner_2") + cls.rd_template = cls.rd_obj.create( + { + "name": "Template for report", + "model_id": cls.env.ref("base.model_res_partner").id, + "is_template": True, + } + ) + cls.tpl_section = cls.env["report.dynamic.section"].create( + { + "report_id": cls.rd_template.id, + "content": "

Some Green content

", + "condition_python": "object.name.startswith('D')", + } + ) + cls.rd_report = cls.rd_obj.new() + cls.rd_report.template_id = cls.rd_template.id + cls.rd_report._onchange_template_id() + cls.rd_report.res_id = cls.partner_wood_corner.id + cls.rd_report._onchange_template_id() + cls.report_section = cls.rd_report.section_ids + + cls.alias = cls.env["report.dynamic.alias"].create( + {"expression_from": "Green", "expression_to": "Blue"} + ) + + def test_setup_class_data(self): + self.assertEquals(self.rd_report.model_id.model, "res.partner") + self.assertTrue(self.rd_report.resource_ref) + self.assertEquals(len(self.report_section), 1) + self.assertIn("D", self.report_section.condition_python) + + def test_action_view_sections(self): + action = self.rd_template.action_view_sections() + self.assertTrue(self.rd_template.section_ids) + self.assertEquals(action["domain"][0][2], self.rd_template.section_ids.ids) + + def test_section_count(self): + self.assertEquals(self.rd_template.section_count, 1) + self.env["report.dynamic.section"].create({"report_id": self.rd_template.id}) + self.assertEqual(self.rd_template.section_count, 2) + + def test_condition_python_preview(self): + # Initial selected partner record is 'Wood Corner' + section = self.report_section + self.assertEquals(section.resource_ref, self.partner_wood_corner) + # Which does not start with 'D' + self.assertFalse(section.condition_python_preview) + self.assertFalse(section.dynamic_content) + # But now simulate more favourable conditions + section.condition_python = 'object.name.startswith("W")' + section._compute_condition_python_preview() + self.assertTrue(section.condition_python_preview) + self.assertTrue(section.resource_ref) + section._compute_dynamic_content() + self.assertTrue(section.dynamic_content) + # Check for syntax errors + section.condition_python = 'object_name.startswith("D")' + section._compute_condition_python_preview() + # read preview + self.assertEqual( + section.condition_python_preview, + ": " + "\"name 'object_name' is not defined" + '" while evaluating\n\'object_name.startswith("D")\'', + ) + # nullify condition python + # an empty python_condition evaluates to string True + section.condition_python = False + section._compute_condition_python_preview() + self.assertEqual(section.condition_python_preview, "True") + # Finally, check that preview gets False if there's no record + section.resource_ref = False + section._compute_condition_python_preview() + self.assertFalse(section.condition_python_preview) + + def test_compute_dynamic_content(self): + section = self.report_section + self.assertFalse(section.dynamic_content) + section.condition_python = 'object.name.startswith("W")' + self.alias.active = False + self.assertIn("Green", section.content) + self.alias.active = True + section._compute_dynamic_content() + self.assertIn("Blue", section.dynamic_content) + + def test_header(self): + header = self.report_section._get_header_object() + # a header with a child and a grandchild + self.assertTrue(header.child) + self.assertTrue(header.child.child) + self.assertFalse(header.value) + header.next # pylint: disable=pointless-statement + header.child.next # pylint: disable=pointless-statement + header.child.child.next # pylint: disable=pointless-statement + self.assertEqual(header.value, 1) + self.assertEqual(header.child.value, 1) + self.assertEqual(header.child.child.value, 1) + header.next # pylint: disable=pointless-statement + self.assertEqual(header.value, 2) + header.previous # pylint: disable=pointless-statement + self.assertEqual(header.value, 1) + self.assertEqual(header.child.value, 0) + self.assertEqual(header.child.child.value, 0) + header.child.next # pylint: disable=pointless-statement + self.assertEqual(header.child.value, 1) + self.assertEqual(header.child.child.value, 0) + header.child.child.next # pylint: disable=pointless-statement + self.assertEqual(header.child.child.value, 1) + header.reset() + self.assertFalse(header.value + header.child.value + header.child.child.value) diff --git a/report_dynamic/utils.py b/report_dynamic/utils.py new file mode 100644 index 0000000000..5eb28114f9 --- /dev/null +++ b/report_dynamic/utils.py @@ -0,0 +1,26 @@ +class Header: + def __init__(self, child=False, parent=False): + self.value = 0 + self.base_value = 0 + self.child = child + if parent: + parent.child = self + + @property + def next(self): + self.value += 1 + if self.child: + self.child.reset() + return self.value + + @property + def previous(self): + if self.value: + self.value -= 1 + return self.value + + def reset(self): + self.value = self.base_value + if self.child: + self.child.reset() + return self.value diff --git a/report_dynamic/views/report_dynamic.xml b/report_dynamic/views/report_dynamic.xml new file mode 100644 index 0000000000..4c7bce1e30 --- /dev/null +++ b/report_dynamic/views/report_dynamic.xml @@ -0,0 +1,286 @@ + + + + report.dynamic.form + report.dynamic + +
+ + + +
+ + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + report.dynamic.tree + report.dynamic + + + + + + + + + + + + + report.dynamic.search + report.dynamic + + + + + + + + + + + + + + + + + + + + Templates + report.dynamic + tree,form + [('is_template', '=', True)] + {'default_is_template': True, 'is_template':True} + + + + + Reports + report.dynamic + [('is_template', '=', False)] + tree,form + + +
diff --git a/report_dynamic/views/report_dynamic_alias.xml b/report_dynamic/views/report_dynamic_alias.xml new file mode 100644 index 0000000000..57ca677bc6 --- /dev/null +++ b/report_dynamic/views/report_dynamic_alias.xml @@ -0,0 +1,26 @@ + + + + report.dynamic.alias.tree + report.dynamic.alias + + + + + + + + + + Aliases + report.dynamic.alias + tree,form + + + diff --git a/report_dynamic/views/report_dynamic_section.xml b/report_dynamic/views/report_dynamic_section.xml new file mode 100644 index 0000000000..87e2bedd53 --- /dev/null +++ b/report_dynamic/views/report_dynamic_section.xml @@ -0,0 +1,114 @@ + + + + report.dynamic.section.search + search + report.dynamic.section + + + + + + + + + + report.dynamic.section.tree + report.dynamic.section + + + + + + + + + + + + report.dynamic.section.form + report.dynamic.section + +
+ +
+
+
+
+ + + + +
+
+
+ + + + + + + +
+

You can add a condition to show or hide this block, either a Python expression that should evaluate to a boolean value (eg. "object.gender == 'male'" or "not object.parent_id") or a domain expression eg. [('name', '=ilike', 'John%')]).

+ +

For the domain expressions, you can arrive at a functional domain without having to type anything: simply open the popup with Matched records, then make a search for the records that you want to match. Select one or all of them with the checkbox and return to this dialog. The domain condition will have updated to a value that will match the rules that you followed in creating the selection. (Note: for this you need to have the OCA module web_widget_domain_editor_dialog installed). +

+
+
+ + + + + + + + + + + +
+
+
+
+
+
+
diff --git a/report_dynamic/wizards/__init__.py b/report_dynamic/wizards/__init__.py new file mode 100644 index 0000000000..7a42b13f1e --- /dev/null +++ b/report_dynamic/wizards/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import wizard_lock_report +from . import wizard_report_dynamic diff --git a/report_dynamic/wizards/wizard_lock_report.py b/report_dynamic/wizards/wizard_lock_report.py new file mode 100644 index 0000000000..907a43c944 --- /dev/null +++ b/report_dynamic/wizards/wizard_lock_report.py @@ -0,0 +1,18 @@ +# Copyright 2022 Sunflower IT +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class WizardLockReport(models.TransientModel): + """Lock report at given date, make it readonly""" + + _name = "wizard.lock.report" + _description = "Provide lock date for report" + + report_id = fields.Many2one(comodel_name="report.dynamic", string="Report") + lock_date = fields.Date(default=fields.Date.today(), required=True) + + def action_lock_report(self): + """Lock the report on given date""" + self.ensure_one() + self.report_id.lock_date = self.lock_date diff --git a/report_dynamic/wizards/wizard_lock_report.xml b/report_dynamic/wizards/wizard_lock_report.xml new file mode 100644 index 0000000000..e1acfb1ce5 --- /dev/null +++ b/report_dynamic/wizards/wizard_lock_report.xml @@ -0,0 +1,23 @@ + + + + wizard.lock.report.form.view + wizard.lock.report + +
+ + + +
+
+
+
+
+
diff --git a/report_dynamic/wizards/wizard_report_dynamic.py b/report_dynamic/wizards/wizard_report_dynamic.py new file mode 100644 index 0000000000..3b0c4a64e8 --- /dev/null +++ b/report_dynamic/wizards/wizard_report_dynamic.py @@ -0,0 +1,51 @@ +# Copyright 2022 Sunflower IT +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import _, fields, models + + +class WizardReportDynamic(models.TransientModel): + """Generate reports in bulk""" + + _name = "wizard.report.dynamic" + _description = "Select template for report for record(s)" + + template_id = fields.Many2one( + comodel_name="report.dynamic", + domain=lambda self: [ + ("is_template", "=", True), + ("model_id.model", "=", self.env.context.get("active_model")), + ], + ) + model_id = fields.Many2one(related="template_id.model_id", readonly=True) + + def action_generate_reports(self): + """Generate reports for given template_id""" + active_model = self.env.context.get("active_model") + active_ids = self.env.context.get("active_ids") + records = self.env[active_model].browse(active_ids) + reports = self.env["report.dynamic"] + for record in records: + if record._name != self.template_id.model_model: + continue + # check that the record has a name field + if hasattr(record, "name"): + record_name = record.name + else: + record_name = _("Model %s, id %s") % (record._name, record.id) + report = self.template_id.copy( + { + "is_template": False, + "name": record_name, + "res_id": record.id, + "template_id": self.template_id.id, + } + ) + reports += report + return { + "name": "Generated Reports", + "type": "ir.actions.act_window", + "res_model": "report.dynamic", + "domain": [("id", "in", reports.ids)], + "view_mode": "tree,form", + "target": "current", + } diff --git a/report_dynamic/wizards/wizard_report_dynamic.xml b/report_dynamic/wizards/wizard_report_dynamic.xml new file mode 100644 index 0000000000..0ae172e2f3 --- /dev/null +++ b/report_dynamic/wizards/wizard_report_dynamic.xml @@ -0,0 +1,24 @@ + + + + wizard.report.dynamicform.view + wizard.report.dynamic + +
+ + + + + +
+
+
+
diff --git a/setup/report_dynamic/odoo/addons/report_dynamic b/setup/report_dynamic/odoo/addons/report_dynamic new file mode 120000 index 0000000000..9a9a04fc6d --- /dev/null +++ b/setup/report_dynamic/odoo/addons/report_dynamic @@ -0,0 +1 @@ +../../../../report_dynamic \ No newline at end of file diff --git a/setup/report_dynamic/setup.py b/setup/report_dynamic/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/report_dynamic/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)