odoo18/addons_extensions/knowledge/tests/test_knowledge_article_busi...

1767 lines
84 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from datetime import datetime, timedelta
from lxml import html
from unittest.mock import patch
from urllib import parse
from odoo import exceptions
from odoo.addons.knowledge.tests.common import KnowledgeCommon, KnowledgeCommonWData
from odoo.exceptions import UserError
from odoo.tests.common import tagged, users
from odoo.tools import mute_logger
@tagged('knowledge_internals', 'knowledge_management')
class KnowledgeCommonBusinessCase(KnowledgeCommonWData):
@classmethod
def setUpClass(cls):
""" Add some hierarchy to have mixed rights tests """
super().setUpClass()
# - Private seq=997 private none (manager-w+)
# - Child1 seq=0 " "
# - Shared seq=998 shared none (admin-w+,employee-r+,manager-r+)
# - Child1 seq=0 " " (employee-w+)
# - Child2 seq=0 " " (portal-r+)
# - Child3 seq=0 " " (admin-w+,employee-n-)
# - Playground seq=999 workspace w+
# - Child1 seq=0 " "
# - Gd Child1
# - Gd Child2
# - GdGd Child1
# - Child2 seq=1 " "
# - Gd Child1
# - GdGd Child2
cls.shared_children += cls.env['knowledge.article'].sudo().create([
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_admin.id,
'permission': 'write',
}),
(0, 0, {'partner_id': cls.partner_employee.id,
'permission': 'none',
}),
],
'internal_permission': False,
'name': 'Shared Child3',
'parent_id': cls.article_shared.id,
},
])
# to test descendants computation, add some sub children
cls.wkspace_grandchildren = cls.env['knowledge.article'].create([
{'name': 'Grand Children of workspace',
'parent_id': cls.workspace_children[0].id,
},
{'name': 'Grand Children of workspace',
'parent_id': cls.workspace_children[0].id,
},
{'name': 'Grand Children of workspace',
'parent_id': cls.workspace_children[1].id,
}
])
cls.wkspace_grandgrandchildren = cls.env['knowledge.article'].create([
{'name': 'Grand Grand Children of workspace',
'parent_id': cls.wkspace_grandchildren[1].id,
},
{'name': 'Grand Children of workspace',
'parent_id': cls.wkspace_grandchildren[2].id,
},
])
cls.env.flush_all()
@tagged('knowledge_internals', 'knowledge_management')
class TestKnowledgeArticleBusiness(KnowledgeCommonBusinessCase):
""" Test business API and main tools or helpers methods. """
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_article_create(self):
""" Testing the helper to create articles with right values. """
Article = self.env['knowledge.article']
article = self.article_workspace.with_env(self.env)
readonly_article = self.article_shared.with_env(self.env)
_title = 'Fthagn'
new = Article.article_create(title=_title, parent_id=False, is_private=False)
self.assertMembers(new, 'write', {self.env.user.partner_id: 'write'}) # With the visibility we add directly the user as member
self.assertEqual(new.body, f'<h1>{_title}</h1>')
self.assertEqual(new.category, 'workspace')
self.assertEqual(new.name, _title)
self.assertFalse(new.parent_id)
self.assertEqual(new.sequence, self._base_sequence + 1)
_title = 'Fthagn, but private'
private = Article.article_create(title=_title, parent_id=False, is_private=True)
self.assertMembers(private, 'none', {self.env.user.partner_id: 'write'})
self.assertEqual(private.category, 'private')
self.assertFalse(private.parent_id)
self.assertEqual(private.sequence, self._base_sequence + 2)
_title = 'Fthagn, but with parent (workspace)'
child = Article.article_create(title=_title, parent_id=article.id, is_private=False)
self.assertMembers(child, False, {})
self.assertEqual(child.category, 'workspace')
self.assertEqual(child.parent_id, article)
self.assertEqual(child.sequence, 2, 'Already two children existing')
_title = 'Fthagn, but with parent (private): forces private'
child_private = Article.article_create(title=_title, parent_id=private.id, is_private=False)
self.assertMembers(child_private, False, {})
self.assertFalse(child_private.article_member_ids)
self.assertEqual(child_private.category, 'private')
self.assertEqual(child_private.parent_id, private)
self.assertEqual(child_private.sequence, 0)
_title = 'Fthagn, but private under non private: cracboum'
with self.assertRaises(exceptions.ValidationError):
Article.article_create(title=_title, parent_id=article.id, is_private=True)
_title = 'Fthagn, but with parent read only: cracboum'
with self.assertRaises(exceptions.AccessError):
Article.article_create(title=_title, parent_id=readonly_article.id, is_private=False)
# Test fix: cannot create under unwritable parent even if a sequence is set.
with self.assertRaises(exceptions.AccessError):
Article.create({
"name": "I've a sequence, can I bypass security ? Was internal Right ? no more !",
"parent_id": readonly_article.id,
"sequence": 10
})
private_nonmember = Article.sudo().create({
'article_member_ids': [
(0, 0, {'partner_id': self.partner_employee2.id,
'permission': 'write',}),
(0, 0, {'partner_id': self.partner_employee.id,
'permission': 'none',}),
],
'internal_permission': 'none',
'name': 'AdminPrivate',
})
# If no body given at create, make it reflect article title.
self.assertEqual(private_nonmember.body, "<h1>AdminPrivate</h1>")
_title = 'Fthagn, but with parent private none: cracboum'
with self.assertRaises(exceptions.AccessError):
Article.article_create(title=_title, parent_id=private_nonmember.id, is_private=False)
@users('employee')
def test_article_get_sidebar_articles(self):
""" Testing the main access point for the sidebar. """
playground_root = self.article_workspace.with_env(self.env)
playground_children = self.workspace_children.with_env(self.env)
shared_root = self.article_shared.with_env(self.env)
# all articles are folded, expect only roots and no favorite
sidebar_articles = self.env['knowledge.article'].get_sidebar_articles()
self.assertListEqual(sidebar_articles['favorite_ids'], [])
self.assertListEqual([article['id'] for article in sidebar_articles['articles']], (shared_root + playground_root).ids)
# add both articles as favorite, favorite_ids should be populated
(playground_root + shared_root).action_toggle_favorite()
sidebar_articles = self.env['knowledge.article'].get_sidebar_articles()
self.assertListEqual(sidebar_articles['favorite_ids'], (playground_root + shared_root).ids)
# remove access to shared article, favorite_ids should have one element and only playground should be kept
shared_root.sudo()._add_members(self.partner_employee, 'none', True)
sidebar_articles = self.env['knowledge.article'].get_sidebar_articles()
self.assertListEqual(sidebar_articles['favorite_ids'], playground_root.ids)
self.assertListEqual([article['id'] for article in sidebar_articles['articles']], playground_root.ids)
# unfold playground, should contain its children
sidebar_articles = self.env['knowledge.article'].get_sidebar_articles(playground_root.ids)
self.assertListEqual(sidebar_articles['favorite_ids'], [playground_root.id])
self.assertListEqual([article['id'] for article in sidebar_articles['articles']], (playground_children + playground_root).ids)
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
@users('employee')
def test_article_invite_members(self):
""" Test inviting members API. Create a hierarchy of 3 shared articles
and check privilege is not granted below invited articles.
# - Shared seq=998 shared none (admin-w+,employee-r+,manager-r+)
# - Child1 seq=0 " " (employee-w+)
# - Gd Child1 " " (manager-w+,employee-r+)
# - GdGd Child1 " " (employee-w+)
# - Gd Child2 " " (employee-w+)
# - Child2 seq=0 " " (portal-r+)
# - Child3 seq=0 " " (admin-w+,employee-n-)
"""
direct_child_read, direct_child_write = self.env['knowledge.article'].sudo().create([
{'article_member_ids': [
(0, 0, {'partner_id': self.partner_employee_manager.id,
'permission': 'write',
}),
(0, 0, {'partner_id': self.partner_employee.id,
'permission': 'read',
}),
],
'internal_permission': False,
'name': 'Shared Readonly Child (should not propagate)',
'parent_id': self.shared_children[0].id,
},
{'article_member_ids': [
(0, 0, {'partner_id': self.partner_employee.id,
'permission': 'write',
}),
],
'internal_permission': False,
'name': 'Shared Writable Child (propagate is ok)',
'parent_id': self.shared_children[0].id,
}
]).with_env(self.env)
grand_child = self.env['knowledge.article'].sudo().create({
'article_member_ids': [
(0, 0, {'partner_id': self.partner_employee.id,
'permission': 'write',
}),
],
'internal_permission': 'read',
'name': 'Shared GrandChild (blocked by readonly parent, should not propagate)',
'parent_id': direct_child_read.id,
}).with_env(self.env)
shared_article = self.shared_children[0].with_env(self.env)
self.assertMembers(shared_article, False,
{self.partner_employee: 'write'})
self.assertMembers(direct_child_read, False,
{self.partner_employee_manager: 'write',
self.partner_employee: 'read'})
self.assertMembers(direct_child_write, False,
{self.partner_employee: 'write'})
self.assertMembers(grand_child, 'read',
{self.partner_employee: 'write'})
# invite a mix of shared and internal people
partners = (self.customer + self.partner_employee_manager + self.partner_employee2).with_env(self.env)
with self.mock_mail_gateway():
shared_article.invite_members(partners, 'write')
self.assertMembers(shared_article, False,
{self.partner_employee: 'write',
self.customer: 'write',
self.partner_employee_manager: 'write',
self.partner_employee2: 'write'},
msg='Invite: should add rights for people')
self.assertMembers(direct_child_read, False,
{self.partner_employee: 'read',
self.customer: 'none',
self.partner_employee_manager: 'write',
self.partner_employee2: 'none'},
msg='Invite: rights should be stopped for non writable children')
self.assertMembers(direct_child_write, False,
{self.partner_employee: 'write'},
msg='Invite: writable child should not be impacted')
self.assertMembers(grand_child, 'read',
{self.partner_employee: 'write'},
msg='Invite: descendants should not be impacted')
# check access is effectively granted
shared_article.with_user(self.user_employee2).check_access('write')
shared_article.with_user(self.user_customer).check_access('write')
direct_child_write.with_user(self.user_employee2).check_access('write')
direct_child_write.with_user(self.user_customer).check_access('write')
direct_child_read.browse().with_user(self.user_employee2).check_access('read')
with self.assertRaises(exceptions.AccessError,
msg='Invite: access should have been blocked'):
direct_child_read.with_user(self.user_employee2).check_access('read')
grand_child.browse().with_user(self.user_employee2).check_access('read')
with self.assertRaises(exceptions.AccessError,
msg='Invite: access should have been blocked'):
grand_child.with_user(self.user_employee2).check_access('read')
# employee2 is downgraded, employee_manager is removed
with self.mock_mail_gateway():
shared_article.invite_members(partners[2], 'read')
with self.mock_mail_gateway():
shared_article.invite_members(partners[1], 'none')
self.assertMembers(shared_article, False,
{self.partner_employee: 'write',
self.customer: 'write',
self.partner_employee_manager: 'none',
self.partner_employee2: 'read'})
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.mail.models.mail_mail')
@users('employee')
def test_article_invite_members_rights(self):
""" Testing trying to bypass granted privilege: inviting people require
write access. """
article_shared = self.article_shared.with_env(self.env)
partners = (self.customer + self.partner_employee_manager + self.partner_employee2).with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg='Invite: cannot invite with read permission'):
article_shared.invite_members(partners, 'write')
with self.assertRaises(exceptions.AccessError,
msg='Invite: cannot try to reject people with read permission'):
article_shared.invite_members(partners, 'none')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.mail.models.mail_mail')
@users('employee')
def test_article_invite_members_non_accessible_children(self):
""" Test that user cannot give access to non-accessible children article
when inviting people.
# Private Parent private none (employee-w+)
# - Child1 " write (employee-no)
# - Gd Child1
# - Child2 " write (employee-r+)
# - Gd Child1
# - Child3 " " "
# - Gd Child1
"""
private_parent = self.env['knowledge.article'].create([{
'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee.id,
'permission': 'write',
})
],
'internal_permission': 'none',
'name': 'Private parent',
'parent_id': False,
}])
child_no_access, child_read_access, child_write_access = self.env['knowledge.article'].sudo().create([
{'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee.id,
'permission': 'none',
})
],
'internal_permission': 'write',
'name': 'Shared No Access Child (should not propagate)',
'parent_id': private_parent.id,
},
{'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee.id,
'permission': 'read',
})
],
'internal_permission': 'write',
'name': 'Shared Read Child (should not propagate)',
'parent_id': private_parent.id,
},
{'internal_permission': False,
'name': 'Shared Inherited Write Child (should propagate)',
'parent_id': private_parent.id,
}
]).with_env(self.env)
grandchild_no_access, grandchild_read_access, grandchild_write_access = self.env['knowledge.article'].sudo().create([
{'internal_permission': False,
'name': 'Shared inherit No access GrandChild (should not propagate)',
'parent_id': child_no_access.id,
},
{'internal_permission': False,
'name': 'Shared inherit read GrandChild (should not propagate)',
'parent_id': child_read_access.id,
},
{'internal_permission': False,
'name': 'Shared inherit write GrandChild (should propagate)',
'parent_id': child_write_access.id,
}
]).with_env(self.env)
partners = self.partner_employee_manager.with_env(self.env)
with self.mock_mail_gateway():
private_parent.invite_members(partners, 'read')
# Manager got read on article
self.assertMembers(private_parent, 'none', {
self.partner_employee: 'write',
self.partner_employee_manager: 'read'
})
# CHILDREN
# Manager got none on child_read_access
self.assertMembers(child_read_access, 'write', {
self.partner_employee: 'read',
self.partner_employee_manager: 'none'
})
# Manager got none on child_no_access
self.assertMembers(child_no_access, 'write', {
self.partner_employee: 'none',
self.partner_employee_manager: 'none'
})
# Manager got inherited read on child_write_access
self.assertMembers(child_write_access, False, {})
self.assertTrue(child_write_access.user_has_write_access)
self.assertTrue(child_write_access.with_user(self.user_employee_manager).user_has_access)
# GRAND CHILDREN
# Manager got inherited none on child_read_access and Employee still have inherited member access
self.assertMembers(grandchild_read_access, False, {})
self.assertTrue(grandchild_read_access.user_has_access)
with self.assertRaises(exceptions.AccessError):
grandchild_read_access.with_user(self.user_employee_manager).body # Acls should trigger AccessError
# Manager got inherited none on child_no_access and Employee still have no access
self.assertMembers(grandchild_no_access, False, {})
with self.assertRaises(exceptions.AccessError):
grandchild_no_access.body # Acls should trigger AccessError
with self.assertRaises(exceptions.AccessError):
grandchild_no_access.with_user(self.user_employee_manager).body # Acls should trigger AccessError
# Manager got inherited read on grandchild_write_access and Employee still have write access
self.assertMembers(grandchild_write_access, False, {})
self.assertTrue(grandchild_write_access.user_has_write_access)
self.assertTrue(grandchild_write_access.with_user(self.user_employee_manager).user_has_access)
@users('employee')
def test_article_toggle_favorite(self):
""" Testing the API for toggling favorites. """
playground_articles = (self.article_workspace + self.workspace_children).with_env(self.env)
self.assertEqual(playground_articles.mapped('is_user_favorite'), [False, False, False])
playground_articles[0].action_toggle_favorite()
playground_articles.invalidate_model(['is_user_favorite'])
self.assertEqual(playground_articles.mapped('is_user_favorite'), [True, False, False])
# correct uid-based computation
playground_articles_asmanager = playground_articles.with_user(self.user_employee_manager)
self.assertEqual(playground_articles_asmanager.mapped('is_user_favorite'), [False, False, False])
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.models.unlink')
@users('employee')
def test_article_make_private(self):
""" Testing the API that makes an article 'private'. Making an article
private generally:
- sets internal_permission 'none';
- sets current environment user as only write member;
A lot of extra post-processing is applied, see ``KnowledgeArticle.
_move_and_make_private()`` for details.
Specific setup for this test
# - Playground workspace w+ (customer-r+)
# - Child1 " " (customer-r+)
# - ReadMemb GrandChild " w+ (employee-r+)
# - Gd Child1 " "
# - Gd Child2 " "
# - GdGd Child1 " "
# - Child2 " "
# - Gd Child1 " "
# - GdGd Child " "
# - ReadMember Child " w+ (employee-r+)
# - ReadInternal Child " r+ (employee2-w+)
# - Hidden Child " w+ (employee-no)
"""
article_workspace = self.article_workspace.with_env(self.env)
workspace_children = self.workspace_children.with_env(self.env)
wkspace_grandchildren = self.wkspace_grandchildren.with_env(self.env)
wkspace_grandgrandchildren = self.wkspace_grandgrandchildren.with_env(self.env)
# add an additional member on 'article_workspace' and one of its children for further checks
(self.article_workspace + self.workspace_children[0]).write({
'article_member_ids': [(0, 0, {
'partner_id': self.customer.id,
'permission': 'read',
})]
})
# add 4 extra articles for further checks
# - one to which 'employee' only has 'read' access (as member)
# - one to which 'employee' only has 'read' access (internal)
# - one invisible, "employee2" has access to that one in write mode
# - one to which 'employee' only has 'read' access (as member) as grandchild (descendants testing)
[wkspace_child_read_member_access,
wkspace_child_read_internal_access,
wkspace_child_no_access,
wkspace_grandchild_read_member_access] = self.env['knowledge.article'].sudo().create([
{
'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee.id,
'permission': 'read',
})],
'internal_permission': 'write',
'name': 'Read Member Child',
'parent_id': article_workspace.id,
}, {
'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee2.id,
'permission': 'write',
})],
'internal_permission': 'read',
'name': 'Read Internal Child',
'parent_id': article_workspace.id,
}, {
'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee.id,
'permission': 'none',
})],
'internal_permission': 'write',
'name': 'Hidden Child',
'parent_id': article_workspace.id,
}, {
'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee.id,
'permission': 'read',
})],
'internal_permission': 'write',
'name': 'Read Member GrandChild',
'parent_id': workspace_children[0].id,
}
])
with self.assertRaises(exceptions.AccessError):
wkspace_child_no_access.with_env(self.env).body
article_workspace._move_and_make_private()
# 1. main article was correctly moved to private
self.assertEqual(article_workspace.category, 'private')
self.assertEqual(article_workspace.internal_permission, 'none')
self.assertMembers(
article_workspace,
'none',
{self.partner_employee: 'write'}
)
# 2. accessible children were correctly moved to private
for workspace_descendant, parent_id in zip(
workspace_children + wkspace_grandchildren + wkspace_grandgrandchildren,
[article_workspace, article_workspace, workspace_children[0], workspace_children[0],
workspace_children[1], wkspace_grandchildren[1], wkspace_grandchildren[2]]
):
self.assertEqual(workspace_descendant.category, 'private')
self.assertEqual(workspace_descendant.inherited_permission_parent_id, article_workspace)
self.assertEqual(workspace_descendant.parent_id, parent_id)
# all specific members should have been wiped, no permission
self.assertMembers(
workspace_descendant,
False,
{}
)
# 3.children that were not writable are moved as a root articles and that
# members / internal permissions are kept
self.assertEqual(wkspace_child_read_member_access.category, 'workspace')
self.assertFalse(wkspace_child_read_member_access.parent_id)
self.assertMembers(
wkspace_child_read_member_access,
'write',
{self.partner_employee: 'read',
self.customer: 'read'}
)
self.assertEqual(wkspace_child_read_internal_access.category, 'workspace')
self.assertFalse(wkspace_child_read_internal_access.parent_id)
self.assertMembers(
wkspace_child_read_internal_access,
'read',
{self.partner_employee2: 'write',
self.customer: 'read'}
)
self.assertEqual(wkspace_child_no_access.category, 'workspace')
self.assertFalse(wkspace_child_no_access.parent_id)
self.assertMembers(
wkspace_child_no_access,
'write',
{self.partner_employee: 'none',
self.customer: 'read'}
)
self.assertEqual(wkspace_grandchild_read_member_access.category, 'workspace')
self.assertFalse(wkspace_grandchild_read_member_access.parent_id)
self.assertMembers(
wkspace_grandchild_read_member_access,
'write',
{self.partner_employee: 'read',
self.customer: 'read'}
)
# 'Hidden Child' is still not accessible for employee
with self.assertRaises(exceptions.AccessError):
wkspace_child_no_access.with_env(self.env).body
wkspace_child_no_access.with_user(self.user_employee2).body
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.models.unlink')
@users('employee')
def test_article_make_private_w_desynchronized(self):
""" Test a special case when making private: we have desynchronized children.
Children that are de-synchronized should NOT have members from their parent(s)
copied onto them when they are detached.
Specific setup for this test
# - Playground workspace w+ (customer-r+,employee-w+)
# - Child1 " w+DES (customer-r+,employee-r+)
# - Gd Child1 " "
# - Gd Child2 " "
# - GdGd Child1 " "
# - Child2 " "
# - Gd Child1 " "
# - GdGd Child " "
"""
# add employee on playground and desynchronize its child
self.article_workspace._add_members(self.partner_employee, 'write')
self.workspace_children[0]._set_member_permission(
self.article_workspace.article_member_ids.filtered(
lambda member: member.partner_id == self.partner_employee
),
'read',
is_based_on=True,
)
# check updated data
self.assertTrue(self.workspace_children[0].is_desynchronized)
self.assertMembers(
self.article_workspace,
'write',
{self.partner_employee: 'write'}
)
self.assertMembers(
self.workspace_children[0],
'write',
{self.partner_employee: 'read'}
)
article_workspace = self.article_workspace.with_env(self.env)
[workspace_child_desync, workspace_child_tosync] = self.workspace_children.with_env(self.env)
# add a member on the parent, it will NOT be propagated to the desync child
article_workspace._add_members(self.customer, 'read')
self.assertMembers(
article_workspace,
'write',
{self.customer: 'read', self.partner_employee: 'write'}
)
# now move the article to private and check the post-processing
article_workspace._move_and_make_private()
# 1. desync article: moved to root (as employee does not have write access)
# and is not desynchronized (root articles are never desynchornized)
# should be moved to root (as employee does not have write access)
self.assertEqual(workspace_child_desync.category, "workspace")
self.assertFalse(workspace_child_desync.is_desynchronized)
self.assertFalse(workspace_child_desync.parent_id)
# it should NOT have had customer access copied onto it as it was desync when we moved it
self.assertMembers(
workspace_child_desync,
"write",
{self.partner_employee: 'read'}
)
# 2. sync article: NOT moved to root (as employee has write access) and is
# still sunchronized
self.assertEqual(workspace_child_tosync.category, "private")
self.assertFalse(workspace_child_desync.is_desynchronized)
self.assertEqual(workspace_child_tosync.parent_id, article_workspace)
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.models.unlink')
@users('employee_manager')
def test_article_make_private_w_parent(self):
""" Test a special case when making private: moving under an existing private parent. """
article_shared = self.article_shared.with_env(self.env)
article_private_manager = self.article_private_manager.with_env(self.env)
# first test that making 'article_shared' fails since 'employee_manager'
# does not have write access to it (only read)
with self.assertRaises(exceptions.AccessError):
article_shared._move_and_make_private(parent=article_private_manager)
# then grant write access to test the flow
article_shared.sudo().article_member_ids.filtered(
lambda member: member.partner_id == self.partner_employee_manager
).write({'permission': 'write'})
article_shared._move_and_make_private(parent=article_private_manager)
self.assertEqual(article_shared.category, 'private')
# the internal permission should not be set as we inherit from our private parent
# members should be wiped as we inherit from our private parent
self.assertMembers(
article_shared,
False,
{}
)
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_article_move_to(self):
""" Testing the API for moving articles. """
article_workspace = self.article_workspace.with_env(self.env)
article_shared = self.article_shared.with_env(self.env)
workspace_children = self.workspace_children.with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg='Cannot move under readonly parent'):
workspace_children[0].move_to(parent_id=article_shared.id)
with self.assertRaises(exceptions.AccessError,
msg='Cannot move a readonly article'):
article_shared[0].move_to(parent_id=article_workspace.id)
with self.assertRaises(exceptions.AccessError,
msg='Cannot move a readonly article (even out of any hierarchy)'):
article_shared[0].move_to(category='workspace')
# valid move: put second child of workspace under the first one
workspace_children[1].move_to(parent_id=workspace_children[0].id)
workspace_children.flush_model()
self.assertEqual(article_workspace.child_ids, workspace_children[0])
self.assertTrue(workspace_children < article_workspace._get_descendants())
self.assertEqual(workspace_children.root_article_id, article_workspace)
self.assertEqual(workspace_children[1].parent_id, workspace_children[0])
self.assertEqual(workspace_children[0].parent_id, article_workspace)
# Test that desynced articles are resynced when moved to root
workspace_children[0].sudo().write(workspace_children[0]._desync_access_from_parents_values())
self.assertTrue(workspace_children[0].is_desynchronized)
# other valid move: first child is moved to private section
workspace_children[0].move_to(category='private')
workspace_children.flush_model()
self.assertMembers(workspace_children[0], 'none', {self.partner_employee: 'write'})
self.assertEqual(workspace_children[0].category, 'private')
self.assertEqual(workspace_children[0].internal_permission, 'none')
self.assertFalse(workspace_children[0].is_desynchronized)
self.assertFalse(workspace_children[0].parent_id)
self.assertEqual(workspace_children.root_article_id, workspace_children[0])
workspace_child_item = self.env['knowledge.article'].create({
'name': 'Article Item Child',
'parent_id': article_workspace.id,
'is_article_item': True
}).with_env(self.env)
with self.assertRaises(exceptions.ValidationError):
workspace_children[0].move_to(parent_id=workspace_child_item.id)
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_article_move_to_shared(self):
""" Testing the valid moves to the shared section. """
article_private = self.env['knowledge.article'].sudo().create({
'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee.id,
'permission': 'write',
})],
'internal_permission': 'none',
'name': 'Employee Priv.',
'sequence': self._base_sequence - 3,
})
article_shared_employee = self.env['knowledge.article'].sudo().create({
'article_member_ids': [
(0, 0, {
'partner_id': self.partner_employee.id,
'permission': 'write',
}),
(0, 0, {
'partner_id': self.partner_employee2.id,
'permission': 'read',
}),
],
'internal_permission': 'none',
'name': 'Employee Shared',
'sequence': self._base_sequence - 4,
})
article_workspace = self.article_workspace.with_env(self.env)
article_workspace2 = self.env['knowledge.article'].sudo().create({
'internal_permission': 'write',
'name': 'To be shared',
'article_member_ids': [
(0, 0, {'partner_id': self.user_employee2.partner_id.id, 'permission': 'read'}),
],
}).with_env(self.env)
a_ws2_child_write, a_ws2_child_read = self.env['knowledge.article'].sudo().create([{
'name': 'Employee can write',
'article_member_ids': [
(0, 0, {'partner_id': self.partner_employee.id, 'permission': 'write'}),
],
'parent_id': article_workspace2.id,
}, {
'name': 'Employee can read',
'article_member_ids': [
(0, 0, {'partner_id': self.partner_employee.id, 'permission': 'read'}),
],
'parent_id': article_workspace2.id,
}]).with_env(self.env)
shared_child = self.shared_children[0].with_env(self.env)
# valid move: shared root -> shared root (resequence)
article_shared_employee.move_to(category='shared')
article_shared_employee.flush_model()
self.assertTrue(article_shared_employee.sequence > self.article_shared.sequence)
self.assertEqual(article_shared_employee.category, 'shared')
self.assertFalse(article_shared_employee.parent_id)
# valid move: workspace -> shared child
article_workspace.move_to(parent_id=article_shared_employee.id)
article_workspace.flush_model()
self.assertEqual(article_workspace.inherited_permission_parent_id, article_shared_employee)
self.assertFalse(article_workspace.internal_permission)
# valid move: private -> shared child
article_private.move_to(parent_id=article_shared_employee.id)
article_private.flush_model()
self.assertEqual(article_private.inherited_permission_parent_id, article_shared_employee)
self.assertFalse(article_workspace.internal_permission)
# valid move: shared child -> shared child
shared_child.move_to(parent_id=article_shared_employee.id)
shared_child.flush_model()
self.assertEqual(shared_child.inherited_permission_parent_id, article_shared_employee)
# valid move: shared child -> shared root
shared_child.move_to(category='shared')
shared_child.flush_model()
self.assertFalse(shared_child.parent_id)
self.assertEqual(shared_child.category, 'shared')
# should have added inherited members on the article
self.assertMembers(shared_child, 'none', {
self.partner_employee: 'write',
self.partner_employee2: 'read',
})
# valid move: workspace with 1 partner with read permission -> shared root
article_workspace2.move_to(category='shared', before_article_id=article_shared_employee.id)
article_workspace2.flush_model()
self.assertFalse(article_workspace2.parent_id)
self.assertEqual(article_workspace2.category, 'shared')
self.assertTrue(article_workspace2.sequence < article_shared_employee.sequence)
# should have added user as member on the article
self.assertMembers(article_workspace2, 'none', {
self.partner_employee: 'write',
self.partner_employee2: 'read',
})
# ensure that a_ws2_child_write is still a child of article_workspace2
self.assertEqual(a_ws2_child_write.parent_id, article_workspace2)
self.assertEqual(a_ws2_child_write.category, 'shared')
# ensure that a_ws2_child_read has become a root since employee did not
# have write access on it while moving article_workspace2 to shared
self.assertFalse(a_ws2_child_read.parent_id)
self.assertEqual(a_ws2_child_read.category, 'workspace')
@users('employee')
def test_user_has_access_parent_path(self):
Articles = self.env['knowledge.article']
root = Articles.with_user(self.user_admin).article_create(title="Root")
child = Articles.with_user(self.user_admin).article_create(title="Child", parent_id=root.id)
grandchild = Articles.with_user(self.user_admin).article_create(title="Grandchild", parent_id=child.id)
baby = Articles.with_user(self.user_admin).article_create(title="Baby", parent_id=grandchild.id)
root_user = root.with_user(self.env.user)
child_user = child.with_user(self.env.user)
grandchild_user = grandchild.with_user(self.env.user)
baby_user = baby.with_user(self.env.user)
self.assertTrue(baby_user.user_has_access_parent_path)
self.assertTrue(baby.user_has_access_parent_path)
child._add_members(self.env.user.partner_id, 'none')
grandchild._add_members(self.env.user.partner_id, 'write')
self.assertMembers(child, False, {self.env.user.partner_id: 'none'})
self.assertMembers(grandchild, False, {self.env.user.partner_id: 'write'})
self.assertMembers(root, 'write', {self.user_admin.partner_id: 'write'})
self.assertTrue(root_user.user_has_access)
self.assertTrue(root.user_has_access)
self.assertTrue(root_user.user_has_access_parent_path)
self.assertTrue(root.user_has_access_parent_path)
self.assertFalse(child_user.user_has_access)
self.assertTrue(child.user_has_access)
self.assertTrue(grandchild_user.user_has_access)
self.assertFalse(grandchild_user.user_has_access_parent_path)
self.assertTrue(baby.user_has_access_parent_path)
self.assertFalse(baby_user.user_has_access_parent_path)
with self.assertRaises(exceptions.AccessError):
baby_user.action_join()
@tagged('knowledge_internals', 'knowledge_management')
class TestKnowledgeArticleCopy(KnowledgeCommonBusinessCase):
""" Test copy and duplication of articles """
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('employee')
def test_article_duplicate(self):
""" Test articles duplication (=copy/copy_batch methods). Verifies that
the children of a duplicated article are also duplicated, that
duplicating an article and one of its children does not duplicate the
children 2 times, and that employee cannot bypass access rules.
"""
article_workspace = self.article_workspace.with_env(self.env)
# Selecting several articles in the same hierarchy should only duplicate the highest one
workspace_articles = article_workspace | article_workspace._get_descendants()
duplicate = workspace_articles.copy_batch()
self.assertEqual(
len(duplicate), 1,
'Copy batch should not return a copy of workspace descendants as they are already in article children'
)
self.assertEqual(duplicate.name, f'{article_workspace.name} (copy)')
self.assertEqual(len(duplicate.child_ids), 2, 'Copy batch should copy children')
self.assertEqual(
sorted(duplicate.mapped('child_ids.name')),
sorted([f'{name}' for name in article_workspace.mapped('child_ids.name')])
)
# Selecting 2 articles in different hierarchies (under same parent) should duplicate both
workspace_children = self.workspace_children.with_env(self.env)
duplicates = workspace_children.copy_batch()
self.assertEqual(
sorted(duplicates.mapped('name')),
sorted([f'{name}' for name in workspace_children.mapped('name')])
)
# Duplicating readonly article should raise an error
article_readonly = self.article_shared.with_env(self.env)
with self.assertRaises(exceptions.AccessError):
article_readonly.copy()
# Duplicating hidden article should raise an error
article_hidden = self.article_private_manager.with_env(self.env)
with self.assertRaises(exceptions.AccessError):
article_hidden.copy()
# Duplicating readonly article's child with write permission should raise an error
article_write_member = self.shared_children[0].with_env(self.env)
with self.assertRaises(exceptions.AccessError):
article_write_member.copy()
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('admin')
def test_article_duplicate_admin(self):
""" Test duplicate (copy_batch) as admin as he has enough rights to really
copy articles, not like employee currently. """
workspace_children = self.workspace_children.with_env(self.env)
shared = self.article_shared.with_env(self.env)
duplicates = (workspace_children + shared).copy_batch()
for original, copy in zip(workspace_children + shared, duplicates):
self.assertEqual(copy.name, f'{original.name}{" (copy)" if not original.parent_id else ""}')
self.assertEqual(len(original.child_ids), len(copy.child_ids))
self.assertEqual(len(original._get_descendants()), len(copy._get_descendants()))
self.assertNotEqual(original.child_ids, copy.child_ids)
self.assertEqual(
sorted(duplicates.mapped('child_ids.name')),
sorted([f'{name}' for name in (workspace_children + shared).mapped('child_ids.name')])
)
self.assertEqual(
sorted(article.name for article in duplicates[-1]._get_descendants()),
sorted(f'{article.name}' for article in shared._get_descendants()),
"Check descendants name is also updated (not only direct children)"
)
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('employee')
def test_article_make_private_copy(self):
article_hidden = self.article_private_manager.with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: copy should not allow to access hidden articles"):
_new_article = article_hidden.action_make_private_copy()
# Copying an article should create a private article without parent nor children
article_readonly = self.article_shared.with_env(self.env)
new_article = article_readonly.action_make_private_copy()
self.assertEqual(new_article.name, f'{article_readonly.name} (copy)')
self.assertMembers(
new_article,
'none',
{self.partner_employee: 'write'}
)
self.assertFalse(new_article.child_ids)
self.assertFalse(new_article.parent_id)
def test_article_make_private_copy_having_embedded_views_of_article_items(self):
""" When the user copies an article, the system should copy the body
of the original article and update the ID references stored within
it so that the embedded views listing the article items of the original
article now list the article items of the copy. This test will check
that the ID references have been updated in the body of the new article. """
article = self.env['knowledge.article'].create({
'name': 'Hello'
})
def render_embedded_view(embedded_props):
return '''
<div data-embedded='view'
data-oe-protected="true"
data-embedded-props="%s"/>
''' % (parse.quote(json.dumps(embedded_props)))
article.write({
'body': (
'<p>Hello world</p>' +
render_embedded_view({
'action_xml_id': 'knowledge.knowledge_article_item_action',
'display_name': 'Kanban',
'view_type': 'kanban',
'context': {
'active_id': article.id,
'default_parent_id': article.id,
'default_icon': '📄',
'default_is_article_item': True,
}
}) +
render_embedded_view({
'action_xml_id': 'knowledge.knowledge_article_item_action',
'display_name': 'List',
'view_type': 'list',
'context': {
'active_id': article.id,
'default_parent_id': article.id,
'default_icon': '📄',
'default_is_article_item': True,
}
}) +
render_embedded_view({
'action_xml_id': 'knowledge.knowledge_article_action',
'display_name': 'Articles',
'view_type': 'list',
'context': {
'search_default_filter_trashed': 1,
}
})
)
})
expected_view_types = [
'kanban',
'list',
'list'
]
expected_contexts = [{
'active_id': article.id,
'default_parent_id': article.id,
'default_icon': '📄',
'default_is_article_item': True,
}, {
'active_id': article.id,
'default_parent_id': article.id,
'default_icon': '📄',
'default_is_article_item': True,
}, {
'search_default_filter_trashed': 1,
}]
fragment = html.fragment_fromstring(article.body, create_parent=True)
embedded_views = list(fragment.findall('.//*[@data-embedded="view"]'))
# Check that the original article contains the embedded views we want
self.assertEqual(len(embedded_views), 3)
for (embedded_view, expected_view_type, expected_context) in zip(embedded_views, expected_view_types, expected_contexts):
embedded_props = json.loads(parse.unquote(embedded_view.get('data-embedded-props', {})))
self.assertEqual(embedded_props['view_type'], expected_view_type)
self.assertEqual(embedded_props['context'], expected_context)
# Copy the article
new_article = article.action_make_private_copy()
expected_contexts = [{
'active_id': new_article.id,
'default_parent_id': new_article.id,
'default_icon': '📄',
'default_is_article_item': True,
}, {
'active_id': new_article.id,
'default_parent_id': new_article.id,
'default_icon': '📄',
'default_is_article_item': True,
}, {
'search_default_filter_trashed': 1,
}]
fragment = html.fragment_fromstring(new_article.body, create_parent=True)
embedded_views = list(fragment.findall('.//*[@data-embedded="view"]'))
# Check that the context of the embedded views stored in the body of the
# newly created article have properly been updated: The embedded views
# listing the article items of the original article should now list the
# article items of the copy.
self.assertEqual(len(embedded_views), 3)
for (embedded_view, expected_view_type, expected_context) in zip(embedded_views, expected_view_types, expected_contexts):
embedded_props = json.loads(parse.unquote(embedded_view.get('data-embedded-props', {})))
self.assertEqual(embedded_props['view_type'], expected_view_type)
self.assertEqual(embedded_props['context'], expected_context)
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('employee')
def test_copy(self):
article_hidden = self.article_private_manager.with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: copy should not allow to access hidden articles"):
_new_article = article_hidden.copy()
# Copying an article should create a private article without parent nor children
article_readonly = self.article_shared.with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: copy should not allow to access readonly articles members"):
_new_article = article_readonly.copy()
# Copy an accessible article
article_workspace = self.article_workspace.with_env(self.env)
new_article = article_workspace.copy()
self.assertEqual(new_article.name, f'{article_workspace.name} (copy)')
self.assertMembers(
new_article,
'write',
{}
)
self.assertEqual(len(new_article.child_ids), 2, 'Copy: should copy children')
self.assertTrue(new_article.child_ids != article_workspace.child_ids)
self.assertEqual(
sorted(new_article.child_ids.mapped('name')),
sorted([f"{name}" for name in article_workspace.child_ids.mapped('name')])
)
self.assertFalse(new_article.parent_id)
@tagged('knowledge_internals', 'knowledge_management')
class TestKnowledgeArticleRemoval(KnowledgeCommonBusinessCase):
""" Test unlink / archive management of articles """
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.shared_article_multi_company = cls.env['knowledge.article'].create({
'name': "Multi-Company Article",
'article_member_ids': [
(0, 0, {'partner_id': cls.partner_employee_c2.id, 'permission': 'read'}),
(0, 0, {'partner_id': cls.partner_employee.id, 'permission': 'write'}),
(0, 0, {'partner_id': cls.partner_admin.id, 'permission': 'write'})
],
'internal_permission': 'none'
})
@users('employee')
def test_send_to_trash_multi_company(self):
article_to_trash = self.shared_article_multi_company
article_to_trash.action_send_to_trash()
self.assertFalse(article_to_trash.active)
self.assertTrue(article_to_trash.to_delete)
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_archive(self):
""" Testing archive that should also archive children. """
self._test_archive(test_trash=False)
def test_archive_unactive_article(self):
""" Checking that an unactive article can be archived. """
article = self.env['knowledge.article'].create({'name': 'Article'})
self.assertTrue(article.active)
self.assertFalse(article.to_delete)
article.action_archive()
self.assertFalse(article.active)
self.assertFalse(article.to_delete)
article.action_send_to_trash()
self.assertFalse(article.active)
self.assertTrue(article.to_delete)
def _test_archive(self, test_trash=False):
archive_method_name = 'action_send_to_trash' if test_trash else 'action_archive'
article_shared = self.article_shared.with_env(self.env)
article_workspace = self.article_workspace.with_env(self.env)
wkspace_children = self.workspace_children.with_env(self.env)
# to test descendants computation, add some sub children
wkspace_grandchildren = self.wkspace_grandchildren.with_env(self.env)
wkspace_grandgrandchildren = self.wkspace_grandgrandchildren.with_env(self.env)
# no write access -> cracboum
with self.assertRaises(exceptions.AccessError,
msg='Employee can read thus not archive'):
getattr(article_shared, archive_method_name)()
# set the root + children inactive
getattr(article_workspace, archive_method_name)()
self.assertFalse(article_workspace.active)
self.assertEqual(article_workspace.to_delete, test_trash)
for article in wkspace_children + wkspace_grandchildren + wkspace_grandgrandchildren:
self.assertFalse(article.active, 'Archive: should propagate to children')
self.assertEqual(article.root_article_id, article_workspace,
'Archive: does not change hierarchy when archiving without breaking hierarchy')
self.assertEqual(article.to_delete, test_trash)
# reset as active
articles_to_restore = article_workspace + wkspace_children + wkspace_grandchildren + wkspace_grandgrandchildren
articles_to_restore.action_unarchive()
for article in articles_to_restore:
self.assertTrue(article.active)
self.assertFalse(article.to_delete)
# set only part of tree inactive
getattr(wkspace_children, archive_method_name)()
self.assertTrue(article_workspace.active)
self.assertFalse(article_workspace.to_delete)
for article in wkspace_children + wkspace_grandchildren + wkspace_grandgrandchildren:
self.assertFalse(article.active, 'Archive: should propagate to children')
self.assertEqual(article.to_delete, test_trash, 'Trash: should propagate to children')
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_archive_mixed_rights(self):
self._test_archive_mixed_rights(test_trash=False)
def _test_archive_mixed_rights(self, test_trash=False):
""" Test archive in case of mixed rights """
# give write access to shared section, but have children in read or none
# and add a customer on top of shared articles to check propagation
archive_method_name = 'action_send_to_trash' if test_trash else 'action_archive'
self.article_shared.write({
'article_member_ids': [(0, 0, {
'partner_id': self.customer.id,
'permission': 'read',
})]
})
self.article_shared.article_member_ids.sudo().filtered(
lambda article: article.partner_id == self.partner_employee
).write({'permission': 'write'})
self.shared_children[1].write({
'article_member_ids': [(0, 0, {'partner_id': self.partner_employee.id,
'permission': 'read'})]
})
# prepare comparison data as sudo
writable_child_su = self.article_shared.child_ids.filtered(
lambda article: article.name in ['Shared Child1'])
readonly_child_su = self.article_shared.child_ids.filtered(
lambda article: article.name in ['Shared Child2'])
hidden_child_su = self.article_shared.child_ids.filtered(
lambda article: article.name in ['Shared Child3'])
# perform archive as user
article_shared = self.article_shared.with_env(self.env)
article_shared.invalidate_model(['child_ids']) # context dependent
shared_children = article_shared.child_ids
writable_child, readonly_child = writable_child_su.with_env(self.env), readonly_child_su.with_env(self.env)
self.assertEqual(len(shared_children), 2)
self.assertFalse(readonly_child.user_has_write_access)
self.assertTrue(writable_child.user_has_write_access)
self.assertEqual(shared_children, writable_child + readonly_child,
'Should see only two first children')
getattr(article_shared, archive_method_name)()
# check writable articles have been archived, readonly or hidden not
self.assertFalse(article_shared.active)
self.assertEqual(article_shared.to_delete, test_trash)
self.assertFalse(writable_child.active)
self.assertEqual(writable_child.to_delete, test_trash)
self.assertTrue(readonly_child.active)
self.assertFalse(readonly_child.to_delete)
self.assertTrue(hidden_child_su.active)
self.assertFalse(hidden_child_su.to_delete)
# check hierarchy
self.assertEqual(writable_child.parent_id, article_shared,
'Archive: archived articles hierarchy does not change')
self.assertFalse(readonly_child.parent_id, 'Archive: article should be extracted in archive process as non writable')
self.assertEqual(readonly_child.root_article_id, readonly_child)
self.assertFalse(hidden_child_su.parent_id, 'Archive: article should be extracted in archive process as non writable')
self.assertEqual(hidden_child_su.root_article_id, hidden_child_su)
# verify that the child that was not accessible was moved as a root article...
self.assertTrue(hidden_child_su.active)
self.assertEqual(hidden_child_su.category, 'shared')
self.assertEqual(hidden_child_su.internal_permission, 'none')
self.assertFalse(hidden_child_su.parent_id)
# ... and kept his access rights: still member for employee / admin and
# copied customer access from the archived parent
self.assertMembers(
hidden_child_su,
'none',
{self.user_admin.partner_id: 'write',
self.partner_employee_manager: 'read',
self.partner_employee: 'none',
self.customer: 'read',
}
)
# Test that articles removed from trash are made roots if their parent are still in the Trash.
if test_trash:
writable_child.action_unarchive()
self.assertFalse(writable_child.to_delete)
self.assertFalse(writable_child.parent_id)
self.assertTrue(article_shared.to_delete)
self.assertTrue(writable_child not in article_shared.with_context(
active_test=False).child_ids)
self.assertEqual(writable_child.internal_permission,
article_shared.internal_permission)
# Note: could be different if writable_child had custom partners. Not the case here so we can use '=='.
self.assertTrue(article_shared.article_member_ids.partner_id == writable_child.article_member_ids.partner_id)
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_trashed(self):
""" Testing 'send to trash' that should also trash children. """
self._test_archive(test_trash=True)
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_trashed_mixed_rights(self):
""" Test Trash in case of mixed rights """
self._test_archive_mixed_rights(test_trash=True)
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule', 'odoo.models.unlink')
@users('admin')
def test_unlink_admin(self):
""" Admin (system) has access to unlink, test propagation and effect
on children. """
article_shared = self.article_shared.with_env(self.env)
article_shared.unlink()
self.assertFalse(
(self.article_shared + self.shared_children).exists(),
'Unlink: should also unlink children'
)
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule', 'odoo.models.unlink')
@users('employee')
def test_unlink_employee(self):
""" Employee cannot unlink anyway """
article_hidden = self.article_private_manager.with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: uhnlink is not accessible to employees"):
article_hidden.unlink()
article_workspace = self.article_workspace.with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: unlink is not accessible to employees"):
article_workspace.unlink()
def test_unarchive_article_items_having_archived_parent(self):
""" Check that the user can not unarchive an article item whose parent is archived. """
parent_article = self.env['knowledge.article'].create({
'active': False,
'to_delete': True,
'name': 'Parent article',
})
article_item = self.env['knowledge.article'].create({
'active': False,
'to_delete': True,
'name': 'Article item',
'parent_id': parent_article.id,
'is_article_item': True,
})
with self.assertRaises(UserError):
article_item.action_unarchive()
(parent_article + article_item).action_unarchive()
self.assertTrue(parent_article.active)
self.assertFalse(parent_article.to_delete)
self.assertTrue(article_item.active)
self.assertFalse(article_item.to_delete)
self.assertEqual(article_item.parent_id, parent_article)
@users('employee')
def test_unarchive_article_having_inaccessible_parent(self):
""" Check that the user can restore an article whose parent is inaccessible. """
parent_article = self.env['knowledge.article'].sudo().create({
'active': False,
'to_delete': True,
'name': 'Parent article',
'internal_permission': 'write',
'article_member_ids': [
(0, 0, {
'partner_id': self.env.user.partner_id.id,
'permission': 'none'
}),
(0, 0, {
'partner_id': self.customer.id,
'permission': 'read'
}),
]
}).with_user(self.env.user)
child_article = self.env['knowledge.article'].sudo().create({
'active': False,
'to_delete': True,
'name': 'Child article',
'parent_id': parent_article.id,
'article_member_ids': [
(0, 0, {
'partner_id': self.env.user.partner_id.id,
'permission': 'write'
}),
]
}).with_user(self.env.user)
# Check the access rights:
parent_article.browse().check_access('read')
with self.assertRaises(exceptions.AccessError):
parent_article.check_access('read')
child_article.check_access('write')
# Unarchive the child article:
child_article.action_unarchive()
# When the parent article is in the trash, the child article should be
# detached from its parent so that it will not be deleted when the parent
# article is deleted.
self.assertTrue(child_article.active)
self.assertFalse(child_article.to_delete)
self.assertFalse(child_article.parent_id)
self.assertFalse(child_article.is_desynchronized)
self.assertMembers(child_article, 'write', {
self.env.user.partner_id: 'write',
self.customer: 'read'
})
self.assertFalse(parent_article.active)
self.assertTrue(parent_article.to_delete)
self.assertMembers(parent_article, 'write', {
self.env.user.partner_id: 'none',
self.customer: 'read'
})
def test_trashed_article_garbage_collect(self):
[normal, trashed_not_to_unlink, archived_not_trashed] = self.env['knowledge.article'].create([{
'name': 'Normal article',
'internal_permission': 'write',
}, {
'active': False,
'to_delete': True,
'internal_permission': 'write',
'name': 'Trashed not to unlink',
'parent_id': False,
}, {
'active': False,
'internal_permission': 'write',
'to_delete': False,
'name': 'Only Archived',
'parent_id': False,
}])
self.env['knowledge.article'].flush_model()
before = datetime.now() - timedelta(days=self.env['knowledge.article'].DEFAULT_ARTICLE_TRASH_LIMIT_DAYS + 1)
# Older article
with patch.object(self.env.cr, 'now', return_value=before):
trashed_to_unlink = self.env['knowledge.article'].create({
'active': False,
'internal_permission': 'write',
'to_delete': True,
'name': 'Trashed to unlink',
'parent_id': False,
})
self.env['knowledge.article'].flush_model()
self.env['knowledge.article']._gc_trashed_articles()
self.assertFalse(trashed_to_unlink.exists())
self.assertTrue(normal.exists())
self.assertTrue(trashed_not_to_unlink.exists(), "This article's deletion date is not yet passed.")
self.assertTrue(archived_not_trashed.exists(), "This article is archived and not trashed (to_delete = False), it shouldn't be unlinked.")
@tagged('post_install', '-at_install', 'knowledge_internals', 'knowledge_management')
class TestKnowledgeShare(KnowledgeCommonWData):
""" Test share feature. """
def test_article_can_invite_members_with_wizard(self):
"""Check that the administrator is allowed to invite a new member
without 'write' permission by using the invitation wizard."""
article = self.env['knowledge.article'].create({
'name': 'My article',
'internal_permission': 'write',
'article_member_ids': [
(0, 0, {'partner_id': self.partner_employee.id, 'permission': 'read'}),
(0, 0, {'partner_id': self.partner_admin.id, 'permission': 'read'})
]
})
self.assertFalse(article.with_user(self.user_employee).user_has_write_access)
self.assertFalse(article.with_user(self.user_admin).user_has_write_access)
with self.assertRaises(exceptions.AccessError):
self.env['knowledge.invite'].with_user(self.user_employee).create({
'article_id': article.id,
'partner_ids': self.partner_public,
'permission': 'read',
})
self.assertMembers(article, 'write', {
self.partner_employee: 'read',
self.partner_admin: 'read'
})
self.env['knowledge.invite'].with_user(self.user_admin).create({
'article_id': article.id,
'partner_ids': self.partner_public,
'permission': 'read',
}).action_invite_members()
self.assertMembers(article, 'write', {
self.partner_employee: 'read',
self.partner_admin: 'read',
self.partner_public: 'read'
})
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
@users('employee2')
def test_knowledge_article_share(self):
# private article of "employee manager"
knowledge_article_sudo = self.env['knowledge.article'].sudo().create({
'name': 'Test Article',
'body': '<p>Content</p>',
'internal_permission': 'none',
'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee_manager.id,
'permission': 'write',
})],
})
article = knowledge_article_sudo.with_env(self.env)
self.assertFalse(article.user_has_access)
# employee2 is not supposed to be able to share it
with self.assertRaises(exceptions.AccessError):
self._knowledge_article_share(article, self.partner_portal.ids, 'read')
# give employee2 read access on the document
knowledge_article_sudo.write({
'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee2.id,
'permission': 'read',
})]
})
self.assertTrue(article.user_has_access)
# still not supposed to be able to share it
with self.assertRaises(exceptions.AccessError):
self._knowledge_article_share(article, self.partner_portal.ids, 'read')
# modify employee2 access to write
knowledge_article_sudo.article_member_ids.filtered(
lambda member: member.partner_id == self.partner_employee2
).write({'permission': 'write'})
# now they should be able to share it
with self.mock_mail_gateway(), self.mock_mail_app():
self._knowledge_article_share(article, self.partner_portal.ids, 'read')
# check that portal user received an invitation link
self.assertEqual(len(self._new_msgs), 1)
self.assertIn(
knowledge_article_sudo._get_invite_url(self.partner_portal),
self._new_mails.body_html
)
with self.with_user('portal_test'):
# portal should now have read access to the article
# (re-browse to have the current user context for user_permission)
article_asportal = knowledge_article_sudo.with_env(self.env)
self.assertTrue(article_asportal.user_has_access)
def _knowledge_article_share(self, article, partner_ids, permission='write'):
""" Re-browse the article to make sure we have the current user context on it.
Necessary for all access fields compute methods in knowledge.article. """
return self.env['knowledge.invite'].create({
'article_id': self.env['knowledge.article'].browse(article.id).id,
'partner_ids': partner_ids,
'permission': permission,
}).action_invite_members()
@tagged('post_install', '-at_install', 'knowledge_internals', 'knowledge_management')
class TestKnowledgeArticleCovers(KnowledgeCommonWData):
""" Test article covers management """
@users('employee')
def test_article_cover_management(self):
# User cannot modify cover of hidden article
article_hidden = self.article_private_manager.with_env(self.env)
cover = self._create_cover()
with self.assertRaises(exceptions.AccessError,
msg="Cannot add cover to hidden article"):
article_hidden.write({'cover_image_id': cover.id})
article_hidden.with_user(self.user_admin).write({'cover_image_id': cover.id})
with self.assertRaises(exceptions.AccessError,
msg="Cannot remove cover of hidden article"):
article_hidden.write({'cover_image_id': False})
# User cannot modify cover of readable article but has access to it
article_read = self.article_shared.with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="Cannot add cover to readable article"):
article_read.write({'cover_image_id': cover.id})
cover_2 = self._create_cover()
article_read.with_user(self.user_admin).write({'cover_image_id': cover_2.id})
with self.assertRaises(exceptions.AccessError,
msg="Cannot remove cover of readable article"):
article_read.write({'cover_image_id': False})
# User can reuse a cover used in another article.
article_write = self.article_workspace.with_env(self.env)
article_write.write({'cover_image_id': cover_2.id})
self.assertEqual(article_write.cover_image_id, cover_2)
@tagged('post_install', '-at_install', 'knowledge_internals', 'knowledge_management', 'knowledge_visibility')
class TestKnowledgeArticleVisibility(KnowledgeCommon):
""" Test suite checking that the articles can be marked as hidden.
This test suite should ensure that:
1. Users can search for articles that are visible or hidden using the custom
search method on the `is_article_visible` computed field.
2. The shared and private articles are always visible.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Articles:
Article = cls.env['knowledge.article']
with mute_logger('odoo.models.unlink'):
Article.search([]).unlink()
cls.workspace_article = Article.create({
'name': 'Workspace article'
})
cls.shared_article = Article.create({
'name': 'Shared article',
'internal_permission': 'none',
'article_member_ids': [
(0, 0, {
'partner_id': cls.user_admin.partner_id.id,
'permission': 'write'
}),
(0, 0, {
'partner_id': cls.user_employee.partner_id.id,
'permission': 'write'
})
]
})
cls.private_article = Article.create({
'name': 'Private article',
'internal_permission': 'none',
'article_member_ids': [(0, 0, {
'partner_id': cls.user_employee.partner_id.id,
'permission': 'write'
})]
})
@users('employee')
def test_is_workspace_article_visible(self):
Article = self.env['knowledge.article']
workspace_article = self.workspace_article.with_user(self.env.user)
# When creating a new article in the workspace, the article should be
# hidden by default and shouldn't appear in the sidebar.
self.assertEqual(workspace_article.category, 'workspace')
self.assertTrue(workspace_article.user_has_access)
self.assertFalse(workspace_article.is_article_visible)
self.assertFalse(workspace_article.is_article_visible_by_everyone)
self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', False)]), self.workspace_article)
self.assertEqual(Article.search([('is_article_visible', '!=', True)]), self.workspace_article)
self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.shared_article + self.private_article)
workspace_article.action_join()
self.assertMembers(workspace_article, 'write', {self.env.user.partner_id: 'write'})
# If the article is hidden and the user has explicit "read" or "write"
# permission on the article (with the membership), the article should
# become visible to the user.
self.assertTrue(workspace_article.is_article_visible)
self.assertFalse(workspace_article.is_article_visible_by_everyone)
self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.workspace_article + self.shared_article + self.private_article)
self.assertFalse(Article.search([('is_article_visible', '=', False)]))
self.assertFalse(Article.search([('is_article_visible', '!=', True)]))
self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.workspace_article + self.shared_article + self.private_article)
# When setting the `is_article_visible_by_everyone` field to `True`, the
# article should become visible to everyone (with the exception of those
# having no access to the article).
workspace_article.set_is_article_visible_by_everyone(True)
self.assertTrue(workspace_article.is_article_visible)
self.assertTrue(workspace_article.is_article_visible_by_everyone)
self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.workspace_article + self.shared_article + self.private_article)
self.assertFalse(Article.search([('is_article_visible', '=', False)]))
self.assertFalse(Article.search([('is_article_visible', '!=', True)]))
self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.workspace_article + self.shared_article + self.private_article)
# Removing the user from the member list should not change the visibility
# status of the article.
for member in workspace_article.article_member_ids:
workspace_article._remove_member(member)
self.assertTrue(workspace_article.is_article_visible)
self.assertTrue(workspace_article.is_article_visible_by_everyone)
self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.workspace_article + self.shared_article + self.private_article)
self.assertFalse(Article.search([('is_article_visible', '=', False)]))
self.assertFalse(Article.search([('is_article_visible', '!=', True)]))
self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.workspace_article + self.shared_article + self.private_article)
@users('employee')
def test_is_private_article_visible(self):
Article = self.env['knowledge.article']
private_article = self.private_article.with_user(self.env.user)
# For the private articles, the articles should always be visible even
# if the article is not marked as visible to everyone and the user does
# not have explicit "read" or "write" permission on the article.
self.assertEqual(private_article.category, 'private')
self.assertTrue(private_article.user_has_access)
self.assertTrue(private_article.is_article_visible)
self.assertFalse(private_article.is_article_visible_by_everyone)
self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', False)]), self.workspace_article)
self.assertEqual(Article.search([('is_article_visible', '!=', True)]), self.workspace_article)
self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.shared_article + self.private_article)
private_article.set_is_article_visible_by_everyone(True)
# When setting the `is_article_visible_by_everyone` field to `True`, the
# article should remains visible to everyone (with the exception of those
# having no access to the article).
self.assertTrue(private_article.is_article_visible)
self.assertTrue(private_article.is_article_visible_by_everyone)
self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', False)]), self.workspace_article)
self.assertEqual(Article.search([('is_article_visible', '!=', True)]), self.workspace_article)
self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.shared_article + self.private_article)
@users('employee')
def test_is_shared_article_visible(self):
Article = self.env['knowledge.article']
shared_article = self.shared_article.with_user(self.env.user)
# For the shared articles, the articles should always be visible even
# if the article is not marked as visible to everyone and the user does
# not have explicit "read" or "write" permission on the article.
self.assertEqual(shared_article.category, 'shared')
self.assertTrue(shared_article.user_has_access)
self.assertTrue(shared_article.is_article_visible)
self.assertFalse(shared_article.is_article_visible_by_everyone)
self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', False)]), self.workspace_article)
self.assertEqual(Article.search([('is_article_visible', '!=', True)]), self.workspace_article)
self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.shared_article + self.private_article)
shared_article.set_is_article_visible_by_everyone(True)
self.assertTrue(shared_article.is_article_visible)
self.assertTrue(shared_article.is_article_visible_by_everyone)
self.assertEqual(Article.search([]), self.workspace_article + self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', True)]), self.shared_article + self.private_article)
self.assertEqual(Article.search([('is_article_visible', '=', False)]), self.workspace_article)
self.assertEqual(Article.search([('is_article_visible', '!=', True)]), self.workspace_article)
self.assertEqual(Article.search([('is_article_visible', '!=', False)]), self.shared_article + self.private_article)