[REM][MOV] knowledge: move to enterprise

Due to business decisions knowledge is moved to enterprise repository. As
some other moves are planned, notably coupon or the complete responsible UI
and support that are coming to community Odoo feels like knowledge belongs
to enterprise applications.

Planned move from enterprise to community are

  * mass mailing themes;
  * mobile UI;
  * complete coupon and loyalty cards support;
  * spreadsheet library;

For any questions please refer to decision makers.

Task-2900765

X-original-commit: 9ba76621dc66fcf672ce514a47cc933bd9462f38
Part-of: odoo/odoo#95556
This commit is contained in:
Thibault Delavallée
2022-07-08 14:34:03 +02:00
parent bd832118ee
commit 3305e2d1d4
98 changed files with 0 additions and 14560 deletions
-10
View File
@@ -452,11 +452,6 @@ file_filter = addons/im_livechat_mail_bot/i18n/<lang>.po
source_file = addons/im_livechat_mail_bot/i18n/im_livechat_mail_bot.pot
source_lang = en
[odoo-s15-3.knowledge]
file_filter = addons/knowledge/i18n/<lang>.po
source_file = addons/knowledge/i18n/knowledge.pot
source_lang = en
[odoo-s15-3.l10n_multilang]
file_filter = addons/l10n_multilang/i18n/<lang>.po
source_file = addons/l10n_multilang/i18n/l10n_multilang.pot
@@ -1182,11 +1177,6 @@ file_filter = addons/website_jitsi/i18n/<lang>.po
source_file = addons/website_jitsi/i18n/website_jitsi.pot
source_lang = en
[odoo-s15-3.website_knowledge]
file_filter = addons/website_knowledge/i18n/<lang>.po
source_file = addons/website_knowledge/i18n/website_knowledge.pot
source_lang = en
[odoo-s15-3.website_links]
file_filter = addons/website_links/i18n/<lang>.po
source_file = addons/website_links/i18n/website_links.pot
-13
View File
@@ -1,13 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import wizard
from odoo import api, SUPERUSER_ID
def _init_private_article_per_user(cr, registry):
env = api.Environment(cr, SUPERUSER_ID, {})
env['res.users'].search([('partner_share', '=', False)])._generate_tutorial_articles()
-95
View File
@@ -1,95 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Knowledge',
'summary': 'Centralize, manage, share and grow your knowledge library',
'description': 'Centralize, manage, share and grow your knowledge library',
'category': 'Productivity/Knowledge',
'version': '0.1',
'depends': [
'web',
'web_editor',
'mail',
'portal'
],
'data': [
'data/article_templates.xml',
'data/knowledge_data.xml',
'data/ir_actions_data.xml',
'data/mail_templates.xml',
'wizard/knowledge_invite_views.xml',
'views/knowledge_article_views.xml',
'views/knowledge_article_favorite_views.xml',
'views/knowledge_article_member_views.xml',
'views/knowledge_templates.xml',
'views/knowledge_templates_common.xml',
'views/knowledge_templates_frontend.xml',
'views/knowledge_menus.xml',
'security/ir.model.access.csv',
'security/ir_rule.xml',
],
'installable': True,
'application': True,
'auto_install': False,
'license': 'LGPL-3',
'post_init_hook': '_init_private_article_per_user',
'assets': {
'web.assets_backend': [
'web/static/fonts/fonts.scss',
'knowledge/static/src/components/*/*.scss',
'knowledge/static/src/components/*/*.js',
'knowledge/static/src/scss/knowledge_views.scss',
'knowledge/static/src/scss/knowledge_editor.scss',
'knowledge/static/src/scss/knowledge_blocks.scss',
'knowledge/static/src/components/*/*.js',
'knowledge/static/src/js/knowledge_controller.js',
'knowledge/static/src/js/knowledge_renderers.js',
'knowledge/static/src/js/knowledge_views.js',
'knowledge/static/src/js/widgets/knowledge_dialogs.js',
'knowledge/static/src/js/tools/tree_panel_mixin.js',
'knowledge/static/src/js/widgets/knowledge_permission_panel.js',
'knowledge/static/src/js/widgets/knowledge_emoji_picker.js',
'knowledge/static/src/webclient/commands/*.js',
'knowledge/static/src/models/*/*.js',
'knowledge/static/src/js/form_controller.js',
'knowledge/static/src/js/form_renderer.js',
'knowledge/static/src/js/knowledge_macros.js',
'knowledge/static/src/js/knowledge_behaviors.js',
'knowledge/static/src/js/knowledge_toolbars.js',
'knowledge/static/src/js/knowledge_field_html_injector.js',
'knowledge/static/src/js/knowledge_plugin.js',
'knowledge/static/src/js/field_html.js',
'knowledge/static/src/js/knowledge_service.js',
],
'web.assets_frontend': [
'knowledge/static/src/scss/knowledge_frontend.scss',
'knowledge/static/src/scss/knowledge_blocks.scss',
'knowledge/static/src/js/knowledge_frontend.js',
'knowledge/static/src/js/tools/tree_panel_mixin.js',
],
'web_editor.assets_wysiwyg': [
'knowledge/static/src/js/wysiwyg/knowledge_article_link.js',
'knowledge/static/src/js/wysiwyg.js',
'knowledge/static/src/js/knowledge_toolbars_edit.js',
'knowledge/static/src/js/knowledge_clipboard_whitelist.js'
],
'web.assets_qweb': [
'knowledge/static/src/components/*/*.xml',
'knowledge/static/src/xml/knowledge_editor.xml',
'knowledge/static/src/xml/knowledge_templates.xml',
'knowledge/static/src/xml/chatter_topbar.xml',
'knowledge/static/src/xml/knowledge_command_palette.xml',
'knowledge/static/src/xml/knowledge_toolbars.xml',
],
'web.assets_tests': [
'knowledge/static/tests/tours/*.js',
],
'web.qunit_suite_tests': [
'knowledge/static/tests/test_services.js',
],
'web.qunit_mobile_suite_tests': [
'knowledge/static/tests/test_services.js',
],
},
}
-4
View File
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
-349
View File
@@ -1,349 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import werkzeug
from odoo import http, tools, _
from odoo.exceptions import AccessError, ValidationError
from odoo.http import request
class KnowledgeController(http.Controller):
# ------------------------
# Article Access Routes
# ------------------------
@http.route('/knowledge/home', type='http', auth='user')
def access_knowledge_home(self):
""" This route will redirect internal users to the backend view of the
article and the share users to the frontend view instead. """
article = request.env["knowledge.article"]._get_first_accessible_article()
if request.env.user.has_group('base.group_user') and not article:
return self._redirect_to_backend_view(article)
if not article:
return self._redirect_to_portal_view(False, hide_side_bar=True)
return request.redirect("/knowledge/article/%s" % article.id)
@http.route('/knowledge/article/<int:article_id>', type='http', auth='user')
def redirect_to_article(self, article_id):
""" This route will redirect internal users to the backend view of the
article and the share users to the frontend view instead."""
article = self._fetch_article(article_id)
if request.env.user.has_group('base.group_user'):
if not article:
return werkzeug.exceptions.Forbidden()
return self._redirect_to_backend_view(article)
return self._redirect_to_portal_view(article)
@http.route('/knowledge/article/invite/<int:member_id>/<string:invitation_hash>', type='http', auth='public')
def article_invite(self, member_id, invitation_hash):
""" This route will check if the given parameter allows the client to access the article via the invite token.
Then, if the partner has not registered yet, we will redirect the client to the signup page to finally redirect
them to the article.
If the partner already has registrered, we redirect them directly to the article.
"""
member = request.env['knowledge.article.member'].sudo().browse(member_id).exists()
correct_token = member._get_invitation_hash() if member else False
if not correct_token or not tools.consteq(correct_token, invitation_hash):
raise werkzeug.exceptions.NotFound()
partner = member.partner_id
article = member.article_id
if not partner.user_ids:
# Force the signup even if not enabled (as we explicitly invited the member).
# They should still be able to create a user.
signup_allowed = request.env['res.users']._get_signup_invitation_scope() == 'b2c'
if not signup_allowed:
partner.signup_prepare()
partner.signup_get_auth_param()
signup_url = partner._get_signup_url_for_action(url='/knowledge/article/%s' % article.id)[partner.id]
return request.redirect(signup_url)
return request.redirect('/web/login?redirect=/knowledge/article/%s' % article.id)
def _fetch_article(self, article_id):
""" Check the access of the given article for the current user. """
article = request.env['knowledge.article'].browse(int(article_id)).exists()
if not article:
return request.env['knowledge.article']
try:
article.check_access_rights('read')
article.check_access_rule('read')
except AccessError:
return request.env['knowledge.article']
return article
def _redirect_to_backend_view(self, article):
return request.redirect("/web#id=%s&model=knowledge.article&action=%s&menu_id=%s" % (
article.id if article else '',
request.env.ref("knowledge.knowledge_article_action_form").id,
request.env.ref('knowledge.knowledge_menu_root').id
))
def _redirect_to_portal_view(self, article, hide_side_bar=False):
return request.render('knowledge.knowledge_article_view_frontend', {
'article': article,
'portal_readonly_mode': True, # used to bypass access check (to speed up loading)
'show_sidebar': not hide_side_bar and bool(self._get_root_articles(limit=1))
})
# ------------------------
# Articles tree generation
# ------------------------
def _get_root_articles(self, limit=None):
""" Meant to be overriden by website_knowledge to search in sudo with adapted domain."""
return request.env["knowledge.article"].search([("parent_id", "=", False)], limit=limit, order='sequence, id')
def _prepare_articles_tree_html(self, template, active_article, unfolded_articles=False):
""" Prepares all the info needed to render the article tree view side panel
and returns the rendered given template with those values.
:param <knowledge.article> active_article: used to highlight the given
article_id in the template;
:param list unfolded_articles: List of IDs used to display the children
of the given article ids. Unfolded articles are saved into local storage.
When reloading/opening the article page, previously unfolded articles
nodes must be opened;
"""
unfolded_articles = set() if not unfolded_articles else set(unfolded_articles)
if active_article:
# root articles = starting point of the tree view : unfold only if root_article in (accessible) parents
ancestors = active_article._get_readable_ancetors()
if active_article.root_article_id in ancestors:
unfolded_articles |= set(ancestors.ids)
root_articles = self._get_root_articles()
values = {
"active_article": active_article,
"workspace_articles": root_articles.filtered(lambda article: article.category == 'workspace'),
"shared_articles": root_articles.filtered(lambda article: article.category == 'shared'),
"private_articles": root_articles.filtered(
lambda article: article.category == "private" and article.user_has_write_access),
"unfolded_articles": unfolded_articles,
'portal_readonly_mode': not request.env.user.has_group('base.group_user'),
}
favorites = request.env['knowledge.article.favorite']
if not request.env.user._is_public():
favorites = request.env['knowledge.article.favorite'].search([
("user_id", "=", request.env.user.id), ('article_id.active', '=', True)
])
values["favorites"] = favorites
return request.env['ir.qweb']._render(template, values)
@http.route('/knowledge/tree_panel', type='json', auth='user')
def get_tree_panel_all(self, active_article_id=False, unfolded_articles=False):
return self._prepare_articles_tree_html(
'knowledge.knowledge_article_tree',
self._fetch_article(active_article_id),
unfolded_articles=unfolded_articles
)
@http.route('/knowledge/tree_panel/portal', type='json', auth='public')
def get_tree_panel_portal(self, active_article_id=False, unfolded_articles=False):
""" Frontend access for left panel. """
return self._prepare_articles_tree_html(
'knowledge.knowledge_article_tree_frontend',
self._fetch_article(active_article_id),
unfolded_articles=unfolded_articles
)
@http.route('/knowledge/tree_panel/children', type='json', auth='user')
def get_tree_panel_children(self, parent_id):
parent = self._fetch_article(parent_id)
if not parent:
return werkzeug.exceptions.NotFound()
return request.env['ir.qweb']._render('knowledge.articles_template', {
'articles': parent.child_ids,
'portal_readonly_mode': not request.env.user.has_group('base.group_user'), # used to bypass access check (to speed up loading)
})
@http.route('/knowledge/tree_panel/favorites', type='json', auth='user')
def get_tree_panel_favorites(self, active_article_id=False):
favorite_articles = request.env['knowledge.article.favorite'].search([
("user_id", "=", request.env.user.id), ('article_id.active', '=', True)
])
return request.env['ir.qweb']._render('knowledge.knowledge_article_tree_favorites', {
'favorites': favorite_articles,
"active_article": request.env['knowledge.article'].browse(active_article_id),
})
# ------------------------
# Article permission panel
# ------------------------
@http.route('/knowledge/get_article_permission_panel_data', type='json', auth='user')
def get_article_permission_panel_data(self, article_id):
"""
Returns a dictionnary containing all values required to render the permission panel.
:param article_id: (int) article id
"""
article = self._fetch_article(article_id)
if not article:
return werkzeug.exceptions.Forbidden()
is_sync = not article.is_desynchronized
# Get member permission info
members_values = []
members_permission = article._get_article_member_permissions(additional_fields={
'res.partner': [
('name', 'partner_name'),
('email', 'partner_email'),
('partner_share', 'partner_share'),
],
'knowledge.article': [
('icon', 'based_on_icon'),
('name', 'based_on_name'),
],
})[article.id]
for partner_id, member in members_permission.items():
# empty member added by '_get_article_member_permissions', don't show it in the panel
if not member['member_id']:
continue
# if share partner and permission = none, don't show it in the permission panel.
if member['permission'] == 'none' and member['partner_share']:
continue
# if article is desyncronized, don't show members based on parent articles.
if not is_sync and member['based_on']:
continue
member_values = {
'id': member['member_id'],
'partner_id': partner_id,
'partner_name': member['partner_name'],
'partner_email': member['partner_email'],
'permission': member['permission'],
'based_on': f'{member["based_on_icon"] or "📄"} {member["based_on_name"]}' if member['based_on_name'] else False,
'based_on_id': member['based_on'],
'partner_share': member['partner_share'],
'is_unique_writer': member['permission'] == "write" and article.inherited_permission != "write" and not any(
other_member['permission'] == 'write'
for partner_id, other_member in members_permission.items()
if other_member['member_id'] != member['member_id']
),
}
members_values.append(member_values)
internal_permission_field = request.env['knowledge.article']._fields['internal_permission']
permission_field = request.env['knowledge.article.member']._fields['permission']
user_is_admin = request.env.user._is_admin()
return {
'internal_permission_options': internal_permission_field.get_description(request.env).get('selection', []),
'internal_permission': article.inherited_permission,
'parent_permission': article.parent_id.inherited_permission,
'based_on': article.inherited_permission_parent_id.display_name,
'based_on_id': article.inherited_permission_parent_id.id,
'members_options': permission_field.get_description(request.env).get('selection', []),
'members': members_values,
'is_sync': is_sync,
'parent_id': article.parent_id.id,
'parent_name': article.parent_id.display_name,
'user_is_admin': user_is_admin,
'show_admin_tip': user_is_admin and article.user_permission != 'write',
}
@http.route('/knowledge/article/set_member_permission', type='json', auth='user')
def article_set_member_permission(self, article_id, permission, member_id=False, inherited_member_id=False):
""" Sets the permission of the given member for the given article.
The returned result can also include a `reload_tree` entry that tells the
caller that the aside block listing all articles should be reloaded. This
happens when the article moves from one section to another.
**Note**: The user needs "write" permission to change the permission of a user.
:param int article_id: target article id;
:param string permission: permission to set on member, one of 'none',
'read' or 'write';
:param int member_id: id of a member of the given article;
:param int inherited_member_id: id of a member from one of the article's
parent (indicates rights are inherited from parents);
"""
article = self._fetch_article(article_id)
if not article:
return werkzeug.exceptions.Forbidden()
member = request.env['knowledge.article.member'].browse(member_id or inherited_member_id).exists()
if not member:
return {'error': _("The selected member does not exists or has been already deleted.")}
previous_category = article.category
try:
article._set_member_permission(member, permission, bool(inherited_member_id))
except (AccessError, ValidationError):
return {'error': _("You cannot change the permission if this member.")}
if article.category != previous_category:
return {'reload_tree': True}
return {}
@http.route('/knowledge/article/remove_member', type='json', auth='user')
def article_remove_member(self, article_id, member_id=False, inherited_member_id=False):
""" Removes the given member from the given article.
The returned result can also include a `reload_tree` entry that tells the
caller that the aside block listing all articles should be reloaded. This
happens when the article moves from one section to another.
**Note**: The user needs "write" permission to remove another member from
the list. The user can always remove themselves from the list.
:param int article_id: target article id;
:param int member_id: id of a member of the given article;
:param int inherited_member_id: id of a member from one of the article's
parent (indicates rights are inherited from parents);
"""
article = self._fetch_article(article_id)
if not article:
return werkzeug.exceptions.Forbidden()
member = request.env['knowledge.article.member'].browse(member_id or inherited_member_id).exists()
if not member:
return {'error': _("The selected member does not exists or has been already deleted.")}
previous_category = article.category
try:
article._remove_member(member)
except (AccessError, ValidationError) as e:
return {'error': e}
if article.category != previous_category:
return {'reload_tree': True}
return {}
@http.route('/knowledge/article/set_internal_permission', type='json', auth='user')
def article_set_internal_permission(self, article_id, permission):
""" Sets the internal permission of the given article.
The returned result can also include a `reload_tree` entry that tells the
caller that the aside block listing all articles should be reloaded. This
happens when the article moves from one section to another.
**Note**: The user needs "write" permission to update the internal permission
of the article.
:param int article_id: target article id;
:param string permission: permission to set on member, one of 'none',
'read' or 'write';
"""
article = self._fetch_article(article_id)
if not article:
return werkzeug.exceptions.Forbidden()
previous_category = article.category
try:
article._set_internal_permission(permission)
except (AccessError, ValidationError):
return {'error': _("You cannot change the internal permission of this article.")}
if article.category != previous_category:
return {'reload_tree': True}
return {}
-350
View File
@@ -1,350 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<template id="knowledge_article_user_onboarding">
<h1>
<span style="font-size: 36px;">Hello there <t t-out="object.name"/>,</span>
</h1>
<p>
<span style="font-size: 14px;">
This is where you and your team can centralize your
<font class="text-o-color-2" style="font-weight: bolder;">Knowledge</font>
and best practices! 🚀
</span>
</p>
<p><br /></p>
<h3>Diving in</h3>
<hr />
<p>
<span style="font-size: 14px;">This private page is for you to play around with.</span>
<br />
<span style="font-size: 14px;">Ready to give it a spin?</span>
</p>
<p>
<span style="font-size: 14px;">Try the following 👇</span>
</p>
<ul class="o_checklist">
<li id="checkId-140908257281">
<span style="font-size: 14px;">Click anywhere, and just start typing</span>
</li>
<li id="checkId-441216037148">
<span style="font-size: 14px;">Press Ctrl-Z to undo any change</span>
</li>
<li id="checkId-948702291173">
<span style="font-size: 14px;">Check this box to indicate it's done</span>
</li>
<li id="checkId-980463482772">
<span style="font-size: 14px;">
Select text to
<font class="bg-o-color-1">Highlight</font>,
<span style="text-decoration-line: line-through;">strikethrough</span>
or
<span style="font-weight: bolder;">style</span>
<span style="font-style: italic; text-decoration-line: underline;">it</span>
</span>
</li>
<li id="checkId-152369821198">
<span style="font-size: 14px;">
Below this list, try
<span style="font-weight: bolder;">commands</span>
by
<span style="font-weight: bolder;">
<font class="text-o-color-2">typing</font>
</span>
"<span style="font-weight: bolder;">/</span>"
</span>
</li>
<li class="oe-nested" id="checkId-947916154443">
<ul class="o_checklist">
<li id="checkId-1422510558483">
<span style="font-size: 14px;">
Add a checklist
(/<span style="font-style: italic;">checklist</span>)
</span>
</li>
<li id="checkId-109605733655">
<span style="font-size: 14px;">
Add a separator
(/<span style="font-style: italic;">separator</span>)
</span>
</li>
<li id="checkId-1518315885641">
<span style="font-size: 14px;">
Use
/<span style="font-style: italic;">heading</span>
to convert a text into a title
</span>
</li>
</ul>
</li>
</ul>
<p><br /></p>
<p>
<span style="font-size: 14px;">Got it? Now let's try advanced features 🧠</span>
</p>
<ul>
<li>
<span style="font-size: 14px;">
Use
/<span style="font-weight: bolder;"><font class="text-o-color-2">template</font></span>
to insert a
<span style="font-weight: bolder;">
<font class="text-o-color-2">template</font>
</span>
box. Need to re-use its content?
</span>
</li>
<li class="oe-nested">
<ul>
<li>
<span style="font-size: 14px;">From your document, find this article by clicking on the 📗 icon in the chatter.</span>
</li>
<li>
<span style="font-size: 14px;">You can use the template as a description, a message or simply copy it to your clipboard! 👌</span>
</li>
</ul>
</li>
</ul>
<div class="o_knowledge_template o_knowledge_toolbars_container o_knowledge_behavior_anchor o_knowledge_behavior_type_contents_container">
<div class="o_knowledge_toolbar_anchor o_knowledge_toolbar_type_template"></div>
<div class="o_knowledge_content">
<p>
Hello there, I am a template 👋
<br />
Use the buttons at the top-right of this box to re-use my content.
<br />
No more time wasted! 🔥
</p>
</div>
</div>
<p><br /></p>
<ul>
<li>
<span style="font-size: 14px;">
Use
/<span style="font-weight: bolder;"><font class="text-o-color-2">file</font></span>
to share documents that are frequently needed
</span>
</li>
<li class="oe-nested">
<ul>
<li>
<span style="font-size: 14px;">Need this document somewhere? Come back here by clicking on the 📗 icon in the chatter.</span>
</li>
<li>
<span style="font-size: 14px;">From this box, files can be previewed, forwarded and downloaded. 📁</span>
</li>
</ul>
</li>
</ul>
<div class="o_knowledge_file o_knowledge_toolbars_container o_knowledge_behavior_anchor o_knowledge_behavior_type_contents_container">
<div class="o_knowledge_file_image">
<a href="https://www.odoo.com/web/content/31413934?download=true" class="o_image" title="Onboarding.pdf" alt="Onboarding.pdf" data-mimetype="application/pdf"></a>
</div>
<div class="o_knowledge_file_description">
<div class="o_knowledge_file_name">Odoo Survival Guide</div>
<div class="o_knowledge_file_extension">pdf</div>
</div>
<div class="o_knowledge_toolbar_anchor o_knowledge_toolbar_type_file mt-auto ms-auto"></div>
</div>
<p><br /></p>
<p>
<span style="font-size: 14px;">
Want to go
<span style="font-style: italic;">even</span>
faster? ⚡️
</span>
<br />
<span style="font-size: 14px;">
Access
<span style="font-weight: bolder;">
<font class="text-o-color-2">Articles</font>
</span>
by opening the
<span style="font-weight: bolder;">
<font class="text-o-color-1">Command Palette</font>
</span>
(Ctrl + k/⌘ + k) then search through articles by starting your query with
"<span style="font-weight: bolder;">?</span>".
</span>
</p>
<p><br /></p>
<h3>Navigation Basics 🐣</h3>
<hr />
<p>
<span style="font-size: 14px;">
👈 See the
<span style="font-weight: bolder;">
<font class="text-o-color-2">Menu</font>
</span>
there, on the left?
</span>
</p>
<p>
<span style="font-size: 14px;">
Those are your
<span style="font-weight: bolder;"><font class="text-o-color-2">Articles</font></span>.
</span>
</p>
<p>
<span style="font-size: 14px;">Each of them can bu used both as:</span>
</p>
<ul>
<li>
<span style="font-size: 14px;">
Content — Just click and
<span style="font-weight: bolder;">
<font class="text-o-color-1">start typing</font>
</span>
(<span style="font-style: italic;">documentation, tips, reports, ...</span>)
</span>
</li>
<li>
<span style="font-size: 14px;">
Folders —
</span>
<span style="font-size: 14px;">
<span style="font-weight: bolder;">
<font class="text-o-color-1">Nest other Articles</font>
</span>
under it to regroup them
(<span style="font-style: italic;">per team, topic, project, ...</span>)
</span>
</li>
</ul>
<p>
<span style="font-size: 14px;">
To change the way
<span style="font-weight: bolder;">
<font class="text-o-color-2">Articles</font>
</span>
are organized, you can simply
<span style="font-weight: bolder;">
<font class="text-o-color-1">Drag &amp; Drop</font>
</span>
them
</span>
</p>
<p><br /></p>
<h3>Who has access to what? 🕵️</h3>
<hr />
<p>
<span style="font-size: 14px;">
<span style="font-weight: bolder;">
<font class="text-o-color-2">Articles</font>
</span>
are stored into different
<span style="font-weight: bolder;"><font class="text-o-color-2">Sections</font></span>:
</span>
</p>
<ul>
<li>
<span style="font-size: 14px;">
<span style="font-weight: bolder;">
<font class="text-o-color-2">Favorites</font>
</span>
— Those are
<span style="font-weight: bolder;">
<font class="text-o-color-1">shortcuts</font>
</span>
you create for yourself.
Unstar ⭐ this page at the top to remove it from your favorites.
</span>
</li>
<li>
<span style="font-size: 14px;">
<span style="font-weight: bolder;">
<font class="text-o-color-2">Workspace</font>
</span>
</span>
<span style="font-size: 14px;">
— Articles there can be accessed by
<span style="font-weight: bolder;">
<font class="text-o-color-1">your team</font>
</span>
</span>
</li>
<li>
<span style="font-size: 14px;">
<span style="font-weight: bolder;">
<font class="text-o-color-2">Shared</font>
</span>
</span>
<span style="font-size: 14px;">
— Those are the ones you
<span style="font-weight: bolder;">
<font class="text-o-color-1">have invited someone or been invited to</font>
</span>
</span>
</li>
<li>
<span style="font-size: 14px;">
<span style="font-weight: bolder;">
<font class="text-o-color-2">Private</font>
</span>
</span>
<span style="font-size: 14px;">
— This is
<span style="font-weight: bolder;"><font class="text-o-color-1">your stuff</font></span>,
the things you keep for yourself
(<span style="font-style: italic;">Drafts, Todo lists, ...</span>)
</span>
</li>
</ul>
<p>
<span style="font-size: 14px;">
And again, to move an
<span style="font-weight: bolder;">
<font class="text-o-color-2">Article</font>
</span>
from a
<span style="font-weight: bolder;">
<font class="text-o-color-2">Section</font>
</span>
to another, just
<span style="font-weight: bolder;">
<font class="text-o-color-1">Drag &amp; Drop</font>
</span>
it.
</span>
</p>
<p>
<span style="font-size: 14px;">
A good workflow is to write your drafts in
<span style="font-weight: bolder;">
<font class="text-o-color-2">Private</font>
</span>
and, once done, move it from
<span style="font-weight: bolder;">
<font class="text-o-color-2">Private</font>
</span>
to
<span style="font-weight: bolder;">
<font class="text-o-color-2">Workspace</font>
</span>
to share it with everyone.
</span>
</p>
<p>
<span style="font-size: 14px;">And voilà, it is that simple.</span>
</p>
<p>
<span style="font-size: 48px;">🚀</span>
</p>
<p><br /></p>
<hr />
<p>
To be sure to stay updated, follow us on
<a href="https://twitter.com/Odoo" target="_blank"><span style="font-weight: bolder;"><font class="text-o-color-1">Twitter</font></span></a>,
<a href="https://www.youtube.com/channel/UCkQPikELWZFLgQNHd73jkdg" target="_blank">
<span style="font-weight: bolder;">
<font class="text-o-color-1">YouTube</font>
</span>
</a>
or
<a href="https://www.facebook.com/odoo" target="_blank"><span style="font-weight: bolder;"><font class="text-o-color-1">Facebook</font></span></a>.
</p>
</template>
</data></odoo>
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="0">
<record id="ir_actions_server_knowledge_home_page" model="ir.actions.server">
<field name="name">Articles</field>
<field name="model_id" ref="model_knowledge_article"/>
<field name="state">code</field>
<field name="code">action = model.action_home_page()</field>
</record>
</data></odoo>
-513
View File
@@ -1,513 +0,0 @@
<?xml version="1.0"?>
<odoo>
<data noupdate="1">
<record id="knowledge_article_hr_faq" model="knowledge.article" forcecreate="False">
<field name="name">HR FAQ</field>
<field name="icon">❓</field>
<field name="body" type="html">
<h1>
<span style="font-weight: bolder;">
<font class="text-o-color-1">HR FAQ</font>
</span>
</h1>
<hr />
<p><br /></p>
<h5>
<span style="font-weight: bolder; text-decoration-line: underline; font-size: 24px;">
<font class="text-o-color-1">Expense Policy</font>
</span>
</h5>
<blockquote data-o-mail-quote="1">
<span style="font-size: 18px;">Who do I need to address if I need office supplies?</span>
</blockquote>
<p>
<span style="font-size: 14px;">
Instead of setting up complicated processes,
<span style="font-weight: bolder;">we prefer to let our employees buy whatever they need</span>.
Just fill in an expense and we will reimburse you.
</span>
</p>
<p><br /></p>
<blockquote data-o-mail-quote="1">
<span style="font-size: 18px;">Why has my expense not been reimbursed? It has been accepted.</span>
</blockquote>
<p>
<span style="font-size: 14px;">The Accounting team handles all payments on Fridays afternoon.</span>
<br />
<span style="font-size: 14px;">If 2 weeks have passed and you are still waiting to be paid, get in touch with them.</span>
</p>
<p><br /></p>
<h5>
<span style="font-weight: bolder; text-decoration-line: underline; font-size: 24px;">
<font class="text-o-color-1">Car Policy</font>
</span>
</h5>
<blockquote data-o-mail-quote="1">
<span style="font-size: 18px;">How do I know which model I can order or not?</span>
</blockquote>
<p>
<span style="font-size: 14px;">
Our catalog can be found here and is updated every 2 years.
If you do not manage to find the specific model you are looking for,
</span>
<a href="mailto:fleet@company.com" target="_blank">
<span style="font-size: 14px; font-weight: bolder; text-decoration-line: underline;">contact our Fleet Officer</span>
</a>
</p>
<div class="o_knowledge_file o_knowledge_toolbars_container o_knowledge_behavior_anchor o_knowledge_behavior_type_contents_container">
<div class="o_knowledge_file_image">
<a href="/knowledge/static/src/img/fleet_icon.png" class="o_image" title="Car_Catalog.png" alt="Car_Catalog.png" data-mimetype="image/png"></a>
</div>
<div class="o_knowledge_file_description">
<div class="o_knowledge_file_name">Car_Catalog.png</div>
<div class="o_knowledge_file_extension">png</div>
</div>
<div class="o_knowledge_toolbar_anchor o_knowledge_toolbar_type_file mt-auto ms-auto"></div>
</div>
<p><br /></p>
<blockquote data-o-mail-quote="1">
<span style="font-size: 18px;">Who can drive my car?</span>
</blockquote>
<p>
<span style="font-size: 14px;">
Your car can be driven by
<span style="font-weight: bolder;">you, your spouse and by any person living under the same roof</span>
as long as they have a valid permit.
</span>
</p>
<p><br /></p>
<h5>
<span style="font-size: 24px; font-weight: bolder; text-decoration-line: underline;">
<font class="text-o-color-1">Leaves &amp; Time Off</font>
</span>
</h5>
<blockquote data-o-mail-quote="1">
<span style="font-size: 18px;">What do I have to do if I cannot work?</span>
</blockquote>
<p>
<span style="font-size: 14px;">Please refer to the chart below.</span>
</p>
<p><br /></p>
<table class="table table-bordered">
<tbody>
<tr>
<td>
<span style="font-size: 14px;">An hour or two (medical appointment, ...)</span>
</td>
<td>
<span style="font-size: 14px;">Let your Team Leader know in advance.</span>
</td>
</tr>
<tr>
<td>
<span style="font-size: 14px;">A day or less</span>
</td>
<td>
<span style="font-size: 14px;">Inform HR and your Team Leader.</span>
</td>
</tr>
<tr>
<td>
<span style="font-size: 14px;">Absent for more than a day</span>
</td>
<td>
<span style="font-size: 14px;">See a doctor and send us the sick note.</span>
</td>
</tr>
</tbody>
</table>
<p><br /></p>
</field>
<field name="parent_id" eval="False" />
<field name="internal_permission">write</field>
</record>
<record id="knowledge_article_sales_and_marketing" model="knowledge.article" forcecreate="False">
<field name="name">Sales &amp; Marketing</field>
<field name="icon">🤩</field>
<field name="body" type="html">
<h1>
<span style="font-weight: bolder;">
<font class="text-o-color-1">Sales &amp; Marketing</font>
</span>
</h1>
<hr />
<p>We decided to gather all relevant documentation here to make sure all resources were easy to access.</p>
<p><br /></p>
<table class="table table-bordered">
<tbody>
<tr>
<td>
<span style="font-size: 24px; font-weight: bolder;">
<font class="text-o-color-1">SALES</font>
</span>
</td>
<td>
<span style="font-size: 24px; font-weight: bolder;">
<font class="text-o-color-1">MARKETING</font>
</span>
</td>
</tr>
<tr>
<td>PlaybookArticle</td>
<td>BrandAssetsArticle</td>
</tr>
</tbody>
</table>
<p><br /></p>
</field>
<field name="parent_id" eval="False" />
<field name="internal_permission">write</field>
</record>
<record id="knowledge_article_sales_playbook" model="knowledge.article" forcecreate="False">
<field name="name">Sales Playbook</field>
<field name="icon">📘</field>
<field name="body" type="html">
<h1>Sales Playbook</h1>
<hr />
<p>
<span style="font-size: 14px;">Compiled below are tips &amp; tricks collected among our veterans to help newcomers get started. We hope it will help you sign deals. 🔔</span>
</p>
<p><br /></p>
<h3>Prospection Templates 👌</h3>
<hr />
<p><br /></p>
<p>
<span style="font-size: 14px;">
<span style="font-weight: bolder;">
<font class="text-o-color-1">PRO TIP</font>
</span>
: From a lead, use the 📗 button in the chatter to find this article and autocomplete your email with this template.
</span>
</p>
<div class="o_knowledge_template o_knowledge_toolbars_container o_knowledge_behavior_anchor o_knowledge_behavior_type_contents_container">
<div class="o_knowledge_toolbar_anchor o_knowledge_toolbar_type_template"></div>
<div class="o_knowledge_content">
<p>Hey ProspectName,</p>
<p>
I was doing some research online and found your company.
<br />
Considering we just launched ProductName, I was thinking you would be interested.
</p>
<p>
Could you please let me know on which number I could reach you so that we could get in touch?
<br />
It should not take longer than 15 minutes.
</p>
<p>
Talk to you soon,
<br />
YourName
</p>
<p><br /></p>
</div>
</div>
<p><br /></p>
<p>
<span style="font-size: 14px;">
<span style="font-weight: bolder;"><font class="text-o-color-1">PRO TIP</font></span>: From a lead, use the
</span>
<span style="font-size: 14px;">📗 button in the chatter to find this article and autocomplete your description with this qualification template.</span>
</p>
<div class="o_knowledge_template o_knowledge_toolbars_container o_knowledge_behavior_anchor o_knowledge_behavior_type_contents_container">
<div class="o_knowledge_toolbar_anchor o_knowledge_toolbar_type_template"></div>
<div class="o_knowledge_content">
<h2>Prospect Qualification</h2>
<p><br /></p>
<ol>
<li>
<span style="font-weight: bolder;">Company</span>
</li>
<li class="oe-nested">
<ul>
<li>Background</li>
<li>Size</li>
<li>Estimated Revenues</li>
<li>Pain Points</li>
</ul>
</li>
<li>
<span style="font-weight: bolder;">Contact</span>
</li>
<li class="oe-nested">
<ul>
<li>Job Position</li>
<li>Email</li>
<li>Phone</li>
</ul>
</li>
</ol>
<p><br /></p>
</div>
</div>
<p><br /></p>
<h3>The 5 Commandments 🙏</h3>
<hr />
<ol>
<li>
<span style="font-size: 14px;">My Forecast will always be accurate and up-to-date</span>
</li>
<li>
<span style="font-size: 14px;">All my activities will always be encoded in our CRM</span>
</li>
<li>
<span style="font-size: 14px;">I will only sell a project if I am convinced it can be a success</span>
</li>
<li>
<span style="font-size: 14px;">I will not steal prospects from colleagues</span>
</li>
<li>
<span style="font-size: 14px;">I will not waste time and energy bad-mouthing competitors</span>
</li>
</ol>
<p><br /></p>
<h3>Tips to close more deals 🤝</h3>
<hr />
<ul>
<li>
<span style="font-size: 14px;">Be assertive but listen to what is being said</span>
</li>
<li>
<span style="font-size: 14px;">Identify the pain points and offer clear solutions</span>
</li>
<li>
<span style="font-size: 14px;">Prepare your demos in advance and integrate the prospect's use case into it</span>
</li>
</ul>
<p><br /></p>
<h3>Company Abbreviations 📚</h3>
<hr />
<p>
<span style="font-size: 14px;">You may have heard those a million times and yet you are not entirely sure of what it means.</span>
<br />
<span style="font-size: 14px;">Here is a quick recap for you to shine during the next meeting.</span>
</p>
<table class="table table-bordered">
<tbody>
<tr>
<td>
<span style="font-size: 14px; font-weight: bolder;">MRR</span>
</td>
<td>
<span style="font-size: 14px;">
<span style="font-weight: bolder;">M</span>onthly
<span style="font-weight: bolder;">R</span>ecurring
<span style="font-weight: bolder;">R</span>evenues
(<span style="font-style: italic;">subscriptions, ...</span>)
</span>
</td>
</tr>
<tr>
<td>
<span style="font-size: 14px; font-weight: bolder;">NRR</span>
</td>
<td>
<span style="font-size: 14px;">
<span style="font-weight: bolder;">N</span>on-<span style="font-weight: bolder;">R</span>ecurring
<span style="font-weight: bolder;">R</span>evenues
(<span style="font-style: italic;">consultancy services, ...</span>)
</span>
</td>
</tr>
<tr>
<td>
<span style="font-size: 14px; font-weight: bolder;">USP</span>
</td>
<td>
<span style="font-size: 14px;">
<span style="font-weight: bolder;">U</span>nique
<span style="font-weight: bolder;">S</span>elling
<span style="font-weight: bolder;">P</span>roposition:
Advantage that makes you stand out from the competition.
</span>
</td>
</tr>
<tr>
<td>
<span style="font-size: 14px; font-weight: bolder;">Q1,2,3,4</span>
</td>
<td>
<span style="font-size: 14px;">Nth
<span style="font-weight: bolder;">Q</span>uarter of the fiscal year.</span>
<br />
<span style="font-size: 14px; font-style: italic;">E.g. Q4 starts on Oct. 1 and ends on Dec. 31.</span>
</td>
</tr>
</tbody>
</table>
<p><br /></p>
</field>
<field name="parent_id" eval="ref('knowledge_article_sales_and_marketing')" />
<field name="internal_permission">write</field>
</record>
<record id="knowledge_article_brand_assets" model="knowledge.article" forcecreate="False">
<field name="name">Brand Assets</field>
<field name="icon">🎨</field>
<field name="body" type="html">
<h1>Brand Assets</h1>
<hr />
<p>
<span style="font-size: 14px;">Please submit a request for assistance to the Marketing Team if you fall into one of the following:</span>
</p>
<ul>
<li>
<span style="font-size: 14px;">Advertising that would appear online or on TV</span>
</li>
<li>
<span style="font-size: 14px;">SEO &amp; SEA projects (betting on keywords, ...)</span>
</li>
<li>
<span style="font-size: 14px;">Booklets for a total price &gt; $1000</span>
</li>
</ul>
<p>
<span style="font-size: 14px;">For all other categories, we simply require you to follow the rules listed below.</span>
</p>
<table class="table table-bordered">
<tbody>
<tr>
<td>
<span style="font-size: 24px;">
Make sure to comply as
<span style="font-weight: bolder;">you will represent <span style="text-decoration-line: underline;">our</span> brand</span>.
</span>
<br />
<span style="font-size: 24px;">If in doubt, get in touch with us.</span>
</td>
</tr>
</tbody>
</table>
<p><br /></p>
<h2>Logo</h2>
<hr />
<p>
<span style="font-size: 14px;">Here are logos that you can use at your own convenience.</span>
<br />
<span style="font-size: 14px;">They can also be shared with customers, journalists and resellers.</span>
</p>
<div class="o_knowledge_file o_knowledge_toolbars_container o_knowledge_behavior_anchor o_knowledge_behavior_type_contents_container">
<div class="o_knowledge_file_image">
<a href="https://odoocdn.com/openerp_website/static/src/img/assets/png/odoo_logo.png?download=true" class="o_image" title="logo.jpg" alt="logo.jpg" data-mimetype="image/jpeg"></a>
</div>
<div class="o_knowledge_file_description">
<div class="o_knowledge_file_name">Odoo Logo</div>
<div class="o_knowledge_file_extension">JPG</div>
</div>
<div class="o_knowledge_toolbar_anchor o_knowledge_toolbar_type_file mt-auto ms-auto"></div>
</div>
<p><br /></p>
<h4>Usage</h4>
<ul>
<li>
<span style="font-size: 14px; font-weight: bolder;">Do not recreate the Logo from scratch, use these ones</span>
</li>
<li>
<span style="font-size: 14px;">Logos should only be used in the colors provided</span>
</li>
<li>
<span style="font-size: 14px;">Always include sufficient clear space around the logo</span>
</li>
</ul>
<p><br /></p>
<h2>Brand Name Rules</h2>
<hr />
<p>
<span style="font-size: 14px;">YourCompany is very proud of its brand image.</span>
<br />
<span style="font-size: 14px;">When representing the brand, we thus ask you to be very cautious in how you refer to the company.</span>
</p>
<table class="table table-bordered">
<tbody>
<tr>
<td>
<h3>
<span style="font-weight: bolder;">
<font style="color: rgb(148, 189, 123);">Do</font>
</span>
</h3>
<ul>
<li>
<span style="font-size: 14px;">
Capitalize the word
<span style="font-weight: bolder;">YourCompany</span>,
except if it's part of an URL e.g. website/company
</span>
</li>
<li>
<span style="font-size: 14px;">
Always type
<span style="font-weight: bolder;">YourCompany</span>
in the same font size and style as the content of the text
</span>
</li>
<li>
<span style="font-size: 14px;">
Write it without any article
(<span style="font-style: italic;"><span style="text-decoration-line: line-through;">the</span> YourCompany, <span style="text-decoration-line: line-through;">a</span> YourCompany, ...</span>)
</span>
</li>
</ul>
</td>
<td>
<h3>
<span style="font-weight: bolder;">
<font style="color: rgb(231, 99, 99);">Do Not</font>
</span>
</h3>
<ul>
<li>
<span style="font-size: 14px;">
Pluralize the trademark (e.g.
<span style="font-style: italic;">YourCompanies</span>)
</span>
</li>
<li>
<span style="font-size: 14px;">Use the logo instead of the word inside sentences</span>
</li>
<li>
<span style="font-size: 14px;">Use the expression "YourCompanions" to refer to our community</span>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
<p><br /></p>
<h2>"About Us" Template</h2>
<hr />
<p>
<span style="font-size: 14px;">Use this template whenever you have an announcement to make.</span>
</p>
<p><br /></p>
<div class="o_knowledge_template o_knowledge_toolbars_container o_knowledge_behavior_anchor o_knowledge_behavior_type_contents_container">
<div class="o_knowledge_toolbar_anchor o_knowledge_toolbar_type_template"></div>
<div class="o_knowledge_content">
<p>
250 Executive Park Blvd, Suite 3400 94134
<br />
San Francisco California (US)
<br />
United States
<br />
info@yourcompany.com
</p>
<p><br /></p>
<p>
YourCompany is proud to announce that...
</p>
<p><br /></p>
<p>
About YourCompany: YourCompany is a team of passionate people whose goal is to improve everyone's life through disruptive products.
We build great products to solve your business problems.
<br />
Our products are designed for small to medium size companies willing to optimize their performance.
</p>
<p><br /></p>
</div>
</div>
<p><br /></p>
</field>
<field name="parent_id" eval="ref('knowledge_article_sales_and_marketing')" />
<field name="internal_permission">write</field>
</record>
</data>
</odoo>
-27
View File
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<template id="knowledge_article_mail_invite">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: white; padding: 0; border-collapse:separate;">
<tr><td valign="top">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
<p>Dear <t t-out="recipient.name"/>,</p>
<t t-out="user.name or ''"></t> invited you to access <t t-out="record.name"></t>.<br/><br/>
</p>
</div>
</td></tr>
<tr><td valign="top">
<p style="margin: 16px 0px 16px 0px; text-align: center;">
<a t-att-href="link"
style="background-color:#875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px;">
Go to Article
</a>
</p>
</td></tr>
</table>
</template>
</data>
</odoo>
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import knowledge_article_favorite
from . import knowledge_article_member
from . import knowledge_article
from . import res_partner
from . import res_users
File diff suppressed because it is too large Load Diff
@@ -1,52 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, exceptions, fields, models, _
class ArticleFavorite(models.Model):
_name = 'knowledge.article.favorite'
_description = 'Favorite Article'
_order = 'sequence ASC, id DESC'
_rec_name = 'article_id'
article_id = fields.Many2one(
'knowledge.article', 'Article',
index=True, required=True, ondelete='cascade')
user_id = fields.Many2one(
'res.users', 'User',
index=True, required=True, ondelete='cascade')
sequence = fields.Integer(default=0)
_sql_constraints = [
('unique_article_user',
'unique(article_id, user_id)',
'User already has this article in favorites.')
]
@api.model_create_multi
def create(self, vals_list):
""" At creation, we need to set the max sequence, if not given, for each favorite to create, in order to keep
a correct ordering as much as possible. Some sequence could be given in create values, that could lead to
duplicated sequence per user_id. That is not an issue as they will be resequenced the next time the user reorder
their favorites. """
# TDE TODO: env.uid -> user_id
default_sequence = 1
if any(not vals.get('sequence') for vals in vals_list):
favorite = self.env['knowledge.article.favorite'].search(
[('user_id', '=', self.env.uid)],
order='sequence DESC',
limit=1
)
default_sequence = favorite.sequence + 1 if favorite else default_sequence
for vals in vals_list:
if not vals.get('sequence'):
vals['sequence'] = default_sequence
default_sequence += 1
return super(ArticleFavorite, self).create(vals_list)
def write(self, vals):
""" Whatever rights, avoid any attempt at privilege escalation. """
if ('article_id' in vals or 'user_id' in vals) and not self.env.is_admin():
raise exceptions.AccessError(_("Can not update the article or user of a favorite."))
return super().write(vals)
@@ -1,110 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools, _
from odoo.exceptions import AccessError, ValidationError
class ArticleMember(models.Model):
_name = 'knowledge.article.member'
_description = 'Article Member'
_rec_name = 'partner_id'
article_id = fields.Many2one(
'knowledge.article', 'Article',
index=True, ondelete='cascade', required=True)
partner_id = fields.Many2one(
'res.partner', 'Partner',
index=True, ondelete='cascade', required=True)
permission = fields.Selection(
[('write', 'Can write'),
('read', 'Can read'),
('none', 'No access')],
required=True, default='read')
article_permission = fields.Selection(
related='article_id.inherited_permission',
readonly=True, store=True)
_sql_constraints = [
('unique_article_partner',
'unique(article_id, partner_id)',
'You already added this partner on this article.')
]
@api.constrains('article_permission', 'permission')
def _check_is_writable(self, on_unlink=False):
""" Articles must always have at least one writer. This constraint is done
on member level, in coordination to the constraint on article model (see
``_check_is_writable`` on ``knowledge.article``).
Since this constraint only triggers if we have at least one member another
validation is done on article model. The article_permission related field
has been added and stored to force triggering this constraint when
article.permission is modified.
Ǹote: computation is done in Py instead of using optimized SQL queries
because value are not yet in DB at this point.
:param bool on_unlink: when called on unlink we must remove the members
in self (the ones that will be deleted) to check if one of the remaining
members has write access.
"""
if self.env.context.get('knowledge_member_skip_writable_check'):
return
articles_to_check = self.article_id.filtered(lambda a: a.inherited_permission != 'write')
if not articles_to_check:
return
if on_unlink:
deleted_members_by_article = dict.fromkeys(articles_to_check.ids, self.env['knowledge.article.member'])
for member in self.filtered(lambda member: member.article_id in articles_to_check):
deleted_members_by_article[member.article_id.id] |= member
for article in articles_to_check:
# Check on permission on members
members_to_check = article.article_member_ids
if on_unlink:
members_to_check -= deleted_members_by_article[article.id]
if any(m.permission == 'write' for m in members_to_check):
continue
members_to_exclude = deleted_members_by_article[article.id] if on_unlink else False
if not article._has_write_member(members_to_exclude=members_to_exclude):
raise ValidationError(
_("Article '%s' should always have a writer: inherit write permission, or have a member with write access",
article.display_name)
)
@api.constrains('partner_id', 'permission')
def _check_external_member_permission(self):
for member in self.filtered(lambda member: member.permission == 'write'):
if member.partner_id.partner_share:
raise ValidationError(
_("The external user %(user_name)s cannot have a 'write' permission on article %(article_name)s",
user_name=member.partner_id.display_name,
article_name=member.article_id.display_name
))
def init(self):
self._cr.execute("CREATE INDEX IF NOT EXISTS knowledge_article_member_article_partner_idx ON knowledge_article_member (article_id, partner_id)")
def write(self, vals):
""" Whatever rights, avoid any attempt at privilege escalation. """
if ('article_id' in vals or 'partner_id' in vals) and not self.env.is_admin():
raise AccessError(_("Can not update the article or partner of a member."))
return super().write(vals)
@api.ondelete(at_uninstall=False)
def _unlink_except_no_writer(self):
""" When removing a member, the constraint is not triggered.
We need to check manually on article with no write permission that we do not remove the last write member """
self._check_is_writable(on_unlink=True)
def _get_invitation_hash(self):
""" We use a method instead of a field in order to reduce DB space."""
self.ensure_one()
return tools.hmac(self.env(su=True),
'knowledge-article-invite',
f'{self.id}-{self.create_date}-{self.partner_id.id}-{self.article_id.id}'
)
-16
View File
@@ -1,16 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class Partner(models.Model):
_name = 'res.partner'
_inherit = 'res.partner'
def unlink(self):
""" This override will delete all the private articles linked to the deleted partners. """
self.env['knowledge.article.member'].sudo().search(
[('partner_id', 'in', self.ids), ('article_id.category', '=', 'private')]
).article_id.unlink()
return super(Partner, self).unlink()
-49
View File
@@ -1,49 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api
from odoo.tools.translate import _lt
class Users(models.Model):
_name = 'res.users'
_inherit = 'res.users'
@api.model_create_multi
def create(self, vals_list):
users = super(Users, self).create(vals_list)
if not self.env.context.get('knowledge_skip_onboarding_article'):
users.filtered(lambda user: not user.partner_share)._generate_tutorial_articles()
return users
def _generate_tutorial_articles(self):
articles_to_create = []
for user in self:
self = self.with_context(lang=user.lang or self.env.user.lang)
render_ctx = {'object': user}
body = self.env['ir.qweb']._render(
'knowledge.knowledge_article_user_onboarding',
render_ctx,
minimal_qcontext=True,
raise_if_not_found=False
)
if not body:
break
welcome = _lt('Welcome %s', user.name)
articles_to_create.append({
'article_member_ids': [(0, 0, {
'partner_id': user.partner_id.id,
'permission': 'write',
})],
'body': body,
'icon': "👋",
'internal_permission': 'none',
'favorite_ids': [(0, 0, {
'sequence': 0,
'user_id': user.id,
})],
'name': welcome,
})
if articles_to_create:
self.env['knowledge.article'].sudo().create(articles_to_create)
@@ -1,16 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_knowledge_article_all,access.knowledge.article.all,knowledge.model_knowledge_article,,0,0,0,0
access_knowledge_article_portal,access.knowledge.article.portal,knowledge.model_knowledge_article,base.group_portal,1,0,0,0
access_knowledge_article_user,access.knowledge.article.user,knowledge.model_knowledge_article,base.group_user,1,1,1,0
access_knowledge_article_system,access.knowledge.article.system,knowledge.model_knowledge_article,base.group_system,1,1,1,1
access_knowledge_article_member_all,access.knowledge.article.member.all,knowledge.model_knowledge_article_member,,0,0,0,0
access_knowledge_article_member_portal,access.knowledge.article.member.portal,knowledge.model_knowledge_article_member,base.group_portal,1,0,0,0
access_knowledge_article_member_user,access.knowledge.article.member.user,knowledge.model_knowledge_article_member,base.group_user,1,0,0,0
access_knowledge_article_member_system,access.knowledge.article.member.system,knowledge.model_knowledge_article_member,base.group_system,1,1,1,1
access_knowledge_article_favorite_all,access.knowledge.article.favorite.all,knowledge.model_knowledge_article_favorite,,0,0,0,0
access_knowledge_article_favorite_portal,access.knowledge.article.favorite.portal,knowledge.model_knowledge_article_favorite,base.group_portal,1,1,1,1
access_knowledge_article_favorite_user,access.knowledge.article.favorite.user,knowledge.model_knowledge_article_favorite,base.group_user,1,1,1,1
access_knowledge_article_favorite_system,access.knowledge.article.favorite.system,knowledge.model_knowledge_article_favorite,base.group_system,1,1,1,1
access_knowledge_invite_all,access.knowledge.invite.all,knowledge.model_knowledge_invite,,0,0,0,0
access_knowledge_invite_user,access.knowledge.invite.user,knowledge.model_knowledge_invite,base.group_user,1,1,1,0
access_knowledge_invite_system,access.knowledge.invite.system,knowledge.model_knowledge_invite,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_knowledge_article_all access.knowledge.article.all knowledge.model_knowledge_article 0 0 0 0
3 access_knowledge_article_portal access.knowledge.article.portal knowledge.model_knowledge_article base.group_portal 1 0 0 0
4 access_knowledge_article_user access.knowledge.article.user knowledge.model_knowledge_article base.group_user 1 1 1 0
5 access_knowledge_article_system access.knowledge.article.system knowledge.model_knowledge_article base.group_system 1 1 1 1
6 access_knowledge_article_member_all access.knowledge.article.member.all knowledge.model_knowledge_article_member 0 0 0 0
7 access_knowledge_article_member_portal access.knowledge.article.member.portal knowledge.model_knowledge_article_member base.group_portal 1 0 0 0
8 access_knowledge_article_member_user access.knowledge.article.member.user knowledge.model_knowledge_article_member base.group_user 1 0 0 0
9 access_knowledge_article_member_system access.knowledge.article.member.system knowledge.model_knowledge_article_member base.group_system 1 1 1 1
10 access_knowledge_article_favorite_all access.knowledge.article.favorite.all knowledge.model_knowledge_article_favorite 0 0 0 0
11 access_knowledge_article_favorite_portal access.knowledge.article.favorite.portal knowledge.model_knowledge_article_favorite base.group_portal 1 1 1 1
12 access_knowledge_article_favorite_user access.knowledge.article.favorite.user knowledge.model_knowledge_article_favorite base.group_user 1 1 1 1
13 access_knowledge_article_favorite_system access.knowledge.article.favorite.system knowledge.model_knowledge_article_favorite base.group_system 1 1 1 1
14 access_knowledge_invite_all access.knowledge.invite.all knowledge.model_knowledge_invite 0 0 0 0
15 access_knowledge_invite_user access.knowledge.invite.user knowledge.model_knowledge_invite base.group_user 1 1 1 0
16 access_knowledge_invite_system access.knowledge.invite.system knowledge.model_knowledge_invite base.group_system 1 1 1 1
-79
View File
@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- ARTICLE -->
<record id="rule_knowledge_article_system" model="ir.rule">
<field name="name">Articles: System = CRUD on all articles</field>
<field name="model_id" ref="model_knowledge_article"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
</record>
<record id="rule_knowledge_article_users_read" model="ir.rule">
<field name="name">Articles: users/portal: read based on access</field>
<field name="model_id" ref="model_knowledge_article"/>
<field name="domain_force">[('user_has_access', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal'))]"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_knowledge_article_users_write" model="ir.rule">
<field name="name">Articles: users/portal: write based on flag</field>
<field name="model_id" ref="model_knowledge_article"/>
<field name="domain_force">[('user_has_write_access', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="False"/>
</record>
<!-- ARTICLE MEMBER -->
<record id="rule_knowledge_article_member_users" model="ir.rule">
<field name="name">Article members: users: read article members</field>
<field name="model_id" ref="model_knowledge_article_member"/>
<field name="domain_force">[('article_id.user_has_access', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_knowledge_article_member_portal" model="ir.rule">
<field name="name">Article members: portal: read own article members</field>
<field name="model_id" ref="model_knowledge_article_member"/>
<field name="domain_force">[('partner_id', '=', user.partner_id.id), ('article_id.user_has_access', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_knowledge_article_member_system" model="ir.rule">
<field name="name">Article members: System CRUD all</field>
<field name="model_id" ref="model_knowledge_article_member"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
</record>
<!-- ARTICLE FAVORITE -->
<record id="rule_knowledge_article_favorite_users" model="ir.rule">
<field name="name">Article favorite: users/portal: own + readable articles</field>
<field name="model_id" ref="model_knowledge_article_favorite"/>
<field name="domain_force">[('user_id', '=', user.id), ('article_id.user_has_access', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
</record>
<record id="rule_knowledge_article_favorite_system" model="ir.rule">
<field name="name">Article favorite: System CRUD all</field>
<field name="model_id" ref="model_knowledge_article_favorite"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
</record>
<!-- INVITE WIZARD -->
<record id="rule_knowledge_invite_system" model="ir.rule">
<field name="name">Invite: System invite members</field>
<field name="model_id" ref="model_knowledge_invite"/>
<field name="domain_force">[('article_id.user_has_write_access', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
</record>
</odoo>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="knowledge.LegacyEmojiList" t-inherit="mail.LegacyEmojiList" t-inherit-mode="primary" owl="1">
<xpath expr="//div[hasclass('o_LegacyEmojiList')]" position="before">
<div class="d-flex justify-content-end p-2">
<button class="btn btn-outline-danger w-100" t-on-click="onRemoveEmoji" title="Remove Icon">
<i class="fa fa-times-circle"/> Remove Icon
</button>
</div>
</xpath>
</t>
</templates>
@@ -1,60 +0,0 @@
/** @odoo-module **/
import emojis from '@mail/js/emojis';
const { Component } = owl;
class LegacyEmoji extends Component {
/**
* @override
*/
setup () {
// Mock the template variables:
this.className = '';
this.emojiListView = this.props.emojiListView;
}
}
LegacyEmoji.template = 'mail.LegacyEmoji';
LegacyEmoji.props = {
emojiView: Object
};
class EmojiPicker extends Component {
/**
* @override
*/
setup () {
// Mock the template variables:
this.className = '';
const viewEventHandlers = {
/**
* @param {Event} event
*/
onClickEmoji: event => {
this.props.onClickEmoji(event.target.dataset.unicode);
},
};
this.emojiListView = {
emojiViews: emojis.map((emoji, index) => {
return {
localId: index,
emoji: emoji,
emojiListView: viewEventHandlers
};
})
};
}
/**
* @param {Event} event
*/
onRemoveEmoji (event) {
this.props.onClickEmoji(false);
}
}
EmojiPicker.template = 'knowledge.LegacyEmojiList';
EmojiPicker.props = ['onClickEmoji'];
EmojiPicker.components = { LegacyEmoji };
export default EmojiPicker;
@@ -1,70 +0,0 @@
/** @odoo-module */
import { qweb as QWeb } from 'web.core';
import { DocumentSelector } from '@web_editor/components/media_dialog/document_selector';
import { TABS, MediaDialog } from '@web_editor/components/media_dialog/media_dialog';
import { patch } from '@web/core/utils/patch';
/**
* Override the @see DocumentSelector to manage files in a @see MediaDialog used
* by the /file command. The purpose of this override is to redefine the
* rendering of the media (/file block), and to merge images in the documents
* tab of the MediaDialog, since the /file block displays a default mimetype for
* every files.
*/
export class KnowledgeDocumentSelector extends DocumentSelector {
/**
* Filter files for the documents tab of the MediaDialog. Any file with a
* mimetype is valid. (images and documents are displayed together)
*
* @override
*/
get attachmentsDomain() {
const domain = super.attachmentsDomain;
return domain.map(d => {
if (d[0] === 'mimetype') {
return ['mimetype', '!=', false];
}
return d;
});
}
static async createElements(...args) {
const files = await DocumentSelector.createElements(...args);
return files.map(file => {
const extension = (file.title && file.title.split('.').pop()) || file.dataset.mimetype;
const fileEl = $(QWeb.render('knowledge.file_block', {
img: {
name: file.title,
extension: extension,
},
}))[0];
file.classList.add(...DocumentSelector.mediaSpecificClasses);
fileEl.querySelector('.o_knowledge_file_image').replaceChildren(file);
return fileEl;
});
}
}
KnowledgeDocumentSelector.mediaSpecificClasses = [];
KnowledgeDocumentSelector.tagNames = DocumentSelector.tagNames;
patch(TABS, 'knowledge_media_dialog_tabs', {
KNOWLEDGE_DOCUMENTS: {
...TABS.DOCUMENTS,
id: 'KNOWLEDGE_DOCUMENTS',
Component: KnowledgeDocumentSelector,
}
});
patch(MediaDialog.prototype, 'knowledge_media_dialog', {
/**
* @override
*/
addTabs() {
this._super(...arguments);
if (this.props.knowledgeDocuments) {
this.addTab(TABS.KNOWLEDGE_DOCUMENTS);
}
}
});
@@ -1,327 +0,0 @@
/** @odoo-module **/
import { session } from "@web/session";
import Dialog from 'web.Dialog';
import { _lt } from "@web/core/l10n/translation";
import { _t } from 'web.core';
import { useService } from '@web/core/utils/hooks';
const { Component, onWillStart, useState } = owl;
const permissionLevel = {'none': 0, 'read': 1, 'write': 2}
const restrictMessage = _lt("Are you sure you want to restrict this role and restrict access ? "
+ "This article will no longer inherit access settings from the parent page.");
const loseWriteMessage = _lt('Are you sure you want to remove you own "Write" access ?');
class PermissionPanel extends Component {
/**
* @override
*/
setup () {
this.rpc = useService('rpc');
this.state = useState({
loading: true,
partner_id: session.partner_id
})
onWillStart(this.loadPanel);
}
async loadPanel () {
const data = await this.loadData();
this.state = {
...this.state,
...data,
loading: false
};
this.render();
this._showPanel();
}
/**
* @returns {Object}
*/
loadData () {
return this.rpc({
route: '/knowledge/get_article_permission_panel_data',
params: {
article_id: this.props.article_id
}
});
}
/**
* @returns {Array[Array]}
*/
getInternalPermissionOptions () {
return this.state.internal_permission_options;
}
/**
* @param {Proxy} member
* @returns {Boolean}
*/
isLoggedUser (member) {
return member.partner_id === session.partner_id;
}
/**
* Opens the article with the given id
* @param {integer} id
*/
openArticle (id) {
this.env.bus.trigger('do-action', {
action: 'knowledge.ir_actions_server_knowledge_home_page',
options: {
additional_context: {
res_id: id
}
}
});
}
/**
* Callback function called when the internal permission of the article changes.
* @param {Event} event
*/
_onChangeInternalPermission (event) {
const $select = $(event.target);
const index = this.state.members.findIndex(current => {
return current.partner_id === session.partner_id;
});
const newPermission = $select.val();
const oldPermission = this.state.internal_permission;
const willRestrict = this.state.based_on && permissionLevel[newPermission] < permissionLevel[oldPermission]
&& permissionLevel[newPermission] < permissionLevel[this.state.parent_permission];
const willLoseAccess = $select.val() === 'none' && (index >= 0 && this.state.members[index].permission === 'none');
const confirm = () => {
this.rpc({
route: '/knowledge/article/set_internal_permission',
params: {
article_id: this.props.article_id,
permission: newPermission,
}
}).then(res => {
if (this._onChangedPermission(res, willLoseAccess)) {
this.loadPanel();
}
});
};
if (!willLoseAccess && !willRestrict) {
confirm();
return;
}
const discard = () => {
$select.val(oldPermission);
this.loadPanel();
};
const loseAccessMessage = _t('Are you sure you want to set the internal permission to "none" ? If you do, you will no longer have access to the article.');
this._showConfirmDialog(willLoseAccess ? loseAccessMessage : restrictMessage, confirm, discard);
}
/**
* Callback function called when the permission of a user changes.
* @param {Event} event
* @param {Proxy} member
*/
_onChangeMemberPermission (event, member) {
const index = this.state.members.indexOf(member);
if (index < 0) {
return;
}
const $select = $(event.target);
const newPermission = $select.val();
const oldPermission = member.permission;
const willLoseAccess = this.isLoggedUser(member) && newPermission === 'none';
const willRestrict = this.state.based_on && permissionLevel[newPermission] < permissionLevel[oldPermission];
const willLoseWrite = this.isLoggedUser(member) && newPermission !== 'write' && oldPermission === 'write';
const confirm = () => {
this.rpc({
route: '/knowledge/article/set_member_permission',
params: {
article_id: this.props.article_id,
permission: newPermission,
member_id: member.based_on ? false : member.id,
inherited_member_id: member.based_on ? member.id: false,
}
}).then(res => {
const reloadArticleId = willLoseWrite && !willLoseAccess ? this.props.article_id : false;
if (this._onChangedPermission(res, willLoseAccess||willLoseWrite, reloadArticleId)) {
this.loadPanel();
}
});
};
if (!willLoseAccess && !willRestrict && !willLoseWrite) {
confirm();
return;
}
const discard = () => {
$select.val(this.state.members[index].permission);
this.loadPanel();
};
const loseAccessMessage = _t('Are you sure you want to set your permission to "none"? If you do, you will no longer have access to the article.');
const message = willLoseAccess ? loseAccessMessage : willLoseWrite ? loseWriteMessage : loseAccessMessage;
this._showConfirmDialog(message, confirm, discard);
}
/**
* @param {Event} event
* @param {integer} id - article id
*/
_onOpen (event, id) {
event.preventDefault();
this.openArticle(id);
}
/**
* Callback function called when a member is removed.
* @param {Event} event
* @param {Proxy} member
*/
_onRemoveMember (event, member) {
if (!this.state.members.includes(member)) {
return;
}
const willRestrict = member.based_on ? true : false;
const willLoseAccess = this.isLoggedUser(member);
const confirm = () => {
this.rpc({
route: '/knowledge/article/remove_member',
params: {
article_id: this.props.article_id,
member_id: member.based_on ? false : member.id,
inherited_member_id: member.based_on ? member.id: false,
}
}).then(res => {
if (this._onChangedPermission(res, willLoseAccess)) {
this.loadPanel();
}
});
};
if (!willLoseAccess && !willRestrict) {
confirm();
return;
}
const loseAccessMessage = _t('Are you sure you want to leave this article? If you do, you will no longer have access to the article.');
const message = willLoseAccess ? loseAccessMessage : restrictMessage;
const discard = () => {
this.loadPanel();
};
this._showConfirmDialog(message, confirm, discard);
}
/**
* Callback function called when user clicks on 'Restore' button.
* @param {Event} event
*/
_onRestore (event) {
const articleId = this.props.article_id;
const confirm = () => {
this.rpc({
model: 'knowledge.article',
method: 'restore_article_access',
args: [[articleId]],
}).then(res => {
if (res) {
if (this._onChangedPermission({success: res})) {
this.loadPanel();
}
}
});
};
const message = _t('Are you sure you want to restore access?');
this._showConfirmDialog(message, confirm);
}
/**
* @param {Event} event
* @param {Proxy} member
*/
async _onMemberAvatarClick (event, member) {
if (!member.partner_share) {
const partnerRead = await this.rpc({
model: 'res.partner',
method: 'read',
args: [member.partner_id, ['user_ids']],
});
const userIds = partnerRead && partnerRead.length === 1 ? partnerRead[0]['user_ids'] : false;
const userId = userIds && userIds.length === 1 ? userIds[0] : false;
if (userId) {
const messaging = await this.env.services.messaging.get();
messaging.openChat({
userId: userId
});
}
}
}
/**
* This method is called before each permission change rpc when the user needs to confirm the change as them
* would lose them access to the article if them do confirm.
* @param {str} message
* @param {function} confirm
* @param {function} discard
*/
_showConfirmDialog (message, confirm, discard) {
if (discard === undefined) {
discard = this.loadPanel;
}
Dialog.confirm(this, message, {
buttons: [{
text: _t('confirm'),
classes: 'btn-primary',
close: true,
click: confirm
}, {
text: _t('Discard'),
close: true,
click: discard
}],
});
}
/**
* This method is called after each permission change rpc.
* It will check if a reloading of the article tree or a complete reload is needed in function
* of the new article state (if change of category or if user lost their own access to the current article).
* return True if the caller should continue after executing this method, and False, if caller should stop.
* @param {Dict} result
* @param {Boolean} lostAccess
*/
_onChangedPermission (result, reloadAll, reloadArticleId) {
if (result.error) {
Dialog.alert(this, result.error,{
title: _t("Error"),
});
} else if (reloadAll && reloadArticleId) { // Lose write access
this.openArticle(reloadArticleId);
return false;
} else if (reloadAll) { // Lose access -> Hard Reload
window.location.replace('/knowledge/home');
} else if (result.reload_tree) {
this.env.bus.trigger('reload_tree', {});
}
return true;
}
_showPanel () {
// TODO DBE: get permission panel with owl brol ??
const $permissionPanel = $('.o_knowledge_share_panel');
$permissionPanel.addClass('show');
$permissionPanel.parent().addClass('show');
}
}
PermissionPanel.template = 'knowledge.PermissionPanel';
PermissionPanel.props = [
'article_id',
'user_permission'
];
export default PermissionPanel;
@@ -1,39 +0,0 @@
.o_knowledge_permission_panel {
.o_m2m_avatar,
.o_company_icon {
width: 30px;
height: 30px;
}
.active {
background: #cceaf9;
}
.text-muted {
font-size: 0.85rem;
}
.o_member_email {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 150px;
}
.o_delete {
display: none;
}
div:hover {
.o_delete {
display: inline;
}
}
.o_permission {
&.readonly {
background: white;
cursor: default;
color: black;
border-color: #787878;
}
width:100px;
}
}
@@ -1,84 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="knowledge.PermissionPanel" owl="1">
<t t-if="state.loading">
<div class="py-3 text-center">
<i class="fa fa-circle-o-notch fa-2x fa-spin" title="loader" role="img"/>
</div>
</t>
<t t-else="">
<div t-if="!state.is_sync &amp;&amp; state.parent_name" class="d-flex align-items-center px-3 py-1 my-1 bg-light">
<span><i class="fa fa-eye"/> Access Restricted. May not be shared with everyone from
<a href="#" t-on-click="event => this._onOpen(event, state.parent_id)" t-out="state.parent_name"/></span>
<button t-if="props.user_permission === 'write' || state.user_is_admin"
class="btn btn-link" t-on-click="event => this._onRestore(event)" type="button">Restore</button>
</div>
<div class="d-flex align-items-center px-3 py-1">
<div class="flex-shrink-0">
<div class="o_company_icon rounded-circle d-flex justify-content-center align-items-center bg-light">
<i class="fa fa-building"/>
</div>
</div>
<div class="flex-grow-1 px-3">
Internal members
<div t-if="state.based_on" class="text-muted">Based on <a href="#" t-on-click="event => this._onOpen(event, state.based_on_id)" t-out="state.based_on"/></div>
</div>
<div class="flex-shrink-0 bg-white">
<select class="o_permission form-select"
t-att-value="state.internal_permission"
t-on-change="_onChangeInternalPermission"
t-att-disabled="state.user_is_admin || props.user_permission === 'write' ? undefined : 'disabled'"
aria-label="Internal Permission">
<t t-foreach="this.getInternalPermissionOptions()" t-as="option" t-key="option_index">
<option t-att-value="option[0]" t-out="option[1]" t-att-selected="option[0] === state.internal_permission ? 'selected' : undefined"/>
</t>
</select>
</div>
</div>
<t t-foreach="state.members" t-as="member" t-key="member_index">
<div t-attf-class="d-flex align-items-center px-3 py-1 {{ this.isLoggedUser(member) ? 'active' : '' }}">
<div class="flex-shrink-0">
<img class="rounded-circle o_m2m_avatar" t-if="member.partner_id"
t-attf-src="/web/image?model=res.partner&amp;field=avatar_128&amp;id={{ member.partner_id }}"
t-on-click="event => this._onMemberAvatarClick(event, member)"/>
</div>
<div class="flex-grow-1 px-3">
<div t-out="member.partner_name"/>
<div class="d-flex align-items-center text-muted">
<span t-if="member.partner_share"><i class="fa fa-user pe-1" title="External Guest"/>Guest</span>
<span t-if="state.user_is_admin &amp;&amp; this.isLoggedUser(member)"><i class="fa fa-cog pe-1" title="Administrator"/>Admin</span>
<span t-if="(member.partner_share || (state.user_is_admin &amp;&amp; this.isLoggedUser(member))) &amp;&amp; member.partner_email" class="px-1">•</span>
<div class="o_member_email" t-if="member.partner_email" t-out="member.partner_email"/>
</div>
<div t-if="member.based_on" class="text-muted">Based on <a href="#" t-on-click="event => this._onOpen(event, member.based_on_id)" t-out="member.based_on"/></div>
</div>
<div class="flex-shrink-0">
<div class="input-group">
<div t-if="!(member.based_on &amp;&amp; member.permission === 'none') &amp;&amp; !member.is_unique_writer
&amp;&amp; (state.user_is_admin || props.user_permission === 'write' || state.partner_id === member.partner_id)"
class="d-flex align-items-center me-2">
<i class="fa fa-w fa-times o_delete text-danger" title="Remove Member"
t-on-click="event => this._onRemoveMember(event, member)" type="button">
</i>
</div>
<select t-attf-class="o_permission form-select {{ member.partner_share ? 'readonly' : '' }}"
t-att-value="member.permission"
t-on-change="event => this._onChangeMemberPermission(event, member)"
t-att-disabled="member.is_unique_writer || member.partner_share
|| (props.user_permission !== 'write' &amp;&amp; !state.user_is_admin) ? 'disabled' : undefined"
aria-label="User Permission">
<t t-foreach="this.state.members_options" t-as="option" t-key="option_index">
<option t-att-value="option[0]" t-out="option[1]" t-att-selected="option[0] === member.permission ? 'selected' : undefined"/>
</t>
</select>
</div>
</div>
</div>
</t>
<div t-if="state.show_admin_tip" class="d-flex align-items-center px-3 py-1 my-1 bg-light text-muted">
<i><i class="fa fa-cog me-2"/>As an administrator, you can always modify this article.</i>
</div>
</t>
</t>
</templates>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

@@ -1,85 +0,0 @@
/** @odoo-module */
import FieldHtml from 'web_editor.field.html';
import {FieldHtmlInjector} from './knowledge_field_html_injector';
import {KnowledgePlugin} from './knowledge_plugin';
FieldHtml.include({
/**
* @override
*/
_renderReadonly: function () {
const prom = this._super.apply(this, arguments);
if (prom) {
return prom.then(function () {
return this._addFieldHtmlInjector();
}.bind(this));
} else {
return this._addFieldHtmlInjector();
}
},
/**
* Add a @see FieldHtmlInjector to this field_html, which will add temporary
* @see KnowledgeBehavior and/or @see KnowledgeToolbar to @see OdooEditor
* blocks. Delegate control of the editor to the injector, mainly to access
* history methods. i.e.: @see KnowledgeToolbar insertion in the dom should
* not be registered in the user history
*
* @returns {Promise}
*/
_addFieldHtmlInjector: function () {
if (this.$content && this.$content.length) {
const editor = (this.mode === 'edit' && this.wysiwyg) ? this.wysiwyg.odooEditor : null;
const fieldHtmlInjector = new FieldHtmlInjector(this, this.mode, this.$content[0], editor);
return fieldHtmlInjector.appendTo(this.el);
}
},
/**
* Some events can cause the insertion of new elements which may need a
* @see KnowledgeBehavior and/or @see KnowledgeToolbar , so the
* @see FieldHtmlInjector needs to be notified.
*
* @override
*/
_onLoadWysiwyg: function () {
this._super.apply(this, arguments);
this._addFieldHtmlInjector();
/**
* setTimeout is called so that the refresh_injector event is dispatched
* at the start of the next event cycle. This is to ensure that other
* handlers for those events have execution priority over the refresh.
* In particular, the content to drop in the editor would be inserted
* after the refreshInjector without setTimeout.
*/
const refreshInjector = () => setTimeout(this.refreshInjector.bind(this));
this.wysiwyg.odooEditor.addEventListener('historyUndo', refreshInjector);
this.wysiwyg.odooEditor.addEventListener('historyRedo', refreshInjector);
this.$content[0].addEventListener('paste', refreshInjector);
this.$content[0].addEventListener('drop', refreshInjector);
},
/**
* Add a plugin (@see KnowledgePlugin ) for the @see OdooEditor which
* implements @see cleanForSave in order to remove @see KnowledgeToolbar
* before saving a record.
*
* @override
*/
_getWysiwygOptions: function () {
const options = this._super.apply(this, arguments);
if (Array.isArray(options.editorPlugins)) {
options.editorPlugins.push(KnowledgePlugin);
} else {
options.editorPlugins = [KnowledgePlugin];
}
return options;
},
/**
* Notify the @see FieldHtmlInjector to render and apply KnowledgeToolbar
* and KnowledgeBehavior where it is needed.
*/
refreshInjector: function () {
if (this.$content) {
this.$content.trigger('refresh_injector');
}
},
});
@@ -1,192 +0,0 @@
/** @odoo-module **/
import FormController from 'web.FormController';
import { Domain } from '@web/core/domain';
FormController.include({
/**
* Knowledge articles can interact with some records with the help of the
* @see KnowledgeService. Those records need to have a field whose name is
* in @see knowledgeTriggerFieldNames. This list is ordered and the first
* match found in a record will take precedence. Once a match is found, it
* is stored in the KnowledgeService to be accessed later by an article.
*
* @override
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
this.knowledgeRecordFieldNames = [
'note', 'memo', 'description', 'comment', 'narration', 'additional_note', 'internal_notes', 'notes'
];
if (this._isPotentialKnowledgeRecord()) {
this._searchKnowledgeRecord();
}
if (this.ignoreKnowledgeRecordSearch) {
this._unregisterObsoleteKnowledgeRecords(this.renderer.breadcrumbs);
}
},
/**
* Since Knowledge toolbars are cleaned before saving the record
* @see KnowledgePlugin , the renderer needs to render them again once it is
* done.
*
* @override
* @param {string} recordID
*/
saveRecord: async function (recordID) {
const changedFields = this._super(...arguments);
recordID = recordID || this.handle;
if (recordID) {
changedFields.then(() => {
this.renderer.refreshFieldHtmlInjectors(recordID);
});
}
return changedFields;
},
/**
* Notify the @see KnowledgeService that the record was updated
*
* @override
*/
update: async function (params, options) {
await this._super(...arguments);
if (this._isPotentialKnowledgeRecord()) {
this._searchKnowledgeRecord();
}
if (this.ignoreKnowledgeRecordSearch) {
this._unregisterObsoleteKnowledgeRecords(this.renderer.breadcrumbs);
}
},
/**
* Evaluate the provided breadcrumbs sequence against currently recorded
* records in @see KnowledgeService to remove them if they are obsolete.
* Breadcrumb information does not contain the res_id of the record for a
* form view, and the actionService does not allow to access the breadcrumb
* controller externally. This is why the full breadcrumb string sequence is
* used here as an identifier.
*
* @param {Array} breadcrumbs
* @param {boolean} revoke whether the record is deemed obsolete if a match
* is found or not.
*/
_unregisterObsoleteKnowledgeRecords: function (breadcrumbs, revoke = false) {
const records = this.call('knowledgeService', 'getRecords');
let isObsolete = revoke;
for (let record of records) {
if (record.breadcrumbs.length > breadcrumbs.length) {
isObsolete = !revoke;
} else {
const slicedBreadcrumbs = breadcrumbs.slice(0, record.breadcrumbs.length);
if (_.isEqual(slicedBreadcrumbs, record.breadcrumbs)) {
isObsolete = revoke;
} else {
isObsolete = !revoke;
}
}
if (isObsolete) {
this.call('knowledgeService', 'unregisterRecord', record);
}
}
},
/**
* Conditions for a record to be usable by a Knowledge article:
* - The current view is not a Knowledge form
* - has a ControlPanel which uses breadcrumbs
* - is the result of an action
* - displays an existing record (already stored in DB)
* (saved new records will be registered upon @see update )
*/
_isPotentialKnowledgeRecord: function () {
return !this.ignoreKnowledgeRecordSearch && this.withControlPanel && this.controlPanelProps.withBreadcrumbs &&
this.controlPanelProps.action && this.controlPanelProps.action.controllers &&
this.handle && !this.model.localData[this.handle].isNew();
},
/**
* @param {Object} record raw record
* @param {string} modifier modifier as registered in the view (xml)
* @returns {boolean} whether the domain includes this record
*/
_readModifier: function (record, modifier) {
if (!modifier) {
// falsy modifier
return false;
}
let value = false;
try {
// unaware of context
const preDomain = new Domain(modifier);
// aware of context
const domain = new Domain(preDomain.toList(record.context));
value = domain.contains(record.data);
} catch (_error) {
// truthy modifier
return true;
}
return value;
},
/**
* Evaluate the current record and notify @see KnowledgeService if it can or
* could be used in a Knowledge article.
*
* @private
*/
_searchKnowledgeRecord: function () {
const record = this.model.get(this.handle, {raw: true});
const controller = this.controlPanelProps.action.controllers.form;
const breadcrumbs = this.controlPanelProps.breadcrumbs.slice();
// add the current breadcrumbs information
breadcrumbs.push({
title: record.data.display_name,
controllerID: controller.jsId,
});
/**
* If the current potential record has exactly the same breadcrumbs
* sequence as another record registered in the @see KnowledgeService,
* the previous record should be unregistered here because this problem
* will not be caught later, as the Knowledge form view only checks
* whether its breadcrumbs sequence contains a record's breadcrumbs
* sequence, regardless of the fact that the current potential record
* may not have been registered in the service.
*
* This call could be omitted if the breadcrumbs would also store the
* related res_id if any, but currently two records with the same
* display name and model will have exactly the same breadcrumbs
* information (controllerID and title).
*/
this._unregisterObsoleteKnowledgeRecords(breadcrumbs, true);
const hasMessageIds = this.renderer.chatterFields && this.renderer.chatterFields.hasMessageIds;
const view = this.controlPanelProps.view;
const fields = view.viewFields;
const formFields = view.fieldsInfo.form;
const knowledgeRecord = {
res_id: record.res_id,
res_model: view.model,
breadcrumbs: breadcrumbs,
fieldNames: [],
};
if (hasMessageIds) {
knowledgeRecord.withChatter = true;
this.call('knowledgeService', 'registerRecord', knowledgeRecord);
}
for (let fieldName of this.knowledgeRecordFieldNames) {
if (fieldName in formFields && fields[fieldName].type === 'html' && !fields[fieldName].readonly) {
const readonlyModifier = formFields[fieldName].modifiers.readonly;
const invisibleModifier = formFields[fieldName].modifiers.invisible;
if (this._readModifier(record, readonlyModifier) || this._readModifier(record, invisibleModifier)) {
continue;
}
knowledgeRecord.fieldNames.push({
name: fieldName,
string: fields[fieldName].string,
});
}
}
/**
* While a field by itself can be visible and not readonly, its
* group / notebook pane could be invisible. Another check will be done
* after rendering to determine if any of the catched fields are in fact
* usable by the user. @see FormRenderer
*/
this.call('knowledgeService', 'pushToValidateWithHtmlField', knowledgeRecord);
},
});
@@ -1,77 +0,0 @@
/** @odoo-module **/
import FormRenderer from 'web.FormRenderer';
import core from 'web.core';
FormRenderer.include({
/**
* Refresh the injector of html fields (to update toolbars and behaviors)
* @see FieldHtmlInjector
*
* @param {string} recordID
*/
refreshFieldHtmlInjectors: function (recordID) {
const recordWidgets = this.allFieldWidgets[recordID];
if (!recordWidgets) {
return;
}
recordWidgets.filter(widget => widget.field.type === 'html' && typeof widget.refreshInjector === 'function')
.forEach(widget => {
widget.refreshInjector();
});
},
/**
* Once the view is rendered, check whether a record's field registered in
* @see KnowledgeService is available to the user directly or through
* an inactive pane of the notebook.
*
* @override
*/
_render: async function () {
await this._super(...arguments);
core.bus.once('DOM_updated', this, function () {
let record = this.call('knowledgeService', 'popToValidateWithHtmlField');
while (record) {
for (let fieldName = record.fieldNames.shift(); fieldName; fieldName = record.fieldNames.shift()) {
if (this._checkAvailability(fieldName.name)) {
record.fieldNames = [fieldName];
record.withHtmlField = true;
break;
}
}
if (record.withHtmlField) {
this.call('knowledgeService', 'registerRecord', record);
break;
}
record = this.call('knowledgeService', 'popToValidateWithHtmlField');
}
});
},
/**
* Inspect the view to check whether the field matching "name" is accessible
* in the view at least once, directly or through a notebook pane switch.
*
* @param {string} name of a field of the record
* @returns {boolean}
*/
_checkAvailability: function (name) {
const selector = `.oe_form_field_html[name="${name}"]`;
const $sel = $(selector);
for (let i = 0; i < $sel.length; i++) {
if ($sel.eq(i).is(':visible:hasVisibility')) {
return true;
} else if ($sel[i].closest('.o_invisible_modifier')) {
continue;
} else {
let pane = $sel[i].closest('.tab-pane:not(.active)');
if (pane) {
let $paneSwitch = $(`[data-bs-toggle="tab"][href*="${pane.id}"]`);
if ($paneSwitch.is(':visible:hasVisibility')) {
return true;
}
}
}
}
return false;
}
});
@@ -1,166 +0,0 @@
/** @odoo-module */
import Class from 'web.Class';
import core from 'web.core';
import Dialog from "web.Dialog";
const _t = core._t;
/**
* Behavior to be injected through @see FieldHtmlInjector to @see OdooEditor
* blocks which have specific classes calling for such behaviors.
*
* A typical usage could be the following:
* - An @see OdooEditor block like /template has the generic class:
* @see o_knowledge_behavior_anchor to signify that it needs to have a
* behavior injected.
* - This block also has the specific class:
* @see o_knowledge_behavior_type_[behaviorType] which specifies the type of
* behavior that needs to be injected. @see FieldHtmlInjector has a dictionary
* mapping those classes to the correct behavior class.
*
* The @see KnowledgeBehavior is a basic behavior intended to be overriden for
* more complex implementations
*/
const KnowledgeBehavior = Class.extend({
/**
* @param {Widget} handler @see FieldHtmlInjector which has access to
* widget specific functions
* @param {Element} anchor dom node to apply the behavior to
* @param {string} mode edit/readonly
*/
init: function (handler, anchor, mode) {
this.handler = handler;
this.anchor = anchor;
this.mode = mode;
if (this.handler.editor) {
this.handler.editor.observerUnactive('knowledge_attributes');
}
this.applyAttributes();
if (this.handler.editor) {
this.handler.editor.observerActive('knowledge_attributes');
}
this.applyListeners();
},
/**
* Add specific attributes related to this behavior to this.anchor
*/
applyAttributes: function () {},
/**
* Add specific listeners related to this behavior to this.anchor
*/
applyListeners: function () {},
/**
* Disable the listeners added in @see applyListeners
*/
disableListeners: function () {},
/**
* Used by @see KnowledgePlugin to remove behaviors when the field_html is
* saved. Also used by @see FieldHtmlInjector to manage injected behaviors
*/
removeBehavior: function () {
this.handler.trigger_up('behavior_removed', {
anchor: this.anchor,
});
this.disableListeners();
delete this.anchor.oKnowledgeBehavior;
},
});
/**
* A behavior to set a block as uneditable. Such a block can have children
* marked as @see o_knowledge_content which are set as editable
*/
const ContentsContainerBehavior = KnowledgeBehavior.extend({
/**
* @override
*/
applyAttributes: function () {
this._super.apply(this, arguments);
if (this.mode === 'edit') {
this.anchor.querySelectorAll('.o_knowledge_content').forEach(element => {
element.setAttribute('contenteditable', 'true');
});
this.anchor.setAttribute('contenteditable', 'false');
}
},
});
/**
* A behavior for the /article command @see Wysiwyg
*/
const ArticleBehavior = KnowledgeBehavior.extend({
/**
* @override
*/
init: function () {
this.busy = false;
this.linkClickHandler = async function (ev) {
if (this.busy) {
ev.preventDefault();
ev.stopPropagation();
} else {
this.busy = true;
await this._onLinkClick(ev);
this.busy = false;
}
}.bind(this);
this.linkDblClickHandler = function (ev) {
ev.preventDefault();
ev.stopPropagation();
};
this._super.apply(this, arguments);
},
/**
* @override
*/
applyAttributes: function () {
this._super.apply(this, arguments);
if (this.mode === 'edit') {
this.anchor.setAttribute('contenteditable', 'false');
}
},
/**
* @override
*/
applyListeners: function () {
this._super.apply(this, arguments);
this.anchor.addEventListener("click", this.linkClickHandler);
this.anchor.addEventListener("dblclick", this.linkDblClickHandler);
},
/**
* @override
*/
disableListeners: function () {
this.anchor.removeEventListener("click", this.linkClickHandler);
this.anchor.removeEventListener("dblclick", this.linkDblClickHandler);
},
/**
* When the user clicks on an article link, we can directly open the
* article in the current view without having to reload the page.
*
* @param {Event} event
*/
_onLinkClick: async function (event) {
const res_id = parseInt(event.currentTarget.dataset.res_id);
if (res_id) {
event.stopPropagation();
event.preventDefault();
const actionPromise = this.handler.do_action('knowledge.ir_actions_server_knowledge_home_page', {
additional_context: {
res_id: res_id
}
});
await actionPromise.catch(() => {
Dialog.alert(this,
_t("This article was deleted or you don't have the rights to access it."), {
title: _t('Error'),
});
});
}
},
});
export {
KnowledgeBehavior,
ContentsContainerBehavior,
ArticleBehavior,
};
@@ -1,8 +0,0 @@
/** @odoo-module */
import { CLIPBOARD_WHITELISTS } from '@web_editor/js/editor/odoo-editor/src/OdooEditor';
/**
* Allow @see o_knowledge_ classes to be preserved in the editor on paste.
*/
CLIPBOARD_WHITELISTS.classes.push(/^o_knowledge_/);
@@ -1,446 +0,0 @@
/** @odoo-module */
import { _t, bus } from 'web.core';
import Dialog from 'web.Dialog';
import FormController from 'web.FormController';
import { MoveArticleToDialog } from './widgets/knowledge_dialogs.js';
import emojis from '@mail/js/emojis';
const disallowedEmojis = ['💩', '👎', '💔', '😭', '😢', '😐', '😕', '😞', '😢', '💀'];
const emojisRandomPickerSource = emojis.filter(emoji => !disallowedEmojis.includes(emoji.unicode));
const KnowledgeArticleFormController = FormController.extend({
events: Object.assign({}, FormController.prototype.events, {
'click .o_knowledge_add_icon': '_onAddRandomIcon',
'click .o_knowledge_add_cover': '_onAddCover',
'click #knowledge_search_bar': '_onSearch',
'click .o_breadcrumb_article_name': '_onArticleBreadcrumbClick',
'change .o_breadcrumb_article_name': '_onRename',
'click i.o_toggle_favorite': '_onToggleFavorite',
'input .o_breadcrumb_article_name': '_adjustInputSize',
}),
custom_events: Object.assign({}, FormController.prototype.custom_events, {
create: '_onCreate',
duplicate: '_onDuplicate',
move: '_onMove',
open_move_to_modal: '_onOpenMoveToModal',
reload_tree: '_onReloadTree',
emoji_click: '_onEmojiClick',
}),
/**
* Register the fact that the current @see FormController is one from
* Knowledge in order not to try and search for a matching record for
* @see KnowledgeService .
*
* @override
*/
init: function (parent, model, renderer, params) {
/**
* This property is used to specify that the current form view will
* get/use records stored in the @see KnowledgeService instead of
* replacing them (default). TODO ABD: maybe set this as an option of
* the form view (XML) so that other form views can easily specify that
* they will get/use records from the KnowledgeService instead of
* providing them.
*/
this.ignoreKnowledgeRecordSearch = true;
this.renderer = renderer;
this._super.apply(this, arguments);
this.onFieldSavedListeners = new Map();
},
/**
* If a reload is called with param 'keepChanges', only the chatter needs
* to be updated, no need for a full reload
*
* @override
*/
reload: function (params) {
if (params && params.keepChanges) {
return this.renderer.updateChatter();
} else {
return this._super.apply(this, arguments);
}
},
/**
* @override
*/
saveRecord: async function () {
const modifiedFields = await this._super(...arguments);
const { data } = this.model.get(this.handle);
for (const field of modifiedFields) {
if (this.onFieldSavedListeners.has(field)) {
this.onFieldSavedListeners.get(field).forEach(listener => {
listener.call(this, data[field]);
});
}
}
return modifiedFields;
},
/**
* @override
* @returns {Promise}
*/
start: function () {
return this._super.apply(this, arguments).then(() => {
this._onFieldSaved('icon', unicode => {
const { id } = this.getState();
this.renderer._setEmoji(id, unicode);
});
});
},
// Listeners:
/**
* @param {Event} event
*/
_adjustInputSize: function (event) {
event.target.setAttribute('size', event.target.value.length);
},
_onAddCover: async function() {
if (this.mode === 'readonly') {
await this._setMode('edit');
}
this.$('.o_input_file').click();
},
_onAddRandomIcon: function() {
this.trigger_up('field_changed', {
dataPointID: this.handle,
changes: {
'icon': emojisRandomPickerSource[Math.floor(Math.random() * emojisRandomPickerSource.length)].unicode,
}
});
},
/**
* When the user clicks on the name of the article, checks if the article
* name hasn't been set yet. If it hasn't, it will look for a title in the
* body of the article and set it as the name of the article.
* @param {Event} event
*/
_onArticleBreadcrumbClick: async function (event) {
const name = event.currentTarget.value;
if (name === _t('New Article')) {
const $articleTitle = this.$('.o_knowledge_editor h1');
if ($articleTitle.text().length !== 0) {
this.$('.o_breadcrumb_article_name').val($articleTitle.text());
this.trigger_up('field_changed', {
dataPointID: this.handle,
changes: {
'name': $articleTitle.text(),
}
});
this._rename(await this._getId(), $articleTitle.text());
}
}
},
/**
* @param {OdooEvent} event
*/
_onCreate: async function (event) {
await this._create(event.data);
},
/**
* @param {OdooEvent} event
*/
_onDuplicate: async function (event) {
const handle = await this.model.duplicateRecord(this.handle);
const { res_id } = this.model.get(handle);
this.do_action('knowledge.ir_actions_server_knowledge_home_page', {
additional_context: { res_id }
});
},
/**
* @param {Event} event
*/
_onEmojiClick: async function (event) {
const { id } = this.getState();
const { articleId, unicode } = event.data;
if (articleId === id) {
this.trigger_up('field_changed', {
dataPointID: this.handle,
changes: {
icon: unicode
}
});
} else {
const result = await this._rpc({
model: 'knowledge.article',
method: 'write',
args: [[articleId], { icon: unicode }],
});
if (result) {
this.renderer._setEmoji(articleId, unicode);
}
}
},
/**
* @override
* @param {Event} event
*/
_onFieldChanged: async function (event) {
this._super(...arguments);
const { changes } = event.data;
for (const field of this._getFieldsToForceSave()) {
if (changes.hasOwnProperty(field)) {
await this.saveRecord(this.handle, {
reload: false,
stayInEdit: true
});
return;
}
}
},
/**
* @param {String} name - field name
* @param {Function} callback
*/
_onFieldSaved: function (name, callback) {
if (this.onFieldSavedListeners.has(name)) {
this.onFieldSavedListeners.get(name).push(callback);
} else {
this.onFieldSavedListeners.set(name, [callback]);
}
},
/**
* When the user clicks on a field in readonly mode, a new 'quick_edit' event
* will be triggered. To prevent the view from switching to the edit mode when
* the article is locked, we will overwrite the `_onQuickEdit` handler. This
* function will now ignore the event if the article is locked.
* @override
*/
_onQuickEdit: function () {
const { data } = this.model.get(this.handle);
if (data.is_locked) {
return;
}
this._super.apply(this, arguments);
},
/**
* @param {Event} event
*/
_onMove: function (event) {
this._confirmMove(event.data);
},
/**
* Opens the "Move To" modal
* @param {OdooEvent} event
*/
_onOpenMoveToModal: async function (event) {
const id = await this._getId();
const state = this.model.get(this.handle);
const dialog = new MoveArticleToDialog(this, {}, {
state: state,
/**
* @param {String} value
*/
onSave: async value => {
const params = { article_id: id };
if (typeof value === 'number') {
params.target_parent_id = value;
} else {
params.newCategory = value;
params.oldCategory = state.data.category;
}
this._confirmMove({...params,
onSuccess: () => {
dialog.close();
this.reload();
},
onReject: () => {}
});
}
});
dialog.open();
},
/**
* @param {Event} event
*/
_onReloadTree: function (event) {
// TODO JBN: Create a widget for the tree and reload it without reloading the whole view.
this.reload();
},
/**
* Callback function called when the user renames the active article.
* The function will update the name of the articles in the aside block.
* @param {Event} event
*/
_onRename: async function (event) {
var name = event.currentTarget.value;
if (name.length === 0) {
name = _t('New Article');
}
const id = await this._getId();
await this._rename(id, name);
},
/**
* @param {Event} event
*/
_onSearch: function (event) {
// TODO: change to this.env.services.commandes.openMainPalette when form views are migrated to owl
bus.trigger("openMainPalette", {
searchValue: "?",
});
},
/**
* @param {Event} event
*/
_onToggleFavorite: async function (event) {
const id = await this._getId();
const active = await this._toggleFavorite(id);
const $target = $(event.target);
$target.toggleClass('fa-star-o', !active);
$target.toggleClass('fa-star', active);
$target.attr('title', active ? _t('Remove from favorites') : _t('Add to favorites'));
this._rpc({
route: '/knowledge/tree_panel/favorites',
params: {
active_article_id: id,
}
}).then(template => {
const $dom = $(template);
this.$(".o_favorite_container").replaceWith($dom);
this.renderer._setTreeFavoriteListener();
this.renderer._renderEmojiPicker($dom);
});
},
// API calls:
/**
* @param {Object} data
* @param {integer} data.article_id
* @param {String} data.oldCategory
* @param {String} data.newCategory
* @param {integer} [data.target_parent_id]
* @param {integer} [data.before_article_id]
* @param {Function} data.onSuccess
* @param {Function} data.onReject
*/
_confirmMove: async function (data) {
data['params'] = {
is_private: data.newCategory === 'private'
};
if (typeof data.target_parent_id !== 'undefined') {
data['params'].parent_id = data.target_parent_id;
}
if (typeof data.before_article_id !== 'undefined') {
data['params'].before_article_id = data.before_article_id;
}
if (data.newCategory === data.oldCategory) {
await this._move(data);
} else {
let message, confirmation_message;
if (data.newCategory === 'workspace') {
message = _t("Are you sure you want to move this article to the Workspace? It will be shared with all internal users.");
confirmation_message = _t("Move to Workspace");
} else if (data.newCategory === 'private') {
message = _t("Are you sure you want to move this to private? Only you will be able to access it.");
confirmation_message = _t("Set as Private");
}
Dialog.confirm(this, message, {
cancel_callback: data.onReject,
buttons: [{
text: confirmation_message,
classes: 'btn-primary',
close: true,
click: async () => {
await this._move(data);
}
}, {
text: _t("Discard"),
close: true,
click: data.onReject,
}],
});
}
},
/**
* @param {Object} data
* @param {String} data.category
* @param {integer} data.target_parent_id
*/
_create: async function (data) {
const articleId = await this._rpc({
model: 'knowledge.article',
method: 'article_create',
args: [[]],
kwargs: {
is_private: data.category === 'private',
parent_id: data.target_parent_id ? data.target_parent_id : false
},
});
if (!articleId) {
return;
}
this.do_action('knowledge.ir_actions_server_knowledge_home_page', {
stackPosition: 'replaceCurrentAction',
additional_context: {
res_id: articleId
}
});
},
/**
* @returns {Array[String]}
*/
_getFieldsToForceSave: function () {
return ['full_width', 'icon', 'cover'];
},
/**
* @returns {integer}
*/
_getId: async function () {
let state = this.getState();
if (typeof state.id === 'undefined') {
await this.saveRecord(this.handle);
state = this.getState();
}
return state.id;
},
/**
* @param {Object} data
* @param {integer} data.article_id
* @param {Function} data.onSuccess
* @param {Function} data.onReject
* @param {Object} data.params
* @return {Promise}
*/
_move: function (data) {
return this._rpc({
model: 'knowledge.article',
method: 'move_to',
args: [data.article_id],
kwargs: data.params
}).then(result => {
if (result) {
data.onSuccess();
} else {
data.onReject();
}
}).catch(error => {
data.onReject();
})
},
/**
* @param {integer} id - Target id
* @param {string} name - Target Name
*/
_rename: async function (id, name) {
this.$(`.o_knowledge_tree .o_article[data-article-id="${id}"] > .o_article_handle > .o_article_name`).text(name);
this.$(`.o_breadcrumb_article_name`).val(name);
},
/**
* @param {integer} id - article id
* @returns {Promise}
*/
_toggleFavorite: function (id) {
return this._rpc({
model: 'knowledge.article',
method: 'action_toggle_favorite',
args: [id]
});
},
});
export {
KnowledgeArticleFormController,
};
@@ -1,317 +0,0 @@
/** @odoo-module */
import Widget from 'web.Widget';
import { ComponentWrapper, WidgetAdapterMixin } from 'web.OwlCompatibility';
import { useService } from "@web/core/utils/hooks";
import { TemplateToolbar, FileToolbar } from './knowledge_toolbars';
import { ArticleBehavior, ContentsContainerBehavior } from './knowledge_behaviors';
const { Component } = owl;
/**
* Component used to access the @see uiService
*/
class FieldHtmlInjectorComponent extends Component {
setup() {
this.uiService = useService('ui');
}
}
FieldHtmlInjectorComponent.template = 'knowledge.injector_component';
/**
* Widget used along @see FieldHtml to inject specific @see KnowledgeBehavior
* and/or @see KnowledgeToolbar for specific blocks elements. These behaviors
* can be used in both edit and readonly modes, and will contribute to a more
* interactive @see FieldHtml . Toolbars added through this injector will be
* cleaned before save through the @see KnowledgePlugin and won't be part of
* the field final value.
*/
const FieldHtmlInjector = Widget.extend(WidgetAdapterMixin, {
custom_events: {
toolbar_removed: '_onToolbarRemoved',
behavior_removed: '_onBehaviorRemoved',
},
/**
* Map classes used in @see OdooEditor blocks to their corresponding
* @see KnowledgeToolbar and template.
*/
toolbar_types: {
o_knowledge_toolbar_type_template: {
template: 'knowledge.template_toolbar',
Toolbar: TemplateToolbar,
},
o_knowledge_toolbar_type_file: {
template: 'knowledge.file_toolbar',
Toolbar: FileToolbar,
},
},
/**
* Map classes used in @see OdooEditor blocks to their corresponding
* @see KnowledgeBehavior
*/
behavior_types: {
o_knowledge_behavior_type_contents_container: {
Behavior: ContentsContainerBehavior,
},
o_knowledge_behavior_type_article: {
Behavior: ArticleBehavior,
},
},
/**
* @override
* @param {Widget} parent
* @param {string} mode edit/readonly
* @param {Element} field content of the @see FieldHtml
* @param {Object} editor @see OdooEditor
*/
init: function (parent, mode, field, editor) {
this._super.apply(this, arguments);
// store every toolbar anchor elements
this.toolbar_anchors = new Set();
// store every behavior anchor elements
this.behavior_anchors = new Set();
this.mode = mode;
this.field = field;
this.editor = editor;
},
/**
* Start the injection process and setup injection event listeners.
* The component is used to access uiService. This widget should be
* converted to a component. TODO ABD
* @see owl_compatibility
*
* @override
*/
start: function () {
const prom = this._super.apply(this, arguments);
this.component = new ComponentWrapper(this, FieldHtmlInjectorComponent, {});
const componentPromise = this.component.mount(this.el);
$(this.field).on('refresh_injector', this._onRefreshInjector.bind(this));
return Promise.all([prom, componentPromise]).then(function() {
this.manageBehaviors();
return this.manageToolbars();
}.bind(this));
},
/**
* @see owl_compatibility
*/
update: function () {
return this.component.update({});
},
/**
* Setup the behavior injection process
*/
manageBehaviors: function() {
$(this.field).on('refresh_behaviors', this._onRefreshBehaviors.bind(this));
this.updateBehaviors();
},
/**
* Setup the toolbar injection process
*
* @returns {Promise} promise to append the initial batch of
* @see KnowledgeToolbar
*/
manageToolbars: function() {
$(this.field).on('refresh_toolbars', this._onRefreshToolbars.bind(this));
return this.updateToolbars();
},
/**
* Scan this.field for every Toolbar container and anchor. Remove and
* destroy currently registered obsolete toolbars.
*
* @param {Array[Object]} toolbarsData array to be filled with data
* currently present in this.field
*/
_scanFieldForToolbars: function (toolbarsData) {
const anchors = new Set();
const types = new Set(Object.getOwnPropertyNames(this.toolbar_types));
this.field.querySelectorAll('.o_knowledge_toolbars_container').forEach(function (types, container) {
container.querySelectorAll('.o_knowledge_toolbar_anchor').forEach(function (types, container, anchor) {
const type = Array.from(anchor.classList).find(className => types.has(className));
if (type) {
toolbarsData.push({
container: container,
anchor: anchor,
type: type,
});
anchors.add(anchor);
}
}.bind(this, types, container));
}.bind(this, types));
// difference between the stored set and the computed one
const differenceAnchors = new Set([...this.toolbar_anchors].filter(anchor => !anchors.has(anchor)));
// remove obsolete toolbars
differenceAnchors.forEach(anchor => {
if (anchor.oKnowledgeToolbar) {
anchor.oKnowledgeToolbar.removeToolbar();
}
});
},
/**
* If toolbarsData is set, update only those toolbars, if not, recompute
* every Toolbar of this.field
*
* @param {Array[Object]} toolbarsData
* @param {Element} [container] element which has a toolbar anchor
* @param {Element} [anchor] element in which to inject the toolbar
* @param {string} [type] html class representing the toolbar type
* (i.e.: @see o_knowledge_toolbar_type_... )
* @returns {Promise} promise to append toolbars
*/
updateToolbars: function (toolbarsData = []) {
if (this.editor) {
this.editor.observerUnactive();
}
if (!toolbarsData.length) {
// no toolbarsData, recreate the array from the field value
this._scanFieldForToolbars(toolbarsData);
}
// inject new toolbars
const promises = [];
toolbarsData.forEach(toolbarData => {
const anchor = toolbarData.anchor;
if (!anchor.oKnowledgeToolbar) {
promises.push(this._createToolbar(toolbarData));
} else {
this.toolbar_anchors.add(anchor);
}
});
return Promise.all(promises).then(() => {
if (this.editor) {
this.editor.observerActive();
}
});
},
/**
* Scan this.field for every Behavior anchor. delete currently registered
* obsolete behaviors.
*
* @param {Array[Object]} behaviorsData array to be filled with data
* currently present in this.field
*/
_scanFieldForBehaviors: function (behaviorsData) {
const anchors = new Set();
const types = new Set(Object.getOwnPropertyNames(this.behavior_types));
this.field.querySelectorAll('.o_knowledge_behavior_anchor').forEach(function (types, anchor) {
const type = Array.from(anchor.classList).find(className => types.has(className));
if (type) {
behaviorsData.push({
anchor: anchor,
type: type,
});
anchors.add(anchor);
}
}.bind(this, types));
// difference between the stored set and the computed one
const differenceAnchors = new Set([...this.behavior_anchors].filter(anchor => !anchors.has(anchor)));
// remove obsolete behaviors
differenceAnchors.forEach(anchor => {
if (anchor.oKnowledgeBehavior) {
anchor.oKnowledgeBehavior.removeBehavior();
}
});
},
/**
* If behaviorsData is set, update only those behaviors, if not, recompute
* every behavior of this.field
*
* @param {Array[Object]} behaviorsData
* @param {Element} [anchor] element to which to inject the behavior
* @param {string} [type] html class representing the behavior type
* (i.e.: @see o_knowledge_behavior_type_... )
*/
updateBehaviors: function (behaviorsData = []) {
if (!behaviorsData.lenth) {
// no behaviorsData, recreate the array from the field value.
this._scanFieldForBehaviors(behaviorsData);
}
// inject new behaviors
behaviorsData.forEach(behaviorData => {
const anchor = behaviorData.anchor;
const {Behavior} = this.behavior_types[behaviorData.type] || {};
if (!Behavior) {
return;
}
if (!anchor.oKnowledgeBehavior) {
anchor.oKnowledgeBehavior = new Behavior(this, anchor, this.mode);
}
this.behavior_anchors.add(anchor);
});
},
/**
* @param {Object}
* @param {Element} [container] element which has a toolbar anchor
* @param {Element} [anchor] element in which to inject the toolbar
* @param {string} [type] html class representing the toolbar type
* (i.e.: @see o_knowledge_toolbar_type_... )
* @returns {Promise} promise to append this toolbar
*/
_createToolbar: function ({container, anchor, type}) {
const {Toolbar, template} = this.toolbar_types[type] || {};
if (!Toolbar) {
return;
}
const toolbar = new Toolbar(this, container, anchor, template, this.editor, this.component.componentRef.comp.uiService);
anchor.oKnowledgeToolbar = toolbar;
this.toolbar_anchors.add(anchor);
const firstElementChild = anchor.firstElementChild;
if (firstElementChild) {
return toolbar.replace(firstElementChild);
}
return toolbar.appendTo(anchor);
},
/**
* Clean @see behavior_anchors set when a behavior is removed from the dom
*
* @param {Event} event
*/
_onBehaviorRemoved: function (event) {
event.stopPropagation();
this.behavior_anchors.delete(event.data.anchor);
},
/**
* Clean @see toolbar_anchors set when a toolbar is removed from the dom
*
* @param {Event} event
*/
_onToolbarRemoved: function (event) {
event.stopPropagation();
this.toolbar_anchors.delete(event.data.anchor);
},
/**
* @param {Event} e
* @param {Object} data
* @param {Array} [toolbarsDatas]
*/
_onRefreshToolbars: function (e, data = {}) {
if (this.field) {
this.updateToolbars("toolbarsData" in data ? data.toolbarsData : []);
}
},
/**
* @param {Event} e
* @param {Object} data
* @param {Array} [behaviorsData]
*/
_onRefreshBehaviors: function (e, data = {}) {
if (this.field) {
this.updateBehaviors("behaviorsData" in data ? data.behaviorsData : []);
}
},
/**
* @param {Event} e
* @param {Object} data
* @param {Array} [behaviorsData]
* @param {Array} [toolbarsDatas]
*/
_onRefreshInjector: function (e, data = {}) {
if (this.field) {
this._onRefreshBehaviors(e, data);
this._onRefreshToolbars(e, data);
}
}
});
export {
FieldHtmlInjector,
};
@@ -1,89 +0,0 @@
/** @odoo-module */
'use strict';
import publicWidget from 'web.public.widget';
import KnowledgeTreePanelMixin from './tools/tree_panel_mixin.js'
publicWidget.registry.KnowledgeWidget = publicWidget.Widget.extend(KnowledgeTreePanelMixin, {
selector: '.o_knowledge_form_view',
events: {
'keyup .knowledge_search_bar': '_searchArticles',
'click .o_article_caret': '_onFold',
'click .o_favorites_toggle_button': '_toggleFavorite',
},
/**
* @override
* @returns {Promise}
*/
start: function () {
return this._super.apply(this, arguments).then(() => {
const id = this.$el.data('article-id');
this._renderTree(id, '/knowledge/tree_panel/portal');
});
},
/**
* @param {Event} event
*/
_searchArticles: function (event) {
const $input = $(event.currentTarget);
const $tree = $('.o_tree');
const keyword = $input.val().toLowerCase();
this._traverse($tree, $li => {
if ($li.text().toLowerCase().includes(keyword)) {
$li.removeClass('d-none');
return true;
} else {
$li.addClass('d-none');
return false;
}
});
},
/**
* Helper function to traverse the dom hierarchy of the aside tree menu.
* The function will call the given callback function with the article item
* beeing visited (i.e: a JQuery dom element). The provided callback function
* should return a boolean indicating whether the algorithm should explore
* the children of the current article item.
* @param {jQuery} $tree
* @param {Function} callback
*/
_traverse: function ($tree, callback) {
const stack = $tree.children('li').toArray();
while (stack.length > 0) {
const $li = $(stack.shift());
if (callback($li)) {
const $ul = $li.children('ul');
stack.unshift(...$ul.children('li').toArray());
}
}
},
/**
* @param {Event} event
*/
_toggleFavorite: function (event) {
const $star = $(event.currentTarget);
const id = $star.data('article-id');
return this._rpc({
model: 'knowledge.article',
method: 'action_toggle_favorite',
args: [[id]]
}).then(result => {
$star.find('i').toggleClass('fa-star', result).toggleClass('fa-star-o', !result);
// Add/Remove the article to/from the favorite in the sidebar
return this._rpc({
route: '/knowledge/tree_panel/favorites',
params: {
active_article_id: id,
}
}).then(template => {
this.$('.o_favorite_container').replaceWith(template);
this._setTreeFavoriteListener();
});
});
},
});
@@ -1,321 +0,0 @@
/** @odoo-module */
import { MacroEngine } from "@web/core/macro";
import Dialog from "web.Dialog";
import core from "web.core";
const _t = core._t;
/**
* Wrapper for @see Macro destined to be used by @see KnowledgeToolbar buttons.
* These macros will navigate through breadcrumbs to inject information from
* a Knowledge article into a previously browsed record that met the
* requirements specified in @see FormController (Knowledge)
*/
class KnowledgeMacroPageChangeError extends Error {}
export class KnowledgeMacro {
/**
* @param {Object} breadcrumbs
* @param {string} action name of the action to be undertaken
* @param {Object} data data object to be used during the action
* @param {Object} uiService used to cover the screen during the macro
* @param {integer} interval delay between the macro steps
*/
constructor (breadcrumbs, action, data, uiService, interval = 16) {
this.breadcrumbsIndex = breadcrumbs.length - 1;
this.breadcrumbsTitle = breadcrumbs[this.breadcrumbsIndex].title;
this.breadcrumbsSelector = `[role="navigation"] > .breadcrumb-item:contains(${this.breadcrumbsTitle})`;
this.interval = interval;
this.data = data;
this.engine = new MacroEngine();
this.action = action;
this.uiService = uiService;
this.blockUI = { action: function () {
if (!this.uiService.isBlocked) {
this.uiService.block();
}
}.bind(this) };
this.unblockUI = { action: function () {
if (this.uiService.isBlocked) {
this.uiService.unblock();
}
}.bind(this) };
}
/**
* Execute the specified macro
*/
start() {
// Build the desired macro action
const macroAction = this._macroActions(this.action);
if (!macroAction) {
return;
}
/**
* Preliminary breadcrumbs macro. It will use the @see breadcrumbsIndex
* to switch back to the view related to the stored record
* (@see KnowledgeService) . Once and if the view of the target record
* is correctly loaded, run the specific macroAction.
*/
const startMacro = {
name: "restore_record",
interval: this.interval,
onError: this.onError.bind(this),
steps: [
this.blockUI, {
trigger: function () {
const $breadcrumbs = $(`.breadcrumb-item:not(.active)`);
if ($breadcrumbs.length > this.breadcrumbsIndex) {
const breadcrumb = $breadcrumbs[this.breadcrumbsIndex];
if (breadcrumb.textContent.includes(this.breadcrumbsTitle)) {
return this.getElement(breadcrumb.querySelector('a'));
}
}
return null;
}.bind(this),
action: 'click',
}, {
trigger: this.getElement.bind(this, `${this.breadcrumbsSelector}.active`),
action: this.engine.activate.bind(this.engine, macroAction),
}],
};
this.engine.activate(startMacro);
}
/**
* @see Macro
*
* @param {Error} error
* @param {Object} step
* @param {integer} index
*/
onError(error, step, index) {
this.unblockUI.action();
if (error instanceof KnowledgeMacroPageChangeError) {
Dialog.alert(this,
_t('The operation was interrupted because the page or the record changed. Please try again later.'), {
title: _t('Error'),
});
} else {
console.error(error);
}
}
/**
* Return the first (or the last) matching element from selector only if it
* is currently visible for the user in the document.
*
* @param {string} selector
* @param {boolean} reverse whether to search from the end to the start
* @returns {Element}
*/
getElement(selector, reverse=false) {
const $sel = $(selector);
for (let i = 0; i < $sel.length; i++) {
i = reverse ? $sel.length - 1 - i : i;
if ($sel.eq(i).is(':visible:hasVisibility')) {
return $sel[i];
}
}
return null;
}
/**
* Validate the page during the macro action. The page is valid if the
* targeted breadcrumb is currently active. Abort a macro in case of failure
* @see Macro
*/
validatePage() {
if (!this.getElement(`${this.breadcrumbsSelector}.active`)) {
throw new KnowledgeMacroPageChangeError();
}
}
/**
* Build the desired action description from this.properties. Every macro
* will validate the page at every step and abort upon failure to do so.
*
* @param {string} action name of a handled macro action
* @returns {Object} valid descrption @see Macro
*/
_macroActions(action) {
switch (action) {
/**
* @see TemplateToolbar
* Copy the contents of a /template block and paste into the
* targeted field stored in @see data . This macro is able to switch
* form notebook panes if the field is available in one of them.
*/
case "use_as_description": return {
name: action,
interval: this.interval,
onError: this.onError.bind(this),
steps: [{
trigger: function () {
this.validatePage();
const selector = `.oe_form_field_html[name="${this.data.fieldName}"]`;
const el = this.getElement(selector);
if (el) {
return el;
}
// Handle the case where the field is hidden in a tab of the form view notebook
const $sel = $(selector);
for (let i = 0; i < $sel.length; i++) {
const pane = $sel[i].closest('.tab-pane:not(.active)');
if (pane) {
const paneSwitch = this.getElement(`[data-bs-toggle="tab"][href*="${pane.id}"]`);
if (paneSwitch) {
paneSwitch.click();
break;
}
}
}
return null;
}.bind(this),
action: 'click',
}, {
trigger: function () {
this.validatePage();
return this.getElement(`.oe_form_field_html[name="${this.data.fieldName}"] > .odoo-editor-editable`);
}.bind(this),
action: this._pasteTemplate.bind(this),
}, this.unblockUI],
};
/**
* @see TemplateToolbar
* Copy the contents of a /template block and paste into a new
* chatter message in the targeted record context. Open the full
* composer to access the editor.
*/
case "send_as_message": return {
name: action,
interval: this.interval,
onError: this.onError.bind(this),
steps: [{
trigger: function() {
this.validatePage();
return this.getElement('.o_ChatterTopbar_buttonSendMessage');
}.bind(this),
action: (el) => {
if (!el.classList.contains('o-active')) {
el.click();
}
},
}, {
trigger: function() {
this.validatePage();
return this.getElement('.o_Composer_buttonFullComposer');
}.bind(this),
action: 'click',
}, {
trigger: function () {
this.validatePage();
const dialog = this.getElement('.o_dialog_container.modal-open');
if (dialog) {
return this.getElement(dialog.querySelector('.oe_form_field_html[name="body"] > .odoo-editor-editable'));
} else {
return null;
}
}.bind(this),
action: this._pasteTemplate.bind(this),
}, this.unblockUI],
};
/**
* @see FileToolbar
* Attach a file to a new chatter message in the targeted record
* context.
*/
case "attach_to_message": return {
name: action,
interval: this.interval,
onError: this.onError.bind(this),
steps: [{
trigger: function() {
this.validatePage();
return this.getElement('.o_ChatterTopbar_buttonSendMessage');
}.bind(this),
action: (el) => {
el.scrollIntoView();
if (!el.classList.contains('o-active')) {
el.click();
}
},
}, {
trigger: function() {
this.validatePage();
return this.getElement('.o_Composer_buttonAttachment');
}.bind(this),
action: this._dragAndDrop.bind(this, 'dragenter'),
}, {
trigger: function () {
this.validatePage();
return this.getElement('.o_Composer_dropZone');
}.bind(this),
action: this._dragAndDrop.bind(this, 'drop'),
}, this.unblockUI],
};
/**
* @see FileToolbar
* Attach a file to the targeted record.
*/
case "use_as_attachment": return {
name: action,
interval: this.interval,
onError: this.onError.bind(this),
steps: [{
trigger: function() {
this.validatePage();
return this.getElement('.o_ChatterTopbar_buttonAttachments');
}.bind(this),
action: function(el) {
if (!this.getElement('.o_AttachmentBox_content')) {
el.click();
}
}.bind(this),
}, {
trigger: function() {
this.validatePage();
return this.getElement('.o_AttachmentBox_content');
}.bind(this),
action: (el) => el.scrollIntoView(),
}, this.unblockUI],
};
}
}
/**
* Handle drag&drop events with the dataTransfer object stored in @see data
*
* @param {string} type event type (related to drag&drop)
* @param {Element} el target element
*/
_dragAndDrop(type, el) {
const fakeDragAndDrop = new Event(type, {
bubbles: true,
cancelable: true,
composed: true,
});
fakeDragAndDrop.dataTransfer = this.data.dataTransfer;
el.dispatchEvent(fakeDragAndDrop);
}
/**
* Handle paste event with the dataTransfer object stored in @see data
*
* @param {Element} el target element
*/
_pasteTemplate(el) {
const fakePaste = new Event('paste', {
bubbles: true,
cancelable: true,
composed: true,
});
fakePaste.clipboardData = this.data.dataTransfer;
const sel = document.getSelection();
sel.removeAllRanges();
const range = document.createRange();
const firstChild = el.firstChild;
if (!firstChild) {
range.setStart(el, 0);
range.setEnd(el, 0);
} else {
range.setStartBefore(firstChild);
range.setEndBefore(firstChild);
}
sel.addRange(range);
el.dispatchEvent(fakePaste);
}
}
@@ -1,26 +0,0 @@
/** @odoo-module */
/**
* Plugin for OdooEditor. Allow to remove temporary toolbars content which are
* not destined to be stored in the field_html
*/
export class KnowledgePlugin {
/**
* @param {Element} editable
*/
cleanForSave(editable) {
for (const node of editable.querySelectorAll('.o_knowledge_toolbar_anchor')) {
if (node.oKnowledgeToolbar) {
node.oKnowledgeToolbar.removeToolbar();
}
while (node.firstChild) {
node.removeChild(node.lastChild);
}
}
for (const node of editable.querySelectorAll('.o_knowledge_behavior_anchor')) {
if (node.oKnowledgeBehavior) {
node.oKnowledgeBehavior.removeBehavior();
}
}
}
}
@@ -1,444 +0,0 @@
/** @odoo-module */
import PermissionPanelWidget from './widgets/knowledge_permission_panel.js';
import EmojiPickerWidget from './widgets/knowledge_emoji_picker.js';
import FormRenderer from 'web.FormRenderer';
import KnowledgeTreePanelMixin from '@knowledge/js/tools/tree_panel_mixin';
import { qweb as QWeb } from 'web.core';
import { ChatterContainer } from '@mail/components/chatter_container/chatter_container';
import { ComponentWrapper } from 'web.OwlCompatibility';
class ChatterContainerWrapperComponent extends ComponentWrapper {}
const KnowledgeArticleFormRenderer = FormRenderer.extend(KnowledgeTreePanelMixin, {
className: 'o_knowledge_form_view',
events: _.extend({}, FormRenderer.prototype.events, {
'click .btn-chatter.active': '_onCloseChatter',
'click .btn-chatter:not(.active)': '_onOpenChatter',
'click .btn-create': '_onBtnCreateClick',
'click .btn-duplicate': '_onBtnDuplicateClick',
'click .btn-move': '_onBtnMoveClick',
'click .breadcrumb a[data-controller-id]': '_onBreadcrumbItemClick',
'click .o_article_caret': '_onFold',
'click .o_article_name': '_onOpen',
'click .o_article_create': '_onBtnArticleCreateClick',
'click .o_section_create': '_onBtnSectionCreateClick',
'click .o_knowledge_share_panel': '_preventDropdownClose',
'click .o_knowledge_more_options_panel': '_preventDropdownClose',
}),
/**
* @override
*/
init: function (parent, state, params) {
this._super(...arguments);
this.breadcrumbs = params.breadcrumbs;
/**
* Manually set values for the chatter props as it is custom handled
* and not loaded with the view (oe_chatter is not used in Knowledge,
* the chatter is only rendered when the user clicks on the button).
*/
this.chatterFields = {
// `knowledge.article` has `mail.activity.mixin`
hasActivities: true,
hasFollowers: true,
hasMessageIds: true,
};
},
/**
* Called when the chatter triggers a reload on the Form view with the
* param 'keepChanges=true'. In this case, we only need to update the
* chatter.
*
* @returns {Promise}
*/
updateChatter: function () {
if (this._chatterContainerComponent && this.state.res_id) {
const props = this._makeChatterContainerProps();
return this._chatterContainerComponent.update(props);
}
this._closeChatter();
return Promise.resolve();
},
/**
* @private
*/
_closeChatter: function () {
const chatter = this.el.querySelector('.o_knowledge_chatter_container');
while (chatter.firstChild) {
chatter.removeChild(chatter.firstChild);
}
if (this._chatterContainerComponent) {
this._chatterContainerComponent.destroy();
this._chatterContainerComponent = undefined;
}
},
/**
* @param {Event} event
*/
_onBreadcrumbItemClick: function (event) {
const $target = $(event.target);
this.trigger_up('breadcrumb_clicked', {
controllerID: $target.data('controller-id'),
});
},
/**
* Callback function called when the user clicks on the '+' sign of an article
* list item. The callback function will create a new article under the target article.
* @param {Event} event
*/
_onBtnArticleCreateClick: function (event) {
const $target = $(event.currentTarget);
const $li = $target.closest('li');
this.trigger_up('create', {
target_parent_id: $li.data('article-id')
});
},
/**
* @param {Event} event
*/
_onBtnChatterClick: function (event) {
const $chatter = $('.o_knowledge_chatter');
$chatter.toggleClass('d-none');
$('.btn-chatter').toggleClass('active');
},
/**
* @param {Event} event
*/
_onBtnCreateClick: function (event) {
this.trigger_up('create', {
category: 'private'
});
},
/**
* @param {Event} event
*/
_onBtnDuplicateClick: function (event) {
event.preventDefault();
this.trigger_up('duplicate', {});
},
/**
* @param {Event} event
*/
_onBtnMoveClick: function (event) {
event.preventDefault();
this.trigger_up('open_move_to_modal', {});
},
/**
* Callback function called when the user clicks on the '+' sign of a section
* (workspace, private, shared). The callback function will create a new article
* on the root of the target section.
* @param {Event} event
*/
_onBtnSectionCreateClick: function (event) {
const $target = $(event.currentTarget);
const $section = $target.closest('.o_section');
this.trigger_up('create', {
category: $section.data('section')
});
},
/**
* Update the btn-chatter appearance and hide the chatter section in the
* view. Closes the chatter.
*
* @private
*/
_onCloseChatter: function () {
this.el.querySelector('.o_knowledge_chatter').classList.add('d-none');
this.el.querySelector('.btn-chatter').classList.remove('active');
this._closeChatter();
},
/**
* Opens the selected record.
* @param {Event} event
*/
_onOpen: async function (event) {
event.stopPropagation();
const $li = $(event.target).closest('li');
this.do_action('knowledge.ir_actions_server_knowledge_home_page', {
stackPosition: 'replaceCurrentAction',
additional_context: {
res_id: $li.data('article-id')
}
});
},
/**
* Update tbe btn-chatter appearance and show the chatter section in the
* view. Open the chatter.
*
* @private
* @param {OdooEvent} ev
*/
_onOpenChatter: async function (ev) {
ev.stopPropagation();
if (this.state.res_id) {
this.el.querySelector('.o_knowledge_chatter').classList.remove('d-none');
this.el.querySelector('.btn-chatter').classList.add('active');
await this._renderChatter();
}
},
/**
* By default, Bootstrap closes automatically the dropdown menu when the user
* clicks inside it. To avoid that behavior, we will add a new event listener
* on the dropdown menu that will prevent the click event from bubbling up and
* triggering the listener closing the dropdown menu.
* @param {Event} event
*/
_preventDropdownClose: function (event) {
event.stopPropagation();
},
_renderArticleEmoji: function () {
const { data } = this.state;
const $dropdown = this.$('.o_knowledge_icon > .o_article_emoji_dropdown');
$dropdown.attr('data-article-id', this.state.res_id);
$dropdown.find('.o_article_emoji').text(data.icon || '');
},
/**
* Renders the breadcrumb
*/
_renderBreadcrumb: function () {
const items = this.breadcrumbs.map(payload => {
return QWeb.render('knowledge.knowledge_breadcrumb_item', { payload });
});
this.$('.breadcrumb').prepend(items);
},
/**
* Render and mount the chatter of the current record.
*
* @private
* @returns {Promise}
*/
_renderChatter: async function () {
if (!this.state.res_id) {
return;
}
this._closeChatter();
const props = this._makeChatterContainerProps();
this._chatterContainerComponent = new ChatterContainerWrapperComponent(
this,
ChatterContainer,
props
);
const target = this.el.querySelector('.o_knowledge_chatter_container');
await this._chatterContainerComponent.mount(target);
},
/**
* Attaches an emoji picker widget to every emoji dropdown of the container.
* Note: The widget will be attached to the view when the user clicks on
* the dropdown menu for the first time.
* @param {JQuery} [$container]
*/
_renderEmojiPicker: function ($container) {
$container = $container || this.$el;
$container.find('.o_article_emoji_dropdown').one('click', event => {
const $dropdown = $(event.currentTarget);
const picker = new EmojiPickerWidget(this, {
article_id: $dropdown.data('article-id')
});
picker.attachTo($dropdown);
});
},
/**
* Renders the permission panel
*/
_renderPermissionPanel: function () {
this.$('.btn-share').one('click', event => {
const $container = this.$('.o_knowledge_permission_panel');
const panel = new PermissionPanelWidget(this, {
article_id: this.state.data.id,
user_permission: this.state.data.user_permission
});
panel.attachTo($container);
});
},
/**
* @override
* @returns {Promise}
*/
_renderView: async function () {
const result = await this._super.apply(this, arguments);
this._renderBreadcrumb();
await this._renderTree(this.state.res_id, '/knowledge/tree_panel');
this._renderArticleEmoji();
this._renderPermissionPanel();
this._setResizeListener();
return result;
},
/**
* @param {integer} id - Article id
* @param {String} unicode
*/
_setEmoji: function (id, emoji) {
const emojis = this.$(`.o_article_emoji_dropdown[data-article-id="${id}"] > .o_article_emoji`);
emojis.text(emoji || '');
},
/**
* Enables the user to resize the aside block.
* Note: When the user grabs the resizer, a new listener will be attached
* to the document. The listener will be removed as soon as the user releases
* the resizer to free some resources.
*/
_setResizeListener: function () {
/**
* @param {PointerEvent} event
*/
const onPointerMove = _.throttle(event => {
event.preventDefault();
this.el.style.setProperty('--default-sidebar-size', `${event.pageX}px`);
}, 100);
/**
* @param {PointerEvent} event
*/
const onPointerUp = event => {
$(document).off('pointermove', onPointerMove);
};
const $resizer = this.$('.o_knowledge_article_form_resizer');
$resizer.on('pointerdown', event => {
event.preventDefault();
$(document).on('pointermove', onPointerMove);
$(document).one('pointerup', onPointerUp);
});
},
/**
* Initializes the drag-and-drop behavior of the tree listing all articles.
* Once this function is called, the user will be able to move an article
* in the tree hierarchy by dragging an article around.
* When an article is moved, the script will send an rpc call to the server
* and the drag-and-drop behavior will be deactivated while the request is pending.
* - If the rpc call succeeds, the drag-and-drop behavior will be reactivated.
* - If the rpc call fails, the change will be undo and the drag-and-drop
* behavior will be reactivated.
* Unfortunately, `nestedSortable` can only restore one transformation. Disabling
* the drag-and-drop behavior will ensure that the tree structure can be restored
* if something went wrong.
*/
_setTreeListener: function () {
const $sortable = this.$('.o_tree');
$sortable.nestedSortable({
axis: 'y',
handle: 'div',
items: 'li',
listType: 'ul',
toleranceElement: '> div',
forcePlaceholderSize: true,
opacity: 0.6,
placeholder: 'bg-info',
tolerance: 'pointer',
helper: 'clone',
cursor: 'grabbing',
cancel: '.readonly',
/**
* @param {Event} event
* @param {Object} ui
*/
stop: (event, ui) => {
$sortable.sortable('disable');
const $li = $(ui.item);
const $section = $li.closest('section');
const $parent = $li.parentsUntil('.o_tree', 'li');
const data = {
article_id: $li.data('article-id'),
oldCategory: $li.data('category'),
newCategory: $section.data('section')
};
if ($parent.length > 0) {
data.target_parent_id = $parent.data('article-id');
}
const $next = $li.next();
if ($next.length > 0) {
data.before_article_id = $next.data('article-id');
}
$li.siblings('.o_knowledge_empty_info').addClass('d-none');
this.$('.o_knowledge_empty_info:only-child').removeClass('d-none');
this.trigger_up('move', {...data,
onSuccess: () => {
const id = $li.data('parent-id');
if (typeof id !== 'undefined') {
const $parent = this.$(`.o_article[data-article-id="${id}"]`);
if (!$parent.children('ul').is(':parent')) {
const $caret = $parent.find('> .o_article_handle > .o_article_caret');
$caret.remove();
}
}
if ($parent.length > 0) {
const $handle = $parent.children('.o_article_handle:first');
if ($handle.children('.o_article_caret').length === 0) {
const $caret = $(QWeb.render('knowledge.knowledge_article_caret', {}));
$handle.prepend($caret);
}
}
$li.data('parent-id', $parent.data('article-id'));
$li.attr('data-parent-id', $parent.data('article-id'));
$li.data('category', data.newCategory);
$li.attr('data-category', data.newCategory);
let $children = $li.find('.o_article');
$children.each((_, child) => {
$(child).data('category', data.newCategory);
$(child).attr('data-category', data.newCategory);
});
$sortable.sortable('enable');
},
onReject: () => {
/**
* When a move between two connected nestedSortable
* trees is canceled, more than one operation may be
* undone (library bug). To bypass sortable('cancel'),
* the last moved $item is returned at its original
* location (which may have to be restored too if it was
* cleaned), and a 'change' event is triggered from that
* rectified position for consistency (see the
* nestedSortable library).
*/
const $item = ui.item.data('nestedSortableItem');
if ($item.domPosition.prev) {
// Restore $item position after its previous sibling
$item.domPosition.prev.after($item.currentItem[0]);
} else {
// Restore $item as the first child of the parent ul
$item.domPosition.parent.prepend($item.currentItem[0]);
if (!$item.domPosition.parent.parentElement) {
// The ul was cleaned from the document since it
// was empty, so it has to be restored too
const offsetParent = $item.offsetParent[0];
offsetParent.append($item.domPosition.parent);
}
}
// For consistency with the nestedSortable library,
// trigger the 'change' event from the moved $item
$item._trigger('change', null, $item._uiHash());
$sortable.sortable('enable');
this.$('.o_knowledge_empty_info').addClass('d-none');
this.$('.o_knowledge_empty_info:only-child').removeClass('d-none');
}
});
},
});
// Allow drag and drop between sections:
this.$('section[data-section="workspace"] .o_tree').nestedSortable(
'option',
'connectWith',
'section[data-section="private"] .o_tree'
);
this.$('section[data-section="private"] .o_tree').nestedSortable(
'option',
'connectWith',
'section[data-section="workspace"] .o_tree'
);
// connectWith both workspace and private sections:
this.$('section[data-section="shared"] .o_tree').nestedSortable(
'option',
'connectWith',
'section[data-section="workspace"] .o_tree, section[data-section="private"] .o_tree'
);
},
});
export {
KnowledgeArticleFormRenderer,
};
@@ -1,151 +0,0 @@
/** @odoo-module */
import AbstractService from 'web.AbstractService';
import core from 'web.core';
/**
* This service store data from non-knowledge form view records that can be used
* by a Knowledge form view.
*
* A typical usage could be the following:
* - A form view is loaded and one field of the current record is a match for
* Knowledge @see FormController @see FormRenderer
* - Information about this record and how to access its form view is stored
* in this @see KnowledgeService .
* - A knowledge Article is opened and it contains a @see TemplateToolbar .
* - When the toolbar is injected (@see FieldHtmlInjector ) in the view, it
* asks this @see KnowledgeService if the record can be interacted with.
* - if there is one such record, the related buttons are displayed in the
* toolbar.
* - When one such button is used, the form view of the record is reloaded
* @see KnowledgeMacro and the button action is executed through a @see Macro
* - an exemple of macro action would be copying the template contents as the
* value of a field_html of the record, such as "description"
*
* Scope of the service:
* It is meant to be called on 3 occasions:
* 1) by @see FormController :
* It will only be called if the viewed record can be used within the
* Knowledge module. Such a record should have a chatter in its form
* view, or have at least one field in a whitelist specified in the
* controller.
* 2) by @see FormRenderer :
* It will only be called if the viewed record has a field match in the
* whitelist of the controller. The renderer has to verify that the
* field's container is visible to the user (so that the field can be
* interacted with).
* 3) by @see KnowledgeToolbar :
* It will be called by a toolbar to check whether it has a record that
* can be interacted with in the context of the toolbar (withChatter or
* withHtmlField).
*/
const KnowledgeService = AbstractService.extend({
/**
* @override
*/
start() {
this._super.apply(...arguments);
this._records = new Set();
this._lastVisitedRecordWithChatter = null;
this._lastVisitedRecordWithHtmlField = null;
/**
* Some records are added by the controller but need to be validated by
* the renderer. They are stored in this stack until the rendering is
* done.
*/
this._toValidateStackWithHtmlField = [];
},
/**
* Called by the controller to indicate that a record need to be validated
* by the renderer @see FormController
*
* @param {Object} record
*/
pushToValidateWithHtmlField(record) {
if (record && record.fieldNames.length) {
this._toValidateStackWithHtmlField.push(record);
}
},
/**
* Called by the renderer to validate a record @see FormRenderer
*
* @returns {Object}
*/
popToValidateWithHtmlField() {
return this._toValidateStackWithHtmlField.pop();
},
/**
* Called when a record may be used by a Knowledge form view.
*
* @param {Object} record
*/
registerRecord(record) {
if (!record) {
return;
}
this._records.add(record);
if (record.withChatter) {
/**
* if the record is flagged "withChatter", overwrite the previously
* registered record
*/
this._lastVisitedRecordWithChatter = record;
}
if (record.withHtmlField) {
/**
* if the record is flagged "withHtmlField", reset the stack and
* the previously registered record
*/
this._toValidateStackWithHtmlField = [];
this._lastVisitedRecordWithHtmlField = record;
}
},
/**
* Remove a record to signify that it can not be used by a Knowledge form
* view anymore.
*
* @param {Object} record
*/
unregisterRecord(record) {
this._records.delete(record);
},
/**
* Recover a record that is able to interact with the chatter
*
* @returns {Object}
*/
getAvailableRecordWithChatter() {
if (this._lastVisitedRecordWithChatter && !this._lastVisitedRecordWithChatter.withChatter) {
this._records.delete(this._lastVisitedRecordWithChatter);
}
if (!this._records.has(this._lastVisitedRecordWithChatter)) {
this._lastVisitedRecordWithChatter = null;
}
return this._lastVisitedRecordWithChatter;
},
/**
* Recover a record that has an available field_html
*
* @returns {Object}
*/
getAvailableRecordWithHtmlField() {
if (this._lastVisitedRecordWithHtmlField && !this._lastVisitedRecordWithHtmlField.withHtmlField) {
this._records.delete(this._lastVisitedRecordWithHtmlField);
}
if (!this._records.has(this._lastVisitedRecordWithHtmlField)) {
this._lastVisitedRecordWithHtmlField = null;
}
return this._lastVisitedRecordWithHtmlField;
},
/**
* Recover a copy of the set of the currently registered records
*
* @returns {Set}
*/
getRecords() {
return new Set(this._records);
},
});
core.serviceRegistry.add('knowledgeService', KnowledgeService);
export default KnowledgeService;
@@ -1,339 +0,0 @@
/** @odoo-module */
import core from 'web.core';
import Widget from 'web.Widget';
import Dialog from "web.Dialog";
import utils from 'web.utils';
import { KnowledgeMacro } from './knowledge_macros';
import { sprintf } from '@web/core/utils/strings';
const _t = core._t;
/**
* Toolbar to be injected through @see FieldHtmlInjector to @see OdooEditor
* blocks which have specific classes calling for such toolbars.
*
* A typical usage could be the following:
* - An @see OdooEditor block like /template has the generic class:
* @see o_knowledge_toolbars_container to signify that some of its children need
* to have toolbars injected.
* - At least one of the children has the generic class:
* @see o_knowledge_toolbar_anchor that is a container in which the toolbar
* will be appended
* - The same child also as the specific class:
* @see o_knowledge_toolbar_type_[toolbarType] which specifies the type of the
* toolbar that needs to be injected. @see FieldHtmlInjector has a dictionary
* mapping those classes to the correct toolbar class.
*
* The @see KnowledgeToolbar is a basic toolbar intended to be overriden for
* more complex implementations
*
* This widget should be converted to a component.
*/
const KnowledgeToolbar = Widget.extend({
/**
* @override
* @param {Widget} parent
* @param {Element} container root node of the @see OdooEditor block, i.e.:
* /template or /file root container
* @param {Element} anchor sub-child of @see container container of the
* toolbar
* @param {string} template
* @param {Object} editor @see OdooEditor
* @param {Object} uiService when this widget is converted to a component,
* uiService won't be needed as a parameter
*/
init: function (parent, container, anchor, template, editor, uiService) {
this._super.apply(this, [parent]);
this.container = container;
this.anchor = anchor;
this.template = template;
this.mode = parent.mode;
this.field = parent.field;
this.editor = editor;
this.uiService = uiService;
},
/**
* Used by @see KnowledgePlugin to remove toolbars when the field_html is
* saved. Also used by @see FieldHtmlInjector to manage injected toolbars
*/
removeToolbar: function () {
this.trigger_up('toolbar_removed', {
anchor: this.anchor,
});
delete this.anchor.oKnowledgeToolbar;
this.destroy();
},
/**
* @override
*/
start: function () {
const prom = this._super.apply(this, arguments);
return prom.then(function () {
this._setupButtons();
}.bind(this));
},
/**
* Setup the toolbar buttons
*/
_setupButtons: function () {
const buttons = this.el.querySelectorAll('button');
buttons.forEach(this._setupButton.bind(this));
},
// FUNCTIONS TO OVERRIDE \\
/**
* Called for each button of the toolbar. Each button should have a
* data-call attribute which is used as a unique key for differentiation.
* A common implementation would be a switch case on "button.dataset.call".
* Intercept dblclick events to avoid @see OdooEditor interference
*
* @param {Element} button
*/
_setupButton: function (button) {
button.addEventListener("dblclick", function (ev) {
ev.stopPropagation();
ev.preventDefault();
});
return;
},
});
/**
* Toolbar for the /file command
*/
const FileToolbar = KnowledgeToolbar.extend({
/**
* Recover the eventual related record from @see KnowledgeService
*
* @override
*/
init: function () {
this._super.apply(this, arguments);
this.recordWithChatter = this.call('knowledgeService', 'getAvailableRecordWithChatter');
},
/**
* Get data from the file related to this toolbar
*
* @param {Element} fileLink <a> element to extract file data from
*/
_fetchData: async function (fileLink) {
if (!fileLink || !fileLink.href) {
return null;
}
return await fetch(fileLink.href).then(async function (response) {
if (response.ok) {
return await response.blob();
} else {
return null;
}
});
},
/**
* @override
*/
_setupButton: function (button) {
this._super.apply(this, arguments);
switch (button.dataset.call) {
/**
* Create a @see KnowledgeMacro to add the file to a new message
* in the context of the related record.
*/
case 'attach_to_message':
button.addEventListener("click", async function(ev) {
ev.stopPropagation();
ev.preventDefault();
const record = this.recordWithChatter;
if (record) {
const fileLink = this.container.querySelector('.o_knowledge_file_image > a');
const data = await this._fetchData(fileLink);
if (!data) {
return;
}
const file = new File([data], fileLink.getAttribute('title'), { type: data.type });
/**
* dataTransfer will be used to mimic a drag and drop of
* the file in the target record chatter.
* @see KnowledgeMacro
*/
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, {
dataTransfer: dataTransfer,
}, this.uiService);
macro.start();
}
}.bind(this));
break;
/**
* Create a @see KnowledgeMacro to add the file as an attachment of
* the related record.
*/
case 'use_as_attachment':
button.addEventListener("click", async function(ev) {
ev.stopPropagation();
ev.preventDefault();
const record = this.recordWithChatter;
if (record) {
const fileLink = this.container.querySelector('.o_knowledge_file_image > a');
const data = await this._fetchData(fileLink);
if (!data) {
return;
}
const dataURL = await utils.getDataURLFromFile(data);
const attachment = await this._rpc({
route: '/web_editor/attachment/add_data',
params: {
'name': fileLink.getAttribute('title'),
'data': dataURL.split(',')[1],
'is_image': false,
'res_id': this.recordWithChatter.res_id,
'res_model': this.recordWithChatter.res_model,
}
});
if (!attachment) {
return;
}
let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, {}, this.uiService);
macro.start();
}
}.bind(this));
break;
/**
* Add the file download behavior to the button and the image link
*/
case 'download':
const download = async function (ev) {
ev.stopPropagation();
ev.preventDefault();
// Roundabout way to click on the link, to avoid OdooEditor interference with the event
const downloadLink = document.createElement('a');
const originalLink = this.container.querySelector('.o_knowledge_file_image > a');
const title = originalLink.getAttribute('title') ? `"${originalLink.getAttribute('title')}" `: "";
const href = originalLink.getAttribute('href');
const response = await fetch(href).then((response) => response);
if (response.ok) {
downloadLink.setAttribute('href', href);
downloadLink.setAttribute('download', '');
downloadLink.setAttribute('target', '_blank');
downloadLink.click();
} else {
Dialog.alert(this,
sprintf(_t('Oops, the file %s could not be found. Please replace this file box by a new one to re-upload the file.'), title), {
title: _t('Missing File'),
buttons: [{
text: _t('Close'),
close: true,
}]
});
}
}.bind(this);
button.addEventListener("click", download);
const imageElement = this.container.querySelector('.o_knowledge_file_image');
if (!imageElement.oKnowledgeClickListener) {
imageElement.addEventListener("click", download);
imageElement.oKnowledgeClickListener = true;
}
break;
}
},
});
/**
* Toolbar for the /template command
*/
const TemplateToolbar = KnowledgeToolbar.extend({
/**
* Recover the eventual related records from @see KnowledgeService
*
* @override
*/
init: function () {
this._super.apply(this, arguments);
this.recordWithChatter = this.call('knowledgeService', 'getAvailableRecordWithChatter');
this.recordWithHtmlField = this.call('knowledgeService', 'getAvailableRecordWithHtmlField');
},
/**
* Create a dataTransfer object with the editable content of the template
* block, to be used for a paste event in the editor
*/
_createHtmlDataTransfer: function () {
const dataTransfer = new DataTransfer();
const content = this.container.querySelector('.o_knowledge_content');
dataTransfer.setData('text/html', content.outerHTML);
return dataTransfer;
},
/**
* @override
*/
_setupButton: function (button) {
this._super.apply(this, arguments);
switch (button.dataset.call) {
/**
* Create a @see KnowledgeMacro to copy the content of the /template
* block and paste it as the content of a new message in the context
* of the related record, in a fullComposer form dialog
*/
case 'send_as_message':
button.addEventListener("click", function (ev) {
ev.stopPropagation();
ev.preventDefault();
const record = this.recordWithChatter;
if (record) {
const dataTransfer = this._createHtmlDataTransfer();
let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, {
dataTransfer: dataTransfer,
}, this.uiService);
macro.start();
}
}.bind(this));
break;
/**
* Create a @see KnowledgeMacro to copy the content of the /template
* block and paste it as the content (prepend) of the field_html
* value of the related record
*/
case 'use_as_description':
button.addEventListener("click", function(ev) {
ev.stopPropagation();
ev.preventDefault();
const record = this.recordWithHtmlField;
if (record) {
const dataTransfer = this._createHtmlDataTransfer();
let macro = new KnowledgeMacro(record.breadcrumbs, button.dataset.call, {
fieldName: record.fieldNames[0].name,
dataTransfer: dataTransfer,
}, this.uiService);
macro.start();
}
}.bind(this));
break;
/**
* Copy the content of the /template block to the clipboard, and
* prevent @see OdooEditor interference
*/
case 'copy_to_clipboard':
button.addEventListener("click", function (ev) {
ev.stopPropagation();
ev.preventDefault();
});
const content = this.container.querySelector('.o_knowledge_content');
const clipboard = new ClipboardJS(
button,
{target: () => content}
);
clipboard.on('success', (e) => {
e.clearSelection();
this.displayNotification({
type: 'success',
message: _t("Template copied to clipboard."),
});
});
break;
}
},
});
export {
TemplateToolbar,
FileToolbar,
KnowledgeToolbar,
};
@@ -1,36 +0,0 @@
/** @odoo-module */
import { KnowledgeToolbar } from './knowledge_toolbars';
/**
* @see KnowledgeToolbar
* This override is loaded as an asset of web_editor to access editor methods
* and utils.
*/
KnowledgeToolbar.include({
/**
* set the toolbar anchor as non-editable
*
* @override
*/
init: function () {
this._super.apply(this, arguments);
if (this.mode === 'edit') {
this.editor.observerUnactive('knowledge_toolbar_uneditable');
this.anchor.setAttribute('contenteditable', 'false');
this.editor.observerActive('knowledge_toolbar_uneditable');
}
},
/**
* @override
*/
removeToolbar: function () {
if (this.mode === 'edit') {
this.editor.observerUnactive();
}
this._super.apply(this, arguments);
if (this.mode === 'edit') {
this.editor.observerActive();
}
},
});
@@ -1,34 +0,0 @@
/** @odoo-module */
import FormView from 'web.FormView';
import viewRegistry from 'web.view_registry';
import { KnowledgeArticleFormController } from './knowledge_controller.js';
import { KnowledgeArticleFormRenderer } from './knowledge_renderers.js';
const KnowledgeArticleFormView = FormView.extend({
config: _.extend({}, FormView.prototype.config, {
Controller: KnowledgeArticleFormController,
Renderer: KnowledgeArticleFormRenderer,
}),
/**
* For the knowledge module, we want to display a headless layout.
* To get a headless layout, the `init` function will deactivate the
* control panel as well as the search bar and the default breadcrumb.
* @override
* @param {Object} viewInfo
* @param {Object} params
*/
init: function (viewInfo, params) {
params.withBreadcrumbs = false;
params.withControlPanel = false;
params.withSearchBar = false;
this._super.apply(this, arguments);
this.rendererParams.breadcrumbs = params.breadcrumbs;
},
});
viewRegistry.add('knowledge_article_view_form', KnowledgeArticleFormView);
export {
KnowledgeArticleFormView,
};
@@ -1,130 +0,0 @@
/** @odoo-module **/
export default {
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Renders the tree listing all articles.
* To minimize loading time, the function will initially load the root articles.
* The other articles will be loaded lazily: The user will have to click on
* the carret next to an article to load and see their children.
* The id of the unfolded articles will be cached so that they will
* automatically be displayed on page load.
* @param {integer} active_article_id
* @param {String} route
*/
_renderTree: async function (active_article_id, route) {
const $container = this.$('.o_knowledge_tree');
const portalReadonlyMode = $container.data('portalReadonlyMode');
let unfoldedArticles = localStorage.getItem('unfoldedArticles');
unfoldedArticles = unfoldedArticles ? unfoldedArticles.split(";").map(Number) : false;
return this._rpc({
route: route,
params: {
active_article_id: active_article_id,
unfolded_articles: unfoldedArticles,
}
}).then(htmlTree => {
$container.empty();
$container.append(htmlTree);
if (!portalReadonlyMode) {
this._setTreeListener();
this._renderEmojiPicker();
}
this._setTreeFavoriteListener();
}).catch(error => {
$container.empty();
});
},
/**
* Initializes the drag-and-drop behavior of the favorite.
* Once this function is called, the user will be able to reorder their favorites.
* When a favorite is reordered, the script will send an rpc call to the server
* and the drag-and-drop behavior will be deactivated while the request is pending.
* - If the rpc call succeeds, the drag-and-drop behavior will be reactivated.
* - If the rpc call fails, the change will be undo and the drag-and-drop
* behavior will be reactivated.
* Unfortunately, `sortable` can only restore one transformation. Disabling
* the drag-and-drop behavior will ensure that the list structure can be restored
* if something went wrong.
*/
_setTreeFavoriteListener: function () {
const $sortable = this.$('.o_tree_favorite');
$sortable.sortable({
axis: 'y',
items: 'li',
cursor: 'grabbing',
forcePlaceholderSize: true,
placeholder: 'o_placeholder',
/**
* @param {Event} event
* @param {Object} ui
*/
stop: (event, ui) => {
const favorite_ids = $sortable.find('.o_article').map(function () {
return $(this).data('favorite-article-id');
}).get();
$sortable.sortable('disable');
this._rpc({
route: '/web/dataset/resequence',
params: {
model: "knowledge.article.favorite",
ids: favorite_ids,
offset: 1,
}
}).then(() => {
$sortable.sortable('enable');
}).catch(() => {
$sortable.sortable('cancel');
$sortable.sortable('enable');
});
},
});
},
/**
* Callback function called when the user clicks on the carret of an article
* The function will load the children of the article and append them to the
* dom. Then, the id of the unfolded article will be added to the cache.
* (see: `_renderTree`).
* @param {Event} event
*/
_onFold: async function (event) {
event.stopPropagation();
const $button = $(event.currentTarget);
const $icon = $button.find('i');
const $li = $button.closest('li');
const $ul = $li.find('ul');
let unfoldedArticles = localStorage.getItem('unfoldedArticles');
unfoldedArticles = unfoldedArticles ? unfoldedArticles.split(";") : [];
const articleId = $li.data('articleId').toString();
if ($ul.is(':visible')) {
if (unfoldedArticles.indexOf(articleId) !== -1) {
unfoldedArticles.splice(unfoldedArticles.indexOf(articleId), 1);
}
$icon.removeClass('fa-caret-down');
$icon.addClass('fa-caret-right');
} else {
if ($ul.length === 0) {
// Call the children content
const children = await this._rpc({
route: '/knowledge/tree_panel/children',
params: {
parent_id: $li.data('articleId')
}
});
$li.append($('<ul/>').append(children));
}
if (unfoldedArticles.indexOf(articleId) === -1) {
unfoldedArticles.push(articleId);
}
$icon.removeClass('fa-caret-right');
$icon.addClass('fa-caret-down');
}
$ul.toggle();
localStorage.setItem('unfoldedArticles', unfoldedArticles.join(";"));
}
};
@@ -1,182 +0,0 @@
/** @odoo-module **/
import Dialog from 'web.Dialog';
import session from 'web.session';
import { _t } from 'web.core';
const MoveArticleToDialog = Dialog.extend({
template: 'knowledge.knowledge_move_article_to_modal',
/**
* @override
* @param {Widget} parent
* @param {Object} options
* @param {Object} props
*/
init: function (parent, options, props) {
// Set default options:
options.title = options.title || _t('Move an Article');
options.buttons = options.buttons || [{
text: _t('Ok'),
classes: 'btn-primary',
click: this.save.bind(this)
}, {
text: _t('Cancel'),
close: true
}];
this._super(...arguments);
this.props = props;
// Set template variables:
const { state } = props;
this.article_name = state.data.display_name;
},
/**
* @override
* @returns {Promise}
*/
start: function () {
return this._super.apply(this, arguments).then(() => {
this.initSelect2();
});
},
/**
* @returns {JQuery}
*/
getInput: function () {
return this.$el.find('input');
},
/**
* @returns {String}
*/
getLoggedUserPicture: function () {
return `/web/image?model=res.users&field=avatar_128&id=${session.uid}`;
},
initSelect2: function () {
const cache = {
results: [{
text: _t('Categories'),
children: [{
id: 'private',
text: _t('Private'),
selected: true
}, {
id: 'workspace',
text: _t('Workspace')
}]
}]
};
const $input = this.getInput();
$input.select2({
containerCssClass: 'o_knowledge_select2',
dropdownCssClass: 'o_knowledge_select2',
data: cache, // Pre-fetched records
ajax: {
/**
* @param {String} term
* @returns {Object}
*/
data: term => {
return { term };
},
/**
* @param {Object} params - parameters
*/
transport: async params => {
const { term } = params.data;
const { state } = this.props;
const results = await this._rpc({
model: 'knowledge.article',
method: 'get_valid_parent_options',
args: [state.data.id],
kwargs: { search_term: term }
});
params.success({ term, results });
},
/**
* @param {Object} data
* @returns {Object}
*/
processResults: function (data) {
const records = { results: [] };
for (const result of cache.results) {
if (typeof result.children === 'undefined') {
records.results.push(result);
continue;
}
const children = result.children.filter(child => {
const text = child.text.toLowerCase();
const term = data.term.toLowerCase();
return text.indexOf(term) >= 0;
});
if (children.length > 0) {
records.results.push({...result, children});
}
}
if (data.results.length > 0) {
records.results.push({
text: _t('Articles'),
children: data.results.map(record => {
return {
id: record.id,
text: record.display_name,
subject: record.root_article_id[1],
}
})
});
}
return records;
},
},
/**
* @param {Object} data
* @param {JQuery} container
* @param {Function} escapeMarkup
*/
formatSelection: (data, container, escapeMarkup) => {
const markup = [];
if (data.id === 'private') {
const src = escapeMarkup(this.getLoggedUserPicture());
markup.push(`<img src="${src}" class="rounded-circle me-1"/>`);
}
markup.push(escapeMarkup(data.text));
return markup.join('');
},
/**
* @param {Object} result
* @param {JQuery} container
* @param {Object} query
* @param {Function} escapeMarkup
*/
formatResult: (result, container, query, escapeMarkup) => {
const { text, subject } = result;
const markup = [];
window.Select2.util.markMatch(text, query.term, markup, escapeMarkup);
if (result.id === 'private') {
const src = escapeMarkup(this.getLoggedUserPicture());
markup.unshift(`<img src="${src}" class="rounded-circle me-1"/>`);
}
if (subject && subject !== text) {
markup.push(`<span class="test-ellipsis small"> - ${escapeMarkup(subject)}</span>`);
}
return markup.join('');
},
});
},
/**
* @override
*/
save: function () {
const $input = this.getInput();
const data = $input.select2('data');
this.props.onSave(data.id);
},
});
export {
MoveArticleToDialog,
};
@@ -1,59 +0,0 @@
/** @odoo-module **/
import Widget from 'web.Widget';
import { ComponentWrapper, WidgetAdapterMixin } from 'web.OwlCompatibility';
import EmojiPicker from '../../components/emoji_picker/emoji_picker.js';
const EmojiPickerWidget = Widget.extend(WidgetAdapterMixin, {
events: {
'click .dropdown-menu': '_onDropdownClick'
},
/**
* @override
* @param {Object} parent
* @param {Object} options
* @param {integer} options.article_id
*/
init: function (parent, options) {
this._super.apply(this, arguments);
this.options = options;
},
/**
* @override
*/
start: function () {
this.component = new ComponentWrapper(this, EmojiPicker, {
/**
* @param {String} unicode
*/
onClickEmoji: unicode => {
this.trigger_up('emoji_click', {
articleId: this.options.article_id,
unicode: unicode || false,
});
this.close();
},
});
const menu = this.el.querySelector('.dropdown-menu');
return this.component.mount(menu);
},
/**
* Closes the dropdown
*/
close: function () {
this.$('.dropdown-menu').removeClass('show');
},
/**
* @param {Event} event
*/
_onDropdownClick: function (event) {
event.preventDefault();
event.stopPropagation();
},
});
export default EmojiPickerWidget;
@@ -1,40 +0,0 @@
/** @odoo-module **/
import Widget from 'web.Widget';
import { ComponentWrapper, WidgetAdapterMixin } from 'web.OwlCompatibility';
import PermissionPanel from '../../components/permission_panel/permission_panel.js';
const PermissionPanelWidget = Widget.extend(WidgetAdapterMixin, {
/**
* @override
* @param {Widget} parent
* @param {Object} options
*/
init: function (parent, options) {
this._super.apply(this, arguments);
this.options = options;
},
/**
* @override
*/
start: function () {
this.component = new ComponentWrapper(this, PermissionPanel, this.options);
this.component.env.bus.on('reload_tree', this, this._onReloadTree);
return this.component.mount(this.el);
},
/**
* @override
*/
destroy: function () {
this.component.env.bus.off('reload_tree', this._onReloadTree);
return this._super(...arguments);
},
_onReloadTree: function () {
this.trigger_up('reload_tree', {});
},
});
export default PermissionPanelWidget;
-182
View File
@@ -1,182 +0,0 @@
/** @odoo-module **/
import { qweb as QWeb } from 'web.core';
import Wysiwyg from 'web_editor.wysiwyg';
import { KnowledgeArticleLinkModal } from './wysiwyg/knowledge_article_link.js';
import { preserveCursor, setCursorStart } from '@web_editor/js/editor/odoo-editor/src/OdooEditor';
Wysiwyg.include({
/**
* @override
*/
init: function (parent, options) {
if (options.knowledge_commands) {
/**
* knowledge_commands is a view option from a field_html that
* indicates that knowledge-specific commands should be loaded.
* powerboxFilters is an array of functions used to filter commands
* displayed in the powerbox.
*/
options.powerboxFilters = options.powerboxFilters ? options.powerboxFilters : [];
options.powerboxFilters.push(this._filterKnowledgeCommandGroupInTemplate);
}
this._super.apply(this, arguments);
},
/**
* Prevent usage of commands from the group "Knowledge" inside the block
* inserted by the /template Knowledge command. The content of a /template
* block is destined to be used in @see OdooEditor in modules other than
* Knowledge, where knowledge-specific commands may not be available.
* i.e.: prevent usage /template in a /template block
*
* @param {Array} commands commands available in this wysiwyg
* @returns {Array} commands which can be used after the filter was applied
*/
_filterKnowledgeCommandGroupInTemplate: function (commands) {
let anchor = document.getSelection().anchorNode;
if (anchor.nodeType !== Node.ELEMENT_NODE) {
anchor = anchor.parentElement;
}
if (anchor && anchor.closest('.o_knowledge_template')) {
commands = commands.filter(command => command.groupName != 'Knowledge');
}
return commands;
},
/**
* @override
* @returns {Array[Object]}
*/
_getCommands: function () {
const commands = this._super();
commands.push({
groupName: 'Medias',
title: 'Article',
description: 'Link an article.',
fontawesome: 'fa-file',
callback: () => {
this._insertArticleLink();
},
});
if (this.options.knowledge_commands) {
commands.push({
groupName: 'Knowledge',
title: 'File',
description: 'Embed a file.',
fontawesome: 'fa-file',
callback: () => {
this.openMediaDialog({
noVideos: true,
noImages: true,
noIcons: true,
noDocuments: true,
knowledgeDocuments: true,
});
}
}, {
groupName: 'Knowledge',
title: "Template",
description: "Add a template section.",
fontawesome: 'fa-pencil-square',
callback: () => {
this._insertTemplate();
},
});
}
return commands;
},
/**
* Notify @see FieldHtmlInjector that toolbars need to be injected
* @see KnowledgeToolbar
*
* @param {Element} container
*/
_notifyNewToolbars(container) {
const toolbarsData = [];
container.querySelectorAll('.o_knowledge_toolbar_anchor').forEach(function (container, anchor) {
const type = Array.from(anchor.classList).find(className => className.startsWith('o_knowledge_toolbar_type_'));
if (type) {
toolbarsData.push({
container: container,
anchor: anchor,
type: type,
});
}
}.bind(this, container));
this.$editable.trigger('refresh_toolbars', { toolbarsData: toolbarsData });
},
/**
* Notify @see FieldHtmlInjector that behaviors need to be injected
* @see KnowledgeBehavior
*
* @param {Element} anchor
*/
_notifyNewBehaviors(anchor) {
const behaviorsData = [];
const type = Array.from(anchor.classList).find(className => className.startsWith('o_knowledge_behavior_type_'));
if (type) {
behaviorsData.push({
anchor: anchor,
type: type,
});
}
this.$editable.trigger('refresh_behaviors', { behaviorsData: behaviorsData});
},
/**
* Insert a /template block
*/
_insertTemplate() {
const templateFragment = new DocumentFragment();
const templateBlock = $(QWeb.render('knowledge.template_block', {}))[0];
templateFragment.append(templateBlock);
const [container] = this.odooEditor.execCommand('insertFragment', templateFragment);
setCursorStart(container.querySelector('.o_knowledge_content > p'));
this._notifyNewToolbars(container);
this._notifyNewBehaviors(container);
},
/**
* Insert a /article block (through a dialog)
*/
_insertArticleLink: function () {
const restoreSelection = preserveCursor(this.odooEditor.document);
const dialog = new KnowledgeArticleLinkModal(this, {});
dialog.on('save', this, article => {
restoreSelection();
if (article) {
const articleLinkFragment = new DocumentFragment();
const articleLinkBlock = $(QWeb.render('knowledge.wysiwyg_article_link', {
display_name: article.display_name,
href: '/knowledge/article/' + article.id,
article_id: article.id,
}))[0];
articleLinkFragment.append(articleLinkBlock);
const [anchor] = this.odooEditor.execCommand('insertFragment', articleLinkFragment);
this._notifyNewBehaviors(anchor);
}
dialog.close();
});
dialog.on('closed', this, () => {
restoreSelection();
});
dialog.open();
},
/**
* Notify the @see FieldHtmlInjector when a /file block is inserted from a
* @see MediaDialog
*
* @private
* @override
*/
_onMediaDialogSave(params, element) {
const result = this._super(...arguments);
if (!result) {
return;
}
const [container] = result;
if (container.classList.contains('o_knowledge_file')) {
setCursorStart(container.nextElementSibling);
this._notifyNewToolbars(container);
this._notifyNewBehaviors(container);
}
return result;
},
});
@@ -1,127 +0,0 @@
/** @odoo-module **/
"use strict";
import Dialog from 'web.Dialog';
import { _t } from 'web.core';
const KnowledgeArticleLinkModal = Dialog.extend({
template: 'knowledge.wysiwyg_article_link_modal',
xmlDependencies: Dialog.prototype.xmlDependencies.concat(
['/knowledge/static/src/xml/knowledge_editor.xml']
),
/**
* @override
* @param {Widget} parent
* @param {Object} options
*/
init: function (parent, options) {
// Set default options:
options.title = options.title || _t('Link an Article');
options.buttons = options.buttons || [{
text: _t('Insert Link'),
classes: 'btn-primary',
click: this.save.bind(this)
}, {
text: _t('Cancel'),
close: true
}];
this._super(...arguments);
},
/**
* @override
* @returns {Promise}
*/
start: function () {
return this._super.apply(this, arguments).then(() => {
this.initSelect2();
});
},
/**
* @returns {JQuery}
*/
getInput: function () {
return this.$el.find('input');
},
initSelect2: function () {
const $input = this.getInput();
$input.select2({
containerCssClass: 'o_knowledge_select2',
dropdownCssClass: 'o_knowledge_select2',
ajax: {
/**
* @param {String} term
* @returns {Object}
*/
data: term => {
return { term };
},
/**
* @param {Object} params - parameters
*/
transport: async params => {
const { term } = params.data;
const results = await this._rpc({
model: 'knowledge.article',
method: 'search_read',
kwargs: {
fields: ['id', 'display_name', 'root_article_id'],
domain: [['name', '=ilike', `%${term}%`]],
},
});
params.success({ results });
},
/**
* @param {Object} data
* @returns {Object}
*/
processResults: data => {
return {
results: data.results.map(record => {
return {
id: record.id,
display_name: record.display_name,
subject: record.root_article_id[1],
};
})
};
},
},
/**
* @param {Object} data
* @param {JQuery} container
* @param {Function} escapeMarkup
*/
formatSelection: (data, container, escapeMarkup) => {
return escapeMarkup(data.display_name);
},
/**
* @param {Object} result
* @param {JQuery} container
* @param {Object} query
* @param {Function} escapeMarkup
*/
formatResult: (result, container, query, escapeMarkup) => {
const { display_name, subject } = result;
const markup = [];
window.Select2.util.markMatch(display_name, query.term, markup, escapeMarkup);
if (subject !== display_name) {
markup.push(`<span class="test-ellipsis small"> - ${escapeMarkup(subject)}</span>`);
}
return markup.join('');
},
});
},
save: function () {
const $input = this.getInput();
const data = $input.select2('data');
this.trigger('save', data);
},
});
export {
KnowledgeArticleLinkModal
};
@@ -1,12 +0,0 @@
/** @odoo-module **/
import { addRecordMethods } from '@mail/model/model_core';
import core from 'web.core';
addRecordMethods('Chatter', {
onClickChatterSearchArticle(event) {
core.bus.trigger("openMainPalette", {
searchValue: "?",
});
},
});
@@ -1,42 +0,0 @@
.o_knowledge_file {
margin-bottom: 5px;
border-radius: 5px;
background-color: $o-gray-100;
display: flex;
padding: 5px;
& > .o_knowledge_file_image {
display: flex;
}
& > .o_knowledge_file_description {
margin-left: 5px;
margin-right: 5px;
}
}
.o_knowledge_file_extension {
text-transform: uppercase;
font-size: $font-size-sm !important;
}
.o_knowledge_template {
margin-bottom: 5px;
border-radius: 5px;
background-color: $o-gray-100;
& > .o_knowledge_content {
padding: 5px;
border-bottom: 5px solid $o-gray-100;
margin: 0 5px;
background-color: $white;
p:last-child {
margin-bottom: 0;
}
}
.o_knowledge_toolbar_anchor {
min-height: 5px;
}
}
.o_knowledge_behavior_type_article {
border-color: $o-gray-200;
}
@@ -1,116 +0,0 @@
$o-knowledge-font-configs: (
'Roboto': (
'family': ('Roboto', sans-serif),
'url': 'Roboto:400,400i,700,700i',
),
'Source Sans Pro': (
'family': ('Source Sans Pro', sans-serif),
'url': 'Source+Sans+Pro:400,400i,700,700i',
)
) !default;
@each $font-name, $font-config in $o-knowledge-font-configs {
$url: map-get($font-config, 'url');
@if $url {
@import url("https://fonts.googleapis.com/css?family=#{unquote($url)}&display=swap");
}
}
.o_knowledge_editor {
min-height: 100%;
font-family: "Roboto", "Odoo Unicode Support Noto", sans-serif;
font-weight: 400;
font-size: 1rem;
line-height: 1.5;
color: #212529;
.oe_form_field {
display: flex;
flex-direction: column;
width: 100%;
}
.note-editable {
border: none;
background: transparent;
}
.note-editable,
.o_readonly {
flex-grow: 1;
padding-top: 1rem !important;
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
font-family: "Source Sans Pro", "Odoo Unicode Support Noto", sans-serif;
font-weight: 500;
line-height: 1.2;
}
.btn {
font-family: "Source Sans Pro", "Odoo Unicode Support Noto", sans-serif;
}
}
}
.o_knowledge_article_view_form_dynamic_width.o_invisible_modifier + .o_knowledge_editor {
.note-editable,
.o_readonly {
max-width: 40rem;
margin-left: auto;
margin-right: auto;
}
}
@include media-breakpoint-down(xl) {
.o_knowledge_editor .note-editable,
.o_knowledge_editor .o_readonly {
padding-bottom: 1rem;
h1 {
font-size: calc(1.375rem + 1.5vw);
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
}
}
@include media-breakpoint-up(lg) {
.o_knowledge_editor .note-editable,
.o_knowledge_editor .o_readonly {
padding-bottom: 50vh;
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.75rem;
}
h4 {
font-size: 1.5rem;
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
}
}
@media print {
.o_knowledge_editor .note-editable,
.o_knowledge_editor .o_readonly {
padding-bottom: 0;
}
}
@@ -1,110 +0,0 @@
.o_knowledge_form_view {
// Header
// --------------------------------------------------------------------------
.o_knowledge_header {
cursor: default;
}
.o_favorites_toggle_button {
i.fa-star, i:hover {
color: #f3cc00;
}
font-size: 16px;
cursor: pointer;
}
.o_view_nocontent_empty_folder:before {
@extend %o-nocontent-empty-document;
}
// Body, cover and icon
// --------------------------------------------------------------------------
.o_knowledge_icon:not(.o_full_width),
.o_knowledge_article:not(.o_full_width) {
width: 750px;
max-width: 100%;
margin-left: auto;
margin-right: auto;
}
.o_knowledge_cover_image {
width: 100%;
img {
width: 100%;
height: 30vh;
min-height: 150px;
object-fit: cover;
object-position: 50% 50%;
}
}
.o_knowledge_cover_image + .o_knowledge_icon {
margin-top: -70px;
}
.o_knowledge_icon {
font-size: 80px;
}
// Sidebar
// --------------------------------------------------------------------------
.o_knowledge_aside {
width: var(--sidebar-size);
transition: width 0.15s linear;
li {
padding: 0.2em 0;
position: relative;
}
ul {
list-style: none;
padding: 0;
}
li > ul > li:not(.ui-sortable-helper) {
margin-left: 1.28571429em * 0.5; // Workaround to get half .fa-fw icon's width.
padding-left: 0.8em;
&:before, &:after {
content: '';
background-color: $border-color;
}
&:before {
@include o-position-absolute($top: 1em, $left: 0);
width: .6em;
height: 1px;
margin: auto;
}
&:after {
@include o-position-absolute(0, auto, 0, 0);
width: 1px;
height: 100%;
}
&:last-child {
padding-bottom: 0;
&:after {
height: 1em;
}
}
}
.o_article_name a {
cursor: pointer;
color: inherit;
text-decoration: none;
}
}
}
@include media-breakpoint-up(sm) {
.o_knowledge_form_view {
.o_scroll_view {
@include o-position-absolute(0, 0, 0, 0);
overflow-y: auto;
overflow-x: hidden;
}
}
}
@@ -1,283 +0,0 @@
.o_knowledge_form_view {
--default-sidebar-size: 300px;
--sidebar-size: clamp(300px, var(--default-sidebar-size), 50vw);
$o-knowledge-bg--active: #cceaf9; // TMP -> should match $o-searchpanel-active-bg
a.o_article_emoji:hover {
background-color: rgba(#000, .1);
}
// = Navbar
// --------------------------------------------------------------------------
.o_breadcrumb_article_name:not(:focus) {
--o-input-border-color: transparent;
}
.o_breadcrumb_article_name:not(.o_readonly_modifier):hover {
border-bottom: $input-border-width solid $o-brand-primary;
}
.o_toggle_favorite {
font-size: 16px;
&.fa-star, &:hover {
color: $o-main-favorite-color;
}
}
.o_knowledge_add_icon, .o_knowledge_add_cover {
visibility: hidden;
opacity: 0;
transition: visibility 0.4s, opacity 0.4s linear;
}
.o_knowledge_body:hover .o_knowledge_add_icon, .o_knowledge_body:hover .o_knowledge_add_cover {
visibility: visible;
opacity: 1;
}
.o_knowledge_article_view_form_dynamic_width.o_invisible_modifier ~ .o_knowledge_icon,
.o_knowledge_article_view_form_dynamic_width.o_invisible_modifier ~ .o_knowledge_icon_cover_buttons,
.o_knowledge_article_view_form_dynamic_width.o_invisible_modifier ~ .o_knowledge_editor .oe_form_field .note-editable,
.o_knowledge_article_view_form_dynamic_width.o_invisible_modifier ~ .o_knowledge_editor .oe_form_field .o_readonly {
width: 100%;
max-width: 750px;
margin-left: auto;
margin-right: auto;
}
// = Sidebar
// --------------------------------------------------------------------------
.o_section .o_tree {
min-height: 2rem;
}
.o_knowledge_aside {
width: var(--sidebar-size);
transition: width 0.15s linear;
.o_article_active, .o_article_emoji_active {
background-color: $o-knowledge-bg--active;
}
ul {
list-style: none;
padding: 0;
}
li {
padding: 0.2em 0;
position: relative;
}
li > ul > li:not(.ui-sortable-helper) {
margin-left: 1.28571429em * 0.5; // Workaround to get half .fa-fw icon's width.
padding-left: 0.8em;
&:before, &:after {
content: '';
background-color: $border-color;
}
&:before {
@include o-position-absolute($top: 1em, $left: 0);
width: .6em;
height: 1px;
margin: auto;
}
&:after {
@include o-position-absolute(0, auto, 0, 0);
width: 1px;
height: 100%;
}
&:last-child {
padding-bottom: 0;
&:after {
height: 1em;
}
}
}
.o_section_header:not(:hover) .o_section_create,
.o_article_handle:not(:hover) .o_article_create {
visibility: hidden;
}
.o_article_name a {
color: inherit;
}
}
// = Resizer
// --------------------------------------------------------------------------
.o_knowledge_article_form_resizer {
// Use '$spacers' measures to match surrounding elements padding
$-resizer-spacing: map-get($spacers, 3);
$-resizer-line-width: map-get($spacers, 1);
@include o-position-absolute(0, auto, 0, $-resizer-spacing * -1);
cursor: ew-resize;
touch-action: none;
span {
@include o-position-absolute(0, auto, 0, $-resizer-spacing + ($-resizer-line-width * -0.5));
}
}
.o_knowledge_body {
min-height: 100%;
}
.o_knowledge_chatter {
li.nav-item {
display: none;
}
}
.dropdown-menu.o_article_emoji_dropdown_panel {
width: 200px;
}
.dropdown-menu.o_knowledge_more_options_panel {
width: 250px;
}
.dropdown-menu.o_knowledge_share_panel {
width: 420px;
overflow-x: hidden;
overflow-y: auto;
.o_m2m_avatar {
max-width: 30px;
}
.o_clipboard_button {
padding: 0 10px;
}
}
}
.o_knowledge_select2 {
img {
width: 20px;
height: 20px;
}
.select2-highlighted {
background: $o-brand-odoo !important;
color: $o-white;
}
}
.o_knowledge_article_kanban_view {
.o_knowledge_kanban_card {
min-height: 10rem;
}
}
// Media queries:
@include media-breakpoint-up(lg) {
.o_knowledge_form_view {
.o_scroll_view_lg {
@include o-position-absolute(0, 0, 0, 0);
overflow-y: auto;
overflow-x: hidden;
}
.o_knowledge_chatter {
border-left: 1px solid $border-color;
min-width: 400px;
}
}
}
@include media-breakpoint-down(xl) {
.o_knowledge_form_view {
.o_scroll_view_lg {
width: 100%;
}
.o_knowledge_chatter {
border-top: 1px solid $border-color;
}
}
}
@include media-breakpoint-up(sm) {
.o_knowledge_form_view {
.o_scroll_view {
@include o-position-absolute(0, 0, 0, 0);
overflow-y: auto;
overflow-x: hidden;
}
.o_knowledge_cover_image {
width: 100%;
img {
width: 100%;
height: 30vh;
min-height: 150px;
object-fit: cover;
object-position: 50% 50%;
}
}
span.o_invisible_modifier + .o_knowledge_icon {
margin-top: -70px;
}
.o_knowledge_icon {
&.o_large {
font-size: 80px;
}
}
.o_knowledge_icon.o_large,
.o_knowledge_icon_cover_buttons,
.o_knowledge_editor .oe_form_field .note-editable,
.o_knowledge_editor .oe_form_field .o_readonly {
padding-left: 3rem !important; // gutter size of the editor
padding-right: 3rem !important;
}
}
}
@include media-breakpoint-down(md) {
.o_knowledge_form_view {
.o_knowledge_icon,
.o_knowledge_icon_cover_buttons,
.o_knowledge_editor .oe_form_field .note-editable,
.o_knowledge_editor .oe_form_field .o_readonly {
padding-left: 1rem !important; // gutter size of the editor
padding-right: 1rem !important;
}
}
}
@include media-breakpoint-down(sm) {
.o_knowledge_form_view {
.o_knowledge_aside {
width: 100%;
}
}
}
@media print {
html,
body,
html .o_web_client {
height: auto;
}
.o_knowledge_form_view {
.o_scroll_view,
.o_scroll_view_lg {
position: static;
overflow-y: visible;
}
}
}
// Knowledge Toolbars
.o_knowledge_toolbar {
display: flex;
justify-content: flex-end;
white-space: nowrap;
button {
font-family: $font-family-base !important;
font-weight: $font-weight-bold;
}
}
@media screen and (max-width: 1024px) {
.o_knowledge_toolbar_button_text {
display: none;
}
}
@@ -1,9 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
const commandCategoryRegistry = registry.category("command_categories");
// displays the articles on input "?"
commandCategoryRegistry.add("knowledge_articles", { namespace: "?" }, { sequence: 10 });
// displays the advanced search command on input "?"
commandCategoryRegistry.add("knowledge_extra", { namespace: "?" }, { sequence: 20 });
@@ -1,124 +0,0 @@
/** @odoo-module */
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { HotkeyCommandItem } from "@web/core/commands/default_providers";
import { splitCommandName } from "@web/core/commands/command_palette";
import { sprintf } from '@web/core/utils/strings';
const { Component } = owl;
// Articles command
class KnowledgeCommand extends Component {}
KnowledgeCommand.template = 'KnowledgeCommandTemplate';
// "Not found, create one" command
class Knowledge404Command extends Component {}
Knowledge404Command.template = 'Knowledge404CommandTemplate';
// Advanced search command
class KnowledgeExtraCommand extends HotkeyCommandItem {}
KnowledgeExtraCommand.template = 'KnowledgeExtraCommandTemplate';
const commandSetupRegistry = registry.category("command_setup");
commandSetupRegistry.add("?", {
debounceDelay: 200,
emptyMessage: _lt("No article found."),
name: _lt("articles"),
placeholder: _lt("Search for an article..."),
});
const commandProviderRegistry = registry.category("command_provider");
commandProviderRegistry.add("knowledge", {
debounceDelay: 200,
namespace: "?",
async provide(env, options) {
const articlesData = await Component.env.services.rpc({
model: "knowledge.article",
method: "get_user_sorted_articles",
args: [[]],
kwargs: {
search_query: options.searchValue,
}
});
if (articlesData.length === 0) {
// check if user has enough rights to create a new article
const canCreate = await Component.env.services.rpc({
model: "knowledge.article",
method: "check_access_rights",
kwargs: {
operation: "create",
raise_exception: false,
},
});
// only display the "create article" command when there are at least 3 character
if (canCreate && options.searchValue.length > 2) {
return [{
Component: Knowledge404Command,
async action() {
const articleId = await Component.env.services.rpc({
model: 'knowledge.article',
method: 'article_create',
args: [options.searchValue],
kwargs: {
is_private: true
},
});
env.services.action.doAction('knowledge.ir_actions_server_knowledge_home_page', {
additionalContext: {
res_id: articleId,
}
});
},
name: sprintf(_lt('No Article found. Create "%s"'), options.searchValue),
props: {
articleName: options.searchValue,
},
}];
}
else {
return [];
}
}
// display the articles
let result = articlesData.map((article) => ({
Component: KnowledgeCommand,
action() {
env.services.action.doAction('knowledge.ir_actions_server_knowledge_home_page', {
additionalContext: {
res_id: article.id,
}
});
},
category: "knowledge_articles",
name: article.name,
props: {
isFavorite: article.is_user_favorite,
subjectName: article.root_article_id[0] != article.id ? article.root_article_id[1] : false,
splitSubjectName: splitCommandName(article.root_article_id[1], options.searchValue),
icon_string: article.icon,
},
}));
// add the "advanced search" command
result.push({
Component: KnowledgeExtraCommand,
action() {
env.services.action.doAction('knowledge.knowledge_article_action', {
additionalContext: {
search_default_name: options.searchValue,
},
});
},
category: "knowledge_extra",
name: _lt("Advanced Search"),
props: {
hotkey: "alt+B",
},
});
return result;
},
});
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ChatterTopbar" t-inherit="mail.ChatterTopbar" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('o_ChatterTopbar_rightSection')]/child::*" position="before">
<button class="btn btn-link o_ChatterTopbar_button" type="button" t-att-disabled="chatterTopbar.chatter.isDisabled" t-on-click="chatterTopbar.chatter.onClickChatterSearchArticle" title="Search Knowledge Articles">
<i class="fa fa-book"/>
</button>
</xpath>
</t>
</templates>
@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="KnowledgeCommandTemplate" owl="1">
<div class="o_command_left d-flex align-items-center">
<span class="pe-2" t-if="props.icon_string" t-out="props.icon_string" />
<span class="pe-2" t-else="">📄</span>
<span class="pe-2 test-ellipsis">
<t t-slot="name" />
</span>
<icon t-if="props.isFavorite" class="fa fa-star o_favorite pe-2 position-static" />
<span t-if="props.subjectName" t-out="'— '" class="text-muted small pe-2" />
<span t-if="props.subjectName" class="o_command_name test-ellipsis text-muted small">
<t t-foreach="props.splitSubjectName" t-as="name" t-key="name_index">
<b t-if="name_index % 2" t-out="name" />
<t t-else="" t-out="name" />
</t>
</span>
</div>
</t>
<t t-name="Knowledge404CommandTemplate" owl="1">
<div class="o_command_hotkey">
<span>
No Article found.
<span class="text-primary">
Create "<u t-out="props.articleName" />"
</span>
</span>
</div>
</t>
<t t-name="KnowledgeExtraCommandTemplate" owl="1">
<div class="o_command_hotkey">
<span>
<icon class="fa fa-arrows-alt pe-2" />
<t t-out="props.name" />
</span>
<span>
<t t-foreach="getKeysToPress(props)" t-as="key" t-key="key_index">
<kbd t-out="key" />
<span t-if="!key_last"> + </span>
</t>
</span>
</div>
</t>
</templates>
@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="knowledge.injector_component" owl="1"></t>
<t t-name="knowledge.file_block">
<div class="o_knowledge_file o_knowledge_toolbars_container o_knowledge_behavior_anchor o_knowledge_behavior_type_contents_container">
<div class="o_knowledge_file_image"/>
<div class="o_knowledge_file_description">
<div class="o_knowledge_file_name" t-out="img.name"/>
<div class="o_knowledge_file_extension" t-out="img.extension"/>
</div>
<div class="o_knowledge_toolbar_anchor o_knowledge_toolbar_type_file mt-auto ms-auto"/>
</div>
</t>
<t t-name="knowledge.template_block">
<div class="o_knowledge_template o_knowledge_toolbars_container o_knowledge_behavior_anchor o_knowledge_behavior_type_contents_container">
<div class="o_knowledge_toolbar_anchor o_knowledge_toolbar_type_template"/>
<div class="o_knowledge_content">
<p><br/></p>
</div>
</div>
</t>
<t t-name="knowledge.wysiwyg_article_link">
<a t-att-href="href" t-att-data-res_id="article_id"
class="btn btn-sm o_knowledge_behavior_anchor o_knowledge_behavior_type_article">
<t t-out="display_name"/>
</a>
</t>
<t t-name="knowledge.wysiwyg_article_link_modal">
<form class="container">
<div class="form-group m-0">
<input class="form-control" type="hidden" placeholder="Choose an Article"/>
</div>
</form>
</t>
</templates>
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="knowledge.knowledge_article_caret">
<button class="o_article_caret btn btn-link text-muted p-0">
<i class="align-self-center fa fa-fw fa-caret-down"/>
</button>
</t>
<t t-name="knowledge.knowledge_breadcrumb_item">
<li class="breadcrumb-item">
<a href="#" t-out="payload.title" t-att-data-controller-id="payload.controllerID"/>
</li>
</t>
<t t-name="knowledge.knowledge_move_article_to_modal">
<form class="container">
<div class="mb-3">
Move "<span t-out="widget.article_name" class="fw-bold"/>" under:
</div>
<div class="form-group m-0">
<input class="form-control" type="hidden" placeholder="Search an Article to use as Parent"/>
</div>
</form>
</t>
</templates>
@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<t t-name="knowledge.template_toolbar">
<div class="o_knowledge_toolbar" contenteditable="false">
<div class="btn-group">
<button t-if="widget.recordWithChatter" data-call="send_as_message" title="Send as Message" class="btn btn-link btn-sm border-0">
<i class="fa fa-envelope"/> <span class="o_knowledge_toolbar_button_text">Send as Message</span>
</button>
<button t-if="widget.recordWithHtmlField" data-call="use_as_description" title="Use as Description" class="btn btn-link btn-sm border-0">
<i class="fa fa-pencil-square"/> <span class="o_knowledge_toolbar_button_text">Use as <t t-out="widget.recordWithHtmlField.fieldNames[0].string"/></span>
</button>
<button data-call="copy_to_clipboard" title="Copy to Clipboard" class="btn btn-link btn-sm border-0">
<i class="fa fa-clipboard"/> <span class="o_knowledge_toolbar_button_text">Copy to Clipboard</span>
</button>
</div>
</div>
</t>
<t t-name="knowledge.file_toolbar">
<div class="o_knowledge_toolbar" contenteditable="false">
<div class="btn-group">
<button t-if="widget.recordWithChatter" data-call="attach_to_message" title="Send as Message" class="btn btn-link btn-sm border-0">
<i class="fa fa-envelope"/> <span class="o_knowledge_toolbar_button_text">Send as Message</span>
</button>
<button t-if="widget.recordWithChatter" data-call="use_as_attachment" title="Use as Attachment" class="btn btn-link btn-sm border-0">
<i class="fa fa-paperclip"/> <span class="o_knowledge_toolbar_button_text">Use as Attachment</span>
</button>
<button data-call="download" title="Download" class="btn btn-link btn-sm border-0">
<i class="fa fa-download"/> <span class="o_knowledge_toolbar_button_text">Download</span>
</button>
</div>
</div>
</t>
</templates>
@@ -1,15 +0,0 @@
/** @odoo-module */
import { testEnvServices } from '@web/../tests/legacy/helpers/test_services';
Object.assign(testEnvServices, {
knowledgeService: {
pushToValidateWithHtmlField: () => {},
popToValidateWithHtmlField: () => {},
registerRecord: () => {},
unregisterRecord: () => {},
getAvailableRecordWithChatter: () => {},
getAvailableRecordWithHtmlField: () => {},
getRecords: () => new Set(),
}
});
@@ -1,185 +0,0 @@
/** @odoo-module */
/**
* Global Knowledge flow tour.
* Features tested:
* - Create an article
* - Change its title / content
* - Share an article with a created partner
* - Create 2 children articles and invert their order
* - Favorite 2 different articles and invert their order in the favorite section
*/
import tour from 'web_tour.tour';
const moveArticle = ($element, $target, offsetX, offsetY) => {
const elementCenter = $element.offset();
elementCenter.left += $element.outerWidth() / 2;
elementCenter.top += $element.outerHeight() / 2;
const targetCenter = $target.offset();
targetCenter.left += $target.outerWidth() / 2;
targetCenter.top += $target.outerHeight() / 2;
$element.trigger($.Event("mouseenter"));
$element.trigger($.Event("mousedown", {
which: 1,
pageX: elementCenter.left,
pageY: elementCenter.top,
}));
$element.trigger($.Event("mousemove", {
which: 1,
pageX: targetCenter.left + (offsetX || 0),
pageY: targetCenter.top + (offsetY || 0),
}));
$element.trigger($.Event("mouseup", {
which: 1,
pageX: targetCenter.left + (offsetX || 0),
pageY: targetCenter.top + (offsetY || 0),
}));
};
tour.register('knowledge_main_flow_tour', {
test: true,
url: '/web',
}, [tour.stepUtils.showAppsMenuItem(), {
// open Knowledge App
trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]',
}, {
// click on the main "Create" action
trigger: '.o_knowledge_header .btn:contains("Create")',
}, {
trigger: 'section[data-section="private"] .o_article .o_article_name:contains("New Article")',
run: () => {}, // check that the article is correctly created (private section)
}, {
trigger: '.o_breadcrumb_article_name',
run: 'text My Private Article', // modify the article name
}, {
trigger: '.note-editable.odoo-editor-editable',
run: 'text Content of My Private Article', // modify the article content
}, {
trigger: 'section[data-section="workspace"]',
run: () => {
// force the create button to be visible (it's only visible on hover)
$('section[data-section="workspace"] .o_section_create').css('visibility', 'visible');
},
}, {
// create an article in the "Workspace" section
trigger: 'section[data-section="workspace"] .o_section_create',
}, {
trigger: 'section[data-section="workspace"] .o_article .o_article_name:contains("New Article")',
run: () => {}, // check that the article is correctly created (workspace section)
}, {
trigger: '.o_breadcrumb_article_name',
run: 'text My Workspace Article', // modify the article name
}, {
trigger: '.note-editable.odoo-editor-editable',
run: 'text Content of My Workspace Article', // modify the article content
}, {
trigger: '.o_article:contains("My Workspace Article")',
run: () => {
// force the create button to be visible (it's only visible on hover)
$('.o_article:contains("My Workspace Article") button.o_article_create').css('visibility', 'visible');
},
}, {
// create child article
trigger: '.o_article:contains("My Workspace Article") button.o_article_create',
}, {
trigger: 'section[data-section="workspace"] .o_article .o_article_name:contains("New Article")',
run: () => {}, // check that the article is correctly created (workspace section)
}, {
trigger: '.o_breadcrumb_article_name',
run: 'text Child Article 1', // modify the article name
}, {
trigger: '.o_article:contains("My Workspace Article")',
run: () => {
// force the create button to be visible (it's only visible on hover)
$('.o_article:contains("My Workspace Article") button.o_article_create').css('visibility', 'visible');
},
}, {
// create child article (2)
trigger: '.o_article:contains("My Workspace Article") button.o_article_create',
}, {
trigger: 'section[data-section="workspace"] .o_article .o_article_name:contains("New Article")',
run: () => {}, // check that the article is correctly created (workspace section)
}, {
trigger: '.o_breadcrumb_article_name',
run: 'text Child Article 2', // modify the article name
}, {
// move child article 2 above child article 1
trigger: '.o_article_handle:contains("Child Article 2")',
run: () => {
moveArticle(
$('.o_article_handle:contains("Child Article 2")'),
$('.o_article_handle:contains("Child Article 1")'),
);
},
}, {
// go back to main workspace article
trigger: 'section[data-section="workspace"] .o_article .o_article_name:contains("My Workspace Article")',
}, {
trigger: '.o_knowledge_editor:contains("Content of My Workspace Article")',
run: () => {}, // wait for article to be correctly loaded
}, {
// open the share dropdown
trigger: '.o_knowledge_header .btn:contains("Share")',
}, {
// click on 'Invite'
trigger: '.o_knowledge_share_panel .btn:contains("Invite")',
}, {
// Type the invited person's name
trigger: '.o_field_many2one[name="partner_ids"] input',
run: 'text micheline@knowledge.com',
}, {
// Create the partner
trigger: '.ui-autocomplete.dropdown-menu a:contains("micheline@knowledge.com")',
in_modal: false,
}, {
// Submit the invite wizard
trigger: 'button:contains("Invite")',
in_modal: false,
}, {
// add to favorite
trigger: '.o_toggle_favorite',
}, {
// check article was correctly added into favorites
trigger: 'section.o_favorite_container .o_article .o_article_name:contains("My Workspace Article")',
run: () => {},
}, {
// open the share dropdown
trigger: '.o_knowledge_header .btn:contains("Share")',
}, {
// open the share dropdown
trigger: '.o_member_email:contains("micheline@knowledge.com")',
in_modal: false,
run: () => {},
}, {
// go back to main workspace article
trigger: 'section[data-section="private"] .o_article .o_article_name:contains("My Private Article")',
}, {
trigger: '.o_knowledge_editor:contains("Content of My Private Article")',
run: () => {}, // wait for article to be correctly loaded
}, {
// add to favorite
trigger: '.o_toggle_favorite',
}, {
// wait for the article to be registered as favorited
trigger: '.o_toggle_favorite.fa-star',
run: () => {},
}, {
// move private article above workspace article in the favorite section
// the mouse needs to be slightly above to actually move the element correctly, hence the offset
trigger: 'section.o_favorite_container .o_article_handle:contains("My Private Article")',
run: () => {
moveArticle(
$('section.o_favorite_container .o_article_handle:contains("My Private Article")'),
$('section.o_favorite_container .o_article_handle:contains("My Workspace Article")'),
0,
-1,
);
},
}, {
// go back to main workspace article
trigger: 'section[data-section="workspace"] .o_article .o_article_name:contains("My Workspace Article")',
}]);
@@ -1,48 +0,0 @@
/** @odoo-module */
import tour from 'web_tour.tour';
tour.register('knowledge_pick_emoji_tour', {
test: true,
url: '/web',
}, [tour.stepUtils.showAppsMenuItem(), {
// open Knowledge App
trigger: '.o_app[data-menu-xmlid="knowledge.knowledge_menu_root"]',
}, {
// click on the main "Create" action
trigger: '.o_knowledge_header .btn:contains("Create")',
}, {
trigger: 'section[data-section="private"] .o_article .o_article_name:contains("New Article")',
run: () => {}, // check that the article is correctly created (private section)
}, {
trigger: '.o_knowledge_icon_cover_buttons',
run: () => {
// force the cover buttons to be visible (it's only visible on hover)
$('.o_knowledge_add_icon, .o_knowledge_add_cover').css({
opacity: 1,
visibility: 'visible'
});
},
}, {
// add a random emoji
trigger: '.o_knowledge_add_icon',
run: 'click',
}, {
trigger: '.o_knowledge_body .o_article_emoji',
run: 'click',
}, {
trigger: '.o_article_emoji_dropdown_panel span[data-unicode="😃"]',
run: 'click',
}, {
// check that the emoji has been properly changed in the article body
trigger: '.o_knowledge_body .o_article_emoji:contains(😃)',
run: () => {},
}, {
// check that the emoji has been properly changed in the header
trigger: '.o_knowledge_header .o_article_emoji:contains(😃)',
run: () => {},
}, {
// check that the emoji has been properly changed in the aside block
trigger: '.o_knowledge_aside .o_article_emoji_active:contains(😃)',
run: () => {}
}]);
-12
View File
@@ -1,12 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_knowledge_article_business
from . import test_knowledge_article_constraints
from . import test_knowledge_article_internals
from . import test_knowledge_article_permissions
from . import test_knowledge_article_sequence
from . import test_knowledge_form_ui
from . import test_knowledge_performance
from . import test_knowledge_security
from . import test_res_users
-422
View File
@@ -1,422 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
from odoo.tools import mute_logger
class KnowledgeCommon(MailCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._activate_multi_company()
cls.user_portal = cls._create_portal_user()
cls.partner_portal = cls.user_portal.partner_id
cls.user_employee_manager = mail_new_test_user(
cls.env,
company_id=cls.company_admin.id,
country_id=cls.env.ref('base.be').id,
groups='base.group_user',
login='employee_manager',
name='Evelyne Employee',
notification_type='inbox',
signature='--\nEvelyne'
)
cls.partner_employee_manager = cls.user_employee_manager.partner_id
cls.user_employee2 = mail_new_test_user(
cls.env,
company_id=cls.company_admin.id,
country_id=cls.env.ref('base.be').id,
groups='base.group_user',
login='employee2',
name='Eglantine Employee',
notification_type='inbox',
signature='--\nEglantine'
)
cls.partner_employee2 = cls.user_employee2.partner_id
cls.user_public = mail_new_test_user(
cls.env,
company_id=cls.company_admin.id,
groups='base.group_public',
login='user_public',
name='Public Anonymous',
)
cls.partner_public = cls.user_public.partner_id
cls.customer = cls.env['res.partner'].create({
'country_id': cls.env.ref('base.be').id,
'email': 'corentine@test.example.com',
'mobile': '+32455001122',
'name': 'Corentine Customer',
'phone': '+32455334455',
})
def assertMembers(self, articles, exp_internal_permission, exp_partners_permissions, msg=None):
""" Custom assert for members, to ease writing tests. Check state of
members on articles, as sudo to avoid ACLs-based misunderstanding.
:param internal_permission: global internal permission of articles;
:param partners_permission: dict of pid: permission that are members
expected to be on articles;
"""
for article in articles.sudo():
self.assertEqual(article.internal_permission, exp_internal_permission)
self.assertEqual(
dict(
(member.partner_id, member.permission)
for member in article.article_member_ids
),
exp_partners_permissions,
msg
)
def assertSortedSequence(self, articles):
""" Assert that the articles are properly sorted according to their sequence
number
:param articles (Model<knowledge.article>): Recordset of knowledge.article
"""
for k in range(len(articles) - 1):
self.assertTrue(
articles[k].sequence <= articles[k + 1].sequence,
f'Article sequence issue: {articles[k].name} ({articles[k].sequence}) which is not <= than {articles[k + 1].name} ({articles[k + 1].sequence})')
def _create_private_article(self, name, target_user=None):
""" Due to membership model constraints, create test records as sudo
and return a record in current user environment. Creation itself is
not tested here. """
target_user = self.env.user if target_user is None else target_user
if target_user:
vals = {
'article_member_ids': [(0, 0, {
'partner_id': target_user.partner_id.id,
'permission': 'write',
})],
}
vals.update({
'internal_permission': 'none',
'name': name,
})
return self.env['knowledge.article'].sudo().create(vals).with_user(self.env.user)
class KnowledgeCommonWData(KnowledgeCommon):
""" Light setup of articles for knowledge tests. It holds data for the
three main categories: workspace, shared and private articles. Some
children exist to have some permission tweaks. """
@classmethod
def setUpClass(cls):
super().setUpClass()
with mute_logger('odoo.models.unlink'):
cls.env['knowledge.article'].search([]).unlink()
# - 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+)
# - Playground seq=999 workspace w+
# - Child1 seq=0 " "
# - Child2 seq=1 " "
cls._base_sequence = 999
cls.article_workspace = cls.env['knowledge.article'].create(
{'internal_permission': 'write',
'favorite_ids': [(0, 0, {'sequence': 1,
'user_id': cls.user_admin.id
}),
],
'name': 'Playground',
'sequence': cls._base_sequence,
}
)
cls.workspace_children = cls.env['knowledge.article'].create([
{'name': 'Playground Child1',
'parent_id': cls.article_workspace.id,
},
{'name': 'Playground Child2',
'parent_id': cls.article_workspace.id,
},
])
cls.article_shared = cls.env['knowledge.article'].create(
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_admin.id,
'permission': 'write',
}),
(0, 0, {'partner_id': cls.partner_employee.id,
'permission': 'read',
}),
(0, 0, {'partner_id': cls.partner_employee_manager.id,
'permission': 'read',
}),
],
'internal_permission': 'none',
'name': 'Shared',
'sequence': cls._base_sequence - 1,
}
)
cls.shared_children = cls.env['knowledge.article'].create([
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_employee.id,
'permission': 'write',
}),
],
'internal_permission': False,
'name': 'Shared Child1',
'parent_id': cls.article_shared.id,
},
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_portal.id,
'permission': 'read',
}),
],
'internal_permission': False,
'favorite_ids': [
(0, 0, {'sequence': 1,
'user_id': cls.user_portal.id,
}),
],
'name': 'Shared Child2',
'parent_id': cls.article_shared.id,
}
])
cls.article_private_manager = cls.env['knowledge.article'].create(
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_employee_manager.id,
'permission': 'write',
}),
],
'internal_permission': 'none',
'name': 'Private',
'sequence': cls._base_sequence - 2,
}
)
cls.private_children = cls.env['knowledge.article'].create([
{'internal_permission': False,
'name': 'Private Child1',
'parent_id': cls.article_private_manager.id,
}
])
cls.env.flush_all()
class KnowledgeArticlePermissionsCase(KnowledgeCommon):
""" Specific test class to test permission management, inheritance and
computation on article model, based on both article and member permissions.
This does not really test ACLs, more the internals of permissions that
are used afterwards in ACLs and in various business methods / flows. """
@classmethod
def setUpClass(cls):
super().setUpClass()
with mute_logger('odoo.models.unlink'):
cls.env['knowledge.article'].search([]).unlink()
# ------------------------------------------------------------
# Perm (" = inherited) + exceptions
# WRITABLE ROOT write
# - Community " (w)
# - Members " (w) (-employee-read)
# - Readonly read (+manager-write)
# - Writable " (w) (+portal-read)
# - Writable child " (w)
# - Child "
# - Nyarlathotep DESYNC read, manager-write
# - Child "
# READABLE ROOT read (+manager-write)
# - TTRPG " (r)
# - OpenCthulhu write (+portal-read)
# - MansionsOfTerror DESYNC none, employee-write, manager-read
# - Child "
# - OpenParanoïa write (-employee-read, +portal-read)
# - Proprietary " (r)
# - Secret none
# - Child "
# - Board Games " (r)
# SHARED ROOT none (+manager-write, +employee-read)
# PRIVATE ROOT none (+employee-write)
#
# ------------------------------------------------------------
cls.article_roots = cls.env['knowledge.article'].create([
{'name': 'Writable Root',
},
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_employee_manager.id,
'permission': 'write',
}),
], # ensure at least one write access
'internal_permission': 'read',
'name': 'Readable Root',
},
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_employee_manager.id,
'permission': 'write',
}),
(0, 0, {'partner_id': cls.partner_employee.id,
'permission': 'read',
}),
],
'internal_permission': 'none',
'favorite_ids': [
(0, 0, {'user_id': cls.user_employee_manager.id}),
(0, 0, {'user_id': cls.user_employee.id})
],
'name': 'Shared Root',
},
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_employee.id,
'permission': 'write',
}),
],
'internal_permission': 'none',
'name': 'Private Root',
}
])
cls.article_headers = cls.env['knowledge.article'].create([
# writable
{'name': 'Write-Community',
'parent_id': cls.article_roots[0].id,
},
# readable
{'name': 'Read-TTRPG',
'parent_id': cls.article_roots[1].id,
},
{'name': 'Read-Board Games',
'parent_id': cls.article_roots[1].id,
},
])
# Under Write internal permission
cls.article_write_contents = cls.env['knowledge.article'].create([
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_employee.id,
'permission': 'read',
}),
],
'favorite_ids': [
(0, 0, {'user_id': cls.user_admin.id}),
(0, 0, {'user_id': cls.user_employee.id})
],
'name': 'Members Subarticle',
'parent_id': cls.article_headers[0].id,
},
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_employee_manager.id,
'permission': 'write',
}),
], # ensure at least one write access
'internal_permission': 'read',
'name': 'Readonly Subarticle',
'parent_id': cls.article_headers[0].id,
},
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_portal.id,
'permission': 'read',
}),
],
'body': '<p>Writable Subarticle through inheritance</p>',
'favorite_ids': [
(0, 0, {'user_id': cls.user_admin.id}),
(0, 0, {'user_id': cls.user_employee.id}),
(0, 0, {'user_id': cls.user_portal.id}),
],
'name': 'Writable Subarticle through inheritance',
'parent_id': cls.article_headers[0].id,
},
])
cls.article_write_contents_children = cls.env['knowledge.article'].create([
{'name': 'Child of writable through inheritance',
'parent_id': cls.article_write_contents[2].id,
},
])
cls.article_write_contents_children += cls.env['knowledge.article'].create([
{'name': 'Child of child of writable through inheritance',
'parent_id': cls.article_write_contents_children[0].id,
},
])
cls.article_write_desync = cls.env['knowledge.article'].create([
# Community/Writable
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_employee_manager.id,
'permission': 'write',
}),
],
'internal_permission': 'read',
'is_desynchronized': True,
'name': 'Desync Nyarlathotep',
'parent_id': cls.article_write_contents[2].id,
},
])
cls.article_write_desync += cls.env['knowledge.article'].create([
{'name': 'Childof Desync Nyarlathotep',
'parent_id': cls.article_write_desync[0].id,
},
])
# Under Read internal permission
cls.article_read_contents = cls.env['knowledge.article'].create([
# TTRPG
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_portal.id,
'permission': 'read',
}),
],
'internal_permission': 'write',
'name': 'OpenCthulhu',
'parent_id': cls.article_headers[1].id,
},
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_portal.id,
'permission': 'read',
}),
(0, 0, {'partner_id': cls.partner_employee.id,
'permission': 'read',
}),
],
'internal_permission': 'write',
'name': 'Open Paranoïa',
'parent_id': cls.article_headers[1].id,
},
{'name': 'Proprietary RPGs',
'parent_id': cls.article_headers[1].id,
},
{'internal_permission': 'none',
'name': 'Secret RPGs',
'parent_id': cls.article_headers[1].id,
},
])
cls.article_read_contents_children = cls.env['knowledge.article'].create([
{'name': 'Child of Secret RPGs',
'parent_id': cls.article_read_contents[3].id,
},
])
cls.article_read_desync = cls.env['knowledge.article'].create([
# Read/TTRPG: Open Cthulhu
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_employee.id,
'permission': 'write',
}),
(0, 0, {'partner_id': cls.partner_employee_manager.id,
'permission': 'read',
}),
],
'internal_permission': 'none',
'is_desynchronized': True,
'name': 'Mansions of Terror',
'parent_id': cls.article_read_contents[0].id,
},
])
cls.article_read_desync += cls.env['knowledge.article'].create([
{'name': 'Childof Desync Mansions',
'parent_id': cls.article_read_desync[0].id,
},
])
cls.articles_all = cls.article_roots + cls.article_headers + \
cls.article_write_contents + cls.article_write_contents_children + \
cls.article_read_contents + cls.article_read_contents_children + \
cls.article_write_desync + cls.article_read_desync
cls.env.flush_all()
@@ -1,896 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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. """
@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()
@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.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'):
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
# and add a customer on top of shared articles to check propagation
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')
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)
# 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',
}
)
@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.
# - 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: '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 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(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_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]._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_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])
@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)
wkspace_grandchildren = self.wkspace_grandchildren.with_env(self.env)
wkspace_grandgrandchildren = self.wkspace_grandgrandchildren.with_env(self.env)
(article_workspace + workspace_children[1] + wkspace_grandchildren[2]).action_toggle_favorite()
# 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.assertTrue(wkspace_grandchildren[2].is_user_favorite)
self.assertEqual(wkspace_grandchildren[2].favorite_count, 1)
self.assertEqual(wkspace_grandchildren[2].user_favorite_sequence, 3)
for other in wkspace_grandchildren[0:2] + wkspace_grandgrandchildren:
self.assertFalse(other.is_user_favorite)
self.assertEqual(other.favorite_count, 0)
self.assertEqual(other.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', limit=4)
expected = self.article_workspace + self.workspace_children[1] + self.wkspace_grandchildren[2] + self.workspace_children[0]
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}'))
# test with bigger limit, both favorites and unfavorites
result = self.env['knowledge.article'].get_user_sorted_articles('laygroun', limit=10)
expected = self.article_workspace + self.workspace_children[1] + self.wkspace_grandchildren[2] + \
self.workspace_children[0] + self.wkspace_grandgrandchildren[1] + self.wkspace_grandgrandchildren[0] + \
self.wkspace_grandchildren[1] + self.wkspace_grandchildren[0]
self.assertEqual([a['id'] for a in result], expected.ids)
# test corner case: search with less than favorite, sequence might not be taken into account
result = self.env['knowledge.article'].get_user_sorted_articles('laygroun', limit=1)
self.assertEqual([a['id'] for a in result], self.article_workspace.ids)
@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()
@@ -1,413 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from psycopg2 import IntegrityError
from odoo import exceptions
from odoo.addons.knowledge.tests.common import KnowledgeCommon
from odoo.tests.common import tagged, users
from odoo.tools import mute_logger
@tagged('knowledge_internals')
class TestKnowledgeArticleConstraints(KnowledgeCommon):
""" This test suite has the responsibility to test the different constraints
defined on the `knowledge.article`, `knowledge.article.member` and
`knowledge.article.favorite` models. """
@classmethod
def setUpClass(cls):
""" Add some hierarchy to have mixed rights tests """
super().setUpClass()
# - Playground seq=20 workspace w+ (admin-w+)
# - Shared seq=21 shared none (admin-w+,employee-r+,manager-r+)
cls.article_workspace = cls.env['knowledge.article'].create(
{'article_member_ids': [
(0, 0, {'partner_id': cls.user_admin.partner_id.id,
'permission': 'write'})],
'internal_permission': 'write',
'favorite_ids': [(0, 0, {'sequence': 1,
'user_id': cls.user_admin.id,
}),
],
'name': 'Playground',
'sequence': 20,
}
)
cls.article_shared = cls.env['knowledge.article'].create(
{'article_member_ids': [
(0, 0, {'partner_id': cls.partner_admin.id,
'permission': 'write',
}),
(0, 0, {'partner_id': cls.partner_employee.id,
'permission': 'read',
}),
(0, 0, {'partner_id': cls.partner_employee_manager.id,
'permission': 'read',
}),
],
'internal_permission': 'none',
'name': 'Shared',
'sequence': 21,
}
)
@users('employee')
def test_article_acyclic_graph(self):
""" Check that the article hierarchy does not contain cycles. """
article = self.article_workspace.with_env(self.env)
article_childs = self.env['knowledge.article'].create([
{'name': 'ChildNew1',
'parent_id': article.id,
'sequence': 3,
},
{'name': 'ChildNew2',
'parent_id': article.id,
'sequence': 4,
}
])
# move the parent article under one of its children should raise an exception
with self.assertRaises(exceptions.ValidationError, msg='The article hierarchy contains a cycle'):
article.move_to(parent_id=article_childs[1].id)
with self.assertRaises(exceptions.ValidationError, msg='The article hierarchy contains a cycle'):
article.write({
'parent_id': article_childs[1].id
})
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('employee')
def test_article_create(self):
""" Testing the helper to create articles with right values. """
article = self.article_workspace.with_env(self.env)
readonly_article = self.article_shared.with_env(self.env)
_title = 'Fthagn, private'
private = self.env['knowledge.article'].create({
'article_member_ids': [
(0, 0, {'partner_id': self.env.user.partner_id.id,
'permission': 'write'})
],
'body': f'<p>{_title}</p>',
'internal_permission': 'none',
'name': _title,
})
self.assertMembers(private, 'none', {self.env.user.partner_id: 'write'})
self.assertEqual(private.category, 'private')
self.assertFalse(private.parent_id)
self.assertEqual(private.sequence, 22)
_title = 'Fthagn, with parent (workspace)'
children = self.env['knowledge.article'].create([
{'body': f'<p>{_title}</p>',
'name': _title,
'parent_id': article.id,
} for idx in range(3)
])
for idx, child in enumerate(children):
self.assertMembers(child, False, {})
self.assertEqual(child.category, 'workspace')
self.assertEqual(child.parent_id, article)
self.assertEqual(child.sequence, idx, 'Batch create should correctly set sequence')
_title = 'Fthagn, with parent (private)'
child_private = self.env['knowledge.article'].create({
'body': f'<p>{_title}</p>',
'internal_permission': False,
'name': _title,
'parent_id': private.id,
})
self.assertMembers(child, False, {})
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.AccessError):
_unwanted_child = self.env['knowledge.article'].create({
'article_member_ids': [
(0, 0, {'partner_id': self.env.user.partner_id.id,
'permission': 'write'})
],
'body': f'<p>{_title}</p>',
'internal_permission': 'none',
'name': _title,
'parent_id': article.id,
})
_title = 'Fthagn, but with parent read only: cracboum'
with self.assertRaises(exceptions.AccessError):
_unallowed_child = self.env['knowledge.article'].create({
'body': f'<p>{_title}</p>',
'internal_permission': 'write',
'name': _title,
'parent_id': readonly_article.id,
})
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_article_parent_constraints_create(self):
""" Checking various article constraints linked to parents """
article = self.article_workspace.with_env(self.env)
# Add employee2 as read member
article.invite_members(self.partner_employee2, 'read')
article_as2 = article.with_user(self.user_employee2)
self.assertFalse(article_as2.user_has_write_access)
self.assertTrue(article_as2.user_has_access)
# Member should not be allowed to create an article under an article without "write" permission
with self.assertRaises(exceptions.AccessError):
self.env['knowledge.article'].with_user(self.user_employee2).create({
'internal_permission': 'write',
'name': 'My Own',
'parent_id': article_as2.id,
})
# Member should not be allowed to create a private article under a non-owned article
article_private = self._create_private_article('MyPrivate')
self.assertMembers(article_private, 'none', {self.partner_employee: 'write'})
self.assertTrue(article_private.category, 'private')
self.assertTrue(article_private.user_has_write_access)
with self.assertRaises(exceptions.AccessError):
self.env['knowledge.article'].with_user(self.user_employee2).create({
'name': 'My Own Private',
'parent_id': article_as2.id,
})
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_article_parent_constraints_write(self):
""" Checking the article parent constraints. """
article = self.article_workspace.with_env(self.env)
# Add employee2 as read member
article.invite_members(self.partner_employee2, 'read')
article_as2 = article.with_user(self.user_employee2)
self.assertFalse(article_as2.user_has_write_access)
self.assertTrue(article_as2.user_has_access)
# Member should not be allowed to move an article under an article without "write" permission
article_user2 = self.env['knowledge.article'].with_user(self.user_employee2).create({
'internal_permission': 'write',
'name': 'My Own',
})
with self.assertRaises(exceptions.AccessError):
article_user2.write({'parent_id': article_as2.id})
with self.assertRaises(exceptions.AccessError):
article_user2.move_to(parent_id=article_as2.id)
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_article_private_management(self):
""" Checking the article private management. """
article_workspace = self.article_workspace.with_env(self.env)
# Private-like article whoe parent is not in private category is under workspace
article_private_u2 = self.env['knowledge.article'].sudo().create({
'article_member_ids': [(0, 0, {'partner_id': self.partner_employee2.id, 'permission': 'write'})],
'internal_permission': 'none',
'name': 'Private Child',
'parent_id': article_workspace.id,
}).with_user(self.user_employee2)
self.assertEqual(article_private_u2.category, 'workspace')
self.assertTrue(article_private_u2.user_has_write_access)
# Effectively private: other user cannot read it
article_private_u2_asuser = article_private_u2.with_user(self.env.user)
with self.assertRaises(exceptions.AccessError):
article_private_u2_asuser.body # should trigger ACLs
# Moving a private article under a workspace category makes it workspace
article_private = self._create_private_article('MyPrivate').with_user(self.env.user)
self.assertTrue(article_private.category, 'private')
self.assertTrue(article_private.user_has_write_access)
# Effectively private: other user cannot read it
article_private_asu2 = article_private.with_user(self.user_employee2)
with self.assertRaises(exceptions.AccessError):
article_private_asu2.body # should trigger ACLs
# Move to workspace, makes it workspace
article_private.move_to(parent_id=article_workspace.id)
self.assertEqual(article_private.category, 'workspace')
article_private_asu2 = article_private.with_user(self.user_employee2)
# Still not accessible
with self.assertRaises(exceptions.AccessError):
article_private_asu2.body # should trigger ACLs
@mute_logger('odoo.sql_db')
@users('employee')
def test_article_root_internal_permission(self):
"""Check that the root article has internal permission set."""
# defaulting to write permission if nothing is given
article = self.env['knowledge.article'].create({
'name': 'Article',
'parent_id': False,
})
self.assertEqual(article.category, 'workspace')
self.assertEqual(article.internal_permission, 'write')
# ensure a member has write access before trying to remove global access
# and allow raising the IntegrityError (otherwise a ValidationError raises)
article.sudo().write({
'article_member_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id,
'permission': 'write'})],
})
with self.assertRaises(IntegrityError, msg='An internal permission should be set for root article'):
with self.cr.savepoint():
article.write({'internal_permission': False})
article_child = self.env['knowledge.article'].create({
'name': 'Article',
'parent_id': article.id,
})
self.assertMembers(article_child, False, {})
self.assertEqual(article_child.category, 'workspace')
self.assertEqual(article_child.root_article_id, article)
with self.assertRaises(IntegrityError, msg='An internal permission should be set for root article'):
with self.cr.savepoint():
article_child.sudo().write({'parent_id': False})
@users('employee')
def test_article_should_have_at_least_one_writer(self):
""" Check that an article has at least one writer."""
with self.assertRaises(exceptions.ValidationError, msg='Article should have at least one writer'):
self.env['knowledge.article'].create({
'internal_permission': 'none',
'name': 'Article',
})
with self.assertRaises(exceptions.ValidationError, msg='Article should have at least one writer'):
self.env['knowledge.article'].create({
'internal_permission': 'read',
'name': 'Article',
})
article_private = self._create_private_article('MyPrivate')
self.assertMembers(article_private, 'none', {self.partner_employee: 'write'})
self.assertEqual(article_private.category, 'private')
# take membership as sudo to really have access to unlink feature
membership_sudo = article_private.sudo().article_member_ids
# cannot remove last writer
with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'):
membership_sudo.unlink()
with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'):
article_private.sudo()._remove_member(membership_sudo)
with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'):
article_private.sudo().write({
'article_member_ids': self.env['knowledge.article.member']
})
with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'):
article_private.sudo().write({
'article_member_ids': [(2, membership_sudo.id)]
})
# cannot transform last writer into rejected
with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'):
article_private.sudo().write({
'article_member_ids': [(1, membership_sudo.id, {'permission': 'none'})]
})
with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'):
article_private.sudo().write({
'article_member_ids': [(1, membership_sudo.id, {'permission': 'read'})]
})
with self.assertRaises(exceptions.ValidationError, msg='Cannot remove the last writer on an article'):
article_private._add_members(membership_sudo.partner_id, 'none')
# moving the article to private will remove the second member
# but should not trigger an error since we also add 'employee' as a write member
article_workspace = self.article_workspace.with_env(self.env)
article_workspace.move_to(is_private=True)
self.assertEqual(article_workspace.category, 'private')
self.assertTrue(article_workspace._has_write_member())
@mute_logger('odoo.sql_db')
@users('employee')
def test_favourite_uniqueness(self):
""" Check there is at most one 'knowledge.article.favourite' entry per
article and user. """
article = self.env['knowledge.article'].create(
{'internal_permission': 'write',
'name': 'Article'}
)
self.assertFalse(article.is_user_favorite)
article.write({'favorite_ids': [(0, 0, {'user_id': self.env.user.id})]})
self.assertTrue(article.is_user_favorite)
with self.assertRaises(IntegrityError,
msg='Multiple favorite entries are not accepted'):
article.write({'favorite_ids': [(0, 0, {'user_id': self.env.user.id})]})
self.assertTrue(article.is_user_favorite)
@mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests')
@users('employee')
def test_member_share_restrictions(self):
"""Checking that the external partner can not have 'write' access."""
article = self._create_private_article('MyPrivate')
self.assertEqual(article.category, 'private')
customer = self.customer.with_env(self.env)
self.assertTrue(customer.partner_share)
# check that an external partner can not have "write" permission
with self.assertRaises(exceptions.ValidationError,
msg='An external partner can not have "write" permission on an article'):
article.sudo().write({
'article_member_ids': [(0, 0, {
'partner_id': customer.id,
'permission': 'write'
})]
})
article.invite_members(customer, 'write')
self.assertMembers(article, 'none',
{self.env.user.partner_id: 'write',
customer: 'read'},
msg='Invite: share should not gain write access')
@mute_logger('odoo.sql_db')
@users('employee')
def test_member_uniqueness(self):
"""Check that there are no duplicated members in the member list. """
article = self.env['knowledge.article'].create({
'internal_permission': 'write',
'name': 'Article',
})
article.sudo().write({
'article_member_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id,
'permission': 'write'})]
})
self.assertEqual(len(self.env['knowledge.article.member'].sudo().search([('article_id', '=', article.id)])), 1)
# adding a duplicate
with self.assertRaises(IntegrityError,
msg='Members should be unique (article_id/partner_id)'):
article.sudo().write({
'article_member_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id,
'permission': 'write'})]
})
self.assertEqual(len(self.env['knowledge.article.member'].sudo().search([('article_id', '=', article.id)])), 1)
# trying with tool method
article.invite_members(self.env.user.partner_id, 'write')
self.assertEqual(len(self.env['knowledge.article.member'].sudo().search([('article_id', '=', article.id)])), 1)
# creating duplicates
with self.assertRaises(IntegrityError,
msg='Members should be unique (article_id/partner_id)'):
article.invite_members(self.partner_employee2 + self.partner_employee2, 'write')
self.assertEqual(len(self.env['knowledge.article.member'].sudo().search([('article_id', '=', article.id)])), 1)
with self.assertRaises(IntegrityError,
msg='Members should be unique (article_id/partner_id)'):
article.sudo().write({
'article_member_ids': [(0, 0, {'partner_id': self.partner_admin.id,
'permission': 'write'}),
(0, 0, {'partner_id': self.partner_admin.id,
'permission': 'write'})
],
})
self.assertEqual(len(self.env['knowledge.article.member'].sudo().search([('article_id', '=', article.id)])), 1)
@@ -1,124 +0,0 @@
# -*- 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')
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]
})
# the with_user() below is necessary for the test to succeed,
# and that's kind of a bad smell...
article.with_user(self.user_employee2).flush_model()
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)
@@ -1,494 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import exceptions
from odoo.addons.knowledge.tests.common import KnowledgeArticlePermissionsCase
from odoo.tests.common import tagged, users
from odoo.tools import mute_logger
@tagged('knowledge_acl')
class TestKnowledgeArticlePermissions(KnowledgeArticlePermissionsCase):
@users('employee')
def test_article_main_parent(self):
""" Test root article computation """
article_roots = self.article_roots.with_env(self.env)
articles_write = (self.article_write_contents + self.article_write_contents_children).with_env(self.env)
self.assertEqual(articles_write.root_article_id, article_roots[0])
articles_write = self.article_read_contents.with_env(self.env)
self.assertEqual(articles_write.root_article_id, article_roots[1])
# desynchronized still have a root (do as sudo)
self.assertEqual(self.article_write_desync.root_article_id, article_roots[0])
self.assertEqual(self.article_read_desync.root_article_id, article_roots[1])
def test_article_permissions_desync(self):
""" Test computed fields based on permissions (independently from ACLs
aka not user_permission, ...). Main use cases: desynchronized articles
or articles without parents. """
for (exp_inherited_permission,
exp_inherited_permission_parent_id,
exp_internal_permission
), article in zip(
[('read', self.env['knowledge.article'], 'read'),
('read', self.article_write_desync[0], False),
('none', self.env['knowledge.article'], 'none'),
('none', self.article_read_desync[0], False),
('write', self.env['knowledge.article'], 'write'),
('read', self.env['knowledge.article'], 'read'),
],
self.article_write_desync + self.article_read_desync + self.article_roots
):
self.assertEqual(article.inherited_permission, exp_inherited_permission,
f'Permission: wrong inherit computation for {article.name}: {article.inherited_permission} instead of {exp_inherited_permission}')
self.assertEqual(article.inherited_permission_parent_id, exp_inherited_permission_parent_id,
f'Permission: wrong inherit computation for {article.name}: {article.inherited_permission_parent_id.name} instead of {exp_inherited_permission_parent_id.name}')
self.assertEqual(article.internal_permission, exp_internal_permission,
f'Permission: wrong inherit computation for {article.name}: {article.internal_permission} instead of {exp_internal_permission}')
@mute_logger('odoo.addons.base.models.ir_rule')
def test_article_permissions_inheritance_desync(self):
""" Test desynchronize (and therefore member propagation that should be
stopped). """
article_desync = self.article_write_desync[0]
self.assertMembers(article_desync, 'read', {self.partner_employee_manager: 'write'})
# as employee w write perms
article_desync = article_desync.with_user(self.user_employee_manager)
self.assertTrue(article_desync.user_has_write_access)
self.assertTrue(article_desync.user_has_access)
# as employee
article_desync = article_desync.with_user(self.user_employee)
self.assertFalse(article_desync.user_has_write_access)
self.assertTrue(article_desync.user_has_access)
# as portal
article_desync = article_desync.with_user(self.user_portal)
self.assertFalse(article_desync.user_has_write_access)
self.assertFalse(article_desync.user_has_access, 'Permissions: member rights should not be fetch on parents')
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_article_permissions_inheritance_employee(self):
article_roots = self.article_roots.with_env(self.env)
# roots: based on internal permissions
self.assertEqual(article_roots.mapped('user_has_write_access'), [True, False, False, True])
self.assertEqual(article_roots.mapped('user_has_access'), [True, True, True, True])
self.assertEqual(article_roots.mapped('user_permission'), ['write', 'read', 'read', 'write'])
# write permission from ancestors
article_write_ancestor = self.article_write_contents[2].with_env(self.env)
self.assertEqual(article_write_ancestor.inherited_permission, 'write')
self.assertEqual(article_write_ancestor.inherited_permission_parent_id, self.article_roots[0])
self.assertFalse(article_write_ancestor.internal_permission)
self.assertEqual(article_write_ancestor.user_permission, 'write')
# write permission from ancestors overridden by internal permission
article_read_forced = self.article_write_contents[1].with_env(self.env)
self.assertEqual(article_read_forced.inherited_permission, 'read')
self.assertFalse(article_read_forced.inherited_permission_parent_id)
self.assertEqual(article_read_forced.internal_permission, 'read')
self.assertEqual(article_read_forced.user_permission, 'read')
# write permission from ancestors overridden by member permission
article_read_member = self.article_write_contents[0].with_env(self.env)
self.assertEqual(article_read_member.inherited_permission, 'write')
self.assertEqual(article_read_member.inherited_permission_parent_id, self.article_roots[0])
self.assertFalse(article_read_member.internal_permission)
self.assertEqual(article_read_member.user_permission, 'read')
# forced lower than base article perm (see 'Community Paranoïa')
article_lower = self.article_read_contents[1].with_env(self.env)
self.assertEqual(article_lower.inherited_permission, 'write')
self.assertFalse(article_lower.inherited_permission_parent_id)
self.assertEqual(article_lower.internal_permission, 'write')
self.assertEqual(article_lower.user_permission, 'read')
# read permission from ancestors
article_read_ancestor = self.article_read_contents[2].with_env(self.env)
self.assertEqual(article_read_ancestor.inherited_permission, 'read')
self.assertEqual(article_read_ancestor.inherited_permission_parent_id, self.article_roots[1])
self.assertFalse(article_read_ancestor.internal_permission)
self.assertEqual(article_read_ancestor.user_permission, 'read')
# permission denied
article_none = self.article_read_contents[3].with_env(self.env)
with self.assertRaises(exceptions.AccessError):
article_none.name
@mute_logger('odoo.addons.base.models.ir_rule')
@users('portal_test')
def test_article_permissions_inheritance_portal(self):
article_roots = self.article_roots.with_env(self.env)
with self.assertRaises(exceptions.AccessError):
article_roots.mapped('internal_permission')
article_members = self.article_read_contents[0:2].with_env(self.env)
self.assertEqual(article_members.mapped('inherited_permission'), ['write', 'write']) # TDE: TOCHECK
self.assertEqual(article_members.mapped('internal_permission'), ['write', 'write']) # TDE: TOCHECK
self.assertEqual(article_members.mapped('user_has_write_access'), [False, False], 'Portal: can never write')
self.assertEqual(article_members.mapped('user_has_access'), [True, True], 'Portal: access through membership')
self.assertEqual(article_members.mapped('user_permission'), ['read', 'read'])
@users('employee')
def test_article_permissions_employee_new_mode(self):
""" Test transient / cache mode: computed fields without IDs, ... """
article = self.env['knowledge.article'].new({'name': 'Transient'})
self.assertFalse(article.inherited_permission)
self.assertFalse(article.internal_permission)
self.assertTrue(article.user_has_write_access)
self.assertTrue(article.user_has_access)
self.assertEqual(article.user_permission, 'write')
@tagged('knowledge_internals', 'knowledge_management')
class KnowledgeArticlePermissionsInitialValues(KnowledgeArticlePermissionsCase):
""" Test initial values or our test data once so that other tests do not have
to do it. """
def test_initial_values(self):
article_roots = self.article_roots.with_env(self.env)
article_headers = self.article_headers.with_env(self.env)
# roots: defaults on write, inherited = internal
self.assertEqual(article_roots.mapped('inherited_permission'), ['write', 'read', 'none', 'none'])
self.assertFalse(article_roots.inherited_permission_parent_id)
self.assertEqual(article_roots.mapped('internal_permission'), ['write', 'read', 'none', 'none'])
# childs: allow void permission, inherited = go up to first defined permission
self.assertEqual(article_headers.mapped('inherited_permission'), ['write', 'read', 'read'])
self.assertEqual(
[p.inherited_permission_parent_id for p in article_headers],
[article_roots[0], article_roots[1], article_roots[1]]
)
self.assertEqual(article_headers.mapped('internal_permission'), [False, False, False])
@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_write_inherit = self.article_write_contents[2].with_env(self.env)
# initial values: write through inheritance
self.assertMembers(article_write_inherit, False, {self.partner_portal: 'read'})
self.assertFalse(article_write_inherit.internal_permission)
self.assertFalse(article_write_inherit.is_desynchronized)
self.assertTrue(article_write_inherit.user_has_write_access)
self.assertTrue(article_write_inherit.user_has_access)
article_write_inherit_as2 = article_write_inherit.with_user(self.user_employee2)
self.assertTrue(article_write_inherit_as2.user_has_write_access)
self.assertTrue(article_write_inherit_as2.user_has_access)
@tagged('knowledge_acl')
class TestKnowledgeArticlePermissionsTools(KnowledgeArticlePermissionsCase):
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_downgrade_internal_permission_none(self):
writable_as1 = self.article_write_contents[2].with_env(self.env)
writable_as2 = writable_as1.with_user(self.user_employee2)
self.assertEqual(writable_as2.user_has_access, True)
writable_root = self.article_roots[0].with_env(self.env)
writable_children = self.article_write_contents_children.with_env(self.env)
for child in writable_children:
self.assertEqual(child.inherited_permission_parent_id, writable_root)
# downgrade write global perm to read
writable_as1._set_internal_permission('none')
writable_as1.flush_model() # ACLs are done using SQL
self.assertMembers(
writable_as1, 'none',
{self.partner_portal: 'read', # untouched by downgrade
self.env.user.partner_id: 'write'},
'Permission: lowering permission adds current user in members to have write access'
)
self.assertTrue(writable_as1.is_desynchronized)
self.assertTrue(writable_as1.user_has_write_access)
self.assertTrue(writable_as1.user_has_access)
# check internal permission has been lowered
with self.assertRaises(exceptions.AccessError):
writable_as2.body # trigger ACLs
# check children inherits downgraded permissions from article
for child in writable_children:
self.assertEqual(child.inherited_permission, 'none', 'Permission: lowering permission should lower the permission of the children')
self.assertEqual(child.inherited_permission_parent_id, writable_as1, 'Permission: lowering permission should make the children inherit the permission from this article')
@users('employee')
def test_downgrade_internal_permission_read(self):
writable_as1 = self.article_write_contents[2].with_env(self.env)
writable_as2 = writable_as1.with_user(self.user_employee2)
self.assertEqual(writable_as2.user_has_access, True)
writable_root = self.article_roots[0].with_env(self.env)
writable_children = self.article_write_contents_children.with_env(self.env)
for child in writable_children:
self.assertEqual(child.inherited_permission_parent_id, writable_root)
# downgrade write global perm to read
writable_as1._set_internal_permission('read')
writable_as1.flush_model() # ACLs are done using SQL
self.assertMembers(
writable_as1, 'read',
{self.partner_portal: 'read', self.env.user.partner_id: 'write'},
'Permission: lowering permission adds current user in members to have write access'
)
self.assertTrue(writable_as1.is_desynchronized)
self.assertTrue(writable_as1.user_has_write_access)
self.assertTrue(writable_as1.user_has_access)
self.assertFalse(writable_as2.user_has_write_access)
self.assertTrue(writable_as2.user_has_access)
# check children inherits downgraded permissions from article
for child in writable_children:
self.assertEqual(child.inherited_permission, 'read', 'Permission: lowering permission should lower the permission of the children')
self.assertEqual(child.inherited_permission_parent_id, writable_as1, 'Permission: lowering permission should make the children inherit the permission from this article')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.models.unlink')
@users('employee')
def test_remove_member_inherited_rights(self):
""" Remove a member from a child inheriting rights: will desync """
writable = self.article_write_contents[2].with_env(self.env)
self.assertTrue(writable.user_has_access)
self.assertTrue(writable.user_has_write_access)
self.assertMembers(writable, False,
{self.partner_portal: 'read'})
# set partner employee manager as writable member of its root
writable_root = writable.root_article_id
writable_root._add_members(self.partner_employee_manager, 'write')
self.assertMembers(writable_root, 'write',
{self.partner_employee_manager: 'write'})
# remove partner employee manager that has rights based on inheritance
writable_children = self.article_write_contents_children.with_env(self.env)
for child in writable_children:
self.assertIn(
self.partner_employee_manager.id,
child._get_article_member_permissions()[child.id],
'Share Panel: if an article inherits a permission, its children should inherit that permission too')
manager_member = writable_root.article_member_ids.filtered(lambda m: m.partner_id == self.partner_employee_manager)
writable._remove_member(manager_member)
self.assertTrue(writable.is_desynchronized,
'Permission: when removing a member having inherited rights it has be be desynchronized')
self.assertMembers(writable, 'write',
{self.partner_portal: 'read'})
for child in writable_children:
self.assertNotIn(
self.partner_employee_manager.id,
child._get_article_member_permissions()[child.id],
'Share Panel: when removing a member having inherited rights, the member should be removed from the children that inherited that right')
# resync
writable.restore_article_access()
self.assertFalse(writable.is_desynchronized)
self.assertMembers(writable, False,
{self.partner_portal: 'read'})
# remove portal partner that has rights based on membership
portal_member = writable.article_member_ids.filtered(lambda m: m.partner_id == self.partner_portal)
writable._remove_member(portal_member)
self.assertFalse(writable.is_desynchronized)
self.assertMembers(writable, False, {})
@mute_logger('odoo.models.unlink')
@users('employee')
def test_set_member_permission(self):
""" Test setting member-specific permission """
writable = self.article_write_contents[2].with_env(self.env)
self.assertTrue(writable.user_has_access)
self.assertTrue(writable.user_has_write_access)
# set partner employee manager as readable member of its root
writable_root = writable.root_article_id
writable_root._add_members(self.partner_employee_manager, 'read')
self.assertMembers(writable_root, 'write',
{self.partner_employee_manager: 'read'})
# update a member permission directly
portal_member = writable.article_member_ids.filtered(lambda m: m.partner_id == self.partner_portal)
writable._set_member_permission(portal_member, 'none')
self.assertMembers(writable, False,
{self.partner_portal: 'none'})
# upgrade a permission based on inheritance
manager_member_root = writable_root.article_member_ids.filtered(lambda m: m.partner_id == self.partner_employee_manager)
writable._set_member_permission(manager_member_root, 'write', is_based_on=True)
self.assertFalse(writable.is_desynchronized)
self.assertMembers(writable, False,
{self.partner_portal: 'none',
self.partner_employee_manager: 'write'})
# now test downgrading
manager_member = writable.article_member_ids.filtered(lambda m: m.partner_id == self.partner_employee_manager)
writable_root._set_member_permission(manager_member_root, 'write')
writable._remove_member(manager_member)
self.assertMembers(writable_root, 'write',
{self.partner_employee_manager: 'write'})
self.assertMembers(writable, False,
{self.partner_portal: 'none'})
# downgrade a permission, should desynchronize from parent
writable_children = self.article_write_contents_children.with_env(self.env)
for child in writable_children:
self.assertEqual(
child._get_article_member_permissions()[child.id][self.partner_employee_manager.id]['permission'],
'write',
'Share Panel: if an article inherits a permission, its children should inherit that permission too')
writable_root._set_member_permission(manager_member_root, 'write')
writable._set_member_permission(manager_member_root, 'read', is_based_on=True)
self.assertTrue(writable.is_desynchronized,
'Permission: when removing a member having inherited rights it has be be desynchronized')
self.assertMembers(writable, 'write',
{self.partner_portal: 'none',
self.partner_employee_manager: 'read'})
for child in writable_children:
self.assertEqual(
child._get_article_member_permissions()[child.id][self.partner_employee_manager.id]['permission'],
'read',
'Share Panel: when downgrading a member having inherited rights, the member should be downgraded from the children that inherited that right')
# adding a member to parent, should not be inherited by children
writable_root._add_members(self.partner_employee2, 'read')
self.assertNotIn(
self.partner_employee2.id,
writable._get_article_member_permissions()[writable.id],
'Share Panel: when adding a member on a parent of a desynced article, the member should not be added on the desynced article')
for child in writable_children:
self.assertNotIn(
self.partner_employee2.id,
child._get_article_member_permissions()[child.id],
'Share Panel: when adding a member on a parent of a desynced article, the member should not be added on the children of the desynced article')
@mute_logger('odoo.addons.base.models.ir_rule')
@users('employee')
def test_update_internal_permission_escalation(self):
""" Check no privilege escalation is possible """
# direct try at setting higher internal permission
readonly = self.article_read_contents[1].with_env(self.env)
self.assertTrue(readonly.user_has_access)
self.assertFalse(readonly.user_has_write_access)
writable = self.article_write_contents[2].with_env(self.env)
self.assertTrue(writable.user_has_access)
self.assertTrue(writable.user_has_write_access)
with self.assertRaises(exceptions.AccessError,
msg='Permission: that is plain stupid trying to do this'):
readonly.write({'internal_permission': 'write'})
with self.assertRaises(exceptions.AccessError,
msg='Permission: do not allow privilege escalation'):
readonly._set_internal_permission('write')
portal_member = writable.article_member_ids.filtered(lambda m: m.partner_id == self.partner_portal)
with self.assertRaises(exceptions.ValidationError,
msg='Permission: share partner cannot gain write access'):
writable._set_member_permission(portal_member, 'write')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.models.unlink')
@users('employee')
def test_update_permissions_rights(self):
""" Check no privilege escalation is possible """
# direct try at setting higher internal permission
readonly = self.article_read_contents[1].with_env(self.env)
self.assertTrue(readonly.user_has_access)
self.assertFalse(readonly.user_has_write_access)
with self.assertRaises(exceptions.AccessError,
msg='Permission: that is plain stupid trying to do this'):
readonly.write({'internal_permission': 'write'})
with self.assertRaises(exceptions.AccessError,
msg='Permission: do not allow privilege escalation'):
readonly._set_internal_permission('write')
other_member = readonly.article_member_ids.filtered(lambda m: m.partner_id == self.partner_portal)
with self.assertRaises(exceptions.AccessError,
msg='Permission: do not allow to remove members when having only read access'):
readonly._remove_member(other_member)
self.assertMembers(readonly, 'write',
{self.env.user.partner_id: 'read',
self.partner_portal: 'read'})
# cannot gain privilege: setting none if I remove myself to ensure no gain is performed
my_member = readonly.article_member_ids.filtered(lambda m: m.partner_id == self.env.user.partner_id)
readonly._remove_member(my_member)
self.assertMembers(readonly, 'write',
{self.env.user.partner_id: 'none',
self.partner_portal: 'read'})
@tagged('knowledge_acl')
class TestKnowledgeArticleSearch(KnowledgeArticlePermissionsCase):
@users('admin')
def test_article_search_admin(self):
""" Test admin: can read / write everything but user_has_access and
user_has_write_access should still be based on real permissions. """
self.assertTrue(self.env.user.has_group('base.group_system'))
articles = self.env['knowledge.article'].search([])
expected = self.articles_all
self.assertEqual(articles, expected,
'Search on user_has_write_access: aka write access (additional: %s, missing: %s)' %
((articles - expected).mapped('name'), (expected - articles).mapped('name'))
)
articles = self.env['knowledge.article'].search([('user_has_write_access', '=', True)])
expected = self.article_roots[0] + self.article_headers[0] + \
self.article_write_contents[0] + self.article_write_contents[2] + \
self.article_write_contents_children + \
self.article_read_contents[0:2]
self.assertEqual(articles, expected,
'Search on user_has_write_access: aka write access (additional: %s, missing: %s)' %
((articles - expected).mapped('name'), (expected - articles).mapped('name'))
)
@users('employee')
def test_article_search_employee(self):
""" Test regular searches using permission-based ACLs """
# explicitly remove an article, check it is not included (nor its child)
self.article_write_desync[0].write({
'article_member_ids': [
(0, 0, {'partner_id': self.user_employee.partner_id.id,
'permission': 'none'})]
})
articles = self.env['knowledge.article'].search([])
# not reachable: 'none', desynchronized 'none' (and their children)
expected = self.articles_all - self.article_read_contents[3] - self.article_write_desync
self.assertEqual(articles, expected,
'Search on main article: aka everything except "none"-based articles (additional: %s, missing: %s)' %
((articles - expected).mapped('name'), (expected - articles).mapped('name'))
)
# add its child as readable through membership and perform a new search
self.article_write_desync[1].write({
'article_member_ids': [
(0, 0, {'partner_id': self.user_employee.partner_id.id,
'permission': 'read'})]
})
articles = self.env['knowledge.article'].search([('root_article_id', '=', self.article_roots[0].id)])
expected = self.article_roots[0] + self.article_headers[0] + \
self.article_write_contents + self.article_write_contents_children + self.article_write_desync[1]
self.assertEqual(articles, expected,
'Search on main article: aka read access on read root + its children (additional: %s, missing: %s)' %
((articles - expected).mapped('name'), (expected - articles).mapped('name'))
)
@users('employee')
def test_article_search_employee_method_based(self):
""" Test search methods """
articles = self.env['knowledge.article'].search([('user_has_write_access', '=', True)])
expected = self.article_roots[0] + self.article_roots[3] + \
self.article_headers[0] + \
self.article_write_contents[2] + self.article_write_contents_children + \
self.article_read_contents[0] + self.article_read_desync
self.assertEqual(articles, expected,
'Search on user_has_write_access: aka write access (additional: %s, missing: %s)' %
((articles - expected).mapped('name'), (expected - articles).mapped('name'))
)
@@ -1,177 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.knowledge.tests.common import KnowledgeCommon
from odoo.tests.common import tagged, users
from odoo.tools import mute_logger
@tagged('knowledge_sequence')
class TestKnowledgeArticleSequence(KnowledgeCommon):
""" Test sequencing and auto-resequence of articles. """
@classmethod
def setUpClass(cls):
super().setUpClass()
with mute_logger('odoo.models.unlink'):
cls.env['knowledge.article'].search([]).unlink()
# HIERARCHY
# - Existing 1 seq=1
# - Existing 2 seq=3
# - Article 1 seq=4
# - Article 1.1
# - Article 1.2
# - Article 1.2.1
# - Article 1.3
# - Article 2 seq=5
# define starting sequence for root articles
cls.article_root_noise = cls.env['knowledge.article'].create([
{'internal_permission': 'write',
'name': 'Existing1',
'sequence': 1,
},
{'internal_permission': 'write',
'name': 'Existing2',
'sequence': 3,
}
])
cls.article_private = cls._create_private_article(cls, 'Article1', target_user=cls.user_employee)
cls.article_children = cls.env['knowledge.article'].create([
{'name': 'Article1.1',
'parent_id': cls.article_private.id,
},
{'name': 'Article1.2',
'parent_id': cls.article_private.id,
},
])
cls.article_children += cls.env['knowledge.article'].create([
{'name': 'Article1.2.1',
'parent_id': cls.article_children[1].id,
},
])
cls.article_children += cls.env['knowledge.article'].create([
{'name': 'Article1.3',
'parent_id': cls.article_private.id,
}
])
cls.article_private2 = cls._create_private_article(cls, 'Article2', target_user=cls.user_employee)
# flush everything to ease resequencing and date-based computation
cls.env.flush_all()
@users('employee')
def test_initial_tree(self):
# parents
article_private = self.article_private.with_env(self.env)
article_children = self.article_children.with_env(self.env)
article_private2 = self.article_private2.with_env(self.env)
self.assertFalse(article_private.parent_id)
self.assertEqual((article_children[0:2] + article_children[3:]).parent_id, article_private)
self.assertEqual(article_children[2].parent_id, article_children[1])
self.assertFalse(article_private2.parent_id)
# ancestors
self.assertEqual((article_private + article_children).root_article_id, article_private)
self.assertEqual(article_private2.root_article_id, article_private2)
# categories
self.assertEqual(article_private.category, 'private')
self.assertEqual(set(article_children.mapped('category')), set(['private']))
self.assertEqual(article_private2.category, 'private')
# user permission
self.assertEqual(article_private.user_permission, 'write')
self.assertEqual(set(article_children.mapped('user_permission')), set(['write']))
self.assertEqual(article_private2.user_permission, 'write')
self.assertEqual(article_private.inherited_permission, 'none')
self.assertEqual(set(article_children.mapped('inherited_permission')), set(['none']))
self.assertEqual(article_private2.inherited_permission, 'none')
# sequences
self.assertSortedSequence(article_private + article_private2)
self.assertSortedSequence(article_children[0:2] + article_children[3])
@users('employee')
def test_resequence_with_move(self):
"""Checking the sequence of the articles"""
article_private = self.article_private.with_env(self.env)
article_children = self.article_children.with_env(self.env)
article_private2 = self.article_private2.with_env(self.env)
# move last child "Article 1.3" before "Article 1.2"
last_child = article_children[3]
last_child.move_to(parent_id=article_private.id, before_article_id=article_children[1].id)
# expected
# - Article 1
# - Article 1.1
# - Article 1.3
# - Article 1.2
# - Article 1.2.1
# - Article 6
self.assertFalse(article_private.parent_id)
self.assertEqual((article_children[0:2] + article_children[3:]).parent_id, article_private)
self.assertEqual(article_children[2].parent_id, article_children[1])
self.assertFalse(article_private2.parent_id)
self.assertSortedSequence(article_private + article_private2)
self.assertSortedSequence(article_children[0] + article_children[3] + article_children[1])
# move "Article 1.2.1" in first position under "Article 1"
article_children[2].move_to(parent_id=article_private.id, before_article_id=article_children[0].id)
# expected
# - Article 1
# - Article 1.2.1
# - Article 1.1
# - Article 1.3
# - Article 1.2
# - Article 6
self.assertFalse(article_private.parent_id)
self.assertEqual(article_children.parent_id, article_private)
self.assertFalse(article_private2.parent_id)
self.assertSortedSequence(article_private + article_private2)
self.assertSortedSequence(article_children[2] + article_children[0] + article_children[3] + article_children[1])
# move "Article 1.1" in last position under "Article 1"
article_children[0].move_to(parent_id=article_private.id, before_article_id=False)
# expected
# - Article 1
# - Article 1.2.1
# - Article 1.3
# - Article 1.2
# - Article 1.1
# - Article 6
self.assertFalse(article_private.parent_id)
self.assertEqual(article_children.parent_id, article_private)
self.assertFalse(article_private2.parent_id)
self.assertSortedSequence(article_private + article_private2)
self.assertSortedSequence(article_children[2] + article_children[3] + article_children[1] + article_children[0])
@users('employee')
def test_resequence_with_move_noparent(self):
""" Test move resetting parent_id should also compute sequence """
article_private = self.article_private.with_env(self.env)
article_private_child = self.article_children[0].with_env(self.env)
article_private2 = self.article_private2.with_env(self.env)
article_root_noise = self.article_root_noise.with_env(self.env)
self.assertEqual(article_private_child.sequence, 0)
article_private_child.move_to(parent_id=False)
self.assertEqual(article_private_child.sequence, 6)
self.assertSortedSequence(article_root_noise + article_private + article_private2 + article_private_child)
article_private_child.move_to(
parent_id=False,
before_article_id=self.article_root_noise[0].id
)
self.assertEqual(article_private_child.sequence, 1)
self.assertSortedSequence(article_private_child + article_root_noise + article_private + article_private2)
@users('employee')
def test_resequence_with_parent(self):
"""Checking the sequence of the articles"""
existing_private = self.article_private.with_env(self.env)
new_private = self._create_private_article('NewPrivate')
self.assertFalse(new_private.parent_id, 'Sequencing: no parent should be forced')
self.assertEqual(new_private.sequence, 6, 'Sequencing: should be placed after Article2, end of "no root" list')
new_private.write({'parent_id': existing_private.id})
self.assertEqual(new_private.parent_id, existing_private, 'Sequencing: respect parent choice')
self.assertEqual(new_private.sequence, 3,
'Sequencing: without any forced value, should be set last of all children')
@@ -1,74 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields
from odoo.tests.common import tagged, HttpCase
@tagged('post_install', '-at_install', 'knowledge', 'knowledge_tour')
class TestKnowledgeUI(HttpCase):
@classmethod
def setUpClass(cls):
super(TestKnowledgeUI, cls).setUpClass()
# remove existing articles to ease tour management
cls.env['knowledge.article'].search([]).unlink()
def test_knowledge_main_flow(self):
# as the knowledge.article#_resequence method is based on write date
# force the write_date to be correctly computed
# otherwise it always returns the same value as we are in a single transaction
self.patch(self.env.cr, 'now', fields.Datetime.now)
self.start_tour('/web', 'knowledge_main_flow_tour', login='admin', step_delay=100)
# check our articles were correctly created
# with appropriate default values (section / internal_permission)
private_article = self.env['knowledge.article'].search([('name', '=', 'My Private Article')])
self.assertTrue(bool(private_article))
self.assertEqual(private_article.category, 'private')
self.assertEqual(private_article.internal_permission, 'none')
workspace_article = self.env['knowledge.article'].search([('name', '=', 'My Workspace Article')])
self.assertTrue(bool(workspace_article))
self.assertEqual(workspace_article.category, 'workspace')
self.assertEqual(workspace_article.internal_permission, 'write')
children_workspace_articles = workspace_article.child_ids.sorted('sequence')
self.assertEqual(len(children_workspace_articles), 2)
child_article_1 = children_workspace_articles.filtered(
lambda article: article.name == 'Child Article 1')
child_article_2 = children_workspace_articles.filtered(
lambda article: article.name == 'Child Article 2')
# as we re-ordered children, article 2 should come first
self.assertEqual(children_workspace_articles[0], child_article_2)
self.assertEqual(children_workspace_articles[1], child_article_1)
# workspace article should have one partner invited on it
invited_member = workspace_article.article_member_ids
self.assertEqual(len(invited_member), 1)
invited_partner = invited_member.partner_id
self.assertEqual(len(invited_partner), 1)
self.assertEqual(invited_partner.name, 'micheline@knowledge.com')
self.assertEqual(invited_partner.email, 'micheline@knowledge.com')
# check that the partner received an invitation link
invitation_message = self.env['mail.message'].search([
('partner_ids', 'in', invited_partner.id)
])
self.assertEqual(len(invitation_message), 1)
self.assertIn(
workspace_article._get_invite_url(invited_partner),
invitation_message.body
)
# as we re-ordered our favorites, private article should come first
article_favorites = self.env['knowledge.article.favorite'].search([])
self.assertEqual(len(article_favorites), 2)
self.assertEqual(article_favorites[0].article_id, private_article)
self.assertEqual(article_favorites[1].article_id, workspace_article)
def test_knowledge_pick_emoji(self):
"""This tour will check that the emojis of the form view are properly updated
when the user picks an emoji from an emoji picker."""
self.start_tour('/web', 'knowledge_pick_emoji_tour', login='admin', step_delay=100)
@@ -1,95 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.knowledge.tests.common import KnowledgeCommonWData
from odoo.tests.common import tagged, users, warmup
from odoo.tools import mute_logger
@tagged('knowledge_performance', 'post_install', '-at_install')
class KnowledgePerformanceCase(KnowledgeCommonWData):
def setUp(self):
super().setUp()
# patch registry to simulate a ready environment
self.patch(self.env.registry, 'ready', True)
self._flush_tracking()
def _flush_tracking(self):
""" Force the creation of tracking values notably, and ensure tests are
reproducible. """
self.env.flush_all()
self.cr.flush()
@users('employee')
@warmup
def test_article_creation_single_shared_grandchild(self):
""" Test with 2 levels of hierarchy in a private/shared environment """
with self.assertQueryCount(employee=21):
_article = self.env['knowledge.article'].create({
'body': '<p>Hello</p>',
'name': 'Article in shared',
'parent_id': self.shared_children[0].id,
})
self.assertEqual(_article.category, 'shared')
@users('employee')
@warmup
def test_article_creation_single_workspace(self):
with self.assertQueryCount(employee=18):
_article = self.env['knowledge.article'].create({
'body': '<p>Hello</p>',
'name': 'Article in workspace',
'parent_id': self.article_workspace.id,
})
self.assertEqual(_article.category, 'workspace')
@users('employee')
@warmup
def test_article_creation_multi_roots(self):
with self.assertQueryCount(employee=33):
_article = self.env['knowledge.article'].create([
{'body': '<p>Hello</p>',
'internal_permission': 'write',
'name': f'Article {index} in workspace',
}
for index in range(10)
])
@users('employee')
@warmup
def test_article_creation_multi_shared_grandchild(self):
with self.assertQueryCount(employee=57):
_article = self.env['knowledge.article'].create([
{'body': '<p>Hello</p>',
'name': f'Article {index} in workspace',
'parent_id': self.shared_children[0].id,
}
for index in range(10)
])
@users('employee')
@warmup
def test_article_favorite(self):
with self.assertQueryCount(employee=8): # knowledge only: 8
shared_article = self.shared_children[0].with_env(self.env)
shared_article.action_toggle_favorite()
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests')
@users('employee')
@warmup
def test_article_invite_members(self):
with self.assertQueryCount(employee=85): # knowledge only: 84
shared_article = self.shared_children[0].with_env(self.env)
partners = (self.customer + self.partner_employee_manager + self.partner_employee2).with_env(self.env)
shared_article.invite_members(partners, 'write')
@users('employee')
@warmup
def test_article_move_to(self):
before_id = self.workspace_children[0].id
with self.assertQueryCount(employee=25):
writable_article = self.workspace_children[1].with_env(self.env)
writable_article.move_to(parent_id=writable_article.parent_id.id, before_article_id=before_id)
@@ -1,204 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import exceptions
from odoo.addons.knowledge.tests.common import KnowledgeArticlePermissionsCase
from odoo.tests.common import tagged, users
from odoo.tools import mute_logger
@tagged('knowledge_acl')
class TestKnowledgeSecurity(KnowledgeArticlePermissionsCase):
""" Tests ACLs and low level access on models. Do not test the internals
of permission comuptation as those are done in another test suite. Here
we rely on them to check the create/read/write/unlink access checks. """
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('user_public')
def test_models_as_public(self):
# ARTICLE
with self.assertRaises(exceptions.AccessError, msg='ACLs: No article access to public'):
self.env['knowledge.article'].search([])
# FAVORITES
with self.assertRaises(exceptions.AccessError, msg='ACLs: No favorite access to public'):
self.env['knowledge.article.favorite'].search([])
# MEMBERS
with self.assertRaises(exceptions.AccessError, msg='ACLs: No member access to public'):
self.env['knowledge.article.member'].search([])
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('portal_test')
def test_models_as_portal(self):
article_root = self.article_roots[0].with_env(self.env)
# ARTICLES
with self.assertRaises(exceptions.AccessError,
msg="ACLs: No access given to portal"):
article_root.body # access body should trigger acls
article_shared = self.article_roots[2].with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: Internal permission 'none', not for portal"):
article_shared.body # access body should trigger acls
article_accessible = self.article_write_contents[2].with_env(self.env)
self.assertEqual(article_accessible.body, '<p>Writable Subarticle through inheritance</p>',
"ACLs: should be accessible due to explicit 'read' member permission")
self.assertTrue(article_accessible.is_user_favorite)
# FAVORITES
favs = self.env['knowledge.article.favorite'].search([])
self.assertEqual(len(favs), 1)
self.assertEqual(favs.article_id, article_accessible)
sudo_favorites = self.article_roots.favorite_ids.with_env(self.env)
self.assertEqual(len(sudo_favorites), 2)
with self.assertRaises(exceptions.AccessError,
msg='ACLs: Breaking rule for portal'):
sudo_favorites.mapped('user_id') # access body should trigger acls
# MEMBERS
my_members = self.env['knowledge.article.member'].search([])
self.assertEqual(len(my_members), 3)
self.assertEqual(my_members.partner_id, self.env.user.partner_id)
sudo_members = self.article_roots.article_member_ids.with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg='Breaking rule for portal'):
sudo_members.mapped('partner_id') # access body should trigger acls
@mute_logger('odoo.models.unlink')
@users('admin')
def test_models_as_system(self):
self.assertTrue(self.env.user.has_group('base.group_system'))
article_roots = self.article_roots.with_env(self.env)
article_roots.mapped('body') # access body should trigger acls
article_hidden = self.article_read_contents[3].with_env(self.env)
article_hidden.body # access body should trigger acls
# ARTICLE: CREATE/READ
# create a private article for another user
other_private = self.env['knowledge.article'].create({
'article_member_ids': [(0, 0, {
'partner_id': self.partner_employee.id,
'permission': 'write',
})],
'internal_permission': 'none',
'name': 'Private for Employee',
})
self.assertMembers(other_private, 'none', {self.partner_employee: 'write'})
self.assertEqual(other_private.category, 'private')
self.assertFalse(other_private.user_has_write_access, 'Can write based on permission is False but can perform write due to ACLs')
other_private.write({'name': 'Admin can do everything'})
# create a child to it
other_private_child = self.env['knowledge.article'].create({
'name': 'Child of Private for Employee',
'parent_id': other_private.id,
})
self.assertMembers(other_private_child, False, {})
self.assertEqual(other_private_child.article_member_ids.partner_id, self.env['res.partner'])
self.assertEqual(other_private_child.category, 'private')
self.assertFalse(other_private_child.user_has_write_access, 'Can write based on permission is False but can perform write due to ACLs')
# ARTICLE: WRITE
other_private.write({'name': 'Can Update'})
other_private_child.write({'name': 'Can Also Update'})
# FAVORITES: CREATE/READ/UNLINK
other_private_child.action_toggle_favorite()
self.assertTrue(other_private_child.is_user_favorite)
favorite_rec = self.env['knowledge.article.favorite'].search([('article_id', '=', other_private_child.id)])
favorite_rec.unlink()
self.assertFalse(other_private_child.is_user_favorite)
# MEMBERS: CREATE/READ/UNLINK
members = other_private.article_member_ids
self.assertEqual(members.partner_id, self.partner_employee)
new_member = self.env['knowledge.article.member'].create({
'article_id': other_private.id,
'partner_id': self.partner_employee2.id,
'permission': 'read',
})
members = other_private.article_member_ids
self.assertEqual(members.partner_id, self.partner_employee + self.partner_employee2)
new_member.write({'permission': 'write'})
members.filtered(lambda m: m.partner_id == self.partner_employee).unlink()
members = other_private.article_member_ids
self.assertEqual(members, new_member)
self.assertEqual(members.partner_id, self.partner_employee2)
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('employee')
def test_models_as_user(self):
article_roots = self.article_roots.with_env(self.env)
# ARTICLES
article_roots.mapped('body') # access body should trigger acls
article_roots[0].write({'name': 'Hacked (or not)'})
with self.assertRaises(exceptions.AccessError,
msg="ACLs: 'read' internal permission"):
article_roots[1].write({'name': 'Hacked'})
with self.assertRaises(exceptions.AccessError,
msg="ACLs: 'read' member permission"):
article_roots[2].write({'name': 'Hacked'})
article_hidden = self.article_read_contents[3].with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: 'none' internal permission"):
article_hidden.body # access body should trigger acls
# FAVORITES
my_favs = self.env['knowledge.article.favorite'].search([])
self.assertEqual(
my_favs,
self.articles_all.favorite_ids.filtered(lambda f: f.user_id == self.env.user),
'Favorites: employee should see its own favorites'
)
my_favs.mapped('user_id') # access body should trigger acls
my_favs.write({'sequence': 0})
with self.assertRaises(exceptions.AccessError,
msg="ACLs: should not be used to change article/user"):
my_favs[0].write({'article_id': article_roots[0].id})
with self.assertRaises(exceptions.AccessError,
msg="ACLs: should not be used to change article/user"):
my_favs[0].write({'user_id': self.user_portal.id})
# MEMBERS
my_members = self.env['knowledge.article.member'].search([('article_id', 'in', self.article_roots.ids)])
self.assertEqual(len(my_members), 4)
self.assertEqual(
my_members,
self.article_roots.article_member_ids,
'Members: employee should memberships of visible '
)
# remove employee from Shared root, check they cannot read those members
self.article_roots[2].article_member_ids.filtered(lambda m: m.partner_id == self.partner_employee).unlink()
my_members = self.env['knowledge.article.member'].search([('article_id', 'in', self.article_roots.ids)])
self.assertEqual(len(my_members), 2)
self.assertEqual(
my_members,
(self.article_roots[1] + self.article_roots[3]).article_member_ids,
'Members: employee should see its own memberships'
)
my_members.mapped('partner_id') # access body should trigger acls
with self.assertRaises(exceptions.AccessError,
msg="ACLs: no ACLs for write for user"):
my_members.write({'permission': 'write'})
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('employee')
def test_models_as_user_copy(self):
article_hidden = self.article_read_contents[3].with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: 'none' internal permission"):
article_hidden.body # access body should trigger acls
with self.assertRaises(exceptions.AccessError,
msg="ACLs: copy should not allow to access hidden articles"):
_new_article = article_hidden.copy()
article_root_readonly = self.article_roots[0].with_env(self.env)
new_article = article_root_readonly.copy()
self.assertEqual(new_article.name, f'{article_root_readonly.name} (copy)')
-53
View File
@@ -1,53 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.knowledge.tests.common import KnowledgeCommon
from odoo.tests.common import tagged
@tagged('knowledge_user')
class TestResUsers(KnowledgeCommon):
def test_onboarding_article(self):
internal_user = self.env['res.users'].with_context(**self._test_context).create({
'email': 'hector@example.com',
'login': 'hector',
'password': 'hectorhector',
'name': 'Hector Tue',
'groups_id': [(4, self.env.ref('base.group_user').id)],
})
onboarding_article = self.env['knowledge.article'].search(
[('article_member_ids.partner_id', '=', internal_user.partner_id.id)]
)
self.assertMembers(onboarding_article, 'none', {internal_user.partner_id: 'write'})
hector_article = onboarding_article.with_user(internal_user)
self.assertTrue(hector_article.is_user_favorite)
self.assertTrue(hector_article.name, f'Welcome {internal_user.name}')
def test_onboarding_article_skip(self):
portal_user = self.env['res.users'].with_context(**self._test_context).create({
'email': 'patrick@example.com',
'login': 'patrick',
'password': 'patrickpatrick',
'name': 'Patrick Hochet',
'groups_id': [(4, self.env.ref('base.group_portal').id)],
})
onboarding_article = self.env['knowledge.article'].search(
[('article_member_ids.partner_id', '=', portal_user.partner_id.id)]
)
self.assertFalse(onboarding_article)
internal_user = self.env['res.users'].with_context(
knowledge_skip_onboarding_article=True,
**self._test_context
).create({
'email': 'roberta@example.com',
'login': 'roberta',
'password': 'robertaroberta',
'name': 'Roberta Rabiscotée',
'groups_id': [(4, self.env.ref('base.group_user').id)],
})
onboarding_article = self.env['knowledge.article'].search(
[('article_member_ids.partner_id', '=', internal_user.partner_id.id)]
)
self.assertFalse(onboarding_article)
@@ -1,61 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="knowledge_article_favorite_view_form" model="ir.ui.view">
<field name="name">knowledge.article.favorite.view.form</field>
<field name="model">knowledge.article.favorite</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form string="Favorite">
<sheet>
<group>
<field name="article_id"/>
<field name="user_id"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="knowledge_article_favorite_view_tree" model="ir.ui.view">
<field name="name">knowledge.article.favorite.view.tree</field>
<field name="model">knowledge.article.favorite</field>
<field name="arch" type="xml">
<tree string="Favorites" sample="1" editable="bottom">
<field name="article_id" options="{'no_open': True}"/>
<field name="user_id" widget="many2one_avatar"/>
</tree>
</field>
</record>
<record id="knowledge_article_favorite_view_search" model="ir.ui.view">
<field name="name">knowledge.article.favorite.view.search</field>
<field name="model">knowledge.article.favorite</field>
<field name="arch" type="xml">
<search string="Search Favorites">
<field name="article_id"/>
<field name="user_id"/>
<group expand="0" string="Group By">
<filter string="Article" name="group_by_article_id"
domain="[]" context="{'group_by': 'article_id'}"/>
<filter string="User" name="group_by_user_id"
domain="[]" context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
</record>
<record id="knowledge_article_favorite_action" model="ir.actions.act_window">
<field name="name">Favorites</field>
<field name="res_model">knowledge.article.favorite</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No Favorites yet!
</p>
<p>
Add articles in your list of favorites by clicking on the <i class="fa fa-star-o"/> next to the article name.
</p>
</field>
</record>
</odoo>
@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="knowledge_article_member_view_form" model="ir.ui.view">
<field name="name">knowledge.article.member.view.form</field>
<field name="model">knowledge.article.member</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form string="Member">
<sheet>
<group>
<field name="article_id"/>
<field name="partner_id"/>
<field name="permission"/>
<field name="article_permission"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="knowledge_article_member_view_tree" model="ir.ui.view">
<field name="name">knowledge.article.member.view.tree</field>
<field name="model">knowledge.article.member</field>
<field name="arch" type="xml">
<tree string="Members" sample="1" create="0" edit="0">
<field name="article_id"/>
<field name="partner_id" widget="many2one_avatar"/>
<field name="permission"/>
</tree>
</field>
</record>
<record id="knowledge_article_member_view_search" model="ir.ui.view">
<field name="name">knowledge.article.member.view.search</field>
<field name="model">knowledge.article.member</field>
<field name="arch" type="xml">
<search string="Search Members">
<field name="article_id"/>
<field name="partner_id"/>
<group expand="0" string="Group By">
<filter string="Article" name="group_by_article_id"
domain="[]" context="{'group_by': 'article_id'}"/>
<filter string="Partner" name="group_by_partner_id"
domain="[]" context="{'group_by': 'partner_id'}"/>
</group>
</search>
</field>
</record>
<record id="knowledge_article_member_action" model="ir.actions.act_window">
<field name="name">Members</field>
<field name="res_model">knowledge.article.member</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No Members yet!
</p>
<p>
Adding members allows you to share Articles while granting specific access rights<br />(can write, can read, ...).
</p>
</field>
</record>
</odoo>
@@ -1,349 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="knowledge_article_view_form" model="ir.ui.view">
<field name="name">knowledge.article.view.form</field>
<field name="model">knowledge.article</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form class="h-100" js_class="knowledge_article_view_form">
<field name="active" invisible="1"/>
<field name="icon" invisible="1"/>
<field name="category" invisible="1"/>
<field name="is_locked" invisible="1" readonly="1"/>
<field name="is_user_favorite" invisible="1"/>
<field name="user_has_write_access" invisible="1"/>
<field name="user_permission" invisible="1"/>
<div class="d-sm-flex h-100 bg-white">
<aside class="o_knowledge_aside d-print-none flex-shrink-0 p-0">
<div class="d-flex flex-column h-100">
<!-- Search bar -->
<div id="knowledge_search_bar" class="d-flex align-items-center border bg-light rounded m-3 o_cursor_pointer" data-hotkey="f">
<div class="flex-grow-1 px-2 py-1">Search an article...</div>
<i class="oi oi-search pe-2" title="search"/>
</div>
<!-- File explorer -->
<div class="flex-grow-1 position-relative">
<div class="o_scroll_view px-3">
<div class="o_knowledge_tree">
<div class="p-5 text-center">
<i class="fa fa-circle-o-notch fa-2x fa-spin" title="loader" role="img"/>
</div>
</div>
</div>
</div>
</div>
</aside>
<div class="flex-grow-1 position-relative">
<div class="o_knowledge_article_form_resizer d-none d-sm-block" />
<div class="d-flex flex-column h-100">
<!-- Article header -->
<div class="o_knowledge_header d-sm-flex border-bottom justify-content-between d-print-none">
<div class="d-flex align-items-center">
<!-- Breadcrumbs -->
<ol class="breadcrumb align-items-center p-3" role="navigation">
<li class="breadcrumb-item d-flex align-items-center">
<div class="o_knowledge_icon pe-1" attrs="{'invisible': [('icon', '=', False)]}">
<div class="o_article_emoji_dropdown dropdown">
<a href="#" class="o_article_editable_emoji o_article_emoji dropdown-toggle o-no-caret"
type="button" data-bs-toggle="dropdown" aria-expanded="true"
attrs="{'invisible': ['|', ('is_locked', '=', True), ('user_has_write_access', '=', False)]}"/>
<span class="o_article_editable_emoji o_article_emoji o-no-caret"
attrs="{'invisible': [('is_locked', '=', False), ('user_has_write_access', '=', True)]}"/>
<div class="o_article_emoji_dropdown_panel dropdown-menu rounded p-0" role="menu"
aria-labelledby="o_article_emoji" />
</div>
</div>
<field attrs="{'readonly': ['|', ('is_locked', '=', True), ('user_has_write_access', '=', False)]}"
class="o_breadcrumb_article_name o_input" name="name"/>
</li>
</ol>
<i class="fa fa-star o_toggle_favorite o_cursor_pointer"
title="Remove from favorites"
attrs="{'invisible': [('is_user_favorite', '=', False)]}"/>
<i class="fa fa-star-o o_toggle_favorite o_cursor_pointer"
title="Add to favorites"
attrs="{'invisible': [('is_user_favorite', '=', True)]}"/>
<div attrs="{'invisible': ['|', ('is_locked', '=', False), ('user_has_write_access', '=', False)]}">
<i class="fa fa-fw fa-lock" title="This article is locked"/>
</div>
</div>
<!-- Buttons -->
<div class="d-flex flex-shrink-0 align-items-center pe-2">
<a type="button" role="button" class="btn btn-light btn-create text-capitalize me-1" data-hotkey="c">
<i class="fa fa-plus-circle"/> Create
</a>
<div class="btn-group" role="toolbar" aria-label="options">
<div class="o-dropdown dropdown o-dropdown--no-caret">
<a type="button" role="button" class="btn btn-light btn-share dropdown-toggle me-1"
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" data-hotkey="s"
attrs="{'invisible': [('id', '=', False)]}">
<i class="fa fa-share-alt"/> Share
</a>
<a type="button" role="button" class="btn btn-light btn-chatter me-1" data-hotkey="d"
attrs="{'invisible': [('id', '=', False)]}">
<i class="rounded-circle text-center fa fa-comments" title="Open chatter"/>
</a>
<div class="o_knowledge_share_panel dropdown-menu" role="menu">
<div class="px-3 pb-2">
<button type="action" name="%(knowledge_invite_action_from_article)d"
class="btn btn-primary w-100"
attrs="{'invisible': [('user_has_write_access', '=', False)]}">
<i class="fa fa-plus"/> Invite
</button>
</div>
<div class="o_knowledge_permission_panel"/>
</div>
</div>
</div>
<div class="btn-group" role="toolbar" aria-label="options">
<div class="o-dropdown dropdown o-dropdown--no-caret">
<a type="button" role="button" class="btn btn-light dropdown-toggle" data-bs-toggle="dropdown"
aria-haspopup="true" aria-expanded="false" data-hotkey="p">
<i class="fa fa-ellipsis-v"/>
</a>
<div class="o_knowledge_more_options_panel dropdown-menu" role="menu">
<a class="dropdown-item btn-duplicate" href="#" role="button">
<i class="fa fa-fw fa-copy"/> Duplicate
</a>
<div attrs="{'invisible': [('user_has_write_access', '=', False)]}">
<a class="dropdown-item btn-move" href="#" role="button">
<i class="fa fa-fw fa-angle-double-right"/> Move To
</a>
<button class="dropdown-item btn-lock" href="#" type="object" name="action_set_lock"
attrs="{'invisible': [('is_locked', '=', True)]}">
<i class="fa fa-fw fa-lock"/> Lock
</button>
<button class="dropdown-item btn-lock" href="#" type="object" name="action_set_unlock"
attrs="{'invisible': [('is_locked', '=', False)]}">
<i class="fa fa-fw fa-unlock"/> Unlock
</button>
<button class="dropdown-item" type="object" name="action_article_archive"
attrs="{'invisible': [('active', '=', False)]}">
<i class="fa fa-fw fa-archive"/> Archive
</button>
<button class="dropdown-item" type="object" name="action_unarchive"
attrs="{'invisible': [('active', '=', True)]}">
<i class="fa fa-fw fa-archive"/> Unarchive
</button>
<div class="dropdown-divider"/>
<div class="px-3">
<field name="full_width" widget="boolean_toggle" force_save="1"/>
<label for="full_width" class="m-0"/>
</div>
<div class="dropdown-divider"
attrs="{'invisible': [('last_edition_uid', '=', False), ('last_edition_date', '=', False)]}"/>
</div>
<div class="px-4 py-2 mx-1" attrs="{'invisible': [('last_edition_uid', '=', False)]}">
<h6 class="small fw-bold m-0">Last Edited by:</h6>
<div class="d-flex">
<field name="last_edition_uid" widget="many2one_avatar_user" readonly="1" class="text-truncate"/>
<span class="mx-1">-</span>
<field name="last_edition_date" widget="datetime" readonly="1" class="text-nowrap"/>
</div>
<h6 class="small fw-bold m-0">Created by:</h6>
<div class="d-flex">
<field name="create_uid" widget="many2one_avatar_user" readonly="1" class="text-truncate"/>
<span class="mx-1">-</span>
<field name="create_date" widget="datetime" readonly="1" class="text-nowrap"/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex-grow-1 position-relative">
<div class="o_scroll_view">
<div class="row p-0 m-0" style="min-height: 100%">
<div class="d-flex col-12 col-lg position-relative p-0">
<div class="o_scroll_view_lg">
<!-- Article body -->
<div class="o_knowledge_body d-flex flex-column">
<!-- Article Cover -->
<field name="cover" widget="image" class="o_knowledge_cover_image"
attrs="{'invisible': [('cover', '=', False)], 'readonly': [('is_locked', '=', True)]}"/>
<!-- Full Width handling -->
<span class="o_knowledge_article_view_form_dynamic_width d-none"
attrs="{'invisible': [('full_width', '=', False)]}"/>
<!-- Icon and Cover buttons -->
<div class="o_knowledge_icon_cover_buttons text-muted bg-white d-print-none mt-2"
attrs="{'invisible': ['|', ('is_locked', '=', True), ('user_has_write_access', '=', False)]}">
<a type="button" role="button" attrs="{'invisible': [('icon', '!=', False)]}"
class="btn btn-secondary me-2 ps-1 o_knowledge_add_icon">
<i class="fa fa-fw fa-smile-o" style="font-size: 1.5em;" title="Add icon"/> Add icon
</a>
<a type="button" role="button" attrs="{'invisible': [('cover', '!=', False)]}"
class="btn btn-secondary o_knowledge_add_cover">
<i class="fa fa-fw fa-picture-o" style="font-size: 1.5em;" title="Add cover"/> Add cover
</a>
</div>
<!-- Article Icon -->
<span class="d-none" attrs="{'invisible': [('cover', '!=', False)]}" />
<div class="o_knowledge_icon o_large" attrs="{'invisible': [('icon', '=', False)]}">
<div class="o_article_emoji_dropdown dropdown">
<a href="#" class="o_article_emoji dropdown-toggle o-no-caret"
type="button" data-bs-toggle="dropdown" aria-expanded="true"
attrs="{'invisible': ['|', ('is_locked', '=', True), ('user_has_write_access', '=', False)]}"/>
<p class="o_article_emoji o-no-caret"
attrs="{'invisible': [('is_locked', '=', False), ('user_has_write_access', '=', True)]}"/>
<div class="o_article_emoji_dropdown_panel dropdown-menu rounded p-0" role="menu"
aria-labelledby="o_article_emoji" />
</div>
</div>
<!-- No article helper -->
<field name="category" invisible="1"/>
<div class="o_view_nocontent" attrs="{'invisible': [('id', '!=', False)]}">
<div class="o_nocontent_help">
<p class="o_view_nocontent_smiling_face">
No article yet.
</p>
<p>
<a href="#" role="button" class="btn-create">Create an article</a> to unleash the power of Knowledge !
</p>
</div>
</div>
<!-- Article body -->
<div class="o_knowledge_editor d-flex flex-grow-1">
<field name="body" widget="html" no_label="1" default_focus="1"
options="{'resizable': False, 'knowledge_commands': true}"
attrs="{'readonly': ['|', ('is_locked', '=', True), ('user_has_write_access', '=', False)]}"/>
</div>
</div>
</div>
</div>
<!-- Chatter -->
<div class="o_knowledge_chatter col-12 col-lg-4 position-relative d-none d-print-none p-0">
<div class="o_scroll_view_lg o_knowledge_chatter_container"/>
</div>
</div>
</div>
</div>
</div>
<div class="o_knowledge_article_form_resizer d-print-none d-none d-sm-block px-3 opacity-75 opacity-100-hover">
<span class="bg-300 ps-1"/>
</div>
</div>
</div>
</form>
</field>
</record>
<record id="knowledge_article_view_tree" model="ir.ui.view">
<field name="name">knowledge.article.view.tree</field>
<field name="model">knowledge.article</field>
<field name="arch" type="xml">
<tree create="0" default_order="is_user_favorite desc, favorite_count desc">
<field name="display_name"/>
<field name="is_user_favorite" widget="boolean_favorite" nolabel="1"
force_save="1" options="{'allow_order': '1'}"/>
<field name="favorite_count" invisible="1"/>
<field name="root_article_id"/>
<field name="last_edition_uid" widget="many2one_avatar_user"/>
<field name="last_edition_date"/>
</tree>
</field>
</record>
<record id="knowledge_article_view_kanban" model="ir.ui.view">
<field name="name">knowledge.article.view.kanban</field>
<field name="model">knowledge.article</field>
<field name="arch" type="xml">
<kanban class="o_knowledge_article_kanban_view">
<field name="icon" invisible="1"/>
<field name="is_locked" invisible="1"/>
<field name="is_user_favorite" invisible="1"/>
<field name="user_has_write_access" invisible="1"/>
<field name="article_url" invisible="1"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<div class="o_knowledge_kanban_card d-flex flex-column">
<div class="d-flex align-items-center">
<div class="flex-grow-1 lead text-truncate">
<field name="display_name"/>
</div>
<!-- When the user does not have 'write' access on an article, the user will not be
allowed to write on the `knowledge.article` model. Therefore, we can not use the
`boolean_favorite` widget as the model can block the write call issued by the widget.
To let the user add an article to their favorites, we will instead call an
intermediate action that will do a sudo call on the `knowledge.article` model. -->
<a type="object" name="action_toggle_favorite" class="o_favorite o_field_widget"
attrs="{'invisible': [('is_user_favorite', '=', False)]}">
<i class="fa fa-star" title="Remove from favorites"/>
</a>
<a type="object" name="action_toggle_favorite" class="o_favorite o_field_widget"
attrs="{'invisible': [('is_user_favorite', '=', True)]}">
<i class="fa fa-star-o" title="Add to favorites"/>
</a>
<div attrs="{'invisible': ['|', ('is_locked', '=', False), ('user_has_write_access', '=', False)]}">
<i class="fa fa-fw fa-lock" title="locked"/>
</div>
</div>
<div class="flex-grow-1 text-muted">
<field name="category"/>
</div>
<div class="d-flex justify-content-end align-items-center">
<div class="flex-grow-1">
<field name="article_url" widget="CopyClipboardButton"
options="{'label': 'Copy Link'}"/>
</div>
<field name="activity_ids" widget="kanban_activity"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="knowledge_article_view_search" model="ir.ui.view">
<field name="name">knowledge.article.view.search</field>
<field name="model">knowledge.article</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="root_article_id"/>
<field name="body"/>
<field name="last_edition_uid"/>
<filter name="filter_active" string="Archived"
domain="[('active', '=', False)]"/>
<filter name="filter_favorites" string="My Favorites"
domain="[('is_user_favorite', '=', True)]"/>
<filter name="filter_own_privates" string="My Private Articles"
domain="[('category', '=', 'private'), ('user_has_write_access', '=', True)]"/>
<filter name="filter_shared_articles" string="My Shared Articles"
domain="[('category', '=', 'shared')]"/>
<group expand="0" string="Group By">
<filter string="Category" name="group_by_category" domain="[]" context="{'group_by': 'category'}"/>
</group>
</search>
</field>
</record>
<record id="knowledge_article_action" model="ir.actions.act_window">
<field name="name">Articles</field>
<field name="res_model">knowledge.article</field>
<field name="view_mode">tree,kanban,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create an article
</p><p>
Be the first one to unleash the power of Knowledge !
</p>
</field>
</record>
<record id="knowledge_article_action_form" model="ir.actions.act_window">
<field name="name">Articles</field>
<field name="res_model">knowledge.article</field>
<field name="view_mode">form</field>
<field name="view_id" ref="knowledge.knowledge_article_view_form"/>
</record>
</odoo>
@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem
id="knowledge_menu_root"
name="Knowledge"
web_icon="knowledge,static/description/icon.png"
sequence="17"/>
<menuitem
id="knowledge_menu_home"
name="Home"
parent="knowledge_menu_root"
action="knowledge.ir_actions_server_knowledge_home_page"
sequence="1"/>
<menuitem
id="knowledge_menu_article"
name="Search"
parent="knowledge_menu_root"
action="knowledge.knowledge_article_action"
sequence="2"/>
<menuitem
id="knowledge_menu_configuration"
name="Configuration"
parent="knowledge_menu_root"
groups="base.group_no_one"
sequence="99"/>
<menuitem
id="knowledge_article_member_menu"
name="Members"
action="knowledge.knowledge_article_member_action"
parent="knowledge_menu_configuration"
groups="base.group_system"
sequence="1"/>
<menuitem
id="knowledge_article_favorite_menu"
name="Favorites"
action="knowledge.knowledge_article_favorite_action"
parent="knowledge_menu_configuration"
groups="base.group_system"
sequence="2"/>
</odoo>
@@ -1,54 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Article Tree layout -->
<template id="knowledge_article_tree" name="Article Tree">
<t t-call="knowledge.knowledge_article_tree_favorites"/>
<section class="o_section" data-section="workspace">
<div class="o_section_header d-flex h4 mb-0">
<div class="flex-grow-1 text-truncate">Workspace</div>
<button class="o_section_create btn btn-link text-secondary rounded p-0" type="button">
<i class="fa fa-fw fa-plus" title="Create a new article in workspace" />
</button>
</div>
<ul class="o_tree">
<span t-att-class="('d-none' if workspace_articles else '') + ' o_knowledge_empty_info text-muted'">
<i>There are no Articles in your Workspace.</i>
</span>
<t t-call="knowledge.articles_template">
<t t-set="articles" t-value="workspace_articles"/>
</t>
</ul>
</section>
<section t-if="shared_articles" class="o_section mb-3" data-section="shared">
<div class="o_section_header d-flex h4 mb-0">
<div class="flex-grow-1 text-truncate">Shared</div>
</div>
<ul class="o_tree">
<t t-call="knowledge.articles_template">
<t t-set="articles" t-value="shared_articles"/>
</t>
</ul>
</section>
<section class="o_section mb-3" data-section="private">
<div class="o_section_header d-flex h4 mb-0">
<div class="flex-grow-1 text-truncate">Private</div>
<button class="o_section_create btn btn-link text-secondary rounded p-0" type="button">
<i class="fa fa-fw fa-plus" title="Create a new private article" />
</button>
</div>
<ul class="o_tree">
<span t-att-class="('d-none' if private_articles else '') + ' o_knowledge_empty_info text-muted'">
<i>You do not have any private Articles.</i>
</span>
<t t-call="knowledge.articles_template">
<t t-set="articles" t-value="private_articles"/>
</t>
</ul>
</section>
</template>
</data>
</odoo>
@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="knowledge_article_tree_favorites">
<section class="o_favorite_container" t-if="favorites">
<div class="h4 mb-0">Favorites</div>
<ul class="o_tree_favorite">
<t t-call="knowledge.articles_template">
<t t-set="articles" t-value="favorites.mapped('article_id')"/>
<t t-set="hideChildren" t-value="true"/>
</t>
</ul>
</section>
<section class="o_favorite_container" t-else=""/>
</template>
<template id="articles_template">
<t t-set="unfolded_articles" t-value="unfolded_articles or []"/>
<t t-foreach="articles" t-as="article">
<t t-set="readonly" t-value="portal_readonly_mode or not article.user_has_write_access"/>
<t t-set="favorite" t-value="favorites.filtered(lambda f: f.article_id == article) if favorites else False"/>
<!-- readonly is used to for the drag and drop cancel.-->
<li t-attf-class="o_article #{ 'readonly' if readonly else '' }" t-attf-id="article_#{article.id}"
t-att-data-article-id="article.id" t-att-data-parent-id="article.parent_id.id"
t-att-data-favorite-article-id="favorite.id if favorite else False"
t-att-data-category="article.category">
<t t-set="isActive" t-value="article.id == active_article.id if active_article else False"/>
<div t-attf-class="#{ '' if readonly else 'o_article_handle' } d-flex align-items-center #{ 'o_article_active fw-bold' if isActive else 'text-muted' }">
<button t-if="not hideChildren and article.child_ids" class="o_article_caret btn btn-link text-muted p-0" type="button">
<i t-if="article.child_ids" t-att-class="'align-self-center fa fa-fw fa-caret-' + ('down' if article.id in unfolded_articles else 'right')"/>
<i t-else="" class="ps-3 align-self-center"/>
</button>
<t t-call="knowledge.articles_template_name"/>
</div>
<ul t-if="not hideChildren and article.child_ids and article.id in unfolded_articles">
<t t-call="knowledge.articles_template">
<t t-set="articles" t-value="article.child_ids.sorted('sequence')"/>
</t>
</ul>
</li>
</t>
</template>
<template id="articles_template_name">
<t t-if="not portal_readonly_mode">
<div class="o_article_emoji_dropdown dropdown" t-att-data-article-id="article.id">
<a t-attf-class="o_article_emoji p-1 {{'o_article_emoji_active text-900' if isActive else ''}}" type="button"
t-attf-id="o_article_emoji_dropdown_#{article.id}" data-bs-toggle="dropdown" aria-expanded="true"
t-if="not readonly and not article.is_locked">
<t t-if="article.icon" t-out="article.icon"/>
<t t-else="">📄</t>
</a>
<a t-else="" class="p-1">
<t t-if="article.icon" t-out="article.icon"/>
<t t-else="">📄</t>
</a>
<div class="o_article_emoji_dropdown_panel dropdown-menu rounded p-0" role="menu"
t-attf-aria-labelledby="o_article_emoji_dropdown_#{article.id}"/>
</div>
<div class="o_article_name flex-grow-1 text-truncate o_cursor_pointer" t-out="article.name"/>
<button t-if="not hideChildren and not readonly" class="o_article_create btn p-0 opacity-75 opacity-100-hover" type="button">
<i class="fa fa-fw fa-plus" title="Create a nested article" />
</button>
</t>
<div class="o_article_name flex-grow-1 text-truncate o_cursor_pointer" t-else="">
<a t-out="article.display_name" t-attf-href="/knowledge/article/{{article.id}}" class="text-decoration-none"/>
</div>
</template>
</data>
</odoo>
@@ -1,102 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Article layout -->
<template id="knowledge.layout" name="knowledge Layout" inherit_id="web.frontend_layout" primary="True">
<xpath expr="//header" position="before">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
</xpath>
</template>
<template id="knowledge_article_view_frontend" name="Knowledge Portal">
<t t-call="knowledge.layout">
<div class="container-fluid h-100 o_knowledge_form_view" t-att-data-article-id="article.id if article else False">
<div class="row h-100">
<div t-att-class="'p-0 overflow-auto' + (' col-sm-2' if show_sidebar else ' d-none')">
<div t-att-class="'o_knowledge_aside border-bottom border-end flex-column h-100 w-100' + (' d-flex' if show_sidebar else ' d-none')">
<div class="p-3">
<input type="text" class="form-control knowledge_search_bar" placeholder="Search an Article..."/>
</div>
<!-- File explorer -->
<div class="flex-grow-1 position-relative">
<div class="o_scroll_view px-3">
<div class="o_knowledge_tree" t-att-data-portal-readonly-mode="portal_readonly_mode">
<div class="p-5 text-center">
<i class="fa fa-circle-o-notch fa-2x fa-spin" title="loader" role="img"/>
</div>
</div>
</div>
</div>
</div>
</div>
<div t-att-class="('col-sm-10' if show_sidebar else 'col-sm-12') + ' p-0 overflow-auto'">
<div class="d-flex flex-column h-100">
<!-- Article header -->
<div class="o_knowledge_header d-sm-flex flex-row justify-content-between border-bottom">
<div class="d-flex align-items-center p-3" t-if="not article">
<h3>Error</h3>
</div>
<div class="d-flex align-items-center p-3" t-else="">
<!-- Title -->
<span t-out="article.name" />
<a href="#" class="o_favorites_toggle_button ps-1" t-att-data-article-id="article.id">
<i t-if="article.is_user_favorite" class="fa fa-star" />
<i t-else="" class="fa fa-star-o" />
</a>
</div>
<!-- Log In -->
<t t-call="portal.placeholder_user_sign_in">
<t t-set="_item_class" t-valuef="d-flex align-items-center p-2"/>
<t t-set="_link_class" t-valuef="nav-link fw-bold"/>
</t>
<t t-call="portal.user_dropdown">
<t t-set="_user_name" t-value="True"/>
<t t-set="_item_class" t-valuef="d-flex align-items-center p-2"/>
<t t-set="_link_class" t-valuef="nav-link fw-bold"/>
</t>
</div>
<div class="p-3 text-center" t-if="not article">
<p class="o_view_nocontent_empty_folder">
<h2>Not found</h2>
<br />
<p>The requested article does not exist or you do not have access to it.</p>
</p>
</div>
<div class="flex-grow-1 position-relative" t-else="">
<div class="o_scroll_view">
<!-- Article Cover -->
<div t-if="article.cover" class="o_knowledge_cover_image">
<img t-attf-src="data:image/png;base64,{{ article.cover }}" />
</div>
<!-- Article Icon -->
<div t-if="article.icon" t-attf-class="o_knowledge_icon px-3 #{'o_full_width' if article.full_width else ''}">
<t t-out="article.icon" />
</div>
<!-- Article Body -->
<div t-attf-class="o_knowledge_article px-3 #{'o_full_width' if article.full_width else ''}">
<t t-out="article.body" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
<template id="knowledge_article_tree_frontend" name="Frontend Article Tree">
<t t-call="knowledge.knowledge_article_tree_favorites" />
<t t-set="visible_articles" t-value="(workspace_articles | shared_articles)"/>
<section t-if="visible_articles">
<div class="h4 mb-0">Shared with you</div>
<ul class="o_tree o_tree_workspace m-0 px-0 py-2">
<t t-call="knowledge.articles_template">
<t t-set="articles" t-value="visible_articles"/>
</t>
</ul>
</section>
</template>
</data>
</odoo>
-4
View File
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import knowledge_invite
@@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class KnowledgeInvite(models.TransientModel):
_name = 'knowledge.invite'
_description = 'Knowledge Invite Wizard'
article_id = fields.Many2one('knowledge.article', required=True, ondelete="cascade")
have_share_partners = fields.Boolean(compute='_compute_have_share_partners')
partner_ids = fields.Many2many('res.partner', string='Recipients', required=True)
permission = fields.Selection([
('write', 'Can write'),
('read', 'Can read'),
('none', 'No access')
], required=True, default='write')
def action_invite_members(self):
self.article_id.invite_members(self.partner_ids, self.permission)
@api.depends('partner_ids')
def _compute_have_share_partners(self):
for wizard in self:
wizard.have_share_partners = any(partner_id.partner_share for partner_id in self.partner_ids)
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="knowledge_invite_view_form" model="ir.ui.view">
<field name="name">knowledge.invite.view.form</field>
<field name="model">knowledge.invite</field>
<field name="arch" type="xml">
<form class="py-0">
<field name="article_id" invisible="1"/>
<field name="have_share_partners" invisible="1"/>
<div colspan="2" class="alert alert-info text-center mb-0" role="alert"
attrs="{'invisible': ['|', ('have_share_partners', '=', False), ('permission', '!=', 'write')]}">
<i class="fa fa-w fa-info-circle"/> External users cannot write on Articles and will be granted read access instead.
</div>
<div colspan="2" class="alert alert-info text-center mb-0" role="alert"
attrs="{'invisible': ['|', ('have_share_partners', '=', False), ('permission', '!=', 'none')]}">
<i class="fa fa-w fa-info-circle"/> All external users you selected won't be added to the members.
</div>
<sheet>
<group>
<field name="permission"/>
<field name="partner_ids" widget="many2many_tags_email"
placeholder="Invite users (email address)..."
context="{'force_email': True, 'show_email': True}"/>
</group>
</sheet>
<footer>
<button string="Invite" type="object" name="action_invite_members" class="btn-primary"/>
<button string="Cancel" special="cancel" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<record id="knowledge_invite_action_from_article" model="ir.actions.act_window">
<field name="name">Invite people</field>
<field name="res_model">knowledge.invite</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_article_id': active_id}</field>
</record>
</odoo>
-5
View File
@@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
-25
View File
@@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Knowledge Website',
'summary': 'Publish your articles',
'version': '1.0',
'depends': ['knowledge', 'website'],
'data': [
'security/ir.model.access.csv',
'security/ir_rule.xml',
'views/knowledge_views.xml',
'views/knowledge_templates_frontend.xml',
],
'installable': True,
'application': False,
'auto_install': True,
'license': 'LGPL-3',
'assets': {
'web.assets_backend': [
'website_knowledge/static/src/js/knowledge_controller.js',
'website_knowledge/static/src/client_actions/website_preview/website_preview.js',
]
},
}
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
@@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.addons.knowledge.controllers.main import KnowledgeController
class KnowledgeWebsiteController(KnowledgeController):
# Override routes to display articles to public users
@http.route('/knowledge/article/<int:article_id>', type='http', auth='public', website=True, sitemap=False)
def redirect_to_article(self, **kwargs):
return super().redirect_to_article(**kwargs)
@http.route('/knowledge/home', type='http', auth='public', website=True, sitemap=False)
def access_knowledge_home(self):
return super().access_knowledge_home()
@http.route('/knowledge/tree_panel/children', type='json', auth='public', website=True, sitemap=False)
def get_tree_panel_children(self, parent_id):
return super().get_tree_panel_children(parent_id)
@@ -1,76 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * website_knowledge
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server saas~15.4\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-06-09 14:06+0000\n"
"PO-Revision-Date: 2022-06-09 14:06+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: website_knowledge
#: model_terms:ir.ui.view,arch_db:website_knowledge.knowledge_article_view_form
msgid "Anyone can view"
msgstr ""
#. module: website_knowledge
#: model_terms:ir.ui.view,arch_db:website_knowledge.knowledge_article_view_form
msgid "Article published"
msgstr ""
#. module: website_knowledge
#: model:ir.model.fields,field_description:website_knowledge.field_knowledge_article__can_publish
msgid "Can Publish"
msgstr ""
#. module: website_knowledge
#: model:ir.model.fields,field_description:website_knowledge.field_knowledge_article__is_published
msgid "Is Published"
msgstr ""
#. module: website_knowledge
#: model:ir.model,name:website_knowledge.model_knowledge_article
msgid "Knowledge Article"
msgstr ""
#. module: website_knowledge
#: model_terms:ir.ui.view,arch_db:website_knowledge.website_knowledge_article_tree_frontend
msgid "Public articles"
msgstr ""
#. module: website_knowledge
#: model_terms:ir.ui.view,arch_db:website_knowledge.knowledge_article_view_form
msgid "Publish and share with anyone"
msgstr ""
#. module: website_knowledge
#: model_terms:ir.ui.view,arch_db:website_knowledge.knowledge_article_view_form
msgid "Share to web"
msgstr ""
#. module: website_knowledge
#: model_terms:ir.ui.view,arch_db:website_knowledge.website_knowledge_article_tree_frontend
msgid "Shared with you"
msgstr ""
#. module: website_knowledge
#: model:ir.model.fields,help:website_knowledge.field_knowledge_article__website_url
msgid "The full URL to access the document through the website."
msgstr ""
#. module: website_knowledge
#: model:ir.model.fields,field_description:website_knowledge.field_knowledge_article__website_published
msgid "Visible on current website"
msgstr ""
#. module: website_knowledge
#: model:ir.model.fields,field_description:website_knowledge.field_knowledge_article__website_url
msgid "Website URL"
msgstr ""
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import knowledge_article
@@ -1,17 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class Article(models.Model):
_name = 'knowledge.article'
_inherit = ['knowledge.article', 'website.published.mixin']
@api.depends('article_url')
def _compute_website_url(self):
for record in self:
record.website_url = record.article_url
def get_backend_menu_id(self):
return self.env.ref('knowledge.knowledge_menu_root').id
@@ -1,2 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_knowledge_article_all,access.knowledge.article.all,knowledge.model_knowledge_article,,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_knowledge_article_all access.knowledge.article.all knowledge.model_knowledge_article 1 0 0 0
@@ -1,52 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- ARTICLE -->
<record id="rule_knowledge_article_users_read" model="ir.rule">
<field name="name">Articles: users/portal: published gives read access</field>
<field name="model_id" ref="knowledge.model_knowledge_article"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
<field name="domain_force">[('website_published', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_user')), (4, ref('base.group_portal'))]"/>
</record>
<record id="rule_knowledge_article_public_users_read" model="ir.rule">
<field name="name">Articles: public: published gives read access</field>
<field name="model_id" ref="knowledge.model_knowledge_article"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
<field name="domain_force">[('website_published', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_public'))]"/>
</record>
<!-- ARTICLE MEMBER -->
<record id="rule_knowledge_article_member_users" model="ir.rule">
<field name="name">Article members: sync with article</field>
<field name="model_id" ref="knowledge.model_knowledge_article_member"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
<field name="domain_force">[('article_id.website_published', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="rule_knowledge_article_member_portal" model="ir.rule">
<field name="name">Article members: portal: sync with article</field>
<field name="model_id" ref="knowledge.model_knowledge_article_member"/>
<field name="domain_force">[('partner_id', '=', user.partner_id.id), ('article_id.website_published', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ARTICLE FAVORITE -->
<record id="rule_knowledge_article_favorite_users" model="ir.rule">
<field name="name">Article favorite: sync with article</field>
<field name="model_id" ref="knowledge.model_knowledge_article_favorite"/>
<field name="domain_force">[('user_id', '=', user.id), ('article_id.website_published', '=', True)]</field>
<field name="groups" eval="[(4, ref('base.group_portal')), (4, ref('base.group_user'))]"/>
</record>
</odoo>
@@ -1,12 +0,0 @@
/** @odoo-module **/
import { patch } from 'web.utils';
import { WebsitePreview } from '@website/client_actions/website_preview/website_preview';
patch(WebsitePreview.prototype, 'website_knowledge_website_preview', {
/**
* @override
*/
_isTopWindowURL({ pathname }) {
return (pathname && pathname.startsWith('/knowledge/article/')) || this._super(...arguments);
}
});
@@ -1,13 +0,0 @@
/** @odoo-module **/
import { KnowledgeArticleFormController } from '@knowledge/js/knowledge_controller';
KnowledgeArticleFormController.include({
/**
* @override
* @returns {Array[String]}
*/
_getFieldsToForceSave: function () {
return this._super().concat('website_published');
},
});
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_knowledge_security
@@ -1,64 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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_acl')
class TestWKnowledgeSecurity(KnowledgeCommonWData):
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('user_public')
def test_models_as_public(self):
""" Test publish flag giving read access to articles """
article_shared = self.article_shared.with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: Internal permission 'none', not for public"):
article_shared.body # access body should trigger acls
article_shared.sudo().website_published = True
article_shared.body # access body should not trigger acls
self.assertFalse(article_shared.is_user_favorite)
# FAVOURITE
with self.assertRaises(exceptions.AccessError, msg='ACLs: No favorite access to public'):
self.env['knowledge.article.favorite'].search([])
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('portal_test')
def test_models_as_portal(self):
""" Test publish flag giving read access to articles """
article_shared = self.article_shared.with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: Internal permission 'none', not for portal"):
article_shared.body # access body should trigger acls
article_shared.sudo().website_published = True
article_shared.body # access body should trigger acls
self.assertFalse(article_shared.is_user_favorite)
# Read access gives access to favorite toggling
article_shared.action_toggle_favorite()
article_shared.invalidate_model(['is_user_favorite'])
self.assertTrue(article_shared.is_user_favorite)
@mute_logger('odoo.addons.base.models.ir_model', 'odoo.addons.base.models.ir_rule')
@users('portal_test')
def test_models_as_user(self):
""" Test publish flag giving read access to articles """
article_hidden = self.private_children[0].with_env(self.env)
with self.assertRaises(exceptions.AccessError,
msg="ACLs: 'none' internal permission"):
article_hidden.body # access body should trigger acls
article_hidden.sudo().website_published = True
article_hidden.body # access body should trigger acls
self.assertFalse(article_hidden.is_user_favorite)
# Read access gives access to favorite toggling
article_hidden.action_toggle_favorite()
article_hidden.invalidate_model(['is_user_favorite'])
self.assertTrue(article_hidden.is_user_favorite)
@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="website_knowledge_article_view_frontend" name="Website Knowledge Layout" inherit_id="knowledge.knowledge_article_view_frontend">
<xpath expr="//a[hasclass('o_favorites_toggle_button')]" position="replace">
<a t-if="not request.env.user._is_public()"
href="#" class="o_favorites_toggle_button ps-1" t-att-data-article-id="article.id">
<i t-if="article.is_user_favorite" class="fa fa-star" />
<i t-else="" class="fa fa-star-o" />
</a>
</xpath>
</template>
<template id="website_knowledge_article_tree_frontend" name="Website Knowledge Article Tree" inherit_id="knowledge.knowledge_article_tree_frontend">
<xpath expr="//section[1]" position="replace">
<t t-set="all_articles" t-value="(workspace_articles | shared_articles)"/>
<t t-set="published_articles" t-value="all_articles.filtered(lambda a: a.website_published)"/>
<t t-set="visible_articles" t-value="all_articles.filtered(lambda a: not a.website_published)"/>
<section t-if="published_articles">
<div class="h4 mb-0">Public articles</div>
<ul class="o_tree o_tree_workspace m-0 px-0 py-2">
<t t-call="knowledge.articles_template">
<t t-set="articles" t-value="published_articles"/>
</t>
</ul>
</section>
<section t-if="visible_articles">
<div class="h4 mb-0">Shared with you</div>
<ul class="o_tree o_tree_workspace m-0 px-0 py-2">
<t t-call="knowledge.articles_template">
<t t-set="articles" t-value="visible_articles"/>
</t>
</ul>
</section>
</xpath>
</template>
</odoo>
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form view: -->
<record id="knowledge_article_view_form" model="ir.ui.view">
<field name="name">knowledge.article.view.form.inherit.website</field>
<field name="model">knowledge.article</field>
<field name="inherit_id" ref="knowledge.knowledge_article_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('o_knowledge_share_panel')]/*[1]" position="before">
<field name="user_has_write_access" invisible="1"/>
<div>
<div class="d-flex px-3">
<div class="o_knowledge_share_panel_icon align-self-center">
<i class="fa fa-globe align-self-center fa-2x"/>
</div>
<div attrs="{'invisible': [('website_published', '=', False)]}"
class="flex-grow-1 px-3">
<div class="lead">Article published</div>
<div>Anyone can view</div>
</div>
<div attrs="{'invisible': [('website_published', '=', True)]}" class="flex-grow-1 px-3">
<div class="lead">Share to web</div>
<div>Publish and share with anyone</div>
</div>
<div class="align-self-center" attrs="{'invisible': [('user_has_write_access', '=', False)]}">
<field name="website_published" widget="boolean_toggle" force_save="1"/>
</div>
</div>
</div>
<div class="px-3 my-2" attrs="{'invisible': [('website_published', '=', False)]}">
<field name="article_url" widget="CopyClipboardChar" readonly="1"/>
</div>
<div class="dropdown-divider" attrs="{'invisible': [('user_has_write_access', '=', False), ('website_published', '=', False)]}"></div>
</xpath>
</field>
</record>
</odoo>