[MIG] document_page_reference: Migration to 17.0

[FIX]document_page_reference :log warning updated

[FIX]document_page_reference: null value issue fixed

Co-authored-by: Anusha <anusha.pp@calin.co.in>
This commit is contained in:
Anusha 2025-06-13 18:13:44 +02:00 committed by Víctor Martínez
parent c8d3a289d9
commit 4ba3272d01
10 changed files with 128 additions and 186 deletions

View File

@ -39,7 +39,7 @@ the link between document pages.
Usage Usage
===== =====
When editing a document page add elements like ${XXX} where XXX is the When editing a document page add elements like {{XXX}} where XXX is the
reference of another page. Now, when viewing the document, it will link reference of another page. Now, when viewing the document, it will link
directly to the page. Also, the name will be parsed as the display name. directly to the page. Also, the name will be parsed as the display name.

View File

@ -3,20 +3,19 @@
{ {
"name": "Document Page Reference", "name": "Document Page Reference",
"summary": """ "summary": "Include references on document pages",
Include references on document pages""", "version": "17.0.1.0.0",
"version": "16.0.1.0.1",
"license": "AGPL-3", "license": "AGPL-3",
"author": "Creu Blanca,Odoo Community Association (OCA)", "author": "Creu Blanca,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/knowledge", "website": "https://github.com/OCA/knowledge",
"depends": ["document_page", "web_editor"], "depends": ["document_page"],
"data": [ "data": [
"views/document_page.xml", "views/document_page.xml",
"views/report_document_page.xml", "views/report_document_page.xml",
], ],
"assets": { "assets": {
"web.assets_backend": [ "web.assets_backend": [
"document_page_reference/static/src/js/**/*", "document_page_reference/static/src/js/editor.esm.js",
], ],
}, },
"maintainers": ["etobella"], "maintainers": ["etobella"],

View File

@ -0,0 +1,12 @@
# Copyright 2025 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import re
from openupgradelib import openupgrade
@openupgrade.migrate()
def migrate(env, version):
for item in env["document.page"].search([("content", "ilike", "${")]):
item.content = re.sub(r"\${(.+)}", r"{{\1}}", item.content)

View File

@ -1,50 +1,17 @@
# Copyright 2019 Creu Blanca # Copyright 2019 Creu Blanca
# Copyright 2025 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging import re
from odoo import _, api, fields, models, tools from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.tools.misc import html_escape from odoo.tools import html_escape
from odoo.addons.http_routing.models.ir_http import slugify from odoo.addons.http_routing.models.ir_http import slugify
_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")
class DocumentPage(models.Model): class DocumentPage(models.Model):
_inherit = "document.page" _inherit = "document.page"
@ -52,7 +19,9 @@ class DocumentPage(models.Model):
reference = fields.Char( reference = fields.Char(
help="Used to find the document, it can contain letters, numbers and _" 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): def get_formview_action(self, access_uid=None):
res = super().get_formview_action(access_uid) res = super().get_formview_action(access_uid)
@ -60,90 +29,56 @@ class DocumentPage(models.Model):
res["views"] = [(view_id, "form")] res["views"] = [(view_id, "form")]
return res return res
@api.depends("history_head") @api.depends("content")
def _compute_content_parsed(self): def _compute_content_parsed(self):
for record in self: for record in self:
content = record.get_content() record.content_parsed = record.get_content()
if content == "<p>" and self.content != "<p>":
_logger.error(
"Template from page with id = %s cannot be processed correctly"
% self.id
)
content = self.content
record.content_parsed = content
@api.constrains("reference") @api.constrains("reference")
def _check_reference(self): def _check_reference_validity(self):
for record in self: for rec in self:
if not record.reference: if not rec.reference:
continue continue
record._validate_reference(record=record) regex = r"^[a-zA-Z_][a-zA-Z0-9_]*$"
if not re.match(regex, rec.reference):
@api.model raise ValidationError(_("Reference is not valid"))
def _validate_reference(self, record=None, reference=None): domain = [("reference", "=", rec.reference), ("id", "!=", rec.id)]
if not reference: if self.search(domain):
reference = self.reference raise ValidationError(_("Reference must be unique"))
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"))
def _get_document(self, code): def _get_document(self, code):
# Hook created in order to add check on other models return self.search([("reference", "=", code)], limit=1)
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 = """<a href="#" class="oe_direct_line"
data-oe-model="%s" data-oe-id="%s" name="%s">%s</a>
"""
if not element:
text = "<i>%s</i>" % 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}
def get_content(self): def get_content(self):
try: self.ensure_one()
content = self.content content_parsed = raw = self.content or ""
mako_env = mako_template_env for text in re.findall(r"\{\{.*?\}\}", raw):
template = mako_env.from_string(tools.ustr(content)) reference = text.replace("{{", "").replace("}}", "")
return template.render(self._get_template_variables()) content_parsed = content_parsed.replace(
except Exception: text, self._resolve_reference(reference)
_logger.error(
"Template from page with id = %s cannot be processed" % self.id
) )
return self.content return content_parsed
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)
oe_model = doc._name if doc else self._name
oe_id = doc.id if doc else ""
name = html_escape(doc.display_name) if doc else sanitized_code
return (
f"<a href='#' class='oe_direct_line' data-oe-model='{oe_model}' "
f"data-oe-id='{oe_id}' name='{sanitized_code}'>"
f"{name}</a>"
)
def get_raw_content(self): 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 @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
for vals in vals_list: for vals in vals_list:
if not vals.get("reference"): if not vals.get("reference") and vals.get("name"):
# Propose a default reference vals["reference"] = slugify(vals["name"]).replace("-", "_")
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
return super().create(vals_list) return super().create(vals_list)

View File

@ -1,3 +1,3 @@
When editing a document page add elements like \${XXX} where XXX is the When editing a document page add elements like {{XXX}} where XXX is the
reference of another page. Now, when viewing the document, it will link reference of another page. Now, when viewing the document, it will link
directly to the page. Also, the name will be parsed as the display name. directly to the page. Also, the name will be parsed as the display name.

View File

@ -387,7 +387,7 @@ the link between document pages.</p>
</div> </div>
<div class="section" id="usage"> <div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1> <h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1>
<p>When editing a document page add elements like ${XXX} where XXX is the <p>When editing a document page add elements like {{XXX}} where XXX is the
reference of another page. Now, when viewing the document, it will link reference of another page. Now, when viewing the document, it will link
directly to the page. Also, the name will be parsed as the display name.</p> directly to the page. Also, the name will be parsed as the display name.</p>
</div> </div>

View File

@ -0,0 +1,33 @@
/** @odoo-module **/
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,
});

View File

@ -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;
});

View File

@ -1,78 +1,75 @@
# Copyright 2019 Creu Blanca # Copyright 2019 Creu Blanca
# Copyright 2025 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from markupsafe import Markup
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase
from odoo.addons.base.tests.common import BaseCommon
class TestDocumentReference(TransactionCase): class TestDocumentReference(BaseCommon):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
cls.page_obj = cls.env["document.page"] cls.page_obj = cls.env["document.page"]
cls.history_obj = cls.env["document.page.history"] cls.history_obj = cls.env["document.page.history"]
cls.page1 = cls.page_obj.create( cls.page1 = cls.page_obj.create(
{"name": "Test Page 1", "content": "${r2}", "reference": "R1"} {"name": "Test Page 1", "content": Markup("{{r2}}"), "reference": "R1"}
) )
cls.page2 = cls.page_obj.create( cls.page2 = cls.page_obj.create(
{"name": "Test Page 1", "content": "${r1}", "reference": "r2"} {"name": "Test Page 1", "content": Markup("{{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): with self.assertRaises(ValidationError):
self.page2.write({"reference": self.page1.reference}) 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): with self.assertRaises(ValidationError):
self.page2.write({"reference": self.page2.reference + "-02"}) self.page2.write({"reference": self.page2.reference + "-02"})
def test_no_contrains(self): def test_no_contrains(self):
self.page1.write({"reference": False}) self.page1.write({"reference": False})
self.assertFalse(self.page1.reference)
self.page2.write({"reference": False}) self.page2.write({"reference": False})
self.assertEqual(self.page1.reference, self.page2.reference) self.assertFalse(self.page2.reference)
def test_check_raw(self): def test_check_raw(self):
self.assertEqual(self.page2.display_name, self.page1.get_raw_content()) 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): def test_auto_reference(self):
"""Test if reference is proposed when saving a page without one.""" """Test if reference is proposed when saving a page without one."""
self.assertEqual(self.page1.reference, "R1") self.assertEqual(self.page1.reference, "R1")
new_page = self.page_obj.create( 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") self.assertEqual(new_page.reference, "test_page_with_no_reference")
new_page_duplicated_name = self.page_obj.create( 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 " "name": "test page with no reference",
"because reference must be unique", "content": "this should have an empty reference "
} "because reference must be unique",
) }
self.assertFalse(new_page_duplicated_name.reference) )
self.assertFalse(new_page_duplicated_name.reference)
def test_get_formview_action(self): def test_get_formview_action(self):
res = self.page1.get_formview_action() res = self.page1.get_formview_action()
view_id = self.env.ref("document_page.view_wiki_form").id view_id = self.env.ref("document_page.view_wiki_form").id
expected_result = { expected_keys = {
"type": "ir.actions.act_window", "type": "ir.actions.act_window",
"context": {},
"res_model": "document.page", "res_model": "document.page",
"res_id": self.page1.id, "res_id": self.page1.id,
"view_mode": "form",
"view_type": "form",
"target": "current", "target": "current",
"views": [(view_id, "form")], "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): def test_compute_content_parsed(self):
self.page1.content = "<p>" self.page1.content = Markup("<p></p>")
self.page1._compute_content_parsed() self.assertEqual(self.page1.content_parsed, Markup("<p></p>"))
self.assertEqual(str(self.page1.content_parsed), "<p></p>")

View File

@ -4,11 +4,11 @@
id="report_documentpage_doc" id="report_documentpage_doc"
inherit_id="document_page.report_documentpage_doc" inherit_id="document_page.report_documentpage_doc"
> >
<xpath expr="//div[@t-raw='doc.content']" position="attributes"> <xpath expr="//div[@t-out='doc.content']" position="attributes">
<attribute name="t-if">1==0</attribute> <attribute name="t-if">1==0</attribute>
</xpath> </xpath>
<xpath expr="//div[@t-raw='doc.content']" position="after"> <xpath expr="//div[@t-out='doc.content']" position="after">
<div t-raw="doc.get_raw_content()" /> <t t-out="doc.get_raw_content()" />
</xpath> </xpath>
</template> </template>
</odoo> </odoo>