Slink/dms/models/dms_file.py

662 lines
24 KiB
Python
Executable File

# Copyright 2020 Antoni Romera
# Copyright 2017-2019 MuK IT GmbH
# Copyright 2021 Tecnativa - Víctor Martínez
# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com).
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
import base64
import hashlib
import json
import logging
from collections import defaultdict
from PIL import Image
from odoo import _, api, fields, models, tools
from odoo.exceptions import UserError, ValidationError
from odoo.osv import expression
from odoo.tools import consteq, human_size
from odoo.tools.mimetypes import guess_mimetype
from ..tools import file
_logger = logging.getLogger(__name__)
class DMSFile(models.Model):
_name = "dms.file"
_description = "File"
_inherit = [
"portal.mixin",
"dms.security.mixin",
"dms.mixins.thumbnail",
"mail.thread",
"mail.activity.mixin",
"abstract.dms.mixin",
]
_order = "name asc"
# Database
active = fields.Boolean(
string="Archived",
default=True,
help="If a file is set to archived, it is not displayed, but still exists.",
)
directory_id = fields.Many2one(
comodel_name="dms.directory",
string="Directory",
domain="[('permission_create', '=', True)]",
context={"dms_directory_show_path": True},
ondelete="restrict",
auto_join=True,
required=True,
index="btree",
tracking=True, # Leave log if "moved" to another directory
)
root_directory_id = fields.Many2one(related="directory_id.root_directory_id")
# Override acording to defined in AbstractDmsMixin
storage_id = fields.Many2one(
related="directory_id.storage_id",
readonly=True,
prefetch=False,
)
path_names = fields.Char(
compute="_compute_path",
compute_sudo=True,
readonly=True,
store=False,
)
path_json = fields.Text(
compute="_compute_path",
compute_sudo=True,
readonly=True,
store=False,
)
tag_ids = fields.Many2many(
comodel_name="dms.tag",
relation="dms_file_tag_rel",
column1="fid",
column2="tid",
domain="['|', ('category_id', '=', False),('category_id', '=?', category_id)]",
string="Tags",
)
content = fields.Binary(
compute="_compute_content",
inverse="_inverse_content",
attachment=False,
prefetch=False,
required=True,
store=False,
)
extension = fields.Char(compute="_compute_extension", readonly=True, store=True)
mimetype = fields.Char(
compute="_compute_mimetype", string="Type", readonly=True, store=True
)
size = fields.Float(readonly=True)
human_size = fields.Char(
readonly=True,
string="Size (human readable)",
compute="_compute_human_size",
store=True,
)
checksum = fields.Char(string="Checksum/SHA1", readonly=True, index="btree")
content_binary = fields.Binary(attachment=False, prefetch=False)
save_type = fields.Char(
compute="_compute_save_type",
string="Current Save Type",
prefetch=False,
)
migration = fields.Char(
compute="_compute_migration",
string="Migration Status",
readonly=True,
prefetch=False,
compute_sudo=True,
store=True,
)
require_migration = fields.Boolean(
compute="_compute_migration", store=True, compute_sudo=True
)
content_file = fields.Binary(attachment=True, prefetch=False)
# Extend inherited field(s)
image_1920 = fields.Image(compute="_compute_image_1920", store=True, readonly=False)
@api.depends("mimetype", "content")
def _compute_image_1920(self):
"""Provide thumbnail automatically if possible."""
for one in self.filtered("mimetype"):
# Image.MIME provides a dict of mimetypes supported by Pillow,
# SVG is not present in the dict but is also a supported image format
# lacking a better solution, it's being added manually
# Some component modifies the PIL dictionary by adding PDF as a valid
# image type, so it must be explicitly excluded.
if one.mimetype != "application/pdf" and one.mimetype in (
*Image.MIME.values(),
"image/svg+xml",
):
one.image_1920 = one.content
def check_access_rule(self, operation):
self.mapped("directory_id").check_access_rule(operation)
return super().check_access_rule(operation)
def _compute_access_url(self):
res = super()._compute_access_url()
for item in self:
item.access_url = "/my/dms/file/%s/download" % (item.id)
return res
def check_access_token(self, access_token=False):
if not access_token:
return False
if self.access_token and consteq(self.access_token, access_token):
return True
items = (
self.env["dms.directory"]
.sudo()
.search([("access_token", "=", access_token)])
)
if items:
item = items[0]
if self.directory_id.id == item.id:
return True
directory_item = self.directory_id
while directory_item.parent_id:
if directory_item.id == self.directory_id.id:
return True
directory_item = directory_item.parent_id
# Fix last level
if directory_item.id == self.directory_id.id:
return True
return False
res_model = fields.Char(
string="Linked attachments model", related="directory_id.res_model"
)
res_id = fields.Integer(
string="Linked attachments record ID", related="directory_id.res_id"
)
attachment_id = fields.Many2one(
comodel_name="ir.attachment",
string="Attachment File",
prefetch=False,
ondelete="cascade",
)
def get_human_size(self):
return human_size(self.size)
# Helper
@api.model
def _get_checksum(self, binary):
return hashlib.sha1(binary or b"").hexdigest()
@api.model
def _get_content_inital_vals(self):
return {"content_binary": False, "content_file": False}
def _update_content_vals(self, vals, binary):
new_vals = vals.copy()
new_vals.update(
{
"checksum": self._get_checksum(binary),
"size": binary and len(binary) or 0,
}
)
if self.storage_id.save_type in ["file", "attachment"]:
new_vals["content_file"] = self.content
else:
new_vals["content_binary"] = self.content and binary
return new_vals
@api.model
def _get_binary_max_size(self):
return int(
self.env["ir.config_parameter"]
.sudo()
.get_param("dms.binary_max_size", default=25)
)
@api.model
def _get_forbidden_extensions(self):
get_param = self.env["ir.config_parameter"].sudo().get_param
extensions = get_param("dms.forbidden_extensions", default="")
return [extension.strip() for extension in extensions.split(",")]
def _get_icon_placeholder_name(self):
return self.extension and "file_%s.svg" % self.extension or ""
# Actions
def action_migrate(self, should_logging=True):
record_count = len(self)
index = 1
for dms_file in self:
if should_logging:
_logger.info(
_(
"Migrate File %(index)s of %(record_count)s [ %("
"dms_file_migration)s ]",
index=index,
record_count=record_count,
dms_file_migration=dms_file.migration,
)
)
index += 1
dms_file.write(
{
"content": dms_file.with_context(**{}).content,
"storage_id": dms_file.directory_id.storage_id.id,
}
)
def action_save_onboarding_file_step(self):
self.env.user.company_id.set_onboarding_step_done(
"documents_onboarding_file_state"
)
def action_wizard_dms_file_move(self):
items = self.browse(self.env.context.get("active_ids"))
root_directories = items.mapped("root_directory_id")
if len(root_directories) > 1:
raise UserError(_("Only files in the same root directory can be moved."))
result = self.env["ir.actions.act_window"]._for_xml_id(
"dms.wizard_dms_file_move_act_window"
)
result["context"] = dict(self.env.context)
return result
# SearchPanel
@api.model
def _search_panel_directory(self, **kwargs):
search_domain = (kwargs.get("search_domain", []),)
category_domain = kwargs.get("category_domain", [])
if category_domain and len(category_domain):
return "=", category_domain[0][2]
if search_domain and len(search_domain):
for domain in search_domain[0]:
if domain[0] == "directory_id":
return domain[1], domain[2]
return None, None
@api.model
def _search_panel_domain(self, field, operator, directory_id, comodel_domain=False):
if not comodel_domain:
comodel_domain = []
files_ids = self.search([("directory_id", operator, directory_id)]).ids
return expression.AND([comodel_domain, [(field, "in", files_ids)]])
@api.model
def search_panel_select_range(self, field_name, **kwargs):
"""This method is overwritten to make it 'similar' to v13.
The goal is that the directory searchpanel shows all directories
(even if some folders have no files).
"""
if field_name != "directory_id":
context = {}
if field_name == "category_id":
context["category_short_name"] = True
return super(
DMSFile, self.with_context(**context)
).search_panel_select_range(field_name, **kwargs)
domain = [("is_hidden", "=", False)]
# If we pass by context something, we filter more about it we filter
# the directories of the files, or we show all of them
if self.env.context.get("active_model") == "dms.directory":
active_id = self.env.context.get("active_id")
files = self.env["dms.file"].search(
[("directory_id", "child_of", active_id)]
)
all_directory_ids = []
for file_record in files:
directory = file_record.directory_id
while directory:
all_directory_ids.append(directory.id)
directory = directory.parent_id
domain.append(("id", "in", all_directory_ids))
# Get all possible directories
comodel_records = (
self.env["dms.directory"]
.with_context(directory_short_name=True)
.search_read(domain, ["display_name", "parent_id"])
)
all_record_ids = [rec["id"] for rec in comodel_records]
field_range = {}
enable_counters = kwargs.get("enable_counters")
for record in comodel_records:
record_id = record["id"]
parent = record["parent_id"]
record_values = {
"id": record_id,
"display_name": record["display_name"],
# If the parent directory is not in all the records we should not
# set parent_id because the user does not have access to parent.
"parent_id": (
parent[0] if parent and parent[0] in all_record_ids else False
),
}
if enable_counters:
record_values["__count"] = 0
field_range[record_id] = record_values
if enable_counters:
res = super().search_panel_select_range(field_name, **kwargs)
for item in res["values"]:
if item["id"] in field_range:
field_range[item["id"]]["__count"] = item["__count"]
return {"parent_field": "parent_id", "values": list(field_range.values())}
@api.model
def search_panel_select_multi_range(self, field_name, **kwargs):
operator, directory_id = self._search_panel_directory(**kwargs)
if field_name == "tag_ids":
sql_query = """
SELECT t.name AS name, t.id AS id, c.name AS group_name,
c.id AS group_id, COUNT(r.fid) AS count
FROM dms_tag t
JOIN dms_category c ON t.category_id = c.id
LEFT JOIN dms_file_tag_rel r ON t.id = r.tid
WHERE %(filter_by_file_ids)s IS FALSE OR r.fid = ANY(%(file_ids)s)
GROUP BY c.name, c.id, t.name, t.id
ORDER BY c.name, c.id, t.name, t.id;
"""
file_ids = []
if directory_id:
file_ids = self.search([("directory_id", operator, directory_id)]).ids
self.env.cr.execute(
sql_query,
{"file_ids": file_ids, "filter_by_file_ids": bool(directory_id)},
)
return self.env.cr.dictfetchall()
if directory_id and field_name in ["directory_id", "category_id"]:
comodel_domain = kwargs.pop("comodel_domain", [])
directory_comodel_domain = self._search_panel_domain(
"file_ids", operator, directory_id, comodel_domain
)
return super(
DMSFile, self.with_context(directory_short_name=True)
).search_panel_select_multi_range(
field_name, comodel_domain=directory_comodel_domain, **kwargs
)
return super(
DMSFile, self.with_context(directory_short_name=True)
).search_panel_select_multi_range(field_name, **kwargs)
# Read
@api.depends("name", "directory_id", "directory_id.parent_path")
def _compute_path(self):
model = self.env["dms.directory"]
for record in self:
path_names = [record.display_name]
path_json = [
{
"model": record._name,
"name": record.display_name,
"id": isinstance(record.id, int) and record.id or 0,
}
]
current_dir = record.directory_id
while current_dir:
path_names.insert(0, current_dir.name)
path_json.insert(
0,
{
"model": model._name,
"name": current_dir.name,
"id": current_dir._origin.id,
},
)
current_dir = current_dir.parent_id
record.update(
{
"path_names": "/".join(path_names) if all(path_names) else "",
"path_json": json.dumps(path_json),
}
)
@api.depends("name", "mimetype", "content")
def _compute_extension(self):
for record in self:
record.extension = file.guess_extension(
record.name, record.mimetype, record.content
)
@api.depends("content")
def _compute_mimetype(self):
for record in self:
binary = base64.b64decode(record.content or "")
record.mimetype = guess_mimetype(binary)
@api.depends("size")
def _compute_human_size(self):
for item in self:
item.human_size = human_size(item.size)
@api.depends("content_binary", "content_file", "attachment_id")
def _compute_content(self):
bin_size = self.env.context.get("bin_size", False)
for record in self:
if record.content_file:
context = {"human_size": True} if bin_size else {"base64": True}
record.content = record.with_context(**context).content_file
elif record.content_binary:
record.content = (
record.content_binary
if bin_size
else base64.b64encode(record.content_binary)
)
elif record.attachment_id:
context = {"human_size": True} if bin_size else {"base64": True}
record.content = record.with_context(**context).attachment_id.datas
@api.depends("content_binary", "content_file")
def _compute_save_type(self):
for record in self:
if record.content_file:
record.save_type = "file"
else:
record.save_type = "database"
@api.depends("storage_id", "storage_id.save_type")
def _compute_migration(self):
storage_model = self.env["dms.storage"]
save_field = storage_model._fields["save_type"]
values = save_field._description_selection(self.env)
selection = {value[0]: value[1] for value in values}
for record in self:
storage_type = record.storage_id.save_type
if storage_type == "attachment" or storage_type == record.save_type:
record.migration = selection.get(storage_type)
record.require_migration = False
else:
storage_label = selection.get(storage_type)
file_label = selection.get(record.save_type)
record.migration = f"{file_label} > {storage_label}"
record.require_migration = True
# View
@api.onchange("category_id")
def _change_category(self):
self.tag_ids = self.tag_ids.filtered(
lambda rec: not rec.category_id or rec.category_id == self.category_id
)
# Constrains
@api.constrains("storage_id", "res_model", "res_id")
def _check_storage_id_attachment_res_model(self):
for record in self:
if record.storage_id.save_type == "attachment" and not (
record.res_model and record.res_id
):
raise ValidationError(
_("A file must have model and resource ID in attachment storage.")
)
@api.constrains("name")
def _check_name(self):
for record in self:
if not file.check_name(record.name):
raise ValidationError(_("The file name is invalid."))
files = record.sudo().directory_id.file_ids
if files.filtered(
lambda file, record=record: file.name == record.name and file != record
):
raise ValidationError(
_("A file with the same name already exists in this directory.")
)
@api.constrains("extension")
def _check_extension(self):
if self.filtered(
lambda rec: rec.extension
and rec.extension in self._get_forbidden_extensions()
):
raise ValidationError(_("The file has a forbidden file extension."))
@api.constrains("size")
def _check_size(self):
if self.filtered(
lambda rec: rec.size > self._get_binary_max_size() * 1024 * 1024
):
raise ValidationError(
_("The maximum upload size is %s MB.") % self._get_binary_max_size()
)
# Create, Update, Delete
def _inverse_content(self):
updates = defaultdict(set)
for record in self:
values = self._get_content_inital_vals()
binary = base64.b64decode(record.content or "")
values = record._update_content_vals(values, binary)
updates[tools.frozendict(values)].add(record.id)
for vals, ids in updates.items():
self.browse(ids).write(dict(vals))
def _create_model_attachment(self, vals):
res_vals = vals.copy()
directory_id = False
if "directory_id" in res_vals:
directory_id = res_vals["directory_id"]
elif self.env.context.get("active_id"):
directory_id = self.env.context.get("active_id")
elif self.env.context.get("default_directory_id"):
directory_id = self.env.context.get("default_directory_id")
directory = self.env["dms.directory"].browse(directory_id)
if (
directory.res_model
and directory.res_id
and directory.storage_id_save_type == "attachment"
):
attachment = (
self.env["ir.attachment"]
.with_context(dms_file=True)
.create(
{
"name": vals["name"],
"datas": vals["content"],
"res_model": directory.res_model,
"res_id": directory.res_id,
}
)
)
res_vals["attachment_id"] = attachment.id
res_vals["res_model"] = attachment.res_model
res_vals["res_id"] = attachment.res_id
del res_vals["content"]
return res_vals
def copy(self, default=None):
self.ensure_one()
default = dict(default or [])
names = self.sudo().directory_id.file_ids.mapped("name")
if "directory_id" in default:
directory = self.env["dms.directory"].browse(
default.get("directory_id", False)
)
names = directory.sudo().file_ids.mapped("name")
default.update({"name": file.unique_name(self.name, names, self.extension)})
return super().copy(default)
@api.model_create_multi
def create(self, vals_list):
new_vals_list = []
for vals in vals_list:
if "attachment_id" not in vals:
vals = self._create_model_attachment(vals)
new_vals_list.append(vals)
return super().create(new_vals_list)
# ----------------------------------------------------------
# Locking fields and functions
locked_by = fields.Many2one(comodel_name="res.users")
is_locked = fields.Boolean(compute="_compute_locked", string="Locked")
is_lock_editor = fields.Boolean(compute="_compute_locked", string="Editor")
# ----------------------------------------------------------
# Locking
# ----------------------------------------------------------
def lock(self):
self.write({"locked_by": self.env.uid})
def unlock(self):
self.write({"locked_by": None})
# Read, View
@api.depends("locked_by")
def _compute_locked(self):
for record in self:
if record.locked_by.exists():
record.update(
{
"is_locked": True,
"is_lock_editor": record.locked_by.id == record.env.uid,
}
)
else:
record.update({"is_locked": False, "is_lock_editor": False})
def get_attachment_object(self, attachment):
return {
"name": attachment.name,
"datas": attachment.datas,
"res_model": attachment.res_model,
"mimetype": attachment.mimetype,
}
@api.model
def get_dms_files_from_attachments(self, attachment_ids=None):
"""Get the dms files from uploaded attachments.
:return: An Array of dms files.
"""
if not attachment_ids:
raise UserError(_("No attachment was provided"))
attachments = self.env["ir.attachment"].browse(attachment_ids)
if any(
attachment.res_id or attachment.res_model != "dms.file"
for attachment in attachments
):
raise UserError(_("Invalid attachments!"))
return [self.get_attachment_object(attachment) for attachment in attachments]