add partner_email_sync_portal_login
This commit is contained in:
parent
3c5809774f
commit
3be3de173e
11 changed files with 388 additions and 0 deletions
24
partner_email_sync_portal_login/README.md
Normal file
24
partner_email_sync_portal_login/README.md
Normal 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.
|
||||
2
partner_email_sync_portal_login/__init__.py
Normal file
2
partner_email_sync_portal_login/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import models
|
||||
from . import wizard
|
||||
17
partner_email_sync_portal_login/__manifest__.py
Normal file
17
partner_email_sync_portal_login/__manifest__.py
Normal 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,
|
||||
}
|
||||
67
partner_email_sync_portal_login/i18n/es.po
Normal file
67
partner_email_sync_portal_login/i18n/es.po
Normal 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."
|
||||
|
|
@ -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 ""
|
||||
1
partner_email_sync_portal_login/models/__init__.py
Normal file
1
partner_email_sync_portal_login/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import res_partner
|
||||
102
partner_email_sync_portal_login/models/res_partner.py
Normal file
102
partner_email_sync_portal_login/models/res_partner.py
Normal 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,
|
||||
)
|
||||
1
partner_email_sync_portal_login/tests/__init__.py
Normal file
1
partner_email_sync_portal_login/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_partner_email_sync_portal_login
|
||||
|
|
@ -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()
|
||||
1
partner_email_sync_portal_login/wizard/__init__.py
Normal file
1
partner_email_sync_portal_login/wizard/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import portal_wizard
|
||||
14
partner_email_sync_portal_login/wizard/portal_wizard.py
Normal file
14
partner_email_sync_portal_login/wizard/portal_wizard.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue