From 661f98be3cfe513c5e16042a9b589d79fb516830 Mon Sep 17 00:00:00 2001 From: David Beguin Date: Thu, 9 Dec 2021 15:23:33 +0000 Subject: [PATCH] [ADD] knowledge: manage knowledge Purpose ======= Allow companies to centralize all their knowledge in a specific place they own. Create wiki-like pages to structure the information and make it easily available. Leverage those later on while working in the back-end. Specifications ============== An article is mainly composed of a title and a body. The later is an html field that can contain text, images, link to other articles or records from other models, templates, etc.. Articles will be classified in 4 categories: - *Workspace:* The articles are available for each internal users in read or write access. Only internal users can create, read, modify and delete knowledge articles. - *Private:* The articles are only available for the owner. Only internal users can set an article as private. A private article can be shared to external users in read access only. If the article is shared with internal users, see Shared section. - *Shared:* The articles are not available for any user except the ones that have been added as members on it. For the current user, this section contains all the private article the user shared with someone and all the other's private articles shared with me. - *Favorite:* (Additional category for UX) Articles can be set as favorite and are user specific. Any identified user can set an article in favorite, as long as the user has access to it. Public users cannot add articles to favorites. Favorites can be reordered and this ordering is user specific. An article can have a parent and multiple children. Each parent and each children is also an article. Root articles are the highest parent in the hierarchy and are considered as the *Subject* (aka Topic) of their whole hierarchy. There is no limit on how many levels there are in the hierarchy. In the 3 first categories, the articles are listed starting for the root article. The child articles are listed below their parents only if the user has access to them. It can be that a user has access to an article that is not listed in knowledge main view because that user has no access to the parent article. More information about access below. Categories are based on the root article. If a children is private, because only accessible by 1 user, and its parent is accessible by everyone, the children will be listed in Workspace section. Technical notes The following domains apply to assign root articles in the different categories: - Private: permission 'none', only member = uid - Shared: permission 'none', more than one member with read/write access - Workspace: other In addition, the UX category - Favorite: is_user_favorite = True (can contain articles from any category) Home View (backend) =================== The main knowledge view is a tweaked form view that displays all the accessible root articles and their accessible children in a side panel hierarchic tree. This side panel allow quick access to each articles without having to navigate back to a list or kanban view. This form view mainly displays the title and the body but also integrate custom buttons to manage and search the articles. All those buttons + the side panel are detailed below. This "Home" view is loading displaying the first favorite article of the user. If the user has no favorite, it falls back to the first root article of Workspace section. The default view mode depends on the user access: - If user can read: readonly mode - If user can write: edit mode Search View ----------- The standard list view will be used as an advanced search to find an article that cannot be found easily via the command palette (ctrl+K). All the searching options remains standard (search / filter / group by). Selecting an article in that view will redirect to the home view (displaying the selected article). Side Panel ---------- - *Reorder and hierarchy:* From the side panel, drag and dropping an article can resequence the articles between them and/or change the parent of the moved article. Note on reordering: Each parent have its own sequence for its children. So the sequence is not common for each articles in the database, but specific for the children of a same parent. The resequencing is done among the children of the same parent. If some articles has no parent, there are considered as the root articles and all root articles have their own sequence too. - *Create:* From the hierarchy tree, the user can directly create an article using the '+' signs: - as root of Workspace or Private section - as a child of a target article. - *Fold/Unfold:* To speed up Home page loading: only first level is loaded + the articles that user already unfolded in the past. The app keeps track of unfolded articles per user (using Local Storage). - *Icon:* Each article has an icon associated with it. The user can click on the icon to change it. - *Search an article:* This button opens the command palette to perform a search on articles. More detail on this in the Advanced Feature section (below). Article header and Button Menu ------------------------------ - *Breadcrumb:* Located on the top left of the main view, the breadcrumb can be used to edit the article title. - *Favorite:* Next to the title, a star button can be used to add/remove the article to/from the favorite. - *Create:* Creates a new private root article. - *Share:* Opens the permission panel. This is where the user can configure the access on the article or, if user has no write access, see what are the access rules on the article. More detail on this panel in the Access Rules section (below). - *Chatter:* Toggle the chatter On/Off at the right of the article view. There users can: - Send messages and log notes - Schedule activities - Follow an article The following buttons can found in the kebab menu, in which we can also find who created or edited the article last and when. - *Move To:* Opens a modal to select the parent under which the current article should be moved. The article will be placed as the last children of the selected parent. - *Lock:* When activated, prevents all edition of the article content (body and title) until it is unlocked. (Purpose: avoid accidental edits) - *Duplicate:* Creates a copy of the current article, under the same parent, but does not copy the children articles. The new created article will have the same access rules as the original article. - *Archive:* Will archive the current article and all its children. Archived articles can be retrieved using the Advanced Search (see below for detail) with "Archived" filter. - *Full Width:* By default, the article is centered in the view, to make it easier to read. If needed, the can activate the full width. This will make the article body take full width available on the article view. This option impacts the rendering of article, also in the frontend view. More detail on the Frontend View below. Body ---- The body is composed of an html field that can be edited using the Odoo Editor and all its capabilities. Some new commands have been added to enrich the editor: /template - /file - /article (More details in Advanced Features section) At the top of the article body, the article icon and cover can be added using their respective "Add" buttons. - *Icon:* Can be modified by simply clicking on it. - *Cover:* Can be modified using the "Pen" icon at the top left of the cover, and deleted using the "Bin" icon at the top right. Frontend View ============= The frontend view is basically a simpler version of the backend view where users can only read the article and add them to their favorites (if user is identified). The frontend view is composed of the side panel in the left, the article title and favorite button on the top and the main body view. From the side panel, the user can navigate to each available article, reorganize them favorites and filter the articles using the search bar. From the main body view, the user can consult the content of the article and download eventual attached files from the '/file' sections. Access Rules ============ Basics ------ The access is defined depending on two criteria: - The Internal Permission - The article members and their permission Both have 3 level of permission: - none (No Access) - read - write The internal Permission is the base permission for internal users. By default, all internal users can write on the article, unless the permission have been downgraded to 'Read' or 'No access'. An internal user however can be added as a member of the article with a specific permission that applies only for them. This member permission takes the priority on the internal permission. The internal users will always be redirected to the backend view when they want to consult an article. By default, external users (portal or public users) cannot have access to the articles, unless they have been added as member. They can only be invited with read acces on the articles. External users will always be redirected to the frontend view when they want to consult an article they have access to. An administrator (group_system) will always have write access, even if permissions tell the contrary. However, only the articles they have the permission to see or write on will be listed in the side panel. They can find the other articles in the Advanced Search view. Only the users with write access can edit the article body and title, reorder or change the parents and children, invite members and edit the access rules of the article. - *Constraint:* An article should always have at least one user having write access on it. - *Lock mechanism:* Only users with write access can lock or unlock the article. when an article is locked, the title and body of the article cannot be edited, even by a user with write access on it. Access Inheritance ------------------ By default, a child article inherit the access rules from its parent. That means that access rules can be defined on the root article and those rules will apply for each descendant article (children and their own children, etc.). However, a child article can have specific rules that will apply only on itself (+ its descendant): - *Synchronized:* That can be done by adding a member on that article, or by upgrading any of the permission based on its parent(s). In that case, the article access rules still depend on its parent(s). If an additional member is added on the parent, the child article will still inherit that member. The rules that applies specifically on the child article have the priority on the inherited rules. - *Detached:* When an access rule is downgraded in a child article that initially depends on parent's access rules, the article access rules are detached from the parent's access rules. All the access rules that applied on the parent are copied on the child article and that article is flagged as detached. The children of that article will, for now on, only depend on it, and not on higher parents anymore. An detached article can be resyncronized with its parent. In that case, all the specific access rules that do not apply on its parent will be removed. The child article will still keep its specific upgraded access rules but will, once again, depends on its parent. Share Modal (and access rules application) ------------------------------------------ The share modal (or permission panel) is where the access rules of an article can be configured. From there, a user with write access can: - *Invite new members:* A modal opens and the user can: - add existing partners or type email addresses and create new partners - select the permission that should apply on all selected partners. If there are external users among the selected partners, only the read access can be used. That means that if the user select: - Write permission: the read permission will be given to that partner. - No Access: The partner will not be added the members. An invitation email is sent to the invited partner. If the partner is already registered (has an active user), them can directly access the article with the link provided in the email. They may need to log in first. If a partner is not yet registered (have no user), when using the link provided in the email, them will be asked to register first, and will be afterwards redirected to the article. - *Modify the Internal Permission:* The user can select the new permission to set for all internal users. - Special use cases: - If the user sets internal permission != write, the user will be added as member with write access. The purpose is to let the user continue to edit the article. - When downgrading internal permission on a child article (that depends on its parent), detach it from parent in terms of access rules. - when upgrading internal permission on a child article, just set the permission. The article is not detached from parent, but the article will use its own internal permission to compute the user accesses. - *Modify a member's permission:* The user can select the new permission to set on the target member. The user can also remove a member. - Special use cases: - When downgrading or removing a member based on parent: Detach the article from it's parent - When upgrading a member based on parent: add a new member with the selected permission. Website Published (Access for public users) ------------------------------------------- When the Website App is installed, users have the possibility to publish the articles. When an article is published, everyone can read the article. - *Internal users* can read a published article even if their permissions does not allow them to read it. They can access those published articles like every other articles, still in backend view. - *Portal users* can read a published article even if their permissions does not allow them to read it. They can access those published articles like every other articles, still in frontend view. - *Public users* can read a published article. They can access it by using the article link from the frontend view. Advanced Features ================= Search and Advanced Search -------------------------- - *Command Palette:* Searching is done through the command palette. Just as we have '#' for channels, '/' for menus and '@' for users, '?' has been added for articles. The command palet is accessible by either - Hitting CTRL+K (and then adding ? to search among articles) - Clicking on the fa-book icon of the chatter, which already adds the '?' so that one can start searching straight away. - Clicking on the "Search An Article" button (in the side panel) while reading another article, where the '?' is automatically added too) The search result are ordered as followed: My Favorites > Most Favorites > the rest. The results display the article name + the subject (highest parent name). If no results are found and user have write access, them can create a new (private) article with the search query as title. - *Advanced Search:* If the user is not happy with the search results, them can do an "Advanced Search", which sends me towards a normal Odoo list view where I can search, filter, groupby, ... Editor Commands --------------- Some new editor commands have been added: - */article:* This command is available on every html field using the Odoo Editor. This command allows to add a direct link to the selected knowledge article. - */file:* This command is only available in the Knowledge App. This command allows to add a file (or an image) download button inside the body. The user can choose a file to add from its own file directory or an existing attachment already present in the database. - */template:* This command is only available in the Knowledge App. This command allows the user to create a block where them can write and lay out content destined to be reused by the readers of the article. The lasts will be able to copy and paste it to send it as message or use it as a description on other records. No /command can be added inside a template. Technical notes: /file and /template commands can be activated in other modules by adding knowledge_commands = True on the html field in the view where they should be activated. Technical considerations ======================== Access Rules Computation ------------------------ Articles access rules are based on inheritance from parent articles. Purpose is to avoid making copies of all member rules on each article in the hierarchy. This allows to lower database storage. The counter part is that the computation of user accesses is made more complex. Instead of simply reading the article internal permission and members permissions on the article, access is computed based on each parent above the article. To speed up batch compute, user permissions are computed directly with recursive SQL queries. They collect all the needed information for multiple users (based on their partner) and multiple articles. Ordering on is_user_favourite ----------------------------- To allow ordering on is_user_favourite computed field, the search method have been overridden to add the favorite articles of the current user to the search result. This is used in the advanced search view but will also be used in the ctrl+K command. Sequences and resequencing -------------------------- Sequences in knowledge article does not respect the conventional sequencing (one sequence for all the records of the model). Instead, the sequence is specific for all children of same parents. Resequence had to be overridden to take that into account and only resequence the children of same parent, and not the entire table. Moving an article in the middle of a 'folder' should not impact the other folders but only the children of that target folder. Breadcrumb ---------- On the form view, the breadcrumb will be located in the control panel. For the knowledge module, we needed a headless layout. In the mockup, the breadcrumb has to be placed within the form sheet. Unfortunately, the breadcumb is a owl component and its logic is encapsulated in a function which makes it not editable. To avoid having to update the core, we will render a custom breadcumb that will be generated based on the `breadcrumb` parameter passed to the `init` function of the form view. Task-2674460 Part-of: odoo/odoo#82315 Co-authored-by: Damien Abeloos Co-authored-by: Julien Banken Co-authored-by: David Beguin Co-authored-by: Fabio Barbero Co-authored-by: Thibault Delavallee Co-authored-by: Aurelien Warnong --- addons/knowledge/__init__.py | 6 + addons/knowledge/__manifest__.py | 73 + addons/knowledge/controllers/__init__.py | 4 + addons/knowledge/controllers/main.py | 347 ++++ addons/knowledge/data/ir_actions_data.xml | 11 + addons/knowledge/data/knowledge_data.xml | 535 ++++++ addons/knowledge/data/mail_templates.xml | 27 + addons/knowledge/models/__init__.py | 7 + addons/knowledge/models/knowledge_article.py | 1476 +++++++++++++++++ .../models/knowledge_article_favorite.py | 52 + .../models/knowledge_article_member.py | 115 ++ addons/knowledge/models/res_partner.py | 16 + addons/knowledge/security/ir.model.access.csv | 16 + addons/knowledge/security/ir_rule.xml | 78 + addons/knowledge/static/description/icon.png | Bin 0 -> 27374 bytes .../components/emoji_picker/emoji_list.xml | 13 + .../components/emoji_picker/emoji_picker.js | 53 + .../permission_panel/permission_panel.js | 330 ++++ .../permission_panel/permission_panel.scss | 39 + .../permission_panel/permission_panel.xml | 84 + .../static/src/js/knowledge_controller.js | 412 +++++ .../static/src/js/knowledge_frontend.js | 89 + .../static/src/js/knowledge_renderers.js | 337 ++++ .../static/src/js/knowledge_views.js | 34 + .../static/src/js/tools/tree_panel_mixin.js | 130 ++ .../src/js/widgets/knowledge_dialogs.js | 187 +++ .../src/js/widgets/knowledge_emoji_picker.js | 59 + .../js/widgets/knowledge_permission_panel.js | 40 + .../static/src/models/activity/chatter.js | 12 + .../static/src/scss/knowledge_blocks.scss | 42 + .../static/src/scss/knowledge_editor.scss | 116 ++ .../static/src/scss/knowledge_frontend.scss | 110 ++ .../static/src/scss/knowledge_views.scss | 273 +++ .../webclient/commands/command_category.js | 9 + .../webclient/commands/knowledge_providers.js | 128 ++ .../static/src/xml/chatter_topbar.xml | 10 + .../src/xml/knowledge_command_palette.xml | 47 + .../static/src/xml/knowledge_editor.xml | 35 + .../static/src/xml/knowledge_templates.xml | 23 + .../tests/tours/knowledge_main_flow_tour.js | 185 +++ addons/knowledge/tests/__init__.py | 11 + addons/knowledge/tests/common.py | 398 +++++ .../tests/test_knowledge_article_business.py | 687 ++++++++ .../test_knowledge_article_constraints.py | 365 ++++ .../test_knowledge_article_permissions.py | 441 +++++ .../tests/test_knowledge_article_sequence.py | 158 ++ .../knowledge/tests/test_knowledge_form_ui.py | 67 + .../tests/test_knowledge_performance.py | 95 ++ .../tests/test_knowledge_security.py | 204 +++ addons/knowledge/tests/test_res_users.py | 53 + .../knowledge_article_favorite_views.xml | 54 + .../views/knowledge_article_member_views.xml | 57 + .../views/knowledge_article_views.xml | 341 ++++ addons/knowledge/views/knowledge_menus.xml | 43 + .../knowledge/views/knowledge_templates.xml | 48 + .../views/knowledge_templates_common.xml | 75 + .../views/knowledge_templates_frontend.xml | 96 ++ addons/knowledge/wizard/__init__.py | 4 + addons/knowledge/wizard/knowledge_invite.py | 26 + .../wizard/knowledge_invite_views.xml | 39 + .../chatter_topbar/chatter_topbar.xml | 2 +- .../components/message_list/message_list.js | 7 +- .../src/core/commands/command_palette.js | 9 +- .../src/core/commands/command_palette.scss | 32 +- .../src/legacy/js/fields/basic_fields.js | 31 + .../src/legacy/js/fields/field_registry.js | 1 + addons/web/static/src/legacy/utils.js | 4 + addons/web/static/src/legacy/xml/base.xml | 3 + .../src/js/wysiwyg/widgets/link_dialog.js | 12 +- .../static/src/js/wysiwyg/widgets/media.js | 8 +- .../src/js/wysiwyg/widgets/media_dialog.js | 44 +- 71 files changed, 8962 insertions(+), 13 deletions(-) create mode 100644 addons/knowledge/__init__.py create mode 100644 addons/knowledge/__manifest__.py create mode 100644 addons/knowledge/controllers/__init__.py create mode 100644 addons/knowledge/controllers/main.py create mode 100644 addons/knowledge/data/ir_actions_data.xml create mode 100644 addons/knowledge/data/knowledge_data.xml create mode 100644 addons/knowledge/data/mail_templates.xml create mode 100644 addons/knowledge/models/__init__.py create mode 100644 addons/knowledge/models/knowledge_article.py create mode 100644 addons/knowledge/models/knowledge_article_favorite.py create mode 100644 addons/knowledge/models/knowledge_article_member.py create mode 100644 addons/knowledge/models/res_partner.py create mode 100644 addons/knowledge/security/ir.model.access.csv create mode 100644 addons/knowledge/security/ir_rule.xml create mode 100644 addons/knowledge/static/description/icon.png create mode 100644 addons/knowledge/static/src/components/emoji_picker/emoji_list.xml create mode 100644 addons/knowledge/static/src/components/emoji_picker/emoji_picker.js create mode 100644 addons/knowledge/static/src/components/permission_panel/permission_panel.js create mode 100644 addons/knowledge/static/src/components/permission_panel/permission_panel.scss create mode 100644 addons/knowledge/static/src/components/permission_panel/permission_panel.xml create mode 100644 addons/knowledge/static/src/js/knowledge_controller.js create mode 100644 addons/knowledge/static/src/js/knowledge_frontend.js create mode 100644 addons/knowledge/static/src/js/knowledge_renderers.js create mode 100644 addons/knowledge/static/src/js/knowledge_views.js create mode 100644 addons/knowledge/static/src/js/tools/tree_panel_mixin.js create mode 100644 addons/knowledge/static/src/js/widgets/knowledge_dialogs.js create mode 100644 addons/knowledge/static/src/js/widgets/knowledge_emoji_picker.js create mode 100644 addons/knowledge/static/src/js/widgets/knowledge_permission_panel.js create mode 100644 addons/knowledge/static/src/models/activity/chatter.js create mode 100644 addons/knowledge/static/src/scss/knowledge_blocks.scss create mode 100644 addons/knowledge/static/src/scss/knowledge_editor.scss create mode 100644 addons/knowledge/static/src/scss/knowledge_frontend.scss create mode 100644 addons/knowledge/static/src/scss/knowledge_views.scss create mode 100644 addons/knowledge/static/src/webclient/commands/command_category.js create mode 100644 addons/knowledge/static/src/webclient/commands/knowledge_providers.js create mode 100644 addons/knowledge/static/src/xml/chatter_topbar.xml create mode 100644 addons/knowledge/static/src/xml/knowledge_command_palette.xml create mode 100644 addons/knowledge/static/src/xml/knowledge_editor.xml create mode 100644 addons/knowledge/static/src/xml/knowledge_templates.xml create mode 100644 addons/knowledge/static/tests/tours/knowledge_main_flow_tour.js create mode 100644 addons/knowledge/tests/__init__.py create mode 100644 addons/knowledge/tests/common.py create mode 100644 addons/knowledge/tests/test_knowledge_article_business.py create mode 100644 addons/knowledge/tests/test_knowledge_article_constraints.py create mode 100644 addons/knowledge/tests/test_knowledge_article_permissions.py create mode 100644 addons/knowledge/tests/test_knowledge_article_sequence.py create mode 100644 addons/knowledge/tests/test_knowledge_form_ui.py create mode 100644 addons/knowledge/tests/test_knowledge_performance.py create mode 100644 addons/knowledge/tests/test_knowledge_security.py create mode 100644 addons/knowledge/tests/test_res_users.py create mode 100644 addons/knowledge/views/knowledge_article_favorite_views.xml create mode 100644 addons/knowledge/views/knowledge_article_member_views.xml create mode 100644 addons/knowledge/views/knowledge_article_views.xml create mode 100644 addons/knowledge/views/knowledge_menus.xml create mode 100644 addons/knowledge/views/knowledge_templates.xml create mode 100644 addons/knowledge/views/knowledge_templates_common.xml create mode 100644 addons/knowledge/views/knowledge_templates_frontend.xml create mode 100644 addons/knowledge/wizard/__init__.py create mode 100644 addons/knowledge/wizard/knowledge_invite.py create mode 100644 addons/knowledge/wizard/knowledge_invite_views.xml 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 0000000000000000000000000000000000000000..94d8d546a64a2db3ee27b16ea5dcafc903f5f467 GIT binary patch literal 27374 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 A+5i9m literal 0 HcmV?d00001 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($('