diff --git a/.eslintrc.yml b/.eslintrc.yml index fed88d70d..9d58d1147 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -4,7 +4,7 @@ env: # See https://github.com/OCA/odoo-community.org/issues/37#issuecomment-470686449 parserOptions: - ecmaVersion: 2019 + ecmaVersion: 2020 overrides: - files: diff --git a/dms/README.rst b/dms/README.rst index 55245b89f..16941ceaf 100644 --- a/dms/README.rst +++ b/dms/README.rst @@ -17,23 +17,26 @@ Document Management System :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/16.0/dms + :target: https://github.com/OCA/dms/tree/17.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-16-0/dms-16-0-dms + :target: https://translation.odoo-community.org/projects/dms-17-0/dms-17-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=16.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/dms&target_branch=17.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| -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 management system. +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 +management system. -This module adds portal functionality for directories and files for allowed users, both portal or internal users. You can get as well a tokenized link from a directory or a file for sharing it with any anonymous user. +This module adds portal functionality for directories and files for +allowed users, both portal or internal users. You can get as well a +tokenized link from a directory or a file for sharing it with any +anonymous user. **Table of contents** @@ -44,82 +47,119 @@ Installation ============ Preview -~~~~~~~ +------- -``mail_preview_base`` is required for DMS but it is recommended to install all -the other `mail_preview` modules from `social` OCA repository -in order to improve the preview of files. +``mail_preview_base`` is required for DMS but it is recommended to +install all the other mail_preview modules from social OCA repository in +order to improve the preview of files. -``python-magic`` library is recommended to be installed for having whole support -to get proper file types and file preview. +``python-magic`` library is recommended to be installed for having whole +support to get proper file types and file preview. Configuration ============= To configure this module, you need to: -#. Go to *Documents -> Configuration -> Storages*. -#. Create a new document storage. You can choose between two options on `Save Type`: - * `Database`: Store the files on the database as a field - * `Attachment`: Store the files as attachments -#. 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. -#. Afterwards go to *Documents -> Directories*. -#. 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. -#. On the Directory you can also add other access groups (created above) that will be able to: - * read - * create - * write - * delete +1. Go to *Documents -> Configuration -> Storages*. +2. Create a new document storage. You can choose between two options on + \`Save Type\`: -Migration -~~~~~~~~~ + - \`Database\`: Store the files on the database as a field + - \`Attachment\`: Store the files as attachments + +3. 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. -If you need to modify the storage Save Type you might want to migrate the file data. -In order to achieve it you need to: +4. Afterwards go to *Documents -> Directories*. -#. Go to *Documents -> Configuration -> Storage* and select the storage you want to modify -#. Modify the save type -#. Press the button `Migrate files` if you want to migrate all the files at once -#. Press the button `Manual File Migration` in order to specify files one by one +5. Create a new directory, mark it as root and select the previously + created setting. -You can check all the files that still needs to be migrated from all storages -and migrate them manually on *Documents -> Configuration -> Migration* + - Select the *Groups* tab and add your administrative group created + above. +6. On the Directory you can also add other access groups (created above) + that will be able to: + + - read + - create + - write + - delete + +Migration +--------- + +If you need to modify the storage Save Type you might want to migrate +the file data. In order to achieve it you need to: + +1. Go to *Documents -> Configuration -> Storage* and select the storage + you want to modify +2. Modify the save type +3. Press the button Migrate files if you want to migrate all the files + at once +4. Press the button Manual File Migration in order to specify files one + by one + +You can check all the files that still needs to be migrated from all +storages and migrate them manually on *Documents -> Configuration -> +Migration* 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. +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 ===== 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. +Existing documents can be managed there and new documents can be +created. 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 inside a directory or a file for obtaining a tokenized link for single access to that resource, no matter if logged or not. +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 +inside a directory or a file for obtaining a tokenized link for single +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). +- 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). Bug Tracker =========== @@ -127,7 +167,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. @@ -135,43 +175,43 @@ Credits ======= Authors -~~~~~~~ +------- * MuK IT * Tecnativa Contributors -~~~~~~~~~~~~ +------------ -* Mathias Markl -* Enric Tobella -* Antoni Romera -* Gelu Boros +- Mathias Markl +- Enric Tobella +- Antoni Romera +- Gelu Boros +- `Tecnativa `__: -* `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 Other credits -~~~~~~~~~~~~~ - -The migration of this module from 15.0 to 16.0 was financially supported by `AgentERP `_ +------------- Some pictures are based on or inspired by: -* `Roundicons `_ -* `Smashicons `_ +- `Roundicons `__ +- `Smashicons `__ +- `EmojiOne `__ : Portal DMS icon +- `GitHub Octicons `__ : The main + DMS icon Maintainers -~~~~~~~~~~~ +----------- This module is maintained by the OCA. @@ -183,6 +223,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 45e305d31..ca95a177e 100644 --- a/dms/__manifest__.py +++ b/dms/__manifest__.py @@ -1,10 +1,11 @@ # Copyright 2017-2019 MuK IT GmbH +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). { "name": "Document Management System", "summary": """Document Management System for Odoo""", - "version": "16.0.1.4.0", + "version": "17.0.1.0.0", "category": "Document Management", "license": "LGPL-3", "website": "https://github.com/OCA/dms", @@ -12,40 +13,54 @@ "depends": [ "mail", "http_routing", + "onboarding", "portal", "base", + "web", ], "data": [ + # Security "security/security.xml", "security/ir.model.access.csv", + # Actions "actions/file.xml", - "template/onboarding.xml", - "views/menu.xml", - "views/tag.xml", - "views/category.xml", + # Templates + "template/portal.xml", + # Data + "data/onboarding_data.xml", + # Views + "views/dms_tag.xml", + "views/dms_category.xml", "views/dms_file.xml", - "views/directory.xml", + "views/dms_directory.xml", "views/storage.xml", "views/dms_access_groups_views.xml", "views/res_config_settings.xml", "views/menu.xml", - "views/dms_portal_templates.xml", + "views/menu.xml", # Wizard "wizards/wizard_dms_file_move_views.xml", ], "assets": { - "mail.assets_messaging": [ - ("include", "mail.assets_core_messaging"), - "dms/static/src/models/*.js", - ], "web.assets_backend": [ - "dms/static/src/scss/*", - "dms/static/src/js/fields/*", + # Style + "dms/static/src/scss/directory_kanban.scss", + "dms/static/src/scss/file_kanban.scss", + "dms/static/src/scss/dms_common.scss", + # JS + "dms/static/src/models/*.js", + "dms/static/src/js/fields/path_json/path_owl.esm.js", + "dms/static/src/js/fields/preview_binary/preview_record.esm.js", "dms/static/src/js/views/*.esm.js", + # XML + "dms/static/src/js/fields/path_json/path_owl.xml", + "dms/static/src/js/fields/preview_binary/preview_record.xml", "dms/static/src/js/views/*.xml", - "dms/static/src/js/views/fields/binary/*", ], - "web.assets_frontend": ["dms/static/src/js/dms_portal_tour.js"], + "web.assets_frontend": [ + "dms/static/src/js/dms_portal_tour.esm.js", + "dms/static/src/scss/portal.scss", + ], }, "demo": [ "demo/res_users.xml", @@ -56,6 +71,6 @@ "demo/directory.xml", "demo/file.xml", ], - "images": ["static/description/banner.png"], + "icon": "/dms/static/description/icon.png", "application": True, } diff --git a/dms/controllers/main.py b/dms/controllers/main.py index f2bb55d86..5898e80fb 100644 --- a/dms/controllers/main.py +++ b/dms/controllers/main.py @@ -5,40 +5,6 @@ class OnboardingController(http.Controller): - @http.route("/dms/document_onboarding/directory", auth="user", type="json") - def document_onboarding_directory(self): - company = request.env.user.company_id - closed = company.documents_onboarding_state == "closed" - check = request.env.user.has_group("dms.group_dms_manager") - if check and not closed: - return { - "html": request.env["ir.qweb"]._render( - request.env.ref("dms.document_onboarding_directory_panel").id, - { - "state": company.get_and_update_documents_onboarding_state(), - "company": company, - }, - ) - } - return {} - - @http.route("/dms/document_onboarding/file", auth="user", type="json") - def document_onboarding_file(self): - company = request.env.user.company_id - closed = company.documents_onboarding_state == "closed" - check = request.env.user.has_group("dms.group_dms_manager") - if check and not closed: - return { - "html": request.env["ir.qweb"]._render( - request.env.ref("dms.document_onboarding_file_panel").id, - { - "state": company.get_and_update_documents_onboarding_state(), - "company": company, - }, - ) - } - return {} - @http.route("/config/dms.forbidden_extensions", type="json", auth="user") def forbidden_extensions(self, **_kwargs): params = request.env["ir.config_parameter"].sudo() diff --git a/dms/controllers/portal.py b/dms/controllers/portal.py index 54e4355e3..392688030 100644 --- a/dms/controllers/portal.py +++ b/dms/controllers/portal.py @@ -24,8 +24,9 @@ def _dms_check_access(self, model, res_id, access_token=None): def _prepare_home_portal_values(self, counters): values = super()._prepare_home_portal_values(counters) if "dms_directory_count" in counters: - ids = request.env["dms.directory"]._get_own_root_directories() - values["dms_directory_count"] = len(ids) + values["dms_directory_count"] = request.env["dms.directory"].search_count( + [("is_root_directory", "=", True)] + ) return values @http.route(["/my/dms"], type="http", auth="user", website=True) @@ -33,31 +34,23 @@ def portal_my_dms( self, sortby=None, filterby=None, search=None, search_in="name", **kw ): values = self._prepare_portal_layout_values() - searchbar_sortings = {"name": {"label": _("Name"), "order": "name asc"}} - # default sortby br - if not sortby: - sortby = "name" - sort_br = searchbar_sortings[sortby]["order"] - # search - searchbar_inputs = { - "name": {"input": "name", "label": _("Name")}, - } - if not filterby: - filterby = "name" + ( + filterby, + searchbar_inputs, + searchbar_sortings, + sort_br, + sortby, + ) = self._searchbar_data(filterby, sortby) # domain domain = [ - ( - "id", - "in", - request.env["dms.directory"]._get_own_root_directories(), - ) + ("id", "in", request.env["dms.directory"].search([]).ids), + ("is_root_directory", "=", True), ] # search - if search and search_in: - search_domain = [] - if search_in == "name": - search_domain = OR([search_domain, [("name", "ilike", search)]]) - domain += search_domain + search_domain = [] + if search and search_in == "name": + search_domain = OR([search_domain, [("name", "ilike", search)]]) + domain += search_domain # content according to pager and archive selected items = request.env["dms.directory"].search(domain, order=sort_br) request.session["my_dms_folder_history"] = items.ids @@ -91,68 +84,43 @@ def portal_my_dms_directory( search=None, search_in="name", access_token=None, - **kw + **kw, ): + """ + Display the content of a directory. + + :param Optional[int] dms_directory_id: dms_directory_id + :param Optional[str] sortby: sortby + :param Optional[str] filterby: filterby + :param Optional[str] search: search + :param Optional[str] search_in: search_in + :param Optional[str] access_token: access_token + + :return: response + :rtype: odoo.http.Response + """ ensure_db() # operations - searchbar_sortings = {"name": {"label": _("Name"), "order": "name asc"}} - # default sortby br - if not sortby: - sortby = "name" - sort_br = searchbar_sortings[sortby]["order"] - # search - searchbar_inputs = { - "name": {"input": "name", "label": _("Name")}, - } - if not filterby: - filterby = "name" - # domain - domain = [("is_hidden", "=", False), ("parent_id", "=", dms_directory_id)] - # search - if search and search_in: - search_domain = [] - if search_in == "name": - search_domain = OR([search_domain, [("name", "ilike", search)]]) - domain += search_domain - # content according to pager and archive selected - if access_token: - dms_directory_items = ( - request.env["dms.directory"].sudo().search(domain, order=sort_br) - ) - else: - dms_directory_items = request.env["dms.directory"].search( - domain, order=sort_br - ) - request.session["my_dms_folder_history"] = dms_directory_items.ids - res = self._dms_check_access("dms.directory", dms_directory_id, access_token) + ( + filterby, + searchbar_inputs, + searchbar_sortings, + sort_br, + sortby, + ) = self._searchbar_data(filterby, sortby) + dms_directory_items, res = self._get_directories( + access_token, dms_directory_id, search, search_in, sort_br + ) if not res: - if access_token: - return request.redirect("/") - else: - return request.redirect("/my") + return request.redirect("/" if access_token else "/my") + dms_directory_sudo = res # dms_files_count - domain = [ - ("is_hidden", "=", False), - ("directory_id", "=", dms_directory_id), - ] - # search - if search and search_in: - search_domain = [] - if search_in == "name": - search_domain = OR([search_domain, [("name", "ilike", search)]]) - domain += search_domain - # items - if access_token: - dms_file_items = ( - request.env["dms.file"].sudo().search(domain, order=sort_br) - ) - else: - dms_file_items = request.env["dms.file"].search(domain, order=sort_br) - request.session["my_dms_file_history"] = dms_file_items.ids - dms_parent_categories = dms_directory_sudo.sudo()._get_parent_categories( - access_token + dms_file_items = self._get_files( + access_token, dms_directory_id, search, search_in, sort_br ) + + dms_parent_categories = dms_directory_sudo.sudo()._get_parent_categories() # values values = { "dms_directories": dms_directory_items, @@ -170,6 +138,104 @@ def portal_my_dms_directory( } return request.render("dms.portal_my_dms", values) + def _get_files(self, access_token, dms_directory_id, search, search_in, sort_br): + """ + Get files from dms_directory_id + + :param Optional[str] access_token: access_token + :param int dms_directory_id: dms_directory_id + :param Optional[str] search: search + :param Optional[str] search_in: search_in + :param str sort_br: sort_br + + :return: dms_file_items + :rtype: odoo.model.dms_file + """ + allowed_directory_from_access_group = ( + request.env["dms.access.group"] + .search([("users", "in", request.env.uid)]) + .directory_ids + ) + has_all_access = request.env["res.users"]._has_all_access(request.env.uid) + if ( + dms_directory_id not in allowed_directory_from_access_group.ids + and not has_all_access + ): + return request.env["dms.file"] + file_domain = [ + ("is_hidden", "=", False), + ("directory_id", "=", dms_directory_id), + ] + # search + if search and search_in == "name": + file_domain.append(("name", "ilike", search)) + + # items + file_model = request.env["dms.file"] + file_model = file_model.sudo() if access_token else file_model + dms_file_items = file_model.search(file_domain, order=sort_br) + request.session["my_dms_file_history"] = dms_file_items.ids + return dms_file_items + + def _get_directories( + self, access_token, dms_directory_id, search, search_in, sort_br + ): + """ + Get directories from dms_directory_id + + :param Optional[str] access_token: access_token + :param int dms_directory_id: dms_directory_id + :param Optional[str] search: search + :param Optional[str] search_in: search_in + :param str sort_br: sort_br + + :return: dms_directory_items, res + :rtype: tuple[odoo.model.dms_directory, bool|odoo.model.dms_directory] + """ + # domain + domain = [("is_hidden", "=", False), ("parent_id", "=", dms_directory_id)] + # search + if search and search_in: + domain.append(("name", "ilike", search)) + + # content according to pager and archive selected + directory_model = request.env["dms.directory"] + directory_model = directory_model.sudo() if access_token else directory_model + dms_directory_items = directory_model.search(domain, order=sort_br) + + request.session["my_dms_folder_history"] = dms_directory_items.ids + res = self._dms_check_access("dms.directory", dms_directory_id, access_token) + return dms_directory_items, res + + def _searchbar_data(self, filterby, sortby): + """ + Prepare searchbar data for portal. + + :param str filterby: filterby + :param str sortby: sortby + + :return: filterby, searchbar_inputs, searchbar_sortings, selected_sort_order, sortby + :rtype: tuple[str, dict, dict, str, str] + """ + searchbar_sortings = {"name": {"label": _("Name"), "order": "name asc"}} + # default sortby br + if not sortby: + sortby = "name" + selected_sort_order = searchbar_sortings[sortby]["order"] + # search + searchbar_inputs = { + "name": {"input": "name", "label": _("Name")}, + } + if not filterby: + filterby = "name" + return ( + filterby, + searchbar_inputs, + searchbar_sortings, + selected_sort_order, + sortby, + ) + @http.route( ["/my/dms/file//download"], type="http", @@ -194,9 +260,9 @@ def portal_my_dms_file_download(self, dms_file_id, access_token=None, **kw): ): dms_file_sudo = dms_file_sudo.sudo() filecontent = base64.b64decode(dms_file_sudo.content) - content_type = ["Content-Type", "application/octet-stream"] - disposition_content = [ + content_type = ("Content-Type", "application/octet-stream") + disposition_content = ( "Content-Disposition", content_disposition(dms_file_sudo.name), - ] + ) return request.make_response(filecontent, [content_type, disposition_content]) diff --git a/dms/data/onboarding_data.xml b/dms/data/onboarding_data.xml new file mode 100644 index 000000000..96489f062 --- /dev/null +++ b/dms/data/onboarding_data.xml @@ -0,0 +1,92 @@ + + + + + + + Storage + Create a new Document Storage. + Create Storage + Document Storage Created! + action_open_documents_onboarding_storage + + onboarding_default.png + Onboarding Storage + 1 + + + + Directory + Create a new Root Directory. + Create Directory + Root Directory Created! + action_open_documents_onboarding_directory + + onboarding_default.png + Onboarding Directory + 2 + + + + File + Upload your first File. + Upload File + First File Uploaded! + action_open_documents_onboarding_file + + onboarding_default.png + Onboarding File + 3 + + + + + Directory Onboarding + + document_onboarding_directory + action_close_panel_dms_directory + + + + + File Onboarding + + document_onboarding_file + action_close_panel_dms_directory + + diff --git a/dms/demo/access_group.xml b/dms/demo/access_group.xml index 3af5ab420..8b78b14da 100644 --- a/dms/demo/access_group.xml +++ b/dms/demo/access_group.xml @@ -3,8 +3,10 @@ Admin True + True True True + True Portal + True Only admin user True + True True True + True diff --git a/dms/models/__init__.py b/dms/models/__init__.py index 9f158f8a9..7419f28c2 100644 --- a/dms/models/__init__.py +++ b/dms/models/__init__.py @@ -1,17 +1,21 @@ -from . import access_groups +from . import dms_access_groups from . import base +from . import ir_rule from . import mixins_thumbnail from . import dms_security_mixin from . import abstract_dms_mixin from . import storage -from . import directory +from . import dms_directory from . import dms_file -from . import category +from . import onboarding_onboarding +from . import onboarding_onboarding_step +from . import dms_category from . import tag from . import res_company +from . import res_users from . import res_config_settings from . import ir_attachment from . import mail_thread diff --git a/dms/models/abstract_dms_mixin.py b/dms/models/abstract_dms_mixin.py index 009b60253..ebd382525 100644 --- a/dms/models/abstract_dms_mixin.py +++ b/dms/models/abstract_dms_mixin.py @@ -1,3 +1,4 @@ +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import api, fields, models @@ -30,7 +31,7 @@ class AbstractDmsMixin(models.AbstractModel): color = fields.Integer(default=0) category_id = fields.Many2one( comodel_name="dms.category", - context="{'dms_category_show_path': True}", + context={"dms_category_show_path": True}, string="Category", ) diff --git a/dms/models/base.py b/dms/models/base.py index c2ba7e42e..9207655a3 100644 --- a/dms/models/base.py +++ b/dms/models/base.py @@ -1,5 +1,6 @@ # Copyright 2021 Tecnativa - Jairo Llopis # Copyright 2024 Tecnativa - Víctor Martínez +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). from odoo import models @@ -9,9 +10,11 @@ class Base(models.AbstractModel): _inherit = "base" def unlink(self): - """Cascade DMS related resources removal. - Avoid executing in ir.* models (ir.mode, ir.model.fields, etc), in transient - models and in the models we want to check.""" + """ + Cascade DMS related resource removal. + Avoid executing in ir.* models (ir.mode, ir.model.fields, etc.), in transient + models and in the models we want to check. + """ result = super().unlink() if ( not self._name.startswith("ir.") @@ -19,6 +22,15 @@ def unlink(self): and self._name not in ("dms.file", "dms.directory") ): domain = [("res_model", "=", self._name), ("res_id", "in", self.ids)] - self.env["dms.file"].sudo().search(domain).unlink() - self.env["dms.directory"].sudo().search(domain).unlink() + + # Has to check if existing before unlinking, because even if the search returns an empty recordset, + # it will still call the unlink method on it, then calling the current unlink method again, + # resulting in an infinite loop and a recursion depth error. + files = self.env["dms.file"].sudo().search(domain) + if files: + files.unlink() + + directories = self.env["dms.directory"].sudo().search(domain) + if directories: + directories.unlink() return result diff --git a/dms/models/access_groups.py b/dms/models/dms_access_groups.py similarity index 85% rename from dms/models/access_groups.py rename to dms/models/dms_access_groups.py index 371de2f5d..064e1509d 100644 --- a/dms/models/access_groups.py +++ b/dms/models/dms_access_groups.py @@ -1,5 +1,6 @@ # Copyright 2017-2019 MuK IT GmbH # Copyright 2020 RGB Consulting +# 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 @@ -19,6 +20,7 @@ class DmsAccessGroups(models.Model): perm_create = fields.Boolean(string="Create Access") perm_write = fields.Boolean(string="Write Access") perm_unlink = fields.Boolean(string="Unlink Access") + perm_read = fields.Boolean(string="Read Access", default=True) # Permissions computed including parent group perm_inclusive_create = fields.Boolean( @@ -39,15 +41,21 @@ class DmsAccessGroups(models.Model): store=True, recursive=True, ) + perm_inclusive_read = fields.Boolean( + string="Inherited Read Access", + compute="_compute_inclusive_permissions", + store=True, + recursive=True, + ) + has_all_access = fields.Boolean( + string="Has All Access", + help="If checked, this group has access to all directories.", + ) directory_ids = fields.Many2many( comodel_name="dms.directory", - relation="dms_directory_groups_rel", string="Directories", - column1="gid", - column2="aid", - auto_join=True, - readonly=True, + readonly=False, ) complete_directory_ids = fields.Many2many( comodel_name="dms.directory", @@ -111,28 +119,29 @@ def _compute_count_directories(self): "parent_group_id.perm_inclusive_create", "parent_group_id.perm_inclusive_unlink", "parent_group_id.perm_inclusive_write", + "parent_group_id.perm_inclusive_read", "parent_path", "perm_create", "perm_unlink", "perm_write", + "perm_read", ) def _compute_inclusive_permissions(self): """Provide full permissions inheriting from parent recursively.""" for one in self: one.update( { - "perm_inclusive_%s" - % perm: ( + "perm_inclusive_%s" % perm: ( one["perm_%s" % perm] or one.parent_group_id["perm_inclusive_%s" % perm] ) - for perm in ("create", "unlink", "write") + for perm in ("create", "unlink", "write", "read") } ) @api.model def default_get(self, fields_list): - res = super(DmsAccessGroups, self).default_get(fields_list) + res = super().default_get(fields_list) if "explicit_user_ids" in res and res["explicit_user_ids"]: res["explicit_user_ids"] = res["explicit_user_ids"] + [self.env.uid] else: @@ -167,3 +176,8 @@ def _check_parent_recursiveness(self): "current": one.display_name, } ) + + def copy(self, default=None): + default = dict(default or {}) + default["name"] = _("%s (copy)") % self.name + return super().copy(default=default) diff --git a/dms/models/category.py b/dms/models/dms_category.py similarity index 84% rename from dms/models/category.py rename to dms/models/dms_category.py index fecabe57c..81b52184a 100644 --- a/dms/models/category.py +++ b/dms/models/dms_category.py @@ -1,5 +1,6 @@ # Copyright 2020 Creu Blanca # Copyright 2017-2019 MuK IT GmbH +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import logging @@ -10,7 +11,7 @@ _logger = logging.getLogger(__name__) -class Category(models.Model): +class DMSCategory(models.Model): _name = "dms.category" _description = "Document Category" @@ -21,11 +22,9 @@ class Category(models.Model): _rec_name = "complete_name" # ---------------------------------------------------------- - # Database + # Fields # ---------------------------------------------------------- - name = fields.Char(required=True, translate=True) - active = fields.Boolean( default=True, help="The active field allows you to hide the category without removing it.", @@ -34,65 +33,55 @@ class Category(models.Model): compute="_compute_complete_name", store=True, recursive=True ) parent_id = fields.Many2one( - comodel_name="dms.category", string="Parent Category", + comodel_name="dms.category", ondelete="cascade", index="btree", ) - child_category_ids = fields.One2many( + string="Child Categories", comodel_name="dms.category", inverse_name="parent_id", - string="Child Categories", ) - parent_path = fields.Char(index="btree", unaccent=False) tag_ids = fields.One2many( - comodel_name="dms.tag", inverse_name="category_id", string="Tags" + string="Tags", comodel_name="dms.tag", inverse_name="category_id" ) directory_ids = fields.One2many( + string="Directories", comodel_name="dms.directory", inverse_name="category_id", - string="Directories", readonly=True, ) - file_ids = fields.One2many( + string="Files", comodel_name="dms.file", inverse_name="category_id", - string="Files", readonly=True, ) - count_categories = fields.Integer( - compute="_compute_count_categories", string="Count Subcategories" + string="Count Subcategories", compute="_compute_count_categories" ) - count_tags = fields.Integer(compute="_compute_count_tags") - count_directories = fields.Integer(compute="_compute_count_directories") - count_files = fields.Integer(compute="_compute_count_files") # ---------------------------------------------------------- - # Constrains + # SQL Constraints # ---------------------------------------------------------- - _sql_constraints = [ ("name_uniq", "unique (name)", "Category name already exists!"), ] # ---------------------------------------------------------- - # Read + # Compute/Search # ---------------------------------------------------------- - @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, + category.complete_name = ( + f"{category.parent_id.complete_name} / {category.name}" ) else: category.complete_name = category.name @@ -117,18 +106,9 @@ def _compute_count_files(self): for record in self: record.count_files = len(record.file_ids) - def name_get(self): - if not self.env.context.get("category_short_name", False): - return super().name_get() - vals = [] - for record in self: - vals.append(tuple([record.id, record.name])) - return vals - # ---------------------------------------------------------- - # Create + # Onchange/Constrains # ---------------------------------------------------------- - @api.constrains("parent_id") def _check_category_recursion(self): if not self._check_recursion(): diff --git a/dms/models/directory.py b/dms/models/dms_directory.py similarity index 88% rename from dms/models/directory.py rename to dms/models/dms_directory.py index 04bc49e6c..fb2ceb433 100644 --- a/dms/models/directory.py +++ b/dms/models/dms_directory.py @@ -1,6 +1,7 @@ # 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 @@ -12,7 +13,7 @@ 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.tools import human_size from odoo.addons.http_routing.models.ir_http import slugify @@ -42,6 +43,13 @@ class DmsDirectory(models.Model): _parent_name = "parent_id" _directory_field = _parent_name + 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 + parent_path = fields.Char(index="btree", unaccent=False) is_root_directory = fields.Boolean( default=False, @@ -49,21 +57,20 @@ class DmsDirectory(models.Model): 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 + # Override according to defined in AbstractDmsMixin storage_id = fields.Many2one( + string="Storage", 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", + comodel_name="dms.directory", domain="[('permission_create', '=', True)]", ondelete="restrict", # Access to a directory doesn't necessarily mean access its parent, so @@ -76,23 +83,14 @@ class DmsDirectory(models.Model): copy=True, default=lambda self: self._default_parent_id(), ) - root_directory_id = fields.Many2one( - "dms.directory", "Root Directory", compute="_compute_root_id", store=True + string="Root Directory", + comodel_name="dms.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( @@ -108,17 +106,20 @@ def _default_parent_id(self): recursive=True, ) complete_name = fields.Char( - compute="_compute_complete_name", store=True, recursive=True + string="Complete Name", + compute="_compute_complete_name", + store=True, + recursive=True, ) child_directory_ids = fields.One2many( + string="Subdirectories", comodel_name="dms.directory", inverse_name="parent_id", - string="Subdirectories", auto_join=False, - copy=False, + copy=True, ) - tag_ids = fields.Many2many( + string="Tags", comodel_name="dms.tag", relation="dms_directory_tag_rel", domain="""[ @@ -127,12 +128,10 @@ def _default_parent_id(self): """, 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", @@ -140,62 +139,57 @@ def _default_parent_id(self): column2="uid", string="Stars", ) - starred = fields.Boolean( compute="_compute_starred", inverse="_inverse_starred", search="_search_starred", ) - file_ids = fields.One2many( + string="Files", comodel_name="dms.file", inverse_name="directory_id", - string="Files", auto_join=False, - copy=False, + copy=True, ) - count_directories = fields.Integer( - compute="_compute_count_directories", string="Count Subdirectories Title" + string="Count Subdirectories Title", compute="_compute_count_directories" ) - count_files = fields.Integer( - compute="_compute_count_files", string="Count Files Title" + string="Count Files Title", compute="_compute_count_files" ) - count_directories_title = fields.Char( - compute="_compute_count_directories", string="Count Subdirectories" + string="Count Subdirectories", compute="_compute_count_directories" ) - count_files_title = fields.Char( - compute="_compute_count_files", string="Count Files" + string="Count Files", compute="_compute_count_files" ) - count_elements = fields.Integer(compute="_compute_count_elements") - count_total_directories = fields.Integer( - compute="_compute_count_total_directories", string="Total Subdirectories" + string="Total Subdirectories", compute="_compute_count_total_directories" ) - count_total_files = fields.Integer( - compute="_compute_count_total_files", string="Total Files" + string="Total Files", compute="_compute_count_total_files" ) - count_total_elements = fields.Integer( - compute="_compute_count_total_elements", string="Total Elements" + string="Total Elements", compute="_compute_count_total_elements" ) - size = fields.Float(compute="_compute_size") - human_size = fields.Char(compute="_compute_human_size", string="Size") - - inherit_group_ids = fields.Boolean(string="Inherit Groups", default=True) - + human_size = fields.Char(string="Size", compute="_compute_human_size") + inherit_group_ids = fields.Boolean( + string="Inherit Groups", + default=True, + help="If checked, the directory will inherit the groups from its parent directory.", + ) + is_first_creation = fields.Boolean( + string="First Creation", + help="Indicates if the directory is being created for the first time. Should always be False, and is only used internally.", + ) alias_process = fields.Selection( + string="Unpack Emails as", selection=[("files", "Single Files"), ("directory", "Subdirectory")], required=True, default="directory", - string="Unpack Emails as", - help="""\ + 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 @@ -204,6 +198,30 @@ def _default_parent_id(self): are created as files of the subdirectory. """, ) + 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.model def _get_domain_by_access_groups(self, operation): @@ -231,7 +249,7 @@ def _get_domain_by_access_groups(self, operation): def _compute_access_url(self): res = super()._compute_access_url() for item in self: - item.access_url = "/my/dms/directory/%s" % (item.id) + item.access_url = "/my/dms/directory/%s" % item.id return res def check_access_token(self, access_token=False): @@ -258,64 +276,15 @@ def check_access_token(self, access_token=False): return res @api.model - def _get_parent_categories(self, access_token): + def _get_parent_categories(self): 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: @@ -330,22 +299,13 @@ def _inverse_model_id(self): for record in self: record.res_model = record.model_id.model - def name_get(self): - if not self.env.context.get("directory_short_name", False): - return super().name_get() - vals = [] - for record in self: - vals.append(tuple([record.id, record.name])) - return vals - def toggle_starred(self): updates = defaultdict(set) for record in self: vals = {"starred": not record.starred} updates[tools.frozendict(vals)].add(record.id) - with self.env.norecompute(): - for vals, ids in updates.items(): - self.browse(ids).write(dict(vals)) + for vals, ids in updates.items(): + self.browse(ids).write(dict(vals)) self.flush_recordset() # ---------------------------------------------------------- @@ -528,7 +488,8 @@ def _compute_root_id(self): def _compute_tags(self): for record in self: tags = record.tag_ids.filtered( - lambda rec: not rec.category_id or rec.category_id == record.category_id + lambda rec, record=record: not rec.category_id + or rec.category_id == record.category_id ) record.tag_ids = tags @@ -591,14 +552,12 @@ def _check_name(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: - childs = record.sudo().storage_id.root_directory_ids.name_get() + childs = record.sudo().storage_id.root_directory_ids else: - childs = record.sudo().parent_id.child_directory_ids.name_get() - if list( - filter( - lambda child: child[1] == record.name and child[0] != record.id, - childs, - ) + childs = record.sudo().parent_id.child_directory_ids + if childs.filtered( + lambda child, record=record: child.name == record.name + and child != record ): raise ValidationError( _("A directory with the same name already exists.") @@ -630,12 +589,7 @@ def copy(self, default=None): else: names = self.sudo().parent_id.child_directory_ids.mapped("name") default.update({"name": unique_name(self.name, names)}) - new = super().copy(default) - for record in self.file_ids: - record.copy({"directory_id": new.id}) - for record in self.child_directory_ids: - record.copy({"parent_id": new.id}) - return new + return super().copy(default) def _alias_get_creation_values(self): values = super()._alias_get_creation_values() @@ -688,6 +642,7 @@ def _process_message(self, msg_dict, extra_values=False): @api.model_create_multi def create(self, vals_list): for vals in vals_list: + vals["is_first_creation"] = True if vals.get("parent_id", False): parent = self.browse([vals["parent_id"]]) data = next(iter(parent.sudo().read(["storage_id"])), {}) @@ -699,7 +654,7 @@ def create(self, vals_list): return res def write(self, vals): - if any([k in vals.keys() for k in ["storage_id", "parent_id"]]): + 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) @@ -709,17 +664,18 @@ def write(self, vals): if new_parent_id: if old_storage_id != self.browse(new_parent_id).storage_id.id: raise UserError( - _("It is not possible to change parent to other storage.") + _( + "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"]): - with self.env.norecompute(): - res = super(DmsDirectory, self).write(vals) - domain = [("id", "child_of", self.ids)] - records = self.sudo().search(domain) - records.modified(["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) diff --git a/dms/models/dms_file.py b/dms/models/dms_file.py index 459d1fd75..8896f3532 100644 --- a/dms/models/dms_file.py +++ b/dms/models/dms_file.py @@ -1,6 +1,7 @@ # 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 @@ -22,7 +23,7 @@ _logger = logging.getLogger(__name__) -class File(models.Model): +class DMSFile(models.Model): _name = "dms.file" _description = "File" @@ -38,20 +39,18 @@ class File(models.Model): _order = "name asc" # ---------------------------------------------------------- - # Database + # Fields # ---------------------------------------------------------- - 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", + comodel_name="dms.directory", domain="[('permission_create', '=', True)]", - context="{'dms_directory_show_path': True}", + context={"dms_directory_show_path": True}, ondelete="restrict", auto_join=True, required=True, @@ -63,33 +62,28 @@ class File(models.Model): storage_id = fields.Many2one( related="directory_id.storage_id", readonly=True, - store=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( + string="Tags", 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", @@ -98,45 +92,60 @@ class File(models.Model): 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 + string="Type", compute="_compute_mimetype", readonly=True, store=True ) - size = fields.Float(readonly=True) human_size = fields.Char( - readonly=True, string="Size", compute="_compute_human_size", store=True + string="Size", readonly=True, compute="_compute_human_size", store=True ) - checksum = fields.Char(string="Checksum/SHA1", readonly=True, index="btree") - - content_binary = fields.Binary(attachment=False, prefetch=False, invisible=True) - + content_binary = fields.Binary(attachment=False, prefetch=False) save_type = fields.Char( - compute="_compute_save_type", string="Current Save Type", - invisible=True, + compute="_compute_save_type", prefetch=False, ) - migration = fields.Char( - compute="_compute_migration", string="Migration Status", + compute="_compute_migration", 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, invisible=True) - + content_file = fields.Binary(attachment=True, prefetch=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( + string="Attachment File", + comodel_name="ir.attachment", + prefetch=False, + ondelete="cascade", + ) + locked_by = fields.Many2one(comodel_name="res.users") + is_locked = fields.Boolean( + string="Locked", + compute="_compute_locked", + ) + is_lock_editor = fields.Boolean( + string="Editor", + compute="_compute_locked", + ) # Extend inherited field(s) image_1920 = fields.Image(compute="_compute_image_1920", store=True, readonly=False) + # ---------------------------------------------------------- + # Compute/Search + # ---------------------------------------------------------- @api.depends("mimetype", "content") def _compute_image_1920(self): """Provide thumbnail automatically if possible.""" @@ -152,257 +161,12 @@ def _compute_image_1920(self): ): 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): - res = False - if access_token: - if self.access_token and consteq(self.access_token, access_token): - return True - else: - 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 - else: - 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 + item.access_url = "/my/dms/file/%s/download" % item.id return res - 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, - invisible=True, - 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, logging=True): - record_count = len(self) - index = 1 - for dms_file in self: - if 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}) - - 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": - 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", False) == "dms.directory": - active_id = self.env.context.get("active_id") - # para saber que directorios, buscamos las posibles carpetas que nos interesan - files = self.env["dms.file"].search( - [["directory_id", "child_of", active_id]] - ) - all_directories = files.mapped("directory_id") - all_directories += files.mapped("directory_id.parent_id") - domain.append(["id", "in", all_directories.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"]: - field_range[item["id"]]["__count"] = item["__count"] - return {"parent_field": "parent_id", "values": list(field_range.values())} - context = {} - if field_name == "category_id": - context["category_short_name"] = True - return super(File, self.with_context(**context)).search_panel_select_range( - field_name, **kwargs - ) - - @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( - File, self.with_context(directory_short_name=True) - ).search_panel_select_multi_range( - field_name, comodel_domain=directory_comodel_domain, **kwargs - ) - return super( - File, 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"] @@ -429,7 +193,7 @@ def _compute_path(self): current_dir = current_dir.parent_id record.update( { - "path_names": "/".join(path_names), + "path_names": "/".join(path_names) if all(path_names) else "", "path_json": json.dumps(path_json), } ) @@ -491,23 +255,31 @@ def _compute_migration(self): else: storage_label = selection.get(storage_type) file_label = selection.get(record.save_type) - record.migration = "{} > {}".format(file_label, storage_label) + record.migration = f"{file_label} > {storage_label}" record.require_migration = True + @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}) + # ---------------------------------------------------------- - # View + # Onchange/Constrains # ---------------------------------------------------------- - @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: @@ -523,13 +295,13 @@ 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.name_get() - if list( - filter( - lambda file: file[1] == record.name and file[0] != record.id, files - ) + 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.")) + raise ValidationError( + _("A file with the same name already exists in this directory.") + ) @api.constrains("extension") def _check_extension(self): @@ -550,22 +322,113 @@ def _check_size(self): ) # ---------------------------------------------------------- - # Create, Update, Delete + # 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 _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) - with self.env.norecompute(): - for vals, ids in updates.items(): - self.browse(ids).write(dict(vals)) + def action_save_onboarding_file_step(self): + self.ensure_one() + self.env.user.company_id.set_onboarding_step_done( + "documents_onboarding_file_state" + ) + + # ---------------------------------------------------------- + # Other Business Methods + # ---------------------------------------------------------- + def check_access_rule(self, operation): + self.directory_id.check_access_rule(operation) + return super().check_access_rule(operation) + + 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 + + def get_human_size(self): + return human_size(self.size) + + @api.model + def _get_checksum(self, binary): + return hashlib.sha1(binary or b"").hexdigest() + + @api.model + def _get_content_initial_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 "" 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"): @@ -596,64 +459,12 @@ def _create_model_attachment(self, vals): del res_vals["content"] return res_vals - def copy(self, default=None): - self.ensure_one() - default = dict(default or []) - if "directory_id" in default: - model = self.env["dms.directory"] - directory = model.browse(default["directory_id"]) - names = directory.sudo().file_ids.mapped("name") - else: - names = self.sudo().directory_id.file_ids.mapped("name") - default.update({"name": file.unique_name(self.name, names, self.extension)}) - return super(File, self).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(File, self).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, @@ -663,8 +474,10 @@ def get_attachment_object(self, attachment): } def get_dms_files_from_attachments(self, attachment_ids=None): - """Get the dms files from uploaded attachments. + """ + Get the dms files from uploaded attachments. :return: An Array of dms files. + :rtype: list[odoo.model.dms_file] """ if not attachment_ids: raise UserError(_("No attachment was provided")) @@ -678,3 +491,192 @@ def get_dms_files_from_attachments(self, attachment_ids=None): raise UserError(_("Invalid attachments!")) return [self.get_attachment_object(attachment) for attachment in attachments] + + 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 + # ---------------------------------------------------------- + @staticmethod + def _search_panel_directory(**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": + 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", False) == "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"]: + field_range[item["id"]]["__count"] = item["__count"] + return {"parent_field": "parent_id", "values": list(field_range.values())} + 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 + ) + + @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) + + # ---------------------------------------------------------- + # Create, Update, Delete + # ---------------------------------------------------------- + + def _inverse_content(self): + updates = defaultdict(set) + for record in self: + values = self._get_content_initial_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 copy(self, default=None): + self.ensure_one() + default = dict(default or []) + if "directory_id" in default: + directory = self.env["dms.directory"].browse( + default.get("directory_id", False) + ) + names = directory.sudo().file_ids.mapped("name") + else: + names = self.sudo().directory_id.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) + + def write(self, vals): + """ + Override in order to sudo the write if the user has write permission (because rule_file_locked might prevent it, + even if the user has write permission). + If the user has write permission it means he either has the rights to do so, or he is a manager. + See _compute_permissions in dms_security_mixin.py for more details. + """ + if self.permission_write: + return super(DMSFile, self.sudo()).write(vals) + return super().write(vals) + + def unlink(self): + """ + Override in order to sudo the unlink if the user has unlink permission (because rule_file_locked + might prevent it, even if the user has unlink permission). + If the user has unlink permission it means he either has the rights to do so, or he is a manager. + See _compute_permissions in dms_security_mixin.py for more details. + """ + for record in self: + if record.permission_unlink: + super(DMSFile, record.sudo()).unlink() + else: + super(DMSFile, record).unlink() + return True diff --git a/dms/models/dms_security_mixin.py b/dms/models/dms_security_mixin.py index 829a8a786..6ba2aa868 100644 --- a/dms/models/dms_security_mixin.py +++ b/dms/models/dms_security_mixin.py @@ -6,7 +6,13 @@ from logging import getLogger from odoo import api, fields, models -from odoo.osv.expression import FALSE_DOMAIN, NEGATIVE_TERM_OPERATORS, OR, TRUE_DOMAIN +from odoo.osv.expression import ( + AND, + FALSE_DOMAIN, + NEGATIVE_TERM_OPERATORS, + OR, + TRUE_DOMAIN, +) _logger = getLogger(__name__) @@ -60,25 +66,16 @@ def _compute_record_ref(self): for record in self: record.record_ref = False if record.res_model and record.res_id: - record.record_ref = "{},{}".format(record.res_model, record.res_id) + record.record_ref = f"{record.res_model},{record.res_id}" def _compute_permissions(self): """Get permissions for the current record. ⚠ Not very performant; only display field on form views. """ - # Superuser unrestricted 🦸 - if self.env.su: - self.update( - { - "permission_create": True, - "permission_read": True, - "permission_unlink": True, - "permission_write": True, - } - ) - return + # Update according to presence when applying ir.rule + self.invalidate_recordset() creatable = self._filter_access_rules("create") readable = self._filter_access_rules("read") unlinkable = self._filter_access_rules("unlink") @@ -100,10 +97,7 @@ def _get_domain_by_inheritance(self, operation): return [] inherited_access_field = "storage_id_inherit_access_from_parent_record" if self._name != "dms.directory": - inherited_access_field = "{}.{}".format( - self._directory_field, - inherited_access_field, - ) + inherited_access_field = f"{self._directory_field}.{inherited_access_field}" inherited_access_domain = [ ("storage_id_save_type", "=", "attachment"), (inherited_access_field, "=", True), @@ -153,7 +147,7 @@ def _get_access_groups_query(self, operation): "unlink": "AND dag.perm_inclusive_unlink", "write": "AND dag.perm_inclusive_write", }[operation] - select = """ + select = f""" SELECT dir_group_rel.aid FROM @@ -163,11 +157,9 @@ def _get_access_groups_query(self, operation): INNER JOIN dms_access_group_users_rel AS users ON users.gid = dag.id WHERE - users.uid = %s {} - """.format( - operation_check - ) - return (select, (self.env.uid,)) + users.uid = %s {operation_check} + """ + return select, (self.env.uid,) @api.model def _get_domain_by_access_groups(self, operation): @@ -202,11 +194,20 @@ def _get_permission_domain(self, operator, value, operation): if _self.env.su: # You're SUPERUSER_ID return TRUE_DOMAIN if positive else FALSE_DOMAIN + + allowed_directory_ids = _self.env.user._get_allowed_directory_ids( + f"perm_{operation}" + ) # Obtain and combine domains - result = OR( + result = AND( [ - _self._get_domain_by_access_groups(operation), - _self._get_domain_by_inheritance(operation), + OR( + [ + _self._get_domain_by_access_groups(operation), + _self._get_domain_by_inheritance(operation), + ] + ), + [("id", "in", allowed_directory_ids)], ] ) if not positive: @@ -215,6 +216,17 @@ def _get_permission_domain(self, operator, value, operation): @api.model def _search_permission_create(self, operator, value): + if self._name == "dms.directory": + all_access_group = self.env["dms.access.group"].search( + [ + ("users", "in", self.env.uid), + ("has_all_access", "=", True), + ("perm_create", "=", True), + ] + ) + if all_access_group: + return [] + return self._get_permission_domain(operator, value, "create") @api.model diff --git a/dms/models/ir_attachment.py b/dms/models/ir_attachment.py index b484c3c6a..799cc5acb 100644 --- a/dms/models/ir_attachment.py +++ b/dms/models/ir_attachment.py @@ -4,7 +4,6 @@ class IrAttachment(models.Model): - _inherit = "ir.attachment" def _get_dms_directories(self, res_model, res_id): diff --git a/dms/models/ir_rule.py b/dms/models/ir_rule.py new file mode 100644 index 000000000..c430d7163 --- /dev/null +++ b/dms/models/ir_rule.py @@ -0,0 +1,46 @@ +# Copyright 2024 Subteno (https://www.subteno.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo import models + + +class IrRule(models.Model): + # private attributes + _inherit = "ir.rule" + + # default methods + + # fields + + # api.constraints + + # api.model + def _compute_domain(self, model_name, mode="read"): + """ + Override in order to see all directories in the access group form view, because if the user uncheck the + "Has All Access" checkbox, he can't see any directories and therefore can't add them to the access group + in order to see them. + """ + domain = super()._compute_domain(model_name, mode=mode) + params = self._context.get("params", {}) + if ( + self._context.get("force_see_all") + and params.get("model") == "dms.access.group" + and params.get("view_type") == "form" + ): + for i, dom in enumerate(domain): + if dom[0] == "id": + all_ids = self.env["dms.directory"].sudo().search([]).ids + dom = ("id", "in", all_ids) + domain[i] = dom + break + return domain + + # api.depends + + # api.onchange + + # other + + # private diff --git a/dms/models/mail_thread.py b/dms/models/mail_thread.py index 94a3ccce7..4e6514d7d 100644 --- a/dms/models/mail_thread.py +++ b/dms/models/mail_thread.py @@ -7,11 +7,9 @@ class MailThread(models.AbstractModel): _inherit = "mail.thread" - def _message_post_process_attachments( - self, attachments, attachment_ids, message_data - ): + def _process_attachments_for_post(self, attachments, attachment_ids, message_data): """Indicate to DMS that we're attaching a message to a record.""" _self = self.with_context(attaching_to_record=True) - return super(MailThread, _self)._message_post_process_attachments( + return super(MailThread, _self)._process_attachments_for_post( attachments, attachment_ids, message_data ) diff --git a/dms/models/mixins_thumbnail.py b/dms/models/mixins_thumbnail.py index c4673580e..99188caa6 100644 --- a/dms/models/mixins_thumbnail.py +++ b/dms/models/mixins_thumbnail.py @@ -1,15 +1,15 @@ # Copyright 2017-2019 MuK IT GmbH. # Copyright 2020 Creu Blanca +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import os from odoo import api, fields, models -from odoo.modules.module import get_resource_path +from odoo.tools.misc import file_path class Thumbnail(models.AbstractModel): - _name = "dms.mixins.thumbnail" _inherit = "image.mixin" _description = "DMS thumbnail and icon mixin" @@ -18,10 +18,15 @@ class Thumbnail(models.AbstractModel): def _get_icon_disk_path(self): """Obtain local disk path to record icon.""" - folders = ["static", "icons"] name = self._get_icon_placeholder_name() - path = get_resource_path("dms", *folders, name) - return path or get_resource_path("dms", *folders, "file_unknown.svg") + folders = ["dms", "static", "icons"] + + try: + path = file_path(os.path.join(*folders, name)) + except FileNotFoundError: + return file_path(os.path.join(*folders, "file_unknown.svg")) + + return path or file_path(os.path.join(*folders, "file_unknown.svg")) def _get_icon_placeholder_name(self): return "folder.svg" @@ -38,7 +43,7 @@ def _compute_icon_url(self): for one in self: # Get URL to thumbnail or to the default icon by file extension one.icon_url = ( - "/web/image/{}/{}/image_128/128x128?crop=1".format(one._name, one.id) + f"/web/image/{one._name}/{one.id}/image_128/128x128?crop=1" if one.image_128 else one._get_icon_url() ) diff --git a/dms/models/onboarding_onboarding.py b/dms/models/onboarding_onboarding.py new file mode 100644 index 000000000..3146e9ea3 --- /dev/null +++ b/dms/models/onboarding_onboarding.py @@ -0,0 +1,29 @@ +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo import api, models + + +class OnboardingOnboarding(models.Model): + # private attributes + _inherit = "onboarding.onboarding" + + # default methods + + # fields + + # api.constraints + + # api.model + @api.model + def action_close_panel_dms_directory(self): + self.action_close_panel("dms.onboarding_onboarding_dms_directory") + + # api.depends + + # api.onchange + + # other + + # private diff --git a/dms/models/onboarding_onboarding_step.py b/dms/models/onboarding_onboarding_step.py new file mode 100644 index 000000000..a9000a418 --- /dev/null +++ b/dms/models/onboarding_onboarding_step.py @@ -0,0 +1,63 @@ +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from odoo import api, models + + +class OnboardingOnboardingStep(models.Model): + # private attributes + _inherit = "onboarding.onboarding.step" + + # default methods + + # fields + + # api.constraints + + # api.model + @api.model + def action_open_documents_onboarding_storage(self): + """ + Open the form to create a new storage from the onboarding panel. + """ + return self.env.ref("dms.action_dms_storage_new").read()[0] + + @api.model + def action_open_documents_onboarding_directory(self): + """ + Open the form to create a new directory from the onboarding panel. + """ + storage = self.env["dms.storage"].search([], order="create_date desc", limit=1) + action = self.env.ref("dms.action_dms_directory_new").read()[0] + action["context"] = { + **self.env.context, + **{ + "default_is_root_directory": True, + "default_storage_id": storage and storage.id, + }, + } + return action + + @api.model + def action_open_documents_onboarding_file(self): + """ + Open the form to create a new file from the onboarding panel. + """ + directory = self.env["dms.directory"].search( + [], order="create_date desc", limit=1 + ) + action = self.env.ref("dms.action_dms_file_new").read()[0] + action["context"] = { + **self.env.context, + **{"default_directory_id": directory and directory.id}, + } + return action + + # api.depends + + # api.onchange + + # other + + # private diff --git a/dms/models/res_company.py b/dms/models/res_company.py index bf169073b..c0463ca55 100644 --- a/dms/models/res_company.py +++ b/dms/models/res_company.py @@ -1,5 +1,6 @@ # Copyright 2020 Creu Blanca # Copyright 2017-2019 MuK IT GmbH +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import logging @@ -10,7 +11,6 @@ class ResCompany(models.Model): - _inherit = "res.company" # ---------------------------------------------------------- @@ -61,50 +61,38 @@ class ResCompany(models.Model): # Functions # ---------------------------------------------------------- - def get_and_update_documents_onboarding_state(self): - return self._get_and_update_onboarding_state( - "documents_onboarding_state", self.get_documents_steps_states_names() - ) + def set_onboarding_step_done(self, step): + self.ensure_one() + if self[step] == "not_done": + self[step] = "just_done" - def get_documents_steps_states_names(self): - return [ + def get_and_update_documents_onboarding_state(self): + step_states = [ "documents_onboarding_storage_state", "documents_onboarding_directory_state", "documents_onboarding_file_state", ] + onboarding_state = "documents_onboarding_state" + old_values = {} + all_done = True - # ---------------------------------------------------------- - # Actions - # ---------------------------------------------------------- + for step_state in step_states: + old_values[step_state] = self[step_state] + if self[step_state] == "just_done": + self[step_state] = "done" + all_done = all_done and self[step_state] == "done" - @api.model - def action_open_documents_onboarding_storage(self): - return self.env.ref("dms.action_dms_storage_new").read()[0] + if all_done: + old_values[onboarding_state] = ( + "just_done" if self[onboarding_state] == "not_done" else "done" + ) + self[onboarding_state] = "done" - @api.model - def action_open_documents_onboarding_directory(self): - storage = self.env["dms.storage"].search([], order="create_date desc", limit=1) - action = self.env.ref("dms.action_dms_directory_new").read()[0] - action["context"] = { - **self.env.context, - **{ - "default_is_root_directory": True, - "default_storage_id": storage and storage.id, - }, - } - return action + return old_values - @api.model - def action_open_documents_onboarding_file(self): - directory = self.env["dms.directory"].search( - [], order="create_date desc", limit=1 - ) - action = self.env.ref("dms.action_dms_file_new").read()[0] - action["context"] = { - **self.env.context, - **{"default_directory_id": directory and directory.id}, - } - return action + # ---------------------------------------------------------- + # Actions + # ---------------------------------------------------------- @api.model def action_close_documents_onboarding(self): diff --git a/dms/models/res_config_settings.py b/dms/models/res_config_settings.py index 3e57eff8f..e1e00fc97 100644 --- a/dms/models/res_config_settings.py +++ b/dms/models/res_config_settings.py @@ -6,7 +6,6 @@ class ResConfigSettings(models.TransientModel): - _inherit = "res.config.settings" documents_binary_max_size = fields.Integer( diff --git a/dms/models/res_users.py b/dms/models/res_users.py new file mode 100644 index 000000000..02daef5b0 --- /dev/null +++ b/dms/models/res_users.py @@ -0,0 +1,91 @@ +# Copyright 2024 Subteno (https://www.subteno.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from typing import Literal # noqa # pylint: disable=unused-import +from odoo import api, models + + +class ResUsers(models.Model): + # private attributes + _inherit = "res.users" + + # default methods + + # fields + + # api.constraints + + # api.model + + # api.depends + + # api.onchange + + # other + + # private + @api.model + def _get_allowed_directory_ids(self, operation): + """ + Get allowed directory ids for the user based on its inclusion in an access group. + + :param Literal["perm_create", "perm_write", "perm_unlink", "perm_read"] operation: operation to check + + :return: Allowed directory ids + :rtype: list[int] + """ + self = self.sudo() + all_access = self._has_all_access(field_name=operation) + + # if the user has all access to directories for the current operation, then return all directories + if all_access: + return [ + dir["id"] for dir in self.env["dms.directory"].search_read([], ["id"]) + ] + + my_directory = ( + self.env["dms.access.group"] + .search([("users", "in", self.env.uid), (operation, "=", True)]) + .directory_ids.filtered(lambda x: not x.is_hidden) + ) + parent_ids = my_directory.parent_id.ids + + # user should be able to see all allowed directories as well as the parent directories of the allowed + # directories, and that recursively + res_ids = set(my_directory.ids) + while parent_ids: + res_ids |= set(parent_ids) + parent_ids = ( + self.env["dms.directory"] + .search([("id", "in", parent_ids)]) + .parent_id.ids + ) + + # When creating a directory, you can't add the directory to the access group, because it doesn't exist yet + # In order to allow the user to create the directory anyway, we use a temporary flag + just_created_ids = self.env["dms.directory"].search( + [("is_first_creation", "=", True)] + ) + res_ids |= set(just_created_ids.ids) + just_created_ids.write({"is_first_creation": False}) + + return list(res_ids) + + @api.model + def _has_all_access(self, user_id=None, field_name="perm_read"): + """ + Check if user has access to all directories for the specified operation. + + :param int user_id: user id to check + :param Literal["perm_create", "perm_write", "perm_unlink", "perm_read"] field_name: field name to check + + :return: The access group that has all access + :rtype: odoo.model.dms_access_group + """ + return self.env["dms.access.group"].search( + [ + ("users", "in", user_id or self.env.uid), + ("has_all_access", "=", True), + (field_name, "=", True), + ] + ) diff --git a/dms/models/storage.py b/dms/models/storage.py index a33b00937..bc66d7b8f 100644 --- a/dms/models/storage.py +++ b/dms/models/storage.py @@ -12,7 +12,6 @@ class Storage(models.Model): - _name = "dms.storage" _description = "Storage" @@ -30,11 +29,9 @@ class Storage(models.Model): ], default="database", required=True, - help="""The save type is used to determine how a file is saved by the - system. If you change this setting, you can migrate existing files - manually by triggering the action.""", + help="The save type is used to determine how a file is saved by the system. If you change this setting, " + "you can migrate existing files manually by triggering the action.", ) - company_id = fields.Many2one( comodel_name="res.company", string="Company", @@ -42,13 +39,11 @@ class Storage(models.Model): help="If set, directories and files will only be available for " "the selected company.", ) - is_hidden = fields.Boolean( string="Storage is Hidden", default=False, help="Indicates if directories and files are hidden by default.", ) - root_directory_ids = fields.One2many( comodel_name="dms.directory", inverse_name="storage_id", @@ -57,7 +52,6 @@ class Storage(models.Model): readonly=False, copy=False, ) - storage_directory_ids = fields.One2many( comodel_name="dms.directory", inverse_name="storage_id", @@ -66,7 +60,6 @@ class Storage(models.Model): readonly=True, copy=False, ) - storage_file_ids = fields.One2many( comodel_name="dms.file", inverse_name="storage_id", @@ -75,22 +68,19 @@ class Storage(models.Model): readonly=True, copy=False, ) - count_storage_directories = fields.Integer( compute="_compute_count_storage_directories", string="Count Directories" ) - count_storage_files = fields.Integer( compute="_compute_count_storage_files", string="Count Files" ) - model_ids = fields.Many2many("ir.model", string="Linked Models") inherit_access_from_parent_record = fields.Boolean( string="Inherit permissions from related record", default=False, help="Indicate if directories and files access work only with " "related model access (for example, if some directories are related " - "with any sale, only users with read access to these sale can acess)", + "with any sale, only users with read access to these sale can access)", ) include_message_attachments = fields.Boolean( string="Create files from message attachments", diff --git a/dms/models/tag.py b/dms/models/tag.py index 2483144e5..25dfb9a57 100644 --- a/dms/models/tag.py +++ b/dms/models/tag.py @@ -20,7 +20,7 @@ class Tag(models.Model): ) category_id = fields.Many2one( comodel_name="dms.category", - context="{'dms_category_show_path': True}", + context={"dms_category_show_path": True}, string="Category", ondelete="set null", ) diff --git a/dms/pyproject.toml b/dms/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/dms/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/dms/readme/CONFIGURE.md b/dms/readme/CONFIGURE.md new file mode 100644 index 000000000..d0d4b7b4a --- /dev/null +++ b/dms/readme/CONFIGURE.md @@ -0,0 +1,52 @@ +To configure this module, you need to: + +1. Go to *Documents -\> Configuration -\> Storages*. + +2. Create a new document storage. You can choose between two options on \`Save Type\`: + - \`Database\`: Store the files on the database as a field + - \`Attachment\`: Store the files as attachments + +3. 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. + +4. Afterwards go to *Documents -\> Directories*. + +5. 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. + +6. On the Directory you can also add other access groups (created above) that will be able to: + - read + - create + - write + - delete + +## Migration + +If you need to modify the storage Save Type you might want to migrate +the file data. In order to achieve it you need to: + +1. Go to *Documents -\> Configuration -\> Storage* and select the + storage you want to modify +2. Modify the save type +3. Press the button Migrate files if you want to migrate all the files + at once +4. Press the button Manual File Migration in order to specify files one + by one + +You can check all the files that still needs to be migrated from all +storages and migrate them manually on *Documents -\> Configuration -\> +Migration* + +## 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. diff --git a/dms/readme/CONFIGURE.rst b/dms/readme/CONFIGURE.rst deleted file mode 100644 index 057d28009..000000000 --- a/dms/readme/CONFIGURE.rst +++ /dev/null @@ -1,40 +0,0 @@ -To configure this module, you need to: - -#. Go to *Documents -> Configuration -> Storages*. -#. Create a new document storage. You can choose between two options on `Save Type`: - * `Database`: Store the files on the database as a field - * `Attachment`: Store the files as attachments -#. 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. -#. Afterwards go to *Documents -> Directories*. -#. 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. -#. On the Directory you can also add other access groups (created above) that will be able to: - * read - * create - * write - * delete - - -Migration -~~~~~~~~~ - -If you need to modify the storage Save Type you might want to migrate the file data. -In order to achieve it you need to: - -#. Go to *Documents -> Configuration -> Storage* and select the storage you want to modify -#. Modify the save type -#. Press the button `Migrate files` if you want to migrate all the files at once -#. Press the button `Manual File Migration` in order to specify files one by one - -You can check all the files that still needs to be migrated from all storages -and migrate them manually on *Documents -> Configuration -> Migration* - - -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. diff --git a/dms/readme/CONTRIBUTORS.md b/dms/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..8e8f9885a --- /dev/null +++ b/dms/readme/CONTRIBUTORS.md @@ -0,0 +1,12 @@ +- Mathias Markl \<\> +- Enric Tobella \<\> +- Antoni Romera +- Gelu Boros \<\> +- [Tecnativa](https://www.tecnativa.com): + - Víctor Martínez + - Pedro M. Baeza + - Jairo Llopis +- [Elego](https://www.elegosoft.com): + - Yu Weng \<\> + - Philip Witte \<\> + - Khanh Bui \<\> diff --git a/dms/readme/CONTRIBUTORS.rst b/dms/readme/CONTRIBUTORS.rst deleted file mode 100644 index d5a5fef36..000000000 --- a/dms/readme/CONTRIBUTORS.rst +++ /dev/null @@ -1,16 +0,0 @@ -* Mathias Markl -* Enric Tobella -* Antoni Romera -* Gelu Boros - -* `Tecnativa `_: - - * Víctor Martínez - * Pedro M. Baeza - * Jairo Llopis - -* `Elego `_: - - * Yu Weng - * Philip Witte - * Khanh Bui \ No newline at end of file diff --git a/dms/readme/CREDITS.md b/dms/readme/CREDITS.md new file mode 100644 index 000000000..a1fe32cc7 --- /dev/null +++ b/dms/readme/CREDITS.md @@ -0,0 +1,6 @@ +Some pictures are based on or inspired by: + +- [Roundicons](https://www.flaticon.com/authors/roundicons) +- [Smashicons](https://www.flaticon.com/authors/smashicons) +- [EmojiOne](https://github.com/EmojiTwo/emojitwo) : Portal DMS icon +- [GitHub Octicons](https://github.com/primer/octicons/) : The main DMS icon diff --git a/dms/readme/CREDITS.rst b/dms/readme/CREDITS.rst deleted file mode 100644 index fbb097491..000000000 --- a/dms/readme/CREDITS.rst +++ /dev/null @@ -1,6 +0,0 @@ -The migration of this module from 15.0 to 16.0 was financially supported by `AgentERP `_ - -Some pictures are based on or inspired by: - -* `Roundicons `_ -* `Smashicons `_ diff --git a/dms/readme/DESCRIPTION.md b/dms/readme/DESCRIPTION.md new file mode 100644 index 000000000..30699df55 --- /dev/null +++ b/dms/readme/DESCRIPTION.md @@ -0,0 +1,9 @@ +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 +management system. + +This module adds portal functionality for directories and files for +allowed users, both portal or internal users. You can get as well a +tokenized link from a directory or a file for sharing it with any +anonymous user. diff --git a/dms/readme/DESCRIPTION.rst b/dms/readme/DESCRIPTION.rst deleted file mode 100644 index bcd4bc7c5..000000000 --- a/dms/readme/DESCRIPTION.rst +++ /dev/null @@ -1,6 +0,0 @@ -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 management system. - -This module adds portal functionality for directories and files for allowed users, both portal or internal users. You can get as well a tokenized link from a directory or a file for sharing it with any anonymous user. diff --git a/dms/readme/INSTALL.md b/dms/readme/INSTALL.md new file mode 100644 index 000000000..79c5dc3cb --- /dev/null +++ b/dms/readme/INSTALL.md @@ -0,0 +1,8 @@ +## Preview + +`mail_preview_base` is required for DMS but it is recommended to install +all the other mail_preview modules from social OCA repository in order +to improve the preview of files. + +`python-magic` library is recommended to be installed for having whole +support to get proper file types and file preview. diff --git a/dms/readme/INSTALL.rst b/dms/readme/INSTALL.rst deleted file mode 100644 index d964d8104..000000000 --- a/dms/readme/INSTALL.rst +++ /dev/null @@ -1,9 +0,0 @@ -Preview -~~~~~~~ - -``mail_preview_base`` is required for DMS but it is recommended to install all -the other `mail_preview` modules from `social` OCA repository -in order to improve the preview of files. - -``python-magic`` library is recommended to be installed for having whole support -to get proper file types and file preview. diff --git a/dms/readme/ROADMAP.md b/dms/readme/ROADMAP.md new file mode 100644 index 000000000..675906375 --- /dev/null +++ b/dms/readme/ROADMAP.md @@ -0,0 +1,19 @@ +- 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). diff --git a/dms/readme/ROADMAP.rst b/dms/readme/ROADMAP.rst deleted file mode 100644 index 8e714e036..000000000 --- a/dms/readme/ROADMAP.rst +++ /dev/null @@ -1,8 +0,0 @@ -- 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). diff --git a/dms/readme/USAGE.md b/dms/readme/USAGE.md new file mode 100644 index 000000000..115fee86e --- /dev/null +++ b/dms/readme/USAGE.md @@ -0,0 +1,11 @@ +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 + +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 +inside a directory or a file for obtaining a tokenized link for single +access to that resource, no matter if logged or not. diff --git a/dms/readme/USAGE.rst b/dms/readme/USAGE.rst deleted file mode 100644 index b4b45e43f..000000000 --- a/dms/readme/USAGE.rst +++ /dev/null @@ -1,8 +0,0 @@ -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 -~~~~~~~~~~~~~~~~~~~~ - -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 inside a directory or a file for obtaining a tokenized link for single access to that resource, no matter if logged or not. diff --git a/dms/security/security.xml b/dms/security/security.xml index 48446fe5b..9bca0bc61 100644 --- a/dms/security/security.xml +++ b/dms/security/security.xml @@ -4,6 +4,7 @@ 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). --> @@ -115,7 +116,9 @@ - [('permission_create', '=', user.id)] + [('id', 'in', user._get_allowed_directory_ids("perm_create"))] Apply computed read permissions. @@ -125,7 +128,9 @@ - [('permission_read', '=', user.id)] + [('id', 'in', user._get_allowed_directory_ids("perm_read"))] Apply computed unlink permissions. @@ -135,7 +140,9 @@ - [('permission_unlink', '=', user.id)] + [('id', 'in', user._get_allowed_directory_ids("perm_unlink"))] Apply computed write permissions. @@ -145,7 +152,9 @@ - [('permission_write', '=', user.id)] + [('id', 'in', user._get_allowed_directory_ids("perm_write"))] Apply computed create permissions. @@ -155,7 +164,9 @@ - [('permission_create', '=', user.id)] + [('directory_id', 'in', user._get_allowed_directory_ids("perm_create"))] Apply computed read permissions. @@ -165,7 +176,9 @@ - [('permission_read', '=', user.id)] + [('directory_id', 'in', user._get_allowed_directory_ids("perm_read"))] Apply computed unlink permissions. @@ -175,7 +188,9 @@ - [('permission_unlink', '=', user.id)] + [('directory_id', 'in', user._get_allowed_directory_ids("perm_unlink"))] Apply computed write permissions. @@ -185,6 +200,8 @@ - [('permission_write', '=', user.id)] + [('directory_id', 'in', user._get_allowed_directory_ids("perm_write"))] diff --git a/dms/static/description/icon.png b/dms/static/description/icon.png index a561495f1..26d62db69 100644 Binary files a/dms/static/description/icon.png and b/dms/static/description/icon.png differ diff --git a/dms/static/description/icon.svg b/dms/static/description/icon.svg index 1360cc00a..663c409de 100644 --- a/dms/static/description/icon.svg +++ b/dms/static/description/icon.svg @@ -1 +1,4 @@ - + + + + diff --git a/dms/static/description/index.html b/dms/static/description/index.html index 2e1f8412d..1f48c52d5 100644 --- a/dms/static/description/index.html +++ b/dms/static/description/index.html @@ -369,12 +369,15 @@

Document Management System

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! source digest: sha256:d08fb86d7cebb5adbab4a7658e46e630a25d68106405b186db4ca0b011e7cb2d !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

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 management system.

-

This module adds portal functionality for directories and files for allowed users, both portal or internal users. You can get as well a tokenized link from a directory or a file for sharing it with any anonymous user.

+

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 +management system.

+

This module adds portal functionality for directories and files for +allowed users, both portal or internal users. You can get as well a +tokenized link from a directory or a file for sharing it with any +anonymous user.

Table of contents

    @@ -406,11 +409,11 @@

    Document Management System

    Installation

    Preview

    -

    mail_preview_base is required for DMS but it is recommended to install all -the other mail_preview modules from social OCA repository -in order to improve the preview of files.

    -

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

    +

    mail_preview_base is required for DMS but it is recommended to +install all the other mail_preview modules from social OCA repository in +order to improve the preview of files.

    +

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

@@ -418,86 +421,100 @@

Configuration

To configure this module, you need to:

  1. Go to Documents -> Configuration -> Storages.
  2. -
  3. -
    Create a new document storage. You can choose between two options on Save Type:
    -
      -
    • Database: Store the files on the database as a field
    • -
    • Attachment: Store the files as attachments
    • +
    • Create a new document storage. You can choose between two options on +`Save Type`:
        +
      • `Database`: Store the files on the database as a field
      • +
      • `Attachment`: Store the files as attachments
      -
    -
  4. -
  5. -
    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.
    • +
    • 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.
      -
    -
  6. Afterwards go to Documents -> Directories.
  7. -
  8. -
    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.
    • +
    • 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.
      -
    -
  9. -
  10. -
    On the Directory you can also add other access groups (created above) that will be able to:
    -
      +
    • On the Directory you can also add other access groups (created above) +that will be able to:
      • read
      • create
      • write
      • delete
      -
    -

Migration

-

If you need to modify the storage Save Type you might want to migrate the file data. -In order to achieve it you need to:

+

If you need to modify the storage Save Type you might want to migrate +the file data. In order to achieve it you need to:

    -
  1. Go to Documents -> Configuration -> Storage and select the storage you want to modify
  2. +
  3. Go to Documents -> Configuration -> Storage and select the storage +you want to modify
  4. Modify the save type
  5. -
  6. Press the button Migrate files if you want to migrate all the files at once
  7. -
  8. Press the button Manual File Migration in order to specify files one by one
  9. +
  10. Press the button Migrate files if you want to migrate all the files +at once
  11. +
  12. Press the button Manual File Migration in order to specify files one +by one
-

You can check all the files that still needs to be migrated from all storages -and migrate them manually on Documents -> Configuration -> Migration

+

You can check all the files that still needs to be migrated from all +storages and migrate them manually on Documents -> Configuration -> +Migration

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.

+

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

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.

+Existing documents can be managed there and new documents can be +created.

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 inside a directory or a file for obtaining a tokenized link for single access to that resource, no matter if logged or not.

+

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 +inside a directory or a file for obtaining a tokenized link for single +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
  • +
  • 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).
  • +
  • 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).
@@ -505,7 +522,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.

@@ -540,11 +557,13 @@

Contributors

Other credits

-

The migration of this module from 15.0 to 16.0 was financially supported by AgentERP

Some pictures are based on or inspired by:

@@ -554,7 +573,7 @@

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/description/portal_icon.svg b/dms/static/description/portal_icon.svg new file mode 100644 index 000000000..6808515b0 --- /dev/null +++ b/dms/static/description/portal_icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dms/static/src/js/components/path/path.esm.js b/dms/static/src/js/components/path/path.esm.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/dms/static/src/js/dms_portal_tour.esm.js b/dms/static/src/js/dms_portal_tour.esm.js new file mode 100644 index 000000000..fcb6ffc00 --- /dev/null +++ b/dms/static/src/js/dms_portal_tour.esm.js @@ -0,0 +1,57 @@ +/** @odoo-module **/ + +// /** ******************************************************************************** +// Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). +// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +// **********************************************************************************/ +import {registry} from "@web/core/registry"; +import {stepUtils} from "@web_tour/tour_service/tour_utils"; + +registry.category("web_tour.tours").add("dms_portal_mail_tour", { + url: "/my", + test: true, + steps: [ + stepUtils.showAppsMenuItem(), + { + content: "Go /my/dms url", + trigger: 'a[href*="/my/dms"]', + }, + { + content: "Go to Mails directory", + extra_trigger: "li.breadcrumb-item:contains('Documents')", + trigger: ".tr_dms_directory_link:contains('Mails')", + }, + { + content: "Go to Mail_01.eml", + extra_trigger: "li.breadcrumb-item:contains('Mails')", + trigger: ".tr_dms_file_link:contains('Mail_01.eml')", + }, + ], +}); + +registry.category("web_tour.tours").add("dms_portal_partners_tour", { + url: "/my", + test: true, + steps: [ + stepUtils.showAppsMenuItem(), + { + content: "Go /my/dms url", + trigger: 'a[href*="/my/dms"]', + }, + { + content: "Go to Partners directory", + extra_trigger: "li.breadcrumb-item:contains('Documents')", + trigger: ".tr_dms_directory_link:contains('Partners')", + }, + { + content: "Go to Joel Willis", + extra_trigger: "li.breadcrumb-item:contains('Partners')", + trigger: ".tr_dms_directory_link:contains('Joel Willis')", + }, + { + content: "Go to test.txt", + extra_trigger: "li.breadcrumb-item:contains('Joel Willis')", + trigger: ".tr_dms_file_link:contains('test.txt')", + }, + ], +}); diff --git a/dms/static/src/js/dms_portal_tour.js b/dms/static/src/js/dms_portal_tour.js deleted file mode 100644 index f2b31049c..000000000 --- a/dms/static/src/js/dms_portal_tour.js +++ /dev/null @@ -1,57 +0,0 @@ -odoo.define("dms.tour", function (require) { - "use strict"; - - var tour = require("web_tour.tour"); - - tour.register( - "dms_portal_mail_tour", - { - test: true, - url: "/my", - }, - [ - { - content: "Go /my/dms url", - trigger: 'a[href*="/my/dms"]', - }, - { - content: "Go to Mails directory", - extra_trigger: "li.breadcrumb-item:contains('Documents')", - trigger: ".tr_dms_directory_link:contains('Mails')", - }, - { - content: "Go to Mail_01.eml", - extra_trigger: "li.breadcrumb-item:contains('Mails')", - trigger: ".tr_dms_file_link:contains('Mail_01.eml')", - }, - ] - ); - tour.register( - "dms_portal_partners_tour", - { - test: true, - url: "/my", - }, - [ - { - content: "Go /my/dms url", - trigger: 'a[href*="/my/dms"]', - }, - { - content: "Go to Partners directory", - extra_trigger: "li.breadcrumb-item:contains('Documents')", - trigger: ".tr_dms_directory_link:contains('Partners')", - }, - { - content: "Go to Joel Willis", - extra_trigger: "li.breadcrumb-item:contains('Partners')", - trigger: ".tr_dms_directory_link:contains('Joel Willis')", - }, - { - content: "Go to test.txt", - extra_trigger: "li.breadcrumb-item:contains('Joel Willis')", - trigger: ".tr_dms_file_link:contains('test.txt')", - }, - ] - ); -}); diff --git a/dms/static/src/js/fields/path.js b/dms/static/src/js/fields/path.js deleted file mode 100644 index b6a40d92e..000000000 --- a/dms/static/src/js/fields/path.js +++ /dev/null @@ -1,81 +0,0 @@ -/** ******************************************************************************** - Copyright 2020 Creu Blanca - Copyright 2017-2019 MuK IT GmbH - License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). - **********************************************************************************/ - -odoo.define("dms.fields_path", function (require) { - "use strict"; - - var fields = require("web.basic_fields"); - var registry = require("web.field_registry"); - - var FieldPathJson = fields.FieldText.extend({ - events: _.extend({}, fields.FieldText.prototype.events, { - "click a": "_onNodeClicked", - }), - init: function () { - this._super.apply(this, arguments); - this.max_width = this.nodeOptions.width || 500; - this.seperator = this.nodeOptions.seperator || "/"; - this.prefix = this.nodeOptions.prefix || false; - this.suffix = this.nodeOptions.suffix || false; - }, - _renderReadonly: function () { - this.$el.empty(); - this._renderPath(); - }, - _renderPath: function () { - var text_width_measure = ""; - var path = JSON.parse(this.value || "[]"); - $.each( - _.clone(path).reverse(), - function (index, element) { - text_width_measure += element.name + "/"; - if (text_width_measure.length >= this.max_width) { - this.$el.prepend($("").text("..")); - } else if (index === 0) { - if (this.suffix) { - this.$el.prepend($("").text(this.seperator)); - } - this.$el.prepend($("").text(element.name)); - this.$el.prepend($("").text(this.seperator)); - } else { - this.$el.prepend( - $("", { - class: "oe_form_uri", - "data-model": element.model, - "data-id": element.id, - href: "#", - text: element.name, - }) - ); - if (index !== path.length - 1) { - this.$el.prepend($("").text(this.seperator)); - } else if (this.prefix) { - this.$el.prepend($("").text(this.seperator)); - } - } - return text_width_measure.length < this.max_width; - }.bind(this) - ); - }, - _onNodeClicked: function (event) { - event.preventDefault(); - this.do_action({ - type: "ir.actions.act_window", - res_model: $(event.currentTarget).data("model"), - res_id: $(event.currentTarget).data("id"), - views: [[false, "form"]], - target: "current", - context: {}, - }); - }, - }); - - registry.add("path_json", FieldPathJson); - - return { - FieldPathJson: FieldPathJson, - }; -}); diff --git a/dms/static/src/js/fields/path_owl.esm.js b/dms/static/src/js/fields/path_json/path_owl.esm.js similarity index 52% rename from dms/static/src/js/fields/path_owl.esm.js rename to dms/static/src/js/fields/path_json/path_owl.esm.js index 7869674e8..30f1da602 100644 --- a/dms/static/src/js/fields/path_owl.esm.js +++ b/dms/static/src/js/fields/path_json/path_owl.esm.js @@ -1,7 +1,12 @@ /** @odoo-module **/ -import {registry} from "@web/core/registry"; +// /** ******************************************************************************** +// Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). +// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +// **********************************************************************************/ import {Component, onWillUpdateProps} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; import {useService} from "@web/core/utils/hooks"; class DmsPathField extends Component { @@ -13,7 +18,7 @@ class DmsPathField extends Component { } formatData(props) { - this.data = JSON.parse(props.value || "[]"); + this.data = JSON.parse(props.record.data?.path_json || "[]"); } _onNodeClicked(event) { @@ -29,6 +34,18 @@ class DmsPathField extends Component { } } -DmsPathField.supportedTypes = ["text"]; DmsPathField.template = "dms.DmsPathField"; -registry.category("fields").add("path_json", DmsPathField); +DmsPathField.props = { + ...standardFieldProps, +}; + +const dmsPathField = { + component: DmsPathField, + display_name: "Dms Path Field", + supportedTypes: ["text"], + extractProps: () => { + return {}; + }, +}; + +registry.category("fields").add("path_json", dmsPathField); diff --git a/dms/static/src/js/fields/path_owl.xml b/dms/static/src/js/fields/path_json/path_owl.xml similarity index 72% rename from dms/static/src/js/fields/path_owl.xml rename to dms/static/src/js/fields/path_json/path_owl.xml index 02feb45e6..b2a8e7c70 100644 --- a/dms/static/src/js/fields/path_owl.xml +++ b/dms/static/src/js/fields/path_json/path_owl.xml @@ -1,11 +1,14 @@ - - - + + + - / + / { + return {}; + }, +}; +registry.category("fields").add("preview_binary", previewRecordField); diff --git a/dms/static/src/js/views/fields/binary/preview_record.xml b/dms/static/src/js/fields/preview_binary/preview_record.xml similarity index 86% rename from dms/static/src/js/views/fields/binary/preview_record.xml rename to dms/static/src/js/fields/preview_binary/preview_record.xml index 9a0e1dc13..796e74707 100644 --- a/dms/static/src/js/views/fields/binary/preview_record.xml +++ b/dms/static/src/js/fields/preview_binary/preview_record.xml @@ -1,11 +1,13 @@ + - @@ -35,5 +37,4 @@ - 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 38890d7b9..f20e13a28 100644 --- a/dms/static/src/js/views/dms_file_upload.esm.js +++ b/dms/static/src/js/views/dms_file_upload.esm.js @@ -1,175 +1,160 @@ /** @odoo-module */ +// /** ******************************************************************************** +// Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). +// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +// **********************************************************************************/ + import {useBus, useService} from "@web/core/utils/hooks"; -import rpc from "web.rpc"; -import {_t} from "web.core"; +import {_t} from "@web/core/l10n/translation"; const {useRef, useEffect, useState} = owl; -export const FileDropZone = { - setup() { - this._super(); - this.dragState = useState({ - showDragZone: false, - }); - this.root = useRef("root"); - this.rpc = useService("rpc"); - - useEffect( - (el) => { - if (!el) { - return; - } - const highlight = this.highlight.bind(this); - const unhighlight = this.unhighlight.bind(this); - const drop = this.onDrop.bind(this); - el.addEventListener("dragover", highlight); - el.addEventListener("dragleave", unhighlight); - el.addEventListener("drop", drop); - return () => { - el.removeEventListener("dragover", highlight); - el.removeEventListener("dragleave", unhighlight); - el.removeEventListener("drop", drop); - }; - }, - - () => [document.querySelector(".o_content")] - ); - }, - - highlight(ev) { - ev.stopPropagation(); - ev.preventDefault(); - this.dragState.showDragZone = true; - }, - - unhighlight(ev) { - ev.stopPropagation(); - ev.preventDefault(); - this.dragState.showDragZone = false; - }, - - async onDrop(ev) { - ev.preventDefault(); - await this.env.bus.trigger("change_file_input", { - files: ev.dataTransfer.files, - }); - }, -}; - -export const FileUpload = { - setup() { - this._super(); - this.actionService = useService("action"); - this.notification = useService("notification"); - this.orm = useService("orm"); - this.http = useService("http"); - this.fileInput = useRef("fileInput"); - this.root = useRef("root"); - - this.rpc = useService("rpc"); - - useBus(this.env.bus, "change_file_input", async (ev) => { - this.fileInput.el.files = ev.detail.files; - await this.onChangeFileInput(); - }); - }, - - uploadDocument() { - this.fileInput.el.click(); - }, - - async onChangeFileInput() { - const params = { - csrf_token: odoo.csrf_token, - ufile: [...this.fileInput.el.files], - model: "dms.file", - id: 0, - }; - - const fileData = await this.http.post( - "/web/binary/upload_attachment", - params, - "text" - ); - const attachments = JSON.parse(fileData); - if (attachments.error) { - throw new Error(attachments.error); - } - - this.onUpload(attachments); - }, - - async onUpload(attachments) { - const self = this; - const attachmentIds = attachments.map((a) => a.id); - const ctx = this.props.context; - const controllerID = this.actionService.currentController.jsId; - - if (!attachmentIds.length) { - this.notification.add(_t("An error occurred during the upload")); - return; - } - - // Search the correct directory_id value according to the domain - ctx.default_directory_id = false; - if (this.props.domain) { - for (const domain_item of this.props.domain) { - if (domain_item.length === 3) { - if (domain_item[0] === "directory_id" && domain_item[1] === "=") { - ctx.default_directory_id = domain_item[2]; +export function createFileDropZoneExtension() { + return { + setup() { + super.setup(...arguments); + this.dragState = useState({ + showDragZone: false, + }); + this.root = useRef("root"); + + useEffect( + (el) => { + if (!el) { + return; } - } + const highlight = this.highlight.bind(this); + const unhighlight = this.unhighlight.bind(this); + const drop = this.onDrop.bind(this); + el.addEventListener("dragover", highlight); + el.addEventListener("dragleave", unhighlight); + el.addEventListener("drop", drop); + return () => { + el.removeEventListener("dragover", highlight); + el.removeEventListener("dragleave", unhighlight); + el.removeEventListener("drop", drop); + }; + }, + + () => [document.querySelector(".o_content")] + ); + }, + + highlight(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.dragState.showDragZone = true; + }, + + unhighlight(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.dragState.showDragZone = false; + }, + + async onDrop(ev) { + ev.preventDefault(); + this.dragState.showDragZone = false; + await this.env.bus.trigger("change_file_input", { + files: ev.dataTransfer.files, + }); + }, + }; +} + +export function createFileUploadExtension() { + return { + setup() { + super.setup(); + this.notification = useService("notification"); + this.orm = useService("orm"); + this.http = useService("http"); + this.fileInput = useRef("fileInput"); + + useBus(this.env.bus, "change_file_input", async (ev) => { + this.fileInput.el.files = ev.detail.files; + await this.onChangeFileInput(); + }); + }, + + uploadDocument() { + this.fileInput.el.click(); + }, + + async onChangeFileInput() { + const params = { + csrf_token: odoo.csrf_token, + ufile: [...this.fileInput.el.files], + model: "dms.file", + id: 0, + }; + + const fileData = await this.http.post( + "/web/binary/upload_attachment", + params, + "text" + ); + const attachments = JSON.parse(fileData); + if (attachments.error) { + throw new Error(attachments.error); } - } - if (ctx.default_directory_id === false) { - self.actionService.restore(controllerID); - return self.notification.add( - this.env._t("You must select a directory first"), - { - type: "danger", - } - ); - } + await this.onUpload(attachments); + }, - const attachment_datas = await this.orm.call( - "dms.file", - "get_dms_files_from_attachments", - ["", attachmentIds] - ); + async onUpload(attachments) { + const self = this; + const attachmentIds = attachments.map((a) => a.id); + const ctx = this.props.context; + const controllerID = this.actionService.currentController.jsId; - const attachments_args = []; + if (!attachmentIds.length) { + this.notification.add(_t("An error occurred during the upload")); + return; + } - attachment_datas.forEach((attachment_data) => { - attachments_args.push({ - name: attachment_data.name, - content: attachment_data.datas, - mimetype: attachment_data.mimetype, - }); - }); - - rpc.query({ - model: "dms.file", - method: "create", - args: [attachments_args], - kwargs: { - context: ctx, - }, - }) - .then(() => { - self.actionService.restore(controllerID); - }) - .catch((error) => { - console.log("##error##: ", error); - window.alert(this.env._t("A file with the same name already exists.")); - self.notification.add( - this.env._t("A file with the same name already exists"), - { - type: "danger", + // Search the correct directory_id value according to the domain + let directory_id = false; + if (this.props.domain) { + for (const domain_item of this.props.domain) { + if (domain_item.length === 3) { + if ( + domain_item[0] === "directory_id" && + ["=", "child_of"].includes(domain_item[1]) + ) { + directory_id = domain_item[2]; + } } - ); + } + } + + if (directory_id === false) { self.actionService.restore(controllerID); + return self.notification.add(_t("You must select a directory first"), { + type: "danger", + }); + } + + const attachment_datas = await this.orm.call( + "dms.file", + "get_dms_files_from_attachments", + ["", attachmentIds] + ); + + const attachments_args = []; + + attachment_datas.forEach((attachment_data) => { + attachments_args.push({ + name: attachment_data.name, + content: attachment_data.datas, + mimetype: attachment_data.mimetype, + directory_id, + }); }); - }, -}; + + await this.orm.create("dms.file", attachments_args, {context: ctx}); + self.actionService.restore(controllerID); + }, + }; +} diff --git a/dms/static/src/js/views/fields/binary/preview_record.esm.js b/dms/static/src/js/views/fields/binary/preview_record.esm.js deleted file mode 100644 index 9954f7056..000000000 --- a/dms/static/src/js/views/fields/binary/preview_record.esm.js +++ /dev/null @@ -1,35 +0,0 @@ -/** @odoo-module **/ - -import {registry} from "@web/core/registry"; -import {BinaryField} from "@web/views/fields/binary/binary_field"; -import {useService} from "@web/core/utils/hooks"; - -export class PreviewRecordField extends BinaryField { - setup() { - super.setup(); - this.messaging = useService("messaging"); - this.dialog = useService("dialog"); - } - - onFilePreview() { - const self = this; - this.messaging.get().then((messaging) => { - const attachmentList = messaging.models.AttachmentList.insert({ - selectedAttachment: messaging.models.Attachment.insert({ - id: self.props.record.resId, - filename: self.props.record.data.display_name || "", - name: self.props.record.data.display_name || "", - mimetype: self.props.record.data.mimetype, - model_name: self.props.record.resModel, - }), - }); - this.dialog = messaging.models.Dialog.insert({ - attachmentListOwnerAsAttachmentView: attachmentList, - }); - }); - return; - } -} - -PreviewRecordField.template = "dms.FilePreviewField"; -registry.category("fields").add("preview_binary", PreviewRecordField); diff --git a/dms/static/src/js/views/file_kanban_controller.xml b/dms/static/src/js/views/file_kanban_controller.xml index 91c2e396c..da14578ff 100644 --- a/dms/static/src/js/views/file_kanban_controller.xml +++ b/dms/static/src/js/views/file_kanban_controller.xml @@ -4,7 +4,6 @@ t-name="dms.FileKanbanView.Buttons" t-inherit="web.KanbanView.Buttons" t-inherit-mode="primary" - owl="1" >
{ - const file_type = self.props.record.data.name.split(".")[1]; - let mimetype = ""; - - if (self.isVideo(file_type)) { - mimetype = `video/${file_type}`; - } else if (self.isAudio(file_type)) { - mimetype = "audio/mpeg"; - } else { - mimetype = self.props.record.data.mimetype; - } - - const attachmentList = messaging.models.AttachmentList.insert({ - selectedAttachment: messaging.models.Attachment.insert({ - id: self.props.record.data.id, - filename: self.props.record.data.name, - name: self.props.record.data.name, - mimetype: mimetype, - model_name: self.props.record.resModel, - }), - }); - this.dialog = messaging.models.Dialog.insert({ - attachmentListOwnerAsAttachmentView: attachmentList, - }); + const file_type = self.props.record.data.name.split(".")[1]; + let mimetype = ""; + + if (self.isVideo(file_type)) { + mimetype = `video/${file_type}`; + } else if (self.isAudio(file_type)) { + mimetype = "audio/mpeg"; + } else { + mimetype = self.props.record.data.mimetype; + } + + const attachment = this.store.Attachment.insert({ + id: self.props.record.data.id, + filename: self.props.record.data.name, + name: self.props.record.data.name, + mimetype: mimetype, + model_name: self.props.record.resModel, }); + this.fileViewer.open(attachment); return; } - return super.onGlobalClick(...arguments); + return super.onGlobalClick(ev); } } diff --git a/dms/static/src/js/views/file_kanban_renderer.esm.js b/dms/static/src/js/views/file_kanban_renderer.esm.js index 499d611fb..a57fab0d4 100644 --- a/dms/static/src/js/views/file_kanban_renderer.esm.js +++ b/dms/static/src/js/views/file_kanban_renderer.esm.js @@ -4,9 +4,8 @@ // Copyright 2020 Creu Blanca // License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). // **********************************************************************************/ -import {KanbanRenderer} from "@web/views/kanban/kanban_renderer"; - import {FileKanbanRecord} from "./file_kanban_record.esm"; +import {KanbanRenderer} from "@web/views/kanban/kanban_renderer"; export class FileKanbanRenderer extends KanbanRenderer { setup() { diff --git a/dms/static/src/js/views/file_kanban_renderer.xml b/dms/static/src/js/views/file_kanban_renderer.xml index 3e4ba2dde..d774506c5 100644 --- a/dms/static/src/js/views/file_kanban_renderer.xml +++ b/dms/static/src/js/views/file_kanban_renderer.xml @@ -1,10 +1,13 @@ +
@@ -17,19 +20,8 @@ t-name="dms.KanbanButtons" t-inherit="web.KanbanView.Buttons" t-inherit-mode="primary" - owl="1" > - - - - - + - - - + diff --git a/dms/static/src/js/views/file_kanban_view.esm.js b/dms/static/src/js/views/file_kanban_view.esm.js index e58153f8b..4dc16488c 100644 --- a/dms/static/src/js/views/file_kanban_view.esm.js +++ b/dms/static/src/js/views/file_kanban_view.esm.js @@ -3,25 +3,27 @@ // /** ******************************************************************************** // Copyright 2020 Creu Blanca // Copyright 2017-2019 MuK IT GmbH +// Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). // License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). // **********************************************************************************/ -import {registry} from "@web/core/registry"; -import {patch} from "@web/core/utils/patch"; -import {kanbanView} from "@web/views/kanban/kanban_view"; +import { + createFileDropZoneExtension, + createFileUploadExtension, +} from "./dms_file_upload.esm"; import {FileKanbanRenderer} from "./file_kanban_renderer.esm"; -import {FileKanbanController} from "./file_kanban_controller.esm"; - -import {FileDropZone, FileUpload} from "./dms_file_upload.esm"; +import {KanbanController} from "@web/views/kanban/kanban_controller"; +import {kanbanView} from "@web/views/kanban/kanban_view"; +import {patch} from "@web/core/utils/patch"; +import {registry} from "@web/core/registry"; -patch(FileKanbanRenderer.prototype, "file_kanban_renderer_dzone", FileDropZone); -patch(FileKanbanController.prototype, "filee_kanban_controller_upload", FileUpload); +patch(FileKanbanRenderer.prototype, createFileDropZoneExtension()); +patch(KanbanController.prototype, createFileUploadExtension()); FileKanbanRenderer.template = "dms.KanbanRenderer"; export const FileKanbanView = { ...kanbanView, buttonTemplate: "dms.KanbanButtons", - Controller: FileKanbanController, Renderer: FileKanbanRenderer, }; diff --git a/dms/static/src/js/views/file_list_controller.esm.js b/dms/static/src/js/views/file_list_controller.esm.js index fb79f9648..4decba841 100644 --- a/dms/static/src/js/views/file_list_controller.esm.js +++ b/dms/static/src/js/views/file_list_controller.esm.js @@ -10,6 +10,6 @@ import {ListController} from "@web/views/list/list_controller"; export class FileListController extends ListController { setup() { - super.setup(); + super.setup(...arguments); } } diff --git a/dms/static/src/js/views/file_list_renderer.xml b/dms/static/src/js/views/file_list_renderer.xml index bbc3fe3a1..0b1b83418 100644 --- a/dms/static/src/js/views/file_list_renderer.xml +++ b/dms/static/src/js/views/file_list_renderer.xml @@ -1,11 +1,6 @@ - +
@@ -17,7 +12,6 @@ t-name="dms.ListButtons" t-inherit="web.ListView.Buttons" t-inherit-mode="primary" - owl="1" >