[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
=====
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
directly to the page. Also, the name will be parsed as the display name.

View File

@ -3,20 +3,19 @@
{
"name": "Document Page Reference",
"summary": """
Include references on document pages""",
"version": "16.0.1.0.1",
"summary": "Include references on document pages",
"version": "17.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"],

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,49 +1,16 @@
# Copyright 2019 Creu Blanca
# Copyright 2025 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import _, api, fields, models, tools
from odoo.exceptions import ValidationError
from odoo.tools.misc import html_escape
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
from markupsafe import Markup
name_re = re.compile("^%s$" % old_name_re.pattern)
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools import html_escape
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")
from odoo.addons.http_routing.models.ir_http import slugify
class DocumentPage(models.Model):
@ -52,7 +19,9 @@ class DocumentPage(models.Model):
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 +29,56 @@ 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 == "<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
record.content_parsed = record.get_content()
@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):
regex = r"^[a-zA-Z_][a-zA-Z0-9_]*$"
if not re.match(regex, rec.reference):
raise ValidationError(_("Reference is not valid"))
uniq_domain = [("reference", "=", reference)]
if record:
uniq_domain += [("id", "!=", record.id)]
if self.search(uniq_domain):
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 = """<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}
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
self.ensure_one()
content_parsed = raw = self.content or ""
for text in re.findall(r"\{\{.*?\}\}", raw):
reference = text.replace("{{", "").replace("}}", "")
content_parsed = content_parsed.replace(
text, self._resolve_reference(reference)
)
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>"
)
return self.content
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"):
vals["reference"] = slugify(vals["name"]).replace("-", "_")
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
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 class="section" id="usage">
<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
directly to the page. Also, the name will be parsed as the display name.</p>
</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,53 +1,53 @@
# Copyright 2019 Creu Blanca
# Copyright 2025 Tecnativa - Víctor Martínez
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from markupsafe import Markup
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
def setUpClass(cls):
super().setUpClass()
cls.page_obj = cls.env["document.page"]
cls.history_obj = cls.env["document.page.history"]
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(
{"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):
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.assertFalse(self.page1.reference)
self.page2.write({"reference": False})
self.assertEqual(self.page1.reference, self.page2.reference)
self.assertFalse(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")
with self.assertRaises(ValidationError):
new_page_duplicated_name = self.page_obj.create(
{
"name": "test page with no reference",
@ -60,19 +60,16 @@ class TestDocumentReference(TransactionCase):
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",
"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 = "<p>"
self.page1._compute_content_parsed()
self.assertEqual(str(self.page1.content_parsed), "<p></p>")
self.page1.content = Markup("<p></p>")
self.assertEqual(self.page1.content_parsed, Markup("<p></p>"))

View File

@ -4,11 +4,11 @@
id="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>
</xpath>
<xpath expr="//div[@t-raw='doc.content']" position="after">
<div t-raw="doc.get_raw_content()" />
<xpath expr="//div[@t-out='doc.content']" position="after">
<t t-out="doc.get_raw_content()" />
</xpath>
</template>
</odoo>