diff --git a/addons/knowledge/__init__.py b/addons/knowledge/__init__.py new file mode 100644 index 00000000000..88710707227 --- /dev/null +++ b/addons/knowledge/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models +from . import wizard diff --git a/addons/knowledge/__manifest__.py b/addons/knowledge/__manifest__.py new file mode 100644 index 00000000000..ed3bbf83ff7 --- /dev/null +++ b/addons/knowledge/__manifest__.py @@ -0,0 +1,73 @@ +# -*- 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' + ], + 'data': [ + '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', + 'assets': { + 'web.assets_backend': [ + ('remove', 'web_editor/static/src/scss/web_editor.backend.scss'), + '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', + ], + '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.assets_qweb': [ + 'knowledge/static/src/components/*/*.xml', + 'knowledge/static/src/xml/knowledge_templates.xml', + 'knowledge/static/src/xml/chatter_topbar.xml', + 'knowledge/static/src/xml/knowledge_command_palette.xml', + ], + 'web.assets_tests': [ + 'knowledge/static/tests/tours/*.js', + ], + 'web.qunit_suite_tests': [ + 'knowledge/static/src/tests/test_services.js', + ], + }, +} diff --git a/addons/knowledge/controllers/__init__.py b/addons/knowledge/controllers/__init__.py new file mode 100644 index 00000000000..5d4b25db9c0 --- /dev/null +++ b/addons/knowledge/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/knowledge/controllers/main.py b/addons/knowledge/controllers/main.py new file mode 100644 index 00000000000..ab50889df9c --- /dev/null +++ b/addons/knowledge/controllers/main.py @@ -0,0 +1,347 @@ +# -*- 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/', 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//', 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.""" + _order = 'sequence' if limit is not None else None + return request.env["knowledge.article"].search([("parent_id", "=", False)], limit=limit, order=_order) + + 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 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, + } + 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': request.env.user._is_public(), # 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': [('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': member['based_on_name'], + '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 {} diff --git a/addons/knowledge/data/ir_actions_data.xml b/addons/knowledge/data/ir_actions_data.xml new file mode 100644 index 00000000000..b6f519df3c0 --- /dev/null +++ b/addons/knowledge/data/ir_actions_data.xml @@ -0,0 +1,11 @@ + + + + + Articles + + code + action = model.action_home_page() + + + diff --git a/addons/knowledge/data/knowledge_data.xml b/addons/knowledge/data/knowledge_data.xml new file mode 100644 index 00000000000..77ba7c18768 --- /dev/null +++ b/addons/knowledge/data/knowledge_data.xml @@ -0,0 +1,535 @@ + + + + + HR FAQ + ❓ + +

+ HR FAQ +
+

+
+
+

+ Car Policy +

+
+ How do I know which model I can order or not? +
+
+

+ 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, + contact our Fleet Officer. +

+
+
+ +
+
+
Car_Catalog.pdf
+
pdf
+
+
+
+
+
+
+ Who can drive my car? +
+

+ + Your car can be driven by + you, your spouse and by any person living under the same roof + as long as they have a valid permit. + +
+

+
+
+ + Expense Policy + +
+
+ Who do I need to address if I need office supplies? +
+
+

+ + Instead of setting up complicated processes, + we prefer to let our employees buy whatever they need. + Just fill in an expense and we will reimburse you. + +

+
+
+ Why has my expense not been reimbursed? It has been accepted. +
+

+ + The Accounting team handles all payments on Fridays afternoon. +
+ If 2 weeks have passed and you are still waiting to be paid, get in touch with them. +
+

+
+
+ + + Leaves & Time Off + + +
+
+ What do I have to do if I cannot work? +
+

+ + Please refer to the chart below. +
+
+
+

+ + + + + + + + + + + + + + + +
+ + + An hour or two (medical appointment, ...) + + +
+
+ + Let your Team Leader know in advance. + +
+
+ + + A day or less + + +
+
+ + Inform HR and your Team Leader. + +
+ + Absent for more than a day + + + + See a doctor and send us the sick note. + +
+

+ +
+

+
+ + write +
+ + Sales & Marketing + 🀩 + +

+ Sales & Marketing +

+
+

We decided to gather all relevant documentation here to make sure all resources were easy to access.

+
+ + + + + + + + + + + +
+ + SALES + + + + MARKETING + +
+ PlaybookArticle +
+
BrandAssetsArticle
+

+
+

+
+ + write +
+ + Sales Playbook + πŸ“˜ + +

Sales Playbook

+
+

+ Compiled below are tips & tricks collected among our veteran sales representatives to help newcomers get started. We hope it will help you sign contracts quickly. πŸ”” +

+
+

Prospection Templates πŸ‘Œ

+
+
+
+
+
+
+

Hey ProspectName,

+

+ I was doing some research online and found your company. +
+ Considering we just launched ProductName, I was thinking you would be interested. +
+
+ Could you please let me know on which number I could reach you so that we could get in touch? +
+ It should not be longer than 15 minutes. +

+

+ Talk to you soon, +
+ YourName +

+
+
+
+

+ + PRO TIP: + + Open this article from a lead to send this by email without needing to copy/paste. +

+
+
+
+
+

Prospect Qualification

+
+

+ 1. Company +

+
    +
  • Background
  • +
  • Size
  • +
  • Estimated Revenues
  • +
  • Pain Points
  • +
+
+

+ 2. Contact +

+
    +
  • Job Position
  • +
  • Email
  • +
  • Phone
  • +
+
+
+
+

+ + PRO TIP: + + Open this article from a lead to use this template without needing to copy/paste. +
+

+
+

The 5 Commandments πŸ™

+
+
    +
  1. + My Forecast will always be accurate and up-to-date +
  2. +
  3. + All my activities will always be encoded in our CRM +
  4. +
  5. + + I + will only sell a project if I am convinced it can be a success + +
  6. +
  7. + I will not steal prospects from colleagues +
  8. +
  9. + I will not waste time and energy bad-mouthing competitors +
  10. +
+
+

Tips to close more deals 🀝

+
+
    +
  • + Be assertive but listen to what is being said +
  • +
  • + Identify the pain points and offer clear solutions +
  • +
  • + Prepare your demos in advance and integrate the prospect's use case into it +
  • +
+
+

Company Abbreviations πŸ“š

+
+ You may have heard those a million times and yet you are not entirely sure of what it means. +
+ Here is quick recap for you to shine during the next meeting. +
+
+ + + + + + + + + + + + + + + + + + + +
+ MRR + + Monthly + Recurring + Revenues (subscriptions, ...) +
+ NRR + + Non-Recurring + Revenues (consultancy services, ...) +
+ USP + + Unique + Selling + Proposition: Advantage that makes you stand out from the competition. +
+ Q1,2,3,4 + + Nth + Quarter of the fiscal year. +
+ E.g. Q4 stars on Oct. 1 and ends on Dec. 31. +
+
+
+ + write +
+ + Brand Assets + 🎨 + +

+ Brand Assets +
+

+
+

+ Please submit a request for assistance to the Marketing Team if you fall into one of the following: +

+
    +
  • + Advertising that would appear online or on TV +
  • +
  • + SEO & SEA projects (betting on keywords, ...) +
  • +
  • + Booklets for a total price > $1000 +
  • +
+

+ For all other categories, we simply require you following the rules listed below. +

+ + + + + + +
+ + Make sure to comply as + + you will represent + our + brand. +
+ If in doubt, get in touch with us. +
+
+
+
+

Logo

+
+

+ Here are logos that you can use at your own convenience. +
+ They can also be shared with customers, journalists and resellers. +

+
+
+ +
+
+
logo.jpg
+
jpg
+
+
+
+
+
+

+ Usage +

+
    +
  • + Do not recreate the Logo from scratch, use these ones +
  • +
  • + Logos should only be used in the colors provided +
  • +
  • + Always include sufficient clear space around the logo +
  • +
+
+

Brand Name Rules

+
+

+ YourCompany is very proud of its brand image. +
+ When representing the brand, we thus ask you to be very cautious in how you refer to the company. +

+ + + + + + + +
+

+ Do +

+
    +
  • + Capitalize the word + YourCompany, except if it's part of an url +
    + e.g.: website/company +
  • +
  • + Always type + + YourCompany + + in the same font size and style as the content of the text +
  • +
  • + Write it without any article (the + YourCompany, + an + YourCompany + , ...) +
  • +
+
+
+

+ Do Not +

+
    +
  • + Pluralize the trademark (e.g. + + YourCompanies) +
  • +
  • + Use the logo instead of the word inside sentences +
  • +
  • + Use the expression "YourCompanions" to refer to our community +
  • +
+
+
+

"About Us" Template

+
+

+ Use this template whenever you have an announcement to make. +

+
+
+
+
+

+ 250 Executive Park Blvd, Suite 3400 94134 +
+ San Francisco California (US) +
+ United States +
+ info@yourcompany.com +

+
+

YourCompany is proud to announce that...

+
+

+ About YourCompany: YourCompany is team of passionate people whose goal is to improve everyone's life through disruptive products. We build great products to solve your business problems. +
+ Our products are designed for small to medium size companies willing to optimize their performance. +

+
+
+
+
+
+
+
+ + write +
+
+
diff --git a/addons/knowledge/data/mail_templates.xml b/addons/knowledge/data/mail_templates.xml new file mode 100644 index 00000000000..04c01d7f530 --- /dev/null +++ b/addons/knowledge/data/mail_templates.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/addons/knowledge/models/__init__.py b/addons/knowledge/models/__init__.py new file mode 100644 index 00000000000..a1793b669d8 --- /dev/null +++ b/addons/knowledge/models/__init__.py @@ -0,0 +1,7 @@ +# -*- 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 diff --git a/addons/knowledge/models/knowledge_article.py b/addons/knowledge/models/knowledge_article.py new file mode 100644 index 00000000000..dab8e0d2664 --- /dev/null +++ b/addons/knowledge/models/knowledge_article.py @@ -0,0 +1,1476 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import ast + +from collections import defaultdict +from markupsafe import Markup +from werkzeug.urls import url_join + +from odoo import Command, fields, models, api, _ +from odoo.exceptions import AccessError, ValidationError +from odoo.osv import expression +from odoo.tools import get_lang + +ARTICLE_PERMISSION_LEVEL = {'none': 0, 'read': 1, 'write': 2} + + +class Article(models.Model): + _name = "knowledge.article" + _description = "Knowledge Article" + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = "favorite_count, create_date desc, id desc" + + active = fields.Boolean(default=True) + name = fields.Char(string="Title", default=lambda self: _('New Article'), required=True, tracking=20) + body = fields.Html(string="Article Body") + icon = fields.Char(string='Article Icon') + cover = fields.Binary(string='Cover Image') + is_locked = fields.Boolean( + string='Locked', + help="When locked, users cannot write on the body or change the title, " + "even if they have write access on the article.") + full_width = fields.Boolean( + string='Full width', + help="When set, the article body will take the full width available on the article page. " + "Otherwise, the body will have large horizontal margins.") + article_url = fields.Char('Article URL', compute='_compute_article_url', readonly=True) + # Hierarchy and sequence + parent_id = fields.Many2one("knowledge.article", string="Parent Article", tracking=30) + child_ids = fields.One2many("knowledge.article", "parent_id", string="Child Articles") + is_desynchronized = fields.Boolean( + string="Desyncronized with parents", + help="If set, this article won't inherit access rules from its parents anymore.") + sequence = fields.Integer( + string="Article Sequence", + default=0, # Set default=0 to avoid false values and messed up sequence order inside same parent + help="The sequence is computed only among the articles that have the same parent.") + root_article_id = fields.Many2one( + 'knowledge.article', string="Subject", recursive=True, + compute="_compute_root_article_id", store=True, compute_sudo=True, tracking=10, + help="The subject is the title of the highest parent in the article hierarchy.") + # Access rules and members + implied category + internal_permission = fields.Selection( + [('write', 'Can write'), ('read', 'Can read'), ('none', 'No access')], + string='Internal Permission', required=False, + help="Default permission for all internal users. " + "(External users can still have access to this article if they are added to its members)") + inherited_permission = fields.Selection( + [('write', 'Can write'), ('read', 'Can read'), ('none', 'No access')], + string='Article Inherited Permission', + compute="_compute_inherited_permission", compute_sudo=True, + store=True, recursive=True) + inherited_permission_parent_id = fields.Many2one( + "knowledge.article", string="Inherited Permission Parent Article", + compute="_compute_inherited_permission", compute_sudo=True, + store=True, recursive=True) + article_member_ids = fields.One2many( + 'knowledge.article.member', 'article_id', string='Members Information', + copy=True) + user_has_access = fields.Boolean( + string='Has Access', + compute="_compute_user_has_access", search="_search_user_has_access") + user_has_write_access = fields.Boolean( + string='Can Write', + compute="_compute_user_has_write_access", search="_search_user_has_write_access") + user_permission = fields.Selection( + [('write', 'write'), ('read', 'read'), ('none', 'none')], + string='User permission', + compute='_compute_user_permission') + # categories and ownership + category = fields.Selection( + [('workspace', 'Workspace'), ('private', 'Private'), ('shared', 'Shared')], + compute="_compute_category", compute_sudo=True, store=True, + help='Used to categozie articles in UI, depending on their main permission definitions.') + # Stored to improve performance when loading the article tree. (avoid looping through members if 'workspace') + # Same as write_uid/_date but limited to the body + last_edition_uid = fields.Many2one( + "res.users", string="Last Edited by", + compute='_compute_last_edition_data', store=True, + readonly=False, copy=False) + last_edition_date = fields.Datetime( + string="Last Edited on", + compute='_compute_last_edition_data', store=True, + readonly=False, copy=False) + # Favorite + is_user_favorite = fields.Boolean( + string="Is Favorited", + compute="_compute_is_user_favorite", + inverse="_inverse_is_user_favorite", + search="_search_is_user_favorite") + user_favorite_sequence = fields.Integer(string="User Favorite Sequence", compute="_compute_is_user_favorite") + favorite_ids = fields.One2many( + 'knowledge.article.favorite', 'article_id', + string='Favorite Articles', copy=False) + # Set default=0 to avoid false values and messed up order + favorite_count = fields.Integer( + string="#Is Favorite", + compute="_compute_favorite_count", store=True, copy=False, default=0) + + _sql_constraints = [ + ('check_permission_on_root', + 'check(parent_id IS NOT NULL OR internal_permission IS NOT NULL)', + 'Root articles must have internal permission.' + ), + ('check_permission_on_desync', + 'check(is_desynchronized IS NOT TRUE OR internal_permission IS NOT NULL)', + 'Desynchronized articles must have internal permission.' + ), + ('check_desync_on_root', + 'check(parent_id IS NOT NULL OR is_desynchronized IS NOT TRUE)', + 'Root articles cannot be desynchronized.' + ), + ] + + # ------------------------------------------------------------ + # CONSTRAINTS + # ------------------------------------------------------------ + + @api.constrains('internal_permission', 'article_member_ids') + def _check_is_writable(self): + """ Articles must always have at least one writer. This constraint is done + on article level, in coordination to the constraint on member model (see + ``_check_is_writable`` on ``knowledge.article.member``). + + If article has no member the internal_permission must be write. If article + has members validation is done in article.member model as we cannot trigger + the constraint depending on fields from related model. + + ΗΈote: computation is done in Py instead of using optimized SQL queries + because value are not yet in DB at this point.""" + for article in self: + def has_write_member(a, child_members=False): + if not child_members: + child_members = self.env['knowledge.article.member'] + article_members = a.article_member_ids + if any(m.permission == 'write' and m.partner_id not in child_members.mapped('partner_id') + for m in article_members): + return True + if a.parent_id and not a.is_desynchronized: + return has_write_member(a.parent_id, article_members | child_members) + return False + if article.inherited_permission != 'write' and not has_write_member(article): + raise ValidationError(_("The article '%s' needs at least one member with 'Write' access.", article.display_name)) + + @api.constrains('parent_id') + def _check_parent_id_recursion(self): + if not self._check_recursion(): + raise ValidationError( + _('Articles %s cannot be updated as this would create a recursive hierarchy.', + ', '.join(self.mapped('name')) + ) + ) + + def name_get(self): + return [(rec.id, "%s %s" % (rec.icon or "πŸ“„", rec.name)) for rec in self] + + # ------------------------------------------------------------ + # COMPUTED FIELDS + # ------------------------------------------------------------ + + def _compute_article_url(self): + for article in self: + if not article.ids: + article.article_url = False + else: + article.article_url = url_join(article.get_base_url(), 'knowledge/article/%s' % article.id) + + @api.depends('parent_id', 'parent_id.root_article_id') + def _compute_root_article_id(self): + wparent = self.filtered('parent_id') + for article in self - wparent: + article.root_article_id = article + + if not wparent: + return + # group by parents to lessen number of computation + articles_byparent = defaultdict(lambda: self.env['knowledge.article']) + for article in wparent: + articles_byparent[article.parent_id] += article + + for parent, articles in articles_byparent.items(): + ancestors = self.env['knowledge.article'] + while parent: + if parent in ancestors: + raise ValidationError( + _('Articles %s cannot be updated as this would create a recursive hierarchy.', + ', '.join(articles.mapped('name')) + ) + ) + ancestors += parent + parent = parent.parent_id + articles.root_article_id = ancestors[-1:] + + @api.depends('parent_id', 'internal_permission') + def _compute_inherited_permission(self): + """ Computed inherited internal permission. We go up ancestors until + finding an article with an internal permission set, or a root article + (without parent) or until finding a desynchronized article which + serves as permission ancestor. Desynchronized articles break the + permission tree finding. """ + self_inherit = self.filtered(lambda article: article.internal_permission) + for article in self_inherit: + article.inherited_permission = article.internal_permission + article.inherited_permission_parent_id = False + + remaining = self - self_inherit + if not remaining: + return + # group by parents to lessen number of computation + articles_byparent = defaultdict(lambda: self.env['knowledge.article']) + for article in remaining: + articles_byparent[article.parent_id] += article + + for parent, articles in articles_byparent.items(): + ancestors = self.env['knowledge.article'] + while parent: + if parent in ancestors: + raise ValidationError( + _('Articles %s cannot be updated as this would create a recursive hierarchy.', + ', '.join(articles.mapped('name')) + ) + ) + ancestors += parent + if parent.internal_permission or parent.is_desynchronized: + break + parent = parent.parent_id + articles.inherited_permission = ancestors[-1:].internal_permission + articles.inherited_permission_parent_id = ancestors[-1:] + + @api.depends_context('uid') + @api.depends('internal_permission', 'article_member_ids.partner_id', 'article_member_ids.permission') + def _compute_user_permission(self): + """ Compute permission for current user. Public users never have any + permission. Shared users have permission based only on members permission + as internal permission never apply to them. Internal users combine both + internal and members permissions, taking the highest one. """ + if self.env.user._is_public(): + self.user_permission = False + return + + # split transient due to direct SQL query to perform + transient = self.filtered(lambda article: not article.ids) + transient.user_permission = 'write' # not created yet, set default permission value + toupdate = self - transient + if not toupdate: + return + + articles_permissions = {} + if not self.env.user.share: + articles_permissions = self._get_internal_permission() + member_permissions = self._get_partner_member_permissions(self.env.user.partner_id) + for article in self: + article_id = article.ids[0] + if self.env.user.share: + article.user_permission = member_permissions.get(article_id, False) + else: + article.user_permission = member_permissions.get(article_id, False) \ + or articles_permissions[article_id] + + @api.depends_context('uid') + @api.depends('user_permission') + def _compute_user_has_access(self): + """ Compute if the current user has read access to the article based on + permissions and memberships. + + Note that admins have all access through ACLs by default but fields are + still using the permission-based computation. """ + for article in self: + article.user_has_access = article.user_permission != 'none' if article.user_permission else False + + def _search_user_has_access(self, operator, value): + """ This search method looks at article and members permissions to return + all the article the current user has access to. + + Heuristic is + - External users only have access to an article if they are r/w member + on that article; + - Internal users have access if: + - they are read or write member on the article + OR + - The article allow read or write access to all internal users AND the user + is not member with 'none' access + """ + if operator not in ('=', '!=') or not isinstance(value, bool): + raise NotImplementedError("Unsupported search operator") + + articles_with_access = {} + if not self.env.user.share: + articles_with_access = self._get_internal_permission(filter_domain=[('internal_permission', '!=', 'none')]) + member_permissions = self._get_partner_member_permissions(self.env.user.partner_id) + articles_with_no_member_access = [article_id for article_id, perm in member_permissions.items() if perm == 'none'] + articles_with_member_access = list(set(member_permissions.keys() - set(articles_with_no_member_access))) + + # If searching articles for which user has access. + if (value and operator == '=') or (not value and operator == '!='): + if self.env.user.share: + return [('id', 'in', articles_with_member_access)] + return ['|', + '&', ('id', 'in', list(articles_with_access.keys())), ('id', 'not in', articles_with_no_member_access), + ('id', 'in', articles_with_member_access)] + + # If searching articles for which user has NO access. + if self.env.user.share: + return [('id', 'not in', articles_with_member_access)] + return ['|', + '&', ('id', 'not in', list(articles_with_access.keys())), ('id', 'not in', articles_with_member_access), + ('id', 'in', articles_with_no_member_access)] + + @api.depends_context('uid') + @api.depends('user_permission') + def _compute_user_has_write_access(self): + """ Compute if the current user has read access to the article based on + permissions and memberships. + + Note that share user can never write and we therefore shorten the computation. + + Note that admins have all access through ACLs by default but fields are + still using the permission-based computation. """ + if self.env.user.share: + self.user_has_write_access = False + return + for article in self: + article.user_has_write_access = article.user_permission == 'write' + + def _search_user_has_write_access(self, operator, value): + if operator not in ('=', '!=') or not isinstance(value, bool): + raise NotImplementedError("Unsupported search operator") + + # share is never allowed to write + if self.env.user.share: + if (value and operator == '=') or (not value and operator == '!='): + return expression.FALSE_DOMAIN + return expression.TRUE_DOMAIN + + articles_with_access = self._get_internal_permission(filter_domain=[('internal_permission', '=', 'write')]) + member_permissions = self._get_partner_member_permissions(self.env.user.partner_id) + articles_with_member_access = [article_id for article_id, perm in member_permissions.items() if perm == 'write'] + articles_with_no_member_access = list(set(member_permissions.keys() - set(articles_with_member_access))) + + # If searching articles for which user has write access. + if (value and operator == '=') or (not value and operator == '!='): + return ['|', + '&', ('id', 'in', list(articles_with_access.keys())), ('id', 'not in', articles_with_no_member_access), + ('id', 'in', articles_with_member_access) + ] + # If searching articles for which user has NO write access. + return ['|', + '&', ('id', 'not in', list(articles_with_access.keys())), ('id', 'not in', articles_with_member_access), + ('id', 'in', articles_with_no_member_access) + ] + + @api.depends('root_article_id.internal_permission', 'root_article_id.article_member_ids.permission') + def _compute_category(self): + # compute workspace articles + workspace_articles = self.filtered(lambda a: a.root_article_id.internal_permission != 'none') + workspace_articles.category = 'workspace' + + remaining_articles = self - workspace_articles + if not remaining_articles: + return + + results = self.env['knowledge.article.member'].read_group([ + ('article_id', 'in', remaining_articles.root_article_id.ids), ('permission', '!=', 'none') + ], ['article_id'], ['article_id']) # each returned member is read on write. + access_member_per_root_article = dict.fromkeys(remaining_articles.root_article_id.ids, 0) + for result in results: + access_member_per_root_article[result['article_id'][0]] += result["article_id_count"] + + for article in remaining_articles: + # should never crash as non workspace articles always have at least one member with access. + if access_member_per_root_article[article.root_article_id.id] > 1: + article.category = 'shared' + else: + article.category = 'private' + + @api.depends('favorite_ids') + def _compute_favorite_count(self): + favorites = self.env['knowledge.article.favorite'].read_group( + [('article_id', 'in', self.ids)], ['article_id'], ['article_id'] + ) + favorites_count_by_article = { + favorite['article_id'][0]: favorite['article_id_count'] for favorite in favorites} + for article in self: + article.favorite_count = favorites_count_by_article.get(article.id, 0) + + @api.depends('body') + def _compute_last_edition_data(self): + """ Each change of body is considered as a content edition update. """ + self.last_edition_uid = self.env.uid + self.last_edition_date = self.env.cr.now() + + @api.depends_context('uid') + @api.depends('favorite_ids.user_id') + def _compute_is_user_favorite(self): + for article in self: + favorite = article.favorite_ids.filtered(lambda f: f.user_id == self.env.user) + article.is_user_favorite = bool(favorite) + article.user_favorite_sequence = favorite.sequence if favorite else -1 + + def _inverse_is_user_favorite(self): + """ Read access is sufficient for toggling its own favorite status. """ + to_fav = self.filtered(lambda article: self.env.user not in article.favorite_ids.user_id) + to_unfav = self - to_fav + + if to_fav: + to_fav.favorite_ids = [(0, 0, {'user_id': self.env.uid})] + if to_unfav: + to_unfav.favorite_ids.filtered(lambda u: u.user_id == self.env.user).sudo().unlink() + + def _search_is_user_favorite(self, operator, value): + if operator not in ('=', '!='): + raise NotImplementedError("Unsupported search operation on favorite articles") + + if (value and operator == '=') or (not value and operator == '!='): + return [('favorite_ids.user_id', 'in', [self.env.uid])] + + # easier than a not in on a 2many field + favorited = self.env['knowledge.article.favorite'].sudo().search( + [('user_id', '=', self.env.uid)] + ).article_id + return [('id', 'not in', favorited.ids)] + + # ------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------ + + @api.model + def search(self, args, offset=0, limit=None, order=None, count=False): + """ Override to support ordering on is_user_favorite. + + Ordering through web client calls search_read with an order parameter set. + Search_read then calls search. In this override we therefore override search + to intercept a search without count with an order on is_user_favorite. + In that case we do the search in two steps. + + First step: fill with current user's favorite results + + * Search articles that are favorite of the current user. + * Results of that search will be at the top of returned results. Use limit + None because we have to search all favorite articles. + * Finally take only a subset of those articles to fill with + results matching asked offset / limit. + + Second step: fill with other results. If first step does not gives results + enough to match offset and limit parameters we fill with a search on other + articles. We keep the asked domain and ordering while filtering out already + scanned articles to keep a coherent results. + + All other search and search_read are left untouched by this override to avoid + side effects. Search_count is not affected by this override. + """ + if count or not order or 'is_user_favorite' not in order: + return super(Article, self).search(args, offset=offset, limit=limit, order=order, count=count) + order_items = [order_item.strip().lower() for order_item in (order or self._order).split(',')] + favorite_asc = any('is_user_favorite asc' in item for item in order_items) + + # Search articles that are favorite of the current user. + my_articles_domain = expression.AND([[('favorite_ids.user_id', 'in', [self.env.uid])], args]) + my_articles_order = ', '.join(item for item in order_items if 'is_user_favorite' not in item) + articles_ids = super(Article, self).search(my_articles_domain, offset=0, limit=None, order=my_articles_order, count=count).ids + + # keep only requested window (offset + limit, or offset+) + my_articles_ids_keep = articles_ids[offset:(offset + limit)] if limit else articles_ids[offset:] + # keep list of already skipped article ids to exclude them from future search + my_articles_ids_skip = articles_ids[:(offset + limit)] if limit else articles_ids + + # do not go further if limit is achieved + if limit and len(my_articles_ids_keep) >= limit: + return self.browse(my_articles_ids_keep) + + # Fill with remaining articles. If a limit is given, simply remove count of + # already fetched. Otherwise keep none. If an offset is set we have to + # reduce it by already fetch results hereabove. Order is updated to exclude + # is_user_favorite when calling super() . + article_limit = (limit - len(my_articles_ids_keep)) if limit else None + if offset: + article_offset = max((offset - len(articles_ids), 0)) + else: + article_offset = 0 + article_order = ', '.join(item for item in order_items if 'is_user_favorite' not in item) + + other_article_res = super(Article, self).search( + expression.AND([[('id', 'not in', my_articles_ids_skip)], args]), + offset=article_offset, limit=article_limit, order=article_order, count=count + ) + if favorite_asc in order_items: + return other_article_res + self.browse(my_articles_ids_keep) + else: + return self.browse(my_articles_ids_keep) + other_article_res + + @api.model_create_multi + def create(self, vals_list): + """ Article permissions being quite strong, some custom behavior support + is necessary in order to let people create articles with a correct + configuration. + + Constraints + * creating an article under a parent requires to be able to write on + it. As anyway errors will raise we prevently raise a more user friendly + error; + * root articles without permission are forced to write to avoid issues + with constraints; + + Notably + * automatically organize articles to be the last of their parent + children, unless a specific sequence is given; + * allow creation of private articles for him- or her-self. As creation + rights on member model are not granted, we detect private article + creation and sudo the creation of those. This requires some data + manipulation to sudo only those and keep requested ordering based + on vals_list; + """ + defaults = self.default_get(['article_member_ids', 'internal_permission', 'parent_id']) + vals_by_parent_id = {} + vals_as_sudo = [] + + for vals in vals_list: + can_sudo = False + # get values from vals or defaults + member_ids = vals.get('article_member_ids') or defaults.get('article_member_ids') or False + internal_permission = vals.get('internal_permission') or defaults.get('internal_permission') or False + parent_id = vals.get('parent_id') or defaults.get('parent_id') or False + + # force write permission for workspace articles + if not parent_id and not internal_permission: + vals.update({'internal_permission': 'write', + 'parent_id': False, # just be sure we don't grant privileges + }) + + # allow private articles creation if given values are considered as safe + check_for_sudo = not self.env.su and \ + not any(fname in vals for fname in ['favorite_ids', 'child_ids']) and \ + not parent_id and internal_permission == 'none' and \ + member_ids and len(member_ids) == 1 + if check_for_sudo: + self_member = member_ids[0][0] == Command.CREATE and \ + member_ids[0][2].get('partner_id') == self.env.user.partner_id.id + if self_member: + can_sudo = True + + # if no sequence, parent will have to be checked + if not vals.get('sequence'): + vals_by_parent_id.setdefault(parent_id, []).append(vals) + vals_as_sudo.append(can_sudo) + + # compute all maximum sequences / parent + max_sequence_by_parent = {} + if vals_by_parent_id: + parent_ids = list(vals_by_parent_id.keys()) + try: + self.check_access_rights('write') + self.env['knowledge.article'].browse(parent_ids).check_access_rule('write') + except AccessError: + raise AccessError(_("You cannot create an article under articles on which you cannot write")) + max_sequence_by_parent = self._get_max_sequence_inside_parents(parent_ids) + + # update sequences + for parent_id, article_vals in vals_by_parent_id.items(): + current_sequence = 0 + if parent_id in max_sequence_by_parent: + current_sequence = max_sequence_by_parent[parent_id] + 1 + + for vals in article_vals: + if 'sequence' in vals: + current_sequence = vals.get('sequence') + else: + vals['sequence'] = current_sequence + current_sequence += 1 + + # sort by sudo / not sudo + notsudo_articles = iter(super(Article, self).create([ + vals for vals, can_sudo in zip(vals_list, vals_as_sudo) + if not can_sudo + ])) + sudo_articles = iter(super(Article, self.sudo()).create([ + vals for vals, can_sudo in zip(vals_list, vals_as_sudo) + if can_sudo + ]).with_env(self.env)) + articles = self.env['knowledge.article'] + for vals, is_sudo in zip(vals_list, vals_as_sudo): + if is_sudo: + articles += next(sudo_articles) + else: + articles += next(notsudo_articles) + + return articles + + def write(self, vals): + # Move under a parent is considered as a write on it (permissions, ...) + _resequence = False + if vals.get('parent_id'): + parent = self.browse(vals['parent_id']) + try: + parent.check_access_rights('write') + parent.check_access_rule('write') + except AccessError: + raise AccessError(_("You cannot move an article under %(parent_name)s as you cannot write on it", + parent_name=parent.display_name)) + if 'sequence' not in vals: + max_sequence = self._get_max_sequence_inside_parents(parent.ids).get(parent.id, -1) + vals['sequence'] = max_sequence + 1 + else: + _resequence = True + + result = super(Article, self).write(vals) + + # resequence only if a sequence was not already computed based on current + # parent maximum to avoid unnecessary recomputation of sequences + if _resequence: + self._resequence() + + return result + + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + defaults = dict( + {"name": _("%s (copy)", self.name)}, + **(default or {}) + ) + if not self.env.su and not self.user_has_write_access: + defaults.pop('article_member_ids', None) + defaults.pop('favorite_ids', None) + defaults.pop('child_ids', None) + if self.user_has_access: + defaults['article_member_ids'] = [ + (0, 0, {'partner_id': self.env.user.partner_id.id, + 'permission': 'write'}) + ] + return super().copy(default=defaults) + + def unlink(self): + for article in self: + # Make all the article's children be adopted by the parent's parent. + # Otherwise, we will have to manage an orphan house. + parent = article.parent_id + if parent: + article.child_ids.write({"parent_id": parent.id}) + return super(Article, self).unlink() + + def action_archive(self): + """ When archiving + + * archive the current article and all its writable descendants; + * unreachable descendants (none, read) are set as free articles without + root; + """ + all_descendants_sudo = self.sudo()._get_descendants() + writable_descendants = all_descendants_sudo.with_env(self.env)._filter_access_rules_python('write') + other_descendants_sudo = all_descendants_sudo - writable_descendants + + # copy rights to allow breaking the hierarchy while keeping access for members + for article_sudo in other_descendants_sudo: + article_sudo._copy_access_from_parents() + + # create new root articles: direct children of archived articles that are not archived + new_roots_woperm = other_descendants_sudo.filtered(lambda article: article.parent_id in self and not article.internal_permission) + new_roots_wperm = other_descendants_sudo.filtered(lambda article: article.parent_id in self and article.internal_permission) + if new_roots_wperm: + new_roots_wperm.write({'parent_id': False}) + for new_root in new_roots_woperm: + new_root.write({ + 'internal_permission': new_root.inherited_permission, + 'parent_id': False, + }) + + return super(Article, self + writable_descendants).action_archive() + + # ------------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------------ + + def action_home_page(self): + res_id = self.env.context.get('res_id', False) + article = self.browse(res_id) if res_id else self._get_first_accessible_article() + mode = 'edit' if article.user_has_write_access else 'readonly' + action = self.env['ir.actions.act_window']._for_xml_id('knowledge.knowledge_article_action_form') + action['res_id'] = article.id + action['context'] = dict( + ast.literal_eval(action.get('context')), + form_view_initial_mode=mode, + ) + return action + + def action_set_lock(self): + self.is_locked = True + + def action_set_unlock(self): + self.is_locked = False + + def action_toggle_favorite(self): + """ Read access is sufficient for toggling its own favorite status. """ + try: + self.check_access_rights('read') + self.check_access_rule('read') + except AccessError: + # Return a meaningful error message as this may be called through UI + raise AccessError(_("You cannot add or remove this article to your favorites")) + + self.sudo()._inverse_is_user_favorite() + return self[0].is_user_favorite if self else False + + def action_article_archive(self): + """ Article specific archive: after archive, redirect to the home page + displaying accessible articles, instead of doing nothing. """ + self.action_archive() + return self.with_context(res_id=False).action_home_page() + + def action_unarchive(self): + super(Article, self).action_unarchive() + return self.with_context(res_id=self.id).action_home_page() + + # ------------------------------------------------------------ + # SEQUENCE / ORDERING + # ------------------------------------------------------------ + + def move_to(self, parent_id=False, before_article_id=False, is_private=False): + """ Move an article in the tree. + + :param int parent_id: id of an article that will be the new parent; + :param int before_article_id: id of an article before which the article + should be moved. Otherwise it is put as last parent children; + :param bool is_private: set as private; + """ + self.ensure_one() + parent = self.browse(parent_id) + before_article = self.browse(before_article_id) + + values = {'parent_id': parent_id} + if before_article: + values['sequence'] = before_article.sequence + if not parent_id: + # be sure to have an internal permission on the article if moved outside + # of an hierarchy + values.update({ + 'internal_permission': 'none' if is_private else 'write', + 'is_desynchronized': False + }) + + # if set as standalone private: remove members, ensure current user is the + # only member -> require sudo to bypass member ACLs + if not parent and is_private: + # explicitly check for rights before going into sudo + try: + self.check_access_rights('write') + (self + parent).check_access_rule('write') + except: + raise AccessError( + _("You are not allowed to move this article under article %(parent_name)s", + parent_name=parent.display_name) + ) + self_sudo = self.sudo() + self_member = self_sudo.article_member_ids.filtered(lambda m: m.partner_id == self.env.user.partner_id) + member_command = [(2, member.id) for member in self_sudo.article_member_ids if member.partner_id != self.env.user.partner_id] + if self_member: + member_command.append((1, self_member.id, {'permission': 'write'})) + else: + member_command.append((0, 0, { + 'partner_id': self.env.user.partner_id.id, + 'permission': 'write' + })) + values['article_member_ids'] = member_command + return self_sudo.write(values) + return self.write(values) + + def _resequence(self): + """ This method reorders the children of the same parent (brotherhood) if + needed. If an article have been moved from one parent to another we do not + need to resequence the children of the old parent as the order remains + unchanged. We only need to resequence the children of the new parent only if + the sequences of the children contains duplicates. + + When reordering an article, we assume that we always set the sequence + equals to the position we want it to be. When duplicates last modified + wins. We use write date, presence in self (indicating a write hence a + priority) and ID to differentiate new ordering between duplicates. + + e.g. if we want article D to be placed at 3rd position between A B et C + * set D.sequence = 2; + * but C was already 2; + * D is in self: it wins. Or D has newer write_date: it wins. Or D has + been created more recently: it wins. + """ + parent_ids = self.mapped("parent_id").ids + if any(not article.parent_id for article in self): + parent_ids.append(False) + + # fetch and sort all_chidren: sequence ASC, then modified, then write date DESC + all_children = self.search([("parent_id", 'in', parent_ids)]) + all_children = all_children.sorted( + lambda article: (-1 * article.sequence, + article in self, + article.write_date, + article.id + ), + reverse=True # due to date + ) + + article_to_update_by_sequence = defaultdict(self.env['knowledge.article'].browse) + for parent_id in parent_ids: + children = all_children.filtered(lambda a: a.parent_id.id == parent_id) + sequences = children.mapped('sequence') + # no need to resequence if no duplicates. + if len(sequences) == len(set(sequences)): + return + + # only need to resequence after duplicate: allow holes in the sequence but limit number of write operations. + duplicate_index = [idx for idx, item in enumerate(sequences) if item in sequences[:idx]][0] + start_sequence = sequences[duplicate_index] + 1 + for i, child in enumerate(children[duplicate_index:]): + article_to_update_by_sequence[i + start_sequence] |= child + + for sequence in article_to_update_by_sequence: + # call super to avoid loops in write + super(Article, article_to_update_by_sequence[sequence]).write({'sequence': sequence}) + + @api.model + def _get_max_sequence_inside_parents(self, parent_ids): + max_sequence_by_parent = {} + rg_results = self.env['knowledge.article'].sudo().read_group( + [('parent_id', 'in', parent_ids)], + ['sequence:max'], + ['parent_id'] + ) + for rg_line in rg_results: + # beware name_get like returns either 0, either (id, 'name') + index = rg_line['parent_id'][0] if rg_line['parent_id'] else False + max_sequence_by_parent[index] = rg_line['sequence'] + return max_sequence_by_parent + + # ------------------------------------------------------------ + # HELPERS + # ------------------------------------------------------------ + + @api.model + @api.returns('knowledge.article', lambda article: article.id) + def article_create(self, title=False, parent_id=False, is_private=False): + """ Helper to create articles, allowing to pre-compute some configuration + values. + + :param str title: name of the article; + :param int parent_id: id of an existing article who will be the parent + of the newly created articled. Must be writable; + :param bool is_private: set current user as sole owner of the new article; + """ + parent = self.browse(parent_id) if parent_id else self.env['knowledge.article'] + values = {'parent_id': parent.id} + if title: + values.update({ + 'body': Markup('

%s

') % title, + 'name': title, + }) + + if parent: + if not is_private and parent.category == "private": + is_private = True + else: + # child do not have to setup an internal permission as it is inherited + values['internal_permission'] = 'none' if is_private else 'write' + + if is_private: + if parent and parent.category != "private": + raise ValidationError( + _("Cannot create an article under article %(parent_name)s which is a non-private parent", + parent_name=parent.display_name) + ) + if not parent: + values['article_member_ids'] = [(0, 0, { + 'partner_id': self.env.user.partner_id.id, + 'permission': 'write' + })] + return self.create(values) + + def get_user_sorted_articles(self, search_query, limit=10): + """ Called when using the Command palette to search for articles matching the search_query. + As the article should be sorted also in function of the current user's favorite sequence, a search_read rpc + won't be enough to returns the articles in the correct order. + This method returns a list of article proposal matching the search_query sorted by: + - is_user_favorite - by Favorite sequence + - Favorite count + and returned result mimic a search_read result structure. + """ + search_domain = ["|", ("name", "ilike", search_query), ("root_article_id.name", "ilike", search_query)] + articles = self.search( + expression.AND([search_domain, [("is_user_favorite", "=", True)]]), + limit=limit + ) + sorted_articles = articles.sorted( + key=lambda a: (-1 * a.user_favorite_sequence, + a.favorite_count, + a.write_date, + a.id), + reverse=True + ) + + # fill with not favorites articles + if len(sorted_articles) < limit: + articles = self.search( + expression.AND([search_domain, [("is_user_favorite", "=", False)]]), + limit=(limit-len(sorted_articles)) + ) + sorted_articles += articles.sorted( + key=lambda a: (a.favorite_count, + a.write_date, + a.id), + reverse=True + ) + + return sorted_articles.read(['id', 'name', 'is_user_favorite', + 'favorite_count', 'root_article_id', 'icon']) + + # ------------------------------------------------------------ + # PERMISSIONS / MEMBERS + # ------------------------------------------------------------ + + def restore_article_access(self): + """ Resets permissions based on ancestors. It removes all members except + members on the articles that are not on any ancestor or that have higher + permission than from ancestors. """ + self.ensure_one() + if not self.parent_id: + return False + if not self.env.su and not self.user_has_write_access: + raise AccessError(_('You cannot restore the article %(article_name)s', + article_name=self.display_name)) + member_permission = (self | self.parent_id)._get_article_member_permissions() + article_members_permission = member_permission[self.id] + parents_members_permission = member_permission[self.parent_id.id] + + members_values = [] + for partner, values in article_members_permission.items(): + permission = values['permission'] + if values["based_on"] or partner not in parents_members_permission \ + or ARTICLE_PERMISSION_LEVEL[permission] > ARTICLE_PERMISSION_LEVEL[parents_members_permission[partner]['permission']]: + continue + members_values.append((3, values['member_id'])) + + return self.sudo().write({ + 'internal_permission': False, + 'article_member_ids': members_values, + 'is_desynchronized': False + }) + + def invite_members(self, partners, permission): + """ Invite the given partners to the current article. Inviting to remove + access is straightforward (just set permission). Inviting with rights + requires to check for privilege escalation in descendants. + + :param Model partner_ids: recordset of invited partners; + :param string permission: permission of newly invited members, one of + 'none', 'read' or 'write'; + """ + self.ensure_one() + if permission == 'none': + self._add_members(partners, permission) + else: + # prevent the invited user to get access to children articles the current user has no access to + unreachable_children = self.sudo().child_ids.filtered(lambda c: not c.user_has_write_access) + for child in unreachable_children: + child._add_members(partners, 'none', force_update=False) + + share_partner_ids = partners.filtered(lambda partner: partner.partner_share) + self._add_members(share_partner_ids, 'read') + self._add_members(partners - share_partner_ids, permission) + + if permission != 'none': + self._send_invite_mail(partners) + + return True + + def _set_internal_permission(self, permission): + """ Set the internal permission of the article. + + Special cases: + * to ensure the user still has write access after modification, + add the user as write member if given permission != write; + * when downgrading internal permission on a child article, desync it + from parent to stop inherited rights transmission; + * if we set same permission as parent and the article has no specific + member: resync if on parent; + + :param str permission: internal permission to set, one of 'none', 'read' + or 'write'; + """ + self.ensure_one() + if self.user_has_write_access and permission != "write": + self._add_members(self.env.user.partner_id, 'write') + + downgrade = not self.is_desynchronized and self.parent_id and \ + ARTICLE_PERMISSION_LEVEL[self.parent_id.inherited_permission] > ARTICLE_PERMISSION_LEVEL[permission] + if downgrade: + return self._desync_access_from_parents(force_internal_permission=permission) + + values = {'internal_permission': permission} + if permission == self.parent_id.inherited_permission and not self.article_member_ids: + values.update({ + 'internal_permission': False, + 'is_desynchronized': False + }) + return self.write(values) + + def _set_member_permission(self, member, permission, is_based_on=False): + """ Sets the given permission to the given member. + + If the member has rights based on membership: simply update it. + + If the member has rights based on a parent article (inherited rights) + If the new permission is downgrading the member's access + the article is desynchronized form its parent; + Else we add a new member with the higher permission; + + :param member: member whose permission + is to be updated. Can be a member of 'self' or one of its ancestors; + :param str permission: new permission, one of 'none', 'read' or 'write'; + :param bool is_based_on: whether rights are inherited or through membership; + """ + self.ensure_one() + if is_based_on: + downgrade = ARTICLE_PERMISSION_LEVEL[member.permission] > ARTICLE_PERMISSION_LEVEL[permission] + if downgrade: + self._desync_access_from_parents(force_partners=member.partner_id, force_member_permission=permission) + else: + self._add_members(member.partner_id, permission) + else: + member.sudo().write({'permission': permission}) + + def _remove_member(self, member): + """ Removes a member from the article. If the member was based on a + parent article, the current article will be desynchronized form its parent. + We also ensure the partner to remove is removed after the desynchronization + if was copied from parent. + + :param member: member to remove + """ + self.ensure_one() + if not member: + raise ValueError(_('Trying to remove wrong member.')) + + # belongs to current article members + current_membership = self.article_member_ids.filtered(lambda m: m == member) + + current_user_partner = self.env.user.partner_id + remove_self = member.partner_id == current_user_partner + # If remove self member, set member permission to 'none' (= leave article) to hide the article for the user. + if remove_self: + if current_membership: + members_command = [(1, current_membership.id, {'permission': 'none'})] + else: + members_command = [(0, 0, {'partner_id': current_user_partner.id, 'permission': 'none'})] + self.sudo().write({'article_member_ids': members_command}) + # member to remove is on the article itself. Simply remove the member. + elif current_membership: + if not self.env.su and not self.user_has_write_access: + raise AccessError( + _("You cannot remove the member %(member_name)s from article %(article_name)s", + member_name=member.display_name, + article_name=self.display_name + )) + self.sudo().write({'article_member_ids': [(2, current_membership.id)]}) + # Inherited rights from parent + else: + self._desync_access_from_parents(force_partners=self.article_member_ids.partner_id) + current_membership = self.article_member_ids.filtered(lambda m: m.partner_id == member.partner_id) + if current_membership: + self.sudo().write({'article_member_ids': [(2, current_membership.id)]}) + + def _add_members(self, partners, permission, force_update=True): + """ Adds new members to the current article with the given permission. + If a given partner is already member permission is updated instead. + + :param partners: recordset of res.partner for which + new members are added; + :param string permission: member permission, one of 'none', 'read' or 'write'; + :param boolean force_update: if already existing, force the new permission; + this can be used to create default members and left existing one untouched; + """ + self.ensure_one() + if not self.env.su and not self.user_has_write_access: + raise AccessError( + _("You cannot give access to the article '%s' as you are not editor.", self.name)) + + members_to_update = self.article_member_ids.filtered_domain([('partner_id', 'in', partners.ids)]) + partners_to_create = partners - members_to_update.mapped('partner_id') + + members_command = [ + (0, 0, {'partner_id': partner.id, + 'permission': permission}) + for partner in partners_to_create + ] + if force_update: + members_command += [ + (1, member.id, {'permission': permission}) + for member in members_to_update + ] + + return self.sudo().write({'article_member_ids': members_command}) + + def _desync_access_from_parents(self, force_internal_permission=False, + force_partners=False, force_member_permission=False): + """ Copies copy all inherited accesses from parents on the article and + de-synchronize the article from its parent, allowing custom access management. + We allow to force permission of given partners. + + :param string force_internal_permission: force a new internal permission + for the article. Otherwise fallback on inherited computed internal + permission; + :param force_partners: force permission of new members + related to those partners; + :param string force_member_permission: used with force_partners to specify + the custom permission to give. One of 'none', 'read', 'write'; + """ + self.ensure_one() + new_internal_permission = force_internal_permission or self.inherited_permission + members_commands = self._copy_access_from_parents( + force_partners=force_partners, + force_member_permission=force_member_permission + ) + + return self.sudo().write({ + 'article_member_ids': members_commands, + 'internal_permission': new_internal_permission, + 'is_desynchronized': True, + }) + + def _copy_access_from_parents(self, force_partners=False, force_member_permission=False): + """ Copies copy all inherited accesses from parents on the article and + de-synchronize the article from its parent, allowing custom access management. + We allow to force permission of given partners. + + :param string force_internal_permission: force a new internal permission + for the article. Otherwise fallback on inherited computed internal + permission; + :param force_partners: force permission of new members + related to those partners; + """ + self.ensure_one() + members_permission = self._get_article_member_permissions()[self.id] + + members_commands = [] + for partner_id, values in members_permission.items(): + # if member already on self, do not add it. + if not values['based_on'] or values['based_on'] == self.id: + continue + if force_partners and force_member_permission and partner_id in force_partners.ids: + new_member_permission = force_member_permission + else: + new_member_permission = values['permission'] + + members_commands.append( + (0, 0, {'partner_id': partner_id, + 'permission': new_member_permission, + })) + return members_commands + + @api.model + def _get_internal_permission(self, filter_domain=None): + """ Compute article based permissions. + + Note: we don't use domain because we cannot include properly the where clause + in the custom sql query. The query's output table and fields names does not match + the model we are working on. + """ + self.env['knowledge.article'].flush() + + args = [] + base_where_domain = '' + if self.ids: + base_where_domain = "WHERE id in %s" + args.append(tuple(self.ids)) + + where_clause = '' + if filter_domain: + query = self.with_context(active_test=False)._where_calc(filter_domain) + table_name, where_clause, where_params = query.get_sql() + where_clause = f'WHERE {where_clause.replace(table_name, "article_perms")}' + args += where_params + + sql = f''' + WITH RECURSIVE article_perms as ( + SELECT id, id as article_id, parent_id, internal_permission, is_desynchronized + FROM knowledge_article + {base_where_domain} + UNION + SELECT parents.id, perms.article_id, parents.parent_id, + COALESCE(perms.internal_permission, parents.internal_permission), + perms.is_desynchronized + FROM knowledge_article parents + INNER JOIN article_perms perms + ON perms.parent_id=parents.id + AND perms.is_desynchronized IS NOT TRUE + AND perms.internal_permission IS NULL + ) + SELECT article_id, max(internal_permission) + FROM article_perms + {where_clause} + GROUP BY article_id''' + self._cr.execute(sql, args) + return dict(self._cr.fetchall()) + + @api.model + def _get_partner_member_permissions(self, partner): + """ Retrieve the permission for the given partner for all articles. + The articles can be filtered using the article_ids param. + + The member model is fully flushed before running the request. """ + self.env['knowledge.article'].flush() + + args = [partner.id] + base_where_domain = '' + if self.ids: + base_where_domain = "WHERE perms1.id in %s" + args.append(tuple(self.ids)) + + sql = f''' + WITH RECURSIVE article_perms as ( + SELECT a.id, a.parent_id, m.permission, a.is_desynchronized + FROM knowledge_article a + LEFT JOIN knowledge_article_member m + ON a.id=m.article_id and partner_id = %s + ), article_rec as ( + SELECT perms1.id, perms1.id as article_id, perms1.parent_id, + perms1.permission, perms1.is_desynchronized + FROM article_perms as perms1 + {base_where_domain} + UNION + SELECT perms2.id, perms_rec.article_id, perms2.parent_id, + COALESCE(perms_rec.permission, perms2.permission), + perms2.is_desynchronized + FROM article_perms as perms2 + INNER JOIN article_rec perms_rec + ON perms_rec.parent_id=perms2.id + AND perms_rec.is_desynchronized IS NOT TRUE + AND perms_rec.permission IS NULL + ) + SELECT article_id, max(permission) + FROM article_rec + WHERE permission IS NOT NULL + GROUP BY article_id''' + self._cr.execute(sql, args) + return dict(self._cr.fetchall()) + + def _get_article_member_permissions(self, additional_fields=False): + """ Retrieve the permission for all the members that apply to the target article. + Members that apply are not only the ones on the article but can also come from parent articles. + + The caller can pass additional fields to retrieve from the following models: + - res.partner, joined on the partner_id of the membership + - knowledge.article, joined on the 'origin' of the membership + (when the membership is based on one of its parent article) + to retrieve more fields on the origin of the membership + - knowledge.article.member to retrieve more fields on the membership + + The expected format is:: + + {'model': [('field', 'field_alias')]} + e.g:: + { + 'res.partner': [ + ('name', 'partner_name'), + ('email', 'partner_email'), + ] + } + + Please note that these additional fields are not sanitized, the caller + has the responsibility to check that user can access those fields and + that no injection is possible. """ + self.env['knowledge.article'].flush() + + add_where_clause = '' + args = [] + if self.ids: + args = [tuple(self.ids)] + add_where_clause += " AND article_id in %s" + + additional_select_fields = '' + join_clause = '' + if additional_fields: + supported_additional_models = [ + 'res.partner', + 'knowledge.article', + 'knowledge.article.member', + ] + + # 1. build the join clause based on the given models (additional_fields keys) + join_clauses = [] + for model in additional_fields.keys(): + if model not in supported_additional_models: + continue + + table_name = self.env[model]._table + join_condition = '' + if model == 'res.partner': + join_condition = f'{table_name}.id = partner_id' + elif model == 'knowledge.article': + join_condition = f'{table_name}.id = origin_id' + elif model == 'knowledge.article.member': + join_condition = f'{table_name}.id = member_id' + + join_clauses.append(f'LEFT OUTER JOIN {table_name} ON {join_condition}') + + join_clause = ' '.join(join_clauses) + + # 2. build the select clause based on the given fields/aliases pairs + # (additional_fields values) + select_fields = [] + for model, fields_list in additional_fields.items(): + if model not in supported_additional_models: + continue + + table_name = self.env[model]._table + for (field, field_alias) in fields_list: + select_fields.append(f'{table_name}.{field} as {field_alias}') + + additional_select_fields = ', %s' % ', '.join(select_fields) + + sql = f''' + WITH article_permission as ( + WITH RECURSIVE article_perms as ( + SELECT a.id, a.parent_id, m.id as member_id, m.partner_id, + m.permission + FROM knowledge_article a + LEFT JOIN knowledge_article_member m + ON a.id = m.article_id + ), article_rec as ( + SELECT perms1.id, perms1.id as article_id, perms1.parent_id, + perms1.member_id, perms1.partner_id, perms1.permission, + perms1.id as origin_id, 0 as level + FROM article_perms as perms1 + UNION + SELECT perms2.id, perms_rec.article_id, perms2.parent_id, + perms2.member_id, perms2.partner_id, perms2.permission, + perms2.id as origin_id, perms_rec.level + 1 + FROM article_perms as perms2 + INNER JOIN article_rec perms_rec + ON perms_rec.parent_id=perms2.id + ) + SELECT article_id, origin_id, member_id, partner_id, + permission, min(level) as min_level + FROM article_rec + WHERE partner_id is not null + {add_where_clause} + GROUP BY article_id, origin_id, member_id, partner_id, permission + ) + SELECT article_id, origin_id, member_id, partner_id, permission, min_level + {additional_select_fields} + FROM article_permission + {join_clause} + ''' + + self._cr.execute(sql, args) + results = self._cr.dictfetchall() + + # Now that we have, for each article, all the members found on themselves and their parents. + # We need to keep only the first partners found (lowest level) for each article + article_members = defaultdict(dict) + min_level_dict = defaultdict(dict) + + _nolevel = -1 + for result in results: + article_id = result['article_id'] + origin_id = result['origin_id'] + partner_id = result['partner_id'] + level = result['min_level'] + min_level = min_level_dict[article_id].get(partner_id, _nolevel) + if min_level == _nolevel or level < min_level: + article_members[article_id][partner_id] = { + 'member_id': result['member_id'], + 'based_on': origin_id if origin_id != article_id else False, + 'permission': result['permission'] + } + min_level_dict[article_id][partner_id] = level + + if additional_fields: + # update our resulting dict based on additional fields + article_members[article_id][partner_id].update({ + field_alias: result[field_alias] if model != 'knowledge.article' or origin_id != article_id else False + for model, fields_list in additional_fields.items() + for (field, field_alias) in fields_list + }) + # add empty member for each article that doesn't have any. + for article in self: + if article.id not in article_members: + article_members[article.id][None] = {'based_on': False, 'member_id': False, 'permission': None} + + if additional_fields: + # update our resulting dict based on additional fields + article_members[article.id][None].update({ + field_alias: False + for model, fields_list in additional_fields.items() + for (field, field_alias) in fields_list + }) + + return article_members + + # ------------------------------------------------------------ + # MAILING + # ------------------------------------------------------------ + + def _send_invite_mail(self, partners): + # TDE NOTE: try to cleanup and batchize + self.ensure_one() + for partner in partners: + subject = _("Invitation to access %s", self.name) + partner_lang = get_lang(self.env, lang_code=partner.lang).code + body = self.env['ir.qweb'].with_context(lang=partner_lang)._render( + 'knowledge.knowledge_article_mail_invite', { + 'record': self, + 'user': self.env.user, + 'recipient': partner, + 'link': self._get_invite_url(partner), + }) + + self.with_context(lang=partner_lang).message_notify( + partner_ids=partner.ids, body=body, subject=subject, + email_layout_xmlid='mail.mail_notification_light' + ) + + # ------------------------------------------------------------ + # TOOLS + # ------------------------------------------------------------ + + def _get_invite_url(self, partner): + self.ensure_one() + member = self.env['knowledge.article.member'].search([('article_id', '=', self.id), ('partner_id', '=', partner.id)]) + return url_join(self.get_base_url(), "/knowledge/article/invite/%s/%s" % (member.id, member._get_invitation_hash())) + + def _get_first_accessible_article(self): + """ Returns the first accessible article for the current user. + If user has favorites, return first favorite article. """ + article = self.env['knowledge.article'] + if not self.env.user._is_public(): + article = self.env['knowledge.article.favorite'].search([ + ('user_id', '=', self.env.uid), ('article_id.active', '=', True) + ], limit=1).article_id + if not article: + # retrieve workspace articles first, then private/shared ones. + article = self.search([ + ('parent_id', '=', False) + ], limit=1, order='sequence, internal_permission desc') + return article + + def get_valid_parent_options(self, term=""): + """ Returns the list of articles that can be set as parent for the + current article (to avoid recursions) """ + return self.search_read( + domain=['&', + ['name', '=ilike', '%%%s%%' % term], + ['id', 'not in', (self._get_descendants() + self).ids] + ], + fields=['id', 'icon', 'name'], + limit=15, + ) + + def _get_descendants(self): + """ Returns the descendants recordset of the current article. """ + return self.env['knowledge.article'].search([('id', 'not in', self.ids), ('parent_id', 'child_of', self.ids)]) + + def _get_readable_ancetors(self): + """ Returns the parents recordset of the current article. Do the computation + as sudo """ + self.ensure_one() + ancestors = self.env['knowledge.article'].sudo() + current = self.sudo().parent_id + while current: + ancestors += current + current = current.parent_id + return ancestors._filter_access_rules_python('read').with_env(self.env) diff --git a/addons/knowledge/models/knowledge_article_favorite.py b/addons/knowledge/models/knowledge_article_favorite.py new file mode 100644 index 00000000000..8748e6f0ed7 --- /dev/null +++ b/addons/knowledge/models/knowledge_article_favorite.py @@ -0,0 +1,52 @@ +# -*- 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) diff --git a/addons/knowledge/models/knowledge_article_member.py b/addons/knowledge/models/knowledge_article_member.py new file mode 100644 index 00000000000..2b9bee70834 --- /dev/null +++ b/addons/knowledge/models/knowledge_article_member.py @@ -0,0 +1,115 @@ +# -*- 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. + """ + 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 + + parents_members_permission = articles_to_check.parent_id._get_article_member_permissions() + 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 + + # we need to add the members on parents to check the validity + parent_write_members = any( + values['permission'] == 'write' for partner_id, values + in parents_members_permission[article.parent_id.id].items() + if article.parent_id and not article.is_desynchronized + and partner_id not in article.article_member_ids.partner_id.ids + ) + + if not parent_write_members: + 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}' + ) diff --git a/addons/knowledge/models/res_partner.py b/addons/knowledge/models/res_partner.py new file mode 100644 index 00000000000..a2142e9c413 --- /dev/null +++ b/addons/knowledge/models/res_partner.py @@ -0,0 +1,16 @@ +# -*- 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() diff --git a/addons/knowledge/security/ir.model.access.csv b/addons/knowledge/security/ir.model.access.csv new file mode 100644 index 00000000000..c3d7cc81eea --- /dev/null +++ b/addons/knowledge/security/ir.model.access.csv @@ -0,0 +1,16 @@ +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 diff --git a/addons/knowledge/security/ir_rule.xml b/addons/knowledge/security/ir_rule.xml new file mode 100644 index 00000000000..7ef8b749a9d --- /dev/null +++ b/addons/knowledge/security/ir_rule.xml @@ -0,0 +1,78 @@ + + + + + Articles: System = CRUD on all articles + + [(1, '=', 1)] + + + + + Articles: users/portal: read based on access + + [('user_has_access', '=', True)] + + + + + + + + Articles: users/portal: write based on flag + + [('user_has_write_access', '=', True)] + + + + + + Article members: users: read article members + + [('article_id.user_has_access', '=', True)] + + + + + + + + Article members: portal: read own article members + + [('partner_id', '=', user.partner_id.id), ('article_id.user_has_access', '=', True)] + + + + + + + + Article members: System CRUD all + + [(1,'=',1)] + + + + + + Article favorite: users/portal: own + readable articles + + [('user_id', '=', user.id), ('article_id.user_has_access', '=', True)] + + + + + Article favorite: System CRUD all + + [(1, '=', 1)] + + + + + + Invite: System invite members + + [('article_id.user_has_write_access', '=', True)] + + + diff --git a/addons/knowledge/static/description/icon.png b/addons/knowledge/static/description/icon.png new file mode 100644 index 00000000000..94d8d546a64 Binary files /dev/null and b/addons/knowledge/static/description/icon.png differ diff --git a/addons/knowledge/static/src/components/emoji_picker/emoji_list.xml b/addons/knowledge/static/src/components/emoji_picker/emoji_list.xml new file mode 100644 index 00000000000..6f316d9b919 --- /dev/null +++ b/addons/knowledge/static/src/components/emoji_picker/emoji_list.xml @@ -0,0 +1,13 @@ + + + + +
+ +
+
+
+ +
diff --git a/addons/knowledge/static/src/components/emoji_picker/emoji_picker.js b/addons/knowledge/static/src/components/emoji_picker/emoji_picker.js new file mode 100644 index 00000000000..bebcac7d906 --- /dev/null +++ b/addons/knowledge/static/src/components/emoji_picker/emoji_picker.js @@ -0,0 +1,53 @@ +/** @odoo-module **/ + +import emojis from '@mail/js/emojis'; +const { Component } = owl; + +class Emoji extends Component { + /** + * @override + */ + setup () { + // Mock the template variables: + this.className = ''; + this.emojiListView = this.props.emojiListView; + } +} + +Emoji.template = 'mail.Emoji'; +Emoji.props = { + emoji: Object, + emojiListView: Object, +}; + +class EmojiPicker extends Component { + /** + * @override + */ + setup () { + // Mock the template variables: + this.emojis = emojis; + this.className = ''; + this.emojiListView = { + /** + * @param {Event} event + */ + onClickEmoji: event => { + this.props.onClickEmoji(event.target.dataset.unicode); + } + }; + } + + /** + * @param {Event} event + */ + onRemoveEmoji (event) { + this.props.onClickEmoji(false); + } +} + +EmojiPicker.template = 'knowledge.EmojiList'; +EmojiPicker.props = ['onClickEmoji']; +EmojiPicker.components = { Emoji }; + +export default EmojiPicker; diff --git a/addons/knowledge/static/src/components/permission_panel/permission_panel.js b/addons/knowledge/static/src/components/permission_panel/permission_panel.js new file mode 100644 index 00000000000..d37ff44f72c --- /dev/null +++ b/addons/knowledge/static/src/components/permission_panel/permission_panel.js @@ -0,0 +1,330 @@ +/** @odoo-module **/ + +import { session } from "@web/session"; +import { Dialog } from "@web/core/dialog/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; + }); + if (index < 0) { + return; + } + 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 Component.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 his 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; diff --git a/addons/knowledge/static/src/components/permission_panel/permission_panel.scss b/addons/knowledge/static/src/components/permission_panel/permission_panel.scss new file mode 100644 index 00000000000..f538a990f6c --- /dev/null +++ b/addons/knowledge/static/src/components/permission_panel/permission_panel.scss @@ -0,0 +1,39 @@ +.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; + } +} diff --git a/addons/knowledge/static/src/components/permission_panel/permission_panel.xml b/addons/knowledge/static/src/components/permission_panel/permission_panel.xml new file mode 100644 index 00000000000..d22d3b3a778 --- /dev/null +++ b/addons/knowledge/static/src/components/permission_panel/permission_panel.xml @@ -0,0 +1,84 @@ + + + + +
+ +
+
+ +
+ Access Restricted. May not be shared with everyone from + + +
+ + +
+
+ +
+
+ + +
+ As an administrator, you can always modify this article. +
+ + + diff --git a/addons/knowledge/static/src/js/knowledge_controller.js b/addons/knowledge/static/src/js/knowledge_controller.js new file mode 100644 index 00000000000..69d8da01f74 --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_controller.js @@ -0,0 +1,412 @@ +/** @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 emojisBlacklist = ['πŸ’©', 'πŸ‘Ž', 'πŸ’”', '😭', '😒', '😐', 'πŸ˜•', '😞', '😒']; +const safeEmojis = emojis.filter(emoji => !emojisBlacklist.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', + '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', + }), + + init: function (parent, model, renderer, params) { + this.renderer = renderer; + this._super.apply(this, arguments); + this.onFieldSavedListeners = new Map(); + }, + + /** + * @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: + + _onAddRandomIcon: function() { + this.trigger_up('field_changed', { + dataPointID: this.handle, + changes: { + 'icon': safeEmojis[Math.floor(Math.random() * safeEmojis.length)].unicode, + } + }); + }, + + _onAddCover: async function() { + if (this.mode === 'readonly') { + await this._setMode('edit'); + } + this.$('.o_input_file').click(); + }, + + /** + * 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); + }, + + /** + * 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) { + const id = await this._getId(); + await this._rename(id, event.currentTarget.value); + }, + + /** + * @param {Event} event + */ + _adjustInputSize: function (event) { + event.target.setAttribute('size', event.target.value.length); + }, + + /** + * @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 {OdooEvent} event + */ + _onCreate: async function (event) { + await this._create(event.data); + }, + + /** + * @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(); + }, + + /** + * @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); + }); + }, + + /** + * @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); + } + } + }, + // API calls: + + /** + * @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 + } + }); + }, + + /** + * @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); + }, + + /** + * @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 to workspace? It will be accessible by everyone in the company."); + 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 {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 - article id + * @returns {Promise} + */ + _toggleFavorite: function (id) { + return this._rpc({ + model: 'knowledge.article', + method: 'action_toggle_favorite', + args: [id] + }); + }, + + /** + * @returns {Array[String]} + */ + _getFieldsToForceSave: function () { + return ['full_width', 'icon', 'cover']; + }, + + /** + * @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; + } + } + }, + + /** + * @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; + }, + + /** + * @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]); + } + }, + + /** + * @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; + }, +}); + +export { + KnowledgeArticleFormController, +}; diff --git a/addons/knowledge/static/src/js/knowledge_frontend.js b/addons/knowledge/static/src/js/knowledge_frontend.js new file mode 100644 index 00000000000..ee7704a190d --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_frontend.js @@ -0,0 +1,89 @@ +/** @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(); + }); + }); + }, +}); diff --git a/addons/knowledge/static/src/js/knowledge_renderers.js b/addons/knowledge/static/src/js/knowledge_renderers.js new file mode 100644 index 00000000000..c1ec7caff39 --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_renderers.js @@ -0,0 +1,337 @@ +/** @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'; + + +const KnowledgeArticleFormRenderer = FormRenderer.extend(KnowledgeTreePanelMixin, { + className: 'o_knowledge_form_view', + events: _.extend({}, FormRenderer.prototype.events, { + 'click .btn-chatter': '_onBtnChatterClick', + '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; + }, + + /** + * 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'); + } + + 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.data('category', data.newCategory); + let $children = $li.find('.o_article'); + $children.each((_, child) => { + $(child).data('category', data.newCategory); + }); + $sortable.sortable('enable'); + }, + onReject: () => { + $sortable.sortable('cancel'); + $sortable.sortable('enable'); + } + }); + }, + }); + + // Allow drag and drop between sections: + + const selectors = [ + 'section[data-section="workspace"] .o_tree', + 'section[data-section="shared"] .o_tree', + 'section[data-section="private"] .o_tree' + ]; + + selectors.forEach(selector => { + // Note: An element can be connected to one selector at most. + this.$(selector).nestedSortable( + 'option', + 'connectWith', + `.o_tree:not(${selector})` + ); + }); + }, + + /** + * 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') + }); + }, + + /** + * 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') + }); + }, + + /** + * 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') + } + }); + }, + + _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); + }, + + /** + * @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; + }, + + /** + * Renders the breadcrumb + */ + _renderBreadcrumb: function () { + const items = this.breadcrumbs.map(payload => { + return QWeb.render('knowledge.knowledge_breadcrumb_item', { payload }); + }); + this.$('.breadcrumb').prepend(items); + }, + + /** + * 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); + }); + }, + + /** + * 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); + }); + }, + + /** + * @param {integer} id - Article id + * @param {String} unicode + */ + _setEmoji: function (id, unicode) { + const emojis = this.$(`.o_article_emoji_dropdown[data-article-id="${id}"] > .o_article_emoji`); + emojis.text(unicode || 'πŸ“„'); + }, + + /** + * @param {Event} event + */ + _onBreadcrumbItemClick: function (event) { + const $target = $(event.target); + this.trigger_up('breadcrumb_clicked', { + controllerID: $target.data('controller-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', {}); + }, + + /** + * 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(); + }, +}); + +export { + KnowledgeArticleFormRenderer, +}; diff --git a/addons/knowledge/static/src/js/knowledge_views.js b/addons/knowledge/static/src/js/knowledge_views.js new file mode 100644 index 00000000000..5880070908e --- /dev/null +++ b/addons/knowledge/static/src/js/knowledge_views.js @@ -0,0 +1,34 @@ +/** @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, +}; diff --git a/addons/knowledge/static/src/js/tools/tree_panel_mixin.js b/addons/knowledge/static/src/js/tools/tree_panel_mixin.js new file mode 100644 index 00000000000..00428fcdfac --- /dev/null +++ b/addons/knowledge/static/src/js/tools/tree_panel_mixin.js @@ -0,0 +1,130 @@ +/** @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($('