[ADD]partner_create_internal_user
This commit is contained in:
390
partner_create_internal_user/wizard/internal_user_wizard.py
Normal file
390
partner_create_internal_user/wizard/internal_user_wizard.py
Normal file
@@ -0,0 +1,390 @@
|
||||
# Copyright 2026 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import Command, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import email_normalize
|
||||
from odoo.tools.translate import _
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InternalUserWizard(models.TransientModel):
|
||||
"""A wizard to manage the creation of internal users from contacts.
|
||||
|
||||
It mirrors the standard ``portal.wizard`` ("Grant portal access") but
|
||||
creates internal users (``base.group_user``) instead of portal users.
|
||||
"""
|
||||
|
||||
_name = "internal.user.wizard"
|
||||
_description = "Grant Internal User Access"
|
||||
|
||||
def _default_partner_ids(self):
|
||||
partner_ids = self.env.context.get(
|
||||
"default_partner_ids", []
|
||||
) or self.env.context.get("active_ids", [])
|
||||
contact_ids = set()
|
||||
for partner in self.env["res.partner"].sudo().browse(partner_ids):
|
||||
contact_partners = (
|
||||
partner.child_ids.filtered(lambda p: p.type in ("contact", "other"))
|
||||
| partner
|
||||
)
|
||||
contact_ids |= set(contact_partners.ids)
|
||||
|
||||
return [Command.link(contact_id) for contact_id in contact_ids]
|
||||
|
||||
partner_ids = fields.Many2many(
|
||||
"res.partner", string="Partners", default=_default_partner_ids
|
||||
)
|
||||
user_ids = fields.One2many(
|
||||
"internal.user.wizard.user",
|
||||
"wizard_id",
|
||||
string="Users",
|
||||
compute="_compute_user_ids",
|
||||
store=True,
|
||||
readonly=False,
|
||||
)
|
||||
|
||||
@api.depends("partner_ids")
|
||||
def _compute_user_ids(self):
|
||||
for wizard in self:
|
||||
wizard.user_ids = [
|
||||
Command.create(
|
||||
{
|
||||
"partner_id": partner.id,
|
||||
"email": partner.email,
|
||||
}
|
||||
)
|
||||
for partner in wizard.partner_ids
|
||||
]
|
||||
|
||||
@api.model
|
||||
def action_open_wizard(self):
|
||||
"""Create an "internal.user.wizard" and open its form view.
|
||||
|
||||
A server action is needed so the one2many ``user_ids`` records exist
|
||||
(and thus have an id) before the form is displayed, otherwise the
|
||||
per-line buttons would be disabled.
|
||||
"""
|
||||
wizard = self.create({})
|
||||
return wizard._action_open_modal()
|
||||
|
||||
def _action_open_modal(self):
|
||||
"""Allow to keep the wizard modal open after executing an action."""
|
||||
return {
|
||||
"name": _("Internal User Access Management"),
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "internal.user.wizard",
|
||||
"view_mode": "form",
|
||||
"res_id": self.id,
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
|
||||
class InternalUserWizardUser(models.TransientModel):
|
||||
"""A model to configure the users created by the internal user wizard."""
|
||||
|
||||
_name = "internal.user.wizard.user"
|
||||
_description = "Internal User Config"
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
"internal.user.wizard",
|
||||
string="Wizard",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
"res.partner",
|
||||
string="Contact",
|
||||
required=True,
|
||||
readonly=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
email = fields.Char("Email")
|
||||
|
||||
user_id = fields.Many2one(
|
||||
"res.users",
|
||||
string="User",
|
||||
compute="_compute_user_id",
|
||||
compute_sudo=True,
|
||||
)
|
||||
login_date = fields.Datetime(
|
||||
related="user_id.login_date", string="Latest Authentication"
|
||||
)
|
||||
is_internal = fields.Boolean("Is Internal", compute="_compute_group_details")
|
||||
is_portal = fields.Boolean("Is Portal", compute="_compute_group_details")
|
||||
email_state = fields.Selection(
|
||||
[
|
||||
("ok", "Valid"),
|
||||
("ko", "Invalid"),
|
||||
("exist", "Already Registered"),
|
||||
],
|
||||
string="Status",
|
||||
compute="_compute_email_state",
|
||||
default="ok",
|
||||
)
|
||||
|
||||
@api.depends("email")
|
||||
def _compute_email_state(self):
|
||||
users_with_email = self.filtered(lambda user: email_normalize(user.email))
|
||||
(self - users_with_email).email_state = "ko"
|
||||
|
||||
existing_users = (
|
||||
self.env["res.users"]
|
||||
.with_context(active_test=False)
|
||||
.sudo()
|
||||
.search_read(
|
||||
self._get_similar_users_domain(users_with_email),
|
||||
self._get_similar_users_fields(),
|
||||
)
|
||||
)
|
||||
for wizard_user in users_with_email:
|
||||
if next(
|
||||
(
|
||||
user
|
||||
for user in existing_users
|
||||
if self._is_similar_than_user(user, wizard_user)
|
||||
),
|
||||
None,
|
||||
):
|
||||
wizard_user.email_state = "exist"
|
||||
else:
|
||||
wizard_user.email_state = "ok"
|
||||
|
||||
@api.depends("partner_id")
|
||||
def _compute_user_id(self):
|
||||
for wizard_user in self:
|
||||
user = wizard_user.partner_id.with_context(active_test=False).user_ids
|
||||
wizard_user.user_id = user[0] if user else False
|
||||
|
||||
@api.depends("user_id", "user_id.active", "user_id.groups_id")
|
||||
def _compute_group_details(self):
|
||||
for wizard_user in self:
|
||||
user = wizard_user.user_id
|
||||
if user and user.active and user._is_internal():
|
||||
wizard_user.is_internal = True
|
||||
wizard_user.is_portal = False
|
||||
elif user and user.active and user._is_portal():
|
||||
wizard_user.is_internal = False
|
||||
wizard_user.is_portal = True
|
||||
else:
|
||||
wizard_user.is_internal = False
|
||||
wizard_user.is_portal = False
|
||||
|
||||
def action_grant_access(self):
|
||||
"""Grant internal user access to the partner.
|
||||
|
||||
If the partner has no linked user, a new one is created in the same
|
||||
company as the partner (or in the current company if not set) and added
|
||||
to the internal users group. An invitation email is then sent so the
|
||||
user can set their password.
|
||||
"""
|
||||
self.ensure_one()
|
||||
self._assert_user_email_uniqueness()
|
||||
|
||||
if self.is_internal:
|
||||
raise UserError(
|
||||
_(
|
||||
'The partner "%s" already has internal user access.',
|
||||
self.partner_id.name,
|
||||
)
|
||||
)
|
||||
|
||||
group_user = self.env.ref("base.group_user")
|
||||
group_portal = self.env.ref("base.group_portal")
|
||||
group_public = self.env.ref("base.group_public")
|
||||
|
||||
self._update_partner_email()
|
||||
user_sudo = self.user_id.sudo()
|
||||
|
||||
if not user_sudo:
|
||||
# create a user if necessary and make sure it is internal
|
||||
company = self.partner_id.company_id or self.env.company
|
||||
user_sudo = self.sudo().with_company(company.id)._create_user()
|
||||
|
||||
# make sure the (possibly reused portal) user becomes internal.
|
||||
# ``mail_notrack`` prevents ``res.users.write`` (mail module) from
|
||||
# logging a spurious "Portal Access Revoked" note when the portal group
|
||||
# is removed here.
|
||||
user_sudo.with_context(mail_notrack=True).write(
|
||||
{
|
||||
"active": True,
|
||||
"groups_id": [
|
||||
Command.link(group_user.id),
|
||||
Command.unlink(group_portal.id),
|
||||
Command.unlink(group_public.id),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self._log_internal_access_change(True)
|
||||
self._send_invitation_email(user_sudo)
|
||||
|
||||
return self.action_refresh_modal()
|
||||
|
||||
def action_revoke_access(self):
|
||||
"""Archive the internal user of the partner."""
|
||||
self.ensure_one()
|
||||
if not self.is_internal:
|
||||
raise UserError(
|
||||
_(
|
||||
'The partner "%s" has no internal user access.',
|
||||
self.partner_id.name,
|
||||
)
|
||||
)
|
||||
|
||||
self._update_partner_email()
|
||||
|
||||
# Remove the sign up token, so it can not be used
|
||||
self.partner_id.sudo().signup_type = None
|
||||
|
||||
user_sudo = self.user_id.sudo()
|
||||
if user_sudo and user_sudo._is_internal():
|
||||
user_sudo.write({"active": False})
|
||||
self._log_internal_access_change(False)
|
||||
|
||||
return self.action_refresh_modal()
|
||||
|
||||
def action_invite_again(self):
|
||||
"""Re-send the invitation email to the partner."""
|
||||
self.ensure_one()
|
||||
self._assert_user_email_uniqueness()
|
||||
|
||||
if not self.is_internal:
|
||||
raise UserError(
|
||||
_(
|
||||
'You should first grant internal user access to the '
|
||||
'partner "%s".',
|
||||
self.partner_id.name,
|
||||
)
|
||||
)
|
||||
|
||||
self._update_partner_email()
|
||||
self._send_invitation_email(self.user_id.sudo())
|
||||
|
||||
return self.action_refresh_modal()
|
||||
|
||||
def action_refresh_modal(self):
|
||||
"""Refresh the wizard modal and keep it open."""
|
||||
return self.wizard_id._action_open_modal()
|
||||
|
||||
def _get_internal_access_update_body(self, access_granted):
|
||||
"""Build the chatter note logged on the partner for an access change.
|
||||
|
||||
It mirrors ``res.users._get_portal_access_update_body`` from the
|
||||
``mail`` module, which logs "Portal Access Granted/Revoked", but for
|
||||
back office (internal user) access.
|
||||
"""
|
||||
body = (
|
||||
_("Back office access granted")
|
||||
if access_granted
|
||||
else _("Back office access revoked")
|
||||
)
|
||||
if self.partner_id.email:
|
||||
return "%s (%s)" % (body, self.partner_id.email)
|
||||
return body
|
||||
|
||||
def _log_internal_access_change(self, access_granted):
|
||||
"""Log a back office access change as a note in the partner chatter."""
|
||||
self.partner_id.message_post(
|
||||
body=self._get_internal_access_update_body(access_granted),
|
||||
message_type="notification",
|
||||
subtype_xmlid="mail.mt_note",
|
||||
)
|
||||
|
||||
def _create_user(self):
|
||||
"""Create a new internal user for ``wizard_user.partner_id``.
|
||||
|
||||
:returns: record of res.users
|
||||
"""
|
||||
# ``mail_notrack`` prevents ``res.users.create`` (mail module) from
|
||||
# logging a spurious "Portal Access Granted" note: the template user is
|
||||
# a portal user, so the freshly created user is momentarily a portal
|
||||
# user before being turned into an internal one.
|
||||
return self.env["res.users"].with_context(
|
||||
no_reset_password=True, mail_notrack=True
|
||||
)._create_user_from_template(
|
||||
{
|
||||
"email": email_normalize(self.email),
|
||||
"login": email_normalize(self.email),
|
||||
"partner_id": self.partner_id.id,
|
||||
"company_id": self.env.company.id,
|
||||
"company_ids": [Command.set(self.env.company.ids)],
|
||||
"lang": self.env.lang,
|
||||
}
|
||||
)
|
||||
|
||||
def _send_invitation_email(self, user_sudo):
|
||||
"""Send the "set your password" invitation email to a new user.
|
||||
|
||||
The email is sent on a best-effort basis: if the mail cannot be
|
||||
delivered (e.g. no outgoing mail server is configured), the internal
|
||||
user has already been created and must not be rolled back, so the
|
||||
delivery error is only logged. ``action_reset_password`` runs the
|
||||
actual send in its own savepoint, so catching the error here keeps the
|
||||
user creation intact.
|
||||
"""
|
||||
if not user_sudo.email:
|
||||
raise UserError(
|
||||
_(
|
||||
"Cannot send the invitation: the user of %s has no email "
|
||||
"address.",
|
||||
self.partner_id.name,
|
||||
)
|
||||
)
|
||||
# ``create_user`` context makes Odoo use the "account created" template
|
||||
# (auth_signup.set_password_email) instead of a plain password reset.
|
||||
try:
|
||||
user_sudo.with_context(create_user=True).action_reset_password()
|
||||
except UserError as error:
|
||||
_logger.warning(
|
||||
"Internal user %s created but the invitation email could not "
|
||||
"be sent: %s",
|
||||
user_sudo.login,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _assert_user_email_uniqueness(self):
|
||||
"""Check that the email can be used to create a new user."""
|
||||
self.ensure_one()
|
||||
if self.email_state == "ko":
|
||||
raise UserError(
|
||||
_(
|
||||
'The contact "%s" does not have a valid email.',
|
||||
self.partner_id.name,
|
||||
)
|
||||
)
|
||||
if self.email_state == "exist":
|
||||
raise UserError(
|
||||
_(
|
||||
'The contact "%s" has the same email as an existing user.',
|
||||
self.partner_id.name,
|
||||
)
|
||||
)
|
||||
|
||||
def _update_partner_email(self):
|
||||
"""Update the partner email if a new valid one was introduced."""
|
||||
email_normalized = email_normalize(self.email)
|
||||
if self.email_state == "ok" and (
|
||||
email_normalize(self.partner_id.email) != email_normalized
|
||||
):
|
||||
self.partner_id.write({"email": email_normalized})
|
||||
|
||||
def _get_similar_users_domain(self, users_with_email):
|
||||
normalized_emails = [
|
||||
email_normalize(wizard_user.email) for wizard_user in users_with_email
|
||||
]
|
||||
return [("login", "in", normalized_emails)]
|
||||
|
||||
def _get_similar_users_fields(self):
|
||||
return ["id", "login"]
|
||||
|
||||
def _is_similar_than_user(self, user, wizard_user):
|
||||
"""Check whether ``user`` is a distinct user sharing the email."""
|
||||
return user["login"] == email_normalize(wizard_user.email) and (
|
||||
user["id"] != wizard_user.user_id.id
|
||||
)
|
||||
Reference in New Issue
Block a user