import base64 import contextlib import io import logging import re import string import uuid from ast import literal_eval from collections import OrderedDict, defaultdict import requests from dateutil.relativedelta import relativedelta from urllib.parse import quote from werkzeug.urls import url_encode import odoo from odoo import _, api, Command, fields, models, modules, SUPERUSER_ID from odoo.exceptions import AccessError, MissingError, UserError, ValidationError from odoo.fields import Domain from odoo.tools import groupby, SQL from odoo.tools.image import image_process from odoo.tools.mimetypes import get_extension from odoo.tools.misc import clean_context, consteq from odoo.tools.pdf import PdfFileReader from odoo.addons.mail.tools import link_preview _logger = logging.getLogger(__name__) def _sanitize_file_extension(extension): """ Remove leading and trailing spacing + Remove leading "." """ return re.sub(r'^[\s.]+|\s+$', '', extension) class DocumentsDocument(models.Model): _name = 'documents.document' _description = 'Document' _inherit = ['mail.thread.cc', 'mail.activity.mixin', 'mail.alias.mixin.optional'] _mail_post_access = 'read' _order = 'sequence, id desc' _parent_name = 'folder_id' _parent_store = True _systray_view = 'activity' # Attachment attachment_id = fields.Many2one('ir.attachment', ondelete='cascade', bypass_search_access=True, copy=False) attachment_name = fields.Char('Attachment Name', related='attachment_id.name', readonly=False) attachment_type = fields.Selection(string='Attachment Type', related='attachment_id.type', readonly=False) is_editable_attachment = fields.Boolean(default=False, help='True if we can edit the link attachment.') is_multipage = fields.Boolean( 'Is considered multipage', compute='_compute_is_multipage', store=True, readonly=False) datas = fields.Binary(related='attachment_id.datas', related_sudo=True, readonly=False, prefetch=False) raw = fields.Binary(related='attachment_id.raw', related_sudo=True, readonly=False, prefetch=False) file_extension = fields.Char('File Extension', copy=True, store=True, readonly=False, compute='_compute_file_extension', inverse='_inverse_file_extension') file_size = fields.Integer(compute='_compute_file_size', store=True) checksum = fields.Char(related='attachment_id.checksum') mimetype = fields.Char(related='attachment_id.mimetype') res_model = fields.Char('Resource Model', compute="_compute_res_record", recursive=True, inverse="_inverse_res_record", store=True) res_id = fields.Many2oneReference('Resource ID', compute="_compute_res_record", recursive=True, inverse="_inverse_res_record", store=True, model_field="res_model") res_name = fields.Char('Resource Name', compute="_compute_res_name", compute_sudo=True) index_content = fields.Text(related='attachment_id.index_content') description = fields.Text('Attachment Description', related='attachment_id.description', readonly=False) # Versioning previous_attachment_ids = fields.Many2many('ir.attachment', string="History", bypass_search_access=True) # Document name = fields.Char('Name', copy=True, store=True, compute='_compute_name_and_preview', readonly=False, translate=True, tracking=True) active = fields.Boolean(default=True, string="Active") thumbnail = fields.Binary( readonly=False, store=True, attachment=True, compute='_compute_thumbnail', recursive=True) thumbnail_status = fields.Selection([ ('present', 'Present'), # Document has a thumbnail ('error', 'Error'), # Error when generating the thumbnail ('client_generated', 'Client Generated'), # The PDF thumbnail is generated by the user browser ('restricted', 'Inaccessible'), # Shortcut to no-permission source ], compute="_compute_thumbnail", store=True, readonly=False, recursive=True, ) url = fields.Char('Link URL', index=True, size=1024, tracking=True) url_preview_image = fields.Char( 'URL Preview Image', store=True, compute='_compute_name_and_preview', readonly=False) res_model_name = fields.Char(compute='_compute_res_model_name') type = fields.Selection([('url', 'URL'), ('binary', 'File'), ('folder', 'Folder')], default='binary', string='Type', required=True, readonly=True, index=True) shortcut_document_id = fields.Many2one('documents.document', 'Source Document', ondelete='cascade', index='btree_not_null') shortcut_document_owner_id = fields.Many2one( 'res.users', 'Source Document Owner', related="shortcut_document_id.owner_id", store=True) shortcut_ids = fields.One2many('documents.document', 'shortcut_document_id') favorited_ids = fields.Many2many('res.users', string="Favorite of") is_favorited = fields.Boolean(compute='_compute_is_favorited', inverse='_inverse_is_favorited') tag_ids = fields.Many2many('documents.tag', 'document_tag_rel', string="Tags") partner_id = fields.Many2one('res.partner', string="Contact", tracking=True, index='btree_not_null') owner_id = fields.Many2one( 'res.users', tracking=True, index=True, string="Owner", copy=False, default=lambda self: self.env.user.id if self.env.user.active else False) lock_uid = fields.Many2one('res.users', string="Locked by", tracking=True) request_activity_id = fields.Many2one('mail.activity') requestee_partner_id = fields.Many2one('res.partner') sequence = fields.Integer('Sequence', default=10) # Access document_token = fields.Char( required=True, copy=False, index='btree', default=lambda __: base64.urlsafe_b64encode(uuid.uuid4().bytes).decode().removesuffix('==')) access_token = fields.Char(compute='_compute_access_token') access_url = fields.Char(string='Access url', compute='_compute_access_url') is_access_via_link_hidden = fields.Boolean( 'Link Access Hidden', index=True, help='If "True", only people given direct access to this document will be able to view it. ' 'If "False", access with the link also given to all who can access the parent folder.' ) access_via_link = fields.Selection( [('view', 'Viewer'), ('edit', 'Editor'), ('none', 'None')], string='Link Access Rights', default='none', required=True, index=True) access_internal = fields.Selection( [('view', 'Viewer'), ('edit', 'Editor'), ('none', 'None')], string='Internal Users Rights', default='none', required=True, index=True) access_ids = fields.One2many('documents.access', 'document_id', string="Allowed Access") user_permission = fields.Selection( [('edit', 'Editor'), ('view', 'Viewer'), ('none', 'None')], string='User permission', compute='_compute_user_permission', search='_search_user_permission', compute_sudo=True) user_can_move = fields.Boolean(string='Can move it', compute='_compute_user_can_move') # Folder = parent document parent_path = fields.Char(index=True) # see '_parent_store' implementation in the ORM for details folder_id = fields.Many2one('documents.document', string='Folder', ondelete='set null', tracking=True, domain="[('type', '=', 'folder'), ('shortcut_document_id', '=', False)]", required=False, index=True, search='_search_folder_id') user_folder_id = fields.Char(string='Parent', compute='_compute_user_folder_id', search='_search_user_folder_id') children_ids = fields.One2many('documents.document', 'folder_id') deletion_delay = fields.Integer("Deletion delay", compute="_compute_deletion_delay", help="Delay after permanent deletion of the document in the trash (days)") company_id = fields.Many2one('res.company', string='Company', store=True, readonly=False, index=True) # Activity create_activity_option = fields.Boolean(string='Create a new activity', compute='_compute_create_activity_option', store=True, readonly=False) create_activity_type_id = fields.Many2one('mail.activity.type', string="Activity type") create_activity_summary = fields.Char('Summary') create_activity_date_deadline_range = fields.Integer(string='Due Date In') create_activity_date_deadline_range_type = fields.Selection([ ('days', 'Days'), ('weeks', 'Weeks'), ('months', 'Months'), ], string='Due type', default='days') create_activity_note = fields.Html(string="Note") create_activity_user_id = fields.Many2one('res.users', string='Responsible') # Actions that we can do on the document available_embedded_actions_ids = fields.Many2many( 'ir.embedded.actions', 'Available Actions', compute='_compute_available_embedded_actions_ids', groups='base.group_user') # Alias alias_tag_ids = fields.Many2many('documents.tag', 'document_alias_tag_rel', string="Alias Tags") mail_alias_domain_count = fields.Integer("Mail Alias Domain Count", compute='_compute_mail_alias_domain_count') # UI fields last_access_date_group = fields.Selection(selection=[ ('0_older', 'Older'), ('1_month', 'This Month'), ('2_week', 'This Week'), ('3_day', 'Today'), ], string="Last Accessed On", compute='_compute_last_access_date_group', search='_search_last_access_date_group') _attachment_unique = models.Constraint( 'unique (attachment_id)', "This attachment is already a document", ) _document_token_unique = models.Constraint( 'unique (document_token)', "Access tokens already used.", ) _folder_id_not_id = models.Constraint( 'check(folder_id <> id)', "A folder cannot be included in itself", ) _shortcut_document_id_not_id = models.Constraint( 'check(shortcut_document_id <> id)', "A shortcut cannot point to itself", ) _res_model_res_id_idx = models.Index("(res_model, res_id)") @api.depends('document_token') def _compute_access_token(self): for document in self: document.access_token = f"{document.document_token}o{document.id or 0:x}" @api.depends('access_token') def _compute_access_url(self): for document in self: document.access_url = f'{document.sudo().get_base_url()}/odoo/documents/{quote(document.access_token, safe="")}' @api.depends('create_activity_type_id', 'create_activity_user_id') def _compute_create_activity_option(self): to_activate = self.filtered(lambda d: d.create_activity_type_id and d.create_activity_user_id) to_activate.create_activity_option = True (self - to_activate).create_activity_option = False @api.depends("folder_id", "company_id") @api.depends_context("uid", "allowed_company_ids", "documents_show_parent_name") def _compute_display_name(self): accessible_records = self._filtered_access('read') not_accessible_records = self - accessible_records not_accessible_records.display_name = _("Restricted") folders = accessible_records.filtered(lambda d: d.type == 'folder') for record in folders: if record.user_permission != 'none': record.display_name = ( record.name if not self.env.context.get('documents_show_parent_name') or not record.folder_id else _("%(record)s (in %(parent)s)", record=record.name, parent=record.folder_id.name) ) else: record.display_name = _("Restricted Folder") for record in accessible_records - folders: record.display_name = record.name @api.depends('name', 'type', 'shortcut_document_id.name') def _compute_file_extension(self): for record in self: if record.type != 'binary': record.file_extension = False elif record.shortcut_document_id.name: file_extension = _sanitize_file_extension(get_extension(record.shortcut_document_id.name.strip())) record.file_extension = file_extension or False elif record.name: record.file_extension = _sanitize_file_extension(get_extension(record.name.strip())) or False @api.depends('attachment_id', 'shortcut_document_id.attachment_id') def _compute_file_size(self): shortcuts = self.filtered('shortcut_document_id') for document in self - shortcuts: document.file_size = document.attachment_id.file_size for document in shortcuts: document.file_size = document.shortcut_document_id.file_size def _inverse_file_extension(self): for record in self: file_extension = _sanitize_file_extension(record.file_extension) if record.file_extension else False (record | record.shortcut_ids).file_extension = file_extension @api.constrains('document_token') def _check_document_token(self): charset = set(string.ascii_letters + string.digits + '-_') for document in self: if len(document.document_token or '') != 22 or set(document.document_token) - charset: raise ValidationError(_('Invalid document token')) @api.constrains('shortcut_document_id', 'shortcut_ids', 'type', 'folder_id', 'children_ids', 'company_id') def _check_shortcut_fields(self): errors = [] wrong_types, wrong_companies = self.browse(), self.browse() # Access rights can allow for a document to be edited without having access to its parent folder wrong_parents_sudo = self.folder_id.sudo().filtered('shortcut_document_id') for target in self.filtered('shortcut_ids'): for shortcut in target.shortcut_ids: if shortcut.type != target.type: wrong_types |= shortcut for shortcut in self.filtered('shortcut_document_id'): if shortcut.type != shortcut.shortcut_document_id.type: wrong_types |= shortcut if shortcut.children_ids: wrong_parents_sudo |= shortcut if (shortcut.shortcut_document_id.company_id and shortcut.shortcut_document_id.company_id != shortcut.company_id): wrong_companies |= shortcut if wrong_types: message = _("The following documents/shortcuts have a type mismatch: \n") documents_list = "\n- ".join(wrong_types.mapped('name')) errors.append(f'{message}\n- {documents_list}') if wrong_parents_sudo: message = _("The following shortcuts cannot be set as documents parents: \n") shortcuts_list = "\n- ".join(wrong_parents_sudo.mapped('name')) errors.append(f'{message}\n- {shortcuts_list}') if wrong_companies: message = _("The following documents/shortcuts have a company mismatch: \n") shortcuts_list = "\n- ".join(wrong_companies.mapped('name')) errors.append(f'{message}\n- {shortcuts_list}') if errors: raise ValidationError('\n\n'.join(errors)) @api.constrains('owner_id', 'folder_id') def _check_root_documents_owner_id(self): root_documents = self.filtered(lambda d: not d.folder_id) unauthorized_owners_sudo = root_documents._get_unauthorized_root_document_owners_sudo() if unauthorized_owners_sudo: users_documents_list = [ (document.owner_id.name, document.name) for document in root_documents if document.owner_id in unauthorized_owners_sudo ] raise ValidationError( _("The following user(s) cannot own root documents/folders: \n- %(lines)s", lines="\n-".join(f'{user_name}: {doc_name}' for user_name, doc_name in users_documents_list)) ) def _get_unauthorized_root_document_owners_sudo(self): """ Return sudo'ed documents records as only used by system process.""" return self.mapped('owner_id').sudo().filtered('share') @api.constrains('type', 'alias_name') def _check_alias(self): wrong_records = self.filtered(lambda d: (d.type != 'folder' or d.shortcut_document_id) and d.alias_name) if wrong_records: raise ValidationError(_( "The following documents can't have alias: \n- %(records)s", records="\n-".join(wrong_records.mapped('name')))) @api.constrains('res_model') def _check_res_model(self): if self.filtered(lambda d: d.res_model == 'documents.document'): raise ValidationError(_('A document can not be linked to itself or another document.')) def _is_company_root_folder(self): self.ensure_one() return self.type == 'folder' and not self.folder_id and not self.owner_id @api.depends_context('uid') @api.depends('folder_id', 'folder_id.user_permission', 'owner_id', 'active') def _compute_user_folder_id(self): SHARED = 'SHARED' if not self.env.user.share else False self.user_folder_id = False # Inaccessible active_documents = self.filtered('active') (self - active_documents).user_folder_id = "TRASH" for document in active_documents.filtered(lambda d: d.user_permission != 'none'): if document.folder_id: if document.folder_id.user_permission != 'none': document.user_folder_id = str(document.folder_id.id) else: document.user_folder_id = SHARED elif self.env.user.share: document.user_folder_id = False elif not document.owner_id: document.user_folder_id = 'COMPANY' elif document.owner_id == self.env.user: document.user_folder_id = 'MY' # Root of user's drive else: document.user_folder_id = SHARED # Root of another user's drive def _search_user_folder_id(self, operator, operand): """Search domain for user_folder_id virtual folder_id. Note that searching in "RECENT" is allowed for practicality w.r.t. webclient even though no record will have "RECENT" as computed `user_folder_id` """ if operator not in ('in', 'child_of'): return NotImplemented values = {operand} if isinstance(operand, str) else set(operand) if 'TRASH' in values: # Would need `active_test=False` in context raise UserError(_("Searching on TRASH is not supported.")) domain_parts = [] folder_ids = [] for value in values: if isinstance(value, int): value = str(value) elif not isinstance(value, str): raise UserError(_("Invalid search operand.")) if not value and self.env.user.share: domain_parts.append(Domain("folder_id", "=", False) | Domain('folder_id', 'not any', [])) elif not value: domain_parts.append(Domain.FALSE) elif value == "COMPANY": domain_parts.append(Domain('folder_id', '=', False) & Domain('owner_id', '=', False)) elif value == "MY": domain_parts.append(Domain('folder_id', '=', False) & Domain('owner_id', '=', self.env.user.id)) elif value == "RECENT": domain_parts.append(Domain( 'access_ids', 'any', Domain('partner_id', '=', self.env.user.partner_id.id) & Domain('last_access_date', '!=', False))) elif value == "SHARED": # Find records without permission on folder_id as directly searching on user_permission = 'none' is not allowed. domain_parts.append( Domain('folder_id', '!=', False) & Domain('folder_id', 'not any', []) | Domain("folder_id", "=", False) & Domain("owner_id", "not in", [self.env.user.id, False]) ) elif value.isnumeric(): folder_ids.append(int(value)) else: raise UserError(_("Unknown searched value %s", value)) if folder_ids: domain_parts.append(Domain('folder_id', 'in', folder_ids)) domain = Domain.OR(domain_parts) if operator == 'child_of': if len(values) > 1: raise UserError(_('Only one value can be searched for children of `user_folder_id`')) return self._get_child_of_domain(domain, values.pop()) return domain @api.model def _get_child_of_domain(self, roots_domain, value: str | int): """Make sure that all intermediate folders are also part of the result.""" if not isinstance(value, str | int): raise UserError(_('Only one string or number value can be searched for documents `child_of`.')) if value == 'SHARED': # Can't use sudo speedup here shared_roots = self.with_context(active_test=False).search_fetch(roots_domain, ['id']) return Domain('id', 'child_of', shared_roots.ids) candidates, top_level_folders = ( query.select(*(self._field_to_sql(query.table, fname, query) for fname in ('id', 'folder_id'))) for query in ( self.with_context(active_test=False)._search([('type', '=', 'folder')]), self.with_context(active_test=False)._search(roots_domain & Domain('type', '=', 'folder')) ) ) children = SQL( """ WITH RECURSIVE candidates as (%(candidates)s), top_level as (%(top_level_folders)s), children AS ( SELECT id FROM top_level UNION ALL SELECT c.id FROM candidates c JOIN children f ON c.folder_id = f.id ) SELECT id FROM children """, candidates=candidates, top_level_folders=top_level_folders, ) return roots_domain | Domain('folder_id', 'any', children) @api.model def _search_folder_id(self, operator, operand): if operator != 'child_of': return Domain(Domain('folder_id', operator, operand), internal=True) values = {operand} if isinstance(operand, int) else set(operand) if len(values) > 1: raise UserError(_("Only one value can be searched for child of `folder_id`.")) value = values.pop() return self._get_child_of_domain(Domain('folder_id', '=', value) | Domain('id', '=', value), value) @api.model def _clean_vals_for_user_folder_id(self, vals): """Update vals to integrate `user_folder_id`. This allows to * Override context-provided values if `user_folder_id` is defined * Handle constraints on moving only on `folder_id` and `owner_id` instead of duplicating them for `user_folder_id` * Centralize logic about vals vs context defaults Note that passing any values for `folder_id` and `owner_id` in vals or context will discard default_user_folder_id. :param dict vals: Values for record :raises UserError: on invalid new `user_folder_id` or conflict with `folder_id` or `owner_id` in `vals` """ user_folder_id = vals.get('user_folder_id') if not user_folder_id: if ( (default_user_folder_id := self.env.context.get('default_user_folder_id')) and 'folder_id' not in vals and 'owner_id' not in vals and 'default_folder_id' not in self.env.context and 'default_owner_id' not in self.env.context ): user_folder_id = default_user_folder_id else: return if user_folder_id == "COMPANY": new_vals = {'owner_id': False, 'folder_id': False} elif user_folder_id == "MY": if not self.env.user.active: raise UserError(_("Inactive user cannot create/move in 'My Drive'.")) new_vals = {'owner_id': self.env.user.id, 'folder_id': False} elif user_folder_id == "RECENT": raise UserError(_("Documents cannot be created or moved in 'Recent'.")) elif user_folder_id == "SHARED": raise UserError(_("Documents cannot be created or moved in 'Shared With Me'.")) elif user_folder_id == "TRASH": raise UserError(_("Documents cannot be created or moved in the trash.")) elif user_folder_id.isnumeric(): new_vals = {"folder_id": int(user_folder_id)} else: raise UserError(_("Unexpected user_folder_id value %s", user_folder_id)) message = _("Conflicting values passed with user_folder_id.") if (folder_id := vals.get('folder_id')) and folder_id != new_vals['folder_id']: raise UserError(message) if (owner_id := vals.get('owner_id')) and 'owner_id' in new_vals and owner_id != new_vals['owner_id']: raise UserError(message) vals.update(new_vals) @api.depends('attachment_id', 'url', 'shortcut_document_id') def _compute_name_and_preview(self): request_session = requests.Session() shortcuts = self.filtered('shortcut_document_id') for record in self - shortcuts: if record.attachment_id: record.name = record.attachment_id.name record.url_preview_image = False elif record.url: preview = link_preview.get_link_preview_from_url(record.url, request_session) if not preview: continue if preview.get('og_title'): record.name = preview['og_title'] if preview.get('og_image'): record.url_preview_image = preview['og_image'] for shortcut in shortcuts: shortcut.name = shortcut.name or shortcut.shortcut_document_id.name shortcut.url_preview_image = shortcut.url_preview_image or shortcut.shortcut_document_id.url_preview_image @api.depends_context('uid', 'allowed_company_ids') @api.depends('access_ids', 'access_internal', 'access_via_link', 'owner_id', 'is_access_via_link_hidden', 'company_id', 'folder_id.access_ids', 'folder_id.access_internal', 'folder_id.access_via_link', 'folder_id.owner_id', 'folder_id.company_id', 'shortcut_document_id', 'shortcut_document_owner_id') def _compute_user_permission(self): if self.env.user.has_group('documents.group_documents_system'): for document in self: if ( not (company := document.company_id) or company in self.env.companies or company not in self.env.user.with_context(active_test=True).company_ids ): document.user_permission = 'edit' else: document.user_permission = 'none' return permission_by_document = self._get_permission_without_token_multi() for document in self: if document.company_id and not document.company_id.active: document.user_permission = 'none' continue document.user_permission = permission_by_document[document] if document.user_permission == 'view' and document.access_via_link == 'edit': document.user_permission = 'edit' elif ( document.shortcut_document_id and document.owner_id == self.env.user and (document.user_permission == 'view' or document.shortcut_document_owner_id == self.env.user) ): # Extend shortcut owner permission when target is accessible document.user_permission = 'edit' if document.user_permission == 'none' and document.folder_id and document.access_via_link != 'none' \ and not document.is_access_via_link_hidden \ and (document.company_id in self.env.companies or document.company_id not in self.env.user.with_context(active_test=True).company_ids): # If the user can access the parent, they have the link. # This only works one level up, as it mimics accessing through the interface. with contextlib.suppress(AccessError): if document.folder_id._get_permission_without_token() != 'none': document.user_permission = document.access_via_link def _get_permission_without_token(self): self.ensure_one() return self._get_permission_without_token_multi()[self] def _get_permission_without_token_multi(self): permission_by_document = {} documents_to_process = self for document in self: exclude_ownership = bool(document.shortcut_document_id) is_user_company = document.company_id and document.company_id in self.env.user.with_context(active_test=False).company_ids is_disabled_company = is_user_company and document.company_id not in self.env.companies if is_disabled_company: permission_by_document[document] = 'none' documents_to_process -= document continue if document.owner_id == self.env.user and not exclude_ownership: permission_by_document[document] = 'edit' documents_to_process -= document continue permission_by_document[document] = 'none' if not documents_to_process: return permission_by_document # access with access_by_document = self.env['documents.access']._read_group( domain=[ ('partner_id', '=', self.env.user.partner_id.id), ('document_id', 'in', documents_to_process.ids), '|', ('expiration_date', '=', False), ('expiration_date', '>', fields.Datetime.now()), ], groupby=['document_id'], aggregates=['id:recordset'], ) # `access` is a singleton, since there can be only 1 access per (document_id, partner_id) for document, access in access_by_document: if access: permission_by_document[document] = access.role or document.access_via_link # access as internal for document in documents_to_process: if ( not self.env.user.share and permission_by_document[document] != "edit" and document.access_internal != 'none' and (not document.company_id or document.company_id in self.env.companies) ): permission_by_document[document] = ( 'edit' if self.env.user.has_group('documents.group_documents_manager') else document.access_internal ) return permission_by_document def _search_user_permission(self, operator, value, exclude_ownership=False): if self.env.user._is_public(): return Domain.FALSE searched_roles = {'view', 'edit', 'none'} if operator == 'in': searched_roles.intersection_update(value) elif operator == 'not in': searched_roles.difference_update(value) else: return NotImplemented searched_roles.discard('none') if not searched_roles: return Domain.FALSE searched_roles = list(searched_roles) other_company = Domain('company_id', '!=', False) & Domain('company_id', 'not in', self.env.user.with_context(active_test=False).company_ids.ids) allowed_or_no_company = Domain('company_id', 'in', [False] + self.env.companies.ids) any_except_disabled_company = Domain.OR([ Domain('company_id', 'in', self.env.companies.ids), Domain('company_id', 'not in', self.env.user.company_ids.ids), Domain('company_id.active', '=', False), ]) any_except_disabled_and_archived_company = ( Domain('company_id', 'in', self.env.companies.ids) | Domain('company_id', 'not in', self.env.user.with_context(active_test=False).company_ids.ids) ) if self.env.user.has_group('documents.group_documents_system'): if searched_roles == ['view']: return Domain.FALSE # System Administrator has "edit" on all documents, so finds none with "view" only. # System Administrator should always be able to edit documents from archived companies (even with active_test=False) return any_except_disabled_company # Access from membership if searched_roles == ['view']: access_level_domain = ( (Domain('role', '=', 'view') & Domain('document_id.access_via_link', 'in', ('none', 'view'))) | (Domain('role', '=', False) & Domain('document_id.access_via_link', '=', 'view')) ) elif searched_roles == ['edit']: access_level_domain = Domain('role', '=', 'edit') | Domain('document_id.access_via_link', '=', 'edit') else: access_level_domain = Domain('role', 'in', ('view', 'edit')) | Domain('document_id.access_via_link', '!=', 'none') access_domain = Domain('access_ids', 'any', Domain.AND(( access_level_domain, Domain('partner_id', '=', self.env.user.partner_id.id), Domain('expiration_date', '=', False) | Domain('expiration_date', '>', fields.Datetime.now()), ))) # Access from ownership if exclude_ownership: owner_domain = Domain.FALSE else: owner_domain = Domain('owner_id', '=', self.env.user.id) & Domain.OR([ [('shortcut_document_id', '=', False)], [('shortcut_document_owner_id', '=', self.env.user.id)], # extend permission to edit on shortcuts when otherwise viewer (synced with target) # optimized to avoid recursive call if owner_domain is not going to be used (see below) # or if everything we need is already in `access_domain` self._search_user_permission('in', ['view'], exclude_ownership=True) if set(searched_roles) == {'edit'} else Domain.FALSE, ]) direct_domain = any_except_disabled_and_archived_company & ( access_domain if 'edit' not in searched_roles else access_domain | owner_domain ) # Access form access_internal if self.env.user.has_group('documents.group_documents_manager'): if searched_roles == ['view']: direct_domain &= Domain('access_internal', '=', 'none') | other_company else: direct_domain |= Domain('access_internal', 'in', ('view', 'edit')) & allowed_or_no_company elif not self.env.user.share: if searched_roles == ['view']: internal_domain = Domain('access_internal', '=', 'view') & Domain('access_via_link', 'in', ('none', 'view')) elif searched_roles == ['edit']: internal_domain = Domain('access_internal', '=', 'edit') | ( Domain('access_internal', '=', 'view') & Domain('access_via_link', '=', 'edit') ) else: internal_domain = Domain('access_internal', 'in', ('view', 'edit')) direct_domain |= internal_domain & allowed_or_no_company if exclude_ownership: return direct_domain # Look one level up for links unless hidden link_via_parent_domain = Domain.AND([ any_except_disabled_and_archived_company, [('access_via_link', 'in', searched_roles)], [('is_access_via_link_hidden', '=', False)], [('folder_id', 'any', direct_domain)], ]) return direct_domain | link_via_parent_domain @api.depends('datas', 'mimetype') def _compute_is_multipage(self): for document in self: # external computation to be extended document.is_multipage = bool(document._get_is_multipage()) # None => False @api.depends('attachment_id', 'attachment_id.res_model', 'attachment_id.res_id', 'shortcut_document_id.res_model', 'shortcut_document_id.res_id') def _compute_res_record(self): for record in self: attachment = record.attachment_id if attachment: record.res_model = (attachment.res_model != 'documents.document' and attachment.res_model) or False record.res_id = (attachment.res_model != 'documents.document' and attachment.res_id) or False if record.shortcut_document_id: record.res_model = record.shortcut_document_id.res_model record.res_id = record.shortcut_document_id.res_id @api.depends('attachment_id', 'res_model', 'res_id') def _compute_res_name(self): for record in self: if record.attachment_id: record.res_name = record.attachment_id.res_name elif record.res_id and record.res_model: try: record.res_name = self.env[record.res_model].browse(record.res_id).display_name except MissingError: record.res_name = False else: record.res_name = False def _inverse_res_record(self): for record in self: attachment = record.attachment_id.with_context(no_document=True) # If no linked record, link the attachment to the document # (so users see the attachment in the technical view if they have access to the document) res_model, res_id = record.res_model, record.res_id if not res_model: res_model = 'documents.document' res_id = record.id if attachment and (attachment.res_model, attachment.res_id) != (res_model, res_id): # Avoid inconsistency in the data, write both at the same time. # In case a check_access is done between res_id and res_model modification, # an access error can be received. (Mail causes this check_access) attachment.sudo().write({'res_model': res_model, 'res_id': res_id}) @api.depends('checksum', 'shortcut_document_id.thumbnail', 'shortcut_document_id.thumbnail_status', 'shortcut_document_id.user_permission') def _compute_thumbnail(self): for document in self: if document.shortcut_document_id: if document.shortcut_document_id: document.thumbnail = document.shortcut_document_id.thumbnail document.thumbnail_status = document.shortcut_document_id.thumbnail_status else: document.thumbnail = False document.thumbnail_status = 'restricted' elif document.mimetype and ( document.mimetype.startswith('application/pdf') or document.mimetype.startswith('image/webp')): # These thumbnails are generated by the client. To force the generation, we invalidate the thumbnail. document.thumbnail = False document.thumbnail_status = 'client_generated' elif document.mimetype and document.mimetype.startswith('image/'): try: document.thumbnail = base64.b64encode(image_process(document.raw, size=(200, 140), crop='center')) document.thumbnail_status = 'present' except (UserError, TypeError): document.thumbnail = False document.thumbnail_status = 'error' else: document.thumbnail = False document.thumbnail_status = False @api.depends('type') def _compute_deletion_delay(self): folders = self.filtered(lambda d: d.type == 'folder') folders.deletion_delay = self.get_deletion_delay() (self - folders).deletion_delay = False def _get_folder_embedded_actions(self, folder_ids): """Return the enabled actions for the given folder.""" folders_sudo = self.env['documents.document'].sudo().search([ ('id', 'in', folder_ids), '|', ('user_permission', '!=', 'none'), ('children_ids', 'any', [('user_permission', '!=', 'none')]) ]) if not folders_sudo: return {} all_embedded_actions_sudo = self.env['ir.embedded.actions'].sudo().search( domain=Domain.AND([ self.env['ir.embedded.actions'].sudo()._get_documents_embed_base_domain(), [('parent_res_id', 'in', (folders_sudo + folders_sudo.shortcut_document_id).ids)], ]), order='sequence', ) # Filtering on action_id.groups_id above is not possible because the orm "considers" action_id # to be of the ir.actions.action model, that does not have a groups_id field. accessible_server_actions_ids = self.env['ir.actions.server'].sudo().search( Domain.AND([ [('id', 'in', all_embedded_actions_sudo.action_id.ids)], self._get_embeddable_server_action_domain(), ])).ids embedded_actions = all_embedded_actions_sudo.filtered( lambda e: e.action_id.id in accessible_server_actions_ids).sudo(False) # group after ordering by `ir.embedded.actions` sequence actions_per_folder = embedded_actions.grouped('parent_res_id') targets_to_shortcuts_sudo = folders_sudo.grouped('shortcut_document_id') actions_per_shortcut_folder = { shortcut_sudo.id: actions for target_sudo, shortcuts_sudo in targets_to_shortcuts_sudo.items() for shortcut_sudo in shortcuts_sudo if (actions := actions_per_folder.get(target_sudo.id)) } return actions_per_folder | actions_per_shortcut_folder @api.depends_context('uid', 'allowed_company_ids') @api.depends('folder_id') def _compute_available_embedded_actions_ids(self): embedded_actions = self._get_folder_embedded_actions(self.folder_id.ids) embedded_actions_per_folder = { folder_id: actions.ids for folder_id, actions in embedded_actions.items() } self.available_embedded_actions_ids = False for document in self.filtered(lambda d: d.type != 'folder' and not d.shortcut_document_id): document.available_embedded_actions_ids = embedded_actions_per_folder.get(document.folder_id.id, False) def _get_last_access_date_group_cte(self): return SQL(""" WITH last_access_date AS ( SELECT (CASE WHEN last_access_date > NOW() - INTERVAL '1 days' THEN '3_day' WHEN last_access_date > NOW() - INTERVAL '7 days' THEN '2_week' WHEN last_access_date > NOW() - INTERVAL '1 months' THEN '1_month' ELSE '0_older' END) AS date, document_id FROM documents_access WHERE partner_id = %s ) """, self.env.user.partner_id.id) def _compute_mail_alias_domain_count(self): self.mail_alias_domain_count = self.env['mail.alias.domain'].sudo().search_count([]) @api.depends('access_ids') def _compute_last_access_date_group(self): self.env.cr.execute(SQL( """(%s SELECT document_id, date FROM last_access_date WHERE document_id = ANY(%s))""", self._get_last_access_date_group_cte(), self.ids, )) values = {line['document_id']: line['date'] for line in self.env.cr.dictfetchall()} for document in self: document.last_access_date_group = values.get(document.id) def _search_last_access_date_group(self, operator, operand): if operator != 'in': return NotImplemented values = set(operand) if False in values: query = SQL("(%s SELECT document_id FROM last_access_date)", self._get_last_access_date_group_cte()) domain = [('id', 'not in', query)] if len(values) > 1: values.remove(False) domain += self._search_last_access_date_group(operator, values) return domain query = SQL( """(%s SELECT document_id FROM last_access_date WHERE date IN %s)""", self._get_last_access_date_group_cte(), tuple(values)) return [('id', 'in', query)] def _field_to_sql(self, alias, fname, query=None) -> SQL: if fname == 'last_access_date_group': # Allow to group on the field return SQL("""(%s SELECT date FROM last_access_date WHERE document_id = %s)""", self._get_last_access_date_group_cte(), SQL.identifier(alias, 'id')) return super()._field_to_sql(alias, fname, query) def _order_field_to_sql(self, alias, field_name, direction, nulls, query): if field_name == 'last_access_date_group': sql_field = SQL( "SELECT last_access_date FROM documents_access WHERE partner_id = %s AND document_id = %s", self.env.user.partner_id.id, SQL.identifier(alias, 'id') ) return SQL("(%s) %s %s", sql_field, direction, nulls) if field_name == 'is_folder': sql_field = SQL("%s != 'folder'", SQL.identifier(alias, 'type')) return SQL("(%s) %s %s", sql_field, direction, nulls) return super()._order_field_to_sql(alias, field_name, direction, nulls, query) @api.model def get_previewable_file_extensions(self): return {'bmp', 'png', 'jpg', 'jpeg', 'pdf', 'gif', 'txt'} def action_move_folder(self, target, before_folder_id=False): """Move one folder to the given position and update its sequence. If no parent_folder is given, check whether the parent is 'COMPANY' or 'MY'. If no before_folder is given, place it as last child of its parent (last root if no parent is given) :param str target: user_folder_id of the new parent folder :param int|bool before_folder_id: id of the folder before which to move """ self.ensure_one() if self.type != 'folder' or not self.active: return values = {'user_folder_id': target} sibling_folders_domain = ( Domain('type', '=', 'folder') & Domain('id', '!=', self.id) & Domain('user_folder_id', '=', target) ) # If before_folder is indeed a sibling given the passed target (as it could have been moved by someone else), # assign its current sequence value to the current record and shift the following folders to keep ordering. if before_folder := self.browse(before_folder_id): located_after_domain = ( Domain('sequence', '>', before_folder.sequence) | (Domain('sequence', '=', before_folder.sequence) & Domain('id', '<=', before_folder_id)) ) folders_to_resequence_domain = sibling_folders_domain & located_after_domain folders_to_resequence_sudo = self.sudo().search(folders_to_resequence_domain) if before_folder == folders_to_resequence_sudo[0]: values['sequence'] = before_folder.sequence new_sequence = before_folder.sequence + 1 for folder_sudo in folders_to_resequence_sudo: if folder_sudo.sequence >= new_sequence: break folder_sudo.sequence = new_sequence new_sequence += 1 return self.write(values) # Otherwise, move the folder as last child of its parent if result := self.env['documents.document'].sudo().search_read( sibling_folders_domain, fields=['sequence'], order="sequence DESC", limit=1): values['sequence'] = result[0]["sequence"] + 1 return self.write(values) def action_change_owner(self, new_user_id): if not self.env.user._is_admin() and not self.env.user.has_group('documents.group_documents_system'): if any(document.owner_id != self.env.user for document in self): raise AccessError(_("You are not allowed to change ownerships of documents you do not own.")) self.owner_id = new_user_id @api.model def _ensure_user_role_without_propagation(self, role, documents_per_user): """Set role membership without propagating to children.""" existing_access = self.env['documents.access'].sudo().search(Domain.OR( [('partner_id', '=', owner.partner_id.id), ('document_id', 'in', documents.ids)] for owner, documents in documents_per_user.items() )) existing_access.role = role existing_access_values = {(a.partner_id, a.document_id) for a in existing_access} self.env['documents.access'].sudo().create([ {'partner_id': owner.partner_id.id, 'document_id': document.id, 'role': role} for owner, documents in documents_per_user.items() for document in documents if (owner.partner_id, document) not in existing_access_values ]) def action_create_shortcut(self, location_user_folder_id=None): """Create a shortcut to self in a specific user_folder or as a sibling. :param str | None location_user_folder_id: Optional: where to create the shortcut. """ if not self.ids: return self.browse() if len(self.folder_id.ids) > 1 and location_user_folder_id is None: raise UserError(_("A destination is required when creating multiple shortcuts at once.")) if location_user_folder_id is False: raise UserError(_('Ambiguous shortcut target location.')) if location_user_folder_id is not None: try: location_folder_id = int(location_user_folder_id) except ValueError: # Company, My => False location_folder_id = False else: location_folder_id = None location = self.browse(location_folder_id) if location_folder_id is not None else self.folder_id if self.shortcut_document_id: targets = self.filtered(lambda d: not d.shortcut_document_id) | self.shortcut_document_id return targets.action_create_shortcut(str(location.id) if location else location_user_folder_id) if location_folder_id and location.shortcut_document_id: return self.action_create_shortcut(str(location.shortcut_document_id.id)) if location and location.user_permission != 'edit': raise AccessError(_("You are not allowed to write in this folder.")) self.check_access('read') return self.sudo().create([{ "user_folder_id": str(location.id) if location else location_user_folder_id, "shortcut_document_id": document.id, "access_internal": document.access_internal or 'view', "access_via_link": document.access_via_link or 'none', "access_ids": [ Command.create({ "partner_id": access.partner_id.id, "role": access.role, }) for access in document.access_ids if access.role ], **{ field_name: (value.id if isinstance((value := document[field_name]), models.Model) else value) for field_name in self._get_shortcuts_copy_fields() } } for document in self]).sudo(False) def action_delete_from_history(self, attachment_id): """Delete an attachment from the document's history.""" self.ensure_one() attachment = self.env['ir.attachment'].browse(attachment_id) if ( attachment not in self.previous_attachment_ids and (attachment != self.attachment_id or not self.previous_attachment_ids) ): raise UserError(_('You cannot delete this attachment.')) if attachment == self.attachment_id: self.attachment_id = max(self.previous_attachment_ids, key=lambda a: (a.create_date, a.id)) attachment.unlink() @api.model def _get_shortcuts_copy_fields(self): # Note that current simple usage in action_create_shortcut supports scalar and m2o fields. return {'company_id', 'file_size', 'file_extension', 'is_access_via_link_hidden', 'is_multipage', 'name', 'partner_id', 'type', 'url', 'url_preview_image'} def action_update_access_rights(self, access_internal=None, access_via_link=None, is_access_via_link_hidden=None, partners=None, no_propagation=False): """Update access to a document and propagate if applicable. This method can be called to update the access of internal users, with the link, as well as a set of partners and roles to the records in self and their children (except shortcuts), and shortcuts pointing to them as they are kept synchronized. Modifications to internal users and link access are propagated down to children until the new value is already present. Note that changes to the discoverability(`is_access_via_link_hidden`) are never propagated. For partners, all changes are applied to all children regardless of the existing rights structure. :param str | None access_internal: optional new permission level for internal users :param str | None access_via_link: optional new permission level for partners with the link :param bool|None is_access_via_link_hidden: optional new value for discoverability :param dict[str | int | res.partner(), tuple[str | bool | None, str | datetime | bool | None] partners: Mapping of partner(_id) to the tuple: role: 'edit', 'view', False (=>delete), expiration: datetime string, False (removed/None) :param bool no_propagation: whether to propagate rights to sub-folders """ if len(self.ids) == 0: return try: self.check_access('write') except UserError: raise AccessError(self.env._("You are not allowed to update these access rights.")) if self.shortcut_document_id: raise UserError(_("You can not update the access of a shortcut, update its target instead.")) # Check inputs as we are going to bypass the ORM in the private method(s) access_options = {'view', 'edit', 'none', None} hidden_options = {None, True, False} role_options = {'edit', 'view', False, None} incorrect_fields_to_options = { **({'is_access_via_link_hidden': hidden_options} if is_access_via_link_hidden not in hidden_options else {}), **({'access_via_link': access_options} if access_via_link not in access_options else {}), **({'access_internal': access_options} if access_internal not in access_options else {}), **({'partners.role': role_options} if any(role not in role_options for (__, (role, __)) in (partners or {}).items()) else {}) } if incorrect_fields_to_options: hints = "\n- " + "\n- ".join(f'{name}: {options}' for name, options in incorrect_fields_to_options.items()) raise UserError(_( "Incorrect values. Use one of the following for the following fields: %(hints)s.)", hints=hints )) changes_by_document_dict = self._action_update_access( access_internal, access_via_link, is_access_via_link_hidden, no_propagation=no_propagation) if partners: partners = { self.env['res.partner'].browse(int(partner)) if isinstance(partner, str | int) else partner: (role, fields.Datetime.to_datetime(exp) if exp and isinstance(exp, str) else exp) for partner, (role, exp) in (partners or {}).items() } created_or_updated_access, removed_access = self._action_update_members(partners, no_propagation=no_propagation) self._update_changes_by_document_dict(created_or_updated_access, removed_access, changes_by_document_dict) self.env['documents.access.tracking']._create_access_tracking(changes_by_document_dict) return self.mapped('user_permission') def _action_update_access(self, access_internal, access_via_link, is_access_via_link_hidden, no_propagation=False): """Update the access on self and children. Stop the propagation when the value is already the right one. :param str | None access_internal: change the `access_internal` if not None :param str | None access_via_link: change the `access_via_link` if not None :param bool | None is_access_via_link_hidden: change the `is_access_via_link_hidden` if not None :param bool no_propagation: whether to propagate access update to sub-folders """ self.flush_model() changes_by_document_dict = defaultdict(dict) for field, value in ( ('access_internal', access_internal), ('access_via_link', access_via_link), ('is_access_via_link_hidden', is_access_via_link_hidden), ): if value is None: continue # never propagate discoverability skip_propagation = no_propagation or field == 'is_access_via_link_hidden' # records that we might need to update candidates_domain = Domain([ (field, '!=', value), # the update is done only "target -> shortcut", # but not "shortcut -> target" ('shortcut_document_id', '=', False), ('id', 'in' if skip_propagation else 'child_of', self.ids), ]) candidates_domain &= self._get_access_update_domain() candidates_query = self.with_context(active_test=False)._search(candidates_domain) candidates = candidates_query.select(*( self._field_to_sql(candidates_query.table, fname, candidates_query) for fname in ('id', 'folder_id', 'shortcut_document_id', field) )) self.env.cr.execute(SQL(""" WITH RECURSIVE candidates AS (%(candidates)s), -- explore the folders documents_to_update AS ( SELECT id, %(field)s FROM candidates WHERE id = ANY(%(root_ids)s) UNION SELECT child.id, child.%(field)s FROM candidates AS child JOIN documents_to_update AS parent ON child.folder_id = parent.id ), documents_and_shortcuts AS ( SELECT id, %(field)s FROM documents_to_update UNION -- document.shortcut_ids -- update in "SUDO" to keep them synchronized SELECT shortcut.id, shortcut.%(field)s FROM documents_document AS shortcut JOIN documents_to_update ON documents_to_update.id = shortcut.shortcut_document_id ) UPDATE documents_document SET %(field)s = %(value)s FROM documents_and_shortcuts AS doc -- document | document.children_ids | document.shortcut_ids WHERE documents_document.id = doc.id RETURNING doc.id, doc.%(field)s """, field=SQL(field), value=value, root_ids=self.ids, candidates=candidates)) for id, old_value in self.env.cr.fetchall(): changes_by_document_dict[id][field] = old_value self.invalidate_model([ 'access_internal', 'access_via_link', 'is_access_via_link_hidden', 'user_permission', ]) return changes_by_document_dict def _action_update_members(self, partners, no_propagation=False): """Update the members access on all files bellow the current folder. :param partners: Partners to add as members / change access :param bool no_propagation: whether to propagate members update to sub-folders """ self.env['documents.access'].flush_model() partners_to_remove = self.env['res.partner'] # {(role, expiration_date): partners} values_to_update = defaultdict(lambda: self.env['res.partner']) for partner, (role, expiration_date) in partners.items(): if role is False: # remove the members partners_to_remove |= partner elif role is not None or expiration_date is not None: values_to_update[role, expiration_date] |= partner # use `_search` to respect access rules and to use `_search_user_permission` to_update_domain = Domain([ ('shortcut_document_id', '=', False), # update "target -> shortcuts" but not "shortcut -> target" ('id', 'in' if no_propagation else 'child_of', self.ids), ]) to_update_domain &= self._get_access_update_domain() documents = self.with_context(active_test=False)._search(to_update_domain).select() created_or_updated_access = [] for (role, expiration_date), partners in values_to_update.items(): if role not in ('edit', 'view'): raise UserError(_("Invalid role.")) # The public method would have returned a more insightful message update_fields = [SQL('role = %(role)s', role=role)] if expiration_date is not None: update_fields.append(SQL( 'expiration_date = %(expiration_date)s', expiration_date=expiration_date or None, )) update_fields = SQL(',').join(update_fields) self.env.cr.execute(SQL( """ WITH documents AS (%(documents)s), documents_and_shortcuts AS ( SELECT * FROM documents UNION -- document.shortcut_ids SELECT shortcut.id FROM documents_document AS shortcut JOIN documents AS document ON document.id = shortcut.shortcut_document_id ), existing AS ( SELECT document_id, partner_id, role, expiration_date FROM documents_access JOIN documents_and_shortcuts ON document_id = documents_and_shortcuts.id AND partner_id = any(%(partner_ids)s) ), updated_or_created AS ( INSERT INTO documents_access ( document_id, partner_id, role, expiration_date ) ( SELECT DISTINCT ON (doc.id, partner_id) doc.id, partner_id, %(role)s, %(expiration_date)s FROM documents_and_shortcuts AS doc JOIN LATERAL UNNEST(%(partner_ids)s) AS partner_id ON TRUE ) ON CONFLICT (document_id, partner_id) DO UPDATE SET %(update_fields)s RETURNING document_id, partner_id, role, expiration_date ) SELECT 'existing' as action, * FROM existing UNION ALL SELECT 'upsert' as action, * FROM updated_or_created ORDER BY action ASC """, documents=documents, partner_ids=partners.ids, expiration_date=expiration_date or None, role=role, update_fields=update_fields, )) created_or_updated_access += self.env.cr.fetchall() removed_access = [] if partners_to_remove: self.env.cr.execute(SQL(""" WITH documents AS (%(documents)s), docs_and_shortcuts AS ( SELECT id FROM documents UNION SELECT shortcut.id FROM documents AS doc JOIN documents_document AS shortcut ON doc.id = shortcut.shortcut_document_id ) DELETE FROM documents_access AS access USING docs_and_shortcuts AS doc WHERE access.document_id = doc.id AND access.partner_id = ANY(%(partner_ids)s) RETURNING access.document_id, access.partner_id """, documents=documents, partner_ids=partners_to_remove.ids)) removed_access = self.env.cr.fetchall() self.env['documents.document'].invalidate_model([ 'access_ids', 'user_permission', ]) self.env['documents.access'].invalidate_model() return created_or_updated_access, removed_access @api.model def _update_changes_by_document_dict( self, created_or_updated_access, removed_access, changes_by_document_dict): old_values = defaultdict(dict) for action, doc, partner, role, exp in created_or_updated_access: exp = fields.Date.to_string(exp) or 'None' partner_dict = (changes_by_document_dict.setdefault(doc, {}) .setdefault('members', {'added': {}, 'updated': {}, 'removed': []})) if action == 'upsert': if old := old_values[doc].get(partner): partner_dict['updated'][partner] = { 'role': (old['role'], role), 'expiration_date': (old['expiration_date'], exp), } else: partner_dict['added'][partner] = { 'role': role, 'expiration_date': exp, } elif action == 'existing': old_values[doc][partner] = { 'role': role, 'expiration_date': exp, } for doc, partner in removed_access: (changes_by_document_dict.setdefault(doc, {}) .setdefault('members', {'added': {}, 'updated': {}, 'removed': []})['removed'].append(partner)) def _update_company(self, company_id): """Apply company to documents and children, without stopping (see _action_update_members). :param int|bool company_id: Id to set or False """ self.flush_model() to_update_domain = Domain.AND(( Domain('id', 'in', self.ids) | Domain('company_id', '!=', company_id), # the update is done only "target -> shortcut", # but not "shortcut -> target" [('shortcut_document_id', '=', False)], [('id', 'child_of', self.ids)], [] if self.env.su else [('user_permission', '=', 'edit')], )) to_update = self.with_context(active_test=False)._search(to_update_domain).select() # update shortcuts in sudo to keep them synchronized shortcuts_union = SQL(""" UNION SELECT shortcut.id FROM documents_document AS shortcut JOIN documents_to_update ON documents_to_update.id = shortcut.shortcut_document_id """ if company_id else "") self.env.cr.execute(SQL(""" WITH documents_to_update AS (%(to_update)s), documents_and_shortcuts AS ( SELECT id FROM documents_to_update %(shortcuts_union)s ) UPDATE documents_document SET %(field)s = %(value)s FROM documents_and_shortcuts AS doc WHERE documents_document.id = doc.id """, field=SQL('company_id'), value=company_id or None, to_update=to_update, shortcuts_union=shortcuts_union)) self.invalidate_model(['company_id', 'user_permission']) def _get_access_update_domain(self): return Domain.TRUE if self.env.su else Domain('user_permission', '=', 'edit') def get_formview_action(self, access_uid=None): """Return the `document_action_preference` action as the default view for M2O fields.""" if not self.has_access("read"): return { "type": "ir.actions.client", "tag": "display_notification", "params": { "message": self.env._("Document not found or inaccessible."), "type": "danger", }, } default_user_folder_id = str(self.id) if self.type == "folder" and self.active else self.user_folder_id action = self.env["ir.actions.actions"]._for_xml_id("documents.document_action_preference") action["context"] = { **self.env.context, "documents_show_default_breadcrumb": True, "no_documents_unique_folder_id": True, "searchpanel_default_user_folder_id": default_user_folder_id, "documents_init_document_id": self.id if self.type != "folder" or not self.active else False, } return action @api.model def get_documents_actions(self, folder_id): """Return the available actions and a key to know if the action is embedded on the folder.""" if not isinstance(folder_id, int): raise ValueError("Invalid folder_id") folder = self.env['documents.document'].search([('id', '=', folder_id)]) if not folder: raise UserError(_('This folder does not exist or is not accessible.')) embedded_actions = self._get_folder_embedded_actions(folder.ids) embedded_actions = embedded_actions[folder.id].action_id.ids if embedded_actions else [] actions = self.env['ir.actions.server'].sudo().search(self._get_embeddable_server_action_domain()) return [{ "id": action.id, "name": action.display_name, "is_embedded": action.id in embedded_actions } for action in actions] @api.model def _get_embeddable_server_action_domain(self): """Wrap `_get_base_server_actions_domain`'s domain to exclude children and actions with invalid children.""" candidate_actions_sudo = self.env["ir.actions.server"].sudo()._search( Domain.AND([ self._get_base_server_actions_domain(), Domain.OR([[('group_ids', 'any', [('id', 'in', self.env.user.all_group_ids.ids)])], [('group_ids', '=', False)]]), ]), ) return Domain.AND([ [('id', 'in', candidate_actions_sudo)], [('parent_id', '=', False)], # no child action [('child_ids', 'not any', [('id', 'not in', candidate_actions_sudo)])], # no invalid child ]) @api.model def _get_base_server_actions_domain(self): """Return the base domain for actions applicable to documents in the current context. !Meant to be wrapped by _get_embeddable_server_action_domain. Override to add validity conditions. """ return Domain.AND([ [('model_id', '=', self.env['ir.model']._get_id('documents.document'))], [('usage', 'in', ('ir_actions_server', 'documents_embedded'))], ]) @api.model def action_folder_embed_action(self, folder_id, action_id): """Enable / disable the action for the given folder :param int folder_id: The folder on which we pin the actions :param int action_id: The id of the action to enable """ if not self.env.user.has_group('documents.group_documents_user') and not self.env.su: raise AccessError(_("You are not allowed to pin/unpin embedded Actions.")) server_actions_groups_domain = [ '|', ('group_ids', 'any', [('id', 'in', self.env.user.all_group_ids.ids)]), ('group_ids', '=', False), ] action = self.env['ir.actions.server'].sudo().search([('id', '=', action_id), *server_actions_groups_domain]) if not action: raise UserError(_('This action does not exist.')) if action.type != 'ir.actions.server': raise UserError(_('You cannot pin that type of action.')) folder = self.env['documents.document'].browse(folder_id).sudo().exists() if not folder or folder.type != 'folder': raise UserError(_('You cannot pin an action on that document.')) if folder.shortcut_document_id: return self.action_folder_embed_action(folder.shortcut_document_id.id, action_id) all_embedded_actions_sudo = self.env['ir.embedded.actions'].sudo().search( Domain.AND([ self.env['ir.embedded.actions'].sudo()._get_documents_embed_base_domain(), [('action_id', '=', action_id), ('parent_res_id', '=', folder_id)], ]) ) # See _get_folder_embedded_actions accessible_server_action_ids = self.env['ir.actions.server'].sudo().search([ ('id', 'in', all_embedded_actions_sudo.action_id.ids), *server_actions_groups_domain ]).ids embedded_actions_sudo = all_embedded_actions_sudo.filtered( lambda e: e.action_id.id in accessible_server_action_ids) if embedded_actions_sudo: embedded_actions_sudo.unlink() else: # first pinned action should be displayed first last_action = self.env['ir.embedded.actions'].search( [], order='sequence DESC', limit=1) self.env['ir.embedded.actions'].create({ 'name': action.name, 'parent_action_id': self.env.ref("documents.document_action").id, 'action_id': action.id, 'parent_res_model': 'documents.document', 'parent_res_id': folder_id, 'groups_ids': self.env.ref('base.group_user').ids, 'sequence': last_action.sequence + 1 if last_action else 1, }) return self.get_documents_actions(folder_id) @api.model def action_execute_embedded_action(self, action_id): """Execute an embedded action on context records. :param int action_id: id of embedded action to be run on context provided records. """ if self.env.user.share: raise AccessError(_("You are not allowed to execute embedded actions.")) if self.env.context.get('active_model') != 'documents.document': raise UserError(_("Unavailable action.")) ids = self.env.context.get('active_ids', [self.env.context['active_id']] if self.env.context.get('active_id') else []) if not ids: raise UserError(_("Missing documents reference.")) embedded_action = self.env['ir.embedded.actions'].browse([action_id]) if all(action_id in document.available_embedded_actions_ids.ids for document in self.browse(ids)): return self.env['ir.actions.server'].with_context(documents_active_ids=ids).browse(embedded_action.action_id.id).run() raise UserError(_("Unavailable action.")) def _embed_action(self, action_id): """Embed a server action on the current folder(s) if not already done.""" IrEmbeddedActions = self.env['ir.embedded.actions'] embedded_actions = self._get_folder_embedded_actions(self.ids) new_embedding_folders = self.env['documents.document'] for folder in self: if action_id not in embedded_actions.get(folder.id, IrEmbeddedActions).action_id.ids: folder.action_folder_embed_action(folder.id, action_id) new_embedding_folders |= folder return new_embedding_folders def action_link_to_record(self, model=False): """Open the `link_to_record_wizard` to choose a record to link to the current documents. This method can be used inside server actions. """ context = { 'default_document_ids': self.ids, 'default_resource_ref': False, 'default_is_readonly_model': False, 'default_model_ref': False, } if documents_link_record := self.filtered('res_model'): return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'type': 'warning', 'message': _( "Already linked Documents: %s", ", ".join(documents_link_record.mapped('name')) ), } } if model: self.env[model].check_access('write') context['default_is_readonly_model'] = True context['default_model_id'] = self.env['ir.model']._get_id(model) first_valid_id = self.env[model].search([], limit=1).id if not first_valid_id: raise UserError(_("There are no records to link this document. Create one first.")) context['default_resource_ref'] = f'{model},{first_valid_id}' return { 'name': _('Choose a record to link'), 'type': 'ir.actions.act_window', 'res_model': 'documents.link_to_record_wizard', 'view_mode': 'form', 'target': 'new', 'views': [(False, "form")], 'context': context, } def _notify_get_recipients_groups(self, message, model_description, msg_vals=False): groups = super()._notify_get_recipients_groups( message, model_description, msg_vals=msg_vals ) if len(self.ids) != 1: return groups group_values = { 'active': True, 'button_access': {'url': self.access_url}, 'has_button_access': True, } return [ ('group_documents_document_people_with_access', lambda pdata: (pdata['uid'] and self.with_user(pdata['uid']).user_permission != 'none') or (pdata['id'] and self.access_via_link != 'none' and self.access_ids.filtered(lambda a: a.partner_id.id == pdata['id'] and a.role)), group_values) ] + groups def get_deletion_delay(self): return int(self.env['ir.config_parameter'].sudo().get_param('documents.deletion_delay', '30')) def _get_is_multipage(self): """Whether the document can be considered multipage, if able to determine. :return: `None` if mimetype not handled or url-type attachment, `False` if single page or error occurred, `True` otherwise. :rtype: bool | None """ if self.mimetype not in ('application/pdf', 'application/pdf;base64') or self.attachment_type == 'url': return None decoded = base64.b64decode(self.datas) # Avoid warning in tests due to IrActionsReport._pre_render_qweb_pdf rendering pdf as html # It is done before even reading the PDF as PdfFileReader emit warning in that case if modules.module.current_test and b'' in decoded[:32]: _logger.info("Skip _get_is_multipage of %r: html content detected in pdf document while in testing mode", self.name) return False stream = io.BytesIO(decoded) try: return PdfFileReader(stream, strict=False).numPages > 1 except AttributeError: raise # If PyPDF's API changes and the `numPages` property isn't there anymore, not if its computation fails. except Exception: # noqa: BLE001 message = ('Impossible to count pages in %r. It could be due to a malformed document or a ' '(possibly known) issue within PyPDF2.') _logger.warning(message, self.name, exc_info=True) return False @api.depends('active', 'user_permission', 'folder_id.user_permission', 'owner_id', 'user_folder_id') @api.depends_context('uid') def _compute_user_can_move(self): """Compute whether the env user can move the document. A document can be moved if it is not in the trash and can be deleted, i.e., * if it is owned, or * if the user can edit it AND its containing folder AND it is not protected (see _is_company_root_folder) """ active_documents = self.filtered('active') (self - active_documents).user_can_move = False user_can_unlink_map = active_documents._get_user_can_unlink() for document in active_documents: document.user_can_move = user_can_unlink_map[document] def _get_user_can_unlink(self): if self.env.is_admin() or self.env.user.has_group('documents.group_documents_system'): return {document: True for document in self} is_manager = self.env.user.has_group('documents.group_documents_manager') return { document: ( document.owner_id == self.env.user or ( document.user_permission == 'edit' and (not document.folder_id or document.folder_id.user_permission == 'edit') and (is_manager or document.user_folder_id != 'COMPANY' or document.type != 'folder') ) ) for document in self } @api.depends('favorited_ids') @api.depends_context('uid') def _compute_is_favorited(self): favorited = self._filtered_access('read').filtered(lambda d: self.env.user in d.favorited_ids) favorited.is_favorited = True (self - favorited).is_favorited = False def _inverse_is_favorited(self): unfavorited_documents = favorited_documents = self.env['documents.document'].sudo() for document in self: if self.env.user in document.favorited_ids: unfavorited_documents |= document else: favorited_documents |= document favorited_documents.write({'favorited_ids': [(4, self.env.uid)]}) unfavorited_documents.write({'favorited_ids': [(3, self.env.uid)]}) @api.depends('res_model') def _compute_res_model_name(self): for record in self: if record.res_model: record.res_model_name = self.env['ir.model']._get(record.res_model).display_name else: record.res_model_name = False @api.constrains('url') def _check_url(self): for document in self.filtered("url"): if not document.url.startswith(('https://', 'http://', 'ftp://')): raise ValidationError(_('URL %s does not seem complete, as it does not begin with http(s):// or ftp://', document.url)) @api.model def message_new(self, msg_dict, custom_values=None): # When an email comes, create a document with the default values, # then let `_message_post_after_hook` create one document per attachment. custom_values = custom_values or {} folder = self.env['documents.document'].browse(custom_values.get('folder_id')) custom_values['name'] = _('Mail: %s', msg_dict.get('subject')) if 'company_id' not in custom_values: custom_values['company_id'] = folder.company_id.id if 'tag_ids' not in custom_values: custom_values['tag_ids'] = folder.alias_tag_ids.ids else: tags = custom_values['tag_ids'] if tags and isinstance(tags[0], list | tuple): # we have a list of m2m commands if all(len(t) >= 2 and t[0] == Command.LINK for t in tags): tags = [t[1] for t in tags] elif len(tags) == 1 and len(tags[0]) == 3 and tags[0][0] == Command.SET: tags = tags[0][2] else: # do not support other commands tags = [] custom_values['tag_ids'] = self.env['documents.tag'].browse(tags).exists().ids custom_values['active'] = False return super().message_new(msg_dict, custom_values).with_context(document_message_new=True) def _alias_get_creation_values(self): values = super()._alias_get_creation_values() values['alias_model_id'] = self.env['ir.model']._get('documents.document').id if self.id: values['alias_defaults'] = literal_eval(self.alias_defaults or "{}") values['alias_defaults'] |= {'folder_id': self.id} return values def message_post(self, *, message_type='notification', **kwargs): """ Prevent document creation when posting message with attachment on a document (_create_attachments_for_post). If new documents must be created (ex.: alias on document folder), it will be handled by the _message_post_after_hook based on the context variable "document_message_new" (ignoring no_document) That variable is set to True by the message_new method (and not set by message_update method). """ return super(DocumentsDocument, self.with_context(no_document=True)).message_post( message_type=message_type, **kwargs) def _message_post_after_hook(self, message, msg_vals): # If the res model was an attachment and a mail, adds all the custom values of the linked # document settings to the attachments of the mail. If it was only a new email converts # its body to an attachment for the given document (use case: invoice/receipt sent as an email) if message.message_type != 'email' or not self.env.context.get("document_message_new"): return super()._message_post_after_hook(message, msg_vals) m2m_commands = msg_vals['attachment_ids'] attachments = self.env['ir.attachment'].browse([x[1] for x in m2m_commands]) disable_mail_to_document = literal_eval(self.env['ir.config_parameter'].get_param('documents.disable_mail_to_document', default="0")) documents = None if attachments: self.attachment_id = False documents = self.env['documents.document'].create([{ **self._message_post_after_hook_template_values(), 'name': attachment.name, 'attachment_id': attachment.id, 'company_id': self.folder_id.company_id.id, } for attachment in attachments]) for attachment, document in zip(attachments, documents): attachment.write({ 'res_model': 'documents.document', 'res_id': document.id, }) sub_message_values = { 'author_id': msg_vals.get('author_id'), 'body': msg_vals.get('body', ''), 'email_from': msg_vals.get('email_from'), 'message_type': 'email', 'subject': msg_vals.get('subject') or self.name, 'subtype_id': msg_vals.get('subtype_id'), 'subtype_xmlid': msg_vals.get('subtype_xmlid'), } sub_message_values.pop('model', None) sub_message_values.pop('res_id', None) sub_message_values.pop('attachment_ids', None) document.message_post(**sub_message_values) elif not self.attachment_id and not disable_mail_to_document: attachment = self.env['ir.attachment'].create({ 'name': msg_vals.get('subject') or msg_vals.get('email_from', _('email')), 'type': 'binary', 'raw': message.body, 'mimetype': 'application/documents-email', # Custom mimetype. Only for preview in Documents 'res_model': 'documents.document', }) document = self.env['documents.document'].create({ **self._message_post_after_hook_template_values(), 'attachment_id': attachment.id, }) message.res_id = document.id attachment.res_id = document.id documents = document # Activity settings set through alias_defaults values has precedence over the activity folder settings if documents: for document in documents: if self.create_activity_option: document.documents_set_activity(settings_record=self) elif self.folder_id.create_activity_option: document.documents_set_activity(settings_record=self.folder_id) return super()._message_post_after_hook(message, msg_vals) def _message_post_after_hook_template_values(self): """Values that will be taken from the document template.""" return { 'folder_id': self.folder_id.id, 'owner_id': self.folder_id.owner_id.id, 'partner_id': self.partner_id.id, 'tag_ids': self.tag_ids.ids, } def documents_set_activity(self, settings_record=None): """ Generate an activity based on the fields of settings_record. :param settings_record: the record that contains the activity fields. settings_record.create_activity_type_id (required) settings_record.create_activity_summary settings_record.create_activity_note settings_record.create_activity_date_deadline_range settings_record.create_activity_date_deadline_range_type settings_record.create_activity_user_id """ if settings_record and settings_record.create_activity_type_id: for record in self: activity_vals = { 'activity_type_id': settings_record.create_activity_type_id.id, 'summary': settings_record.create_activity_summary or '', 'note': settings_record.create_activity_note or '', } if settings_record.create_activity_date_deadline_range > 0: activity_vals['date_deadline'] = fields.Date.context_today(settings_record) + relativedelta( **{ settings_record.create_activity_date_deadline_range_type: settings_record.create_activity_date_deadline_range}) if settings_record._fields.get( 'create_has_owner_activity') and settings_record.create_has_owner_activity and record.owner_id: user = record.owner_id elif settings_record._fields.get('create_activity_user_id') and settings_record.create_activity_user_id: user = settings_record.create_activity_user_id elif settings_record._fields.get('user_id') and settings_record.user_id: user = settings_record.user_id else: user = self.env.user if user: activity_vals['user_id'] = user.id record.activity_schedule(**activity_vals) def copy_data(self, default=None): default = dict(default or {}) if 'user_folder_id' in default: self._clean_vals_for_user_folder_id(default) vals_list = super().copy_data(default=default) if 'name' not in default: for document, vals in zip(self, vals_list): vals['name'] = document.name if self.env.context.get('documents_copy_skip_rename') else _("%s (copy)", document.name) for vals in vals_list: # Avoid to propagate folder access as we want to copy the document accesses alone vals['access_ids'] = default.get('access_ids', False) if 'owner_id' not in vals: vals['owner_id'] = self.env.user.id return vals_list def copy(self, default=None): if not self: return self if not all(self.mapped('active')): raise UserError(_('You cannot duplicate document(s) in the Trash.')) if default and default.get('user_folder_id') == 'MY': default['owner_id'] = self.env.user.id # As we avoid to propagate the folder permission by setting access_ids to False (see copy_data), user has no # right to create the document. So after checking permission, we execute the copy in sudo. self.env['documents.document'].check_access('create') self.check_access('read') documents_order = {doc.id: idx for idx, doc in enumerate(self)} new_documents = [self.browse()] * len(self) is_manager = self.env.is_admin() or self.env.user.has_group('documents.group_documents_manager') skip_documents = self.env.context.get('documents_copy_folders_only') shortcuts = self.filtered('shortcut_document_id') if shortcuts and not skip_documents: for destination, targets in self._get_copy_shortcuts_destinations(shortcuts, default): new_shortcuts = targets.action_create_shortcut(location_user_folder_id=destination) for new_shortcut, target in zip(new_shortcuts, targets): new_shortcut.name = _("%s (copy)", target.name) new_documents[documents_order[target.id]] = new_shortcut folders = (self - shortcuts).filtered(lambda d: d.type == 'folder') if folders: if not is_manager and default and default.get('user_folder_id') == 'COMPANY': raise AccessError(_('Only Documents Managers can create in company folder.')) embedded_actions = self._get_folder_embedded_actions(folders.ids) new_folders = folders.sudo()._copy_with_access(default=default).sudo(False) # move in "My Drive" if needed self.browse([ new_folder.id for old_folder, new_folder in zip(folders, new_folders) if old_folder._cannot_create_sibling() ]).sudo().write({'folder_id': False}) for old_folder, new_folder in zip(folders, new_folders): if folder_embedded_actions := embedded_actions.get(old_folder.id): embedded_actions_copies = folder_embedded_actions.copy() embedded_actions_copies.parent_res_id = new_folder.id # no need to check for permission as all the checks have been done children_default = {'folder_id': new_folder.id} owner_id_in_default = (default or {}).get('owner_id') is not None if owner_id_in_default: children_default.update(owner_id=default['owner_id']) # check if we are not copying a folder into itself or one of its descendants if new_folder.parent_path.startswith(old_folder.parent_path): raise UserError(_("You cannot copy a folder into itself or into one of its own descendants.")) old_folder.children_ids.with_context(documents_copy_skip_rename=True).copy(children_default) new_documents[documents_order[old_folder.id]] = new_folder if is_manager and old_folder._is_company_root_folder() and not owner_id_in_default: new_folder.owner_id = old_folder.owner_id if not skip_documents and (documents_sudo := (self - shortcuts - folders).sudo()): new_binaries_sudo = documents_sudo._copy_with_access(default=default) for old_document_sudo, new_binary_sudo in zip(documents_sudo, new_binaries_sudo): new_documents[documents_order[old_document_sudo.id]] = new_binary_sudo.sudo(False) if ( is_manager and 'owner_id' not in (default or {}) and (not old_document_sudo.owner_id and not old_document_sudo.folder_id) # company root ): new_binary_sudo.owner_id = False # move in "My Drive" if needed self.browse([ new_binary_sudo.id for new_binary_sudo in new_binaries_sudo if new_binary_sudo.sudo(self.env.su)._cannot_create_sibling() ]).sudo().write({'folder_id': False}) if to_copy_attachment_sudo := documents_sudo._copy_attachment_filter(default): new_attachments_iterator = iter(to_copy_attachment_sudo.attachment_id.with_context(no_document=True).copy()) # Avoid recompute based on attachment_id with self.env.protecting(self._get_fields_to_recompute(depends=['attachment_id']), new_binaries_sudo): for old_document_sudo, new_binary_sudo in zip(documents_sudo, new_binaries_sudo): if old_document_sudo._copy_attachment_filter(default): new_attachment = next(new_attachments_iterator) new_binary_sudo.write({ 'attachment_id': new_attachment.id, 'res_id': False, 'res_model': False, }) return self.browse([new_document.id for new_document in new_documents]) def _cannot_create_sibling(self): """Return whether the user is not allowed to create in the same folder, used for copy.""" self.ensure_one() if self.env.su: return False if self.folder_id: # do not check edit access rule, to allow copying in root company folders return self.folder_id.user_permission != 'edit' return ( # allow the manager to copy root folder without moving them to his drive not self.env.user.has_group('documents.group_documents_manager') # anyone can copy in one's drive and self.owner_id != self.env.user ) def _copy_attachment_filter(self, default): if default and 'attachment_id' in default: return self.env['documents.document'] return self.filtered('attachment_id') def _get_copy_shortcuts_destinations(self, shortcuts, default): """Integrate copy `default` and access rights to return valid destinations for shortcuts to copy.""" default = default or {} folder_id = default.get('folder_id') user_folder_id = default.get('user_folder_id') prefetch_ids = None candidates = {} if user_folder_id: if user_folder_id.isnumeric(): candidates[self.browse(int(user_folder_id))] = shortcuts else: return ((user_folder_id, shortcuts),) elif folder_id is not None: candidates[self.browse(folder_id)] = shortcuts else: candidates = shortcuts.grouped('folder_id') prefetch_ids = shortcuts.folder_id.ids targets_per_destination = defaultdict(self.browse) for destination, destination_shortcuts in candidates.items(): if isinstance(destination, str): pass elif not self.env.su and destination and destination.with_prefetch(prefetch_ids).user_permission != 'edit': destination = 'MY' else: destination = str(destination.id) targets_per_destination[destination] |= destination_shortcuts return targets_per_destination.items() @api.model def _get_fields_to_recompute(self, depends): """ Get copyable `compute stored` fields that need recomputation based on the provided dependencies. """ if not depends: return [] fields_to_recompute = set() fields_compute_stored = { field for field in self._fields.values() if field.copy and field.store and field.compute } for field_dependence in (self._fields[depend] for depend in depends): fields_dependent = set(self.pool.get_dependent_fields(field_dependence)) fields_to_recompute |= fields_compute_stored & fields_dependent return fields_to_recompute def _copy_with_access(self, default): """Copy documents with their access. !Assumes that access rights were checked before! """ if not self: return self res = super().copy(default=default) if default and 'access_ids' in default: return res access_vals_list = [] for doc, doc_copied in zip(self, res): owner_partner = doc_copied.owner_id.partner_id # already done at doc_copied creation doc_access_to_have = doc.access_ids.filtered('role') doc_access_to_create = doc_access_to_have.filtered( lambda a: a.partner_id not in doc_copied.access_ids.partner_id | owner_partner) access_vals_list += doc_access_to_create.copy_data(default={'document_id': doc_copied.id}) self.env['documents.access'].sudo().create(access_vals_list) return res def toggle_favorited(self): self.ensure_one() self.sudo().is_favorited = not self.is_favorited return self.is_favorited def toggle_favorited_multi(self): # TODO remove in master and directly modify toggle_favorited for record in self: record.toggle_favorited() def access_content(self): self.ensure_one() action = { 'type': "ir.actions.act_url", 'target': "new", } if self.url: action['url'] = self.url elif self.type == 'binary': action['url'] = f'/documents/content/{quote(self.access_token, safe="")}' return action def open_resource(self): self.ensure_one() if self.res_model and self.res_id: view_id = self.env[self.res_model].get_formview_id(self.res_id) return { 'res_id': self.res_id, 'res_model': self.res_model, 'type': 'ir.actions.act_window', 'views': [[view_id, 'form']], } def toggle_lock(self): """ sets a lock user, the lock user is the user who locks a file for themselves, preventing data replacement and archive (therefore deletion) for any user but himself. Any user with the edit permission can unlock the file. """ self.ensure_one() if self.lock_uid: self.lock_uid = False else: self.lock_uid = self.env.uid def action_archive(self): if not self: return to_archive_sudo = self.sudo().with_context(active_test=False).search([('id', 'child_of', self.ids)]) active_documents = to_archive_sudo.filtered(self._active_name).sudo(False) if not active_documents: return active_documents._raise_if_unauthorized_archive() active_documents._raise_if_used_folder() deletion_date = fields.Date.to_string(fields.Date.today() + relativedelta(days=self.get_deletion_delay())) log_message = _("This file has been sent to the trash and will be deleted forever on the %s", deletion_date) active_documents._message_log_batch(bodies={doc.id: log_message for doc in active_documents}) return super(DocumentsDocument, active_documents).action_archive() def action_unarchive(self): self_archived = self.filtered(lambda d: not d.active) if not self_archived: return archived_top_parent_documents = self.env["documents.document"].sudo().search( Domain.AND(( Domain('id', 'parent_of', self_archived.ids), Domain('id', 'not in', self_archived.ids), Domain('active', '=', False), Domain('folder_id', '=', False) | Domain('folder_id.active', '=', True), )) ).sudo(False) if archived_top_parent_documents: raise UserError(_( "Item(s) you wish to restore are included in archived folders. " "To restore these items, you must restore the following including folders instead:\n" "- %(folders_list)s", folders_list="\n-".join(archived_top_parent_documents.mapped('name')))) # "Restricted" if not allowed # Leave archived children (and descendants) the current user doesn't have access to. to_unarchive_candidate_documents = self.env['documents.document'].with_context(active_test=False).search( [('id', 'child_of', self_archived.ids)]) seen_documents, to_unarchive_ids = set(), set() def add_if_can_be_restored(doc): if doc in seen_documents or seen_documents.add(doc): return doc.id in to_unarchive_ids if not doc.folder_id or doc.folder_id.sudo().active or add_if_can_be_restored(doc.folder_id): to_unarchive_ids.add(doc.id) return True return False for document in to_unarchive_candidate_documents: add_if_can_be_restored(document) to_unarchive_documents = to_unarchive_candidate_documents.filtered(lambda d: d.id in to_unarchive_ids) log_message = _("This document has been restored.") to_unarchive_documents._message_log_batch(bodies={doc.id: log_message for doc in to_unarchive_documents}) return super(DocumentsDocument, to_unarchive_documents).action_unarchive() @api.model_create_multi def create(self, vals_list): """Access rights fields (access_ids), access_internal, and access_via_link are inherited from containing folder unless specified in vals or context defaults. """ attachments = [] for vals in vals_list: keys = [key for key in vals if self._fields[key].related and self._fields[key].related.split('.')[0] == 'attachment_id'] attachment_dict = {key: vals.pop(key) for key in keys if key in vals} attachment = self.env['ir.attachment'].browse(vals.get('attachment_id')) self._clean_vals_for_user_folder_id(vals) if attachment and attachment_dict: attachment.write(attachment_dict) elif attachment_dict: attachment_dict.setdefault('name', vals.get('name', 'unnamed')) # default_res_model and default_res_id will cause unique constraints to trigger. attachment = self.env['ir.attachment'].with_context(clean_context(self.env.context)).create(attachment_dict) vals['attachment_id'] = attachment.id vals['name'] = vals.get('name', attachment.name) attachments.append(attachment) # don't allow using default_access_ids documents = super(DocumentsDocument, self.with_context(default_access_ids=None)).create(vals_list) is_manager = self.env.is_admin() or self.env.user.has_group('documents.group_documents_manager') if not is_manager: if any(d.alias_name for d in documents): raise AccessError(_('Only Documents Managers can set aliases.')) if any(d._is_company_root_folder() for d in documents): raise AccessError(_('Only Documents Managers can create in company folder.')) for document, attachment in zip(documents, attachments): if attachment and not attachment.res_id and ( not attachment.res_model or attachment.res_model == 'documents.document'): attachment.with_context(no_document=True).write({ 'res_model': 'documents.document', 'res_id': document.id, }) return documents def _prepare_create_values(self, vals_list): old_vals_list = [vals.copy() for vals in vals_list] vals_list = super()._prepare_create_values(vals_list) folders = self.env['documents.document'].browse(v['folder_id'] for v in vals_list if v.get('folder_id')) users = self.env['res.users'].browse(v['owner_id'] for v in vals_list if v.get('owner_id')) folders.fetch(('access_internal', 'access_via_link', 'access_ids', 'active', 'company_id', 'owner_id')) (users | folders.owner_id).fetch(['partner_id']) vals_list_to_update_linked_record = [] for vals, old_vals in zip(vals_list, old_vals_list): owner = self.env['res.users'].browse(vals.get('owner_id', self.env.user.active and self.env.user.id)) if owner and not owner.active: _logger.warning( "Documents: Creating document(s) as %s" % ( "superuser" if owner.id == SUPERUSER_ID else f"archived user (id={owner.id})"), ) owner = self.env['res.users'] vals['owner_id'] = False vals_values = {'owner_id': owner.id} if vals.get('shortcut_document_id'): self.browse(vals.get('shortcut_document_id')).check_access('read') folder = self.env['documents.document'].browse(vals.get('folder_id', False)) if folder: if not folder.active: raise UserError(self.env._('It is not possible to create documents in an archived folder.')) vals_values.update({ 'access_via_link': folder.access_via_link, 'access_internal': folder.access_internal, }) if folder.company_id: vals_values['company_id'] = folder.company_id.id vals.update((k, v) for k, v in vals_values.items() if k not in old_vals) # Add folder-inherited members without overriding provided values if ('shortcut_document_id' not in old_vals and old_vals.get('access_ids') not in (False, Command.set([]), []) and folder and (inherited_access_ids := folder._get_inherited_access_ids_vals())): vals_access_ids_to_check = vals['access_ids'] if old_vals.get('access_ids') else [] partner_ids = [val[2]['partner_id'] for val in vals_access_ids_to_check] access_vals_to_add = [v for v in inherited_access_ids if v['partner_id'] not in partner_ids] vals['access_ids'] += [Command.create(access_vals) for access_vals in access_vals_to_add] # Ensure owner logged access if owner: vals['access_ids'] = vals['access_ids'] or [] for values in vals['access_ids']: if values and values[2] and values[2]['partner_id'] == owner.partner_id.id: values[2]['last_access_date'] = fields.Datetime.now() break else: vals['access_ids'] += [ Command.create({'partner_id': owner.partner_id.id, 'last_access_date': fields.Datetime.now()}) ] # If res_model and res_id are not set, we must get it from the related attachment if set (prepare list) if 'res_model' not in vals and 'res_id' not in vals and isinstance(vals.get('attachment_id'), int): vals_list_to_update_linked_record.append(vals) # For the next step, we need to ensure the related ref is present by getting it from attachment if needed if vals_list_to_update_linked_record: attachment_by_id = self.env['ir.attachment'].browse( [vals['attachment_id'] for vals in vals_list_to_update_linked_record]).grouped('id') for vals in vals_list_to_update_linked_record: attachment = attachment_by_id[vals['attachment_id']] vals['res_model'] = False if attachment.res_model == 'documents.document' else attachment.res_model vals['res_id'] = False if attachment.res_model == 'documents.document' else attachment.res_id # Delegate vals_list update to _prepare_create_values_for_model to add values depending on related record updated_vals_list = [] for res_model, model_vals_tuple_list in groupby(zip(vals_list, old_vals_list), lambda v: v[0].get('res_model')): updated_vals_list += self._prepare_create_values_for_model( res_model, [vals_tuple[0] for vals_tuple in model_vals_tuple_list], [vals_tuple[1] for vals_tuple in model_vals_tuple_list], ) return updated_vals_list def _get_inherited_access_ids_vals(self): """Get access values to create when creating a document inside a folder (self). :rtype: list[dict] :return: vals_list for folder child `access_ids`. """ self.ensure_one() vals = [ {'partner_id': access.partner_id.id, 'role': access.role, 'expiration_date': access.expiration_date} for access in self.access_ids.filtered('role') if access.partner_id != self.owner_id.partner_id ] if self.owner_id: vals += [{'partner_id': self.owner_id.partner_id.id, 'role': 'edit'}] return vals def _prepare_create_values_for_model(self, res_model, vals_list, pre_vals_list): """Override to add values depending on related model/record""" if ( res_model and issubclass(self.pool[res_model], self.pool['documents.mixin']) and not self.env.context.get('no_document') ): return self.env[res_model]._prepare_document_create_values_for_linked_records( res_model, vals_list, pre_vals_list) return vals_list def write(self, vals): if 'shortcut_document_id' in vals: raise UserError(_("Shortcuts cannot change target document.")) self._clean_vals_for_user_folder_id(vals) is_manager = self.env.is_admin() or self.env.user.has_group('documents.group_documents_manager') pinned_folders_start = self.filtered(lambda d: d._is_company_root_folder()) previous_owner_access_to_keep = {} documents_per_initial_active = {} if (owner_id := vals.get('owner_id')) is not None: can_change_owner = is_manager or all( (not d.owner_id and d.user_can_move) or d.owner_id == self.env.user for d in self) if not can_change_owner: raise AccessError(_("You cannot change the owner of documents you do not own.")) if not isinstance(owner_id, int | bool | None): owner_id = owner_id.id documents_changing_owner = self.filtered(lambda d: d.owner_id and d.owner_id.id != owner_id) previous_owner_access_to_keep.update(documents_changing_owner.grouped('owner_id')) new_parent_folder = self.browse() documents_to_move, documents_to_move_per_initial_folder = self.browse(), self.browse() if folder_id := vals.get('folder_id'): new_parent_folder = self.browse(folder_id) if new_parent_folder.type != 'folder': raise UserError(_("Invalid folder id")) documents_to_move = self.filtered(lambda d: d.folder_id != new_parent_folder) if documents_to_move and not new_parent_folder.active: raise UserError(_("It is not possible to move documents into archived folders.")) if documents_to_move and not self.env.su: if new_parent_folder.user_permission != 'edit': raise AccessError(_("You can't access that folder_id.")) for doc in documents_to_move: if doc.user_permission != 'edit': raise AccessError(_("You are not allowed to move (some of) these documents.")) if not doc.user_can_move: raise AccessError(_("You can't move documents you do not own out of folders you cannot edit.")) if new_parent_folder.shortcut_document_id: return self.write(vals | {'folder_id': new_parent_folder.shortcut_document_id.id}) for doc in documents_to_move: to_active = vals.get('active') if ( not doc.active and not to_active or doc.folder_id and not doc.folder_id.active and (not to_active or doc.folder_id not in self) ): raise UserError(_("It is not possible to move archived documents.")) documents_to_move_per_initial_folder = documents_to_move.grouped('folder_id') if (to_active := vals.get('active')) is not None: if self.env.user.share and not self.env.su: raise UserError(_("You are not allowed to (un)archive documents.")) if not to_active: self.check_access('unlink') # As archived gc leads to unlink after `deletion_delay` days. documents_per_initial_active = self.grouped('active') attachment_id = vals.get('attachment_id') if attachment_id: self.ensure_one() attachments_was_present = [] for record in self: attachments_was_present.append(bool(record.attachment_id)) if record.type == 'binary' and ('datas' in vals or 'url' in vals) and not record.datas: body = _("Document Request: %(name)s Uploaded by: %(user)s", name=record.name, user=self.env.user.name) record.with_context(no_document=True).message_post(body=body) if record.attachment_id and record._has_versioning(): # versioning if attachment_id and attachment_id != record.attachment_id.id: # Link the new attachment to the related record and link the previous one # to the document. attachment = self.env["ir.attachment"].browse(attachment_id) if (attachment.res_model, attachment.res_id) != (record.res_model, record.res_id): attachment.with_context(no_document=True).write({ "res_model": record.res_model or "documents.document", "res_id": record.res_id if record.res_model else record.id, }) related_record = record.res_model and self.env[record.res_model].browse(record.res_id) if ( not hasattr(related_record, "message_main_attachment_id") or related_record.message_main_attachment_id != record.attachment_id ): record.attachment_id.with_context(no_document=True).write( {"res_model": "documents.document", "res_id": record.id} ) if attachment_id in record.previous_attachment_ids.ids: record.previous_attachment_ids = [(3, attachment_id, False)] record.previous_attachment_ids = [(4, record.attachment_id.id, False)] if 'datas' in vals: old_attachment = record.attachment_id.with_context(no_document=True).copy() # removes the link between the old attachment and the record. old_attachment.write({ 'res_model': 'documents.document', 'res_id': record.id, }) record.previous_attachment_ids = [(4, old_attachment.id, False)] elif not record.attachment_id and vals.get('datas') and not vals.get('attachment_id'): res_model = vals.get('res_model', record.res_model) res_id = vals.get('res_id', record.res_id) if res_model and not self.env[res_model].browse(res_id).exists(): record.res_model = False record.res_id = False attachment = self.env['ir.attachment'].with_context(no_document=True).create({ 'name': vals.get('name', record.name), 'res_model': record.res_model or 'documents.document', 'res_id': record.res_id if record.res_model else record.id, }) record.attachment_id = attachment.id # pops the datas and/or the mimetype key(s) to explicitly write them in batch on the ir.attachment # so the mimetype is properly set. The reason was because the related keys are not written in batch # and because mimetype is readonly on `ir.attachment` (which prevents writing through the related). attachment_dict = {key: vals.pop(key) for key in ['datas', 'mimetype'] if key in vals} if not is_manager and set(vals) & set(self.env['mail.alias.mixin']._fields): raise AccessError(_('Only Documents managers can set an alias.')) write_result = super().write(vals) if attachment_dict: self.attachment_id.write(attachment_dict) if 'attachment_id' in vals: self.attachment_id.check_access('read') if (new_active := vals.get('active')) is not None: if not new_active: if self.sudo().search([('id', 'child_of', self.ids), ('active', '=', True)]): raise UserError(_('Operation not supported. Please use "Move to Trash" / `action_archive` instead.')) if archived_documents := documents_per_initial_active.get(True): # Log moved to trash instead of "archived" for folder, children in archived_documents.filtered('folder_id').grouped('folder_id').items(): folder.sudo(self.env.user in children.owner_id).message_post( body=_('The following documents have been sent to trash: %(documents)s.', documents=', '.join(children.mapped('display_name'))) ) elif new_active: if self.sudo().search([('id', 'parent_of', self.ids), ('active', '=', False)]): raise UserError(_('Operation not supported. Please use "Restore" / `action_unarchive` instead.')) if restored_documents := documents_per_initial_active.get(False): # Log restored instead of "unarchived" for folder, children in restored_documents.filtered('folder_id').grouped('folder_id').items(): folder.sudo(self.env.user in children.owner_id).message_post( body=_('The following documents have been restored from the trash: %(documents)s.', documents=', '.join(children.mapped('display_name'))) ) if not is_manager and self.filtered(lambda d: d._is_company_root_folder()) != pinned_folders_start: raise AccessError(_("Only Documents Managers can create in company folder.")) for document, attachment_was_present in zip(self, attachments_was_present): if document.request_activity_id and document.attachment_id and not attachment_was_present: feedback = _("Document Request: %(name)s Uploaded by: %(user)s", name=self.name, user=self.env.user.name) document.with_context(no_document=True).request_activity_id.action_feedback( feedback=feedback, attachment_ids=[document.attachment_id.id]) if ((company_id := vals.get('company_id')) is not None) and self.shortcut_ids | self.children_ids: self._update_company(company_id) # Ensure edit role for previous owners self._ensure_user_role_without_propagation('edit', previous_owner_access_to_keep) if new_parent_folder and (documents_to_sync := documents_to_move.filtered(lambda d: not d.shortcut_document_id)): documents_to_sync.action_update_access_rights( access_internal=new_parent_folder.access_internal, access_via_link=new_parent_folder.access_via_link, # Simply add partners of destination partners={access.partner_id: (access.role, access.expiration_date) for access in new_parent_folder.access_ids if access.role}, ) # Propagate folder company unless passed as well (already done) if 'company_id' not in vals: documents_to_sync._update_company(new_parent_folder.company_id.id) if new_parent_folder and documents_to_move: for folder, documents in documents_to_move.grouped('folder_id').items(): folder.message_post_with_source( source_ref='documents.folder_notification_move_in', render_values={"documents": documents}, ) for folder, documents in documents_to_move_per_initial_folder.items(): folder.message_post_with_source( source_ref='documents.folder_notification_move_out', render_values={"documents": documents}, ) return write_result @api.onchange("attachment_id") def _onchange_attachment_id(self): self.attachment_id.check_access('read') def _has_versioning(self): self.ensure_one() return True @api.model def _pdf_split(self, new_files=None, open_files=None, vals=None): vals = vals or {} new_attachments = self.env['ir.attachment']._pdf_split(new_files=new_files, open_files=open_files) new_documents = self.create([ dict(vals, attachment_id=attachment.id) for attachment in new_attachments ]) # Prevent concurrent update error on accessing these documents for the first time on exiting the split tool env_partner = self.env.user.partner_id documents_not_member = new_documents.filtered(lambda d: env_partner not in d.access_ids.partner_id) self.env['documents.access'].sudo().create([ {'document_id': doc.id, 'partner_id': env_partner.id, 'last_access_date': fields.Datetime.now()} for doc in documents_not_member ]) return new_documents @api.model def search_panel_select_range(self, field_name, **kwargs): def convert_user_folder_ids_to_int(vals): """Convert user_folder_id to int where applicable to construct categoryTree matching on id of parent.""" if (user_folder_id := vals['user_folder_id']) and user_folder_id.isnumeric(): vals['user_folder_id'] = int(user_folder_id) if field_name == 'user_folder_id': enable_counters = kwargs.get('enable_counters', False) search_panel_fields = self._get_search_panel_fields() domain = Domain('type', '=', 'folder') if unique_folder_id := self.env.context.get('documents_unique_folder_id'): if access_token := self.env.context.get('documents_shared_access_token'): # Enable non-public access via link through a knowledge embedded view without extra rpc call. self._search_panel_access_via_link(unique_folder_id, access_token) values = self.env['documents.document'].search_read( domain & Domain('folder_id', 'child_of', unique_folder_id), search_panel_fields, ) for record in values: convert_user_folder_ids_to_int(record) if record['id'] == unique_folder_id: record['user_folder_id'] = False # Set as root return { 'parent_field': 'user_folder_id', 'values': values, } records = self.env['documents.document'].search_read(domain, search_panel_fields) alias_tag_data = {} if not self.env.user.share: alias_tag_ids = {alias_tag_id for rec in records for alias_tag_id in rec['alias_tag_ids']} alias_tag_data = { alias_tag['id']: { 'id': alias_tag.id, 'color': alias_tag.color, 'display_name': alias_tag.display_name } for alias_tag in self.env['documents.tag'].browse(alias_tag_ids) } domain_image = {} if enable_counters: model_domain = Domain.AND([ kwargs.get('search_domain', []), kwargs.get('category_domain', []), kwargs.get('filter_domain', []), Domain(field_name, '!=', False), ]) domain_image = self._search_panel_domain_image(field_name, model_domain, enable_counters) # Read the targets in batch targets = self.browse(r['shortcut_document_id'][0] for r in records if r['shortcut_document_id']) targets_data = targets.grouped('id') values_range = OrderedDict() for record in records: record_id = record['id'] convert_user_folder_ids_to_int(record) if not self.env.user.share: record['alias_tag_ids'] = [alias_tag_data[tag_id] for tag_id in record['alias_tag_ids']] if enable_counters: image_element = domain_image.get(record_id) record['__count'] = image_element['__count'] if image_element else 0 if record['shortcut_document_id']: target = targets_data[record['shortcut_document_id'][0]] record['target_user_permission'] = target.user_permission record['target_access_token'] = target.access_token values_range[record_id] = record if enable_counters: self._search_panel_global_counters(values_range, 'user_folder_id') special_roots = [] if not self.env.user.share: special_roots = [ {'bold': True, 'childrenIds': [], 'parentId': False, 'user_permission': 'edit'} | values for values in [ { 'display_name': _("Company"), 'id': 'COMPANY', 'description': _("Common roots for all company users."), 'user_permission': 'edit' if self.env.user.has_group('documents.group_documents_manager') else 'view', }, { 'display_name': _("My Drive"), 'id': 'MY', 'user_permission': 'edit', 'description': _("Your individual space."), }, { 'display_name': _("Shared with me"), 'id': 'SHARED', 'description': _("Additional documents you have access to."), }, { 'display_name': _("Recent"), 'id': 'RECENT', 'description': _("Recently accessed documents."), }] ] if not self.env.context.get('documents_search_panel_no_trash'): special_roots.append({ 'display_name': _("Trash"), 'id': 'TRASH', 'description': _("Items in trash will be deleted forever after %s days.", self.get_deletion_delay()), }) return { 'parent_field': 'user_folder_id', 'values': list(values_range.values()) + special_roots, } return super().search_panel_select_range(field_name) @api.model def _search_panel_access_via_link(self, folder_id, access_token): """Log access if necessary to access the record and it is accessible via link. In this case, both folder_id and access_token are necessary and must agree, and access is only logged if access_via_link is not "none". Notably useful for knowledge embedded views. """ folder_sudo = self.browse(folder_id).sudo() if ( folder_sudo.user_permission == "none" and folder_sudo.access_via_link != "none" and consteq(folder_sudo.access_token, access_token) ): self.env['documents.access'].sudo().create({ 'document_id': folder_sudo.id, 'partner_id': self.env.user.partner_id.id, 'last_access_date': fields.Datetime.now(), }) @api.readonly @api.model def get_document_max_upload_limit(self): ICP = self.env['ir.config_parameter'].sudo() for key in ('document.max_fileupload_size', 'web.max_file_upload_size'): value = ICP.get_param(key, default=None) if value is None: continue try: return int(value) or None except ValueError: _logger.error("invalid %s: %r", key, value) return odoo.http.DEFAULT_MAX_CONTENT_LENGTH @api.readonly @api.model def get_details_panel_res_models(self): """Return the list of models that a document can be linked to via the details panel. :rtype: list[str] """ functional_models = [ "account.move", "fleet.vehicle", "hr.expense", "hr.leave", "product.product", "project.project", "project.task", "purchase.order", "sale.order", ] return [ model for model in functional_models if (res_model := self.env.get(model)) is not None and res_model.has_access('read') ] @api.model def _get_traceback_folder_sudo(self): folder_id = self.env["ir.config_parameter"].sudo().get_param('documents.support_folder', False) folder_sudo = self.env["documents.document"].sudo().browse(int(folder_id)) if not folder_sudo or not folder_sudo.exists(): folder_sudo = self.env["documents.document"].sudo().create({ 'name': self.env._('Support'), 'type': 'folder', 'access_internal': 'none', 'access_via_link': 'none' }) self.env["ir.config_parameter"].sudo().set_param('documents.support_folder', folder_sudo.id) return folder_sudo def unlink(self): """Clean unused linked records too. This applies to: * Children documents when deleting the parent folder * Parent folder if it is archived and has no other children and user is allowed to * Attachment if document-related record is deleted """ to_delete = self.sudo().with_context(active_test=False).search([('id', 'child_of', self.ids)]).sudo(False) removable_parent_folders = self.with_context(active_test=False).folder_id.filtered( lambda folder: len(folder.children_ids - self) == 0 and not folder.active and folder.id not in self.ids) removable_attachments = self.attachment_id.filtered(lambda a: a.res_model != 'documents.document') res = super(DocumentsDocument, to_delete).unlink() if removable_attachments: removable_attachments.unlink() if removable_parent_folders: with contextlib.suppress(AccessError): removable_parent_folders.unlink() return res def _check_access(self, operation: str): """Prevent unlink when not the owner and no edit permission on the containing folder.""" res = super()._check_access(operation) if self.env.su or operation != 'unlink': return res forbidden_documents = res[0] if res else self.browse() can_unlink_map = self._get_user_can_unlink() forbidden_documents |= self.filtered(lambda d: not can_unlink_map[d]) if forbidden_documents: # Hide potentially unknown inaccessible content's name. message = _("You do not have sufficient access rights to delete these documents.") return forbidden_documents, lambda: AccessError(message) return res @api.ondelete(at_uninstall=False) def _unlink_except_unauthorized(self): """deprecated: legacy hook as the logic is now included in `_check_access`. todo: remove in master.""" self._raise_if_unauthorized_archive() @api.ondelete(at_uninstall=False) def _unlink_except_company_folders(self): self._raise_if_used_folder() def _raise_if_used_folder(self): if folder_ids := self.filtered(lambda d: d.type == 'folder').ids: company_used_folders_domain = self.env['res.company']._get_used_folder_ids_domain(folder_ids) if self.env['res.company'].sudo().search_count(company_used_folders_domain, limit=1): raise ValidationError(_("Impossible to delete folders used by other applications.")) def _raise_if_unauthorized_archive(self): """deprecated: legacy hook as the logic is now included in `_check_access`. todo: remove in master.""" @api.autovacuum def _gc_clear_bin(self): """Files are deleted automatically from the trash bin after the configured remaining days.""" self.search(self._get_gc_clear_bin_domain(), limit=1000).unlink() @api.model def _get_gc_clear_bin_domain(self): deletion_delay = self.get_deletion_delay() return [ ('active', '=', False), ('write_date', '<=', fields.Datetime.now() - relativedelta(days=deletion_delay)), ] @api.model def _get_search_panel_fields(self): """Return the list of fields used by the search panel.""" search_panel_fields = ['access_internal', 'access_token', 'access_via_link', 'active', 'company_id', 'description', 'display_name', 'user_folder_id', 'is_access_via_link_hidden', 'is_favorited', 'mail_alias_domain_count', 'owner_id', 'shortcut_document_id', 'user_permission'] if not self.env.user.share: search_panel_fields += ['alias_domain_id', 'alias_name', 'alias_tag_ids', 'create_activity_type_id', 'create_activity_user_id', 'partner_id'] return search_panel_fields def _get_access_action(self, access_uid=None, force_website=False): self.ensure_one() if access_uid and not force_website and self.active and self.env.user.has_group("documents.group_documents_user"): url_params = url_encode({ 'documents_init_document_id': self.id, 'view_id': self.env.ref("documents.document_view_kanban").id, 'menu_id': self.env.ref("documents.menu_root").id, 'folder_id': self.folder_id.id, }) return { "type": "ir.actions.act_url", "url": f"/odoo/action-documents.document_action?{url_params}" } return super()._get_access_action(access_uid=access_uid, force_website=force_website) @api.model def _data_embed_if_records_exist(self, folder_xmlid, server_action_xmlid): if ( (action := self.env.ref(server_action_xmlid, raise_if_not_found=False)) and (folder := self.env.ref(folder_xmlid, raise_if_not_found=False)) ): self.action_folder_embed_action(folder.id, action.id) def add_documents_attachment(self, res_model, res_id, is_public=False): """Unified method to create document attachments with optional plugin handling :param str res_model: model name to attach the document to :param int res_id: record ID to attach the document to :param bool is_public: specify attachment can publicly accessible :return: Data of newly created attachments :rtype: list[dict] """ # Build attachment data with conditional plugin parameters new_attachments = self.env['ir.attachment'] for attachment in self.attachment_id: copied = attachment.copy({ "res_model": res_model, "res_id": res_id, "res_field": False, "public": is_public, "original_id": attachment.id, }) new_attachments |= copied # Generate access tokens if needed if is_public: for attachment in new_attachments: attachment.generate_access_token() return [attachment._get_media_info() for attachment in new_attachments]