Files
partner-tools/partner_create_internal_user/wizard/internal_user_wizard.py

391 lines
13 KiB
Python

# 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
)