diff --git a/checklog-odoo.cfg b/checklog-odoo.cfg index 0b55b7bf6..486495855 100644 --- a/checklog-odoo.cfg +++ b/checklog-odoo.cfg @@ -1,3 +1,4 @@ [checklog-odoo] ignore= WARNING.* 0 failed, 0 error\(s\).* + WARNING .* Killing chrome descendants-or-self .* diff --git a/dms/README.rst b/dms/README.rst index 2e770f84a..3aaae5e23 100644 --- a/dms/README.rst +++ b/dms/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ========================== Document Management System ========================== @@ -7,23 +11,23 @@ Document Management System !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:966c4331ff7c75b1ea8cb1d065c878d81250957cd305a5d6422def133e2a7d63 + !! source digest: sha256:451b6d4ca876833e0ac116a7abe59895abb355b15d825ea00d205611a4be8c70 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdms-lightgray.png?logo=github - :target: https://github.com/OCA/dms/tree/18.0/dms + :target: https://github.com/OCA/dms/tree/19.0/dms :alt: OCA/dms .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/dms-18-0/dms-18-0-dms + :target: https://translation.odoo-community.org/projects/dms-19-0/dms-19-0-dms :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/dms&target_branch=18.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/dms&target_branch=19.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -68,9 +72,9 @@ To configure this module, you need to: 2. Create a new document storage. You can choose between three options on ``Save Type``: - - ``Database``: Store the files on the database as a field - - ``Attachment``: Store the files as attachments - - ``File``: Store the files on the file system + - ``Database``: Store the files on the database as a field + - ``Attachment``: Store the files as attachments + - ``File``: Store the files on the file system 2. Create an access group ------------------------- @@ -78,13 +82,13 @@ To configure this module, you need to: 1. Next, create an administrative access group. Go to *Configuration -> Access Groups*. - - Create a new group, name it appropriately, and turn on all three - permissions (Create, Write and Unlink. Read is implied and always - enabled). - - Add any other top-level administrative users to the group if - needed (your user should already be there). - - You can create other groups in here later for fine-grained access - control. + - Create a new group, name it appropriately, and turn on all three + permissions (Create, Write and Unlink. Read is implied and always + enabled). + - Add any other top-level administrative users to the group if needed + (your user should already be there). + - You can create other groups in here later for fine-grained access + control. 3. Create a directory --------------------- @@ -94,18 +98,18 @@ To configure this module, you need to: 2. Create a new directory, mark it as root and select the previously created setting. - - Select the *Groups* tab and add your administrative group created - above. If your directory was already created before the group, you - can also add it in the access groups (*Configuration -> Access - Groups*). + - Select the *Groups* tab and add your administrative group created + above. If your directory was already created before the group, you + can also add it in the access groups (*Configuration -> Access + Groups*). 3. In the directory settings, you can also add other access groups (created above) that will be able to: - - read - - create - - write - - delete + - read + - create + - write + - delete Migration ========= @@ -151,28 +155,28 @@ access to that resource, no matter if logged or not. Known issues / Roadmap ====================== -- Files preview in portal -- Allow to download folder in portal and create zip file with all - content -- Save in cache own_root directories and update in every - create/write/unlink function -- Add a migration procedure for converting an storage to attachment one - for populating existing records with attachments as folders -- Add a link from attachment view in chatter to linked documents -- If Inherit permissions from related record (the - inherit_access_from_parent_record field from storage) is changed when - directories already exist, inconsistencies may occur because groups - defined in the directories and subdirectories will still exist, all - groups in these directories should be removed before changing. -- Since portal users can read ``dms.storage`` records, if your module - extends this model to another storage backend that needs using - secrets, remember to forbid access to the secrets fields by other - means. It would be nice to be able to remove that rule at some point. -- Searchpanel in files: Highlight items (shading) without records when - filtering something (by name for example). -- Accessing the clipboard (for example copy share link of - file/directory) is limited to secure connections. It also happens in - any part of Odoo. +- Files preview in portal +- Allow to download folder in portal and create zip file with all + content +- Save in cache own_root directories and update in every + create/write/unlink function +- Add a migration procedure for converting an storage to attachment one + for populating existing records with attachments as folders +- Add a link from attachment view in chatter to linked documents +- If Inherit permissions from related record (the + inherit_access_from_parent_record field from storage) is changed when + directories already exist, inconsistencies may occur because groups + defined in the directories and subdirectories will still exist, all + groups in these directories should be removed before changing. +- Since portal users can read ``dms.storage`` records, if your module + extends this model to another storage backend that needs using + secrets, remember to forbid access to the secrets fields by other + means. It would be nice to be able to remove that rule at some point. +- Searchpanel in files: Highlight items (shading) without records when + filtering something (by name for example). +- Accessing the clipboard (for example copy share link of + file/directory) is limited to secure connections. It also happens in + any part of Odoo. Bug Tracker =========== @@ -180,7 +184,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -196,40 +200,40 @@ Authors Contributors ------------ -- Mathias Markl -- Enric Tobella -- Antoni Romera -- Gelu Boros -- `Tecnativa `__: +- Mathias Markl +- Enric Tobella +- Antoni Romera +- Gelu Boros +- `Tecnativa `__: - - Víctor Martínez - - Pedro M. Baeza - - Jairo Llopis + - Víctor Martínez + - Pedro M. Baeza + - Jairo Llopis -- `Elego `__: +- `Elego `__: - - Yu Weng - - Philip Witte - - Khanh Bui + - Yu Weng + - Philip Witte + - Khanh Bui -- `Subteno `__: +- `Subteno `__: - - Timothée Vannier + - Timothée Vannier -- `Kencove `__: +- `Kencove `__: - - Mohamed Alkobrosli + - Mohamed Alkobrosli Other credits ------------- Some pictures are based on or inspired by: -- `Roundicons `__ -- `Smashicons `__ -- `EmojiOne `__ : Portal DMS icon -- `GitHub Octicons `__ : The main - DMS icon +- `Roundicons `__ +- `Smashicons `__ +- `EmojiOne `__ : Portal DMS icon +- `GitHub Octicons `__ : The main + DMS icon Maintainers ----------- @@ -244,6 +248,6 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. -This module is part of the `OCA/dms `_ project on GitHub. +This module is part of the `OCA/dms `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/dms/__manifest__.py b/dms/__manifest__.py index 595254d23..0f69a1936 100644 --- a/dms/__manifest__.py +++ b/dms/__manifest__.py @@ -4,8 +4,8 @@ { "name": "Document Management System", - "summary": """Document Management System for Odoo""", - "version": "18.0.1.0.0", + "summary": "Document Management System for Odoo", + "version": "19.0.1.0.0", "category": "Document Management", "license": "LGPL-3", "website": "https://github.com/OCA/dms", diff --git a/dms/controllers/main.py b/dms/controllers/main.py index cf2fb612d..38e982214 100644 --- a/dms/controllers/main.py +++ b/dms/controllers/main.py @@ -4,7 +4,7 @@ import json import unicodedata -from odoo import _, http +from odoo import http from odoo.exceptions import AccessError from odoo.http import request @@ -12,7 +12,7 @@ class OnboardingController(http.Controller): - @http.route("/config/dms.forbidden_extensions", type="json", auth="user") + @http.route("/config/dms.forbidden_extensions", type="jsonrpc", auth="user") def forbidden_extensions(self, **_kwargs): params = request.env["ir.config_parameter"].sudo() return { @@ -49,9 +49,15 @@ def upload_dms_file(self, ufile, directory_id, callback=None): } ) except AccessError: - args.append({"error": _("You are not allowed to upload a file here.")}) + args.append( + { + "error": request.env._( + "You are not allowed to upload a file here." + ) + } + ) except Exception: - args.append({"error": _("Something horrible happened")}) + args.append({"error": request.env._("Something horrible happened")}) else: args.append( { diff --git a/dms/controllers/portal.py b/dms/controllers/portal.py index e1eaa7d64..9e8caef52 100644 --- a/dms/controllers/portal.py +++ b/dms/controllers/portal.py @@ -4,9 +4,9 @@ import base64 from typing import Optional # noqa # pylint: disable=unused-import -from odoo import _, http +from odoo import http +from odoo.fields import Domain from odoo.http import content_disposition, request -from odoo.osv.expression import OR from odoo.addons.portal.controllers.portal import CustomerPortal from odoo.addons.web.controllers.utils import ensure_db @@ -64,12 +64,12 @@ def portal_my_dms( sortby, ) = self._searchbar_data(filterby, sortby) # domain - domain = [ - ("id", "in", request.env["dms.directory"]._get_own_root_directories()), - ] + domain = Domain( + [("id", "in", request.env["dms.directory"]._get_own_root_directories())] + ) # search if search and search_in == "name": - domain += OR([[], [("name", "ilike", search)]]) + domain = Domain.AND([domain, Domain.OR([[], [("name", "ilike", search)]])]) # content according to pager and archive selected items = request.env["dms.directory"].search(domain, order=sort_order) request.session["my_dms_folder_history"] = items.ids @@ -235,14 +235,16 @@ def _searchbar_data(self, filterby, sortby): sortby :rtype: tuple[str, dict, dict, str, str] """ - searchbar_sortings = {"name": {"label": _("Name"), "order": "name asc"}} + searchbar_sortings = { + "name": {"label": request.env._("Name"), "order": "name asc"} + } # default sortby if not sortby: sortby = "name" sort_order = searchbar_sortings[sortby]["order"] # search searchbar_inputs = { - "name": {"input": "name", "label": _("Name")}, + "name": {"input": "name", "label": request.env._("Name")}, } if not filterby: filterby = "name" diff --git a/dms/demo/res_users.xml b/dms/demo/res_users.xml index c586a6007..ca02b6a9a 100644 --- a/dms/demo/res_users.xml +++ b/dms/demo/res_users.xml @@ -7,6 +7,6 @@ --> - + diff --git a/dms/models/access_groups.py b/dms/models/access_groups.py index 71113d28b..899c93026 100644 --- a/dms/models/access_groups.py +++ b/dms/models/access_groups.py @@ -3,7 +3,7 @@ # Copyright 2024 Timothée Vannier - Subteno (https://www.subteno.com). # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import ValidationError @@ -47,7 +47,7 @@ class DmsAccessGroups(models.Model): string="Directories", column1="gid", column2="aid", - auto_join=True, + bypass_search_access=True, readonly=True, ) complete_directory_ids = fields.Many2many( @@ -56,7 +56,7 @@ class DmsAccessGroups(models.Model): column1="gid", column2="aid", string="Complete directories", - auto_join=True, + bypass_search_access=True, readonly=True, ) count_users = fields.Integer(compute="_compute_users", store=True) @@ -94,7 +94,7 @@ class DmsAccessGroups(models.Model): column2="uid", string="Group Users", compute="_compute_users", - auto_join=True, + bypass_search_access=True, store=True, recursive=True, ) @@ -104,9 +104,10 @@ def _compute_count_directories(self): for record in self: record.count_directories = len(record.directory_ids) - _sql_constraints = [ - ("name_uniq", "unique (name)", "The name of the group must be unique!") - ] + _name_uniq = models.Constraint( + "unique (name)", + "The name of the group must be unique!", + ) @api.depends( "parent_group_id.perm_inclusive_create", @@ -143,13 +144,13 @@ def default_get(self, fields_list): "parent_group_id", "parent_group_id.users", "group_ids", - "group_ids.users", + "group_ids.user_ids", "explicit_user_ids", ) def _compute_users(self): for record in self: users = ( - record.group_ids.users + record.group_ids.user_ids | record.explicit_user_ids | record.parent_group_id.users ) @@ -158,7 +159,7 @@ def _compute_users(self): def copy_data(self, default=None): vals_list = super().copy_data(default) for group, vals in zip(self, vals_list, strict=False): - vals["name"] = _("%s (copy)") % group.name + vals["name"] = self.env._("%s (copy)", group.name) return vals_list @api.constrains("parent_path") @@ -169,9 +170,9 @@ def _check_parent_recursiveness(self): for one in self.filtered("parent_group_id"): if str(one.id) in one.parent_path.split("/"): raise ValidationError( - _("Parent group '%(parent)s' is child of '%(current)s'.") - % { - "parent": one.parent_group_id.display_name, - "current": one.display_name, - } + self.env._( + "Parent group '%(parent)s' is child of '%(current)s'.", + parent=one.parent_group_id.display_name, + current=one.display_name, + ) ) diff --git a/dms/models/directory.py b/dms/models/directory.py index c183b8832..89d122711 100644 --- a/dms/models/directory.py +++ b/dms/models/directory.py @@ -12,9 +12,9 @@ from collections import defaultdict from typing import Literal # noqa # pylint: disable=unused-import -from odoo import _, api, fields, models, tools +from odoo import api, fields, models, tools from odoo.exceptions import UserError, ValidationError -from odoo.osv.expression import AND, OR +from odoo.fields import Domain from odoo.tools import consteq, human_size from ..tools.file import check_name, unique_name @@ -60,7 +60,7 @@ class DmsDirectory(models.Model): comodel_name="dms.storage", string="Storage", ondelete="restrict", - auto_join=True, + bypass_search_access=True, store=True, ) parent_id = fields.Many2one( @@ -116,7 +116,7 @@ def _default_parent_id(self): comodel_name="dms.directory", inverse_name="parent_id", string="Subdirectories", - auto_join=False, + bypass_search_access=False, copy=True, ) @@ -153,7 +153,7 @@ def _default_parent_id(self): comodel_name="dms.file", inverse_name="directory_id", string="Files", - auto_join=False, + bypass_search_access=False, copy=True, ) @@ -221,7 +221,7 @@ def _get_domain_by_access_groups(self, 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( + result = Domain.OR( [ [("is_root_directory", "=", False)] + result, [("is_root_directory", "=", True)] + self_filter, @@ -379,7 +379,7 @@ def _search_panel_directory(self, **kwargs): # Search @api.model def _search_starred(self, operator, operand): - if operator == "=" and operand: + if operator in ("=", "in") and operand: return [("user_star_ids", "in", [self.env.uid])] return [("user_star_ids", "not in", [self.env.uid])] @@ -412,14 +412,16 @@ 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 + record.count_directories_title = self.env._( + "%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 + record.count_files_title = self.env._("%s Files", files) @api.depends("child_directory_ids", "file_ids") def _compute_count_elements(self): @@ -488,8 +490,17 @@ def _compute_parent_id(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 + ctx = self.env.context + record.parent_id = ( + record.parent_id + or record._origin.parent_id + or ctx.get("default_parent_id") + or ( + ctx.get("active_id") + if ctx.get("active_model") == "dms.directory" + else False + ) + ) @api.depends("is_root_directory", "parent_id") def _compute_root_id(self): @@ -528,7 +539,9 @@ def _onchange_model_id(self): @api.constrains("parent_id") def _check_directory_recursion(self): if self._has_cycle(): - raise ValidationError(_("Error! You cannot create recursive directories.")) + raise ValidationError( + self.env._("Error! You cannot create recursive directories.") + ) return True @api.constrains("storage_id", "model_id") @@ -538,34 +551,40 @@ def _check_storage_id_attachment_model_id(self): ): if not record.model_id: raise ValidationError( - _("A directory has to have model in attachment storage.") + self.env._("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.") + self.env._("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.")) + raise ValidationError( + self.env._("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.") + self.env._( + "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.")) + raise ValidationError( + self.env._("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.")) + raise ValidationError(self.env._("The directory name is invalid.")) if record.is_root_directory: children = record.sudo().storage_id.root_directory_ids else: @@ -576,7 +595,7 @@ def _check_name(self): and child != record ): raise ValidationError( - _("A directory with the same name already exists.") + self.env._("A directory with the same name already exists.") ) # Create, Update, Delete @@ -626,7 +645,7 @@ def message_new(self, msg_dict, custom_values=None): return parent_directory names = parent_directory.child_directory_ids.mapped("name") slugify = self.env["ir.http"]._slugify - subject = slugify(msg_dict.get("subject", _("Alias-Mail-Extraction"))) + subject = slugify(msg_dict.get("subject", self.env._("Alias-Mail-Extraction"))) defaults = dict( {"name": unique_name(subject, names, escape_suffix=True)}, **custom_values ) @@ -678,13 +697,15 @@ def write(self, vals): if new_parent_id: if old_storage_id != self.browse(new_parent_id).storage_id.id: raise UserError( - _( + self.env._( "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.")) + raise UserError( + self.env._("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) @@ -743,7 +764,7 @@ def action_dms_directories_all_directory(self): action = self.env["ir.actions.act_window"]._for_xml_id( "dms.action_dms_directory" ) - domain = AND( + domain = Domain.AND( [ literal_eval(action["domain"].strip()), [("parent_id", "child_of", self.id)], @@ -761,7 +782,7 @@ def action_dms_directories_all_directory(self): 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( + domain = Domain.AND( [ literal_eval(action["domain"].strip()), [("directory_id", "child_of", self.id)], diff --git a/dms/models/dms_category.py b/dms/models/dms_category.py index 5900f4d93..575b01f3f 100644 --- a/dms/models/dms_category.py +++ b/dms/models/dms_category.py @@ -5,7 +5,7 @@ import logging -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) @@ -63,9 +63,10 @@ class DMSCategory(models.Model): count_directories = fields.Integer(compute="_compute_count_directories") count_files = fields.Integer(compute="_compute_count_files") - _sql_constraints = [ - ("name_uniq", "unique (name)", "Category name already exists!"), - ] + _name_uniq = models.Constraint( + "unique (name)", + "Category name already exists!", + ) @api.depends("name", "parent_id.complete_name") def _compute_complete_name(self): @@ -100,5 +101,7 @@ def _compute_count_files(self): @api.constrains("parent_id") def _check_category_recursion(self): if self._has_cycle(): - raise ValidationError(_("Error! You cannot create recursive categories.")) + raise ValidationError( + self.env._("Error! You cannot create recursive categories.") + ) return True diff --git a/dms/models/dms_file.py b/dms/models/dms_file.py index 302b0bb79..6c8bd3168 100644 --- a/dms/models/dms_file.py +++ b/dms/models/dms_file.py @@ -12,9 +12,9 @@ from PIL import Image -from odoo import _, api, fields, models, tools +from odoo import api, fields, models, tools from odoo.exceptions import UserError, ValidationError -from odoo.osv import expression +from odoo.fields import Domain from odoo.tools import consteq, human_size from odoo.tools.mimetypes import guess_mimetype @@ -50,10 +50,18 @@ class DMSFile(models.Model): domain="[('permission_create', '=', True)]", context={"dms_directory_show_path": True}, ondelete="restrict", - auto_join=True, + bypass_search_access=True, required=True, index="btree", tracking=True, # Leave log if "moved" to another directory + default=lambda self: ( + self.env.context.get("default_directory_id") + or ( + self.env.context.get("active_id") + if self.env.context.get("active_model") == "dms.directory" + else False + ) + ), ) root_directory_id = fields.Many2one(related="directory_id.root_directory_id") # Override acording to defined in AbstractDmsMixin @@ -250,7 +258,7 @@ def action_migrate(self, should_logging=True): for dms_file in self: if should_logging: _logger.info( - _( + self.env._( "Migrate File %(index)s of %(record_count)s [ %(" "dms_file_migration)s ]", index=index, @@ -275,7 +283,9 @@ 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.")) + raise UserError( + self.env._("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" ) @@ -300,7 +310,7 @@ def _search_panel_domain(self, field, operator, directory_id, comodel_domain=Fal 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)]]) + return Domain.AND([comodel_domain, [(field, "in", files_ids)]]) @api.model def search_panel_select_range(self, field_name, **kwargs): @@ -505,20 +515,24 @@ def _check_storage_id_attachment_res_model(self): record.res_model and record.res_id ): raise ValidationError( - _("A file must have model and resource ID in attachment storage.") + self.env._( + "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.")) + raise ValidationError(self.env._("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.") + self.env._( + "A file with the same name already exists in this directory." + ) ) @api.constrains("extension") @@ -527,7 +541,9 @@ def _check_extension(self): lambda rec: rec.extension and rec.extension in self._get_forbidden_extensions() ): - raise ValidationError(_("The file has a forbidden file extension.")) + raise ValidationError( + self.env._("The file has a forbidden file extension.") + ) @api.constrains("size") def _check_size(self): @@ -535,7 +551,9 @@ def _check_size(self): 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() + self.env._( + "The maximum upload size is %s MB.", self._get_binary_max_size() + ) ) # Create, Update, Delete diff --git a/dms/models/dms_security_mixin.py b/dms/models/dms_security_mixin.py index 87f65a449..a76d7038f 100644 --- a/dms/models/dms_security_mixin.py +++ b/dms/models/dms_security_mixin.py @@ -4,16 +4,12 @@ # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import functools from logging import getLogger from odoo import api, fields, models from odoo.exceptions import AccessError -from odoo.osv.expression import ( - FALSE_DOMAIN, - NEGATIVE_TERM_OPERATORS, - OR, - TRUE_DOMAIN, -) +from odoo.fields import Domain from odoo.tools import SQL _logger = getLogger(__name__) @@ -60,6 +56,8 @@ class DmsSecurityMixin(models.AbstractModel): @api.model def _get_ref_selection(self): + # All registered models are an intentional choice here. + # pylint: disable=no-search-all models = self.env["ir.model"].sudo().search([]) return [(model.model, model.name) for model in models] @@ -116,14 +114,14 @@ def _get_domain_by_inheritance(self, operation): ] domains = [] # Get all used related records - related_groups = self.sudo().read_group( + related_groups = self.sudo()._read_group( domain=inherited_access_domain + [("res_model", "!=", False)], - fields=["res_id:array_agg"], groupby=["res_model"], + aggregates=["res_id:array_agg"], ) - for group in related_groups: + for res_model, res_id_array in related_groups: try: - model = self.env[group["res_model"]] + model = self.env[res_model] except KeyError: # The model might not be registered. # This is normal if you are upgrading the database. @@ -131,7 +129,7 @@ def _get_domain_by_inheritance(self, operation): # These records will be accessible by DB users only. domains.append( [ - ("res_model", "=", group["res_model"]), + ("res_model", "=", res_model), (True, "=", self.env.user.has_group("base.group_user")), ] ) @@ -143,7 +141,7 @@ def _get_domain_by_inheritance(self, operation): continue domains.append([("res_model", "=", model._name), ("res_id", "=", False)]) # Check record access in batch too - res_ids = [i for i in group["res_id"] if i] # Hack to remove None res_id + res_ids = [i for i in res_id_array if i] # Hack to remove None res_id # Apply exists to skip records that do not exist. (e.g. a res.partner # deleted by database). model_records = model.browse(res_ids).exists() @@ -153,8 +151,7 @@ def _get_domain_by_inheritance(self, operation): domains.append( [("res_model", "=", model._name), ("res_id", "in", related_ok.ids)] ) - result = inherited_access_domain + OR(domains) - return result + return Domain.AND([Domain(inherited_access_domain), Domain.OR(domains)]) @api.model def _get_access_groups_query(self, operation): @@ -201,30 +198,25 @@ def _get_domain_by_access_groups(self, operation): return result @api.model - def _get_permission_domain(self, operator, value, operation): - """Abstract logic for searching computed permission fields.""" - _self = self - # HACK ir.rule domain is always computed with sudo, so if this check is - # true, we can assume safely that you're checking permissions - if self.env.su and value == self.env.uid: - _self = self.sudo(False) - value = bool(value) - # Tricky one, to know if you want to search - # positive or negative access - positive = (operator not in NEGATIVE_TERM_OPERATORS) == bool(value) - if _self.env.su: - # You're SUPERUSER_ID - return TRUE_DOMAIN if positive else FALSE_DOMAIN - - result = OR( + def _get_dms_access_domain(self, operation): + """Domain matching the records the current user may access for + ``operation`` through DMS access groups or inheritance.""" + return Domain.OR( [ - _self._get_domain_by_access_groups(operation), - _self._get_domain_by_inheritance(operation), + self._get_domain_by_access_groups(operation), + self._get_domain_by_inheritance(operation), ] ) - if not positive: - result.insert(0, "!") - return result + + @api.model + def _get_permission_domain(self, operator, value, operation): + """Search implementation of the computed ``permission_`` fields, + used by field domains (e.g. ``directory_id``'s create filter).""" + positive = (operator not in Domain.NEGATIVE_OPERATORS) == bool(value) + if self.env.su: + return Domain.TRUE if positive else Domain.FALSE + result = self._get_dms_access_domain(operation) + return result if positive else ~result @api.model def _search_permission_create(self, operator, value): @@ -242,49 +234,57 @@ def _search_permission_unlink(self, operator, value): def _search_permission_write(self, operator, value): return self._get_permission_domain(operator, value, "write") - def filtered_domain(self, domain): - """This method is needed to inhibit the behavior when called from the - _check_access() method with sudo() https://github.com/odoo/odoo/blob/fc737a147b9aefbd6ae5d111835ce3f4f7b4240a/odoo/models.py#L4465. - It would cause the error that multiple records are not accessed to be - displayed. - The _filtered_access() method is also overwritten to prevent this sudo() - specific behavior and to be able to access only the appropriate records. - """ - if self.env.su: - return self - return super().filtered_domain(domain) - - def _filtered_access_no_recursion(self, operation: str): - """This method is just the same as _filtered_access - but it can not be called withoud super due to - recursion error. - """ - if self and not self.env.su and (result := self._check_access(operation)): - return self - result[0] - return self + def _search( + self, + domain, + offset=0, + limit=None, + order=None, + *, + bypass_access=False, + **kwargs, + ): + """Restrict searches to the records the current user may read through + DMS access groups or inheritance (mirrors ``mail.message._search``).""" + if self.env.su or bypass_access: + return super()._search( + domain, offset, limit, order, bypass_access=bypass_access, **kwargs + ) + domain = Domain.AND([Domain(domain), self._get_dms_access_domain("read")]) + return super()._search(domain, offset, limit, order, **kwargs) - def _filtered_access(self, operation): - # Only kept to not break inheritance; see next comment - result = super()._filtered_access(operation) - # HACK Always fall back to applying rules by SQL. - # Upstream `_filtered_access()` doesn't use computed fields - # search methods. Thus, it will take the `[('permission_{operation}', - # '=', user.id)]` rule literally. Obviously that will always fail - # because `self[f"permission_{operation}"]` will always be a `bool`, - # while `user.id` will always be an `int`. - result |= self._filtered_access_no_recursion(operation) + def _check_access(self, operation): + """Add the DMS access-group / inheritance restriction to the + record-level access check (mirrors ``mail.message._check_access``).""" + result = super()._check_access(operation) + if self.env.su or not any(self._ids): + return result + records = self - result[0] if result else self + forbidden = records._get_forbidden_dms_access(operation) + if forbidden: + if result: + return result[0] + forbidden, result[1] + Rule = self.env["ir.rule"] + return forbidden, functools.partial( + Rule._make_access_error, operation, forbidden + ) return result - def _check_access_dms_record(self, operation: str) -> tuple | None: - """Specific method "similar" to _check_access() but with a different - behavior: check if you do not really have access to any of the records - in to avoid performing the corresponding create/write/unlink action.""" - if any(self._ids) and not self.env.su: - Rule = self.env["ir.rule"] - domain = Rule._compute_domain(self._name, operation) - items = self.with_context(active_test=False).search(domain) - if any(x_id not in items.ids for x_id in self.ids): - raise Rule._make_access_error(operation, (self - items)) + def _get_forbidden_dms_access(self, operation): + """Return the subset of ``self`` the current user cannot access for + ``operation`` under the DMS access-group / inheritance rules. The + domain carries an SQL sub-query, so it is resolved by search (not by + ``filtered_domain``), bypassing access to evaluate exactly this domain.""" + domain = Domain.AND( + [ + Domain([("id", "in", self.ids)]), + self._get_dms_access_domain(operation), + ] + ) + allowed = self.browse( + self.with_context(active_test=False)._search(domain, bypass_access=True) + ) + return self - allowed @api.model_create_multi def create(self, vals_list): @@ -296,13 +296,5 @@ def create(self, vals_list): res.flush_recordset() # Go back to the original sudo state and check we really had creation permission res = res.sudo(self.env.su) - res._check_access_dms_record("create") + res.check_access("create") return res - - def write(self, vals): - self._check_access_dms_record("write") - return super().write(vals) - - def unlink(self): - self._check_access_dms_record("unlink") - return super().unlink() diff --git a/dms/models/storage.py b/dms/models/storage.py index cbc74cf01..7f29d3bdc 100644 --- a/dms/models/storage.py +++ b/dms/models/storage.py @@ -5,7 +5,7 @@ import logging -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import AccessError _logger = logging.getLogger(__name__) @@ -44,7 +44,7 @@ class Storage(models.Model): comodel_name="dms.directory", inverse_name="storage_id", string="Root Directories", - auto_join=False, + bypass_search_access=False, readonly=False, copy=False, ) @@ -52,7 +52,7 @@ class Storage(models.Model): comodel_name="dms.directory", inverse_name="storage_id", string="Directories", - auto_join=False, + bypass_search_access=False, readonly=True, copy=False, ) @@ -60,7 +60,7 @@ class Storage(models.Model): comodel_name="dms.file", inverse_name="storage_id", string="Files", - auto_join=False, + bypass_search_access=False, readonly=True, copy=False, ) @@ -100,7 +100,7 @@ def _onchange_save_type(self): def action_storage_migrate(self): if self.save_type != "attachment": if not self.env.user.has_group("dms.group_dms_manager"): - raise AccessError(_("Only managers can execute this action.")) + raise AccessError(self.env._("Only managers can execute this action.")) files = self.env["dms.file"].with_context(active_test=False).sudo() for record in self: diff --git a/dms/models/tag.py b/dms/models/tag.py index 25dfb9a57..dac0dc799 100644 --- a/dms/models/tag.py +++ b/dms/models/tag.py @@ -16,7 +16,7 @@ class Tag(models.Model): name = fields.Char(required=True, translate=True) active = fields.Boolean( default=True, - help="The active field allows you " "to hide the tag without removing it.", + help="The active field allows you to hide the tag without removing it.", ) category_id = fields.Many2one( comodel_name="dms.category", @@ -44,9 +44,10 @@ class Tag(models.Model): count_directories = fields.Integer(compute="_compute_count_directories") count_files = fields.Integer(compute="_compute_count_files") - _sql_constraints = [ - ("name_uniq", "unique (name, category_id)", "Tag name already exists!"), - ] + _name_uniq = models.Constraint( + "unique (name, category_id)", + "Tag name already exists!", + ) @api.depends("directory_ids") def _compute_count_directories(self): diff --git a/dms/security/security.xml b/dms/security/security.xml index 463f914c3..387d32e7b 100644 --- a/dms/security/security.xml +++ b/dms/security/security.xml @@ -12,17 +12,21 @@ Documents + + Documents + + User - + Manager - + @@ -107,85 +111,4 @@ [('is_hidden', '=', True)] - - - Apply computed create permissions. - - - - - - - [('permission_create', '=', user.id)] - - - Apply computed read permissions. - - - - - - - [('permission_read', '=', user.id)] - - - Apply computed unlink permissions. - - - - - - - [('permission_unlink', '=', user.id)] - - - Apply computed write permissions. - - - - - - - [('permission_write', '=', user.id)] - - - Apply computed create permissions. - - - - - - - [('permission_create', '=', user.id)] - - - Apply computed read permissions. - - - - - - - [('permission_read', '=', user.id)] - - - Apply computed unlink permissions. - - - - - - - [('permission_unlink', '=', user.id)] - - - Apply computed write permissions. - - - - - - - [('permission_write', '=', user.id)] - diff --git a/dms/static/description/index.html b/dms/static/description/index.html index e4b0c4fbe..206c97a33 100644 --- a/dms/static/description/index.html +++ b/dms/static/description/index.html @@ -3,7 +3,7 @@ -Document Management System +README.rst -
-

Document Management System

+
+ + +Odoo Community Association + +
+

Document Management System

-

Beta License: LGPL-3 OCA/dms Translate me on Weblate Try me on Runboat

+

Beta License: LGPL-3 OCA/dms Translate me on Weblate Try me on Runboat

DMS is a module for creating, managing and viewing document files directly within Odoo. This module is only the basis for an entire ecosystem of apps that extend and seamlessly integrate with the document @@ -410,21 +415,21 @@

Document Management System

-

Installation

+

Installation

-

Preview

+

Preview

python-magic library is recommended to be installed for having whole support to get proper file types and file preview.

-

Configuration

+

Configuration

To configure this module, you need to:

-

1. Create a storage

+

1. Create a storage

  1. Go to Documents -> Configuration -> Storages.
  2. Create a new document storage. You can choose between three options @@ -437,15 +442,15 @@

    1. Create a storage

-

2. Create an access group

+

2. Create an access group

  1. Next, create an administrative access group. Go to Configuration -> Access Groups.
    • Create a new group, name it appropriately, and turn on all three permissions (Create, Write and Unlink. Read is implied and always enabled).
    • -
    • Add any other top-level administrative users to the group if -needed (your user should already be there).
    • +
    • Add any other top-level administrative users to the group if needed +(your user should already be there).
    • You can create other groups in here later for fine-grained access control.
    @@ -453,7 +458,7 @@

    2. Create an access group

-

3. Create a directory

+

3. Create a directory

  1. Afterward, go to Documents -> Directories.
  2. Create a new directory, mark it as root and select the previously @@ -476,7 +481,7 @@

    3. Create a directory

-

Migration

+

Migration

If you need to modify the storage Save Type you might want to migrate the file data. To achieve it, you need to:

    @@ -492,19 +497,19 @@

    Migration

    Migration

-

File Wizard Selection

+

File Wizard Selection

There is an action called action_dms_file_wizard_selector to open a wizard to list files in kanban view. This can be used (example dms_attachment_link module) to add a button in kanban view with the action we need.

-

Usage

+

Usage

The best way to manage the documents is to switch to the Documents view. Existing documents can be managed there and new documents can be created.

-

Portal functionality

+

Portal functionality

You can add any portal user to DMS access groups, and then allow that group in directories, so they will see in the portal such directories and their files. Another possibility is to click on “Share” button @@ -513,7 +518,7 @@

Portal functionality

-

Known issues / Roadmap

+

Known issues / Roadmap

  • Files preview in portal
  • Allow to download folder in portal and create zip file with all @@ -540,24 +545,24 @@

    Known issues / Roadmap

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

+feedback.

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • MuK IT
  • Tecnativa
-

Contributors

+

Contributors

-

Other credits

+

Other credits

Some pictures are based on or inspired by:

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -605,10 +610,11 @@

Maintainers

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

-

This module is part of the OCA/dms project on GitHub.

+

This module is part of the OCA/dms project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
diff --git a/dms/static/src/js/fields/preview_binary/preview_record.esm.js b/dms/static/src/js/fields/preview_binary/preview_record.esm.js index ca55cdd5b..65fd2bbaf 100644 --- a/dms/static/src/js/fields/preview_binary/preview_record.esm.js +++ b/dms/static/src/js/fields/preview_binary/preview_record.esm.js @@ -18,7 +18,7 @@ export class PreviewRecordField extends BinaryField { onFilePreview() { const self = this; - const attachment = this.store.Attachment.insert({ + const attachment = this.store["ir.attachment"].insert({ id: self.props.record.resId, filename: self.props.record.data.display_name || "", name: self.props.record.data.display_name || "", diff --git a/dms/static/src/js/views/dms_file_upload.esm.js b/dms/static/src/js/views/dms_file_upload.esm.js index 99320208c..e2024b79c 100644 --- a/dms/static/src/js/views/dms_file_upload.esm.js +++ b/dms/static/src/js/views/dms_file_upload.esm.js @@ -33,7 +33,7 @@ export function createFileDropZoneExtension() { el.removeEventListener("drop", drop); }; }, - // eslint-disable-next-line no-undef + () => [document.querySelector(".o_content")] ); }, diff --git a/dms/static/src/js/views/file_kanban_controller.esm.js b/dms/static/src/js/views/file_kanban_controller.esm.js deleted file mode 100644 index ae3d05ee2..000000000 --- a/dms/static/src/js/views/file_kanban_controller.esm.js +++ /dev/null @@ -1,12 +0,0 @@ -// /** ******************************************************************************** -// Copyright 2020 Creu Blanca -// Copyright 2017-2019 MuK IT GmbH -// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -// **********************************************************************************/ - -import {KanbanController} from "@web/views/kanban/kanban_controller"; -export class FileKanbanController extends KanbanController { - setup() { - super.setup(); - } -} diff --git a/dms/static/src/js/views/file_kanban_controller.xml b/dms/static/src/js/views/file_kanban_controller.xml deleted file mode 100644 index da14578ff..000000000 --- a/dms/static/src/js/views/file_kanban_controller.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - -
- - -
-
-
diff --git a/dms/static/src/js/views/file_kanban_record.esm.js b/dms/static/src/js/views/file_kanban_record.esm.js index 335fb08dd..002ec429e 100644 --- a/dms/static/src/js/views/file_kanban_record.esm.js +++ b/dms/static/src/js/views/file_kanban_record.esm.js @@ -44,7 +44,7 @@ export class FileKanbanRecord extends KanbanRecord { mimetype = self.props.record.data.mimetype; } - const attachment = this.store.Attachment.insert({ + const attachment = this.store["ir.attachment"].insert({ id: self.props.record.data.id, filename: self.props.record.data.name, name: self.props.record.data.name, diff --git a/dms/static/src/js/views/file_kanban_renderer.xml b/dms/static/src/js/views/file_kanban_renderer.xml index a1bb3c4fa..8ba228c1f 100644 --- a/dms/static/src/js/views/file_kanban_renderer.xml +++ b/dms/static/src/js/views/file_kanban_renderer.xml @@ -16,12 +16,12 @@ - - + + diff --git a/dms/static/src/js/views/file_list_renderer.xml b/dms/static/src/js/views/file_list_renderer.xml index 0b1b83418..6a88307b9 100644 --- a/dms/static/src/js/views/file_list_renderer.xml +++ b/dms/static/src/js/views/file_list_renderer.xml @@ -13,7 +13,7 @@ t-inherit="web.ListView.Buttons" t-inherit-mode="primary" > - +