social/mail_activity_form/models/mail_activity.py

259 lines
9.3 KiB
Python

# Copyright 2021 Hunki Enterprises BV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import itertools
from lxml import html as lxml_html
from odoo import _, api, exceptions, models, tools
from odoo.tools import mail
from odoo.tools.safe_eval import _BUILTINS
class ModelProxy(object):
""" A wrapper for Odoo models that only exposes fields """
def __init__(self, model):
self.__model__ = model
def __getattribute__(self, name):
model = object.__getattribute__(self, "__model__")
if name in model._fields:
field = model._fields[name]
value = model[name]
if field.relational:
value = ModelProxy(value)
return value
# TODO: also allow functions with some decorator set
raise AttributeError(name)
def __getitem__(self, key):
if isinstance(key, str):
return getattr(self, key)
return ModelProxy(object.__getattribute__(self, "__model__")[key])
class MailActivity(models.Model):
_inherit = "mail.activity"
_mail_activity_form_prefix = "data-form-"
@api.model_create_multi
def create(self, vals_list):
"""
Apply computations in note, check for template
"""
result = super().create(vals_list)
for this in result:
if not this.activity_type_id.uses_forms:
continue
this._mail_activity_form_assert_template()
this._mail_activity_form_compute()
return result
def write(self, vals):
"""
Apply computations in note, check for template
"""
result = super().write(vals)
if (
"note" in vals
and self.env.context.get("_mail_activity_form_compute") != self
):
for this in self:
if not this.activity_type_id.uses_forms:
continue
this._mail_activity_form_assert_template()
this._mail_activity_form_compute()
return result
def read(self, fields=None, load="_classic_read"):
"""
Update note from template if we're told so
"""
result = super().read(fields=fields, load=load)
if (
not fields
or "note" in fields
and self.env.context.get("_mail_activity_form_update") != self
):
for this, this_values in zip(
self.with_context(_mail_activity_form_update=self), result,
):
activity_type = this.activity_type_id
if (
not activity_type.uses_forms
or this.write_date > activity_type.write_date
):
continue
# if the type has been updated, return html from type
# but with values from current activity
fromstring = lxml_html.fromstring
template = fromstring(activity_type.default_description)
current = fromstring(this_values["note"])
this_values["note"] = str(
lxml_html.tostring(
this._mail_activity_form_update(
template, this._mail_activity_form_extract(current),
),
)
)
return result
def _mail_activity_form_compute(self, raise_on_error=True):
"""
Compute computed nodes and write the result to the note field
"""
self.ensure_one()
if not self.note:
return
_id, _editable, _compute, _type = self._mail_activity_form_attributes()
doc = lxml_html.fromstring(self.note)
vals = self._mail_activity_form_extract(doc, compute=True)
for node in doc.xpath("//*[@%s]" % _compute):
if node.get(_id) in vals:
node.text = str(vals.get(node.get(_id)))
else:
node.text = str(
self._mail_activity_form_extract_value(node, vals, compute=True,)
)
self.with_context(_mail_activity_form_compute=self,).write(
{"note": lxml_html.tostring(doc)}
)
def _mail_activity_form_assert_template(self):
"""
Compare notes with activity_type_id.default_description and be
sure only editable nodes have been changed
"""
if not self.note or not self.activity_type_id.default_description:
return
template_html = lxml_html.fromstring(self.activity_type_id.default_description)
activity_html = lxml_html.fromstring(
self.with_context(_mail_activity_form_update=self).note
)
_id, _editable, _compute, _type = self._mail_activity_form_attributes()
different = False
for t, a in itertools.zip_longest(template_html.iter(), activity_html.iter()):
if a is None or t is None or t.tag != a.tag:
different = True
break
if set(t.attrib.keys()) != set(a.attrib.keys()):
different = True
break
for attribute in t.attrib:
if t.attrib[attribute].strip() != a.attrib[attribute].strip():
different = True
if different:
break
if (t.text or "").strip() != (a.text or "").strip():
if t.attrib.get(_compute):
continue
if t.attrib.get(_editable, "").lower() not in ("1", "true"):
different = True
break
if different:
raise exceptions.UserError(
_("You are not supposed to change the content of this activity.")
)
def _mail_activity_form_attributes(self):
"""
Return a tuple of the attribute names constructed with the above prefix
"""
return (
"%sid" % self._mail_activity_form_prefix,
"%seditable" % self._mail_activity_form_prefix,
"%scompute" % self._mail_activity_form_prefix,
"%stype" % self._mail_activity_form_prefix,
)
def _mail_activity_form_update(self, html, values):
"""
Replace the content of all nodes in html with an -id attribute with the
value from values of that name, return modified html
"""
_id, _editable, _compute, _type = self._mail_activity_form_attributes()
for node in html.xpath("//*[@%s]" % (_id)):
node.text = self._mail_activity_form_format_value(
values.get(node.attrib[_id]), node.attrib.get(_type),
)
return html
def _mail_activity_form_extract(self, html, compute=False):
"""
Parse html for nodes marked with {prefix}-editable or -compute
and return (possibly computed) values
"""
self.ensure_one()
_id, _editable, _compute, _type = self._mail_activity_form_attributes()
vals = {}
# get static values first, do computations thereafter
for node in html.xpath("//*[@%s]" % (_id)):
vals[node.get(_id)] = self._mail_activity_form_extract_value(
node, vals, compute=False,
)
for node in html.xpath("//*[@%s]" % _compute):
if not node.get(_id):
# don't compute anonymous nodes here, this happens in
# _compute
continue
vals[node.get(_id)] = self._mail_activity_form_extract_value(
node, vals, compute=compute,
)
return vals
def _mail_activity_form_extract_value(self, node, values, compute=False):
"""
Return a value given by node, possibly involving custom evaluation
"""
_id, _editable, _compute, _type = self._mail_activity_form_attributes()
expression = node.get(_compute)
value = None
if compute and expression:
value = self._mail_activity_form_eval(expression, values)
else:
value = node.text_content().strip()
value_type = node.get(_type)
return self._mail_activity_form_parse_value(value, value_type)
def _mail_activity_form_parse_value(self, value, value_type):
"""
Cast value to value_type
"""
if value_type not in ("float", "int", "str", None):
raise exceptions.UserError(_("Value type %s is invalid",) % value_type)
return _BUILTINS.get(value_type, _BUILTINS["str"])(value)
def _mail_activity_form_format_value(self, value, value_type):
"""
Format a value for representation
"""
return str(value)
def _mail_activity_form_eval(self, expression, values):
"""
Evaluate an expression for this activity
"""
return tools.safe_eval(
expression, self._mail_activity_form_eval_context(values,),
)
def _mail_activity_form_eval_context(self, values):
"""
Return the evaluation context for an expression
"""
res_object = self.env[self.res_model].browse(self.res_id)
return dict(object=ModelProxy(res_object), activity=ModelProxy(self), **values)
def _register_hook(self):
"""
Don't have the HTML cleaner remove our attributes
"""
mail.safe_attrs |= frozenset(self._mail_activity_form_attributes())
return super()._register_hook()