# -*- 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('