diff --git a/setup/.setuptools-odoo-make-default-ignore b/setup/.setuptools-odoo-make-default-ignore new file mode 100644 index 0000000..207e615 --- /dev/null +++ b/setup/.setuptools-odoo-make-default-ignore @@ -0,0 +1,2 @@ +# addons listed in this file are ignored by +# setuptools-odoo-make-default (one addon per line) diff --git a/setup/README b/setup/README new file mode 100644 index 0000000..a63d633 --- /dev/null +++ b/setup/README @@ -0,0 +1,2 @@ +To learn more about this directory, please visit +https://pypi.python.org/pypi/setuptools-odoo diff --git a/setup/sms_masked_content/odoo/addons/sms_masked_content b/setup/sms_masked_content/odoo/addons/sms_masked_content new file mode 120000 index 0000000..73b5017 --- /dev/null +++ b/setup/sms_masked_content/odoo/addons/sms_masked_content @@ -0,0 +1 @@ +../../../../sms_masked_content \ No newline at end of file diff --git a/setup/sms_masked_content/setup.py b/setup/sms_masked_content/setup.py new file mode 100644 index 0000000..28c57bb --- /dev/null +++ b/setup/sms_masked_content/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/sms_masked_content/README.rst b/sms_masked_content/README.rst new file mode 100644 index 0000000..1b7749c --- /dev/null +++ b/sms_masked_content/README.rst @@ -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 `_ + +**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 `_. +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Hunki Enterprises BV +* Therp BV + +Contributors +------------ + +- Holger Brunn + (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 `__: + +|maintainer-hbrunn| + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sms_masked_content/__init__.py b/sms_masked_content/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/sms_masked_content/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sms_masked_content/__manifest__.py b/sms_masked_content/__manifest__.py new file mode 100644 index 0000000..a07b858 --- /dev/null +++ b/sms_masked_content/__manifest__.py @@ -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", + ], +} diff --git a/sms_masked_content/demo/sms_template.xml b/sms_masked_content/demo/sms_template.xml new file mode 100644 index 0000000..661b954 --- /dev/null +++ b/sms_masked_content/demo/sms_template.xml @@ -0,0 +1,12 @@ + + + + + Template sending masked content + + This is your name, but masked in the chatter: {{mask_content(object.name)}} + + diff --git a/sms_masked_content/models/__init__.py b/sms_masked_content/models/__init__.py new file mode 100644 index 0000000..1456f82 --- /dev/null +++ b/sms_masked_content/models/__init__.py @@ -0,0 +1,3 @@ +from . import sms_api +from . import sms_sms +from . import sms_template diff --git a/sms_masked_content/models/sms_api.py b/sms_masked_content/models/sms_api.py new file mode 100644 index 0000000..87dc324 --- /dev/null +++ b/sms_masked_content/models/sms_api.py @@ -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) diff --git a/sms_masked_content/models/sms_sms.py b/sms_masked_content/models/sms_sms.py new file mode 100644 index 0000000..74dac91 --- /dev/null +++ b/sms_masked_content/models/sms_sms.py @@ -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) diff --git a/sms_masked_content/models/sms_template.py b/sms_masked_content/models/sms_template.py new file mode 100644 index 0000000..134bb81 --- /dev/null +++ b/sms_masked_content/models/sms_template.py @@ -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 + } diff --git a/sms_masked_content/readme/CONTRIBUTORS.md b/sms_masked_content/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..b28199e --- /dev/null +++ b/sms_masked_content/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Holger Brunn \ (https://hunki-enterprises.com) diff --git a/sms_masked_content/readme/DESCRIPTION.md b/sms_masked_content/readme/DESCRIPTION.md new file mode 100644 index 0000000..e588d40 --- /dev/null +++ b/sms_masked_content/readme/DESCRIPTION.md @@ -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. diff --git a/sms_masked_content/readme/USAGE.md b/sms_masked_content/readme/USAGE.md new file mode 100644 index 0000000..60f6b64 --- /dev/null +++ b/sms_masked_content/readme/USAGE.md @@ -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')`` diff --git a/sms_masked_content/static/description/icon.png b/sms_masked_content/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/sms_masked_content/static/description/icon.png differ diff --git a/sms_masked_content/static/description/index.html b/sms_masked_content/static/description/index.html new file mode 100644 index 0000000..cbe5805 --- /dev/null +++ b/sms_masked_content/static/description/index.html @@ -0,0 +1,445 @@ + + + + + +Masked content in SMS + + + +
+

Masked content in SMS

+ + +

Alpha License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

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

+
+

Table of contents

+ +
+

Usage

+

To use this module, you need to:

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

Bug Tracker

+

Bugs are tracked on GitHub 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.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Hunki Enterprises BV
  • +
  • Therp BV
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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.

+

Current maintainer:

+

hbrunn

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/sms_masked_content/tests/__init__.py b/sms_masked_content/tests/__init__.py new file mode 100644 index 0000000..8d931b8 --- /dev/null +++ b/sms_masked_content/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sms_masked_content diff --git a/sms_masked_content/tests/test_sms_masked_content.py b/sms_masked_content/tests/test_sms_masked_content.py new file mode 100644 index 0000000..09ebc7c --- /dev/null +++ b/sms_masked_content/tests/test_sms_masked_content.py @@ -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)