1 Commits

Author SHA1 Message Date
4d739af30a [ADD]partner_create_internal_user 2026-07-01 16:57:56 +02:00
24 changed files with 816 additions and 1060 deletions

View File

@@ -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 <https://git.elabore.coop/Elabore/partner-tools/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 <https://elabore.coop>`_
Maintainer
----------
.. image:: https://elabore.coop/logo.png
:alt: Elabore
:target: https://elabore.coop
This module is maintained by Elabore.

View File

@@ -0,0 +1 @@
from . import wizard

View File

@@ -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,
}

View File

@@ -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 ""

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_internal_user_wizard access.internal.user.wizard model_internal_user_wizard base.group_erp_manager 1 1 1 0
3 access_internal_user_wizard_user access.internal.user.wizard.user model_internal_user_wizard_user base.group_erp_manager 1 1 1 0

View File

@@ -0,0 +1 @@
from . import internal_user_wizard

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

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- wizard action on res.partner -->
<record id="partner_internal_user_wizard_action_create_and_open" model="ir.actions.server">
<field name="name">Grant internal user access</field>
<field name="model_id" ref="model_internal_user_wizard"/>
<field name="binding_model_id" ref="base.model_res_partner"/>
<field name="state">code</field>
<field name="code">action = model.action_open_wizard()</field>
</record>
<!-- wizard view -->
<record id="internal_user_wizard_view" model="ir.ui.view">
<field name="name">Grant internal user access</field>
<field name="model">internal.user.wizard</field>
<field name="arch" type="xml">
<form string="Internal User Access Management">
<div class="mb-3">
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.
</div>
<field name="user_ids">
<list string="Contacts" editable="bottom" create="false" delete="false">
<field name="partner_id" force_save="1"/>
<field name="email" readonly="is_internal"/>
<field name="email_state" column_invisible="True"/>
<button name="action_refresh_modal" type="object" icon="fa-check text-success"
invisible="email_state != 'ok'" title="Valid Email Address"/>
<button name="action_refresh_modal" type="object" icon="fa-times text-danger"
invisible="email_state != 'ko'" title="Invalid Email Address"/>
<button name="action_refresh_modal" type="object" icon="fa-user-times text-danger"
invisible="email_state != 'exist'" title="Email Address already taken by another user"/>
<field name="login_date"/>
<field name="is_internal" column_invisible="True"/>
<field name="is_portal" column_invisible="True"/>
<button string="Grant Access" name="action_grant_access" type="object" class="btn-secondary"
invisible="is_internal or email_state != 'ok'"/>
<button string="Revoke Access" name="action_revoke_access" type="object" class="btn-secondary"
invisible="not is_internal"/>
<button string="Re-Invite" name="action_invite_again" type="object" class="btn-secondary"
invisible="not is_internal or email_state != 'ok'"/>
</list>
</field>
<footer>
<button string="Close" class="btn-primary" special="save" data-hotkey="v"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -1 +0,0 @@
from . import models

View File

@@ -1,22 +0,0 @@
{
'name': 'partner_skills',
'version': '18.0.1.0.0',
'category': 'Contacts',
'summary': 'Manage skills for contacts/partners',
'description': 'Adds skill management (skills, skill types, skill levels) to partners.',
'depends': ['base','contacts'],
'data': [
'security/ir.model.access.csv',
'views/partner_skill_type_views.xml',
'views/res_partner_views.xml',
],
'assets': {
'web.assets_backend': [
'partner_skills/static/src/views/partner_skills_list_renderer.js',
'partner_skills/static/src/fields/partner_skills_one2many/*',
],
},
'license': 'LGPL-3',
'installable': True,
'auto_install': False,
}

View File

@@ -1,318 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * partner_skills
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-27 14:49+0000\n"
"PO-Revision-Date: 2026-04-27 14:49+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_skills
#. odoo-python
#: code:addons/partner_skills/models/partner_skill_type.py:0
msgid "%s (copy)"
msgstr ""
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_type__active
msgid "Active"
msgstr ""
#. module: partner_skills
#. odoo-javascript
#: code:addons/partner_skills/static/src/fields/partner_skills_one2many/partner_skills_one2many.xml:0
msgid "Add a skill"
msgstr "Ajouter une compétence"
#. module: partner_skills
#: model_terms:ir.ui.view,arch_db:partner_skills.partner_skill_type_view_form
#: model_terms:ir.ui.view,arch_db:partner_skills.partner_skill_type_view_search
msgid "Archived"
msgstr "Archivé"
#. module: partner_skills
#: model_terms:ir.ui.view,arch_db:partner_skills.partner_skill_line_view_form
msgid "Check all boxes"
msgstr "Cocher toutes les cases"
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill__color
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__color
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_type__color
msgid "Color"
msgstr "Couleur"
#. module: partner_skills
#: model:ir.model,name:partner_skills.model_res_partner
msgid "Contact"
msgstr ""
#. module: partner_skills
#: model_terms:ir.actions.act_window,help:partner_skills.partner_skill_type_action
msgid "Create a new skill type"
msgstr "Créer une nouvelle entrée"
#. module: partner_skills
#. odoo-javascript
#: code:addons/partner_skills/static/src/fields/partner_skills_one2many/partner_skills_one2many.xml:0
msgid "Create new Skills"
msgstr "Créer une nouvelle compétence"
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill__create_uid
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__create_uid
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__create_uid
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_type__create_uid
msgid "Created by"
msgstr ""
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill__create_date
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__create_date
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__create_date
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_type__create_date
msgid "Created on"
msgstr ""
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__default_level
msgid "Default Level"
msgstr "Niveau par défaut"
#. module: partner_skills
#: model_terms:ir.ui.view,arch_db:partner_skills.partner_skill_line_view_form
msgid "Discard"
msgstr "Annuler"
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill__display_name
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__display_name
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__display_name
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_type__display_name
msgid "Display Name"
msgstr ""
#. module: partner_skills
#: model_terms:ir.ui.view,arch_db:partner_skills.partner_skill_view_search
msgid "Group By"
msgstr ""
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill__id
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__id
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__id
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_type__id
msgid "ID"
msgstr ""
#. module: partner_skills
#: model:ir.model.fields,help:partner_skills.field_partner_skill_level__default_level
msgid ""
"If checked, this level will be the default one selected when choosing this "
"skill."
msgstr ""
"Si coché, ce niveau sera sélectionné par défaut lors du choix de cette "
"compétence."
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill__write_uid
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__write_uid
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__write_uid
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_type__write_uid
msgid "Last Updated by"
msgstr ""
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill__write_date
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__write_date
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__write_date
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_type__write_date
msgid "Last Updated on"
msgstr ""
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill__name
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__name
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_type__name
msgid "Name"
msgstr "Nom"
#. module: partner_skills
#. odoo-javascript
#: code:addons/partner_skills/static/src/views/partner_skills_list_renderer.js:0
msgid "Other"
msgstr "Autre"
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__partner_id
msgid "Partner"
msgstr "Contact"
#. module: partner_skills
#: model:ir.model,name:partner_skills.model_partner_skill
msgid "Partner Skill"
msgstr "Compétence"
#. module: partner_skills
#: model:ir.model,name:partner_skills.model_partner_skill_level
msgid "Partner Skill Level"
msgstr "Niveaux"
#. module: partner_skills
#: model:ir.model,name:partner_skills.model_partner_skill_type
msgid "Partner Skill Type"
msgstr "Domaine"
#. module: partner_skills
#: model:ir.ui.menu,name:partner_skills.menu_partner_skills
msgid "Partner Skills"
msgstr "Compétences"
#. module: partner_skills
#. odoo-javascript
#: code:addons/partner_skills/static/src/fields/partner_skills_one2many/partner_skills_one2many.xml:0
msgid "Pick a skill from the list"
msgstr "Choisissez une compétence dans la liste"
#. module: partner_skills
#. odoo-python
#: code:addons/partner_skills/models/partner_skill_line.py:0
msgid "Please save the contact before adding skills."
msgstr "Enregistrez le contact avant d'ajouter des compétences"
#. module: partner_skills
#. odoo-python
#: code:addons/partner_skills/models/partner_skill_line.py:0
msgid "Please select a skill type first."
msgstr "Selectionnez d'abord un domaine de compétences"
#. module: partner_skills
#. odoo-python
#: code:addons/partner_skills/models/partner_skill_line.py:0
msgid "Please select at least one skill."
msgstr "Sélectionnez au moins une compétence"
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__level_progress
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__level_progress
msgid "Progress"
msgstr ""
#. module: partner_skills
#: model:ir.model.fields,help:partner_skills.field_partner_skill_level__level_progress
#: model:ir.model.fields,help:partner_skills.field_partner_skill_line__level_progress
msgid "Progress from zero knowledge (0%) to fully mastered (100%)."
msgstr ""
"Progression d'une connaissance nulle (0%) à complètement maitrisée (100%)."
#. module: partner_skills
#: model:ir.model.constraint,message:partner_skills.constraint_partner_skill_level_check_level_progress
msgid "Progress should be a number between 0 and 100."
msgstr "La progression doit être un nombre compris entre 0 et 100."
#. module: partner_skills
#: model_terms:ir.ui.view,arch_db:partner_skills.partner_skill_line_view_form
msgid "Save"
msgstr "Enregistrer"
#. module: partner_skills
#. odoo-javascript
#: code:addons/partner_skills/static/src/fields/partner_skills_one2many/partner_skills_one2many.js:0
msgid "Select Skills"
msgstr "Sélectionnez des compétences"
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill__sequence
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__sequence
msgid "Sequence"
msgstr ""
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__skill_id
#: model:ir.model.fields,field_description:partner_skills.field_res_partner__skill_ids
#: model:ir.model.fields,field_description:partner_skills.field_res_users__skill_ids
#: model_terms:ir.ui.view,arch_db:partner_skills.res_partner_view_search_inherit_skills
msgid "Skill"
msgstr "Compétence"
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__skill_level_id
msgid "Skill Level"
msgstr "Niveau"
#. module: partner_skills
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill__skill_type_id
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_level__skill_type_id
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__skill_type_id
#: model_terms:ir.ui.view,arch_db:partner_skills.partner_skill_view_search
msgid "Skill Type"
msgstr "Domaine"
#. module: partner_skills
#: model:ir.actions.act_window,name:partner_skills.partner_skill_type_action
#: model:ir.ui.menu,name:partner_skills.menu_action_res_bank_form
msgid "Skill Types"
msgstr "Domaines"
#. module: partner_skills
#: model:ir.model,name:partner_skills.model_partner_skill_line
msgid "Skill level for a partner"
msgstr "Niveau"
#. module: partner_skills
#. odoo-javascript
#: code:addons/partner_skills/static/src/fields/partner_skills_one2many/partner_skills_one2many.xml:0
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_line__skill_ids
#: model:ir.model.fields,field_description:partner_skills.field_partner_skill_type__skill_ids
#: model:ir.model.fields,field_description:partner_skills.field_res_partner__partner_skill_ids
#: model:ir.model.fields,field_description:partner_skills.field_res_users__partner_skill_ids
#: model_terms:ir.ui.view,arch_db:partner_skills.partner_skill_line_view_form
#: model_terms:ir.ui.view,arch_db:partner_skills.partner_skill_type_view_form
#: model_terms:ir.ui.view,arch_db:partner_skills.res_partner_view_form_inherit_skills
msgid "Skills"
msgstr "Compétences"
#. module: partner_skills
#. odoo-python
#: code:addons/partner_skills/models/partner_skill_line.py:0
msgid "The skill %(name)s and skill type %(type)s doesn't match"
msgstr ""
"La compétence %(name)s et le domaine de compétences %(type)s ne "
"correspondent pas"
#. module: partner_skills
#. odoo-javascript
#: code:addons/partner_skills/static/src/fields/partner_skills_one2many/partner_skills_one2many.xml:0
msgid "There are no skills defined in the library."
msgstr "Aucune compétence n'est définie dans la bibliothèque."
#. module: partner_skills
#: model:ir.model.constraint,message:partner_skills.constraint_partner_skill_line__unique_skill
msgid "Two levels for the same skill is not allowed"
msgstr ""
"Deux niveaux pour la même compétence le même jour ne sont pas autorisés"
#. module: partner_skills
#. odoo-javascript
#: code:addons/partner_skills/static/src/fields/partner_skills_one2many/partner_skills_one2many.xml:0
msgid "Why not try adding some?"
msgstr "Pourquoi ne pas essayer d'en ajouter ?"
#. module: partner_skills
#. odoo-javascript
#: code:addons/partner_skills/static/src/fields/partner_skills_one2many/partner_skills_one2many.xml:0
msgid "You can add skills to this contact's profile."
msgstr "Vous pouvez ajouterdes compétences à ce contact"
#. module: partner_skills
#: model_terms:ir.ui.view,arch_db:partner_skills.partner_skill_type_view_form
msgid "e.g. Languages"
msgstr ""

View File

@@ -1,5 +0,0 @@
from . import partner_skill_type
from . import partner_skill
from . import partner_skill_level
from . import partner_skill_line
from . import res_partner

View File

@@ -1,20 +0,0 @@
from odoo import api, fields, models
class PartnerSkill(models.Model):
_name = 'partner.skill'
_description = "Partner Skill"
_order = "sequence, name"
name = fields.Char(required=True, translate=True)
sequence = fields.Integer(default=10)
skill_type_id = fields.Many2one('partner.skill.type', required=True, ondelete='cascade')
color = fields.Integer(related='skill_type_id.color')
@api.depends('skill_type_id')
@api.depends_context('from_skill_dropdown')
def _compute_display_name(self):
if not self._context.get('from_skill_dropdown'):
return super()._compute_display_name()
for record in self:
record.display_name = f"{record.name} ({record.skill_type_id.name})"

View File

@@ -1,39 +0,0 @@
from odoo import api, fields, models
class PartnerSkillLevel(models.Model):
_name = 'partner.skill.level'
_description = "Partner Skill Level"
_order = "sequence, level_progress desc"
sequence = fields.Integer(default=10)
skill_type_id = fields.Many2one('partner.skill.type', ondelete='cascade')
name = fields.Char(required=True)
level_progress = fields.Integer(string="Progress", help="Progress from zero knowledge (0%) to fully mastered (100%).")
default_level = fields.Boolean(help="If checked, this level will be the default one selected when choosing this skill.")
_sql_constraints = [
('check_level_progress', 'CHECK(level_progress BETWEEN 0 AND 100)', "Progress should be a number between 0 and 100."),
]
@api.depends('level_progress')
@api.depends_context('from_skill_level_dropdown')
def _compute_display_name(self):
if not self._context.get('from_skill_level_dropdown'):
return super()._compute_display_name()
for record in self:
record.display_name = f"{record.name} ({record.level_progress}%)"
@api.model_create_multi
def create(self, vals_list):
skill_levels = super().create(vals_list)
for level in skill_levels:
if level.default_level:
level.skill_type_id.skill_level_ids.filtered(lambda r: r.id != level.id).default_level = False
return skill_levels
def write(self, vals):
res = super().write(vals)
if vals.get('default_level'):
self.skill_type_id.skill_level_ids.filtered(lambda r: r.id != self.id).default_level = False
return res

View File

@@ -1,128 +0,0 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
class PartnerSkillLine(models.Model):
_name = 'partner.skill.line'
_description = "Skill level for a partner"
_order = "skill_type_id, skill_level_id"
_rec_name = "skill_id"
partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade')
skill_id = fields.Many2one(
'partner.skill', compute='_compute_skill_id', store=True,
domain="[('skill_type_id', '=', skill_type_id)]",
readonly=False, ondelete='cascade')
# Skill levels are hidden from the UI for now (client request). The field is
# kept so existing data is preserved and the feature can be re-enabled later.
skill_level_id = fields.Many2one(
'partner.skill.level',
# compute='_compute_skill_level_id', # re-enable with the method below
domain="[('skill_type_id', '=', skill_type_id)]",
store=True, readonly=False,
# required=True, # re-enable when skill levels come back
ondelete='cascade')
skill_type_id = fields.Many2one(
'partner.skill.type',
default=lambda self: self.env['partner.skill.type'].search([], limit=1),
required=True, ondelete='cascade')
# Form helper to allow multi-selection of skills in the "Select Skills"
# dialog. Each picked skill ends up as its own partner.skill.line via
# action_save_skill_selection.
skill_ids = fields.Many2many(
'partner.skill',
string="Skills",
domain="[('skill_type_id', '=', skill_type_id)]",
)
level_progress = fields.Integer(related='skill_level_id.level_progress')
color = fields.Integer(related='skill_type_id.color')
_sql_constraints = [
('_unique_skill', 'unique (partner_id, skill_id)',
"Two levels for the same skill is not allowed"),
]
@api.constrains('skill_id', 'skill_type_id')
def _check_skill_type(self):
for record in self:
if record.skill_id not in record.skill_type_id.skill_ids:
raise ValidationError(_(
"The skill %(name)s and skill type %(type)s doesn't match",
name=record.skill_id.name,
type=record.skill_type_id.name,
))
# Temporarily disabled: references skill_type_id.skill_level_ids which is
# commented out on partner.skill.type while levels are hidden.
# @api.constrains('skill_type_id', 'skill_level_id')
# def _check_skill_level(self):
# for record in self:
# if record.skill_level_id not in record.skill_type_id.skill_level_ids:
# raise ValidationError(_(
# "The skill level %(level)s is not valid for skill type: %(type)s",
# level=record.skill_level_id.name,
# type=record.skill_type_id.name,
# ))
@api.depends('skill_type_id', 'skill_ids')
def _compute_skill_id(self):
for record in self:
if record.skill_ids:
record.skill_id = record.skill_ids[0]
elif record.skill_type_id:
record.skill_id = record.skill_type_id.skill_ids[0] if record.skill_type_id.skill_ids else False
else:
record.skill_id = False
@api.onchange('skill_type_id')
def _onchange_skill_type_id_reset_skill_ids(self):
for record in self:
record.skill_ids = False
# Temporarily disabled: references skill_type_id.skill_level_ids which is
# commented out on partner.skill.type while levels are hidden.
# @api.depends('skill_id')
# def _compute_skill_level_id(self):
# for record in self:
# if not record.skill_id:
# record.skill_level_id = False
# else:
# skill_levels = record.skill_type_id.skill_level_ids
# record.skill_level_id = skill_levels.filtered('default_level') or skill_levels[0] if skill_levels else False
def action_save_skill_selection(self):
self.ensure_one()
if not self.skill_type_id:
raise UserError(_("Please select a skill type first."))
if not self.skill_ids:
raise UserError(_("Please select at least one skill."))
if not isinstance(self.partner_id.id, int):
raise UserError(_("Please save the contact before adding skills."))
existing_skills = self.partner_id.partner_skill_ids.filtered(
lambda l: l.skill_type_id == self.skill_type_id
).skill_id
skills_to_add = self.skill_ids - existing_skills
if skills_to_add:
self.env['partner.skill.line'].create([{
'partner_id': self.partner_id.id,
'skill_type_id': self.skill_type_id.id,
'skill_id': skill.id,
} for skill in skills_to_add])
return {
'type': 'ir.actions.client',
'tag': 'soft_reload',
}
def action_check_all_skills(self):
self.ensure_one()
if not self.skill_type_id:
raise UserError(_("Please select a skill type first."))
self.skill_ids = self.skill_type_id.skill_ids
@api.depends('skill_id', 'skill_level_id')
def _compute_display_name(self):
for record in self:
if record.skill_level_id:
record.display_name = f"{record.skill_id.name}: {record.skill_level_id.name}"
else:
record.display_name = record.skill_id.name or ""

View File

@@ -1,21 +0,0 @@
from random import randint
from odoo import fields, models
class PartnerSkillType(models.Model):
_name = 'partner.skill.type'
_description = "Partner Skill Type"
_order = "name"
def _get_default_color(self):
return randint(1, 11)
active = fields.Boolean('Active', default=True)
name = fields.Char(required=True, translate=True)
skill_ids = fields.One2many('partner.skill', 'skill_type_id', string="Skills")
#skill_level_ids = fields.One2many('partner.skill.level', 'skill_type_id', string="Levels", copy=True)
color = fields.Integer('Color', default=_get_default_color)
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=self.env._("%s (copy)", skill_type.name), color=0) for skill_type, vals in zip(self, vals_list)]

View File

@@ -1,16 +0,0 @@
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
partner_skill_ids = fields.One2many(
'partner.skill.line', 'partner_id', string="Skills",
domain=[('skill_type_id.active', '=', True)])
skill_ids = fields.Many2many(
'partner.skill', compute='_compute_skill_ids', store=True)
@api.depends('partner_skill_ids.skill_id')
def _compute_skill_ids(self):
for partner in self:
partner.skill_ids = partner.partner_skill_ids.skill_id or None

View File

@@ -1,5 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_partner_skill_type,partner.skill.type,model_partner_skill_type,base.group_user,1,1,1,1
access_partner_skill,partner.skill,model_partner_skill,base.group_user,1,1,1,1
access_partner_skill_level,partner.skill.level,model_partner_skill_level,base.group_user,1,1,1,1
access_partner_skill_line,partner.skill.line,model_partner_skill_line,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_partner_skill_type partner.skill.type model_partner_skill_type base.group_user 1 1 1 1
3 access_partner_skill partner.skill model_partner_skill base.group_user 1 1 1 1
4 access_partner_skill_level partner.skill.level model_partner_skill_level base.group_user 1 1 1 1
5 access_partner_skill_line partner.skill.line model_partner_skill_line base.group_user 1 1 1 1

View File

@@ -1,86 +0,0 @@
/** @odoo-module */
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import {
useX2ManyCrud,
useOpenX2ManyRecord,
} from "@web/views/fields/relational_utils";
import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
import { PartnerSkillsListRenderer } from "../../views/partner_skills_list_renderer";
import { useService } from "@web/core/utils/hooks";
import { onWillStart } from "@odoo/owl";
export class PartnerSkillsListRendererField extends PartnerSkillsListRenderer {
static template = "partner_skills.SkillsListRenderer";
setup() {
super.setup();
this.orm = useService("orm");
this.actionService = useService("action");
onWillStart(async () => {
const res = await this.orm.searchCount("partner.skill.type", []);
this.anySkills = res > 0;
});
}
get groupBy() {
return "skill_type_id";
}
async skillTypesAction() {
return this.actionService.doAction("partner_skills.partner_skill_type_action");
}
}
export class PartnerSkillsX2ManyField extends X2ManyField {
static components = {
...X2ManyField.components,
ListRenderer: PartnerSkillsListRendererField,
};
setup() {
super.setup();
const { saveRecord, updateRecord } = useX2ManyCrud(
() => this.list,
this.isMany2Many
);
const openRecord = useOpenX2ManyRecord({
resModel: this.list.resModel,
activeField: this.activeField,
activeActions: this.activeActions,
getList: () => this.list,
saveRecord: async (record) => {
await saveRecord(record);
await this.props.record.save({
onError: (e) => { this.list.delete(record); throw e; }
});
},
updateRecord: updateRecord,
withParentId: this.props.widget !== "many2many",
});
this._openRecord = (params) => {
params.title = _t("Select Skills");
openRecord({ ...params });
};
}
async onAdd({ context, editable } = {}) {
return super.onAdd({
editable,
context: {
...context,
default_partner_id: this.props.record.resId,
},
});
}
}
export const partnerSkillsX2ManyField = {
...x2ManyField,
component: PartnerSkillsX2ManyField,
};
registry.category("fields").add("partner_skills_one2many", partnerSkillsX2ManyField);

View File

@@ -1,27 +0,0 @@
.o_field_partner_skills_one2many {
.o_progress {
background: $gray-300;
border: 0;
height: 5px;
}
.o_progressbar_value {
font-size: $font-size-sm;
font-weight: bold;
}
}
table.o_partner_skill_table {
.o_partner_skills_dialog_form {
.o_progressbar {
display: flex;
align-items: center;
.o_progressbar_value input {
width: auto;
}
}
}
table-layout: fixed;
}

View File

@@ -1,68 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<t t-name="partner_skills.SkillsListRenderer" t-inherit-mode="primary" t-inherit="web.ListRenderer">
<xpath expr="//table" position="attributes">
<attribute name="t-attf-class" add="mb-1 {{ !isEditable ? 'cursor-default' : '' }} {{ !showTable ? 'd-none' : ''}} o_partner_skill_table" separator=" "/>
</xpath>
<xpath expr="//thead" position="attributes">
<attribute name="style">visibility: collapse;</attribute>
</xpath>
<xpath expr="//table" position="before">
<div name="skills_header" class="text-uppercase fw-bolder small ms-3" style="margin-top: 2rem; box-shadow: 0 1px 0 #e6e6e6">
Skills
</div>
</xpath>
<xpath expr="//table" position="after">
<t t-if="!showTable">
<t t-if="!anySkills">
<div name="no_skills" class="ms-3 mt-3">
<p>
There are no skills defined in the library.<br/>
Why not try adding some?
</p>
<button t-on-click="skillTypesAction"
class="btn btn-secondary ms-4 mt-3 text-center"
role="button" t-if="isEditable">
Create new Skills
</button>
</div>
</t>
<t t-else="">
<div name="skills_available" class="ms-3 mt-3">
<p class="mt-3">You can add skills to this contact's profile.</p>
<div class="ms-5">
<button t-on-click="props.onAdd"
class="btn btn-secondary ms-4 mt-3 text-center"
role="button" t-if="isEditable">
Pick a skill from the list
</button>
</div>
</div>
</t>
</t>
<t t-else="">
<div name="add_skill" class="ms-3 mt-2" t-if="isEditable">
<button t-on-click="props.onAdd"
class="btn btn-secondary btn-sm"
role="button">
Add a skill
</button>
</div>
</t>
</xpath>
</t>
<t t-name="partner_skills.SkillsListRenderer.Rows">
<t t-foreach="Object.entries(groupedList)" t-as="skill_group" t-key="skill_group[0]">
<tr class="o_group_has_content o_group_header">
<th tabindex="-1" class="o_group_name" t-att-colspan="colspan">
<span t-esc="skill_group[1].name"/>
</th>
</tr>
<t t-foreach="skill_group[1].list.records" t-as="record" t-key="record.id">
<t t-set="group" t-value="skill_group[1]"/>
<t t-call="{{ constructor.recordRowTemplate }}"/>
</t>
</t>
</t>
</odoo>

View File

@@ -1,53 +0,0 @@
/** @odoo-module */
import { _t } from "@web/core/l10n/translation";
import { ListRenderer } from "@web/views/list/list_renderer";
export class PartnerSkillsListRenderer extends ListRenderer {
get colspan() {
const span = this.allColumns.length;
if (this.isEditable) {
return span + 1;
}
return span;
}
get groupBy() {
return 'skill_type_id';
}
get groupedList() {
const grouped = {};
for (const record of this.list.records) {
const data = record.data;
const group = data[this.groupBy];
if (grouped[group[1]] === undefined) {
grouped[group[1]] = {
id: parseInt(group[0]),
name: group[1] || _t('Other'),
list: {
records: [],
},
};
}
grouped[group[1]].list.records.push(record);
}
return grouped;
}
get showTable() {
return this.props.list.records.length;
}
get isEditable() {
return this.props.editable !== false;
}
async onCellClicked(record, column, ev) {
if (!this.isEditable) {
return;
}
return await super.onCellClicked(record, column, ev);
}
}
PartnerSkillsListRenderer.rowsTemplate = "partner_skills.SkillsListRenderer.Rows";

View File

@@ -1,214 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Skill Level Tree -->
<!-- <record id="partner_skill_level_view_tree" model="ir.ui.view">
<field name="name">partner.skill.level.view.tree</field>
<field name="model">partner.skill.level</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="sequence" widget="handle" />
<field name="name" />
<field name="level_progress" widget="progressbar" />
<field name="default_level" widget="boolean_toggle" />
</list>
</field>
</record> -->
<!-- Skill Level Form -->
<!-- <record id="partner_skill_level_view_form" model="ir.ui.view">
<field name="name">partner.skill.level.view.form</field>
<field name="model">partner.skill.level</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name" />
<field name="level_progress" options="{'editable': true}" />
</group>
</sheet>
</form>
</field>
</record> -->
<!-- Skill Tree -->
<record id="partner_skill_view_tree" model="ir.ui.view">
<field name="name">partner.skill.view.tree</field>
<field name="model">partner.skill</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle" />
<field name="name" />
<field name="skill_type_id" />
</list>
</field>
</record>
<!-- Skill Search -->
<record id="partner_skill_view_search" model="ir.ui.view">
<field name="name">partner.skill.view.search</field>
<field name="model">partner.skill</field>
<field name="arch" type="xml">
<search>
<field name="name" />
<field name="skill_type_id" />
<group expand="0" string="Group By">
<filter name="skill_type" string="Skill Type"
context="{'group_by': 'skill_type_id'}" />
</group>
</search>
</field>
</record>
<!-- Skill Type Search -->
<record id="partner_skill_type_view_search" model="ir.ui.view">
<field name="name">partner.skill.type.view.search</field>
<field name="model">partner.skill.type</field>
<field name="arch" type="xml">
<search>
<field name="name" />
<field name="skill_ids" />
<!-- <field name="skill_level_ids" /> -->
<filter name="inactive" string="Archived" domain="[('active', '=', False)]" />
</search>
</field>
</record>
<!-- Skill Type Tree -->
<record id="partner_skill_type_view_tree" model="ir.ui.view">
<field name="name">partner.skill.type.view.tree</field>
<field name="model">partner.skill.type</field>
<field name="arch" type="xml">
<list>
<field name="name" />
<field name="color" widget="color_picker" />
<field name="skill_ids" widget="many2many_tags" />
<!-- <field name="skill_level_ids" widget="many2many_tags" /> -->
</list>
</field>
</record>
<!-- Skill Type Form -->
<record id="partner_skill_type_view_form" model="ir.ui.view">
<field name="name">partner.skill.type.view.form</field>
<field name="model">partner.skill.type</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
invisible="active" />
<div class="oe_title">
<h1>
<field name="name" placeholder="e.g. Languages" />
</h1>
</div>
<group>
<group>
<field name="active" invisible="1" />
<field name="color" widget="color_picker" />
</group>
</group>
<notebook>
<page string="Skills" name="skills">
<field name="skill_ids" nolabel="1">
<list editable="bottom">
<field name="sequence" widget="handle" />
<field name="name" />
</list>
</field>
</page>
<!-- <page string="Levels" name="levels">
<field name="skill_level_ids" nolabel="1">
<list editable="bottom">
<field name="name" />
<field name="level_progress" widget="progressbar"
options="{'editable': true}" />
<field name="default_level" widget="boolean_toggle_load" />
</list>
</field>
</page> -->
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Partner Skill Line Form (Select Skills dialog) -->
<!-- Skill level / progress fields are hidden (client request). To restore
them, un-comment the XML block marked "SKILL LEVEL BLOCK" below. -->
<record id="partner_skill_line_view_form" model="ir.ui.view">
<field name="name">partner.skill.line.view.form</field>
<field name="model">partner.skill.line</field>
<field name="arch" type="xml">
<form string="Skills" class="o_partner_skills_dialog_form">
<sheet>
<group>
<group>
<field name="partner_id" invisible="1"/>
<field name="skill_type_id" options="{'no_open': True, 'no_create': True}"/>
</group>
<group>
<field name="skill_id" invisible="1"/>
<field name="skill_ids"
widget="many2many_checkboxes"
domain="[('skill_type_id', '=', skill_type_id)]"
invisible="not skill_type_id"/>
<div class="text-end mb-2" invisible="not skill_type_id">
<button name="action_check_all_skills"
type="object"
string="Check all boxes"
class="btn btn-link btn-sm p-0"/>
</div>
<!-- SKILL LEVEL BLOCK (hidden for now)
<label for="skill_level_id"
invisible="not (skill_id or skill_type_id)"/>
<div class="o_row" invisible="not (skill_id or skill_type_id)">
<span class="ps-0" style="flex:1">
<field name="skill_level_id"
readonly="not skill_id"
options="{'no_open': True, 'no_create': True}"
context="{'from_skill_level_dropdown': True, 'default_skill_type_id': skill_type_id}"/>
</span>
<span style="flex:1">
<field name="level_progress" widget="progressbar" class="o_partner_skills_progress" invisible="not skill_level_id"/>
</span>
</div>
-->
</group>
</group>
</sheet>
<footer>
<button string="Save"
name="action_save_skill_selection"
type="object"
class="btn-primary"
data-hotkey="q"/>
<button string="Discard" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
<!-- Skill Type Action -->
<record id="partner_skill_type_action" model="ir.actions.act_window">
<field name="name">Skill Types</field>
<field name="res_model">partner.skill.type</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new skill type
</p>
</field>
</record>
<!-- Menu items -->
<menuitem id="menu_partner_skills"
name="Partner Skills"
parent="contacts.res_partner_menu_config"
sequence="10" />
<menuitem id="menu_action_res_bank_form"
action="partner_skill_type_action"
parent="menu_partner_skills"
sequence="1" />
</odoo>

View File

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_partner_view_form_inherit_skills" model="ir.ui.view">
<field name="name">res.partner.view.form.inherit.skills</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='contact_addresses']" position="after">
<page name="partner_skills" string="Skills">
<div class="o_partner_skills_group d-flex flex-column">
<field mode="list" nolabel="1" name="partner_skill_ids" widget="partner_skills_one2many" class="mt-2">
<list>
<field name="skill_id"/>
<!-- <field name="skill_level_id"/> -->
<!-- <field name="level_progress" widget="progressbar"/> -->
<field name="skill_type_id" optional="hidden"/>
</list>
</field>
</div>
</page>
</xpath>
</field>
</record>
<record id="res_partner_view_search_inherit_skills" model="ir.ui.view">
<field name="name">res.partner.view.search.inherit.skills</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_res_partner_filter"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="partner_skill_ids" string="Skill"/>
</xpath>
</field>
</record>
</odoo>