diff --git a/dms/README.rst b/dms/README.rst index 55245b89f..b5f27266f 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,129 @@ 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. -``python-magic`` library is recommended to be installed for having whole support -to get proper file types and file preview. +Configuration +============= Configuration ============= To configure this module, you need to: -#. 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. Create a storage +------------------- +1. Go to *Documents -> Configuration -> Storages*. -Migration -~~~~~~~~~ +2. Create a new document storage. You can choose between three options + on ``Save Type``: + + - ``Database``: Store the files on the database as a field + - ``Attachment``: Store the files as attachments + - ``File``: Store the files on the file system + +2. Create an access group +------------------------- + +1. Next, create an administrative access group. Go to *Configuration -> + Access Groups*. + + - Create a new group, name it appropriately, and turn on all three + permissions (Create, Write and Unlink. Read is implied and always + enabled). + - Add any other top-level administrative users to the group if + needed (your user should already be there). + - You can create other groups in here later for fine-grained access + control. + +3. Create a directory +--------------------- -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. Afterward, 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 +2. 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. If your directory was already created before the group, you + can also add it in the access groups (*Configuration -> Access + Groups*). +3. In the directory settings, you can also add other access groups + (created above) that will be able to: + + - read + - create + - write + - delete + +Migration +========= + +If you need to modify the storage ``Save Type`` you might want to +migrate the file data. 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 to specify files one by one + +You can check all the files that still need 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 +177,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 +185,47 @@ 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 +- `Subteno `__: -Other credits -~~~~~~~~~~~~~ + - Timothée Vannier -The migration of this module from 15.0 to 16.0 was financially supported by `AgentERP `_ +Other credits +------------- 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 +237,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 cdad159e0..9b8155a66 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,41 +13,56 @@ "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", # Wizard "wizards/wizard_dms_file_move_views.xml", "wizards/wizard_dms_share_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/scss/portal.scss", + ], + "web.assets_tests": [ + "dms/static/tests/tours/**/*", + ], }, "demo": [ "demo/res_users.xml", @@ -57,6 +73,6 @@ "demo/directory.xml", "demo/file.xml", ], - "images": ["static/description/banner.png"], + "icon": "/dms/static/description/icon.png", "application": True, } diff --git a/dms/actions/file.xml b/dms/actions/file.xml index afea7c232..84a8ff284 100644 --- a/dms/actions/file.xml +++ b/dms/actions/file.xml @@ -1,10 +1,8 @@ +--> Migrate 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..c0cdb7891 100644 --- a/dms/controllers/portal.py +++ b/dms/controllers/portal.py @@ -1,5 +1,8 @@ # Copyright 2020-2021 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). import base64 +from typing import Optional # noqa # pylint: disable=unused-import from odoo import _, http from odoo.http import content_disposition, request @@ -11,6 +14,15 @@ class CustomerPortal(CustomerPortal): def _dms_check_access(self, model, res_id, access_token=None): + """ + Check access to the record. + + :param str model: model + :param int res_id: res_id + :param Optional[str] access_token: access_token + + :return: item + """ item = request.env[model].browse(res_id) if access_token: item = item.sudo() @@ -32,34 +44,34 @@ def _prepare_home_portal_values(self, counters): def portal_my_dms( self, sortby=None, filterby=None, search=None, search_in="name", **kw ): + """ + Display the main page for the DMS module. + + :param Optional[str] sortby: The field to sort by + :param Optional[str] filterby: The field to filter by + :param Optional[str] search: The search term + :param Optional[str] search_in: The field to search in + + :return: response + :rtype: odoo.http.Response + """ 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_order, + sortby, + ) = self._searchbar_data(filterby, sortby) # domain domain = [ - ( - "id", - "in", - request.env["dms.directory"]._get_own_root_directories(), - ) + ("id", "in", request.env["dms.directory"]._get_own_root_directories()), ] # search - if search and search_in: - search_domain = [] - if search_in == "name": - search_domain = OR([search_domain, [("name", "ilike", search)]]) - domain += search_domain + if search and search_in == "name": + domain += OR([[], [("name", "ilike", search)]]) # content according to pager and archive selected - items = request.env["dms.directory"].search(domain, order=sort_br) + items = request.env["dms.directory"].search(domain, order=sort_order) request.session["my_dms_folder_history"] = items.ids # values values.update( @@ -91,65 +103,42 @@ 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_order, + sortby, + ) = self._searchbar_data(filterby, sortby) + dms_directory_items, res = self._get_directories( + access_token, dms_directory_id, search, search_in, sort_order + ) 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_file_items = self._get_files( + access_token, dms_directory_id, search, search_in, sort_order + ) + dms_parent_categories = dms_directory_sudo.sudo()._get_parent_categories( access_token ) @@ -170,6 +159,100 @@ 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 + """ + if not dms_directory_id: + 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"] + is_access_token_valid = file_model.check_access_token(access_token) + file_model = file_model.sudo() if is_access_token_valid 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_order + ): + """ + 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_order: 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"] + is_access_token_valid = directory_model.check_access_token(access_token) + directory_model = ( + directory_model.sudo() if is_access_token_valid else directory_model + ) + dms_directory_items = directory_model.search(domain, order=sort_order) + + 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, sort_order, + sortby + :rtype: tuple[str, dict, dict, str, str] + """ + searchbar_sortings = {"name": {"label": _("Name"), "order": "name asc"}} + # default sortby + if not sortby: + sortby = "name" + sort_order = searchbar_sortings[sortby]["order"] + # search + searchbar_inputs = { + "name": {"input": "name", "label": _("Name")}, + } + if not filterby: + filterby = "name" + return ( + filterby, + searchbar_inputs, + searchbar_sortings, + sort_order, + sortby, + ) + @http.route( ["/my/dms/file//download"], type="http", @@ -177,26 +260,28 @@ def portal_my_dms_directory( website=True, ) def portal_my_dms_file_download(self, dms_file_id, access_token=None, **kw): - """Process user's consent acceptance or rejection.""" + """ + Download a file. + + :param int dms_file_id: dms_file_id + :param Optional[str] access_token: access_token + + :return: response + :rtype: odoo.http.Response + """ ensure_db() - # operations res = self._dms_check_access("dms.file", dms_file_id, access_token) if not res: if access_token: return request.redirect("/") - else: - return request.redirect("/my") - - dms_file_sudo = res - # It's necessary to prevent AccessError in ir_attachment .check() function - if dms_file_sudo.attachment_id and request.env.user.has_group( - "base.group_portal" - ): - dms_file_sudo = dms_file_sudo.sudo() - filecontent = base64.b64decode(dms_file_sudo.content) - content_type = ["Content-Type", "application/octet-stream"] - disposition_content = [ + return request.redirect("/my") + + if res.attachment_id and request.env.user.has_group("base.group_portal"): + res = res.sudo() + file_content = base64.b64decode(res.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]) + content_disposition(res.name), + ) + return request.make_response(file_content, [content_type, disposition_content]) diff --git a/dms/data/onboarding_data.xml b/dms/data/onboarding_data.xml new file mode 100644 index 000000000..46fce1930 --- /dev/null +++ b/dms/data/onboarding_data.xml @@ -0,0 +1,77 @@ + + + + + + + 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 + + + + + File Onboarding + + document_onboarding_file + action_close_panel_dms_file + + diff --git a/dms/demo/access_group.xml b/dms/demo/access_group.xml index 3af5ab420..136445b33 100644 --- a/dms/demo/access_group.xml +++ b/dms/demo/access_group.xml @@ -5,20 +5,14 @@ True True True - + Portal - Only admin user - True - True - True - + User + diff --git a/dms/i18n/fr.po b/dms/i18n/fr.po index 74b21ab4f..4eb2fa885 100644 --- a/dms/i18n/fr.po +++ b/dms/i18n/fr.po @@ -4,10 +4,11 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 14.0\n" +"Project-Id-Version: Odoo Server 17.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2021-06-03 16:48+0000\n" -"Last-Translator: Yves Le Doeuff \n" +"POT-Creation-Date: 2024-05-24 07:07+0000\n" +"PO-Revision-Date: 2024-05-24 07:07+0000\n" +"Last-Translator: Timothée Vannier \n" "Language-Team: none\n" "Language: fr\n" "MIME-Version: 1.0\n" @@ -19,215 +20,184 @@ msgstr "" #. module: dms #: model:ir.model.fields,help:dms.field_dms_directory__alias_process msgid "" +"\n" " Define how incoming emails are processed:\n" "\n" -" - Single Files: The email gets attached to the directory " -"and\n" +" - Single Files: The email gets attached to the directory and\n" " all attachments are created as files.\n" "\n" -" - Subdirectory: A new subdirectory is created for each " -"email\n" -" and the mail is attached to this subdirectory. The " -"attachments\n" +" - Subdirectory: A new subdirectory is created for each email\n" +" and the mail is attached to this subdirectory. The attachments\n" " are created as files of the subdirectory.\n" " " msgstr "" " Définissez comment les e-mails entrants sont traités : \n" "\n" " - Fichiers uniques : l'e-mail est joint au répertoire\n" -" et toutes les pièces jointes sont créées sous forme de " -"fichiers.\n" +" et toutes les pièces jointes sont créées sous forme de fichiers.\n" "\n" -" - Sous-répertoire : Un nouveau sous-répertoire est créé pour " -"chaque email\n" -" et le courrier est attaché à ce sous-répertoire. Les pièces " -"jointes\n" +" - Sous-répertoire : Un nouveau sous-répertoire est créé pour chaque email\n" +" et le courrier est attaché à ce sous-répertoire. Les pièces jointes\n" " sont créés en tant que fichiers du sous-répertoire.\n" " " #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_access_groups.py:0 +#, python-format +msgid "%s (copy)" +msgstr "%s (copie)" + +#. module: dms +#. odoo-python +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "%s Files" msgstr "%s Fichiers" #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "%s Subdirectories" msgstr "%s Sous-dossiers" -#. module: dms -#: model_terms:ir.ui.view,arch_db:dms.document_onboarding_directory_panel -msgid "/dms/static/lib/img/banner/documents_onboarding_directory.png" -msgstr "/dms/static/lib/img/banner/documents_onboarding_directory.png" - -#. module: dms -#: model_terms:ir.ui.view,arch_db:dms.document_onboarding_file_panel -msgid "/dms/static/lib/img/banner/documents_onboarding_file.png" -msgstr "/dms/static/lib/img/banner/documents_onboarding_file.png" - #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban msgid "" "\n" -" Archive" +" Archive" msgstr "" "\n" -" Archiver" +" Archiver" + #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban msgid "" "\n" -" Unarchive" +" Unarchive" msgstr "" "\n" -" Désarchiver" +" Désarchiver" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban msgid "" "\n" -" Download" +" Download" msgstr "" "\n" -" Télécharger" +" Télécharger" #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_kanban -#: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban +#: model_terms:ir.ui.view,arch_db:dms.wizard_dms_file_move_form_view msgid "" -"\n" -" Open" +"\n" +" ATTENTION: Tips to keep in mind before moving files:
\n" +" - This change cannot be undone.
\n" +" - Remember that the permissions of the files are those of the folder that contains it, therefore, it is possible that when you change it, the permissions will also change.
\n" +" Make this change at your own risk." msgstr "" -"\n" -" Ouvrir" +"\n" +" ATTENTION: Conseils à garder à l'esprit avant de déplacer des fichiers :
\n" +" - Ce changement ne peut pas être annulé.
\n" +" - N'oubliez pas que les autorisations des fichiers sont celles du dossier qui les contient, il est donc possible que lorsque vous les modifiez, les autorisations changent également.
\n" +" Faites ce changement à vos risques et périls." #. module: dms +#: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_kanban +#: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban #: model_terms:ir.ui.view,arch_db:dms.view_dms_tag_kanban msgid "" "\n" -" Open" +" Open" msgstr "" "\n" -" Ouvrir" +" Ouvrir" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_kanban msgid "" "\n" -" Files" +" Files" msgstr "" "\n" -" Fichiers" +" Fichiers" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_kanban msgid "" "\n" -" Directories" +" Directories" msgstr "" "\n" -" Dossiers" +" Dossiers" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban msgid "" "\n" -" Lock" +" Lock" msgstr "" "\n" -" Verrouiller" +" Verrouiller" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_kanban #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban -msgid "" -"\n" -" Edit" -msgstr "" -"\n" -" Modifier" - -#. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_tag_kanban msgid "" "\n" -" Edit" +" Edit" msgstr "" "\n" -" Modifier" +" Modifier" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_kanban #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban -msgid "" -"\n" -" Delete" -msgstr "" -"\n" -" Supprimer" - -#. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_tag_kanban msgid "" "\n" -" Delete" +" Delete" msgstr "" "\n" -" Supprimer" +" Supprimer" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban msgid "" "\n" -" Unlock" +" Unlock" msgstr "" "\n" -" Déverrouiller" +" Déverrouiller" #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.res_config_settings_view_form +#: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_form msgid "" -"\n" -" Storages\n" -" " +"@\n" +" @ " msgstr "" -"\n" -" Stockages\n" -" " +"@\n" +" @ " #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.res_config_settings_view_form -msgid "" -"File\n" -" Extensions\n" -" " +#: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban +#: model_terms:ir.ui.view,arch_db:dms.view_dms_tag_kanban +msgid "Actions" msgstr "" -"Extensions\n" -" de fichier\n" -" " #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.res_config_settings_view_form -msgid "File Size" -msgstr "Taille fichier" - -#. module: dms -#: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_form -msgid "" -"@\n" -" @ " +#: model_terms:ir.ui.view,arch_db:dms.portal_my_dms_breadcrumbs +msgid "Documents" msgstr "" #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.portal_my_dms_breadcrumbs -msgid "Documents" -msgstr "Documents" +#: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban +msgid "Operations" +msgstr "Opérations" #. module: dms #: model:ir.model.fields,help:dms.field_dms_directory__alias_defaults @@ -240,7 +210,7 @@ msgstr "" #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "A directory can't be a root and have a parent directory." msgstr "" @@ -249,14 +219,14 @@ msgstr "" #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "A directory has to have a parent directory." msgstr "Un répertoire doit avoir un répertoire parent." #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "A directory has to have model in attachment storage." msgstr "" @@ -264,7 +234,7 @@ msgstr "" #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "A directory with the same name already exists." msgstr "Un répertoire du même nom existe déjà." @@ -279,24 +249,15 @@ msgstr "" "pièces jointes." #. module: dms -#. odoo-javascript -#: code:addons/dms/static/src/js/views/dms_file_upload.esm.js:0 -#, python-format -msgid "A file with the same name already exists" -msgstr "" - -#. module: dms -#. odoo-javascript #. odoo-python #: code:addons/dms/models/dms_file.py:0 -#: code:addons/dms/static/src/js/views/dms_file_upload.esm.js:0 #, python-format -msgid "A file with the same name already exists." -msgstr "Un fichier du même nom existe déjà." +msgid "A file with the same name already exists in this directory." +msgstr "Un fichier du même nom existe déjà dans ce répertoire." #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "A root directory has to have a storage." msgstr "Un répertoire racine doit avoir un stockage." @@ -342,15 +303,14 @@ msgstr "Action nécessaire" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_kanban -#: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban msgid "Actions" -msgstr "Actions" +msgstr "" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_category__active #: model:ir.model.fields,field_description:dms.field_dms_tag__active msgid "Active" -msgstr "Active" +msgstr "Actif" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_directory__activity_ids @@ -403,6 +363,21 @@ msgstr "" msgid "Alias Contact Security" msgstr "Alias Contact Sécurité" +#. module: dms +#: model:ir.model.fields,field_description:dms.field_dms_directory__alias_domain_id +msgid "Alias Domain" +msgstr "Alias de domaine" + +#. module: dms +#: model:ir.model.fields,field_description:dms.field_dms_directory__alias_domain +msgid "Alias Domain Name" +msgstr "Alias domaine" + +#. module: dms +#: model:ir.model.fields,field_description:dms.field_dms_directory__alias_full_name +msgid "Alias Email" +msgstr "Alias d'email" + #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_directory__alias_name msgid "Alias Name" @@ -414,13 +389,18 @@ msgid "Alias Setting" msgstr "Alias Réglages" #. module: dms -#: model:ir.model.fields,field_description:dms.field_dms_directory__alias_domain -msgid "Alias domain" -msgstr "Alias domaine" +#: model:ir.model.fields,field_description:dms.field_dms_directory__alias_status +msgid "Alias Status" +msgstr "Statut de l'alias" + +#. module: dms +#: model:ir.model.fields,help:dms.field_dms_directory__alias_status +msgid "Alias status assessed on the last message received." +msgstr "Statut de l'alias évalué sur le dernier message reçu." #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "Alias-Mail-Extraction" msgstr "" @@ -446,7 +426,7 @@ msgstr "Tous les fichiers" #: code:addons/dms/static/src/js/views/dms_file_upload.esm.js:0 #, python-format msgid "An error occurred during the upload" -msgstr "" +msgstr "Une erreur s'est produite pendant le téléchargement" #. module: dms #: model:dms.tag,name:dms.tag_06_demo @@ -466,6 +446,11 @@ msgstr "Archivé" msgid "Archived Files" msgstr "Fichiers archivés" +#. module: dms +#: model_terms:ir.ui.view,arch_db:dms.wizard_dms_file_move_form_view +msgid "Are you sure? All files will be moved." +msgstr "Êtes-vous sûr ? Tous les fichiers seront déplacés." + #. module: dms #. odoo-python #: code:addons/dms/models/storage.py:0 @@ -495,6 +480,7 @@ msgstr "" #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_new_form #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_new_form #: model_terms:ir.ui.view,arch_db:dms.view_dms_storage_new_form +#: model_terms:ir.ui.view,arch_db:dms.wizard_dms_file_move_form_view msgid "Cancel" msgstr "Annuler" @@ -589,7 +575,7 @@ msgstr "Indice de couleur" #. module: dms #: model:ir.model,name:dms.model_res_company msgid "Companies" -msgstr "Entreprises" +msgstr "Sociétés" #. module: dms #: model:ir.model.fields,field_description:dms.field_abstract_dms_mixin__company_id @@ -614,7 +600,7 @@ msgstr "Compléter le Nom" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_access_group__complete_directory_ids msgid "Complete directories" -msgstr "" +msgstr "Dossiers complets" #. module: dms #: model:ir.model,name:dms.model_res_config_settings @@ -624,7 +610,7 @@ msgstr "Paramètres de configuration" #. module: dms #: model:ir.ui.menu,name:dms.cat_menu_dms_config msgid "Configuration" -msgstr "Configuration" +msgstr "" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_file__content @@ -669,6 +655,7 @@ msgstr "Nombre d'éléments" #: model:ir.model.fields,field_description:dms.field_dms_directory__count_files_title #: model:ir.model.fields,field_description:dms.field_dms_storage__count_storage_files #: model:ir.model.fields,field_description:dms.field_dms_tag__count_files +#: model:ir.model.fields,field_description:dms.field_wizard_dms_file_move__count_files msgid "Count Files" msgstr "Nombre de fichiers" @@ -700,7 +687,7 @@ msgstr "Nombre d'étiquettes" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_access_group__count_users msgid "Count Users" -msgstr "" +msgstr "Nombre d'utilisateurs" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_new_form @@ -718,14 +705,14 @@ msgid "Create Access" msgstr "Créer Accès" #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.onboarding_directory_step +#: model:onboarding.onboarding.step,button_text:dms.onboarding_step_create_directory msgid "Create Directory" -msgstr "Créer Dossier" +msgstr "Créer un répertoire" #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.onboarding_storage_step +#: model:onboarding.onboarding.step,button_text:dms.onboarding_step_document_storage msgid "Create Storage" -msgstr "Créer Stockage" +msgstr "Créer un stockage" #. module: dms #: model_terms:ir.actions.act_window,help:dms.action_dms_category @@ -733,14 +720,14 @@ msgid "Create a new Category." msgstr "Créer une nouvelle catégorie." #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.onboarding_storage_step +#: model:onboarding.onboarding.step,description:dms.onboarding_step_document_storage msgid "Create a new Document Storage." -msgstr "Créer un nouveau document de stockage." +msgstr "Créer un nouveau stockage de documents." #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.onboarding_directory_step +#: model:onboarding.onboarding.step,description:dms.onboarding_step_create_directory msgid "Create a new Root Directory." -msgstr "Créer un nouveau dossier racine." +msgstr "Créer un nouveau répertoire racine." #. module: dms #: model_terms:ir.actions.act_window,help:dms.action_dms_storage @@ -759,6 +746,7 @@ msgstr "Créer des fichiers à partir des pièces jointes des messages" #: model:ir.model.fields,field_description:dms.field_dms_file__create_uid #: model:ir.model.fields,field_description:dms.field_dms_storage__create_uid #: model:ir.model.fields,field_description:dms.field_dms_tag__create_uid +#: model:ir.model.fields,field_description:dms.field_wizard_dms_file_move__create_uid msgid "Created by" msgstr "Créer par" @@ -769,6 +757,7 @@ msgstr "Créer par" #: model:ir.model.fields,field_description:dms.field_dms_file__create_date #: model:ir.model.fields,field_description:dms.field_dms_storage__create_date #: model:ir.model.fields,field_description:dms.field_dms_tag__create_date +#: model:ir.model.fields,field_description:dms.field_wizard_dms_file_move__create_date msgid "Created on" msgstr "Créer sur" @@ -835,17 +824,13 @@ msgstr "Valeurs par défaut" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.res_config_settings_view_form -msgid "Define forbidden file extensions" -msgstr "Définit les extensions de fichiers à exclure" +msgid "Define the maximum upload size of a file in MB" +msgstr "Définit la taille maximale de téléchargement d'un fichier en Mo" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.res_config_settings_view_form -msgid "" -"Define the maximum upload size of a\n" -" file in MB" -msgstr "" -"Définir la taille maximale de téléchargement d'un\n" -" fichier en Mo" +msgid "Define forbidden file extensions" +msgstr "Définit les extensions de fichiers à exclure" #. module: dms #: model:ir.model.fields,help:dms.field_res_config_settings__documents_forbidden_extensions @@ -897,10 +882,16 @@ msgstr "" #. module: dms #: model:ir.model,name:dms.model_dms_directory #: model:ir.model.fields,field_description:dms.field_dms_file__directory_id -#: model_terms:ir.ui.view,arch_db:dms.onboarding_directory_step +#: model:ir.model.fields,field_description:dms.field_wizard_dms_file_move__directory_id +#: model:onboarding.onboarding.step,title:dms.onboarding_step_create_directory msgid "Directory" msgstr "Dossier" +#. module: dms +#: model:onboarding.onboarding,name:dms.onboarding_onboarding_dms_directory +msgid "Directory Onboarding" +msgstr "" + #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_access_group__display_name #: model:ir.model.fields,field_description:dms.field_dms_category__display_name @@ -908,6 +899,7 @@ msgstr "Dossier" #: model:ir.model.fields,field_description:dms.field_dms_file__display_name #: model:ir.model.fields,field_description:dms.field_dms_storage__display_name #: model:ir.model.fields,field_description:dms.field_dms_tag__display_name +#: model:ir.model.fields,field_description:dms.field_wizard_dms_file_move__display_name msgid "Display Name" msgstr "Afficher le nom" @@ -916,6 +908,11 @@ msgstr "Afficher le nom" msgid "Document Category" msgstr "Catégorie du document" +#. module: dms +#: model:onboarding.onboarding.step,done_text:dms.onboarding_step_document_storage +msgid "Document Storage Created!" +msgstr "Stockage de documents créé !" + #. module: dms #: model:ir.model,name:dms.model_dms_tag msgid "Document Tag" @@ -925,10 +922,9 @@ msgstr "Etiquette du document" #: model:ir.module.category,name:dms.category_dms_security #: model:ir.ui.menu,name:dms.main_menu_dms #: model_terms:ir.ui.view,arch_db:dms.portal_my_dms_breadcrumbs -#: model_terms:ir.ui.view,arch_db:dms.portal_my_home_dms #: model_terms:ir.ui.view,arch_db:dms.res_config_settings_view_form msgid "Documents" -msgstr "Documents" +msgstr "" #. module: dms #: model:ir.model.fields,field_description:dms.field_res_company__documents_onboarding_directory_state @@ -958,13 +954,6 @@ msgstr "État du stockage d'intégration des documents" msgid "Done" msgstr "Terminé" -#. module: dms -#: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_kanban -#: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban -#: model_terms:ir.ui.view,arch_db:dms.view_dms_tag_kanban -msgid "Dropdown menu" -msgstr "Menu déroulant" - #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_file__is_lock_editor msgid "Editor" @@ -976,6 +965,7 @@ msgid "Elements" msgstr "Eléments" #. module: dms +#: model:ir.model.fields,field_description:dms.field_dms_directory__alias_email #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_form msgid "Email Alias" msgstr "Alias de messagerie" @@ -983,18 +973,23 @@ msgstr "Alias de messagerie" #. module: dms #: model:ir.model,name:dms.model_mail_thread msgid "Email Thread" -msgstr "Fil d'e-mail" +msgstr "Discussion par email" + +#. module: dms +#: model:ir.model.fields,help:dms.field_dms_directory__alias_domain +msgid "Email domain e.g. 'example.com' in 'odoo@example.com'" +msgstr "Domaine de messagerie par exemple 'example.com' dans 'odoo@exemple.com" #. module: dms #. odoo-python -#: code:addons/dms/models/category.py:0 +#: code:addons/dms/models/dms_category.py:0 #, python-format msgid "Error! You cannot create recursive categories." msgstr "Erreur ! Vous ne pouvez pas créer de catégories récursives." #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "Error! You cannot create recursive directories." msgstr "Erreur ! Vous ne pouvez pas créer de catégories récursives." @@ -1008,12 +1003,12 @@ msgstr "Utilisateurs explicites" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_file__extension msgid "Extension" -msgstr "Extension" +msgstr "" #. module: dms #: model:ir.model.fields,field_description:dms.field_res_config_settings__documents_forbidden_extensions msgid "Extensions" -msgstr "Extensions" +msgstr "" #. module: dms #: model:dms.category,name:dms.category_05_demo @@ -1022,14 +1017,29 @@ msgstr "Externe" #. module: dms #: model:ir.model,name:dms.model_dms_file -#: model_terms:ir.ui.view,arch_db:dms.onboarding_file_step +#: model:onboarding.onboarding.step,title:dms.onboarding_step_upload_file msgid "File" msgstr "Fichier" +#. module: dms +#: model_terms:ir.ui.view,arch_db:dms.res_config_settings_view_form +msgid "File Extensions" +msgstr "Extensions de fichiers" + +#. module: dms +#: model:onboarding.onboarding,name:dms.onboarding_onboarding_dms_file +msgid "File Onboarding" +msgstr "" + +#. module: dms +#: model_terms:ir.ui.view,arch_db:dms.res_config_settings_view_form +msgid "File Size" +msgstr "Taille du fichier" + #. module: dms #: model_terms:ir.ui.view,arch_db:dms.res_config_settings_view_form msgid "File Upload" -msgstr "Téléchargement de fichiers" +msgstr "Dépot de fichiers" #. module: dms #: model:ir.actions.act_window,name:dms.action_dms_file @@ -1044,7 +1054,6 @@ msgstr "Téléchargement de fichiers" #: model:ir.model.fields,field_description:dms.field_dms_storage__storage_file_ids #: model:ir.model.fields,field_description:dms.field_dms_tag__file_ids #: model:ir.ui.menu,name:dms.menu_dms_file -#: model_terms:ir.ui.view,arch_db:dms.view_dms_access_groups_form #: model_terms:ir.ui.view,arch_db:dms.view_dms_category_form #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_form #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_tree @@ -1063,7 +1072,8 @@ msgstr "Fichiers" #: model_terms:ir.actions.act_window,help:dms.action_dms_files_storage msgid "Files are used to save content directly in Odoo." msgstr "" -"Les fichiers sont utilisés pour enregistrer du contenu directement dans Odoo." +"Les fichiers sont utilisés pour enregistrer du contenu directement dans " +"Odoo." #. module: dms #. odoo-python @@ -1073,6 +1083,11 @@ msgstr "" msgid "Filestore" msgstr "Stockage de fichiers" +#. module: dms +#: model:onboarding.onboarding.step,done_text:dms.onboarding_step_upload_file +msgid "First File Uploaded!" +msgstr "Premier fichier téléchargé !" + #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_directory__message_follower_ids #: model:ir.model.fields,field_description:dms.field_dms_file__message_follower_ids @@ -1133,7 +1148,7 @@ msgstr "Groupes" #: model:ir.model.fields,field_description:dms.field_dms_directory__has_message #: model:ir.model.fields,field_description:dms.field_dms_file__has_message msgid "Has Message" -msgstr "" +msgstr "A un message" #. module: dms #: model:dms.category,name:dms.category_02_demo @@ -1147,14 +1162,15 @@ msgstr "Ressource humaine" #: model:ir.model.fields,field_description:dms.field_dms_file__id #: model:ir.model.fields,field_description:dms.field_dms_storage__id #: model:ir.model.fields,field_description:dms.field_dms_tag__id +#: model:ir.model.fields,field_description:dms.field_wizard_dms_file_move__id msgid "ID" msgstr "" #. module: dms #: model:ir.model.fields,help:dms.field_dms_directory__alias_parent_thread_id msgid "" -"ID of the parent record holding the alias (example: project holding the task " -"creation alias)" +"ID of the parent record holding the alias (example: project holding the task" +" creation alias)" msgstr "" "ID de l'enregistrement parent contenant l'alias (exemple : projet contenant " "l'alias de création de la tâche)" @@ -1172,7 +1188,7 @@ msgstr "Icône" #: model:ir.model.fields,field_description:dms.field_dms_file__icon_url #: model:ir.model.fields,field_description:dms.field_dms_mixins_thumbnail__icon_url msgid "Icon URL" -msgstr "" +msgstr "URL de l'icône" #. module: dms #: model:ir.model.fields,help:dms.field_dms_directory__activity_exception_icon @@ -1195,7 +1211,9 @@ msgstr "Si coché, les nouveaux messages nécessitent votre attention." #. module: dms #: model:ir.model.fields,help:dms.field_dms_directory__message_has_error +#: model:ir.model.fields,help:dms.field_dms_directory__message_has_sms_error #: model:ir.model.fields,help:dms.field_dms_file__message_has_error +#: model:ir.model.fields,help:dms.field_dms_file__message_has_sms_error msgid "If checked, some messages have a delivery error." msgstr "Si coché, certains messages ont une erreur de livraison." @@ -1217,8 +1235,8 @@ msgid "" "If set, this content will automatically be sent out to unauthorized users " "instead of the default message." msgstr "" -"S'il est défini, ce contenu sera automatiquement envoyé aux utilisateurs non " -"autorisés au lieu du message par défaut." +"S'il est défini, ce contenu sera automatiquement envoyé aux utilisateurs non" +" autorisés au lieu du message par défaut." #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_directory__image_1920 @@ -1259,19 +1277,20 @@ msgstr "" #: model:ir.model.fields,help:dms.field_dms_directory__storage_id_inherit_access_from_parent_record #: model:ir.model.fields,help:dms.field_dms_storage__inherit_access_from_parent_record msgid "" -"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)" +"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 access)" msgstr "" "Indiquez si l'accès aux répertoires et aux fichiers fonctionne uniquement " -"avec l'accès aux modèles associés (par exemple, si certains répertoires sont " -"liés à une vente, seuls les utilisateurs ayant un accès en lecture à ces " +"avec l'accès aux modèles associés (par exemple, si certains répertoires sont" +" liés à une vente, seuls les utilisateurs ayant un accès en lecture à ces " "ventes peuvent y accéder)" #. module: dms #: model:ir.model.fields,help:dms.field_dms_storage__include_message_attachments msgid "" -"Indicate if directories and files auto-create in mail composition process too" +"Indicate if directories and files auto-create in mail composition process " +"too" msgstr "" "Indique si les répertoires et les fichiers sont également créés " "automatiquement dans le processus de composition du courrier" @@ -1288,10 +1307,12 @@ msgstr "Indique si les répertoires et les fichiers sont masqués par défaut." #: model:ir.model.fields,help:dms.field_dms_directory__is_root_directory msgid "" "Indicates if the directory is a root directory.\n" -" A root directory has a settings object, while a directory with a " -"set\n" +" A root directory has a settings object, while a directory with a set\n" " parent inherits the settings form its parent." msgstr "" +"Indique si le répertoire est un répertoire racine.\n" +" Un répertoire racine a des paramètres, tandis qu'un répertoire avec un parent défini\n" +" hérite des paramètres de son parent." #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_directory__inherit_group_ids @@ -1307,17 +1328,22 @@ msgstr "Autorisation héritées de l'enregistrement associé" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_access_group__perm_inclusive_create msgid "Inherited Create Access" -msgstr "" +msgstr "Accès Création hérité" + +#. module: dms +#: model:ir.model.fields,field_description:dms.field_dms_access_group__perm_inclusive_read +msgid "Inherited Read Access" +msgstr "Accès Lecture hérité" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_access_group__perm_inclusive_unlink msgid "Inherited Unlink Access" -msgstr "" +msgstr "Accès Suppression hérité" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_access_group__perm_inclusive_write msgid "Inherited Write Access" -msgstr "" +msgstr "Accès Écriture hérité" #. module: dms #: model:dms.category,name:dms.category_01_demo @@ -1336,7 +1362,7 @@ msgstr "Interne / Ressource Humaine" #: code:addons/dms/models/dms_file.py:0 #, python-format msgid "Invalid attachments!" -msgstr "" +msgstr "Pièces jointes non valides !" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_category_form @@ -1356,17 +1382,17 @@ msgstr "Est un répertoire racine" #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format -msgid "It is not possible to change parent to other storage." -msgstr "Il n'est pas possible de changer le parent vers un autre stockage." +msgid "It is not possible to change the storage." +msgstr "Il n'est pas possible de modifier le stockage." #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format -msgid "It is not possible to change the storage." -msgstr "Il n'est pas possible de modifier le stockage." +msgid "It is not possible to change to a parent with other storage." +msgstr "Il n'est pas possible de changer pour un parent avec un autre stockage." #. module: dms #: model:ir.model.fields.selection,name:dms.selection__res_company__documents_onboarding_directory_state__just_done @@ -1376,16 +1402,6 @@ msgstr "Il n'est pas possible de modifier le stockage." msgid "Just done" msgstr "Vient d'être réalisé" -#. module: dms -#: model:ir.model.fields,field_description:dms.field_dms_access_group____last_update -#: model:ir.model.fields,field_description:dms.field_dms_category____last_update -#: model:ir.model.fields,field_description:dms.field_dms_directory____last_update -#: model:ir.model.fields,field_description:dms.field_dms_file____last_update -#: model:ir.model.fields,field_description:dms.field_dms_storage____last_update -#: model:ir.model.fields,field_description:dms.field_dms_tag____last_update -msgid "Last Modified on" -msgstr "Dernière modification le" - #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_access_group__write_uid #: model:ir.model.fields,field_description:dms.field_dms_category__write_uid @@ -1393,6 +1409,7 @@ msgstr "Dernière modification le" #: model:ir.model.fields,field_description:dms.field_dms_file__write_uid #: model:ir.model.fields,field_description:dms.field_dms_storage__write_uid #: model:ir.model.fields,field_description:dms.field_dms_tag__write_uid +#: model:ir.model.fields,field_description:dms.field_wizard_dms_file_move__write_uid msgid "Last Updated by" msgstr "Dernière mise à jour par" @@ -1403,6 +1420,7 @@ msgstr "Dernière mise à jour par" #: model:ir.model.fields,field_description:dms.field_dms_file__write_date #: model:ir.model.fields,field_description:dms.field_dms_storage__write_date #: model:ir.model.fields,field_description:dms.field_dms_tag__write_date +#: model:ir.model.fields,field_description:dms.field_wizard_dms_file_move__write_date msgid "Last Updated on" msgstr "Dernière mise à jour le" @@ -1432,11 +1450,9 @@ msgid "Linked attachments record ID" msgstr "ID d'enregistrement des pièces jointes liées" #. module: dms -#. odoo-javascript -#: code:addons/dms/static/src/xml/views.xml:0 -#, python-format -msgid "Loading" -msgstr "Chargement" +#: model:ir.model.fields,field_description:dms.field_dms_directory__alias_incoming_local +msgid "Local-part based incoming detection" +msgstr "Détection entrante basée sur la partie locale" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_form @@ -1445,19 +1461,14 @@ msgstr "Verrouiller" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_file__is_locked +#: model_terms:ir.ui.view,arch_db:dms.view_dms_file_form msgid "Locked" msgstr "Verrouillé" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_file__locked_by msgid "Locked By" -msgstr "" - -#. module: dms -#: model:ir.model.fields,field_description:dms.field_dms_directory__message_main_attachment_id -#: model:ir.model.fields,field_description:dms.field_dms_file__message_main_attachment_id -msgid "Main Attachment" -msgstr "Pièce jointe principale" +msgstr "Verrouillé par" #. module: dms #: model:res.groups,name:dms.group_dms_manager @@ -1497,7 +1508,7 @@ msgstr "Migrer" #: code:addons/dms/models/dms_file.py:0 #, python-format msgid "Migrate File %(index)s of %(record_count)s [ %(dms_file_migration)s ]" -msgstr "" +msgstr "Migration du fichier %(index)s de %(record_count)s [ %(dms_file_migration)s ]" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_storage_form @@ -1526,11 +1537,22 @@ msgstr "Modèle" msgid "Modification Date" msgstr "Date de modification" +#. module: dms +#: model:ir.actions.server,name:dms.action_wizard_dms_file_move +#: model_terms:ir.ui.view,arch_db:dms.wizard_dms_file_move_form_view +msgid "Move" +msgstr "Déplacer" + +#. module: dms +#: model:ir.actions.act_window,name:dms.wizard_dms_file_move_act_window +msgid "Move files" +msgstr "Déplacer les fichiers" + #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_directory__my_activity_date_deadline #: model:ir.model.fields,field_description:dms.field_dms_file__my_activity_date_deadline msgid "My Activity Deadline" -msgstr "" +msgstr "Date limite de mon activité" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.search_dms_directory @@ -1550,6 +1572,7 @@ msgstr "Mes Fichiers" #. module: dms #. odoo-python #: code:addons/dms/controllers/portal.py:0 +#: code:addons/dms/controllers/portal.py:0 #: model:ir.model.fields,field_description:dms.field_abstract_dms_mixin__name #: model:ir.model.fields,field_description:dms.field_dms_category__name #: model:ir.model.fields,field_description:dms.field_dms_directory__name @@ -1600,7 +1623,7 @@ msgstr "Type d'activité suivante" #: code:addons/dms/models/dms_file.py:0 #, python-format msgid "No attachment was provided" -msgstr "" +msgstr "Aucune pièce jointe n'a été fournie" #. module: dms #: model:ir.model.fields.selection,name:dms.selection__res_company__documents_onboarding_directory_state__not_done @@ -1631,7 +1654,7 @@ msgstr "Nombre d'erreurs" #: model:ir.model.fields,help:dms.field_dms_directory__message_needaction_counter #: model:ir.model.fields,help:dms.field_dms_file__message_needaction_counter msgid "Number of messages requiring action" -msgstr "" +msgstr "Nombre de messages nécessitant une action" #. module: dms #: model:ir.model.fields,help:dms.field_dms_directory__message_has_error_counter @@ -1640,9 +1663,36 @@ msgid "Number of messages with delivery error" msgstr "Nombre de messages avec erreur de livraison" #. module: dms -#: model:dms.access.group,name:dms.access_group_03_demo -msgid "Only admin user" -msgstr "Administrateur uniquement" +#: model:ir.model,name:dms.model_onboarding_onboarding +msgid "Onboarding" +msgstr "Parcours d'intégration" + +#. module: dms +#: model:onboarding.onboarding.step,step_image_alt:dms.onboarding_step_create_directory +msgid "Onboarding Directory" +msgstr "" + +#. module: dms +#: model:onboarding.onboarding.step,step_image_alt:dms.onboarding_step_upload_file +msgid "Onboarding File" +msgstr "" + +#. module: dms +#: model:ir.model,name:dms.model_onboarding_onboarding_step +msgid "Onboarding Step" +msgstr "Étape du parcours d'intégration" + +#. module: dms +#: model:onboarding.onboarding.step,step_image_alt:dms.onboarding_step_document_storage +msgid "Onboarding Storage" +msgstr "" + +#. module: dms +#. odoo-python +#: code:addons/dms/models/dms_file.py:0 +#, python-format +msgid "Only files in the same root directory can be moved." +msgstr "Seuls les fichiers du même répertoire racine peuvent être déplacés." #. module: dms #. odoo-python @@ -1651,11 +1701,6 @@ msgstr "Administrateur uniquement" msgid "Only managers can execute this action." msgstr "Seuls les gestionnaires peuvent exécuter cette action." -#. module: dms -#: model_terms:ir.ui.view,arch_db:dms.view_dms_file_kanban -msgid "Operations" -msgstr "Opérations" - #. module: dms #: model:ir.model.fields,help:dms.field_dms_directory__alias_force_thread_id msgid "" @@ -1673,11 +1718,6 @@ msgstr "" msgid "Organizing" msgstr "Organiser" -#. module: dms -#: model:ir.model.fields,field_description:dms.field_dms_directory__alias_user_id -msgid "Owner" -msgstr "Propriétaire" - #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_category__parent_id msgid "Parent Category" @@ -1712,16 +1752,16 @@ msgstr "ID de l'enregistrement du fil de discussion Parent" #. module: dms #. odoo-python -#: code:addons/dms/models/access_groups.py:0 +#: code:addons/dms/models/dms_access_groups.py:0 #, python-format msgid "Parent group '%(parent)s' is child of '%(current)s'." -msgstr "" +msgstr "Le groupe parent '%(parent)s' est enfant de '%(current)s'." #. module: dms #: model:ir.model.fields,help:dms.field_dms_directory__alias_parent_model_id msgid "" -"Parent model holding the alias. The model holding the alias reference is not " -"necessarily the model given by alias_model_id (example: project " +"Parent model holding the alias. The model holding the alias reference is not" +" necessarily the model given by alias_model_id (example: project " "(parent_model) and task (model))" msgstr "" "Modèle parent détenant l'alias. Le modèle contenant la référence d'alias " @@ -1749,15 +1789,12 @@ msgid "" "Policy to post a message on the document using the mailgateway.\n" "- everyone: everyone can post\n" "- partners: only authenticated partners\n" -"- followers: only followers of the related document or members of following " -"channels\n" +"- followers: only followers of the related document or members of following channels\n" msgstr "" -"Politique de publication d'un message sur le document à l'aide de la " -"passerelle de messagerie.\n" +"Politique de publication d'un message sur le document à l'aide de la passerelle de messagerie.\n" "- tout le monde : tout le monde peut poster\n" "- partenaires : uniquement des partenaires authentifiés\n" -"- abonnés (followers) : uniquement les abonnés du document concerné ou les " -"membres des canaux d'abonnement\n" +"- abonnés (followers) : uniquement les abonnés du document concerné ou les membres des canaux d'abonnement\n" #. module: dms #: model:dms.access.group,name:dms.access_group_02_demo @@ -1773,10 +1810,11 @@ msgstr "URL d'accès au portail" #. module: dms #. odoo-javascript -#: code:addons/dms/static/src/js/views/fields/binary/preview_record.xml:0 +#: code:addons/dms/static/src/js/fields/preview_binary/preview_record.xml:0 +#: code:addons/dms/static/src/js/fields/preview_binary/preview_record.xml:0 #, python-format msgid "Preview" -msgstr "" +msgstr "Aperçu" #. module: dms #: model:dms.tag,name:dms.tag_10_demo @@ -1789,6 +1827,7 @@ msgid "Project" msgstr "Projet" #. module: dms +#: model:ir.model.fields,field_description:dms.field_dms_access_group__perm_read #: model:ir.model.fields,field_description:dms.field_dms_directory__permission_read #: model:ir.model.fields,field_description:dms.field_dms_file__permission_read #: model:ir.model.fields,field_description:dms.field_dms_security_mixin__permission_read @@ -1832,8 +1871,20 @@ msgstr "Dossiers Racine" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_directory__root_directory_id +#: model:ir.model.fields,field_description:dms.field_dms_file__root_directory_id msgid "Root Directory" -msgstr "" +msgstr "Répertoire Racine" + +#. module: dms +#: model:onboarding.onboarding.step,done_text:dms.onboarding_step_create_directory +msgid "Root Directory Created!" +msgstr "Répertoire racine créé !" + +#. module: dms +#: model:ir.model.fields,field_description:dms.field_dms_directory__message_has_sms_error +#: model:ir.model.fields,field_description:dms.field_dms_file__message_has_sms_error +msgid "SMS Delivery error" +msgstr "Erreur d'envoi des SMS" #. module: dms #: model:dms.tag,name:dms.tag_04_demo @@ -1885,15 +1936,23 @@ msgid "Single Files" msgstr "Fichiers uniques" #. module: dms -#: model:ir.model.fields,field_description:dms.field_dms_directory__human_size #: model:ir.model.fields,field_description:dms.field_dms_directory__size -#: model:ir.model.fields,field_description:dms.field_dms_file__human_size #: model:ir.model.fields,field_description:dms.field_dms_file__size #: model:ir.model.fields,field_description:dms.field_res_config_settings__documents_binary_max_size #: model_terms:ir.ui.view,arch_db:dms.portal_my_dms +#: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_form +#: model_terms:ir.ui.view,arch_db:dms.view_dms_file_migration_tree +#: model_terms:ir.ui.view,arch_db:dms.view_dms_file_tree +#: model_terms:ir.ui.view,arch_db:dms.view_dms_storage_form msgid "Size" msgstr "Taille" +#. module: dms +#: model:ir.model.fields,field_description:dms.field_dms_directory__human_size +#: model:ir.model.fields,field_description:dms.field_dms_file__human_size +msgid "Size (human readable)" +msgstr "Taille (lisible)" + #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_directory__starred #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_kanban @@ -1929,7 +1988,7 @@ msgstr "" #: model:ir.model.fields,field_description:dms.field_abstract_dms_mixin__storage_id #: model:ir.model.fields,field_description:dms.field_dms_directory__storage_id #: model:ir.model.fields,field_description:dms.field_dms_file__storage_id -#: model_terms:ir.ui.view,arch_db:dms.onboarding_storage_step +#: model:onboarding.onboarding.step,title:dms.onboarding_step_document_storage #: model_terms:ir.ui.view,arch_db:dms.search_dms_directory #: model_terms:ir.ui.view,arch_db:dms.search_dms_file #: model_terms:ir.ui.view,arch_db:dms.search_dms_file_migration @@ -1997,7 +2056,7 @@ msgstr "Le nom de l'étiquette existe déjà !" #: model:ir.ui.menu,name:dms.menu_dms_tag #: model_terms:ir.ui.view,arch_db:dms.view_dms_category_form msgid "Tags" -msgstr "Etiquettes" +msgstr "Étiquettes" #. module: dms #: model_terms:ir.actions.act_window,help:dms.action_dms_tag @@ -2009,13 +2068,23 @@ msgstr "Les étiquettes sont utilisées pour catégoriser les documents." #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_new_form #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_form msgid "Tags..." -msgstr "Etiquettes..." +msgstr "Étiquettes..." #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_file_form msgid "Technical Information" msgstr "Informations techniques" +#. module: dms +#: model:ir.model.fields,help:dms.field_dms_access_group__perm_read +msgid "" +"Technical field to allow read access by default. Should not touched by the " +"user. This field is used to match the perm_read in ir.rule." +msgstr "" +"Champ technique pour permettre l'accès en lecture par défaut. Ne doit pas être " +"modifié par l'utilisateur. Ce champ est utilisé pour correspondre avec le champ" +"perm_read dans ir.rule." + #. module: dms #: model:ir.model.fields,help:dms.field_dms_category__active msgid "The active field allows you to hide the category without removing it." @@ -2026,15 +2095,9 @@ msgstr "Le champ actif vous permet de masquer la catégorie sans la supprimer." msgid "The active field allows you to hide the tag without removing it." msgstr "Le champ actif vous permet de masquer l'étiquette sans la supprimer." -#. module: dms -#: model_terms:ir.ui.view,arch_db:dms.document_onboarding_directory_panel -#: model_terms:ir.ui.view,arch_db:dms.document_onboarding_file_panel -msgid "The configuration is done!" -msgstr "La configuration est terminée !" - #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "The directory name is invalid." msgstr "Le nom du répertoire n'est pas valide." @@ -2058,14 +2121,14 @@ msgstr "Le nom du fichier n'est pas valide." #: code:addons/dms/models/dms_file.py:0 #, python-format msgid "The maximum upload size is %s MB." -msgstr "La taille de téléchargement maximale est de %s Mo." +msgstr "La taille maximale de téléchargement est de %s Mo." #. module: dms #: model:ir.model.fields,help:dms.field_dms_directory__alias_model_id msgid "" -"The model (Odoo Document Kind) to which this alias corresponds. Any incoming " -"email that does not reply to an existing record will cause the creation of a " -"new record of this model (e.g. a Project Task)" +"The model (Odoo Document Kind) to which this alias corresponds. Any incoming" +" email that does not reply to an existing record will cause the creation of " +"a new record of this model (e.g. a Project Task)" msgstr "" "Le modèle (Type de document Odoo) auquel cet alias correspond. Tout e-mail " "entrant qui ne répond pas à un enregistrement existant entraînera la " @@ -2078,50 +2141,34 @@ msgid "" "The name of the email alias, e.g. 'jobs' if you want to catch emails for " "" msgstr "" -"Le nom de l'alias de messagerie, par ex. « jobs » si vous souhaitez recevoir " -"des e-mails pour " +"Le nom de l'alias de messagerie, par ex. « jobs » si vous souhaitez recevoir" +" des e-mails pour " #. module: dms #: model:ir.model.constraint,message:dms.constraint_dms_access_group_name_uniq msgid "The name of the group must be unique!" msgstr "Le nom du groupe doit être unique !" -#. module: dms -#: model:ir.model.fields,help:dms.field_dms_directory__alias_user_id -msgid "" -"The owner of records created upon receiving emails on this alias. If this " -"field is not set the system will attempt to find the right owner based on " -"the sender (From) address, or will use the Administrator account if no " -"system user is found for that address." -msgstr "" -"Le propriétaire des enregistrements créés lors de la réception d'e-mails sur " -"cet alias. Si ce champ n'est pas défini, le système tentera de trouver le " -"bon propriétaire en fonction de l'adresse de l'expéditeur (De), ou utilisera " -"le compte Administrateur si aucun utilisateur système n'est trouvé pour " -"cette adresse." - #. module: dms #: model:ir.model.fields,help:dms.field_abstract_dms_mixin__storage_id_save_type #: model:ir.model.fields,help:dms.field_dms_directory__storage_id_save_type #: model:ir.model.fields,help:dms.field_dms_file__storage_id_save_type #: model:ir.model.fields,help:dms.field_dms_storage__save_type msgid "" -"The save type is used to determine how a file is saved by the\n" -" system. If you change this setting, you can migrate existing files\n" -" manually by triggering the action." -msgstr "" -"Le type de sauvegarde est utilisé pour déterminer comment un fichier est " -"sauvegardé par\n" -" le système. Si vous modifiez ce paramètre, vous pouvez migrer les " -"fichiers existants\n" +"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." +msgstr "" +"Le type de sauvegarde est utilisé pour déterminer comment un fichier est sauvegardé par\n" +" le système. Si vous modifiez ce paramètre, vous pouvez migrer les fichiers existants\n" " en déclenchant l'action manuellement." #. module: dms #. odoo-python -#: code:addons/dms/models/directory.py:0 +#: code:addons/dms/models/dms_directory.py:0 #, python-format msgid "This directory needs to be associated to a record." -msgstr "" +msgstr "Ce répertoire doit être associé à un enregistrement." #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_directory__count_total_elements @@ -2138,10 +2185,15 @@ msgstr "Total Fichiers" msgid "Total Subdirectories" msgstr "Total Sous-dossiers" +#. module: dms +#: model_terms:ir.ui.view,arch_db:dms.wizard_dms_file_move_form_view +msgid "Total files" +msgstr "Fichiers totaux" + #. module: dms #: model:dms.category,name:dms.category_04_demo msgid "Traveling" -msgstr "" +msgstr "Voyages" #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_file__mimetype @@ -2176,22 +2228,24 @@ msgstr "Décompresser les e-mails en tant que" #: code:addons/dms/static/src/js/views/file_list_renderer.xml:0 #, python-format msgid "Upload" -msgstr "" +msgstr "Ajouter" #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.onboarding_file_step +#: model:onboarding.onboarding.step,button_text:dms.onboarding_step_upload_file msgid "Upload File" -msgstr "Téléverser un fichier" +msgstr "Ajouter un fichier" #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.onboarding_file_step +#: model:onboarding.onboarding.step,description:dms.onboarding_step_upload_file msgid "Upload your first File." -msgstr "Téléverser votre premier fichier" +msgstr "Ajouter votre premier fichier." #. module: dms +#: model:dms.access.group,name:dms.access_group_03_demo +#: model:ir.model,name:dms.model_res_users #: model:res.groups,name:dms.group_dms_user msgid "User" -msgstr "Utilisateur" +msgstr "Administrateur uniquement" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_access_groups_form @@ -2203,13 +2257,6 @@ msgstr "Utilisateurs" msgid "Vendor Bill" msgstr "Facture du fournisseur" -#. module: dms -#. odoo-javascript -#: code:addons/dms/static/src/xml/views.xml:0 -#, python-format -msgid "Viewer" -msgstr "Visionneuse" - #. module: dms #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_kanban msgid "Views" @@ -2227,6 +2274,11 @@ msgstr "Messages du site Web" msgid "Website communication history" msgstr "Historique des communications du site Web" +#. module: dms +#: model:ir.model,name:dms.model_wizard_dms_file_move +msgid "Wizard Dms File Move" +msgstr "" + #. module: dms #: model:ir.model.fields,field_description:dms.field_dms_access_group__perm_write #: model:ir.model.fields,field_description:dms.field_dms_directory__permission_write @@ -2238,16 +2290,14 @@ msgstr "Accès en Ecriture" #. module: dms #. odoo-javascript #: code:addons/dms/static/src/js/views/dms_file_upload.esm.js:0 -#: code:addons/dms/static/src/js/views/many_drop_target.js:0 #, python-format msgid "You must select a directory first" msgstr "Vous devez d'abord sélectionner un dossier" #. module: dms -#: model_terms:ir.ui.view,arch_db:dms.document_onboarding_directory_panel -#: model_terms:ir.ui.view,arch_db:dms.document_onboarding_file_panel -msgid "action_close_documents_onboarding" -msgstr "" +#: model_terms:ir.ui.view,arch_db:dms.portal_my_home_dms +msgid "Your Documents" +msgstr "Vos Documents" #. module: dms #: model_terms:ir.ui.view,arch_db:dms.portal_my_dms @@ -2263,103 +2313,3 @@ msgstr "" #: model_terms:ir.ui.view,arch_db:dms.view_dms_directory_form msgid "mail.catchall.domain" msgstr "" - -#. module: dms -#: model_terms:ir.ui.view,arch_db:dms.document_onboarding_file_panel -msgid "o_onboarding_blue" -msgstr "" - -#. module: dms -#: model_terms:ir.ui.view,arch_db:dms.document_onboarding_directory_panel -msgid "o_onboarding_orange" -msgstr "" - -#. module: dms -#: model_terms:ir.ui.view,arch_db:dms.document_onboarding_directory_panel -#: model_terms:ir.ui.view,arch_db:dms.document_onboarding_file_panel -msgid "res.company" -msgstr "" - -#, python-format -#~ msgid "Error has not been raised" -#~ msgstr "L'erreur n'a pas été signalée" - -#~ msgid "Number of messages which requires an action" -#~ msgstr "Nombre de messages nécessitant une action" - -#~ msgid "Number of unread messages" -#~ msgstr "Nombre de messages non lus" - -#~ msgid "SMS Delivery error" -#~ msgstr "Erreur de livraison SMS" - -#~ msgid "Unread Messages" -#~ msgstr "Messages non lus" - -#~ msgid "Unread Messages Counter" -#~ msgstr "Nombre de messages non lus" - -#~ msgid "Path" -#~ msgstr "Chemin" - -#~ msgid "Search" -#~ msgstr "Rechercher" - -#~ msgid "Followers (Channels)" -#~ msgstr "Abonnés (Canaux)" - -#~ msgid "Locked by" -#~ msgstr "Verrouillé par" - -#~ msgid "Migrate File %s of %s [ %s ]" -#~ msgstr "Migrer le fichier %s sur %s [ %s ]" - -#~ msgid "Drop your files here" -#~ msgstr "Déposez vos fichiers ici" - -#~ msgid "Custom Thumbnail" -#~ msgstr "Vignette personnalisée" - -#~ msgid "Medium Custom Thumbnail" -#~ msgstr "Miniature personnalisée moyenne" - -#~ msgid "Medium Thumbnail" -#~ msgstr "Miniature moyenne" - -#~ msgid "Small Custom Thumbnail" -#~ msgstr "Petite Vignette personnalisée" - -#~ msgid "Thumbnail" -#~ msgstr "Vignette" - -#~ msgid "A file is locked, the folder cannot be deleted." -#~ msgstr "Un fichier est verrouillé, le dossier ne peut pas être supprimé." - -#~ msgid "The directory has to have the permission to create files." -#~ msgstr "Le répertoire doit avoir la permission de créer des fichiers." - -#~ msgid "" -#~ "The parent directory has to have the permission to create directories." -#~ msgstr "" -#~ "Le répertoire parent doit avoir l'autorisation de créer des dossiers." - -#~ msgid "The record (%s [%s]) is locked, by an other user." -#~ msgstr "" -#~ "L'enregistrement (%s [%s]) est verrouillé, par un autre utilisateur." - -#~ msgid "" -#~ "The requested operation cannot be completed due to group security " -#~ "restrictions. Please contact your system administrator.\n" -#~ "\n" -#~ "(Document type: %s, Operation: %s)" -#~ msgstr "" -#~ "L'opération demandée ne peut pas être effectuée en raison de restrictions " -#~ "de sécurité de groupe. Veuillez contacter votre administrateur système.\n" -#~ "\n" -#~ "(Type de document : %s, Opération : %s)" - -#~ msgid "Folder" -#~ msgstr "Dossier" - -#~ msgid "SmallThumbnail" -#~ msgstr "Petite Vignette" diff --git a/dms/models/__init__.py b/dms/models/__init__.py index 9f158f8a9..eec292502 100644 --- a/dms/models/__init__.py +++ b/dms/models/__init__.py @@ -1,17 +1,23 @@ -from . import access_groups +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from . import dms_access_groups from . import base 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..e34c1153a 100644 --- a/dms/models/abstract_dms_mixin.py +++ b/dms/models/abstract_dms_mixin.py @@ -1,4 +1,5 @@ -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). +# License LGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import api, fields, models @@ -7,10 +8,13 @@ class AbstractDmsMixin(models.AbstractModel): _name = "abstract.dms.mixin" _description = "Abstract Dms Mixin" + # ---------------------------------------------------------- + # Fields + # ---------------------------------------------------------- name = fields.Char(required=True, index="btree") # Only defined to prevent error in other fields that related it storage_id = fields.Many2one( - comodel_name="dms.storage", string="Storage", store=True, copy=True + string="Storage", comodel_name="dms.storage", store=True, copy=True ) is_hidden = fields.Boolean( string="Storage is Hidden", @@ -19,9 +23,9 @@ class AbstractDmsMixin(models.AbstractModel): store=True, ) company_id = fields.Many2one( + string="Company", related="storage_id.company_id", comodel_name="res.company", - string="Company", readonly=True, store=True, index="btree", @@ -29,11 +33,14 @@ class AbstractDmsMixin(models.AbstractModel): storage_id_save_type = fields.Selection(related="storage_id.save_type", store=False) color = fields.Integer(default=0) category_id = fields.Many2one( - comodel_name="dms.category", - context="{'dms_category_show_path': True}", string="Category", + comodel_name="dms.category", + context={"dms_category_show_path": True}, ) + # ---------------------------------------------------------- + # Compute/Search + # ---------------------------------------------------------- @api.model def search_panel_select_range(self, field_name, **kwargs): """Add context to display short folder name.""" diff --git a/dms/models/base.py b/dms/models/base.py index c2ba7e42e..b4541ac97 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 @@ -8,10 +9,16 @@ class Base(models.AbstractModel): _inherit = "base" + # ---------------------------------------------------------- + # CRUD/ORM Overrides + # ---------------------------------------------------------- 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. We want to remove the attachments related + to a specific record. + 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 +26,14 @@ 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. + # This can result 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 72% rename from dms/models/access_groups.py rename to dms/models/dms_access_groups.py index 371de2f5d..ca2a75916 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 @@ -7,11 +8,20 @@ class DmsAccessGroups(models.Model): + # ---------------------------------------------------------- + # Private attributes + # ---------------------------------------------------------- _name = "dms.access.group" _description = "Record Access Groups" _parent_store = True _parent_name = "parent_group_id" + _sql_constraints = [ + ("name_uniq", "unique (name)", "The name of the group must be unique!") + ] + # ---------------------------------------------------------- + # Fields + # ---------------------------------------------------------- name = fields.Char(string="Group Name", required=True, translate=True) parent_path = fields.Char(index="btree", unaccent=False) @@ -39,74 +49,100 @@ class DmsAccessGroups(models.Model): store=True, recursive=True, ) - directory_ids = fields.Many2many( - comodel_name="dms.directory", - relation="dms_directory_groups_rel", string="Directories", - column1="gid", - column2="aid", - auto_join=True, - readonly=True, + comodel_name="dms.directory", + readonly=False, ) complete_directory_ids = fields.Many2many( + string="Complete directories", comodel_name="dms.directory", relation="dms_directory_complete_groups_rel", column1="gid", column2="aid", - string="Complete directories", auto_join=True, readonly=True, ) count_users = fields.Integer(compute="_compute_users", store=True) count_directories = fields.Integer(compute="_compute_count_directories") parent_group_id = fields.Many2one( - comodel_name="dms.access.group", string="Parent Group", + comodel_name="dms.access.group", ondelete="cascade", index="btree", ) - child_group_ids = fields.One2many( + string="Child Groups", comodel_name="dms.access.group", inverse_name="parent_group_id", - string="Child Groups", ) group_ids = fields.Many2many( + string="Groups", comodel_name="res.groups", relation="dms_access_group_groups_rel", column1="gid", column2="rid", - string="Groups", ) explicit_user_ids = fields.Many2many( + string="Explicit Users", comodel_name="res.users", relation="dms_access_group_explicit_users_rel", column1="gid", column2="uid", - string="Explicit Users", ) users = fields.Many2many( + string="Group Users", comodel_name="res.users", relation="dms_access_group_users_rel", column1="gid", column2="uid", - string="Group Users", compute="_compute_users", auto_join=True, store=True, recursive=True, ) + # ---------------------------------------------------------- + # SQL Constraints + # ---------------------------------------------------------- + @api.constrains("parent_path") + def _check_parent_recursiveness(self): + """ + Forbid recursive relationships. + """ + for one in self.filtered("parent_group_id"): + if str(one.id) in one.parent_path.split("/"): + raise ValidationError( + _("Parent group '%(parent)s' is child of '%(current)s'.") + % { + "parent": one.parent_group_id.display_name, + "current": one.display_name, + } + ) + + # ---------------------------------------------------------- + # Default methods + # ---------------------------------------------------------- + @api.model + def default_get(self, fields_list): + """ + When creating a new group, add the current user to the explicit users. + """ + res = super().default_get(fields_list) + if res.get("explicit_user_ids"): + res["explicit_user_ids"] = res["explicit_user_ids"] + [self.env.uid] + else: + res["explicit_user_ids"] = [(6, 0, [self.env.uid])] + return res + + # ---------------------------------------------------------- + # Compute/Search + # ---------------------------------------------------------- @api.depends("directory_ids") def _compute_count_directories(self): for record in self: record.count_directories = len(record.directory_ids) - _sql_constraints = [ - ("name_uniq", "unique (name)", "The name of the group must be unique!") - ] - @api.depends( "parent_group_id.perm_inclusive_create", "parent_group_id.perm_inclusive_unlink", @@ -121,8 +157,7 @@ def _compute_inclusive_permissions(self): 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] ) @@ -130,15 +165,6 @@ def _compute_inclusive_permissions(self): } ) - @api.model - def default_get(self, fields_list): - res = super(DmsAccessGroups, self).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: - res["explicit_user_ids"] = [(6, 0, [self.env.uid])] - return res - @api.depends( "parent_group_id", "parent_group_id.users", @@ -148,22 +174,21 @@ def default_get(self, fields_list): ) def _compute_users(self): for record in self: - users = record.mapped("group_ids.users") - users |= record.mapped("explicit_user_ids") - users |= record.mapped("parent_group_id.users") + users = ( + record.group_ids.users + | record.explicit_user_ids + | record.parent_group_id.users + ) record.update({"users": users, "count_users": len(users)}) - @api.constrains("parent_path") - def _check_parent_recursiveness(self): - """Forbid recursive relationships.""" - for one in self: - if not one.parent_group_id: - continue - if str(one.id) in one.parent_path.split("/"): - raise ValidationError( - _("Parent group '%(parent)s' is child of '%(current)s'.") - % { - "parent": one.parent_group_id.display_name, - "current": one.display_name, - } - ) + # ---------------------------------------------------------- + # CRUD/ORM Overrides + # ---------------------------------------------------------- + def copy(self, default=None): + default = dict(default or {}) + default["name"] = _("%s (copy)") % self.name + return super().copy(default=default) + + def write(self, vals): + self.env.registry.clear_cache() + return super().write(vals) 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 83% rename from dms/models/directory.py rename to dms/models/dms_directory.py index e89c7c543..25d601012 100644 --- a/dms/models/directory.py +++ b/dms/models/dms_directory.py @@ -1,13 +1,16 @@ # Copyright 2017-2019 MuK IT GmbH. # Copyright 2020 Creu Blanca # Copyright 2021 Tecnativa - Víctor Martínez +# Copyright 2024 Subteno - Timothée Vannier (https://www.subteno.com). # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). import ast import base64 import logging +import os from ast import literal_eval from collections import defaultdict +from typing import Literal # noqa # pylint: disable=unused-import from odoo import _, api, fields, models, tools from odoo.exceptions import UserError, ValidationError @@ -19,6 +22,7 @@ from ..tools.file import check_name, unique_name _logger = logging.getLogger(__name__) +_path = os.path.dirname(os.path.dirname(__file__)) class DmsDirectory(models.Model): @@ -42,6 +46,9 @@ class DmsDirectory(models.Model): _parent_name = "parent_id" _directory_field = _parent_name + # ---------------------------------------------------------- + # Fields + # ---------------------------------------------------------- parent_path = fields.Char(index="btree", unaccent=False) is_root_directory = fields.Boolean( default=False, @@ -49,21 +56,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,31 +82,22 @@ 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", + comodel_name="dms.access.group", ) complete_group_ids = fields.Many2many( + string="Complete Groups", comodel_name="dms.access.group", relation="dms_directory_complete_groups_rel", column1="aid", column2="gid", - string="Complete Groups", compute="_compute_groups", readonly=True, store=True, @@ -108,17 +105,19 @@ def _default_parent_id(self): recursive=True, ) complete_name = fields.Char( - compute="_compute_complete_name", store=True, recursive=True + 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,77 +126,70 @@ def _default_parent_id(self): """, column1="did", column2="tid", - string="Tags", compute="_compute_tags", readonly=False, store=True, ) - user_star_ids = fields.Many2many( + string="Stars", comodel_name="res.users", relation="dms_directory_star_rel", column1="did", column2="uid", - string="Stars", ) - starred = fields.Boolean( compute="_compute_starred", inverse="_inverse_starred", search="_search_starred", ) - file_ids = fields.One2many( + 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 (human readable)" + string="Size (human readable)", 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.", ) inherit_group_ids = fields.Boolean(string="Inherit Groups", default=True) 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 @@ -206,103 +198,16 @@ def _default_parent_id(self): are created as files of the subdirectory. """, ) - - @api.model - def _get_domain_by_access_groups(self, operation): - """Special rules for directories.""" - self_filter = [ - ("storage_id_inherit_access_from_parent_record", "=", False), - ("id", "inselect", self._get_access_groups_query(operation)), - ] - # Upstream only filters by parent directory - result = super()._get_domain_by_access_groups(operation) - if operation == "create": - # When creating, I need create access in parent directory, or - # self-create permission if it's a root directory - result = OR( - [ - [("is_root_directory", "=", False)] + result, - [("is_root_directory", "=", True)] + self_filter, - ] - ) - else: - # In other operations, I only need self access - result = self_filter - return result - - def _compute_access_url(self): - res = super()._compute_access_url() - for item in self: - item.access_url = "/my/dms/directory/%s" % (item.id) - return res - - def check_access_token(self, access_token=False): - res = False - if access_token: - items = ( - self.env["dms.directory"] - .sudo() - .search([("access_token", "=", access_token)]) - ) - if items: - item = items[0] - if item.id == self.id: - return True - else: - directory_item = self - while directory_item.parent_id: - if directory_item.id == item.id: - return True - directory_item = directory_item.parent_id - # Fix last level - if directory_item.id == item.id: - return True - return res - - @api.model - def _get_parent_categories(self, access_token): - self.ensure_one() - directories = [] - current_directory = self - while current_directory: - directories.insert(0, current_directory) - if ( - ( - access_token - and current_directory.access_token - and consteq(current_directory.access_token, access_token) - ) - or not access_token - and current_directory.check_access_rights("read") - ): - return directories - current_directory = current_directory.parent_id - if access_token: - # Reaching here means we didn't find the directory accessible by this token - return [self] - return directories - - def _get_own_root_directories(self): - res = self.env["dms.directory"].search_read( - [("is_hidden", "=", False)], ["parent_id"] - ) - all_ids = [value["id"] for value in res] - res_ids = [] - for item in res: - if not item["parent_id"] or item["parent_id"][0] not in all_ids: - res_ids.append(item["id"]) - return res_ids - allowed_model_ids = fields.Many2many( related="storage_id.model_ids", comodel_name="ir.model", ) model_id = fields.Many2one( + string="Model", 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( @@ -318,6 +223,24 @@ def _get_own_root_directories(self): store=True, ) + # ---------------------------------------------------------- + # Default methods + # ---------------------------------------------------------- + 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"] + return False + + # ---------------------------------------------------------- + # Compute/Search + # ---------------------------------------------------------- + def _compute_access_url(self): + res = super()._compute_access_url() + for item in self: + item.access_url = "/my/dms/directory/%s" % item.id + return res + @api.depends("res_model") def _compute_model_id(self): for record in self: @@ -332,75 +255,6 @@ 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)) - self.flush_recordset() - - # ---------------------------------------------------------- - # SearchPanel - # ---------------------------------------------------------- - - @api.model - def search_panel_select_range(self, field_name, **kwargs): - context = {} - if field_name == "parent_id": - context["directory_short_name"] = True - return super( - DmsDirectory, self.with_context(**context) - ).search_panel_select_range(field_name, **kwargs) - - @api.model - def search_panel_select_multi_range(self, field_name, **kwargs): - return super( - DmsDirectory, self.with_context(category_short_name=True) - ).search_panel_select_multi_range(field_name, **kwargs) - - # ---------------------------------------------------------- - # Actions - # ---------------------------------------------------------- - - def action_save_onboarding_directory_step(self): - self.env.user.company_id.set_onboarding_step_done( - "documents_onboarding_directory_state" - ) - - # ---------------------------------------------------------- - # SearchPanel - # ---------------------------------------------------------- - - @api.model - def _search_panel_directory(self, **kwargs): - search_domain = (kwargs.get("search_domain", []),) - if search_domain and len(search_domain): - for domain in search_domain[0]: - if domain[0] == "parent_id": - return domain[1], domain[2] - return None, None - - # ---------------------------------------------------------- - # Search - # ---------------------------------------------------------- - - @api.model - def _search_starred(self, operator, operand): - if operator == "=" and operand: - return [("user_star_ids", "in", [self.env.uid])] - return [("user_star_ids", "not in", [self.env.uid])] - @api.depends("name", "parent_id.complete_name") def _compute_complete_name(self): for category in self: @@ -443,9 +297,7 @@ def _compute_count_files(self): @api.depends("child_directory_ids", "file_ids") def _compute_count_elements(self): for record in self: - elements = record.count_files - elements += record.count_directories - record.count_elements = elements + record.count_elements = record.count_files + record.count_directories def _compute_count_total_directories(self): for record in self: @@ -466,9 +318,9 @@ def _compute_count_total_files(self): def _compute_count_total_elements(self): for record in self: - total_elements = record.count_total_files - total_elements += record.count_total_directories - record.count_total_elements = total_elements + record.count_total_elements = ( + record.count_elements + record.count_total_directories + ) def _compute_size(self): sudo_model = self.env["dms.file"].sudo() @@ -502,10 +354,6 @@ def _compute_groups(self): groups |= one.parent_id.complete_group_ids self.complete_group_ids = groups - # ---------------------------------------------------------- - # View - # ---------------------------------------------------------- - @api.depends("is_root_directory") def _compute_parent_id(self): for record in self: @@ -530,10 +378,14 @@ 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 + # ---------------------------------------------------------- + # Onchange/Constrains + # ---------------------------------------------------------- @api.onchange("storage_id") def _onchange_storage_id(self): for record in self: @@ -547,10 +399,6 @@ def _onchange_storage_id(self): def _onchange_model_id(self): self._inverse_model_id() - # ---------------------------------------------------------- - # Constrains - # ---------------------------------------------------------- - @api.constrains("parent_id") def _check_directory_recursion(self): if not self._check_recursion(): @@ -559,9 +407,9 @@ def _check_directory_recursion(self): @api.constrains("storage_id", "model_id") def _check_storage_id_attachment_model_id(self): - for record in self: - if record.storage_id.save_type != "attachment": - continue + for record in self.filtered( + lambda directory: directory.storage_id.save_type == "attachment" + ): if not record.model_id: raise ValidationError( _("A directory has to have model in attachment storage.") @@ -592,52 +440,93 @@ def _check_name(self): for record in self: if self.env.context.get("check_name", True) and not check_name(record.name): raise ValidationError(_("The directory name is invalid.")) + if record.is_root_directory: - childs = record.sudo().storage_id.root_directory_ids.name_get() + children = 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, - ) + children = record.sudo().parent_id.child_directory_ids + + if children.filtered( + lambda child, record=record: child.name == record.name + and child != record ): raise ValidationError( _("A directory with the same name already exists.") ) # ---------------------------------------------------------- - # Create, Update, Delete + # CRUD/ORM Overrides # ---------------------------------------------------------- + @api.model + def _get_domain_by_access_groups(self, operation): + """ + Special rules for directories. - def _inverse_starred(self): - starred_records = self.env["dms.directory"].sudo() - not_starred_records = self.env["dms.directory"].sudo() - for record in self: - if not record.starred and self.env.user in record.user_star_ids: - starred_records |= record - elif record.starred and self.env.user not in record.user_star_ids: - not_starred_records |= record - not_starred_records.write({"user_star_ids": [(4, self.env.uid)]}) - starred_records.write({"user_star_ids": [(3, self.env.uid)]}) + :param Literal["create", "read", "write", "unlink"] operation: + Operation to check. + """ + self_filter = [ + ("storage_id_inherit_access_from_parent_record", "=", False), + ("id", "inselect", self._get_access_groups_query(operation)), + ] + # Upstream only filters by parent directory + result = super()._get_domain_by_access_groups(operation) + if operation == "create": + # When creating, I need the create access in parent directory, or + # self-create permission if it's a root directory + result = OR( + [ + [("is_root_directory", "=", False)] + result, + [("is_root_directory", "=", True)] + self_filter, + ] + ) + else: + # In other operations, I only need self access + result = self_filter + return result + + @api.model + def search_panel_select_range(self, field_name, **kwargs): + """ + Override to add context to search_panel_select_range. + + :param str field_name: Field name to search. + """ + context = {} + if field_name == "parent_id": + context["directory_short_name"] = True + return super( + DmsDirectory, self.with_context(**context) + ).search_panel_select_range(field_name, **kwargs) + + @api.model + def search_panel_select_multi_range(self, field_name, **kwargs): + """ + Override to add context to search_panel_select_multi_range. + """ + return super( + DmsDirectory, self.with_context(category_short_name=True) + ).search_panel_select_multi_range(field_name, **kwargs) + + @api.model + def _search_starred(self, operator, operand): + if operator == "=" and operand: + return [("user_star_ids", "in", [self.env.uid])] + return [("user_star_ids", "not in", [self.env.uid])] def copy(self, default=None): self.ensure_one() default = dict(default or []) if "parent_id" in default: - parent_directory = self.browse(default["parent_id"]) + parent_directory = self.browse(default.get("parent_id")) names = parent_directory.sudo().child_directory_ids.mapped("name") elif self.is_root_directory: names = self.sudo().storage_id.root_directory_ids.mapped("name") else: names = self.sudo().parent_id.child_directory_ids.mapped("name") + default.update({"name": unique_name(self.name, names)}) - 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() @@ -651,8 +540,17 @@ def _alias_get_creation_values(self): @api.model def message_new(self, msg_dict, custom_values=None): + """ + Create a new directory from an email. + + :param dict msg_dict: Email dictionary. + :param dict custom_values: Custom values to set. + + :return: Created directory. + :rtype: odoo.model.dms_directory + """ custom_values = custom_values if custom_values is not None else {} - parent_directory_id = custom_values.get("parent_id", None) + parent_directory_id = custom_values.get("parent_id") parent_directory = self.sudo().browse(parent_directory_id) if not parent_directory_id or not parent_directory.exists(): raise ValueError("No directory could be found!") @@ -661,32 +559,18 @@ def message_new(self, msg_dict, custom_values=None): return parent_directory names = parent_directory.child_directory_ids.mapped("name") subject = slugify(msg_dict.get("subject", _("Alias-Mail-Extraction"))) - defaults = dict( - {"name": unique_name(subject, names, escape_suffix=True)}, **custom_values - ) + defaults = { + "name": unique_name(subject, names, escape_suffix=True), + **custom_values, + } directory = super().message_new(msg_dict, custom_values=defaults) directory._process_message(msg_dict) return directory def message_update(self, msg_dict, update_vals=None): - self._process_message(msg_dict, extra_values=update_vals) + self._process_message(msg_dict) return super().message_update(msg_dict, update_vals=update_vals) - def _process_message(self, msg_dict, extra_values=False): - names = self.sudo().file_ids.mapped("name") - for attachment in msg_dict["attachments"]: - uname = unique_name(attachment.fname, names, escape_suffix=True) - vals = { - "directory_id": self.id, - "name": uname, - } - try: - vals["content"] = base64.b64encode(attachment.content) - except Exception: - vals["content"] = attachment.content - self.env["dms.file"].sudo().create(vals) - names.append(uname) - @api.model_create_multi def create(self, vals_list): for vals in vals_list: @@ -697,11 +581,12 @@ def create(self, vals_list): # Hack to prevent error related to mail_message parent not exists in some cases ctx = dict(self.env.context).copy() ctx.update({"default_parent_id": False}) + self.env.registry.clear_cache() res = super(DmsDirectory, self.with_context(**ctx)).create(vals_list) return res def write(self, vals): - if any([k in vals.keys() for k in ["storage_id", "parent_id"]]): + 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) @@ -711,17 +596,19 @@ 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) @@ -731,19 +618,19 @@ def unlink(self): """Custom cascade unlink. Cannot rely on DB backend's cascade because subfolder and subfile unlinks - must check custom permissions implementation. + must check custom permissions' implementation. """ self.file_ids.unlink() if self.child_directory_ids: self.child_directory_ids.unlink() - return super().unlink() + return super(DmsDirectory, self.exists()).unlink() @api.model def _search_panel_domain_image( self, field_name, domain, set_count=False, limit=False ): """We need to overwrite function from directories because odoo only return - records with childs (very weird for user perspective). + records with children (very weird for user perspective). All records are returned now. """ if field_name == "parent_id": @@ -761,6 +648,15 @@ def _search_panel_domain_image( field_name=field_name, domain=domain, set_count=set_count, limit=limit ) + # ---------------------------------------------------------- + # Actions + # ---------------------------------------------------------- + def action_save_onboarding_directory_step(self): + self.ensure_one() + self.env.user.company_id.set_onboarding_step_done( + "documents_onboarding_directory_state" + ) + def action_dms_directories_all_directory(self): self.ensure_one() action = self.env["ir.actions.act_window"]._for_xml_id( @@ -798,3 +694,113 @@ def action_dms_files_all_directory(self): searchpanel_default_directory_id=self.id, ) return action + + # ---------------------------------------------------------- + # Other Business Methods + # ---------------------------------------------------------- + def check_access_token(self, access_token=False): + """ + Check the validity of the access token. + + :param str access_token: Access token to check. + + :return: True if the access token is valid, False otherwise. + :rtype: bool + """ + res = False + if access_token: + items = ( + self.env["dms.directory"] + .sudo() + .search([("access_token", "=", access_token)]) + ) + if items: + item = items[0] + if item.id == self.id: + return True + directory_item = self + while directory_item.parent_id: + if directory_item.id == item.id: + return True + directory_item = directory_item.parent_id + # Fix last level + if directory_item.id == item.id: + return True + return res + + @api.model + def _get_parent_categories(self, access_token): + """ + Get all parent categories as well as the current category. + + :param str access_token: Access token to check. + + :return: List of categories. + :rtype: list[odoo.models.dms_directory] + """ + 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 toggle_starred(self): + updates = defaultdict(set) + for record in self: + vals = {"starred": not record.starred} + updates[tools.frozendict(vals)].add(record.id) + for vals, ids in updates.items(): + self.browse(ids).write(dict(vals)) + self.flush_recordset() + + def _inverse_starred(self): + starred_records = self.env["dms.directory"].sudo() + not_starred_records = self.env["dms.directory"].sudo() + for record in self: + if not record.starred and self.env.user in record.user_star_ids: + starred_records |= record + elif record.starred and self.env.user not in record.user_star_ids: + not_starred_records |= record + not_starred_records.write({"user_star_ids": [(4, self.env.uid)]}) + starred_records.write({"user_star_ids": [(3, self.env.uid)]}) + + def _process_message(self, msg_dict): + names = self.sudo().file_ids.mapped("name") + for attachment in msg_dict["attachments"]: + uname = unique_name(attachment.fname, names, escape_suffix=True) + vals = { + "directory_id": self.id, + "name": uname, + } + try: + vals["content"] = base64.b64encode(attachment.content) + except Exception: + vals["content"] = attachment.content + self.env["dms.file"].sudo().create(vals) + names.append(uname) + + 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 diff --git a/dms/models/dms_file.py b/dms/models/dms_file.py index 5e70947f8..309e2459a 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,10 +23,9 @@ _logger = logging.getLogger(__name__) -class File(models.Model): +class DMSFile(models.Model): _name = "dms.file" _description = "File" - _inherit = [ "portal.mixin", "dms.security.mixin", @@ -34,24 +34,21 @@ class File(models.Model): "mail.activity.mixin", "abstract.dms.mixin", ] - _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 +60,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,48 +90,63 @@ 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 (human readable)", + 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.""" @@ -155,258 +162,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"]: - if item["id"] in field_range: - 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"] @@ -433,7 +194,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), } ) @@ -495,23 +256,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: @@ -527,48 +296,322 @@ 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): - for record in self: - if ( - record.extension - and record.extension in self._get_forbidden_extensions() - ): - raise ValidationError(_("The file has a forbidden file extension.")) + if self.filtered( + lambda rec: rec.extension + and rec.extension in self._get_forbidden_extensions() + ): + raise ValidationError(_("The file has a forbidden file extension.")) @api.constrains("size") def _check_size(self): - for record in self: - if record.size and record.size > self._get_binary_max_size() * 1024 * 1024: - raise ValidationError( - _("The maximum upload size is %s MB.") % self._get_binary_max_size() - ) + if self.filtered( + lambda rec: rec.size > self._get_binary_max_size() * 1024 * 1024 + ): + raise ValidationError( + _("The maximum upload size is %s MB.") % self._get_binary_max_size() + ) # ---------------------------------------------------------- - # Create, Update, Delete + # CRUD/ORM Overrides # ---------------------------------------------------------- - def _inverse_content(self): updates = defaultdict(set) for record in self: - values = self._get_content_inital_vals() + 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) - 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)) + + def copy(self, default=None): + self.ensure_one() + default = dict(default or []) + names = self.sudo().directory_id.file_ids.mapped("name") + if "directory_id" in default: + directory = self.env["dms.directory"].browse( + default.get("directory_id", False) + ) + names = directory.sudo().file_ids.mapped("name") + default.update({"name": file.unique_name(self.name, names, self.extension)}) + return super().copy(default) + + @api.model_create_multi + def create(self, vals_list): + new_vals_list = [] + for vals in vals_list: + if "attachment_id" not in vals: + vals = self._create_model_attachment(vals) + new_vals_list.append(vals) + return super().create(new_vals_list) + + 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. + """ + res = True + for rec in self: + if rec.permission_write: + res &= super(DMSFile, rec.sudo()).write(vals) + else: + res &= super(DMSFile, rec).write(vals) + return res + + def unlink(self): + """ + Override to sudo the unlink if the user has the 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. + """ + res = True + for record in self: + if record.permission_unlink: + res &= super(DMSFile, record.sudo()).unlink() + else: + res &= super(DMSFile, record).unlink() + return res + + def check_access_rule(self, operation): + self.directory_id.check_access_rule(operation) + return super().check_access_rule(operation) + + @api.model + def search_panel_select_range(self, field_name, **kwargs): + """ + This method is overwritten to make it 'similar' to v13. + The goal is that the directory searchpanel shows all directories + (even if some folders have no files). + """ + if field_name != "directory_id": + context = {} + if field_name == "category_id": + context["category_short_name"] = True + return super( + DMSFile, self.with_context(**context) + ).search_panel_select_range(field_name, **kwargs) + + domain = [("is_hidden", "=", False)] + # If we pass by context something, we filter more about it we filter + # the directories of the files, or we show all of them + if self.env.context.get("active_model") == "dms.directory": + active_id = self.env.context.get("active_id") + files = self.env["dms.file"].search( + [("directory_id", "child_of", active_id)] + ) + all_directory_ids = [] + for file_record in files: + directory = file_record.directory_id + while directory: + all_directory_ids.append(directory.id) + directory = directory.parent_id + domain.append(("id", "in", all_directory_ids)) + # Get all possible directories + comodel_records = ( + self.env["dms.directory"] + .with_context(directory_short_name=True) + .search_read(domain, ["display_name", "parent_id"]) + ) + all_record_ids = [rec["id"] for rec in comodel_records] + field_range = {} + enable_counters = kwargs.get("enable_counters") + for record in comodel_records: + record_id = record["id"] + parent = record["parent_id"] + record_values = { + "id": record_id, + "display_name": record["display_name"], + # If the parent directory is not in all the records we should not + # set parent_id because the user does not have access to parent. + "parent_id": ( + parent[0] if parent and parent[0] in all_record_ids else False + ), + } + if enable_counters: + record_values["__count"] = 0 + field_range[record_id] = record_values + if enable_counters: + res = super().search_panel_select_range(field_name, **kwargs) + for item in res["values"]: + if item["id"] in field_range: + field_range[item["id"]]["__count"] = item["__count"] + return {"parent_field": "parent_id", "values": list(field_range.values())} + + @api.model + def search_panel_select_multi_range(self, field_name, **kwargs): + operator, directory_id = self._search_panel_directory(**kwargs) + if field_name == "tag_ids": + sql_query = """ + SELECT t.name AS name, t.id AS id, c.name AS group_name, + c.id AS group_id, COUNT(r.fid) AS count + FROM dms_tag t + JOIN dms_category c ON t.category_id = c.id + LEFT JOIN dms_file_tag_rel r ON t.id = r.tid + WHERE %(filter_by_file_ids)s IS FALSE OR r.fid = ANY(%(file_ids)s) + GROUP BY c.name, c.id, t.name, t.id + ORDER BY c.name, c.id, t.name, t.id; + """ + file_ids = [] + if directory_id: + file_ids = self.search([("directory_id", operator, directory_id)]).ids + self.env.cr.execute( + sql_query, + {"file_ids": file_ids, "filter_by_file_ids": bool(directory_id)}, + ) + return self.env.cr.dictfetchall() + if directory_id and field_name in ["directory_id", "category_id"]: + comodel_domain = kwargs.pop("comodel_domain", []) + directory_comodel_domain = self._search_panel_domain( + "file_ids", operator, directory_id, comodel_domain + ) + return super( + DMSFile, self.with_context(directory_short_name=True) + ).search_panel_select_multi_range( + field_name, comodel_domain=directory_comodel_domain, **kwargs + ) + return super( + DMSFile, self.with_context(directory_short_name=True) + ).search_panel_select_multi_range(field_name, **kwargs) + + # ---------------------------------------------------------- + # Actions + # ---------------------------------------------------------- + def action_migrate(self, should_logging=True): + record_count = len(self) + index = 1 + for dms_file in self: + if should_logging: + _logger.info( + _( + "Migrate File %(index)s of %(record_count)s [ %(" + "dms_file_migration)s ]", + index=index, + record_count=record_count, + dms_file_migration=dms_file.migration, + ) + ) + index += 1 + dms_file.write( + { + "content": dms_file.with_context(**{}).content, + "storage_id": dms_file.directory_id.storage_id.id, + } + ) + + def action_save_onboarding_file_step(self): + self.ensure_one() + self.env.user.company_id.set_onboarding_step_done( + "documents_onboarding_file_state" + ) + + # ---------------------------------------------------------- + # Other Business Methods + # ---------------------------------------------------------- + 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): + """ + Get the checksum of a binary. + + :param bytes binary: The binary to get the checksum from. + """ + 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): + """ + Update the values with the binary content. + + :param dict vals: The values to update. + :param bytes binary: The binary content. + + :return: The updated values. + """ + 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): + """ + Create an attachment if the file is stored in the database. + + :param dict vals: The values to create the file. + """ 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"): @@ -599,65 +642,19 @@ 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}) + @staticmethod + def get_attachment_object(attachment): + """ + Get the attachment object from an attachment record. - def get_attachment_object(self, attachment): + :param odoo.model.ir_attachment attachment: The attachment record. + """ return { "name": attachment.name, "datas": attachment.datas, @@ -665,9 +662,15 @@ def get_attachment_object(self, attachment): "mimetype": attachment.mimetype, } + @api.model def get_dms_files_from_attachments(self, attachment_ids=None): - """Get the dms files from uploaded attachments. + """ + Get the dms files from uploaded attachments. + + :param list[int] attachment_ids: The attachment ids. + :return: An Array of dms files. + :rtype: list[odoo.model.dms_file] """ if not attachment_ids: raise UserError(_("No attachment was provided")) @@ -681,3 +684,33 @@ 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.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 + + @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)]]) diff --git a/dms/models/dms_security_mixin.py b/dms/models/dms_security_mixin.py index 829a8a786..d982f52cc 100644 --- a/dms/models/dms_security_mixin.py +++ b/dms/models/dms_security_mixin.py @@ -1,12 +1,18 @@ # 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). 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 ( + FALSE_DOMAIN, + NEGATIVE_TERM_OPERATORS, + OR, + TRUE_DOMAIN, +) _logger = getLogger(__name__) @@ -60,14 +66,15 @@ 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 🦸 + Get permissions for the current record. + """ + + # Update according to presence when applying ir.rule + self.invalidate_recordset() if self.env.su: self.update( { @@ -78,7 +85,7 @@ def _compute_permissions(self): } ) return - # Update according to presence when applying ir.rule + creatable = self._filter_access_rules("create") readable = self._filter_access_rules("read") unlinkable = self._filter_access_rules("unlink") @@ -100,10 +107,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), @@ -119,8 +123,9 @@ def _get_domain_by_inheritance(self, operation): try: model = self.env[group["res_model"]] except KeyError: - # Model not registered. This is normal if you are upgrading the - # database. Otherwise, you probably have garbage DMS data. + # The model might not be registered. + # This is normal if you are upgrading the database. + # Otherwise, you probably have garbage DMS data. # These records will be accessible by DB users only. domains.append( [ @@ -153,7 +158,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 +168,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,13 +205,14 @@ def _get_permission_domain(self, operator, value, operation): if _self.env.su: # You're SUPERUSER_ID return TRUE_DOMAIN if positive else FALSE_DOMAIN - # Obtain and combine domains + result = OR( [ _self._get_domain_by_access_groups(operation), _self._get_domain_by_inheritance(operation), ] ) + if not positive: result.insert(0, "!") return result @@ -233,7 +237,7 @@ def _filter_access_rules_python(self, operation): # Only kept to not break inheritance; see next comment result = super()._filter_access_rules_python(operation) # HACK Always fall back to applying rules by SQL. - # Upstream `_filter_acccess_rules_python()` doesn't use computed fields + # Upstream `_filter_access_rules_python()` doesn't use computed fields # search methods. Thus, it will take the `[('permission_{operation}', # '=', user.id)]` rule literally. Obviously that will always fail # because `self[f"permission_{operation}"]` will always be a `bool`, @@ -249,7 +253,7 @@ def create(self, vals_list): # Need to flush now, so all groups are stored in DB and the SELECT used # to check access works res.flush_recordset() - # Go back to original sudo state and check we really had creation permission + # Go back to the original sudo state and check we really had creation permission res = res.sudo(self.env.su) res.check_access_rights("create") res.check_access_rule("create") 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/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..3e7141400 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" @@ -17,11 +17,16 @@ class Thumbnail(models.AbstractModel): icon_url = fields.Char(string="Icon URL", compute="_compute_icon_url") def _get_icon_disk_path(self): - """Obtain local disk path to record icon.""" - folders = ["static", "icons"] + """Get the local disk path to record icon.""" 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..803d9e9b0 --- /dev/null +++ b/dms/models/onboarding_onboarding.py @@ -0,0 +1,16 @@ +# 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): + _inherit = "onboarding.onboarding" + + # ---------------------------------------------------------- + # Actions + # ---------------------------------------------------------- + @api.model + def action_close_panel_dms_file(self): + self.action_close_panel("dms.onboarding_onboarding_dms_file") diff --git a/dms/models/onboarding_onboarding_step.py b/dms/models/onboarding_onboarding_step.py new file mode 100644 index 000000000..3345bb356 --- /dev/null +++ b/dms/models/onboarding_onboarding_step.py @@ -0,0 +1,50 @@ +# 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): + _inherit = "onboarding.onboarding.step" + + # ---------------------------------------------------------- + # Actions + # ---------------------------------------------------------- + @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 diff --git a/dms/models/res_company.py b/dms/models/res_company.py index bf169073b..f2d61f34d 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,13 +11,8 @@ class ResCompany(models.Model): - _inherit = "res.company" - # ---------------------------------------------------------- - # Database - # ---------------------------------------------------------- - documents_onboarding_state = fields.Selection( selection=[ ("not_done", "Not done"), @@ -26,7 +22,6 @@ class ResCompany(models.Model): ], default="not_done", ) - documents_onboarding_storage_state = fields.Selection( selection=[ ("not_done", "Not done"), @@ -36,7 +31,6 @@ class ResCompany(models.Model): ], default="not_done", ) - documents_onboarding_directory_state = fields.Selection( selection=[ ("not_done", "Not done"), @@ -46,7 +40,6 @@ class ResCompany(models.Model): ], default="not_done", ) - documents_onboarding_file_state = fields.Selection( selection=[ ("not_done", "Not done"), @@ -58,54 +51,40 @@ class ResCompany(models.Model): ) # ---------------------------------------------------------- - # Functions + # Actions # ---------------------------------------------------------- + @api.model + def action_close_documents_onboarding(self): + self.env.user.company_id.documents_onboarding_state = "closed" - def get_and_update_documents_onboarding_state(self): - return self._get_and_update_onboarding_state( - "documents_onboarding_state", self.get_documents_steps_states_names() - ) + # ---------------------------------------------------------- + # Other Business Methods + # ---------------------------------------------------------- + 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", ] - - # ---------------------------------------------------------- - # Actions - # ---------------------------------------------------------- - - @api.model - def action_open_documents_onboarding_storage(self): - return self.env.ref("dms.action_dms_storage_new").read()[0] - - @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 - - @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 - - @api.model - def action_close_documents_onboarding(self): - self.env.user.company_id.documents_onboarding_state = "closed" + onboarding_state = "documents_onboarding_state" + old_values = {} + all_done = True + + 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" + + if all_done: + old_values[onboarding_state] = ( + "just_done" if self[onboarding_state] == "not_done" else "done" + ) + self[onboarding_state] = "done" + + return old_values 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..e86752f8c --- /dev/null +++ b/dms/models/res_users.py @@ -0,0 +1,151 @@ +# Copyright 2024 Subteno (https://www.subteno.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, models +from odoo.exceptions import AccessError +from odoo.tools import mute_logger + + +class ResUsers(models.Model): + _inherit = "res.users" + + # ---------------------------------------------------------- + # Other Business Methods + # ---------------------------------------------------------- + @api.model + @mute_logger("odoo.addons.base.models.ir_rule") # do not log access error + def _get_allowed_directory_ids(self, operation): + """ + Get allowed directory ids for the user based on its inclusion in an access + group. + + :param str operation: operation to check + + :return: Allowed directory ids + :rtype: list[int] + """ + all_access = self._has_all_access() + directory_from_attachment_ids = self._get_directory_from_attachment(operation) + + # if the user has all access to directories for the current operation, + # then return all directories + if all_access: + return [ + directory["id"] + for directory in self.env["dms.directory"].search_read([], ["id"]) + ] + + directory_ids = self._get_directories_from_access_group(operation) + directory_ids |= self._get_children_directories(directory_ids) + directory_ids |= self._get_own_created_directories() + directory_ids |= set(directory_from_attachment_ids) + return list(directory_ids) + + def _get_own_created_directories(self): + """ + Get the directories that the user has created. + + :return: The directory ids that the user has created + :rtype: set[int] + """ + return set( + self.env["dms.directory"].search([("create_uid", "=", self.env.uid)]).ids + ) + + def _get_children_directories(self, dir_ids): + """ + Get the children directories of the given directories. + + :param set[int] dir_ids: The directory ids to get the children directories from + :return: The directory ids + the children directories + :rtype: set[int] + """ + children_directories = self.env["dms.directory"].search( + [("parent_id", "in", list(dir_ids))] + ) + dir_ids |= set(children_directories.ids) + while children_directories: + children_directories = self.env["dms.directory"].search( + [("parent_id", "in", list(children_directories.ids))] + ) + dir_ids |= set(children_directories.ids) + return dir_ids + + def _get_directories_from_access_group(self, operation): + """ + Get allowed directory ids for the user based on its inclusion in an access + group. + + :param str operation: operation to check + :return: Allowed directory ids + :rtype: set[int] + """ + domain = [ + ("users", "in", self.env.uid), + ] + if operation != "perm_read": + domain += [(operation, "=", True)] + directories_from_access_group = ( + self.env["dms.access.group"] + .search(domain) + .directory_ids.filtered( + lambda x: not x.is_hidden + and not x.storage_id_inherit_access_from_parent_record + ) + ) + return set(directories_from_access_group.ids) + + @api.model + def _has_all_access(self): + """ + Check if user has access to all directories for the specified operation. + + :return: The access group that has all access + :rtype: odoo.model.dms_access_group + """ + if self.has_group("dms.group_dms_manager"): + return self.env["dms.access.group"].sudo().search([]) + return self.env["dms.access.group"] + + def _get_directory_from_attachment(self, operation): + """ + Get the directory ids from the attachments that the user has access to. The + goal here is to allow the user to + access the directories based on the rule given in its profile settings. That + way, we are only using the standard + rule checking. + + :param str operation: operation to check + + :return: The directory ids that the user has access to + :rtype: list[int] + """ + + # get the directories based on a record + attachment_directories = self.env["dms.directory"].search( + [ + ("storage_id_inherit_access_from_parent_record", "=", True), + ] + ) + if not attachment_directories: + return [] + + authorized_directory_ids = [] + for directory in attachment_directories: + # use of .get because on the first startup of Odoo, all the modules might + # not be loaded yet + model = self.env.get(directory.res_model) + if model is not None: + record = model.browse(directory.res_id).exists() + authorized = True + try: + record.with_env( + self.env(su=self.env.user._is_superuser()) + ).check_access_rule(operation.replace("perm_", "")) + except AccessError: + authorized = False + + if authorized: + authorized_directory_ids.append(directory.id) + + return authorized_directory_ids diff --git a/dms/models/storage.py b/dms/models/storage.py index a33b00937..4fe818c66 100644 --- a/dms/models/storage.py +++ b/dms/models/storage.py @@ -12,16 +12,10 @@ class Storage(models.Model): - _name = "dms.storage" _description = "Storage" - # ---------------------------------------------------------- - # Database - # ---------------------------------------------------------- - name = fields.Char(required=True) - save_type = fields.Selection( selection=[ ("database", _("Database")), @@ -30,11 +24,10 @@ 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 +35,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 +48,6 @@ class Storage(models.Model): readonly=False, copy=False, ) - storage_directory_ids = fields.One2many( comodel_name="dms.directory", inverse_name="storage_id", @@ -66,7 +56,6 @@ class Storage(models.Model): readonly=True, copy=False, ) - storage_file_ids = fields.One2many( comodel_name="dms.file", inverse_name="storage_id", @@ -75,22 +64,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", @@ -100,10 +86,26 @@ class Storage(models.Model): ) model = fields.Char(search="_search_model", store=False) + # ---------------------------------------------------------- + # Compute/Search + # ---------------------------------------------------------- + @api.depends("storage_directory_ids") + def _compute_count_storage_directories(self): + for record in self: + record.count_storage_directories = len(record.storage_directory_ids) + + @api.depends("storage_file_ids") + def _compute_count_storage_files(self): + for record in self: + record.count_storage_files = len(record.storage_file_ids) + def _search_model(self, operator, value): allowed_items = self.env["ir.model"].sudo().search([("model", operator, value)]) return [("model_ids", "in", allowed_items.ids)] + # ---------------------------------------------------------- + # Onchange/Constrains + # ---------------------------------------------------------- @api.onchange("save_type") def _onchange_save_type(self): for record in self: @@ -113,7 +115,6 @@ def _onchange_save_type(self): # ---------------------------------------------------------- # Actions # ---------------------------------------------------------- - def action_storage_migrate(self): if self.save_type != "attachment": if not self.env.user.has_group("dms.group_dms_manager"): @@ -131,17 +132,3 @@ def action_save_onboarding_storage_step(self): self.env.user.company_id.set_onboarding_step_done( "documents_onboarding_storage_state" ) - - # ---------------------------------------------------------- - # Read, View - # ---------------------------------------------------------- - - @api.depends("storage_directory_ids") - def _compute_count_storage_directories(self): - for record in self: - record.count_storage_directories = len(record.storage_directory_ids) - - @api.depends("storage_file_ids") - def _compute_count_storage_files(self): - for record in self: - record.count_storage_files = len(record.storage_file_ids) diff --git a/dms/models/tag.py b/dms/models/tag.py index 2483144e5..3b070cedf 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", ) @@ -44,10 +44,16 @@ class Tag(models.Model): count_directories = fields.Integer(compute="_compute_count_directories") count_files = fields.Integer(compute="_compute_count_files") + # ---------------------------------------------------------- + # SQL Constraints + # ---------------------------------------------------------- _sql_constraints = [ ("name_uniq", "unique (name, category_id)", "Tag name already exists!"), ] + # ---------------------------------------------------------- + # Compute/Search + # ---------------------------------------------------------- @api.depends("directory_ids") def _compute_count_directories(self): for rec in self: 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..7443be667 --- /dev/null +++ b/dms/readme/CONFIGURE.md @@ -0,0 +1,59 @@ +# Configuration + +To configure this module, you need to: + +## 1. Create a storage +1. Go to *Documents -\> Configuration -\> Storages*. + +2. Create a new document storage. You can choose between three options on `Save Type`: + - `Database`: Store the files on the database as a field + - `Attachment`: Store the files as attachments + - `File`: Store the files on the file system + +## 2. Create an access group +1. Next, create an administrative access group. Go to *Configuration -\> Access Groups*. + - Create a new group, name it appropriately, and turn on all three + permissions (Create, Write and Unlink. Read is implied and always + enabled). + - Add any other top-level administrative users to the group if + needed (your user should already be there). + - You can create other groups in here later for fine-grained access + control. + +## 3. Create a directory +1. Afterward, go to *Documents -\> Directories*. + +2. Create a new directory, mark it as root and select the previously created setting. + - Select the *Groups* tab and add your administrative group created + above. + If your directory was already created before the group, you can also add it in the + access groups (*Configuration -\> Access Groups*). + +3. In the directory settings, 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. 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 to specify files one by one + +You can check all the files that still need 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..050b564a7 --- /dev/null +++ b/dms/readme/CONTRIBUTORS.md @@ -0,0 +1,14 @@ +- 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 \<\> +- [Subteno](https://www.subteno.com): + - Timothée Vannier <> 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..c8eb8aba7 --- /dev/null +++ b/dms/readme/INSTALL.md @@ -0,0 +1,4 @@ +## Preview + +`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..463f914c3 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). --> 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..941e7e0bd 100644 --- a/dms/static/description/index.html +++ b/dms/static/description/index.html @@ -1,4 +1,3 @@ - @@ -9,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -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

    @@ -382,22 +385,26 @@

    Document Management System

  • Preview
-
  • Configuration
  • +
    +

    Configuration

    To configure this module, you need to:

    +
    +

    1. Create a storage

    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 three options +on Save Type:
          +
        • Database: Store the files on the database as a field
        • +
        • Attachment: Store the files as attachments
        • +
        • File: Store the files on the file system
        -
      -
    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.
      • +
    +
    +
    +

    2. Create an access group

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

    3. Create a directory

    +
      +
    1. Afterward, go to Documents -> Directories.
    2. +
    3. Create a new directory, mark it as root and select the previously +created setting.
        +
      • Select the Groups tab and add your administrative group created +above. If your directory was already created before the group, you +can also add it in the access groups (Configuration -> Access +Groups).
      - -
    4. -
    5. -
      On the Directory you can also add other access groups (created above) that will be able to:
      -
        +
      • In the directory settings, 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:

    +

    Migration

    +

    If you need to modify the storage Save Type you might want to +migrate the file data. 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 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 need 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.

    -
    +

    File Wizard Selection

    +

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

    -

    Usage

    +

    Usage

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

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

    +

    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.

    -

    Known issues / Roadmap

    +

    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).
    -

    Bug Tracker

    +

    Bug Tracker

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

    +feedback.

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

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • MuK IT
    • Tecnativa
    -

    Contributors

    +

    Contributors

    +
  • Subteno: +
  • -

    Other credits

    -

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

    +

    Other credits

    Some pictures are based on or inspired by:

    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    -Odoo Community Association + +Odoo Community Association +

    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.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 51% 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..d11960e63 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,8 @@ class DmsPathField extends Component { } formatData(props) { - this.data = JSON.parse(props.value || "[]"); + const path_json = props.record.data && props.record.data.path_json; + this.data = JSON.parse(path_json || "[]"); } _onNodeClicked(event) { @@ -29,6 +35,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 f2c6de132..a29988d5d 100644 --- a/dms/static/src/js/views/dms_file_upload.esm.js +++ b/dms/static/src/js/views/dms_file_upload.esm.js @@ -1,165 +1,172 @@ /** @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 {_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, - }); - }); + // 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]; + } + } + } + } - this.orm - .call("dms.file", "create", [attachments_args], { - context: ctx, - }) - .then(() => { + if (directory_id === false) { self.actionService.restore(controllerID); - }) - .catch((error) => { - self.notification.add(error.data.message, { + return self.notification.add(_t("You must select a directory first"), { type: "danger", }); - self.actionService.restore(controllerID); + } + + const attachment_datas = await this.orm.call( + "dms.file", + "get_dms_files_from_attachments", + [], + {attachment_ids: 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, + }); }); - }, -}; + + this.orm + .call("dms.file", "create", [attachments_args], { + context: ctx, + }) + .then(() => { + self.actionService.restore(controllerID); + }) + .catch((error) => { + self.notification.add(error.data.message, { + type: "danger", + }); + 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" >