diff --git a/partner_email_sync_portal_login/README.md b/partner_email_sync_portal_login/README.md new file mode 100644 index 0000000..7214f18 --- /dev/null +++ b/partner_email_sync_portal_login/README.md @@ -0,0 +1,24 @@ +# Partner Email Sync Portal Login + +When the email of a contact that has a **portal user** is changed and saved: + +- the user login is updated with the new (normalized) email, and +- a password reset email is sent to the new address. + +A note is posted in the contact chatter with the result. + +## Behaviour details + +- Only portal users are affected; internal users are never modified. +- Empty or invalid emails are ignored (login is kept). +- Changes that only differ in letter case do not trigger anything. +- If another user already uses the new email as login, the change is + blocked with an error message. +- If the reset email cannot be sent, the login is still updated and the + error is recorded in the chatter. +- From the portal access wizard ("Grant portal access" / "Invite again") + the login is synced but no reset email is sent, since the wizard sends + its own invitation. +- Use the context key `skip_portal_login_sync` to disable the behaviour + (e.g. in imports). +- Changing the login closes the user's open sessions. diff --git a/partner_email_sync_portal_login/__init__.py b/partner_email_sync_portal_login/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/partner_email_sync_portal_login/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/partner_email_sync_portal_login/__manifest__.py b/partner_email_sync_portal_login/__manifest__.py new file mode 100644 index 0000000..b5ea4c6 --- /dev/null +++ b/partner_email_sync_portal_login/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Partner Email Sync Portal Login", + "summary": "Update the portal user login and send a password reset " + "email when the partner email changes", + "version": "16.0.1.0.0", + "category": "Tools", + "website": "https://criptomart.net", + "author": "Criptomart", + "maintainers": ["criptomart"], + "license": "AGPL-3", + "depends": ["portal"], + "data": [], + "installable": True, +} diff --git a/partner_email_sync_portal_login/i18n/es.po b/partner_email_sync_portal_login/i18n/es.po new file mode 100644 index 0000000..d7269cf --- /dev/null +++ b/partner_email_sync_portal_login/i18n/es.po @@ -0,0 +1,67 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_email_sync_portal_login +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-06 09:57+0000\n" +"PO-Revision-Date: 2026-10-06 09:57+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Cannot change the email of %(partner)s: there is already a user with login " +"%(login)s." +msgstr "" +"No se puede cambiar el email de %(partner)s: ya existe un usuario con el login " +"%(login)s." + +#. module: partner_email_sync_portal_login +#: model:ir.model,name:partner_email_sync_portal_login.model_res_partner +msgid "Contact" +msgstr "Contacto" + +#. module: partner_email_sync_portal_login +#: model:ir.model,name:partner_email_sync_portal_login.model_portal_wizard_user +msgid "Portal User Config" +msgstr "Configuración de usuario de portal" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Portal user login changed from %(old)s to %(new)s and a password reset email" +" has been sent." +msgstr "" +"Login del usuario de portal cambiado de %(old)s a %(new)s y enviado el correo " +"de restablecimiento de contraseña." + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Portal user login changed from %(old)s to %(new)s, but the password reset " +"email could not be sent: %(error)s" +msgstr "" +"Login del usuario de portal cambiado de %(old)s a %(new)s, pero no se ha podido " +"enviar el correo de restablecimiento de contraseña: %(error)s" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "Portal user login changed from %(old)s to %(new)s." +msgstr "Login del usuario de portal cambiado de %(old)s a %(new)s." diff --git a/partner_email_sync_portal_login/i18n/partner_email_sync_portal_login.pot b/partner_email_sync_portal_login/i18n/partner_email_sync_portal_login.pot new file mode 100644 index 0000000..90e3269 --- /dev/null +++ b/partner_email_sync_portal_login/i18n/partner_email_sync_portal_login.pot @@ -0,0 +1,60 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_email_sync_portal_login +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-06 09:57+0000\n" +"PO-Revision-Date: 2026-10-06 09:57+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Cannot change the email of %(partner)s: there is already a user with login " +"%(login)s." +msgstr "" + +#. module: partner_email_sync_portal_login +#: model:ir.model,name:partner_email_sync_portal_login.model_res_partner +msgid "Contact" +msgstr "" + +#. module: partner_email_sync_portal_login +#: model:ir.model,name:partner_email_sync_portal_login.model_portal_wizard_user +msgid "Portal User Config" +msgstr "" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Portal user login changed from %(old)s to %(new)s and a password reset email" +" has been sent." +msgstr "" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "" +"Portal user login changed from %(old)s to %(new)s, but the password reset " +"email could not be sent: %(error)s" +msgstr "" + +#. module: partner_email_sync_portal_login +#. odoo-python +#: code:addons/partner_email_sync_portal_login/models/res_partner.py:0 +#, python-format +msgid "Portal user login changed from %(old)s to %(new)s." +msgstr "" diff --git a/partner_email_sync_portal_login/models/__init__.py b/partner_email_sync_portal_login/models/__init__.py new file mode 100644 index 0000000..91fed54 --- /dev/null +++ b/partner_email_sync_portal_login/models/__init__.py @@ -0,0 +1 @@ +from . import res_partner diff --git a/partner_email_sync_portal_login/models/res_partner.py b/partner_email_sync_portal_login/models/res_partner.py new file mode 100644 index 0000000..e22276e --- /dev/null +++ b/partner_email_sync_portal_login/models/res_partner.py @@ -0,0 +1,102 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from markupsafe import Markup + +from odoo import _, models +from odoo.exceptions import UserError +from odoo.tools import email_normalize + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _inherit = "res.partner" + + def write(self, vals): + res = super().write(vals) + if "email" in vals and not self.env.context.get("skip_portal_login_sync"): + self._sync_portal_user_login() + return res + + def _get_portal_users_to_sync(self): + self.ensure_one() + group_portal = self.env.ref("base.group_portal") + return self.sudo().user_ids.filtered(lambda u: group_portal in u.groups_id) + + def _sync_portal_user_login(self): + """Set the partner email as login of its portal users and send them + a password reset email.""" + Users = self.env["res.users"].sudo().with_context(active_test=False) + for partner in self: + users = partner._get_portal_users_to_sync() + if not users: + continue + new_login = email_normalize(partner.email) + if not new_login: + _logger.info( + "Portal login not synced for partner %s: invalid email %r", + partner.id, + partner.email, + ) + continue + for user in users: + if email_normalize(user.login) == new_login: + continue + if Users.search_count( + [("login", "=", new_login), ("id", "!=", user.id)] + ): + raise UserError( + _( + "Cannot change the email of %(partner)s: there is " + "already a user with login %(login)s.", + partner=partner.display_name, + login=new_login, + ) + ) + old_login = user.login + user.write({"login": new_login}) + _logger.info( + "Portal user %s login changed from %s to %s", + user.id, + old_login, + new_login, + ) + if self.env.context.get("portal_login_sync_no_mail"): + body = _( + "Portal user login changed from %(old)s to %(new)s.", + old=old_login, + new=new_login, + ) + else: + body = partner._send_portal_login_reset_password( + user, old_login, new_login + ) + partner.sudo().message_post(body=Markup("

%s

") % body) + + def _send_portal_login_reset_password(self, user, old_login, new_login): + try: + with self.env.cr.savepoint(): + user.action_reset_password() + except Exception as e: + # Do not block the email change if the reset email cannot be sent + _logger.warning( + "Password reset email for portal user %s could not be sent: %s", + user.id, + e, + ) + return _( + "Portal user login changed from %(old)s to %(new)s, but the " + "password reset email could not be sent: %(error)s", + old=old_login, + new=new_login, + error=str(e), + ) + return _( + "Portal user login changed from %(old)s to %(new)s and a password " + "reset email has been sent.", + old=old_login, + new=new_login, + ) diff --git a/partner_email_sync_portal_login/tests/__init__.py b/partner_email_sync_portal_login/tests/__init__.py new file mode 100644 index 0000000..71522fb --- /dev/null +++ b/partner_email_sync_portal_login/tests/__init__.py @@ -0,0 +1 @@ +from . import test_partner_email_sync_portal_login diff --git a/partner_email_sync_portal_login/tests/test_partner_email_sync_portal_login.py b/partner_email_sync_portal_login/tests/test_partner_email_sync_portal_login.py new file mode 100644 index 0000000..de52ecc --- /dev/null +++ b/partner_email_sync_portal_login/tests/test_partner_email_sync_portal_login.py @@ -0,0 +1,99 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged + +from odoo.addons.auth_signup.models.res_users import ResUsers + +RESET_PASSWORD = "action_reset_password" + + +@tagged("post_install", "-at_install") +class TestPartnerEmailSyncPortalLogin(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + Users = cls.env["res.users"].with_context(no_reset_password=True) + cls.portal_user = Users.create( + { + "name": "Portal Member", + "login": "member@example.com", + "email": "member@example.com", + "groups_id": [(6, 0, [cls.env.ref("base.group_portal").id])], + } + ) + cls.internal_user = Users.create( + { + "name": "Internal Member", + "login": "internal@example.com", + "email": "internal@example.com", + "groups_id": [(6, 0, [cls.env.ref("base.group_user").id])], + } + ) + + def test_portal_user_login_synced_and_reset_sent(self): + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + self.portal_user.partner_id.email = "New.Member@Example.com" + self.assertEqual(self.portal_user.login, "new.member@example.com") + reset.assert_called_once() + self.assertEqual(reset.call_args.args[0], self.portal_user) + + def test_internal_user_not_synced(self): + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + self.internal_user.partner_id.email = "other@example.com" + self.assertEqual(self.internal_user.login, "internal@example.com") + reset.assert_not_called() + + def test_partner_without_user(self): + partner = self.env["res.partner"].create( + {"name": "No User", "email": "nouser@example.com"} + ) + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + partner.email = "nouser2@example.com" + reset.assert_not_called() + + def test_invalid_or_empty_email_keeps_login(self): + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + self.portal_user.partner_id.email = False + self.portal_user.partner_id.email = "not-an-email" + self.assertEqual(self.portal_user.login, "member@example.com") + reset.assert_not_called() + + def test_same_email_different_case_not_resent(self): + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + self.portal_user.partner_id.email = "MEMBER@example.com" + self.assertEqual(self.portal_user.login, "member@example.com") + reset.assert_not_called() + + def test_login_already_used(self): + with self.assertRaises(UserError): + self.portal_user.partner_id.email = "internal@example.com" + + def test_reset_email_failure_does_not_block(self): + with patch.object( + ResUsers, RESET_PASSWORD, autospec=True, side_effect=UserError("boom") + ): + self.portal_user.partner_id.email = "fail@example.com" + self.assertEqual(self.portal_user.login, "fail@example.com") + + def test_skip_context(self): + partner = self.portal_user.partner_id.with_context( + skip_portal_login_sync=True + ) + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + partner.email = "skip@example.com" + self.assertEqual(self.portal_user.login, "member@example.com") + reset.assert_not_called() + + def test_no_mail_context_syncs_login_only(self): + partner = self.portal_user.partner_id.with_context( + portal_login_sync_no_mail=True + ) + with patch.object(ResUsers, RESET_PASSWORD, autospec=True) as reset: + partner.email = "wizard@example.com" + self.assertEqual(self.portal_user.login, "wizard@example.com") + reset.assert_not_called() diff --git a/partner_email_sync_portal_login/wizard/__init__.py b/partner_email_sync_portal_login/wizard/__init__.py new file mode 100644 index 0000000..8bff21f --- /dev/null +++ b/partner_email_sync_portal_login/wizard/__init__.py @@ -0,0 +1 @@ +from . import portal_wizard diff --git a/partner_email_sync_portal_login/wizard/portal_wizard.py b/partner_email_sync_portal_login/wizard/portal_wizard.py new file mode 100644 index 0000000..d212959 --- /dev/null +++ b/partner_email_sync_portal_login/wizard/portal_wizard.py @@ -0,0 +1,14 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class PortalWizardUser(models.TransientModel): + _inherit = "portal.wizard.user" + + def _update_partner_email(self): + # The portal wizard already sends its own invitation email + return super( + PortalWizardUser, self.with_context(portal_login_sync_no_mail=True) + )._update_partner_email()