From 4d739af30a13a4269b4506ecf2bac0f7855e3e89 Mon Sep 17 00:00:00 2001 From: Laetitia Da Costa Date: Wed, 1 Jul 2026 16:57:56 +0200 Subject: [PATCH] [ADD]partner_create_internal_user --- partner_create_internal_user/README.rst | 107 +++++ partner_create_internal_user/__init__.py | 1 + partner_create_internal_user/__manifest__.py | 25 ++ partner_create_internal_user/i18n/fr.po | 238 +++++++++++ .../security/ir.model.access.csv | 3 + .../wizard/__init__.py | 1 + .../wizard/internal_user_wizard.py | 390 ++++++++++++++++++ .../wizard/internal_user_wizard_views.xml | 51 +++ 8 files changed, 816 insertions(+) create mode 100644 partner_create_internal_user/README.rst create mode 100644 partner_create_internal_user/__init__.py create mode 100644 partner_create_internal_user/__manifest__.py create mode 100644 partner_create_internal_user/i18n/fr.po create mode 100644 partner_create_internal_user/security/ir.model.access.csv create mode 100644 partner_create_internal_user/wizard/__init__.py create mode 100644 partner_create_internal_user/wizard/internal_user_wizard.py create mode 100644 partner_create_internal_user/wizard/internal_user_wizard_views.xml diff --git a/partner_create_internal_user/README.rst b/partner_create_internal_user/README.rst new file mode 100644 index 0000000..14693df --- /dev/null +++ b/partner_create_internal_user/README.rst @@ -0,0 +1,107 @@ +============================ +Partner Create Internal User +============================ + +This module adds a **Grant internal user access** action on contacts +(``res.partner``), mirroring the standard *Grant portal access* wizard +provided by the ``portal`` module, but creating an **internal user** +(``base.group_user``) instead of a portal user. + +**Table of contents** + +.. contents:: + :local: + +Features +======== + +Grant Internal User Access +-------------------------- + +A new server action *Grant internal user access* is available from the +contact list and form views (in the *Action* menu). It opens a wizard listing +the selected contacts (and their child contacts), where you can, for each line: + +* **Grant Access**: create (or reuse) a user, make it an internal user and + send the standard "set your password" invitation email. +* **Revoke Access**: archive the internal user. +* **Re-Invite**: send the invitation email again. + +Email Validation +---------------- + +For each contact, the email address is validated and the wizard detects +whether the email is already used by another user, preventing duplicates. +Invalid or already-registered emails block the access grant. + +How It Works +============ + +The wizard is built on top of the same logic as the standard ``portal.wizard``: + +1. **Create**: when the contact has no user yet, a user is created from the + default user template (the ``base.template_portal_user_id`` system + parameter), in the same company as the contact. +2. **Promote**: the user is added to the internal users group + (``base.group_user``) and removed from the portal/public groups, so a + contact previously linked to a portal user is upgraded to internal. +3. **Invite**: an invitation email is sent using the standard + ``auth_signup.set_password_email`` template (via ``action_reset_password`` + with the ``create_user`` context), letting the user define their password. + +Dependencies +============ + +This module depends on: + +* ``auth_signup``: provides the user creation from template, the signup token + mechanism and the "set password" invitation email. + +Installation +============ + +Use the standard Odoo module installation procedure to install +``partner_create_internal_user``. + +Usage +===== + +1. Open a contact (or select several contacts in the list view). +2. Use *Action > Grant internal user access*. +3. Fix the email addresses if needed, then click *Grant Access*. + +Configuration +============= + +Creating internal users is restricted to users belonging to the +*Administration / Access Rights* group (``base.group_erp_manager``). + +The new users are created from the template user configured through the +``base.template_portal_user_id`` system parameter and then moved to the +internal users group. + +Bug Tracker +=========== + +Bugs are tracked on `Elabore Git Issues `_. + +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smash it by providing a detailed and welcomed +feedback. + +Credits +======= + +Authors +------- + +* `Elabore `_ + +Maintainer +---------- + +.. image:: https://elabore.coop/logo.png + :alt: Elabore + :target: https://elabore.coop + +This module is maintained by Elabore. diff --git a/partner_create_internal_user/__init__.py b/partner_create_internal_user/__init__.py new file mode 100644 index 0000000..4027237 --- /dev/null +++ b/partner_create_internal_user/__init__.py @@ -0,0 +1 @@ +from . import wizard diff --git a/partner_create_internal_user/__manifest__.py b/partner_create_internal_user/__manifest__.py new file mode 100644 index 0000000..4dcd49e --- /dev/null +++ b/partner_create_internal_user/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2026 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "partner_create_internal_user", + "version": "18.0.1.0.0", + "author": "Elabore", + "website": "https://elabore.coop", + "maintainer": "Elabore", + "license": "AGPL-3", + "category": "Tools", + "summary": "Grant internal user access to a contact, " + "the same way as 'Grant portal access' but creating an internal user.", + "depends": [ + "auth_signup", + "mail", + ], + "data": [ + "security/ir.model.access.csv", + "wizard/internal_user_wizard_views.xml", + ], + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/partner_create_internal_user/i18n/fr.po b/partner_create_internal_user/i18n/fr.po new file mode 100644 index 0000000..f72ca60 --- /dev/null +++ b/partner_create_internal_user/i18n/fr.po @@ -0,0 +1,238 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_create_internal_user +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-04 10:08+0000\n" +"PO-Revision-Date: 2026-06-04 10:08+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_create_internal_user +#: model:ir.model.fields.selection,name:partner_create_internal_user.selection__internal_user_wizard_user__email_state__exist +msgid "Already Registered" +msgstr "Déjà enregistré" + +#. module: partner_create_internal_user +#. odoo-python +#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0 +msgid "Cannot send the invitation: the user of %s has no email address." +msgstr "Impossible d'envoyer l'invitation : l'utilisateur %s n'a pas d'adresse e-mail. Impossible d'envoyer l'invitation : l'utilisateur %s n'a pas d'adresse e-mail." + +#. module: partner_create_internal_user +#. odoo-python +#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0 +msgid "Back office access granted" +msgstr "Accès au back office accordé" + +#. module: partner_create_internal_user +#. odoo-python +#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0 +msgid "Back office access revoked" +msgstr "Accès au back office révoqué" + +#. module: partner_create_internal_user +#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view +msgid "Close" +msgstr "Fermer" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__partner_id +msgid "Contact" +msgstr "" + +#. module: partner_create_internal_user +#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view +msgid "Contacts" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__create_uid +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__create_uid +msgid "Created by" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__create_date +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__create_date +msgid "Created on" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__display_name +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__display_name +msgid "Display Name" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__email +msgid "Email" +msgstr "" + +#. module: partner_create_internal_user +#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view +msgid "Email Address already taken by another user" +msgstr "Cette adresse e-mail est déjà utilisée par un autre utilisateur" + +#. module: partner_create_internal_user +#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view +msgid "Grant Access" +msgstr "Accorder l'accès" + +#. module: partner_create_internal_user +#: model:ir.model,name:partner_create_internal_user.model_internal_user_wizard +msgid "Grant Internal User Access" +msgstr "Créer un utilisateur interne" + +#. module: partner_create_internal_user +#: model:ir.actions.server,name:partner_create_internal_user.partner_internal_user_wizard_action_create_and_open +msgid "Grant internal user access" +msgstr "Créer un utilisateur interne" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__id +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__id +msgid "ID" +msgstr "" + +#. module: partner_create_internal_user +#. odoo-python +#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0 +#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view +msgid "Internal User Access Management" +msgstr "Gestion des accès des utilisateurs internes" + +#. module: partner_create_internal_user +#: model:ir.model,name:partner_create_internal_user.model_internal_user_wizard_user +msgid "Internal User Config" +msgstr "Configuration utilisateur interne" + +#. module: partner_create_internal_user +#: model:ir.model.fields.selection,name:partner_create_internal_user.selection__internal_user_wizard_user__email_state__ko +msgid "Invalid" +msgstr "Invalide" + +#. module: partner_create_internal_user +#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view +msgid "Invalid Email Address" +msgstr "Email adresse invalide" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__is_internal +msgid "Is Internal" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__is_portal +msgid "Is Portal" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__write_uid +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__write_date +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__write_date +msgid "Last Updated on" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__login_date +msgid "Latest Authentication" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__partner_ids +msgid "Partners" +msgstr "Contacts" + +#. module: partner_create_internal_user +#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view +msgid "Re-Invite" +msgstr "Ré-inviter" + +#. module: partner_create_internal_user +#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view +msgid "Revoke Access" +msgstr "Révoquer l'accès" + +#. module: partner_create_internal_user +#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view +msgid "" +"Select which contacts should become internal users in the list below.\n" +" The email address of each selected contact must be valid and unique.\n" +" If necessary, you can fix any contact's email address directly in the list." +msgstr "" +"Sélectionnez dans la liste ci-dessous les contacts qui doivent devenir des utilisateurs internes.\n" +" L'adresse e-mail de chaque contact sélectionné doit être valide et unique.\n" +" Si nécessaire, vous pouvez modifier l'adresse e-mail d'un contact directement dans la liste." + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__email_state +msgid "Status" +msgstr "Statut" + +#. module: partner_create_internal_user +#. odoo-python +#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0 +msgid "The contact \"%s\" does not have a valid email." +msgstr "L'adresse email du contact \"%s\" n'est pas valide." + +#. module: partner_create_internal_user +#. odoo-python +#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0 +msgid "The contact \"%s\" has the same email as an existing user." +msgstr "Le contact \"%s\" a la même adresse email qu'un utilisateur existant." + +#. module: partner_create_internal_user +#. odoo-python +#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0 +msgid "The partner \"%s\" already has internal user access." +msgstr "Le contact \"%s\" a déjà un utilisateur interne associé." + +#. module: partner_create_internal_user +#. odoo-python +#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0 +msgid "The partner \"%s\" has no internal user access." +msgstr "Le contact \"%s\" n'a pas d'utilisateur interne associé." + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__user_id +msgid "User" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard__user_ids +msgid "Users" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields.selection,name:partner_create_internal_user.selection__internal_user_wizard_user__email_state__ok +msgid "Valid" +msgstr "" + +#. module: partner_create_internal_user +#: model_terms:ir.ui.view,arch_db:partner_create_internal_user.internal_user_wizard_view +msgid "Valid Email Address" +msgstr "" + +#. module: partner_create_internal_user +#: model:ir.model.fields,field_description:partner_create_internal_user.field_internal_user_wizard_user__wizard_id +msgid "Wizard" +msgstr "" + +#. module: partner_create_internal_user +#. odoo-python +#: code:addons/partner_create_internal_user/wizard/internal_user_wizard.py:0 +msgid "You should first grant internal user access to the partner \"%s\"." +msgstr "" diff --git a/partner_create_internal_user/security/ir.model.access.csv b/partner_create_internal_user/security/ir.model.access.csv new file mode 100644 index 0000000..3071b2d --- /dev/null +++ b/partner_create_internal_user/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_internal_user_wizard,access.internal.user.wizard,model_internal_user_wizard,base.group_erp_manager,1,1,1,0 +access_internal_user_wizard_user,access.internal.user.wizard.user,model_internal_user_wizard_user,base.group_erp_manager,1,1,1,0 diff --git a/partner_create_internal_user/wizard/__init__.py b/partner_create_internal_user/wizard/__init__.py new file mode 100644 index 0000000..97d2eda --- /dev/null +++ b/partner_create_internal_user/wizard/__init__.py @@ -0,0 +1 @@ +from . import internal_user_wizard diff --git a/partner_create_internal_user/wizard/internal_user_wizard.py b/partner_create_internal_user/wizard/internal_user_wizard.py new file mode 100644 index 0000000..523d7a6 --- /dev/null +++ b/partner_create_internal_user/wizard/internal_user_wizard.py @@ -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 + ) diff --git a/partner_create_internal_user/wizard/internal_user_wizard_views.xml b/partner_create_internal_user/wizard/internal_user_wizard_views.xml new file mode 100644 index 0000000..69e94c6 --- /dev/null +++ b/partner_create_internal_user/wizard/internal_user_wizard_views.xml @@ -0,0 +1,51 @@ + + + + + Grant internal user access + + + code + action = model.action_open_wizard() + + + + + Grant internal user access + internal.user.wizard + +
+
+ Select which contacts should become internal users in the list below. + The email address of each selected contact must be valid and unique. + If necessary, you can fix any contact's email address directly in the list. +
+ + + + + +