391 lines
13 KiB
Python
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
|
|
)
|