add partner_email_sync_portal_login

This commit is contained in:
Luis 2026-10-06 12:10:26 +02:00
parent 3c5809774f
commit 3be3de173e
11 changed files with 388 additions and 0 deletions

View file

@ -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.

View file

@ -0,0 +1,2 @@
from . import models
from . import wizard

View file

@ -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,
}

View file

@ -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."

View file

@ -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 ""

View file

@ -0,0 +1 @@
from . import res_partner

View file

@ -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("<p>%s</p>") % 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,
)

View file

@ -0,0 +1 @@
from . import test_partner_email_sync_portal_login

View file

@ -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()

View file

@ -0,0 +1 @@
from . import portal_wizard

View file

@ -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()