forked from Mapan/odoo17e
756 lines
35 KiB
Python
756 lines
35 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
import base64
|
|
import io
|
|
import logging
|
|
import re
|
|
from ast import literal_eval
|
|
from collections import OrderedDict
|
|
|
|
import requests
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import AccessError, UserError, ValidationError
|
|
from odoo.osv import expression
|
|
from odoo.tools import image_process, create_index
|
|
from odoo.tools.mimetypes import get_extension
|
|
from odoo.tools.misc import clean_context
|
|
from odoo.tools.pdf import PdfFileReader
|
|
from odoo.addons.mail.tools import link_preview
|
|
|
|
from .documents_facet import N_FACET_COLORS
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _sanitize_file_extension(extension):
|
|
""" Remove leading and trailing spacing + Remove leading "." """
|
|
return re.sub(r'^[\s.]+|\s+$', '', extension)
|
|
|
|
|
|
class Document(models.Model):
|
|
_name = 'documents.document'
|
|
_description = 'Document'
|
|
_inherit = ['mail.thread.cc', 'mail.activity.mixin']
|
|
_order = 'id desc'
|
|
_systray_view = 'activity'
|
|
|
|
# Attachment
|
|
attachment_id = fields.Many2one('ir.attachment', ondelete='cascade', auto_join=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)
|
|
datas = fields.Binary(related='attachment_id.datas', related_sudo=True, readonly=False, prefetch=False)
|
|
raw = fields.Binary(related='attachment_id.raw', related_sudo=True, readonly=False, prefetch=False)
|
|
file_extension = fields.Char('File Extension', copy=True, store=True, readonly=False,
|
|
compute='_compute_file_extension', inverse='_inverse_file_extension')
|
|
file_size = fields.Integer(related='attachment_id.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", inverse="_inverse_res_model", store=True)
|
|
res_id = fields.Many2oneReference('Resource ID', compute="_compute_res_record", inverse="_inverse_res_model", 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")
|
|
|
|
# Document
|
|
name = fields.Char('Name', copy=True, store=True, compute='_compute_name_and_preview', inverse='_inverse_name')
|
|
active = fields.Boolean(default=True, string="Active")
|
|
thumbnail = fields.Binary(readonly=False, store=True, attachment=True, compute='_compute_thumbnail')
|
|
thumbnail_status = fields.Selection([
|
|
('present', 'Present'), # Document has a thumbnail
|
|
('error', 'Error'), # Error when generating the thumbnail
|
|
], compute="_compute_thumbnail_status", store=True, readonly=False,
|
|
)
|
|
url = fields.Char('URL', index=True, size=1024, tracking=True)
|
|
url_preview_image = fields.Char('URL Preview Image', store=True, compute='_compute_name_and_preview')
|
|
res_model_name = fields.Char(compute='_compute_res_model_name', index=True)
|
|
type = fields.Selection([('url', 'URL'), ('binary', 'File'), ('empty', 'Request')],
|
|
string='Type', required=True, store=True, default='empty', change_default=True,
|
|
compute='_compute_type')
|
|
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)
|
|
owner_id = fields.Many2one('res.users', default=lambda self: self.env.user.id, string="Owner",
|
|
tracking=True)
|
|
available_rule_ids = fields.Many2many('documents.workflow.rule', compute='_compute_available_rules',
|
|
string='Available Rules')
|
|
lock_uid = fields.Many2one('res.users', string="Locked by")
|
|
is_locked = fields.Boolean(compute="_compute_is_locked", string="Locked")
|
|
create_share_id = fields.Many2one('documents.share', help='Share used to create this document')
|
|
request_activity_id = fields.Many2one('mail.activity')
|
|
|
|
# Folder
|
|
folder_id = fields.Many2one('documents.folder',
|
|
string="Workspace",
|
|
ondelete="restrict",
|
|
tracking=True,
|
|
required=True,
|
|
index=True)
|
|
company_id = fields.Many2one('res.company', string='Company', related='folder_id.company_id', readonly=True)
|
|
group_ids = fields.Many2many('res.groups', string="Access Groups", readonly=True,
|
|
help="This attachment will only be available for the selected user groups",
|
|
related='folder_id.group_ids')
|
|
|
|
_sql_constraints = [
|
|
('attachment_unique', 'unique (attachment_id)', "This attachment is already a document"),
|
|
]
|
|
|
|
def init(self):
|
|
super().init()
|
|
create_index(self.env.cr,
|
|
indexname='documents_document_res_model_res_id_idx',
|
|
tablename=self._table,
|
|
expressions=['res_model', 'res_id'])
|
|
|
|
@api.depends('name', 'type')
|
|
def _compute_file_extension(self):
|
|
for record in self:
|
|
if record.type != 'binary':
|
|
record.file_extension = False
|
|
elif record.name:
|
|
record.file_extension = _sanitize_file_extension(get_extension(record.name.strip())) or False
|
|
|
|
def _inverse_file_extension(self):
|
|
for record in self:
|
|
record.file_extension = _sanitize_file_extension(record.file_extension) if record.file_extension else False
|
|
|
|
@api.depends('attachment_id', 'attachment_id.name', 'url')
|
|
def _compute_name_and_preview(self):
|
|
request_session = requests.Session()
|
|
for record in self:
|
|
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']
|
|
|
|
def _inverse_name(self):
|
|
for record in self:
|
|
if record.attachment_id:
|
|
record.attachment_name = record.name
|
|
|
|
@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')
|
|
def _compute_res_record(self):
|
|
for record in self:
|
|
attachment = record.attachment_id
|
|
if attachment:
|
|
record.res_model = attachment.res_model
|
|
record.res_id = attachment.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:
|
|
record.res_name = self.env[record.res_model].browse(record.res_id).display_name
|
|
else:
|
|
record.res_name = False
|
|
|
|
def _inverse_res_model(self):
|
|
for record in self:
|
|
attachment = record.attachment_id.with_context(no_document=True)
|
|
if attachment:
|
|
# 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': record.res_model, 'res_id': record.res_id})
|
|
|
|
@api.depends('checksum')
|
|
def _compute_thumbnail(self):
|
|
for record in self:
|
|
if record.mimetype == 'application/pdf':
|
|
# Thumbnails of pdfs are generated by the client. To force the generation, we invalidate the thumbnail.
|
|
record.thumbnail = False
|
|
else:
|
|
try:
|
|
record.thumbnail = base64.b64encode(image_process(record.raw, size=(200, 140), crop='center'))
|
|
except (UserError, TypeError):
|
|
record.thumbnail = False
|
|
|
|
@api.depends("thumbnail")
|
|
def _compute_thumbnail_status(self):
|
|
domain = [
|
|
('res_model', '=', self._name),
|
|
('res_field', '=', 'thumbnail'),
|
|
('res_id', 'in', self.ids),
|
|
]
|
|
documents_with_thumbnail = set(res['res_id'] for res in self.env['ir.attachment'].sudo().search_read(domain, ['res_id']))
|
|
for document in self:
|
|
if document.mimetype == 'application/pdf':
|
|
# As the thumbnail invalidation is not propagated to the status, we invalid it as well.
|
|
document.thumbnail_status = False
|
|
else:
|
|
document.thumbnail_status = document.id in documents_with_thumbnail and 'present'
|
|
|
|
@api.depends('attachment_type', 'url')
|
|
def _compute_type(self):
|
|
for record in self:
|
|
record.type = 'empty'
|
|
if record.attachment_id:
|
|
record.type = 'binary'
|
|
elif record.url:
|
|
record.type = 'url'
|
|
|
|
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, `False` if single page or error occurred, `True` otherwise.
|
|
:rtype: bool | None
|
|
"""
|
|
if self.mimetype not in ('application/pdf', 'application/pdf;base64'):
|
|
return None
|
|
stream = io.BytesIO(base64.b64decode(self.datas))
|
|
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
|
|
_logger.warning('Impossible to count pages in %r. It could be due to a malformed document or a '
|
|
'(possibly known) issue within PyPDF2.', self.name, exc_info=True)
|
|
return False
|
|
|
|
def _get_models(self, domain):
|
|
"""
|
|
Return the names of the models to which the attachments are attached.
|
|
|
|
:param domain: the domain of the _read_group on documents.
|
|
:return: a list of model data, the latter being a dict with the keys
|
|
'id' (technical name),
|
|
'name' (display name) and
|
|
'__count' (how many attachments with that domain).
|
|
"""
|
|
not_a_file = []
|
|
not_attached = []
|
|
models = []
|
|
groups = self._read_group(domain, ['res_model'], ['__count'])
|
|
for res_model, count in groups:
|
|
if not res_model:
|
|
not_a_file.append({
|
|
'id': res_model,
|
|
'display_name': _('Not a file'),
|
|
'__count': count,
|
|
})
|
|
elif res_model == 'documents.document':
|
|
not_attached.append({
|
|
'id': res_model,
|
|
'display_name': _('Not attached'),
|
|
'__count': count,
|
|
})
|
|
else:
|
|
models.append({
|
|
'id': res_model,
|
|
'display_name': self.env['ir.model']._get(res_model).display_name,
|
|
'__count': count,
|
|
})
|
|
return sorted(models, key=lambda m: m['display_name']) + not_attached + not_a_file
|
|
|
|
@api.depends('favorited_ids')
|
|
@api.depends_context('uid')
|
|
def _compute_is_favorited(self):
|
|
favorited = self.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.depends('folder_id')
|
|
def _compute_available_rules(self):
|
|
"""
|
|
loads the rules that can be applied to the attachment.
|
|
|
|
"""
|
|
self.available_rule_ids = False
|
|
folder_ids = self.mapped('folder_id.id')
|
|
rule_domain = [('domain_folder_id', 'parent_of', folder_ids)] if folder_ids else []
|
|
# searching rules with sudo as rules are inherited from parent folders and should be available even
|
|
# when they come from a restricted folder.
|
|
rules = self.env['documents.workflow.rule'].sudo().search(rule_domain)
|
|
for rule in rules:
|
|
domain = []
|
|
if rule.condition_type == 'domain':
|
|
domain = literal_eval(rule.domain) if rule.domain else []
|
|
else:
|
|
if rule.criteria_partner_id:
|
|
domain = expression.AND([[['partner_id', '=', rule.criteria_partner_id.id]], domain])
|
|
if rule.criteria_owner_id:
|
|
domain = expression.AND([[['owner_id', '=', rule.criteria_owner_id.id]], domain])
|
|
if rule.create_model:
|
|
domain = expression.AND([[['type', '=', 'binary']], domain])
|
|
if rule.required_tag_ids:
|
|
domain = expression.AND([[['tag_ids', 'in', rule.required_tag_ids.ids]], domain])
|
|
if rule.excluded_tag_ids:
|
|
domain = expression.AND([[['tag_ids', 'not in', rule.excluded_tag_ids.ids]], domain])
|
|
|
|
folder_domain = [['folder_id', 'child_of', rule.domain_folder_id.id]]
|
|
subset = expression.AND([[['id', 'in', self.ids]], domain, folder_domain])
|
|
document_ids = self.env['documents.document'].search(subset)
|
|
for document in document_ids:
|
|
document.available_rule_ids = [(4, rule.id, 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):
|
|
"""
|
|
creates a new attachment from any email sent to the alias.
|
|
The values defined in the share link upload settings are included
|
|
in the custom values (via the alias defaults, synchronized on update)
|
|
"""
|
|
subject = msg_dict.get('subject', '')
|
|
if custom_values is None:
|
|
custom_values = {}
|
|
|
|
# Remove non existing tags to allow saving document with the mail alias
|
|
tags = custom_values.get('tag_ids')
|
|
if tags and isinstance(tags, (list, tuple)) and isinstance(tags[0], (list, tuple)):
|
|
custom_values['tag_ids'] = [(tags[0][0], tags[0][1],
|
|
self.env['documents.tag'].browse(tags[0][2]).exists().ids)]
|
|
|
|
defaults = {
|
|
'name': "Mail: %s" % subject,
|
|
'active': False,
|
|
}
|
|
defaults.update(custom_values)
|
|
|
|
return super(Document, self).message_new(msg_dict, defaults)
|
|
|
|
@api.returns('mail.message', lambda value: value.id)
|
|
def message_post(self, *, message_type='notification', **kwargs):
|
|
if message_type == 'email' and self.create_share_id:
|
|
self = self.with_context(no_document=True)
|
|
return super(Document, self).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 share link
|
|
settings to the attachments of the mail.
|
|
|
|
"""
|
|
m2m_commands = msg_vals['attachment_ids']
|
|
share = self.create_share_id
|
|
if share and not self.env.context.get("no_document") or message.message_type == 'email':
|
|
attachments = self.env['ir.attachment'].browse([x[1] for x in m2m_commands])
|
|
documents = self.env['documents.document'].create([{
|
|
'name': attachment.name,
|
|
'attachment_id': attachment.id,
|
|
'folder_id': share.folder_id.id,
|
|
'owner_id': share.owner_id.user_ids[0].id if share.owner_id.user_ids else share.create_uid.id,
|
|
'partner_id': share.partner_id.id or False,
|
|
'tag_ids': [(6, 0, share.tag_ids.ids or [])],
|
|
} for attachment in attachments])
|
|
for (attachment, document) in zip(attachments, documents):
|
|
attachment.write({
|
|
'res_model': 'documents.document',
|
|
'res_id': document.id,
|
|
})
|
|
document.message_post(
|
|
message_type='email',
|
|
body=msg_vals.get('body', ''),
|
|
email_from=msg_vals.get('email_from'),
|
|
subject=msg_vals.get('subject') or self.name
|
|
)
|
|
if share.activity_option:
|
|
document.documents_set_activity(settings_record=share)
|
|
|
|
return super(Document, self)._message_post_after_hook(message, msg_vals)
|
|
|
|
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.activity_type_id (required)
|
|
settings_record.activity_summary
|
|
settings_record.activity_note
|
|
settings_record.activity_date_deadline_range
|
|
settings_record.activity_date_deadline_range_type
|
|
settings_record.activity_user_id
|
|
"""
|
|
if settings_record and settings_record.activity_type_id:
|
|
for record in self:
|
|
activity_vals = {
|
|
'activity_type_id': settings_record.activity_type_id.id,
|
|
'summary': settings_record.activity_summary or '',
|
|
'note': settings_record.activity_note or '',
|
|
}
|
|
if settings_record.activity_date_deadline_range > 0:
|
|
activity_vals['date_deadline'] = fields.Date.context_today(settings_record) + relativedelta(
|
|
**{settings_record.activity_date_deadline_range_type: settings_record.activity_date_deadline_range})
|
|
if settings_record._fields.get('has_owner_activity') and settings_record.has_owner_activity and record.owner_id:
|
|
user = record.owner_id
|
|
elif settings_record._fields.get('activity_user_id') and settings_record.activity_user_id:
|
|
user = settings_record.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 toggle_favorited(self):
|
|
self.ensure_one()
|
|
self.sudo().write({'favorited_ids': [(3 if self.env.user in self[0].favorited_ids else 4, self.env.user.id)]})
|
|
|
|
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'] = '/documents/content/%s' % self.id
|
|
return action
|
|
|
|
def create_share(self):
|
|
self.ensure_one()
|
|
vals = {
|
|
'type': 'ids',
|
|
'document_ids': [(6, 0, self.ids)],
|
|
'folder_id': self.folder_id.id,
|
|
}
|
|
return self.env['documents.share'].open_share_popup(vals)
|
|
|
|
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.
|
|
|
|
Members of the group documents.group_documents_manager and the superuser can unlock the file regardless.
|
|
"""
|
|
self.ensure_one()
|
|
if self.lock_uid:
|
|
if self.env.user == self.lock_uid or self.env.is_admin() or self.user_has_groups(
|
|
'documents.group_documents_manager'):
|
|
self.lock_uid = False
|
|
else:
|
|
self.lock_uid = self.env.uid
|
|
|
|
def _compute_is_locked(self):
|
|
for record in self:
|
|
record.is_locked = record.lock_uid and not (
|
|
self.env.user == record.lock_uid or
|
|
self.env.is_admin() or
|
|
self.user_has_groups('documents.group_documents_manager'))
|
|
|
|
def action_archive(self):
|
|
if not self:
|
|
return
|
|
active_documents = self.filtered(self._active_name)
|
|
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().action_archive()
|
|
|
|
def action_unarchive(self):
|
|
if not self:
|
|
return
|
|
archived_documents = self.filtered(lambda record: not record[self._active_name])
|
|
log_message = _("This file has been restored")
|
|
archived_documents._message_log_batch(bodies={doc.id: log_message for doc in archived_documents})
|
|
# Unarchive folders linked to archived documents
|
|
archived_documents.folder_id.filtered(lambda folder: not folder[self._active_name]).action_unarchive()
|
|
return super().action_unarchive()
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
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'))
|
|
|
|
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
|
|
attachments.append(attachment)
|
|
|
|
documents = super().create(vals_list)
|
|
|
|
# this condition takes precedence during forward-port.
|
|
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 write(self, vals):
|
|
if vals.get('folder_id') and not self.env.is_superuser():
|
|
folder = self.env['documents.folder'].browse(vals.get('folder_id'))
|
|
if not folder.has_write_access:
|
|
raise AccessError(_("You don't have the right to move documents to that workspace."))
|
|
|
|
attachment_id = vals.get('attachment_id')
|
|
if attachment_id:
|
|
self.ensure_one()
|
|
for record in self:
|
|
|
|
if record.type == 'empty' and ('datas' in vals or 'url' in vals):
|
|
body = _("Document Request: %s Uploaded by: %s", record.name, self.env.user.name)
|
|
record.with_context(no_document=True).message_post(body=body)
|
|
|
|
if record.attachment_id:
|
|
# 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 = 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 vals.get('datas') and not vals.get('attachment_id'):
|
|
res_model = vals.get('res_model', record.res_model or 'documents.document')
|
|
res_id = vals.get('res_id') if vals.get('res_model') else record.res_id if record.res_model else record.id
|
|
if res_model and res_model != 'documents.document' and not self.env[res_model].browse(res_id).exists():
|
|
record.res_model = res_model = 'documents.document'
|
|
record.res_id = res_id = record.id
|
|
attachment = self.env['ir.attachment'].with_context(no_document=True).create({
|
|
'name': vals.get('name', record.name),
|
|
'res_model': res_model,
|
|
'res_id': res_id
|
|
})
|
|
record.attachment_id = attachment.id
|
|
record.with_context(no_document=True)._process_activities(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}
|
|
|
|
write_result = super(Document, self).write(vals)
|
|
if attachment_dict:
|
|
self.mapped('attachment_id').write(attachment_dict)
|
|
|
|
if 'attachment_id' in vals:
|
|
self.attachment_id.check('read')
|
|
|
|
return write_result
|
|
|
|
def _process_activities(self, attachment_id):
|
|
self.ensure_one()
|
|
if attachment_id and self.request_activity_id:
|
|
feedback = _("Document Request: %s Uploaded by: %s", self.name, self.env.user.name)
|
|
self.request_activity_id.action_feedback(feedback=feedback, attachment_ids=[attachment_id])
|
|
|
|
@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)
|
|
|
|
return self.create([
|
|
dict(vals, attachment_id=attachment.id) for attachment in new_attachments
|
|
])
|
|
|
|
@api.model
|
|
def search_panel_select_range(self, field_name, **kwargs):
|
|
if field_name == 'folder_id':
|
|
enable_counters = kwargs.get('enable_counters', False)
|
|
fields = ['display_name', 'description', 'parent_folder_id', 'has_write_access', 'company_id']
|
|
available_folders = self.env['documents.folder'].search([])
|
|
folder_domain = expression.OR([[('parent_folder_id', 'parent_of', available_folders.ids)], [('id', 'in', available_folders.ids)]])
|
|
# also fetches the ancestors of the available folders to display the complete folder tree for all available folders.
|
|
DocumentFolder = self.env['documents.folder'].sudo().with_context(hierarchical_naming=False)
|
|
records = DocumentFolder.search_read(folder_domain, fields)
|
|
|
|
domain_image = {}
|
|
if enable_counters:
|
|
model_domain = expression.AND([
|
|
kwargs.get('search_domain', []),
|
|
kwargs.get('category_domain', []),
|
|
kwargs.get('filter_domain', []),
|
|
[(field_name, '!=', False)]
|
|
])
|
|
domain_image = self._search_panel_domain_image(field_name, model_domain, enable_counters)
|
|
|
|
values_range = OrderedDict()
|
|
for record in records:
|
|
record_id = record['id']
|
|
if enable_counters:
|
|
image_element = domain_image.get(record_id)
|
|
record['__count'] = image_element['__count'] if image_element else 0
|
|
value = record['parent_folder_id']
|
|
record['parent_folder_id'] = value and value[0]
|
|
values_range[record_id] = record
|
|
|
|
if enable_counters:
|
|
self._search_panel_global_counters(values_range, 'parent_folder_id')
|
|
|
|
return {
|
|
'parent_field': 'parent_folder_id',
|
|
'values': list(values_range.values()),
|
|
}
|
|
|
|
return super(Document, self).search_panel_select_range(field_name)
|
|
|
|
def _get_processed_tags(self, domain, folder_id):
|
|
"""
|
|
sets a group color to the tags based on the order of the facets (group_id)
|
|
recomputed each time the search_panel fetches the tags as the colors depend on the order and
|
|
amount of tag categories. If the amount of categories exceeds the amount of colors, the color
|
|
loops back to the first one.
|
|
"""
|
|
tags = self.env['documents.tag']._get_tags(domain, folder_id)
|
|
facets = list(OrderedDict.fromkeys([tag['group_id'] for tag in tags]))
|
|
for tag in tags:
|
|
color_index = (facets.index(tag['group_id']) % N_FACET_COLORS) + 1
|
|
tag['color_index'] = color_index
|
|
|
|
return tags
|
|
|
|
@api.model
|
|
def search_panel_select_multi_range(self, field_name, **kwargs):
|
|
search_domain = kwargs.get('search_domain', [])
|
|
category_domain = kwargs.get('category_domain', [])
|
|
filter_domain = kwargs.get('filter_domain', [])
|
|
|
|
if field_name == 'tag_ids':
|
|
folder_id = category_domain[0][2] if len(category_domain) else False
|
|
if folder_id:
|
|
domain = expression.AND([
|
|
search_domain, category_domain, filter_domain,
|
|
[(field_name, '!=', False)],
|
|
])
|
|
return {'values': self._get_processed_tags(domain, folder_id)}
|
|
else:
|
|
return {'values': []}
|
|
|
|
elif field_name == 'res_model':
|
|
domain = expression.AND([search_domain, category_domain])
|
|
model_values = self._get_models(domain)
|
|
|
|
if filter_domain:
|
|
# fetch new counters
|
|
domain = expression.AND([search_domain, category_domain, filter_domain])
|
|
model_count = {
|
|
model['id']: model['__count']
|
|
for model in self._get_models(domain)
|
|
}
|
|
# update result with new counters
|
|
for model in model_values:
|
|
model['__count'] = model_count.get(model['id'], 0)
|
|
|
|
return {'values': model_values}
|
|
|
|
return super(Document, self).search_panel_select_multi_range(field_name, **kwargs)
|
|
|
|
@api.model
|
|
def get_document_max_upload_limit(self):
|
|
try:
|
|
return int(self.env['ir.config_parameter'].sudo().get_param('document.max_fileupload_size', default=0))
|
|
except Exception:
|
|
return False
|
|
|
|
def unlink(self):
|
|
"""Remove its folder when deleting a document to ensure we don't retain unnecessary folders in the database.
|
|
|
|
If:
|
|
- The folder is inactive
|
|
- It isn't linked to any files
|
|
- It has no child folders
|
|
"""
|
|
removable_folders = self.folder_id.with_context({'active_test': False}).filtered(
|
|
lambda folder: len(folder.document_ids) == 1
|
|
and not folder.children_folder_ids
|
|
and not folder.active
|
|
)
|
|
removable_attachments = self.filtered(lambda self: self.res_model != self._name).attachment_id
|
|
res = super().unlink()
|
|
if removable_attachments:
|
|
removable_attachments.unlink()
|
|
if removable_folders:
|
|
removable_folders.unlink()
|
|
return res
|
|
|
|
@api.autovacuum
|
|
def _gc_clear_bin(self):
|
|
"""Files are deleted automatically from the trash bin after the configured remaining days."""
|
|
deletion_delay = self.get_deletion_delay()
|
|
self.search([
|
|
('active', '=', False),
|
|
('write_date', '<=', fields.Datetime.now() - relativedelta(days=deletion_delay)),
|
|
], limit=1000).unlink()
|