[IMP] portal: improve the "grant access wizard" usability

Purpose
=======
Improve the "grant access wizard" usability, allow to re-invite the
partners and grant / access per partner and not in batch.

Specifications
==============
Add 3 buttons to grant / revoke the access and to re-invite the partner.
When the partner has an internal user linked, do not allow to manage
him in the view (disable the button) because we do not want to remove
the "internal user group" in the wizard, but only the "portal user
group".

When we revoke the access to a partner, if the corresponding user
belong only to the portal group, we archive it instead of deleting it.
So if we re-grant the portal access, he will have the same user as
before and keep his preference, etc.

Task 2381921

closes odoo/odoo#63024

Related: odoo/upgrade#2011
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
std-odoo
2021-02-18 08:37:04 +00:00
parent 84f89d6ff8
commit 83e22fd063
4 changed files with 384 additions and 139 deletions
+1
View File
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_tours
from . import test_portal_wizard
+176
View File
@@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
from odoo.exceptions import UserError, AccessError
from odoo.tests.common import users
class TestPortalWizard(MailCommon):
def setUp(self):
super(TestPortalWizard, self).setUp()
self.partner = self.env['res.partner'].create({
'name': 'Testing Partner',
'email': 'testing_partner@example.com',
})
self.public_user = mail_new_test_user(
self.env,
name='Public user',
login='public_user',
email='public_user@example.com',
groups='base.group_public',
)
self.portal_user = mail_new_test_user(
self.env,
name='Portal user',
login='portal_user',
email='portal_user@example.com',
groups='base.group_portal',
)
self.internal_user = mail_new_test_user(
self.env,
name='Internal user',
login='internal_user',
email='internal_user@example.com',
groups='base.group_user',
)
def test_portal_wizard_acl(self):
portal_wizard = self.env['portal.wizard'].with_context(active_ids=[self.partner.id]).create({})
with self.assertRaises(AccessError, msg='Standard users should not be able to open the portal wizard'):
self.env['portal.wizard'].with_context(active_ids=[self.partner.id]).with_user(self.user_employee).create({})
portal_wizard.invalidate_cache()
with self.assertRaises(AccessError, msg='Standard users should not be able to open the portal wizard'):
portal_wizard.with_user(self.user_employee).welcome_message
portal_wizard.user_ids.invalidate_cache()
with self.assertRaises(AccessError, msg='Standard users should not be able to open the portal wizard'):
portal_wizard.user_ids.with_user(self.user_employee).email
@users('admin')
def test_portal_wizard_partner(self):
portal_wizard = self.env['portal.wizard'].with_context(active_ids=[self.partner.id]).create({})
self.assertEqual(len(portal_wizard.user_ids), 1)
portal_user = portal_wizard.user_ids
self.assertFalse(portal_user.user_id.id)
self.assertFalse(portal_user.is_portal)
self.assertFalse(portal_user.is_internal)
portal_user.email = 'first_email@example.com'
with self.mock_mail_gateway():
portal_user.action_grant_access()
new_user = portal_user.user_id
self.assertTrue(new_user.id, 'Must create a new user')
self.assertTrue(new_user.has_group('base.group_portal'), 'Must add the group to the user')
self.assertEqual(self.partner.email, 'first_email@example.com', 'Must write on the email of the partner')
self.assertEqual(new_user.email, 'first_email@example.com', 'Must create the user with the right email')
self.assertSentEmail(self.env.user.partner_id, [self.partner])
@users('admin')
def test_portal_wizard_public_user(self):
"""Test to grant the access to a public user.
Should remove the group "base.group_public" and add the group "base.group_portal"
"""
group_public = self.env.ref('base.group_public')
public_partner = self.public_user.partner_id
portal_wizard = self.env['portal.wizard'].with_context(active_ids=[public_partner.id]).create({})
self.assertEqual(len(portal_wizard.user_ids), 1)
portal_user = portal_wizard.user_ids
self.assertEqual(portal_user.user_id, self.public_user)
self.assertFalse(portal_user.is_portal)
self.assertFalse(portal_user.is_internal)
portal_user.email = 'new_email@example.com'
with self.mock_mail_gateway():
portal_user.action_grant_access()
self.assertTrue(portal_user.is_portal)
self.assertFalse(portal_user.is_internal)
self.assertTrue(self.public_user.has_group('base.group_portal'), 'Must add the group portal')
self.assertFalse(self.public_user.has_group('base.group_public'), 'Must remove the group public')
self.assertEqual(public_partner.email, 'new_email@example.com', 'Must change the email of the partner')
self.assertEqual(self.public_user.email, 'new_email@example.com', 'Must change the email of the user')
self.assertSentEmail(self.env.user.partner_id, [public_partner])
with self.mock_mail_gateway():
portal_user.action_revoke_access()
self.assertEqual(portal_user.user_id, self.public_user, 'Must keep the user even if it is archived')
self.assertEqual(group_public, portal_user.user_id.groups_id, 'Must add the group public after removing the portal group')
self.assertFalse(portal_user.user_id.active, 'Must have archived the user')
self.assertFalse(portal_user.is_portal)
self.assertFalse(portal_user.is_internal)
self.assertNotSentEmail()
@users('admin')
def test_portal_wizard_internal_user(self):
"""Internal user can not be managed from this wizard."""
internal_partner = self.internal_user.partner_id
portal_wizard = self.env['portal.wizard'].with_context(active_ids=[internal_partner.id]).create({})
self.assertEqual(len(portal_wizard.user_ids), 1)
portal_user = portal_wizard.user_ids
self.assertTrue(portal_user.is_internal)
with self.assertRaises(UserError, msg='Should not be able to manage internal user'), self.mock_mail_gateway():
portal_wizard.user_ids.action_grant_access()
self.assertNotSentEmail()
with self.assertRaises(UserError, msg='Should not be able to manage internal user'):
portal_wizard.user_ids.action_revoke_access()
@users('admin')
def test_portal_wizard_error(self):
portal_wizard = self.env['portal.wizard'].with_context(active_ids=[self.portal_user.partner_id.id]).create({})
self.assertEqual(len(portal_wizard.user_ids), 1)
portal_user = portal_wizard.user_ids
self.internal_user.login = 'test_error@example.com'
portal_user.email = 'test_error@example.com'
with self.assertRaises(UserError, msg='Must detect the already used email.'):
portal_user.action_revoke_access()
portal_user.email = 'wrong email format'
with self.assertRaises(UserError, msg='Must detect wrong email format.'):
portal_user.action_revoke_access()
portal_wizard = self.env['portal.wizard'].with_context(active_ids=[self.internal_user.partner_id.id]).create({})
with self.assertRaises(UserError, msg='Must not be able to change internal user group.'):
portal_user.action_revoke_access()
def test_portal_wizard_multi_company(self):
company_1 = self.env['res.company'].search([], limit=1)
company_2 = self.env['res.company'].create({'name': 'Company 2'})
partner_company_2 = self.env['res.partner'].with_company(company_2).create({
'name': 'Testing Partner',
'email': 'testing_partner@example.com',
'company_id': company_2.id,
})
portal_wizard = self.env['portal.wizard'].with_context(active_ids=[partner_company_2.id]).create({})
portal_user = portal_wizard.user_ids
portal_user.with_company(company_1).action_grant_access()
self.assertEqual(portal_user.user_id.company_id, company_2, 'Must create the user in the same company as the partner.')
+180 -129
View File
@@ -4,22 +4,14 @@
import logging
from odoo.tools.translate import _
from odoo.tools import email_split
from odoo.tools import email_normalize
from odoo.exceptions import UserError
from odoo import api, fields, models
from odoo import api, fields, models, Command
_logger = logging.getLogger(__name__)
# welcome email sent to portal users
# (note that calling '_' has no effect except exporting those strings for translation)
def extract_email(email):
""" extract the email address from a user-friendly email address """
addresses = email_split(email)
return addresses[0] if addresses else ''
class PortalWizard(models.TransientModel):
"""
@@ -29,34 +21,56 @@ class PortalWizard(models.TransientModel):
_name = 'portal.wizard'
_description = 'Grant Portal Access'
def _default_user_ids(self):
# for each partner, determine corresponding portal.wizard.user records
partner_ids = self.env.context.get('active_ids', [])
def _default_partner_ids(self):
partner_ids = self.env.context.get('default_partner_ids', []) or self.env.context.get('active_ids', [])
contact_ids = set()
user_changes = []
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
for contact in contact_partners:
# make sure that each contact appears at most once in the list
if contact.id not in contact_ids:
contact_ids.add(contact.id)
in_portal = False
if contact.user_ids:
in_portal = self.env.ref('base.group_portal') in contact.user_ids[0].groups_id
user_changes.append((0, 0, {
'partner_id': contact.id,
'email': contact.email,
'in_portal': in_portal,
}))
return user_changes
contact_ids |= set(contact_partners.ids)
user_ids = fields.One2many('portal.wizard.user', 'wizard_id', string='Users',default=_default_user_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('portal.wizard.user', 'wizard_id', string='Users', compute='_compute_user_ids', store=True, readonly=False)
welcome_message = fields.Text('Invitation Message', help="This text is included in the email sent to new users of the portal.")
def action_apply(self):
self.ensure_one()
self.user_ids.action_apply()
return {'type': 'ir.actions.act_window_close'}
@api.depends('partner_ids')
def _compute_user_ids(self):
for portal_wizard in self:
portal_wizard.user_ids = [
Command.create({
'partner_id': partner.id,
'email': partner.email,
})
for partner in portal_wizard.partner_ids
]
@api.model
def action_open_wizard(self):
"""Create a "portal.wizard" and open the form view.
We need a server action for that because the one2many "user_ids" records need to
exist to be able to execute an a button action on it. If they have no ID, the
buttons will be disabled and we won't be able to click on them.
That's why we need a server action, to create the records and then open the form
view on them.
"""
portal_wizard = self.create({})
return portal_wizard._action_open_modal()
def _action_open_modal(self):
"""Allow to keep the wizard modal open after executing the action."""
self.refresh()
return {
'name': _('Portal Access Management'),
'type': 'ir.actions.act_window',
'res_model': 'portal.wizard',
'view_type': 'form',
'view_mode': 'form',
'res_id': self.id,
'target': 'new',
}
class PortalWizardUser(models.TransientModel):
@@ -70,103 +84,125 @@ class PortalWizardUser(models.TransientModel):
wizard_id = fields.Many2one('portal.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')
in_portal = fields.Boolean('In Portal')
user_id = fields.Many2one('res.users', string='Login User')
def get_error_messages(self):
emails = []
partners_error_empty = self.env['res.partner']
partners_error_emails = self.env['res.partner']
partners_error_user = self.env['res.partner']
partners_error_internal_user = self.env['res.partner']
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_portal = fields.Boolean('Is Portal', compute='_compute_group_details')
is_internal = fields.Boolean('Is Internal', compute='_compute_group_details')
for wizard_user in self.with_context(active_test=False).filtered(lambda w: w.in_portal and not w.partner_id.user_ids):
email = extract_email(wizard_user.email)
if not email:
partners_error_empty |= wizard_user.partner_id
elif email in emails:
partners_error_emails |= wizard_user.partner_id
user = self.env['res.users'].sudo().with_context(active_test=False).search([('login', '=ilike', email)])
if user:
partners_error_user |= wizard_user.partner_id
emails.append(email)
@api.depends('partner_id')
def _compute_user_id(self):
for portal_wizard_user in self:
user = portal_wizard_user.partner_id.with_context(active_test=False).user_ids
portal_wizard_user.user_id = user[0] if user else False
for wizard_user in self.with_context(active_test=False):
if any(u.has_group('base.group_user') for u in wizard_user.sudo().partner_id.user_ids):
partners_error_internal_user |= wizard_user.partner_id
@api.depends('user_id', 'user_id.groups_id')
def _compute_group_details(self):
for portal_wizard_user in self:
user = portal_wizard_user.user_id
error_msg = []
if partners_error_empty:
error_msg.append("%s\n- %s" % (_("Some contacts don't have a valid email: "),
'\n- '.join(partners_error_empty.mapped('display_name'))))
if partners_error_emails:
error_msg.append("%s\n- %s" % (_("Several contacts have the same email: "),
'\n- '.join(partners_error_emails.mapped('email'))))
if partners_error_user:
error_msg.append("%s\n- %s" % (_("Some contacts have the same email as an existing portal user:"),
'\n- '.join([p.email_formatted for p in partners_error_user])))
if partners_error_internal_user:
error_msg.append("%s\n- %s" % (_("Some contacts are already internal users:"),
'\n- '.join(partners_error_internal_user.mapped('email'))))
if error_msg:
error_msg.append(_("To resolve this error, you can: \n"
"- Correct the emails of the relevant contacts\n"
"- Grant access only to contacts with unique emails"))
error_msg[-1] += _("\n- Switch the internal users to portal manually")
return error_msg
def action_apply(self):
self.env['res.partner'].check_access_rights('write')
""" From selected partners, add corresponding users to chosen portal group. It either granted
existing user, or create new one (and add it to the group).
"""
error_msg = self.get_error_messages()
if error_msg:
raise UserError("\n\n".join(error_msg))
for wizard_user in self.sudo().with_context(active_test=False):
group_portal = self.env.ref('base.group_portal')
#Checking if the partner has a linked user
user = wizard_user.partner_id.user_ids[0] if wizard_user.partner_id.user_ids else None
# update partner email, if a new one was introduced
if wizard_user.partner_id.email != wizard_user.email:
wizard_user.partner_id.write({'email': wizard_user.email})
# add portal group to relative user of selected partners
if wizard_user.in_portal:
user_portal = None
# create a user if necessary, and make sure it is in the portal group
if not user:
if wizard_user.partner_id.company_id:
company_id = wizard_user.partner_id.company_id.id
else:
company_id = self.env.company.id
user_portal = wizard_user.sudo().with_company(company_id)._create_user()
else:
user_portal = user
wizard_user.write({'user_id': user_portal.id})
if not wizard_user.user_id.active or group_portal not in wizard_user.user_id.groups_id:
wizard_user.user_id.write({'active': True, 'groups_id': [(4, group_portal.id)]})
# prepare for the signup process
wizard_user.user_id.partner_id.signup_prepare()
wizard_user.with_context(active_test=True)._send_email()
wizard_user.refresh()
if user and user.has_group('base.group_user'):
portal_wizard_user.is_internal = True
portal_wizard_user.is_portal = False
elif user and user.has_group('base.group_portal'):
portal_wizard_user.is_internal = False
portal_wizard_user.is_portal = True
else:
# remove the user (if it exists) from the portal group
if user and group_portal in user.groups_id:
# if user belongs to portal only, deactivate it
if len(user.groups_id) <= 1:
user.write({'groups_id': [(3, group_portal.id)], 'active': False})
else:
user.write({'groups_id': [(3, group_portal.id)]})
portal_wizard_user.is_internal = False
portal_wizard_user.is_portal = False
def action_grant_access(self):
"""Grant the portal access to the partner.
If the partner has no linked user, we will create a new one in the same company
as the partner (or in the current company if not set).
An invitation email will be sent to the partner.
"""
self.ensure_one()
self._assert_user_email_uniqueness()
if self.is_portal or self.is_internal:
raise UserError(_('The partner "%s" already has the portal access.', self.partner_id.name))
group_portal = self.env.ref('base.group_portal')
group_public = self.env.ref('base.group_public')
# update partner email, if a new one was introduced
if self.partner_id.email != self.email:
self.partner_id.write({'email': self.email})
user_sudo = self.user_id.sudo()
if not user_sudo:
# create a user if necessary and make sure it is in the portal group
company = self.partner_id.company_id or self.env.company
user_sudo = self.sudo().with_company(company.id)._create_user()
if not user_sudo.active or not self.is_portal:
user_sudo.write({'active': True, 'groups_id': [(4, group_portal.id), (3, group_public.id)]})
# prepare for the signup process
user_sudo.partner_id.signup_prepare()
self.with_context(active_test=True)._send_email()
return self.wizard_id._action_open_modal()
def action_revoke_access(self):
"""Remove the user of the partner from the portal group.
If the user was only in the portal group, we archive it.
"""
self.ensure_one()
self._assert_user_email_uniqueness()
if not self.is_portal:
raise UserError(_('The partner "%s" has no portal access.', self.partner_id.name))
group_portal = self.env.ref('base.group_portal')
group_public = self.env.ref('base.group_public')
# update partner email, if a new one was introduced
if self.partner_id.email != self.email:
self.partner_id.write({'email': self.email})
# Remove the sign up token, so it can not be used
self.partner_id.signup_token = False
user_sudo = self.user_id.sudo()
# remove the user from the portal group
if user_sudo and user_sudo.has_group('base.group_portal'):
# if user belongs to portal only, deactivate it
if len(user_sudo.groups_id) <= 1:
user_sudo.write({'groups_id': [(3, group_portal.id), (4, group_public.id)], 'active': False})
else:
user_sudo.write({'groups_id': [(3, group_portal.id), (4, group_public.id)]})
return self.wizard_id._action_open_modal()
def action_invite_again(self):
"""Re-send the invitation email to the partner."""
self.ensure_one()
if not self.is_portal:
raise UserError(_('You should first grant the portal access to the partner "%s".', self.partner_id.name))
# update partner email, if a new one was introduced
if self.partner_id.email != self.email:
self.partner_id.write({'email': self.email})
self.with_context(active_test=True)._send_email()
return self.wizard_id._action_open_modal()
def _create_user(self):
""" create a new user for wizard_user.partner_id
:returns record of res.users
"""
return self.env['res.users'].with_context(no_reset_password=True)._create_user_from_template({
'email': extract_email(self.email),
'login': extract_email(self.email),
'email': email_normalize(self.email),
'login': email_normalize(self.email),
'partner_id': self.partner_id.id,
'company_id': self.env.company.id,
'company_ids': [(6, 0, self.env.company.ids)],
@@ -174,21 +210,36 @@ class PortalWizardUser(models.TransientModel):
def _send_email(self):
""" send notification email to a new portal user """
if not self.env.user.email:
raise UserError(_('You must have an email address in your User Preferences to send emails.'))
self.ensure_one()
# determine subject and body in the portal user's language
template = self.env.ref('portal.mail_template_data_portal_welcome')
for wizard_line in self:
lang = wizard_line.user_id.lang
partner = wizard_line.user_id.partner_id
if not template:
raise UserError(_('The template "Portal: new user" not found for sending email to the portal user.'))
portal_url = partner.with_context(signup_force_type_in_url='', lang=lang)._get_signup_url_for_action()[partner.id]
partner.signup_prepare()
lang = self.user_id.sudo().lang
partner = self.user_id.sudo().partner_id
if template:
template.with_context(dbname=self._cr.dbname, portal_url=portal_url, lang=lang).send_mail(wizard_line.id, force_send=True)
else:
_logger.warning("No email template found for sending email to the portal user")
portal_url = partner.with_context(signup_force_type_in_url='', lang=lang)._get_signup_url_for_action()[partner.id]
partner.signup_prepare()
template.with_context(dbname=self._cr.dbname, portal_url=portal_url, lang=lang).send_mail(self.id, force_send=True)
return True
def _assert_user_email_uniqueness(self):
"""Check that the email can be used to create a new user."""
self.ensure_one()
email = email_normalize(self.email)
if not email:
raise UserError(_('The contact "%s" does not have a valid email.', self.partner_id.name))
user = self.env['res.users'].sudo().with_context(active_test=False).search([
('id', '!=', self.user_id.id),
('login', '=ilike', email),
])
if user:
raise UserError(_('The contact "%s" has the same email has an existing user (%s).', self.partner_id.name, user.name))
+27 -10
View File
@@ -1,37 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- wizard action on res.partner -->
<record id="partner_wizard_action_create_and_open" model="ir.actions.server">
<field name="name">Grant portal access</field>
<field name="model_id" ref="portal.model_portal_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>
<record id="partner_wizard_action" model="ir.actions.act_window">
<field name="name">Grant portal access</field>
<field name="res_model">portal.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="base.model_res_partner"/>
</record>
<!-- wizard view -->
<record id="wizard_view" model="ir.ui.view">
<field name="name">Grant Portal Access</field>
<field name="name">Grant portal access</field>
<field name="model">portal.wizard</field>
<field name="arch" type="xml">
<form string="Grant Portal Access">
<div>
<form string="Portal Access Management">
<div class="mb-3">
Select which contacts should belong to the portal 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="welcome_message"
placeholder="This text is included in the email sent to new portal users."
class="mb-3"/>
<field name="user_ids">
<tree string="Contacts" editable="bottom" create="false" delete="false">
<field name="partner_id" force_save="1"/>
<field name="email"/>
<field name="in_portal"/>
<field name="email" attrs="{'readonly': [('is_internal', '=', True)]}"/>
<field name="login_date"/>
<field name="is_portal" invisible="1"/>
<field name="is_internal" invisible="1"/>
<button string="Grant Access" name="action_grant_access" type="object" class="btn-secondary"
attrs="{'invisible': ['|', ('is_portal', '=', True), ('is_internal', '=', True)]}"/>
<button string="Revoke Access" name="action_revoke_access" type="object" class="btn-secondary"
attrs="{'invisible': ['|', ('is_portal', '=', False), ('is_internal', '=', True)]}"/>
<button string="Re-Invite" name="action_invite_again" type="object" class="btn-secondary"
attrs="{'invisible': ['|', ('is_portal', '=', False), ('is_internal', '=', True)]}"/>
<button string="Internal User" attrs="{'invisible': [('is_internal', '=', False)]}"
disabled="True" title="This partner is linked to an internal User and already has access to the Portal."/>
</tree>
</field>
<field name="welcome_message"
placeholder="This text is included in the email sent to new portal users."/>
<footer>
<button string="Apply" name="action_apply" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel" />
<button string="Close" class="btn-primary" special="save" />
</footer>
</form>
</field>