diff --git a/document_page_reference/__manifest__.py b/document_page_reference/__manifest__.py index 2c6b56fd..988fc223 100644 --- a/document_page_reference/__manifest__.py +++ b/document_page_reference/__manifest__.py @@ -5,18 +5,18 @@ "name": "Document Page Reference", "summary": """ Include references on document pages""", - "version": "16.0.1.0.1", + "version": "18.0.1.0.0", "license": "AGPL-3", "author": "Creu Blanca,Odoo Community Association (OCA)", "website": "https://github.com/OCA/knowledge", - "depends": ["document_page", "web_editor"], + "depends": ["document_page"], "data": [ "views/document_page.xml", "views/report_document_page.xml", ], "assets": { "web.assets_backend": [ - "document_page_reference/static/src/js/**/*", + "document_page_reference/static/src/js/editor.esm.js", ], }, "maintainers": ["etobella"], diff --git a/document_page_reference/models/document_page.py b/document_page_reference/models/document_page.py index 5c5fd37e..f745ee41 100644 --- a/document_page_reference/models/document_page.py +++ b/document_page_reference/models/document_page.py @@ -2,57 +2,29 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging +import re -from odoo import _, api, fields, models, tools +from jinja2.sandbox import SandboxedEnvironment +from markupsafe import Markup + +from odoo import _, api, fields, models from odoo.exceptions import ValidationError -from odoo.tools.misc import html_escape - -from odoo.addons.http_routing.models.ir_http import slugify +from odoo.tools import html_escape _logger = logging.getLogger(__name__) - -try: - import re - - from jinja2 import Undefined - from jinja2.lexer import name_re as old_name_re - from jinja2.sandbox import SandboxedEnvironment - - name_re = re.compile("^%s$" % old_name_re.pattern) - - class Context(SandboxedEnvironment.context_class): - def resolve(self, key): - res = super().resolve(key) - if not isinstance(res, Undefined): - return res - return self.parent["ref"](key) - - class Environment(SandboxedEnvironment): - context_class = Context - - mako_template_env = Environment( - block_start_string="<%", - block_end_string="%>", - variable_start_string="${", - variable_end_string="}", - comment_start_string="<%doc>", - comment_end_string="%doc>", - line_statement_prefix="%", - line_comment_prefix="##", - trim_blocks=True, # do not output newline after blocks - autoescape=False, - ) -except Exception: - _logger.error("Jinja2 is not available") +env = SandboxedEnvironment(autoescape=False) class DocumentPage(models.Model): _inherit = "document.page" + _description = "Document Page" reference = fields.Char( help="Used to find the document, it can contain letters, numbers and _" ) - content_parsed = fields.Html(compute="_compute_content_parsed") + content_parsed = fields.Html( + "Parsed Content", compute="_compute_content_parsed", sanitize=False, store=True + ) def get_formview_action(self, access_uid=None): res = super().get_formview_action(access_uid) @@ -60,90 +32,71 @@ class DocumentPage(models.Model): res["views"] = [(view_id, "form")] return res - @api.depends("history_head") + @api.depends("content") def _compute_content_parsed(self): for record in self: - content = record.get_content() - if content == "
" and self.content != "
": - _logger.error( - "Template from page with id = %s cannot be processed correctly" - % self.id - ) - content = self.content - record.content_parsed = content + try: + raw = record.content or "" + converted = re.sub(r"\$\{([\w_]+)\}", r"{{ resolve('\1') }}", raw) + template = env.from_string(converted) + rendered = template.render(resolve=record._resolve_reference) + record.content_parsed = rendered + except Exception as e: + _logger.info("Render failed for %s: %s", record.id, e) + record.content_parsed = record.content or "" @api.constrains("reference") - def _check_reference(self): - for record in self: - if not record.reference: + def _check_reference_validity(self): + for rec in self: + if not rec.reference: continue - record._validate_reference(record=record) - - @api.model - def _validate_reference(self, record=None, reference=None): - if not reference: - reference = self.reference - if not name_re.match(reference): - raise ValidationError(_("Reference is not valid")) - uniq_domain = [("reference", "=", reference)] - if record: - uniq_domain += [("id", "!=", record.id)] - if self.search(uniq_domain): - raise ValidationError(_("Reference must be unique")) + regex = r"^[a-zA-Z_][a-zA-Z0-9_]*$" + if not re.match(regex, rec.reference): + raise ValidationError(_("Reference is not valid")) + domain = [("reference", "=", rec.reference), ("id", "!=", rec.id)] + if self.search(domain): + raise ValidationError(_("Reference must be unique")) def _get_document(self, code): - # Hook created in order to add check on other models - document = self.search([("reference", "=", code)]) - if document: - return document - else: - return self.env[self._name] - - def get_reference(self, code): - element = self._get_document(code) - if self.env.context.get("raw_reference", False): - return html_escape(element.display_name) - text = """%s - """ - if not element: - text = "%s" % text - res = text % ( - element._name, - element and element.id or "", - code, - html_escape(element.display_name or code), - ) - return res - - def _get_template_variables(self): - return {"ref": self.get_reference} + return self.search([("reference", "=", code)], limit=1) def get_content(self): - try: - content = self.content - mako_env = mako_template_env - template = mako_env.from_string(tools.ustr(content)) - return template.render(self._get_template_variables()) - except Exception: - _logger.error( - "Template from page with id = %s cannot be processed" % self.id + for record in self: + try: + raw = record.content or "" + converted = re.sub(r"\$\{([\w_]+)\}", r"{{ resolve('\1') }}", raw) + template = env.from_string(converted) + return template.render(resolve=record._resolve_reference) + except Exception: + _logger.error( + "Template from page with id = %s cannot be processed", record.id + ) + return record.content + + def _resolve_reference(self, code): + doc = self._get_document(code) + if self.env.context.get("raw_reference", False): + return html_escape(doc.display_name if doc else code) + sanitized_code = html_escape(code) + if not doc: + return ( + f"{sanitized_code}" ) - return self.content + return ( + f"" + f"{html_escape(doc.display_name)}" + ) def get_raw_content(self): - return self.with_context(raw_reference=True).get_content() + return Markup(self.with_context(raw_reference=True).get_content()) @api.model_create_multi def create(self, vals_list): for vals in vals_list: - if not vals.get("reference"): - # Propose a default reference - reference = slugify(vals.get("name")).replace("-", "_") - try: - self._validate_reference(reference=reference) - vals["reference"] = reference - except ValidationError: # pylint: disable=W8138 - # Do not fill reference. - pass + if not vals.get("reference") and vals.get("name"): + reference = self.env["ir.http"]._slugify(vals["name"]).replace("-", "_") + vals["reference"] = reference return super().create(vals_list) diff --git a/document_page_reference/static/src/js/editor.esm.js b/document_page_reference/static/src/js/editor.esm.js new file mode 100644 index 00000000..416bfb85 --- /dev/null +++ b/document_page_reference/static/src/js/editor.esm.js @@ -0,0 +1,32 @@ +import {HtmlField, htmlField} from "@web/views/fields/html/html_field"; +import {onMounted} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; + +class DocumentPageReferenceField extends HtmlField { + setup() { + super.setup(); + this.orm = useService("orm"); + this.action = useService("action"); + onMounted(() => { + // eslint-disable-next-line no-undef + const links = document.querySelectorAll(".oe_direct_line"); + links.forEach((link) => { + link.addEventListener("click", (event) => + this._onClickDirectLink(event) + ); + }); + }); + } + _onClickDirectLink(event) { + const {oeModel: model, oeId} = event.target.dataset; + const id = parseInt(oeId, 10); + this.orm.call(model, "get_formview_action", [[id]], {}).then((action) => { + this.action.doAction(action); + }); + } +} +registry.category("fields").add("document_page_reference", { + ...htmlField, + component: DocumentPageReferenceField, +}); diff --git a/document_page_reference/static/src/js/editor.js b/document_page_reference/static/src/js/editor.js deleted file mode 100644 index 852df084..00000000 --- a/document_page_reference/static/src/js/editor.js +++ /dev/null @@ -1,34 +0,0 @@ -odoo.define("document_page_reference.backend", function (require) { - "use strict"; - - var field_registry = require("web.field_registry"); - var FieldTextHtmlSimple = require("web_editor.field.html"); - var FieldDocumentPage = FieldTextHtmlSimple.extend({ - events: _.extend({}, FieldTextHtmlSimple.prototype.events, { - "click .oe_direct_line": "_onClickDirectLink", - }), - _onClickDirectLink: function (event) { - var self = this; - event.preventDefault(); - event.stopPropagation(); - var element = $(event.target).closest(".oe_direct_line")[0]; - var default_reference = element.name; - var model = $(event.target).data("oe-model"); - var id = $(event.target).data("oe-id"); - var context = this.record.getContext(this.recordParams); - if (default_reference) { - context.default_reference = default_reference; - } - this._rpc({ - model: model, - method: "get_formview_action", - args: [[parseInt(id, 10)]], - context: context, - }).then(function (action) { - self.trigger_up("do_action", {action: action}); - }); - }, - }); - field_registry.add("document_page_reference", FieldDocumentPage); - return FieldDocumentPage; -}); diff --git a/document_page_reference/tests/test_document_reference.py b/document_page_reference/tests/test_document_reference.py index 1a2ba268..73581b8b 100644 --- a/document_page_reference/tests/test_document_reference.py +++ b/document_page_reference/tests/test_document_reference.py @@ -18,61 +18,48 @@ class TestDocumentReference(TransactionCase): {"name": "Test Page 1", "content": "${r1}", "reference": "r2"} ) - def test_constrains_01(self): + def test_constraints_duplicate_reference(self): + """Should raise if reference is not unique (same as another).""" with self.assertRaises(ValidationError): self.page2.write({"reference": self.page1.reference}) - def test_constrains_02(self): + def test_constraints_invalid_reference(self): + """Should raise if reference does not match the required pattern.""" with self.assertRaises(ValidationError): self.page2.write({"reference": self.page2.reference + "-02"}) - def test_no_contrains(self): - self.page1.write({"reference": False}) - self.page2.write({"reference": False}) - self.assertEqual(self.page1.reference, self.page2.reference) - - def test_check_raw(self): - self.assertEqual(self.page2.display_name, self.page1.get_raw_content()) - - def test_check_reference(self): - self.assertRegex(self.page1.content_parsed, ".*%s.*" % self.page2.display_name) - - def test_no_reference(self): - self.page2.reference = "r3" - self.assertRegex(self.page1.content_parsed, ".*r2.*") - def test_auto_reference(self): """Test if reference is proposed when saving a page without one.""" self.assertEqual(self.page1.reference, "R1") new_page = self.page_obj.create( - {"name": "Test Page with no rEfErenCe", "content": "some content"} + {"name": "Test Page with no reference", "content": "some content"} ) self.assertEqual(new_page.reference, "test_page_with_no_reference") - new_page_duplicated_name = self.page_obj.create( - { - "name": "test page with no reference", - "content": "this should have an empty reference " - "because reference must be unique", - } - ) - self.assertFalse(new_page_duplicated_name.reference) + with self.assertRaises(ValidationError): + new_page_duplicated_name = self.page_obj.create( + { + "name": "test page with no reference", + "content": "this should have an empty reference " + "because reference must be unique", + } + ) + self.assertFalse(new_page_duplicated_name.reference) def test_get_formview_action(self): res = self.page1.get_formview_action() view_id = self.env.ref("document_page.view_wiki_form").id - expected_result = { + expected_keys = { "type": "ir.actions.act_window", - "context": {}, "res_model": "document.page", "res_id": self.page1.id, - "view_mode": "form", - "view_type": "form", + "context": {}, "target": "current", "views": [(view_id, "form")], } - self.assertEqual(res, expected_result) + for key, expected_value in expected_keys.items(): + self.assertEqual(res.get(key), expected_value, f"Mismatch in key: {key}") def test_compute_content_parsed(self): - self.page1.content = "
" + self.page1.content = "
" self.page1._compute_content_parsed() self.assertEqual(str(self.page1.content_parsed), "") diff --git a/document_page_reference/views/document_page.xml b/document_page_reference/views/document_page.xml index a9459fcc..9f4c7ce6 100644 --- a/document_page_reference/views/document_page.xml +++ b/document_page_reference/views/document_page.xml @@ -27,6 +27,7 @@ +