2844 lines
140 KiB
Python
2844 lines
140 KiB
Python
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', '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.raw', 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 <documents.access>
|
|
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'<!DOCTYPE html>' 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]
|