Purpose
=======
Allow companies to centralize all their knowledge in a specific place they own.
Create wiki-like pages to structure the information and make it easily
available. Leverage those later on while working in the back-end.
Specifications
==============
An article is mainly composed of a title and a body. The later is an html field
that can contain text, images, link to other articles or records from other
models, templates, etc..
Articles will be classified in 4 categories:
- *Workspace:*
The articles are available for each internal users in read or write access.
Only internal users can create, read, modify and delete knowledge articles.
- *Private:*
The articles are only available for the owner. Only internal users
can set an article as private. A private article can be shared to external users
in read access only. If the article is shared with internal users, see Shared
section.
- *Shared:*
The articles are not available for any user except the ones that have been
added as members on it. For the current user, this section contains all the
private article the user shared with someone and all the other's private
articles shared with me.
- *Favorite:* (Additional category for UX)
Articles can be set as favorite and are user specific. Any identified user can
set an article in favorite, as long as the user has access to it. Public users
cannot add articles to favorites. Favorites can be reordered and this ordering
is user specific.
An article can have a parent and multiple children. Each parent and each
children is also an article. Root articles are the highest parent in the
hierarchy and are considered as the *Subject* (aka Topic) of their whole
hierarchy. There is no limit on how many levels there are in the hierarchy.
In the 3 first categories, the articles are listed starting for the root
article. The child articles are listed below their parents only if the user has
access to them. It can be that a user has access to an article that is not
listed in knowledge main view because that user has no access to the parent
article. More information about access below.
Categories are based on the root article. If a children is private, because only
accessible by 1 user, and its parent is accessible by everyone, the children
will be listed in Workspace section.
Technical notes
The following domains apply to assign root articles in the different categories:
- Private: permission 'none', only member = uid
- Shared: permission 'none', more than one member with read/write access
- Workspace: other
In addition, the UX category
- Favorite: is_user_favorite = True (can contain articles from any category)
Home View (backend)
===================
The main knowledge view is a tweaked form view that displays all the accessible
root articles and their accessible children in a side panel hierarchic tree.
This side panel allow quick access to each articles without having to navigate
back to a list or kanban view. This form view mainly displays the title and the
body but also integrate custom buttons to manage and search the articles.
All those buttons + the side panel are detailed below.
This "Home" view is loading displaying the first favorite article of the
user. If the user has no favorite, it falls back to the first root article of
Workspace section.
The default view mode depends on the user access:
- If user can read: readonly mode
- If user can write: edit mode
Search View
-----------
The standard list view will be used as an advanced search to find an article
that cannot be found easily via the command palette (ctrl+K). All the searching
options remains standard (search / filter / group by). Selecting an article in
that view will redirect to the home view (displaying the selected article).
Side Panel
----------
- *Reorder and hierarchy:*
From the side panel, drag and dropping an article can resequence the articles
between them and/or change the parent of the moved article.
Note on reordering:
Each parent have its own sequence for its children. So the sequence is not
common for each articles in the database, but specific for the children of a
same parent. The resequencing is done among the children of the same parent.
If some articles has no parent, there are considered as the root articles and
all root articles have their own sequence too.
- *Create:*
From the hierarchy tree, the user can directly create an article using the '+'
signs:
- as root of Workspace or Private section
- as a child of a target article.
- *Fold/Unfold:*
To speed up Home page loading: only first level is loaded + the articles that
user already unfolded in the past. The app keeps track of unfolded articles per
user (using Local Storage).
- *Icon:*
Each article has an icon associated with it. The user can click on the icon to
change it.
- *Search an article:*
This button opens the command palette to perform a search on articles.
More detail on this in the Advanced Feature section (below).
Article header and Button Menu
------------------------------
- *Breadcrumb:*
Located on the top left of the main view, the breadcrumb can be used to edit the
article title.
- *Favorite:*
Next to the title, a star button can be used to add/remove the article to/from
the favorite.
- *Create:*
Creates a new private root article.
- *Share:*
Opens the permission panel. This is where the user can configure the access on
the article or, if user has no write access, see what are the access rules on the
article. More detail on this panel in the Access Rules section (below).
- *Chatter:*
Toggle the chatter On/Off at the right of the article view.
There users can:
- Send messages and log notes
- Schedule activities
- Follow an article
The following buttons can found in the kebab menu, in which we can also find
who created or edited the article last and when.
- *Move To:*
Opens a modal to select the parent under which the current article should be
moved. The article will be placed as the last children of the selected parent.
- *Lock:*
When activated, prevents all edition of the article content (body and title)
until it is unlocked. (Purpose: avoid accidental edits)
- *Duplicate:*
Creates a copy of the current article, under the same parent, but does not copy
the children articles. The new created article will have the same access rules
as the original article.
- *Archive:*
Will archive the current article and all its children. Archived articles can be
retrieved using the Advanced Search (see below for detail) with "Archived"
filter.
- *Full Width:*
By default, the article is centered in the view, to make it easier to read.
If needed, the can activate the full width. This will make the article body take
full width available on the article view. This option impacts the rendering of
article, also in the frontend view. More detail on the Frontend View below.
Body
----
The body is composed of an html field that can be edited using the Odoo Editor
and all its capabilities. Some new commands have been added to enrich the
editor: /template - /file - /article (More details in Advanced Features section)
At the top of the article body, the article icon and cover can be added using
their respective "Add" buttons.
- *Icon:*
Can be modified by simply clicking on it.
- *Cover:*
Can be modified using the "Pen" icon at the top left of the cover,
and deleted using the "Bin" icon at the top right.
Frontend View
=============
The frontend view is basically a simpler version of the backend view where users
can only read the article and add them to their favorites (if user is
identified).
The frontend view is composed of the side panel in the left, the article title
and favorite button on the top and the main body view.
From the side panel, the user can navigate to each available article, reorganize
them favorites and filter the articles using the search bar.
From the main body view, the user can consult the content of the article and
download eventual attached files from the '/file' sections.
Access Rules
============
Basics
------
The access is defined depending on two criteria:
- The Internal Permission
- The article members and their permission
Both have 3 level of permission:
- none (No Access)
- read
- write
The internal Permission is the base permission for internal users. By default,
all internal users can write on the article, unless the permission have been
downgraded to 'Read' or 'No access'.
An internal user however can be added as a member of the article with a
specific permission that applies only for them. This member permission takes the
priority on the internal permission.
The internal users will always be redirected to the backend view when they want
to consult an article.
By default, external users (portal or public users) cannot have access to the
articles, unless they have been added as member. They can only be invited with
read acces on the articles.
External users will always be redirected to the frontend view when they want to
consult an article they have access to.
An administrator (group_system) will always have write access, even if
permissions tell the contrary. However, only the articles they have the
permission to see or write on will be listed in the side panel. They can find
the other articles in the Advanced Search view.
Only the users with write access can edit the article body and title, reorder or
change the parents and children, invite members and edit the access rules of the
article.
- *Constraint:*
An article should always have at least one user having write access on it.
- *Lock mechanism:*
Only users with write access can lock or unlock the article.
when an article is locked, the title and body of the article cannot be edited,
even by a user with write access on it.
Access Inheritance
------------------
By default, a child article inherit the access rules from its parent.
That means that access rules can be defined on the root article and those rules
will apply for each descendant article (children and their own children, etc.).
However, a child article can have specific rules that will apply only on itself
(+ its descendant):
- *Synchronized:*
That can be done by adding a member on that article, or by upgrading any of the
permission based on its parent(s). In that case, the article access rules still
depend on its parent(s). If an additional member is added on the parent,
the child article will still inherit that member. The rules that applies
specifically on the child article have the priority on the inherited rules.
- *Detached:*
When an access rule is downgraded in a child article that initially depends on
parent's access rules, the article access rules are detached from the parent's
access rules. All the access rules that applied on the parent are copied on the
child article and that article is flagged as detached. The children of that
article will, for now on, only depend on it, and not on higher parents anymore.
An detached article can be resyncronized with its parent. In that case, all the
specific access rules that do not apply on its parent will be removed. The child
article will still keep its specific upgraded access rules but will, once again,
depends on its parent.
Share Modal (and access rules application)
------------------------------------------
The share modal (or permission panel) is where the access rules of an article
can be configured.
From there, a user with write access can:
- *Invite new members:*
A modal opens and the user can:
- add existing partners or type email addresses and create new partners
- select the permission that should apply on all selected partners.
If there are external users among the selected partners, only the read access
can be used. That means that if the user select:
- Write permission: the read permission will be given to that partner.
- No Access: The partner will not be added the members.
An invitation email is sent to the invited partner.
If the partner is already registered (has an active user), them can directly
access the article with the link provided in the email. They may need to log in
first.
If a partner is not yet registered (have no user), when using the link provided
in the email, them will be asked to register first, and will be afterwards
redirected to the article.
- *Modify the Internal Permission:*
The user can select the new permission to set for all internal users.
- Special use cases:
- If the user sets internal permission != write, the user will be added as
member with write access. The purpose is to let the user continue to edit the
article.
- When downgrading internal permission on a child article (that depends on its
parent), detach it from parent in terms of access rules.
- when upgrading internal permission on a child article, just set the
permission. The article is not detached from parent, but the article will use
its own internal permission to compute the user accesses.
- *Modify a member's permission:*
The user can select the new permission to set on the target member. The user can
also remove a member.
- Special use cases:
- When downgrading or removing a member based on parent: Detach the article
from it's parent
- When upgrading a member based on parent: add a new member with the selected
permission.
Website Published (Access for public users)
-------------------------------------------
When the Website App is installed, users have the possibility to publish the
articles. When an article is published, everyone can read the article.
- *Internal users* can read a published article even if their permissions does not
allow them to read it. They can access those published articles like every other
articles, still in backend view.
- *Portal users* can read a published article even if their permissions does not
allow them to read it. They can access those published articles like every other
articles, still in frontend view.
- *Public users* can read a published article. They can access it by using the
article link from the frontend view.
Advanced Features
=================
Search and Advanced Search
--------------------------
- *Command Palette:*
Searching is done through the command palette. Just as we have '#' for channels,
'/' for menus and '@' for users, '?' has been added for articles.
The command palet is accessible by either
- Hitting CTRL+K (and then adding ? to search among articles)
- Clicking on the fa-book icon of the chatter, which already
adds the '?' so that one can start searching straight away.
- Clicking on the "Search An Article" button (in the side panel)
while reading another article, where the '?' is automatically added too)
The search result are ordered as followed:
My Favorites > Most Favorites > the rest.
The results display the article name + the subject (highest parent name).
If no results are found and user have write access, them can create a new
(private) article with the search query as title.
- *Advanced Search:*
If the user is not happy with the search results, them can do an
"Advanced Search", which sends me towards a normal Odoo list view where I can
search, filter, groupby, ...
Editor Commands
---------------
Some new editor commands have been added:
- */article:*
This command is available on every html field using the Odoo Editor.
This command allows to add a direct link to the selected knowledge article.
- */file:*
This command is only available in the Knowledge App.
This command allows to add a file (or an image) download button inside the
body. The user can choose a file to add from its own file directory or an
existing attachment already present in the database.
- */template:*
This command is only available in the Knowledge App.
This command allows the user to create a block where them can write and lay out
content destined to be reused by the readers of the article. The lasts will be
able to copy and paste it to send it as message or use it as a description on
other records.
No /command can be added inside a template.
Technical notes:
/file and /template commands can be activated in other modules by adding
knowledge_commands = True on the html field in the view where they should
be activated.
Technical considerations
========================
Access Rules Computation
------------------------
Articles access rules are based on inheritance from parent articles. Purpose is
to avoid making copies of all member rules on each article in the hierarchy.
This allows to lower database storage. The counter part is that the computation
of user accesses is made more complex. Instead of simply reading the article
internal permission and members permissions on the article, access is computed
based on each parent above the article.
To speed up batch compute, user permissions are computed directly with recursive
SQL queries. They collect all the needed information for multiple users (based
on their partner) and multiple articles.
Ordering on is_user_favourite
-----------------------------
To allow ordering on is_user_favourite computed field, the search method have
been overridden to add the favorite articles of the current user to the search
result. This is used in the advanced search view but will also be used in
the ctrl+K command.
Sequences and resequencing
--------------------------
Sequences in knowledge article does not respect the conventional sequencing
(one sequence for all the records of the model). Instead, the sequence is
specific for all children of same parents. Resequence had to be overridden to
take that into account and only resequence the children of same parent, and not
the entire table. Moving an article in the middle of a 'folder' should not
impact the other folders but only the children of that target folder.
Breadcrumb
----------
On the form view, the breadcrumb will be located in the control panel.
For the knowledge module, we needed a headless layout. In the mockup,
the breadcrumb has to be placed within the form sheet.
Unfortunately, the breadcumb is a owl component and its logic is
encapsulated in a function which makes it not editable.
To avoid having to update the core, we will render a custom breadcumb
that will be generated based on the `breadcrumb` parameter passed to the
`init` function of the form view.
Task-2674460
Part-of: odoo/odoo#82315
Co-authored-by: Damien Abeloos <abd@odoo.com>
Co-authored-by: Julien Banken <jbn@odoo.com>
Co-authored-by: David Beguin <dbe@odoo.com>
Co-authored-by: Fabio Barbero <faba@odoo.com>
Co-authored-by: Thibault Delavallee <tde@odoo.com>
Co-authored-by: Aurelien Warnong <awa@odoo.com>
688 lines
34 KiB
Python
688 lines
34 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from datetime import datetime, timedelta
|
|
from freezegun import freeze_time
|
|
|
|
from odoo import exceptions
|
|
from odoo.addons.knowledge.tests.common import KnowledgeCommonWData
|
|
from odoo.tests.common import tagged, users
|
|
from odoo.tools import mute_logger
|
|
|
|
|
|
@tagged('knowledge_internals', 'knowledge_management')
|
|
class TestKnowledgeArticleBusiness(KnowledgeCommonWData):
|
|
""" Test business API and main tools or helpers methods. """
|
|
|
|
@mute_logger('odoo.addons.base.models.ir_rule')
|
|
@users('employee')
|
|
def test_article_archive(self):
|
|
""" Testing archive that should also archive children. """
|
|
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.env['knowledge.article'].create([
|
|
{'name': 'Grand Children of workspace',
|
|
'parent_id': wkspace_children[0].id,
|
|
},
|
|
{'name': 'Grand Children of workspace',
|
|
'parent_id': wkspace_children[0].id,
|
|
},
|
|
{'name': 'Grand Children of workspace',
|
|
'parent_id': wkspace_children[1].id,
|
|
}
|
|
])
|
|
wkspace_grandgrandchildren = self.env['knowledge.article'].create([
|
|
{'name': 'Grand Grand Children of workspace',
|
|
'parent_id': wkspace_grandchildren[1].id,
|
|
},
|
|
{'name': 'Grand Children of workspace',
|
|
'parent_id': wkspace_grandchildren[2].id,
|
|
},
|
|
])
|
|
|
|
# no read access -> cracboum
|
|
with self.assertRaises(exceptions.AccessError,
|
|
msg='Employee can read thus not archive'):
|
|
article_shared.action_archive()
|
|
|
|
# set the root + children inactive
|
|
article_workspace.action_archive()
|
|
self.assertFalse(article_workspace.active)
|
|
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')
|
|
|
|
# reset as active
|
|
(article_workspace + wkspace_children + wkspace_grandchildren + wkspace_grandgrandchildren).toggle_active()
|
|
for article in article_workspace + wkspace_children + wkspace_grandchildren + wkspace_grandgrandchildren:
|
|
self.assertTrue(article.active)
|
|
|
|
# set only part of tree inactive
|
|
wkspace_children.action_archive()
|
|
self.assertTrue(article_workspace.active)
|
|
for article in wkspace_children + wkspace_grandchildren + wkspace_grandgrandchildren:
|
|
self.assertFalse(article.active, 'Archive: should propagate to children')
|
|
|
|
@mute_logger('odoo.addons.base.models.ir_rule')
|
|
@users('employee')
|
|
def test_article_archive_mixed_rights(self):
|
|
""" Test archive in case of mixed rights """
|
|
# give write access to shared section, but have children in read or none
|
|
self.article_shared.article_member_ids.sudo().filtered(
|
|
lambda article: article.partner_id == self.partner_employee
|
|
).write({'permission': 'write'})
|
|
# one additional read child and one additional none child
|
|
self.shared_children += self.env['knowledge.article'].sudo().create([
|
|
{'article_member_ids': [
|
|
(0, 0, {'partner_id': self.partner_admin.id,
|
|
'permission': 'write',
|
|
}),
|
|
(0, 0, {'partner_id': self.partner_employee.id,
|
|
'permission': 'read',
|
|
}),
|
|
],
|
|
'internal_permission': False,
|
|
'name': 'Shared Child2',
|
|
'parent_id': self.article_shared.id,
|
|
},
|
|
{'article_member_ids': [
|
|
(0, 0, {'partner_id': self.partner_admin.id,
|
|
'permission': 'write',
|
|
}),
|
|
(0, 0, {'partner_id': self.partner_employee.id,
|
|
'permission': 'none',
|
|
}),
|
|
],
|
|
'internal_permission': False,
|
|
'name': 'Shared Child3',
|
|
'parent_id': self.article_shared.id,
|
|
},
|
|
])
|
|
|
|
# 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_cache(fnames=['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.assertEqual(shared_children, writable_child + readonly_child, 'Should see only two first children')
|
|
|
|
article_shared.action_archive()
|
|
# check writable articles have been archived, readonly or hidden not
|
|
self.assertFalse(article_shared.active)
|
|
self.assertFalse(writable_child.active)
|
|
self.assertTrue(readonly_child.active)
|
|
self.assertTrue(hidden_child_su.active)
|
|
# 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)
|
|
|
|
@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.assertFalse(new.article_member_ids)
|
|
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)
|
|
|
|
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',
|
|
})
|
|
_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)
|
|
|
|
@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. """
|
|
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.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.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.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: 'read', # shared partners are always read only
|
|
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_rule('write')
|
|
direct_child_write.with_user(self.user_employee2).check_access_rule('write')
|
|
with self.assertRaises(exceptions.AccessError,
|
|
msg='Invite: access should have been blocked'):
|
|
direct_child_read.with_user(self.user_employee2).check_access_rule('read')
|
|
with self.assertRaises(exceptions.AccessError,
|
|
msg='Invite: access should have been blocked'):
|
|
grand_child.with_user(self.user_employee2).check_access_rule('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: 'read',
|
|
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 = 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_cache(fnames=['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')
|
|
@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(parent_id=False)
|
|
|
|
# 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()
|
|
self.assertEqual(article_workspace.child_ids, workspace_children[0])
|
|
self.assertEqual(article_workspace._get_descendants(), workspace_children)
|
|
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]._desync_access_from_parents()
|
|
self.assertTrue(workspace_children[0].is_desynchronized)
|
|
|
|
# other valid move: first child is moved to private section
|
|
workspace_children[0].move_to(parent_id=False, is_private=True)
|
|
workspace_children.flush()
|
|
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])
|
|
|
|
@users('employee')
|
|
def test_article_sort_for_user(self):
|
|
""" Testing the sort + custom info returned by get_user_sorted_articles """
|
|
self.workspace_children.write({
|
|
'favorite_ids': [
|
|
(0, 0, {'user_id': user.id})
|
|
for user in self.user_admin + self.user_employee2 + self.user_employee_manager
|
|
],
|
|
})
|
|
article_workspace = self.article_workspace.with_env(self.env)
|
|
workspace_children = self.workspace_children.with_env(self.env)
|
|
(article_workspace + workspace_children[1]).action_toggle_favorite()
|
|
|
|
new_root_child = self.env['knowledge.article'].create({
|
|
'name': 'Child3 without parent name in its name',
|
|
'parent_id': article_workspace.id,
|
|
})
|
|
(self.workspace_children + new_root_child).flush()
|
|
|
|
# ensure initial values
|
|
self.assertTrue(article_workspace.is_user_favorite)
|
|
self.assertEqual(article_workspace.favorite_count, 2)
|
|
self.assertEqual(article_workspace.user_favorite_sequence, 1)
|
|
self.assertFalse(workspace_children[0].is_user_favorite)
|
|
self.assertEqual(workspace_children[0].favorite_count, 3)
|
|
self.assertEqual(workspace_children[0].user_favorite_sequence, -1)
|
|
self.assertTrue(workspace_children[1].is_user_favorite)
|
|
self.assertEqual(workspace_children[1].favorite_count, 4)
|
|
self.assertEqual(workspace_children[1].user_favorite_sequence, 2)
|
|
self.assertFalse(new_root_child.is_user_favorite)
|
|
self.assertEqual(new_root_child.favorite_count, 0)
|
|
self.assertEqual(new_root_child.user_favorite_sequence, -1)
|
|
|
|
# search also includes descendants of articles having the term in their name
|
|
result = self.env['knowledge.article'].get_user_sorted_articles('laygroun')
|
|
expected = self.article_workspace + self.workspace_children[1] + self.workspace_children[0] + new_root_child
|
|
found_ids = [a['id'] for a in result]
|
|
self.assertEqual(found_ids, expected.ids)
|
|
# check returned result once (just to be sure)
|
|
workspace_info = next(article_result for article_result in result if article_result['id'] == article_workspace.id)
|
|
self.assertTrue(workspace_info['is_user_favorite'], article_workspace.name)
|
|
self.assertFalse(workspace_info['icon'])
|
|
self.assertEqual(workspace_info['favorite_count'], 2)
|
|
self.assertEqual(workspace_info['name'], article_workspace.name)
|
|
self.assertEqual(workspace_info['root_article_id'], (article_workspace.id, f'📄 {article_workspace.name}'))
|
|
|
|
|
|
@tagged('knowledge_internals')
|
|
class TestKnowledgeArticleFields(KnowledgeCommonWData):
|
|
""" Test fields and their management. """
|
|
|
|
@users('employee')
|
|
def test_favorites(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].write({'favorite_ids': [(0, 0, {'user_id': self.env.uid})]})
|
|
self.assertEqual(playground_articles.mapped('is_user_favorite'), [True, False, False])
|
|
self.assertEqual(playground_articles.mapped('user_favorite_sequence'), [1, -1, -1])
|
|
favorites = self.env['knowledge.article.favorite'].sudo().search([('user_id', '=', self.env.uid)])
|
|
self.assertEqual(favorites.article_id, playground_articles[0])
|
|
self.assertEqual(favorites.sequence, 1)
|
|
|
|
playground_articles[1].action_toggle_favorite()
|
|
self.assertEqual(playground_articles.mapped('is_user_favorite'), [True, True, False])
|
|
self.assertEqual(playground_articles.mapped('user_favorite_sequence'), [1, 2, -1])
|
|
favorites = self.env['knowledge.article.favorite'].sudo().search([('user_id', '=', self.env.uid)])
|
|
self.assertEqual(favorites.article_id, playground_articles[0:2])
|
|
self.assertEqual(favorites.mapped('sequence'), [1, 2])
|
|
|
|
playground_articles[2].with_user(self.user_employee2).action_toggle_favorite()
|
|
favorites = self.env['knowledge.article.favorite'].sudo().search([('user_id', '=', self.user_employee2.id)])
|
|
self.assertEqual(favorites.article_id, playground_articles[2])
|
|
self.assertEqual(favorites.sequence, 1, 'Favorite: should not be impacted by other people sequence')
|
|
|
|
@users('employee')
|
|
def test_fields_edition(self):
|
|
_reference_dt = datetime(2022, 5, 31, 10, 0, 0)
|
|
body_values = [False, '', '<p><br /></p>', '<p>MyBody</p>']
|
|
|
|
for index, body in enumerate(body_values):
|
|
self.patch(self.env.cr, 'now', lambda: _reference_dt)
|
|
with freeze_time(_reference_dt):
|
|
article = self.env['knowledge.article'].create({
|
|
'body': body,
|
|
'internal_permission': 'write',
|
|
'name': 'MyArticle,'
|
|
})
|
|
self.assertEqual(article.last_edition_uid, self.env.user)
|
|
self.assertEqual(article.last_edition_date, _reference_dt)
|
|
|
|
self.patch(self.env.cr, 'now', lambda: _reference_dt + timedelta(days=1))
|
|
|
|
# fields that does not change content
|
|
with freeze_time(_reference_dt + timedelta(days=1)):
|
|
article.with_user(self.user_employee2).write({
|
|
'name': 'NoContentEdition'
|
|
})
|
|
self.assertEqual(article.last_edition_uid, self.env.user)
|
|
self.assertEqual(article.last_edition_date, _reference_dt)
|
|
|
|
# fields that change content
|
|
with freeze_time(_reference_dt + timedelta(days=1)):
|
|
article.with_user(self.user_employee2).write({
|
|
'body': body_values[(index + 1) if index < (len(body_values)-1) else 0]
|
|
})
|
|
article.with_user(self.user_employee2).flush()
|
|
self.assertEqual(article.last_edition_uid, self.user_employee2)
|
|
self.assertEqual(article.last_edition_date, _reference_dt + timedelta(days=1))
|
|
|
|
|
|
@tagged('knowledge_internals', 'knowledge_management')
|
|
class TestKnowledgeCommonWDataInitialValue(KnowledgeCommonWData):
|
|
""" Test initial values or our test data once so that other tests do not have
|
|
to do it. """
|
|
|
|
def test_initial_values(self):
|
|
""" Ensure all tests have the same basis (global values computed as root) """
|
|
# root
|
|
article_workspace = self.article_workspace
|
|
self.assertTrue(article_workspace.category, 'workspace')
|
|
self.assertEqual(article_workspace.sequence, 999)
|
|
article_shared = self.article_shared
|
|
self.assertTrue(article_shared.category, 'shared')
|
|
self.assertTrue(article_shared.sequence, 998)
|
|
|
|
# workspace children
|
|
workspace_children = article_workspace.child_ids
|
|
self.assertEqual(
|
|
workspace_children.mapped('inherited_permission'),
|
|
['write', 'write']
|
|
)
|
|
self.assertEqual(workspace_children.inherited_permission_parent_id, article_workspace)
|
|
self.assertEqual(
|
|
workspace_children.mapped('internal_permission'),
|
|
[False, False]
|
|
)
|
|
self.assertEqual(workspace_children.root_article_id, article_workspace)
|
|
self.assertEqual(workspace_children.mapped('sequence'), [0, 1])
|
|
|
|
@mute_logger('odoo.addons.base.models.ir_rule')
|
|
@users('employee')
|
|
def test_initial_values_as_employee(self):
|
|
""" Ensure all tests have the same basis (user specific computed as
|
|
employee for acl-dependent tests) """
|
|
article_workspace = self.article_workspace.with_env(self.env)
|
|
self.assertTrue(article_workspace.user_has_access)
|
|
self.assertTrue(article_workspace.user_has_write_access)
|
|
|
|
article_shared = self.article_shared.with_env(self.env)
|
|
self.assertTrue(article_shared.user_has_access)
|
|
self.assertFalse(article_shared.user_has_write_access)
|
|
|
|
article_private = self.article_private_manager.with_env(self.env)
|
|
with self.assertRaises(exceptions.AccessError):
|
|
self.assertFalse(article_private.body)
|
|
|
|
|
|
@tagged('post_install', '-at_install', 'knowledge_internals', 'knowledge_management')
|
|
class TestKnowledgeShare(KnowledgeCommonWData):
|
|
""" Test share feature. """
|
|
|
|
@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_msgs.body
|
|
)
|
|
|
|
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()
|