Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ADD] sms_masked_content #119

Open
wants to merge 1 commit into
base: 16.0
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
[ADD] sms_masked_content
hbrunn committed Jan 17, 2025
commit 8a9ab8b8e08e690461c2a60d1b05c08785fcf58a
2 changes: 2 additions & 0 deletions setup/.setuptools-odoo-make-default-ignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# addons listed in this file are ignored by
# setuptools-odoo-make-default (one addon per line)
2 changes: 2 additions & 0 deletions setup/README
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
To learn more about this directory, please visit
https://pypi.python.org/pypi/setuptools-odoo
1 change: 1 addition & 0 deletions setup/sms_masked_content/odoo/addons/sms_masked_content
6 changes: 6 additions & 0 deletions setup/sms_masked_content/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
106 changes: 106 additions & 0 deletions sms_masked_content/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
=====================
Masked content in SMS
=====================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:b17dc73de8dc59b512f6e94ddeb2e31b8a0a8c6cd7774642805fd695f6f11405
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
:target: https://odoo-community.org/page/development-status
:alt: Alpha
.. |badge2| 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
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github
:target: https://github.com/OCA/web/tree/16.0/sms_masked_content
:alt: OCA/web
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/web-16-0/web-16-0-sms_masked_content
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=16.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This module allows users to mark some SMS content in template as to be
masked, meaning after actually sending an SMS, this content will be
replaced by XXX.

This is useful when ie sending codes which are long lived enough to give
bad incentives for users having access to the messages.

.. IMPORTANT::
This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
`More details on development status <https://odoo-community.org/page/development-status>`_

**Table of contents**

.. contents::
:local:

Usage
=====

To use this module, you need to:

1. Go to ...
2. When writing a template, use ``mask_content($your expression)``
3. If you want something else than the default ``XXX`` as replacement,
say ``mask_content($your_expression, 'your replacement')``

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/web/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/web/issues/new?body=module:%20sms_masked_content%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* Hunki Enterprises BV
* Therp BV

Contributors
------------

- Holger Brunn <mail@hunki-enterprises.com>
(https://hunki-enterprises.com)

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

.. |maintainer-hbrunn| image:: https://github.com/hbrunn.png?size=40px
:target: https://github.com/hbrunn
:alt: hbrunn

Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-hbrunn|

This module is part of the `OCA/web <https://github.com/OCA/web/tree/16.0/sms_masked_content>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions sms_masked_content/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
21 changes: 21 additions & 0 deletions sms_masked_content/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright 2024 Hunki Enterprises BV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0)

{
"name": "Masked content in SMS",
"summary": "Allows to mask some part of SMS content after sending",
"version": "16.0.1.0.0",
"development_status": "Alpha",
"category": "Tools",
"website": "https://github.com/OCA/web",
"author": "Hunki Enterprises BV, Odoo Community Association (OCA), Therp BV",
"maintainers": ["hbrunn"],
"license": "AGPL-3",
"depends": [
"sms",
],
"data": [],
"demo": [
"demo/sms_template.xml",
],
}
12 changes: 12 additions & 0 deletions sms_masked_content/demo/sms_template.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Hunki Enterprises BV
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) -->
<data>
<record id="template_demo" model="sms.template">
<field name="name">Template sending masked content</field>
<field name="model_id" ref="base.model_res_partner" />
<field
name="body"
>This is your name, but masked in the chatter: {{mask_content(object.name)}}</field>
</record>
</data>
3 changes: 3 additions & 0 deletions sms_masked_content/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import sms_api
from . import sms_sms
from . import sms_template
29 changes: 29 additions & 0 deletions sms_masked_content/models/sms_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright 2024 Hunki Enterprises BV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0)

from odoo import api, models


class SmsApi(models.AbstractModel):
_inherit = "sms.api"

@api.model
def _send_sms_batch(self, messages):
"""
If message body is a masked string, pass the unmasked content to super
Otherwise, read the sms' unmasked_body field and pass that if set
"""
SmsSms = self.env["sms.sms"]
messages = [
dict(
message,
content=getattr(
message.get("content"),
"unmasked_content",
SmsSms.browse(message.get("res_id") or []).unmasked_body
or message.get("content"),
),
)
for message in messages
]
return super()._send_sms_batch(messages)
35 changes: 35 additions & 0 deletions sms_masked_content/models/sms_sms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2024 Hunki Enterprises BV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0)

from odoo import api, fields, models


class SmsSms(models.Model):
_inherit = "sms.sms"

unmasked_body = fields.Text(
help="Field that temporarily holds body as it is to be sent"
)

@api.model_create_multi
def create(self, vals_list):
"""
Write unmasked_body if we get one passed
"""
return super().create(
dict(
vals,
unmasked_body=vals.get(
"unmasked_body", getattr(vals.get("body"), "unmasked_content", None)
),
)
for vals in vals_list
)

def write(self, vals):
"""
When sms is marked as sent or cancelled, remove its unmasked body
"""
if vals.get("state") in ("sent", "canceled"):
vals["unmasked_body"] = False
return super().write(vals)
72 changes: 72 additions & 0 deletions sms_masked_content/models/sms_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright 2024 Hunki Enterprises BV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0)

from odoo import api, models

mask_sentinel = object()


class MaskedString(str):
unmasked_content = ""

def __new__(cls, masked_content, unmasked_content):
result = super().__new__(cls, masked_content)
result.unmasked_content = unmasked_content
return result


class SmsTemplate(models.Model):
_inherit = "sms.template"

@api.model
def _render_template(
self,
template_src,
model,
res_ids,
engine="inline_template",
add_context=None,
options=None,
post_process=False,
):
"""
Add masking function to context, attach unmasked version to result if masking was used
"""
masking_used = False

def mask_content(content, replacement="XXX"):
nonlocal masking_used
if self.env.context.get("sms_masked_content_unmask") == mask_sentinel:
return content
else:
masking_used = True
return replacement

result = super()._render_template(
template_src,
model,
res_ids,
engine=engine,
add_context=dict(add_context or {}, mask_content=mask_content),
options=options,
post_process=post_process,
)

if not masking_used:
return result
else:
unmasked_result = self.with_context(
sms_masked_content_unmask=mask_sentinel
)._render_template(
template_src,
model,
res_ids,
engine=engine,
add_context=dict(add_context or {}, mask_content=mask_content),
options=options,
post_process=post_process,
)
return {
res_id: MaskedString(result[res_id], unmasked_result[res_id])
for res_id in result
}
1 change: 1 addition & 0 deletions sms_masked_content/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Holger Brunn \<mail@hunki-enterprises.com> (https://hunki-enterprises.com)
3 changes: 3 additions & 0 deletions sms_masked_content/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
This module allows users to mark some SMS content in template as to be masked, meaning after actually sending an SMS, this content will be replaced by XXX.

This is useful when ie sending codes which are long lived enough to give bad incentives for users having access to the messages.
5 changes: 5 additions & 0 deletions sms_masked_content/readme/USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
To use this module, you need to:

1. Go to ...
2. When writing a template, use ``mask_content($your expression)``
3. If you want something else than the default ``XXX`` as replacement, say ``mask_content($your_expression, 'your replacement')``
Binary file added sms_masked_content/static/description/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
445 changes: 445 additions & 0 deletions sms_masked_content/static/description/index.html

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions sms_masked_content/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_sms_masked_content
133 changes: 133 additions & 0 deletions sms_masked_content/tests/test_sms_masked_content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Copyright 2024 Hunki Enterprises BV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0)

from unittest import mock

from odoo.tests.common import TransactionCase


def _contact_iap_success(local_endpoint, params):
return [dict(message, state="success") for message in params["messages"]]


def _contact_iap_failure(local_endpoint, params):
return [dict(message, state="server_error") for message in params["messages"]]


class TestSmsMaskedContent(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.template = cls.env.ref("sms_masked_content.template_demo")
cls.partner = cls.env.ref("base.user_demo").partner_id

def test_masking_message_post(self):
"""Test that masked content is hidden"""
last_sms = self.env["sms.sms"].search([], order="id desc", limit=1)
last_message = self.env["mail.message"].search([], order="id desc", limit=1)

# successful sending should remove the sms, only leave masked content
with mock.patch.object(
self.env["sms.api"].__class__, "_contact_iap"
) as contact_iap:
contact_iap.side_effect = _contact_iap_success
self.partner._message_sms_with_template(self.template)

self.assertIn(
self.partner.name, contact_iap.call_args.args[1]["messages"][0]["content"]
)

new_sms = self.env["sms.sms"].search([("id", ">", last_sms.id or 0)])
new_message = self.env["mail.message"].search(
[("id", ">", last_message.id or 0)]
)
self.assertFalse(new_sms)
self.assertIn("XXX", new_message.body)
self.assertNotIn(self.partner.name, new_message.body)

last_message = new_message

# failed sending should keep unmasked content in sms.sms#unmasked_body
with mock.patch.object(
self.env["sms.api"].__class__, "_contact_iap"
) as contact_iap:
contact_iap.side_effect = _contact_iap_failure
self.partner._message_sms_with_template(self.template)

self.assertIn(
self.partner.name, contact_iap.call_args.args[1]["messages"][0]["content"]
)

new_sms = self.env["sms.sms"].search([("id", ">", last_sms.id or 0)])
new_message = self.env["mail.message"].search(
[("id", ">", last_message.id or 0)]
)
self.assertEqual(new_sms.state, "error")
self.assertNotIn(self.partner.name, new_sms.body)
self.assertIn(self.partner.name, new_sms.unmasked_body)
self.assertNotIn(self.partner.name, new_message.body)

with mock.patch.object(
self.env["sms.api"].__class__, "_contact_iap"
) as contact_iap:
contact_iap.side_effect = _contact_iap_success
new_sms._send(unlink_sent=False)

self.assertIn(
self.partner.name, contact_iap.call_args.args[1]["messages"][0]["content"]
)

new_message = self.env["mail.message"].search(
[("id", ">", last_message.id or 0)]
)
self.assertEqual(new_sms.state, "sent")
self.assertNotIn(self.partner.name, new_sms.body)
self.assertFalse(new_sms.unmasked_body)
self.assertNotIn(self.partner.name, new_message.body)

def test_masking_composer(self):
"""
Test that masking works for code using the composer
"""
composer = self.env["sms.composer"].create(
{
"composition_mode": "mass",
"res_model": self.partner._name,
"res_id": self.partner.id,
"res_ids": str(self.partner.id),
"template_id": self.template.id,
"mass_force_send": True,
}
)

with mock.patch.object(
self.env["sms.api"].__class__, "_contact_iap"
) as contact_iap:
contact_iap.side_effect = _contact_iap_success
composer.action_send_sms()

self.assertIn(
self.partner.name, contact_iap.call_args.args[1]["messages"][0]["content"]
)

last_sms = self.env["sms.sms"].search([], order="id desc", limit=1)
last_message = self.env["mail.message"].search([], order="id desc", limit=1)

with mock.patch.object(
self.env["sms.api"].__class__, "_contact_iap"
) as contact_iap:
contact_iap.side_effect = _contact_iap_failure
composer.action_send_sms()

self.assertIn(
self.partner.name, contact_iap.call_args.args[1]["messages"][0]["content"]
)

new_sms = self.env["sms.sms"].search([("id", ">", last_sms.id or 0)])
new_message = self.env["mail.message"].search(
[("id", ">", last_message.id or 0)]
)
self.assertEqual(new_sms.state, "error")
self.assertNotIn(self.partner.name, new_sms.body)
self.assertIn(self.partner.name, new_sms.unmasked_body)
self.assertNotIn(self.partner.name, new_message.body)