# Copyright 2017-2019 MuK IT GmbH. # Copyright 2020 Creu Blanca # 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 ast import base64 import logging import os from ast import literal_eval from collections import defaultdict from typing import Literal # noqa # pylint: disable=unused-import from odoo import _, api, fields, models, tools from odoo.exceptions import UserError, ValidationError from odoo.osv.expression import AND, OR from odoo.tools import consteq, human_size from odoo.addons.http_routing.models.ir_http import slugify from ..tools.file import check_name, unique_name _logger = logging.getLogger(__name__) _path = os.path.dirname(os.path.dirname(__file__)) class DmsDirectory(models.Model): _name = "dms.directory" _description = "Directory" _inherit = [ "portal.mixin", "dms.security.mixin", "dms.mixins.thumbnail", "mail.thread", "mail.activity.mixin", "mail.alias.mixin", "abstract.dms.mixin", ] _rec_name = "complete_name" _order = "complete_name" _parent_store = True _parent_name = "parent_id" _directory_field = _parent_name parent_path = fields.Char(index="btree", unaccent=False) is_root_directory = fields.Boolean( default=False, help="""Indicates if the directory is a root directory. A root directory has a settings object, while a directory with a set parent inherits the settings form its parent.""", ) # Override acording to defined in AbstractDmsMixin storage_id = fields.Many2one( compute="_compute_storage_id", compute_sudo=True, readonly=False, comodel_name="dms.storage", string="Storage", ondelete="restrict", auto_join=True, store=True, ) parent_id = fields.Many2one( comodel_name="dms.directory", string="Parent Directory", domain="[('permission_create', '=', True)]", ondelete="restrict", # Access to a directory doesn't necessarily mean access its parent, so # prefetching this field could lead to misleading access errors prefetch=False, index="btree", store=True, readonly=False, compute="_compute_parent_id", copy=True, default=lambda self: self._default_parent_id(), ) root_directory_id = fields.Many2one( "dms.directory", "Root Directory", compute="_compute_root_id", store=True ) def _default_parent_id(self): context = self.env.context if context.get("active_model") == self._name and context.get("active_id"): return context["active_id"] else: return False group_ids = fields.Many2many( comodel_name="dms.access.group", relation="dms_directory_groups_rel", column1="aid", column2="gid", string="Groups", ) complete_group_ids = fields.Many2many( comodel_name="dms.access.group", relation="dms_directory_complete_groups_rel", column1="aid", column2="gid", string="Complete Groups", compute="_compute_groups", readonly=True, store=True, compute_sudo=True, recursive=True, ) complete_name = fields.Char( compute="_compute_complete_name", store=True, recursive=True ) child_directory_ids = fields.One2many( comodel_name="dms.directory", inverse_name="parent_id", string="Subdirectories", auto_join=False, copy=True, ) tag_ids = fields.Many2many( comodel_name="dms.tag", relation="dms_directory_tag_rel", domain="""[ '|', ['category_id', '=', False], ['category_id', 'child_of', category_id]] """, column1="did", column2="tid", string="Tags", compute="_compute_tags", readonly=False, store=True, ) user_star_ids = fields.Many2many( comodel_name="res.users", relation="dms_directory_star_rel", column1="did", column2="uid", string="Stars", ) starred = fields.Boolean( compute="_compute_starred", inverse="_inverse_starred", search="_search_starred", ) file_ids = fields.One2many( comodel_name="dms.file", inverse_name="directory_id", string="Files", auto_join=False, copy=True, ) count_directories = fields.Integer( compute="_compute_count_directories", string="Count Subdirectories Title" ) count_files = fields.Integer( compute="_compute_count_files", string="Count Files Title" ) count_directories_title = fields.Char( compute="_compute_count_directories", string="Count Subdirectories" ) count_files_title = fields.Char( compute="_compute_count_files", string="Count Files" ) count_elements = fields.Integer(compute="_compute_count_elements") count_total_directories = fields.Integer( compute="_compute_count_total_directories", string="Total Subdirectories" ) count_total_files = fields.Integer( compute="_compute_count_total_files", string="Total Files" ) count_total_elements = fields.Integer( compute="_compute_count_total_elements", string="Total Elements" ) size = fields.Float(compute="_compute_size") human_size = fields.Char( compute="_compute_human_size", string="Size (human readable)" ) inherit_group_ids = fields.Boolean(string="Inherit Groups", default=True) alias_process = fields.Selection( selection=[("files", "Single Files"), ("directory", "Subdirectory")], required=True, default="directory", string="Unpack Emails as", help="""\ Define how incoming emails are processed:\n - Single Files: The email gets attached to the directory and all attachments are created as files.\n - Subdirectory: A new subdirectory is created for each email and the mail is attached to this subdirectory. The attachments are created as files of the subdirectory. """, ) @api.model def _get_domain_by_access_groups(self, operation): """Special rules for directories.""" self_filter = [ ("storage_id_inherit_access_from_parent_record", "=", False), ("id", "inselect", self._get_access_groups_query(operation)), ] # Upstream only filters by parent directory result = super()._get_domain_by_access_groups(operation) if operation == "create": # When creating, I need create access in parent directory, or # self-create permission if it's a root directory result = OR( [ [("is_root_directory", "=", False)] + result, [("is_root_directory", "=", True)] + self_filter, ] ) else: # In other operations, I only need self access result = self_filter return result def _compute_access_url(self): res = super()._compute_access_url() for item in self: item.access_url = "/my/dms/directory/%s" % (item.id) return res def check_access_token(self, access_token=False): res = False if access_token: items = ( self.env["dms.directory"] .sudo() .search([("access_token", "=", access_token)]) ) if items: item = items[0] if item.id == self.id: return True # sudo because the user might not usually have access to the record but # now the token is valid. directory_item = self.sudo() while directory_item.parent_id: if directory_item.id == item.id: return True directory_item = directory_item.parent_id # Fix last level if directory_item.id == item.id: return True return res @api.model def _get_parent_categories(self, access_token): self.ensure_one() directories = [] current_directory = self while current_directory: directories.insert(0, current_directory) if ( ( access_token and current_directory.access_token and consteq(current_directory.access_token, access_token) ) or not access_token and current_directory.check_access_rights("read") ): return directories current_directory = current_directory.parent_id if access_token: # Reaching here means we didn't find the directory accessible by this token return [self] return directories def _get_own_root_directories(self): res = self.env["dms.directory"].search_read( [("is_hidden", "=", False)], ["parent_id"] ) all_ids = [value["id"] for value in res] res_ids = [] for item in res: if not item["parent_id"] or item["parent_id"][0] not in all_ids: res_ids.append(item["id"]) return res_ids allowed_model_ids = fields.Many2many( related="storage_id.model_ids", comodel_name="ir.model", ) model_id = fields.Many2one( comodel_name="ir.model", domain="[('id', 'in', allowed_model_ids)]", compute="_compute_model_id", inverse="_inverse_model_id", string="Model", store=True, ) storage_id_save_type = fields.Selection( related="storage_id.save_type", related_sudo=True, readonly=True, store=False, prefetch=False, ) storage_id_inherit_access_from_parent_record = fields.Boolean( related="storage_id.inherit_access_from_parent_record", related_sudo=True, store=True, ) @api.depends("res_model") def _compute_model_id(self): for record in self: if not record.res_model: record.model_id = False continue record.model_id = ( self.env["ir.model"].sudo().search([("model", "=", record.res_model)]) ) def _inverse_model_id(self): for record in self: record.res_model = record.model_id.model def toggle_starred(self): updates = defaultdict(set) for record in self: vals = {"starred": not record.starred} updates[tools.frozendict(vals)].add(record.id) for vals, ids in updates.items(): self.browse(ids).write(dict(vals)) self.flush_recordset() # SearchPanel @api.model def search_panel_select_range(self, field_name, **kwargs): context = {} if field_name == "parent_id": context["directory_short_name"] = True return super( DmsDirectory, self.with_context(**context) ).search_panel_select_range(field_name, **kwargs) @api.model def search_panel_select_multi_range(self, field_name, **kwargs): return super( DmsDirectory, self.with_context(category_short_name=True) ).search_panel_select_multi_range(field_name, **kwargs) # Actions def action_save_onboarding_directory_step(self): self.env.user.company_id.set_onboarding_step_done( "documents_onboarding_directory_state" ) # SearchPanel @api.model def _search_panel_directory(self, **kwargs): search_domain = (kwargs.get("search_domain", []),) if search_domain and len(search_domain): for domain in search_domain[0]: if domain[0] == "parent_id": return domain[1], domain[2] return None, None # Search @api.model def _search_starred(self, operator, operand): if operator == "=" and operand: return [("user_star_ids", "in", [self.env.uid])] return [("user_star_ids", "not in", [self.env.uid])] @api.depends("name", "parent_id.complete_name") def _compute_complete_name(self): for category in self: if category.parent_id: category.complete_name = "{} / {}".format( category.parent_id.complete_name, category.name, ) else: category.complete_name = category.name @api.depends("parent_id") def _compute_storage_id(self): for record in self: if record.parent_id: record.storage_id = record.parent_id.storage_id else: # HACK: Not needed in v14 due to odoo/odoo#64359 record.storage_id = record.storage_id @api.depends("user_star_ids") def _compute_starred(self): for record in self: record.starred = self.env.user in record.user_star_ids @api.depends("child_directory_ids") def _compute_count_directories(self): for record in self: directories = len(record.child_directory_ids) record.count_directories = directories record.count_directories_title = _("%s Subdirectories") % directories @api.depends("file_ids") def _compute_count_files(self): for record in self: files = len(record.file_ids) record.count_files = files record.count_files_title = _("%s Files") % files @api.depends("child_directory_ids", "file_ids") def _compute_count_elements(self): for record in self: record.count_elements = record.count_files + record.count_directories def _compute_count_total_directories(self): for record in self: count = ( self.search_count([("id", "child_of", record.id)]) if record.id else 0 ) record.count_total_directories = count - 1 if count > 0 else 0 def _compute_count_total_files(self): model = self.env["dms.file"] for record in self: # Prevent error in some NewId cases record.count_total_files = ( model.search_count([("directory_id", "child_of", record.id)]) if record.id else 0 ) def _compute_count_total_elements(self): for record in self: record.count_total_elements = ( record.count_total_files + record.count_total_directories ) def _compute_size(self): sudo_model = self.env["dms.file"].sudo() for record in self: # Avoid NewId if not record.id: record.size = 0 continue recs = sudo_model.search_read( domain=[("directory_id", "child_of", record.id)], fields=["size"], ) record.size = sum(rec.get("size", 0) for rec in recs) @api.depends("size") def _compute_human_size(self): for item in self: item.human_size = human_size(item.size) if item.size else False @api.depends( "group_ids", "inherit_group_ids", "parent_id.complete_group_ids", "parent_path", ) def _compute_groups(self): """Get all DMS security groups affecting this directory.""" for one in self: groups = one.group_ids if one.inherit_group_ids: groups |= one.parent_id.complete_group_ids self.complete_group_ids = groups # View @api.depends("is_root_directory") def _compute_parent_id(self): for record in self: if record.is_root_directory: record.parent_id = None else: # HACK: Not needed in v14 due to odoo/odoo#64359 record.parent_id = record.parent_id @api.depends("is_root_directory", "parent_id") def _compute_root_id(self): for record in self: if record.is_root_directory: record.root_directory_id = record else: # recursively check all parent nodes up to the root directory if not record.parent_id.root_directory_id: record.parent_id._compute_root_id() record.root_directory_id = record.parent_id.root_directory_id @api.depends("category_id") def _compute_tags(self): for record in self: tags = record.tag_ids.filtered( lambda rec, record=record: not rec.category_id or rec.category_id == record.category_id ) record.tag_ids = tags @api.onchange("storage_id") def _onchange_storage_id(self): for record in self: if ( record.storage_id.save_type == "attachment" and record.storage_id.inherit_access_from_parent_record ): record.group_ids = False @api.onchange("model_id") def _onchange_model_id(self): self._inverse_model_id() # Constrains @api.constrains("parent_id") def _check_directory_recursion(self): if not self._check_recursion(): raise ValidationError(_("Error! You cannot create recursive directories.")) return True @api.constrains("storage_id", "model_id") def _check_storage_id_attachment_model_id(self): for record in self.filtered( lambda directory: directory.storage_id.save_type == "attachment" ): if not record.model_id: raise ValidationError( _("A directory has to have model in attachment storage.") ) if not record.is_root_directory and not record.res_id: raise ValidationError( _("This directory needs to be associated to a record.") ) @api.constrains("is_root_directory", "storage_id") def _check_directory_storage(self): for record in self: if record.is_root_directory and not record.storage_id: raise ValidationError(_("A root directory has to have a storage.")) @api.constrains("is_root_directory", "parent_id") def _check_directory_parent(self): for record in self: if record.is_root_directory and record.parent_id: raise ValidationError( _("A directory can't be a root and have a parent directory.") ) if not record.is_root_directory and not record.parent_id: raise ValidationError(_("A directory has to have a parent directory.")) @api.constrains("name") def _check_name(self): for record in self: if self.env.context.get("check_name", True) and not check_name(record.name): raise ValidationError(_("The directory name is invalid.")) if record.is_root_directory: children = record.sudo().storage_id.root_directory_ids else: children = record.sudo().parent_id.child_directory_ids if children.filtered( lambda child, record=record: child.name == record.name and child != record ): raise ValidationError( _("A directory with the same name already exists.") ) # Create, Update, Delete def _inverse_starred(self): starred_records = self.env["dms.directory"].sudo() not_starred_records = self.env["dms.directory"].sudo() for record in self: if not record.starred and self.env.user in record.user_star_ids: starred_records |= record elif record.starred and self.env.user not in record.user_star_ids: not_starred_records |= record not_starred_records.write({"user_star_ids": [(4, self.env.uid)]}) starred_records.write({"user_star_ids": [(3, self.env.uid)]}) def copy(self, default=None): self.ensure_one() default = dict(default or []) if "parent_id" in default: parent_directory = self.browse(default.get("parent_id")) names = parent_directory.sudo().child_directory_ids.mapped("name") elif self.is_root_directory: names = self.sudo().storage_id.root_directory_ids.mapped("name") else: names = self.sudo().parent_id.child_directory_ids.mapped("name") default.update({"name": unique_name(self.name, names)}) return super().copy(default) def _alias_get_creation_values(self): values = super()._alias_get_creation_values() values["alias_model_id"] = self.env["ir.model"].sudo()._get("dms.directory").id if self.id: values["alias_defaults"] = defaults = ast.literal_eval( self.alias_defaults or "{}" ) defaults["parent_id"] = self.id return values @api.model def message_new(self, msg_dict, custom_values=None): custom_values = custom_values if custom_values is not None else {} parent_directory_id = custom_values.get("parent_id") parent_directory = self.sudo().browse(parent_directory_id) if not parent_directory_id or not parent_directory.exists(): raise ValueError("No directory could be found!") if parent_directory.alias_process == "files": parent_directory._process_message(msg_dict) return parent_directory names = parent_directory.child_directory_ids.mapped("name") subject = slugify(msg_dict.get("subject", _("Alias-Mail-Extraction"))) defaults = dict( {"name": unique_name(subject, names, escape_suffix=True)}, **custom_values ) directory = super().message_new(msg_dict, custom_values=defaults) directory._process_message(msg_dict) return directory def message_update(self, msg_dict, update_vals=None): self._process_message(msg_dict, extra_values=update_vals) return super().message_update(msg_dict, update_vals=update_vals) def _process_message(self, msg_dict, extra_values=False): names = self.sudo().file_ids.mapped("name") for attachment in msg_dict["attachments"]: uname = unique_name(attachment.fname, names, escape_suffix=True) vals = { "directory_id": self.id, "name": uname, } try: vals["content"] = base64.b64encode(attachment.content) except Exception: vals["content"] = attachment.content self.env["dms.file"].sudo().create(vals) names.append(uname) @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get("parent_id", False): parent = self.browse([vals["parent_id"]]) data = next(iter(parent.sudo().read(["storage_id"])), {}) vals["storage_id"] = self._convert_to_write(data).get("storage_id") # Hack to prevent error related to mail_message parent not exists in some cases ctx = dict(self.env.context).copy() ctx.update({"default_parent_id": False}) self.env.registry.clear_cache() res = super(DmsDirectory, self.with_context(**ctx)).create(vals_list) return res def write(self, vals): if any(k in vals.keys() for k in ["storage_id", "parent_id"]): for item in self: new_storage_id = vals.get("storage_id", item.storage_id.id) new_parent_id = vals.get("parent_id", item.parent_id.id) old_storage_id = ( item.storage_id or item.root_directory_id.storage_id ).id if new_parent_id: if old_storage_id != self.browse(new_parent_id).storage_id.id: raise UserError( _( "It is not possible to change to a parent " "with other storage." ) ) elif old_storage_id != new_storage_id: raise UserError(_("It is not possible to change the storage.")) # Groups part if any(key in vals for key in ["group_ids", "inherit_group_ids"]): res = super().write(vals) domain = [("id", "child_of", self.ids)] records = self.sudo().search(domain) records.modified(["group_ids"]) records.flush_recordset() else: res = super().write(vals) return res def unlink(self): """Custom cascade unlink. Cannot rely on DB backend's cascade because subfolder and subfile unlinks must check custom permissions implementation. """ self.file_ids.unlink() if self.child_directory_ids: self.child_directory_ids.unlink() return super(DmsDirectory, self.exists()).unlink() @api.model def _search_panel_domain_image( self, field_name, domain, set_count=False, limit=False ): """We need to overwrite function from directories because odoo only return records with children (very weird for user perspective). All records are returned now. """ if field_name == "parent_id": res = {} for item in self.search_read( domain=domain, fields=["id", "name", "count_directories"] ): res[item["id"]] = { "id": item["id"], "display_name": item["name"], "__count": item["count_directories"], } return res return super()._search_panel_domain_image( field_name=field_name, domain=domain, set_count=set_count, limit=limit ) def action_dms_directories_all_directory(self): self.ensure_one() action = self.env["ir.actions.act_window"]._for_xml_id( "dms.action_dms_directory" ) domain = AND( [ literal_eval(action["domain"].strip()), [("parent_id", "child_of", self.id)], ] ) action["display_name"] = self.name action["domain"] = domain action["context"] = dict( self.env.context, default_parent_id=self.id, searchpanel_default_parent_id=self.id, ) return action def action_dms_files_all_directory(self): self.ensure_one() action = self.env["ir.actions.act_window"]._for_xml_id("dms.action_dms_file") domain = AND( [ literal_eval(action["domain"].strip()), [("directory_id", "child_of", self.id)], ] ) action["display_name"] = self.name action["domain"] = domain action["context"] = dict( self.env.context, default_directory_id=self.id, searchpanel_default_directory_id=self.id, ) return action