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