From ac5514cdf435664815026a96a8b31a3549dc5a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Duy=20=28=C4=90=E1=BB=97=20Anh=29?= Date: Thu, 10 Apr 2025 15:25:13 +0700 Subject: [PATCH 1/4] [ADD] dms_import: Migration data from documents EE to dms CE [IMP] dms_import: move pre_init_hook to post_init_hook [REF][FIX] dms_import: Use new syntax, avoid sql injection and fix group creation bug dms_import: also migrate achived data [REF] dms_import: avoid duplication by forcing tags, categories, and default group permissions [IMP] dms_import: improve performance, reduce batch size, and bypass heavy compute fields [FIX] dms_import: handle duplicate name [FIX] dms_import: standardize file names [IMP] dms_import: improve unique_name_new to avoid long name [FIX] dms_import: avoid check size when uploading files --- dms_import/README.rst | 77 +++ dms_import/__init__.py | 4 + dms_import/__manifest__.py | 18 + dms_import/hooks.py | 589 +++++++++++++++++++++++ dms_import/models/__init__.py | 1 + dms_import/models/dms_file.py | 15 + dms_import/pyproject.toml | 3 + dms_import/readme/CONTRIBUTORS.rst | 2 + dms_import/readme/DESCRIPTION.rst | 1 + dms_import/static/description/icon.png | Bin 0 -> 9455 bytes dms_import/static/description/index.html | 424 ++++++++++++++++ dms_import/tools/__init__.py | 1 + dms_import/tools/file.py | 32 ++ requirements.txt | 3 + setup/dms_import/odoo/addons/dms_import | 1 + setup/dms_import/setup.py | 6 + 16 files changed, 1177 insertions(+) create mode 100644 dms_import/README.rst create mode 100644 dms_import/__init__.py create mode 100644 dms_import/__manifest__.py create mode 100644 dms_import/hooks.py create mode 100644 dms_import/models/__init__.py create mode 100644 dms_import/models/dms_file.py create mode 100644 dms_import/pyproject.toml create mode 100644 dms_import/readme/CONTRIBUTORS.rst create mode 100644 dms_import/readme/DESCRIPTION.rst create mode 100644 dms_import/static/description/icon.png create mode 100644 dms_import/static/description/index.html create mode 100644 dms_import/tools/__init__.py create mode 100644 dms_import/tools/file.py create mode 100644 requirements.txt create mode 120000 setup/dms_import/odoo/addons/dms_import create mode 100644 setup/dms_import/setup.py diff --git a/dms_import/README.rst b/dms_import/README.rst new file mode 100644 index 000000000..e4dcb1f33 --- /dev/null +++ b/dms_import/README.rst @@ -0,0 +1,77 @@ +================================= +Document Management System Import +================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:d26c6b062fc8f9eab8449f88f869f2da1d45e05f6332b2ca1db17948961c7ed0 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-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_import + :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_import + :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 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Use this module to migrate from the EE `documents*` modules to the OCA `dms*` modules. + +**Table of contents** + +.. contents:: + :local: + +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Kencove + +Contributors +~~~~~~~~~~~~ + +- [Trobz](https://www.trobz.com): + - Do Anh Duy <> + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/dms_import/__init__.py b/dms_import/__init__.py new file mode 100644 index 000000000..14482bc53 --- /dev/null +++ b/dms_import/__init__.py @@ -0,0 +1,4 @@ +from . import models + +from .hooks import post_init_hook +from .hooks import post_load_hook diff --git a/dms_import/__manifest__.py b/dms_import/__manifest__.py new file mode 100644 index 000000000..893569648 --- /dev/null +++ b/dms_import/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2025 Kencove (https://www.kencove.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Document Management System Import", + "summary": """ + Import data from document EE to dms CE + """, + "version": "16.0.1.0.0", + "license": "AGPL-3", + "category": "Document Management", + "author": "Kencove, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/dms", + "depends": ["dms"], + "external_dependencies": {"python": ["pathvalidate", "openupgradelib"]}, + "post_init_hook": "post_init_hook", + "post_load": "post_load_hook", +} diff --git a/dms_import/hooks.py b/dms_import/hooks.py new file mode 100644 index 000000000..5729df9ce --- /dev/null +++ b/dms_import/hooks.py @@ -0,0 +1,589 @@ +# Copyright 2025 Kencove (https://www.kencove.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import mimetypes +import os +import threading +from collections import defaultdict +from random import randint + +from openupgradelib import openupgrade +from pathvalidate import sanitize_filename +from PIL import Image +from psycopg2.sql import SQL, Identifier + +from odoo import SUPERUSER_ID, Command, api, models +from odoo.exceptions import UserError +from odoo.tools import table_exists +from odoo.tools.misc import get_lang, split_every + +from odoo.addons.dms.models.dms_file import File + +from .tools import file as file_utils + +_logger = logging.getLogger(__name__) + + +def _default_color(): + """Generate a random color index.""" + return randint(1, 11) + + +def _normalize(text): + return (text or "").strip().lower() + + +def _preprocess_filename(original_name): + """Sanitize and split filename into name and extension.""" + if original_name.startswith(".") and len(original_name) > 1: + base_name = "unnamed" + extension = original_name + else: + base_name, extension = os.path.splitext(original_name) + if not extension and base_name.startswith("."): + base_name = "unnamed" + extension = original_name + elif not base_name: + base_name = "unnamed" + sanitized_base = sanitize_filename(base_name, replacement_text="_") + sanitized_ext = sanitize_filename(extension, replacement_text="_") + if not sanitized_base: + sanitized_base = "unnamed" + sanitized_name = f"{sanitized_base}{sanitized_ext}" + if original_name != sanitized_name: + _logger.debug("Sanitized filename: '%s' to '%s'", original_name, sanitized_name) + return sanitized_name, sanitized_ext + + +def _get_or_create_default_storage(env): + """Get or create a default DMS storage of type 'file'.""" + Storage = env["dms.storage"] + db_storage = Storage.search([("save_type", "=", "file")], limit=1) + if not db_storage: + db_storage = Storage.create( + { + "name": "Migrated DB Storage (from EE)", + "save_type": "file", + } + ) + _logger.info("Created default file storage (ID: %s)", db_storage.id) + return db_storage + + +def _batch_fetch_folder_relations(cr, folder_ids): + write_groups = defaultdict(list) + read_groups = defaultdict(list) + tags_by_folder = defaultdict(list) + + if table_exists(cr, "documents_folder_res_groups_rel"): + cr.execute( + SQL( + """SELECT documents_folder_id, res_groups_id + FROM documents_folder_res_groups_rel + WHERE documents_folder_id = ANY(%s)""" + ), + (list(folder_ids),), + ) + for folder_id, group_id in cr.fetchall(): + write_groups[folder_id].append(group_id) + + if table_exists(cr, "documents_folder_read_groups"): + cr.execute( + SQL( + """SELECT documents_folder_id, res_groups_id + FROM documents_folder_read_groups + WHERE documents_folder_id = ANY(%s)""" + ), + (list(folder_ids),), + ) + for folder_id, group_id in cr.fetchall(): + read_groups[folder_id].append(group_id) + + if table_exists(cr, "documents_facet") and table_exists(cr, "documents_tag"): + cr.execute( + SQL( + """SELECT f.folder_id, t.id + FROM {} t JOIN {} f ON f.id = t.facet_id + WHERE f.folder_id = ANY(%s)""" + ).format(Identifier("documents_tag"), Identifier("documents_facet")), + (list(folder_ids),), + ) + for folder_id, tag_id in cr.fetchall(): + tags_by_folder[folder_id].append(tag_id) + + return write_groups, read_groups, tags_by_folder + + +def migrate_documents_tags(cr, env, lang): + """Migrate tags and facets from documents_tag to dms.tag and dms.category.""" + if not table_exists(cr, "documents_tag"): + _logger.warning("Skipping tag migration: 'documents_tag' table does not exist.") + return {}, {} + + _logger.info("Migrating tags from 'documents' to 'dms'...") + + DmsCategory = env["dms.category"] + DmsTag = env["dms.tag"] + tag_mapping, category_mapping = {}, {} + + # 1. Facets → categories + if table_exists(cr, "documents_facet"): + cr.execute( + SQL("SELECT id, name->>%s AS name FROM {}").format( + Identifier("documents_facet") + ), + (lang,), + ) + facets = cr.dictfetchall() + + # Map name -> existing record + existing_categories = { + _normalize(cat.name): cat for cat in DmsCategory.search([]) + } + + # Collect new categories only once + new_categories = {} + for f in facets: + norm = _normalize(f["name"]) + if norm and norm not in existing_categories: + new_categories[norm] = {"name": f["name"].strip()} + + if new_categories: + DmsCategory.create(new_categories.values()) + # Refresh map + existing_categories = { + _normalize(cat.name): cat for cat in DmsCategory.search([]) + } + + # Build category_mapping + for f in facets: + norm = _normalize(f["name"]) + if norm and norm in existing_categories: + category_mapping[f["id"]] = existing_categories[norm].id + + # 2. Tags + existing_tags = { + (_normalize(tag.name), tag.category_id.id or False): tag + for tag in DmsTag.search([]) + } + + cr.execute( + SQL("SELECT id, name->>%s AS name, facet_id FROM {}").format( + Identifier("documents_tag") + ), + (lang,), + ) + old_tags = cr.dictfetchall() + + tags_to_create = [] + pending_keys = set() + + for old in old_tags: + norm = _normalize(old["name"]) + if not norm: + continue + cat_id = category_mapping.get(old["facet_id"]) or False + key = (norm, cat_id) + if key not in existing_tags and key not in pending_keys: + tags_to_create.append( + { + "name": old["name"].strip(), + "category_id": cat_id, + "color": _default_color(), + "_old_id": old["id"], + } + ) + pending_keys.add(key) + + if tags_to_create: + created = DmsTag.create( + [ + {k: v for k, v in vals.items() if k != "_old_id"} + for vals in tags_to_create + ] + ) + for _, new_tag in zip(tags_to_create, created): + key = (_normalize(new_tag.name), new_tag.category_id.id or False) + existing_tags[key] = new_tag + + # Final mapping + for old in old_tags: + norm = _normalize(old["name"]) + if not norm: + continue + cat_id = category_mapping.get(old["facet_id"]) or False + record = existing_tags.get((norm, cat_id)) + if record: + tag_mapping[old["id"]] = record.id + + _logger.info( + "Migrated %d categories and %d tags.", len(category_mapping), len(tag_mapping) + ) + return tag_mapping, category_mapping + + +def migrate_documents_folders(cr, env, lang, tag_mapping): + """Migrate documents.folder to dms.directory, optimized for batch processing.""" + if not table_exists(cr, "documents_folder"): + _logger.warning("Skipping folder migration: 'documents_folder' not found.") + return {} + + _logger.info("Migrating folders from 'documents_folder' to 'dms_directory'...") + + cr.execute( + SQL( + """SELECT id, name->>%s AS name, parent_folder_id + FROM {} ORDER BY parent_path""" + ).format(Identifier("documents_folder")), + (lang,), + ) + folders = cr.dictfetchall() + if not folders: + _logger.info("No folders to migrate.") + return {} + + DmsDirectory = env["dms.directory"] + DmsAccessGroups = env["dms.access.group"] + folder_mapping = {} + storage = _get_or_create_default_storage(env) + default_user_group_id = env.ref("base.group_user").id + + existing_dirs = DmsDirectory.search_read([], ["name", "parent_id"]) + existing_names_by_parent = defaultdict(set) + for d in existing_dirs: + parent_id = d["parent_id"][0] if d["parent_id"] else False + existing_names_by_parent[parent_id].add(d["name"]) + + folder_ids = [f["id"] for f in folders] + write_groups, read_groups, tags_by_folder = _batch_fetch_folder_relations( + cr, folder_ids + ) + access_groups_to_create_vals = [] + dirs_to_update_tags = defaultdict(list) + for folder in folders: + try: + parent_id = folder_mapping.get(folder["parent_folder_id"]) + names_in_parent = existing_names_by_parent[parent_id] + vals = { + "name": file_utils.unique_name_new(folder["name"], names_in_parent), + "storage_id": storage.id if not parent_id else False, + "parent_id": parent_id, + "is_root_directory": not bool(parent_id), + } + + new_dir = DmsDirectory.create(vals) + names_in_parent.add(new_dir.name) + folder_mapping[folder["id"]] = new_dir.id + _logger.debug( + "Created dms.directory: '%s' (ID: %s)", new_dir.name, new_dir.id + ) + + # Assign groups using pre-fetched data + has_groups = False + write_group_ids = write_groups.get(folder["id"], []) + if write_group_ids: + has_groups = True + vals = { + "name": f"{new_dir.complete_name} Write Group", + "perm_create": True, + "perm_write": True, + "perm_unlink": True, + "group_ids": [Command.set(write_group_ids)], + "_dir_id": new_dir.id, + } + access_groups_to_create_vals.append(vals) + read_group_ids = read_groups.get(folder["id"], []) + if read_group_ids: + has_groups = True + vals = { + "name": f"{new_dir.complete_name} Read Group", + "group_ids": [Command.set(read_group_ids)], + "_dir_id": new_dir.id, + } + access_groups_to_create_vals.append(vals) + # In case no group, directory will be invisible, so I decide to assign a new group + # this group has full permision like no group in document folder + if not has_groups: + vals = { + "name": f"{new_dir.complete_name} Default Group", + "perm_create": True, + "perm_write": True, + "perm_unlink": True, + "group_ids": [Command.set([default_user_group_id])], + "_dir_id": new_dir.id, + } + access_groups_to_create_vals.append(vals) + + # Assign Tags + folder_tag_ids = [ + tag_mapping[tag_id] + for tag_id in tags_by_folder.get(folder["id"], []) + if tag_id in tag_mapping + ] + if folder_tag_ids: + dirs_to_update_tags[tuple(sorted(folder_tag_ids))].append(new_dir.id) + + except Exception: + _logger.exception( + "Error migrating folder ID %s (%s)", folder["id"], folder["name"] + ) + + for tag_ids_tuple, dir_ids in dirs_to_update_tags.items(): + DmsDirectory.browse(dir_ids).write( + {"tag_ids": [Command.set(list(tag_ids_tuple))]} + ) + + if access_groups_to_create_vals: + new_groups = DmsAccessGroups.create( + [ + {k: v for k, v in vals.items() if k != "_dir_id"} + for vals in access_groups_to_create_vals + ] + ) + + dirs_to_update_groups = defaultdict(list) + for vals, group in zip(access_groups_to_create_vals, new_groups): + dirs_to_update_groups[vals["_dir_id"]].append(group.id) + + for dir_id, group_ids in dirs_to_update_groups.items(): + DmsDirectory.browse(dir_id).write({"group_ids": [Command.set(group_ids)]}) + + # Recompute all user counts at once + new_groups._compute_users() + _logger.info("Successfully migrated %d folders.", len(folder_mapping)) + return folder_mapping + + +# flake8: noqa: C901 +def migrate_documents_files(cr, env, folder_mapping, tag_mapping, batch_size): + """Migrate documents.document to dms.file, optimized for batch processing.""" + if not table_exists(cr, "documents_document") or not folder_mapping: + _logger.warning("Skipping file migration: table or folder mapping is missing.") + return [], [] + + _logger.info("Migrating files from 'documents_document' to 'dms_file'...") + + cr.execute( + SQL("SELECT id FROM {} WHERE type = 'binary' ORDER BY id").format( + Identifier("documents_document") + ) + ) + all_doc_ids = [r[0] for r in cr.fetchall()] + if not all_doc_ids: + _logger.info("No documents to migrate.") + return [], [] + + DmsFile = env["dms.file"].with_context(bypass_check_size=True) + created_file_ids, unmigrated_files = [], set() + auto_commit = not getattr(threading.current_thread(), "testing", False) + for i, batch_of_ids in enumerate(split_every(batch_size, all_doc_ids)): + _logger.info("[Batch %d] Migrating %d documents...", i + 1, len(batch_of_ids)) + cr.execute( + SQL( + """SELECT id, name, folder_id, attachment_id, active + FROM {} WHERE id = ANY(%s)""" + ).format(Identifier("documents_document")), + (list(batch_of_ids),), + ) + documents = cr.dictfetchall() + if not documents: + continue + + directory_ids = list( + { + folder_mapping.get(doc["folder_id"]) + for doc in documents + if doc.get("folder_id") + } + ) + existing_files = DmsFile.search_read( + [("directory_id", "in", directory_ids)], ["name", "directory_id"] + ) + existing_names_by_dir = defaultdict(set) + for f in existing_files: + existing_names_by_dir[f["directory_id"][0]].add(f["name"]) + + # Batch-fetch tag relations for the current batch + tags_by_doc = {} + if table_exists(cr, "document_tag_rel"): + cr.execute( + SQL( + """SELECT documents_document_id, documents_tag_id + FROM {} WHERE documents_document_id = ANY(%s)""" + ).format(Identifier("document_tag_rel")), + (list(batch_of_ids),), + ) + for doc_id, tag_id in cr.fetchall(): + if tag_id in tag_mapping: + tags_by_doc.setdefault(doc_id, []).append(tag_mapping[tag_id]) + + attachment_ids = [ + doc["attachment_id"] for doc in documents if doc.get("attachment_id") + ] + attachment_data_map = {} + if attachment_ids: + cr.execute( + SQL( + """ + SELECT id, mimetype, file_size, checksum + FROM ir_attachment WHERE id = ANY(%s)""" + ), + (list(attachment_ids),), + ) + attachment_data_map = {att["id"]: att for att in cr.dictfetchall()} + + files_to_create_vals = [] + batch_original_names = [] + for doc in documents: + directory_id = folder_mapping.get(doc["folder_id"]) + if doc["folder_id"] and not directory_id: + _logger.warning( + "Skipping doc %s: folder %s not found.", doc["id"], doc["folder_id"] + ) + continue + original_name = doc["name"] + batch_original_names.append(original_name) + sanitized_name, sanitized_ext = _preprocess_filename(original_name) + names_in_directory = existing_names_by_dir[directory_id] + unique_name = file_utils.unique_name_new( + sanitized_name, names_in_directory, escape_suffix=True + ) + attachment_id = doc.get("attachment_id") + att_data = attachment_data_map.get(attachment_id) + mimetype = att_data.get("mimetype") if att_data else None + file_size = att_data.get("file_size") if att_data else 0 + checksum = att_data.get("checksum") if att_data else None + extension_str = sanitized_ext[1:].strip().lower() if sanitized_ext else "" + if not extension_str and mimetype: + guessed_ext = mimetypes.guess_extension(mimetype) + if guessed_ext: + extension_str = guessed_ext[1:].strip().lower() + if not extension_str: + extension_str = "bin" + file_vals = { + "name": unique_name, + "directory_id": directory_id, + "active": doc["active"], + "attachment_id": attachment_id, + "tag_ids": [Command.set(tags_by_doc.get(doc["id"], []))], + "mimetype": mimetype, + "size": file_size, + "checksum": checksum, + "extension": extension_str, + "_original_name": original_name, + } + names_in_directory.add(file_vals["name"]) + files_to_create_vals.append(file_vals) + + if files_to_create_vals: + create_payload = [ + {k: v for k, v in vals.items() if not k.startswith("_")} + for vals in files_to_create_vals + ] + try: + new_files = DmsFile.create(create_payload) + created_file_ids.extend(new_files.ids) + + # Update attachments in bulk + attachment_update_map = {} + for vals, new_file in zip(files_to_create_vals, new_files): + if vals.get("attachment_id"): + attachment_update_map[vals["attachment_id"]] = new_file.id + + if attachment_update_map: + attachment_ids = list(attachment_update_map.keys()) + case_clauses, params = [], [] + for att_id, file_id in attachment_update_map.items(): + case_clauses.append(SQL("WHEN %s THEN %s")) + params.extend([att_id, file_id]) + params.append(tuple(attachment_ids)) + query = SQL( + """ + UPDATE ir_attachment + SET res_model = 'dms.file', + res_id = CASE id {cases} END + WHERE id IN %s + """ + ).format(cases=SQL(" ").join(case_clauses)) + + cr.execute(query, params) + cr.execute("ANALYZE ir_attachment") + + if auto_commit: + env.cr.commit() + _logger.info("Committed batch %d.", i + 1) + except Exception: + _logger.exception("A critical error occurred during batch creation") + if auto_commit: + env.cr.rollback() + for name in batch_original_names: + _logger.error( + "Could not migrate file with original name: '%s'", name + ) + unmigrated_files.add(name) + + _logger.info("Successfully migrated %d file records.", len(created_file_ids)) + return created_file_ids, list(unmigrated_files) + + +def post_init_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {"tracking_disable": True}) + if not openupgrade.is_module_installed(cr, "documents"): + _logger.info("'documents' module not detected. Skipping migration.") + return + + try: + lang = get_lang(env).code or env.lang + _logger.info( + "Starting migration from 'documents' to 'dms' using lang '%s'", lang + ) + tag_mapping, _ = migrate_documents_tags(cr, env, lang) + folder_mapping = migrate_documents_folders(cr, env, lang, tag_mapping) + BATCH_SIZE = models.INSERT_BATCH_SIZE + _, failed_files = migrate_documents_files( + cr, env, folder_mapping, tag_mapping, BATCH_SIZE + ) + if failed_files: + _logger.warning( + "Migration completed with errors. %d files could not be migrated.", + len(failed_files), + ) + # Clear caches to avoid stale data issues + _logger.info("Caches invalidated and registry cleared after migration.") + env.invalidate_all() + env.registry.clear_caches() + _logger.info("Migration from 'documents' to 'dms' completed successfully.") + except Exception: + _logger.exception("Migration from 'documents' to 'dms' failed!") + raise + + +def post_load_hook(): + @api.depends("mimetype", "content") + def _compute_image_1920_new(self): + """Provide thumbnail automatically if possible.""" + for one in self.filtered("mimetype"): + if one.mimetype != "application/pdf" and one.mimetype in ( + *Image.MIME.values(), + "image/svg+xml", + ): + # catch this error: https://github.com/odoo/odoo/blob/c27d978a/odoo/tools/image.py#L94 + # start hooks + try: + File._compute_image_1920_origin(one) + except (UserError, TypeError) as e: + _logger.warning( + "Failed to generate image_1920 for file %s (ID %s): %s", + one.name, + one.id, + str(e), + ) + one.image_1920 = False + else: + one.image_1920 = False + # end hooks + + if not hasattr(File, "_compute_image_1920_origin"): + File._compute_image_1920_origin = File._compute_image_1920 + File._compute_image_1920 = _compute_image_1920_new diff --git a/dms_import/models/__init__.py b/dms_import/models/__init__.py new file mode 100644 index 000000000..e1448f4ab --- /dev/null +++ b/dms_import/models/__init__.py @@ -0,0 +1 @@ +from . import dms_file diff --git a/dms_import/models/dms_file.py b/dms_import/models/dms_file.py new file mode 100644 index 000000000..0c5c4b551 --- /dev/null +++ b/dms_import/models/dms_file.py @@ -0,0 +1,15 @@ +# Copyright 2025 Kencove (https://www.kencove.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import api, models + + +class DMSFile(models.Model): + _inherit = "dms.file" + + @api.constrains("size") + def _check_size(self): + if self.env.context.get("bypass_check_size"): + return + super()._check_size() diff --git a/dms_import/pyproject.toml b/dms_import/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/dms_import/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/dms_import/readme/CONTRIBUTORS.rst b/dms_import/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..47ecc3aeb --- /dev/null +++ b/dms_import/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +- [Trobz](https://www.trobz.com): + - Do Anh Duy <> diff --git a/dms_import/readme/DESCRIPTION.rst b/dms_import/readme/DESCRIPTION.rst new file mode 100644 index 000000000..8b2a7e7eb --- /dev/null +++ b/dms_import/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Use this module to migrate from the EE `documents*` modules to the OCA `dms*` modules. diff --git a/dms_import/static/description/icon.png b/dms_import/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/dms_import/static/description/index.html b/dms_import/static/description/index.html new file mode 100644 index 000000000..1860caddf --- /dev/null +++ b/dms_import/static/description/index.html @@ -0,0 +1,424 @@ + + + + + +Document Management System Import + + + +
+

Document Management System Import

+ + +

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

+

Use this module to migrate from the EE documents* modules to the OCA dms* modules.

+

Table of contents

+ +
+

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.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Kencove
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

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

+

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

+
+
+
+ + diff --git a/dms_import/tools/__init__.py b/dms_import/tools/__init__.py new file mode 100644 index 000000000..1f48df096 --- /dev/null +++ b/dms_import/tools/__init__.py @@ -0,0 +1 @@ +from . import file diff --git a/dms_import/tools/file.py b/dms_import/tools/file.py new file mode 100644 index 000000000..e092831e6 --- /dev/null +++ b/dms_import/tools/file.py @@ -0,0 +1,32 @@ +# Copyright 2025 Kencove (https://www.kencove.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import os +import re + + +def compute_name_new(name_part, suffix, ext_part): + return "{}({}){}".format(name_part, suffix, ext_part) + + +def unique_name_new(name, names, escape_suffix=False): + if name not in names: + return name + if escape_suffix: + name_part, ext_part = os.path.splitext(name) + else: + name_part, ext_part = name, "" + + match = re.fullmatch(r"(.+)\((\d+)\)", name_part) + if match: + base_name_part = match.group(1).rstrip() + suffix = int(match.group(2)) + 1 + else: + base_name_part = name_part + suffix = 1 + new_name = compute_name_new(base_name_part, suffix, ext_part) + while new_name in names: + suffix += 1 + new_name = compute_name_new(base_name_part, suffix, ext_part) + + return new_name diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..1ba1fec09 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# generated from manifests external_dependencies +openupgradelib +pathvalidate diff --git a/setup/dms_import/odoo/addons/dms_import b/setup/dms_import/odoo/addons/dms_import new file mode 120000 index 000000000..fd65bb6f2 --- /dev/null +++ b/setup/dms_import/odoo/addons/dms_import @@ -0,0 +1 @@ +../../../../dms_import \ No newline at end of file diff --git a/setup/dms_import/setup.py b/setup/dms_import/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/dms_import/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From c5ee89ee2a97b271a2d50b4bd5a84683b9bd7d60 Mon Sep 17 00:00:00 2001 From: Do Anh Duy Date: Tue, 4 Nov 2025 12:11:41 +0700 Subject: [PATCH 2/4] [IMP] dms_import: add cleanup after tranformation --- dms_import/__init__.py | 1 + dms_import/__manifest__.py | 1 + dms_import/hooks.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/dms_import/__init__.py b/dms_import/__init__.py index 14482bc53..ce3a5a1d0 100644 --- a/dms_import/__init__.py +++ b/dms_import/__init__.py @@ -2,3 +2,4 @@ from .hooks import post_init_hook from .hooks import post_load_hook +from .hooks import uninstall_hook diff --git a/dms_import/__manifest__.py b/dms_import/__manifest__.py index 893569648..583616c78 100644 --- a/dms_import/__manifest__.py +++ b/dms_import/__manifest__.py @@ -15,4 +15,5 @@ "external_dependencies": {"python": ["pathvalidate", "openupgradelib"]}, "post_init_hook": "post_init_hook", "post_load": "post_load_hook", + "uninstall_hook": "uninstall_hook", } diff --git a/dms_import/hooks.py b/dms_import/hooks.py index 5729df9ce..086f84c6d 100644 --- a/dms_import/hooks.py +++ b/dms_import/hooks.py @@ -15,7 +15,7 @@ from odoo import SUPERUSER_ID, Command, api, models from odoo.exceptions import UserError -from odoo.tools import table_exists +from odoo.tools import sql, table_exists from odoo.tools.misc import get_lang, split_every from odoo.addons.dms.models.dms_file import File @@ -587,3 +587,33 @@ def _compute_image_1920_new(self): if not hasattr(File, "_compute_image_1920_origin"): File._compute_image_1920_origin = File._compute_image_1920 File._compute_image_1920 = _compute_image_1920_new + + +def uninstall_hook(cr, registry): + _logger.info("Running uninstall_hook: cleaning up cloned tables...") + env = api.Environment(cr, SUPERUSER_ID, {}) + modules = env["ir.module.module"].search( + [ + ("name", "=", "documents"), + ("state", "=", "installed"), + ] + ) + if modules: + _logger.info("Triggering uninstall for: %s", "documents") + constraint_to_drop = [ + ("documents_document", "documents_document_folder_id_fkey"), + ] + for table, constraint in constraint_to_drop: + definition = sql.constraint_definition(cr, table, constraint) + if definition: + _logger.info( + "Dropping constraint %s on table %s: %s", + constraint, + table, + definition, + ) + sql.drop_constraint(cr, table, constraint) + modules.sudo().button_uninstall() + else: + _logger.info("No EE 'documents' modules currently installed.") + _logger.info("Cleanup after uninstall completed.") From 1189122de980512e6fa76017e6ba39ac7d8bf2a2 Mon Sep 17 00:00:00 2001 From: Do Anh Duy Date: Mon, 24 Nov 2025 10:25:46 +0700 Subject: [PATCH 3/4] [IMP] dms_import: merge and transfer groups of equivalent permissions --- dms_import/hooks.py | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/dms_import/hooks.py b/dms_import/hooks.py index 086f84c6d..9af966f60 100644 --- a/dms_import/hooks.py +++ b/dms_import/hooks.py @@ -8,7 +8,7 @@ from collections import defaultdict from random import randint -from openupgradelib import openupgrade +from openupgradelib import openupgrade, openupgrade_merge_records from pathvalidate import sanitize_filename from PIL import Image from psycopg2.sql import SQL, Identifier @@ -527,6 +527,44 @@ def migrate_documents_files(cr, env, folder_mapping, tag_mapping, batch_size): return created_file_ids, list(unmigrated_files) +def merge_record_data(cr, env): + xmlid_mapping = [ + ("dms.category_dms_security", "documents.module_category_documents_management"), + ("dms.group_dms_user", "documents.group_documents_user"), + ("dms.group_dms_manager", "documents.group_documents_manager"), + ] + for to_merge_xmlid, target_xmlid in xmlid_mapping: + old_record = env.ref(to_merge_xmlid, raise_if_not_found=False) + new_record = env.ref(target_xmlid, raise_if_not_found=False) + if not old_record: + _logger.warning("To merge XMLID not found: %s", to_merge_xmlid) + continue + if not new_record: + _logger.warning("Target XMLID not found: %s", target_xmlid) + continue + try: + openupgrade_merge_records.merge_records( + env=env, + model_name=new_record._name, + record_ids=[old_record.id], + target_record_id=new_record.id, + method="orm", + ) + openupgrade.rename_xmlids( + cr, + [ + (target_xmlid, to_merge_xmlid), + ], + ) + except Exception as e: + _logger.warning( + "Error merging record from '%s' to '%s': %s", + to_merge_xmlid, + target_xmlid, + e, + ) + + def post_init_hook(cr, registry): env = api.Environment(cr, SUPERUSER_ID, {"tracking_disable": True}) if not openupgrade.is_module_installed(cr, "documents"): @@ -538,6 +576,7 @@ def post_init_hook(cr, registry): _logger.info( "Starting migration from 'documents' to 'dms' using lang '%s'", lang ) + merge_record_data(cr, env) tag_mapping, _ = migrate_documents_tags(cr, env, lang) folder_mapping = migrate_documents_folders(cr, env, lang, tag_mapping) BATCH_SIZE = models.INSERT_BATCH_SIZE From c23b980488d83551865ee78bf22351fcb1fdf087 Mon Sep 17 00:00:00 2001 From: Do Anh Duy Date: Thu, 27 Nov 2025 15:03:32 +0700 Subject: [PATCH 4/4] [IMP] dms_import: remove mapping facet, rename tags with full context --- dms_import/hooks.py | 127 ++++++++++++++++++++------------------------ 1 file changed, 57 insertions(+), 70 deletions(-) diff --git a/dms_import/hooks.py b/dms_import/hooks.py index 9af966f60..07e3f70df 100644 --- a/dms_import/hooks.py +++ b/dms_import/hooks.py @@ -115,6 +115,30 @@ def _batch_fetch_folder_relations(cr, folder_ids): return write_groups, read_groups, tags_by_folder +def _fetch_folder_and_facet_names(cr, lang): + folder_names = {} + facet_details = {} + # 1. Fetch Folder Names (Workspace) + if table_exists(cr, "documents_folder"): + cr.execute( + SQL("SELECT id, name->>%s AS name FROM documents_folder"), + (lang,), + ) + folder_names = {f["id"]: f["name"].strip() for f in cr.dictfetchall()} + # 2. Fetch Facet Details (Facet name and parent folder) + if table_exists(cr, "documents_facet"): + cr.execute( + SQL("SELECT id, name->>%s AS name, folder_id FROM documents_facet"), + (lang,), + ) + facet_details = { + f["id"]: {"name": f["name"].strip(), "folder_id": f["folder_id"]} + for f in cr.dictfetchall() + } + + return folder_names, facet_details + + def migrate_documents_tags(cr, env, lang): """Migrate tags and facets from documents_tag to dms.tag and dms.category.""" if not table_exists(cr, "documents_tag"): @@ -123,51 +147,12 @@ def migrate_documents_tags(cr, env, lang): _logger.info("Migrating tags from 'documents' to 'dms'...") - DmsCategory = env["dms.category"] DmsTag = env["dms.tag"] - tag_mapping, category_mapping = {}, {} - - # 1. Facets → categories - if table_exists(cr, "documents_facet"): - cr.execute( - SQL("SELECT id, name->>%s AS name FROM {}").format( - Identifier("documents_facet") - ), - (lang,), - ) - facets = cr.dictfetchall() - - # Map name -> existing record - existing_categories = { - _normalize(cat.name): cat for cat in DmsCategory.search([]) - } - - # Collect new categories only once - new_categories = {} - for f in facets: - norm = _normalize(f["name"]) - if norm and norm not in existing_categories: - new_categories[norm] = {"name": f["name"].strip()} - - if new_categories: - DmsCategory.create(new_categories.values()) - # Refresh map - existing_categories = { - _normalize(cat.name): cat for cat in DmsCategory.search([]) - } - - # Build category_mapping - for f in facets: - norm = _normalize(f["name"]) - if norm and norm in existing_categories: - category_mapping[f["id"]] = existing_categories[norm].id - - # 2. Tags - existing_tags = { - (_normalize(tag.name), tag.category_id.id or False): tag - for tag in DmsTag.search([]) - } + tag_mapping = {} + # 1. Fetch Folder and Facet context data (Workspace and Facet names) + folder_names, facet_details = _fetch_folder_and_facet_names(cr, lang) + existing_tags = {_normalize(tag.name): tag for tag in DmsTag.search([])} cr.execute( SQL("SELECT id, name->>%s AS name, facet_id FROM {}").format( Identifier("documents_tag") @@ -180,47 +165,49 @@ def migrate_documents_tags(cr, env, lang): pending_keys = set() for old in old_tags: - norm = _normalize(old["name"]) + # build the full context name: [Workspace Name] > [Facet Name] > [Tag Name] + facet = facet_details.get(old["facet_id"]) + # check the completeness of the context + if ( + not facet + or not facet.get("folder_id") + or not folder_names.get(facet["folder_id"]) + ): + # fallback: use tag name only + full_name = old["name"].strip() + else: + folder_name = folder_names[facet["folder_id"]] + facet_name = facet["name"] + tag_name = old["name"].strip() + full_name = f"{folder_name} > {facet_name} > {tag_name}" + norm = _normalize(full_name) if not norm: continue - cat_id = category_mapping.get(old["facet_id"]) or False - key = (norm, cat_id) - if key not in existing_tags and key not in pending_keys: + if norm not in existing_tags and norm not in pending_keys: tags_to_create.append( { - "name": old["name"].strip(), - "category_id": cat_id, + "name": full_name, "color": _default_color(), "_old_id": old["id"], } ) - pending_keys.add(key) - + pending_keys.add(norm) + record = existing_tags.get(norm) + if record: + tag_mapping[old["id"]] = record.id if tags_to_create: created = DmsTag.create( [ - {k: v for k, v in vals.items() if k != "_old_id"} + {k: v for k, v in vals.items() if k not in ["_old_id", "category_id"]} for vals in tags_to_create ] ) - for _, new_tag in zip(tags_to_create, created): - key = (_normalize(new_tag.name), new_tag.category_id.id or False) - existing_tags[key] = new_tag - - # Final mapping - for old in old_tags: - norm = _normalize(old["name"]) - if not norm: - continue - cat_id = category_mapping.get(old["facet_id"]) or False - record = existing_tags.get((norm, cat_id)) - if record: - tag_mapping[old["id"]] = record.id - - _logger.info( - "Migrated %d categories and %d tags.", len(category_mapping), len(tag_mapping) - ) - return tag_mapping, category_mapping + for vals, new_tag in zip(tags_to_create, created): + norm = _normalize(new_tag.name) + existing_tags[norm] = new_tag + tag_mapping[vals["_old_id"]] = new_tag.id + _logger.info("Migrated %d tags with full context.", len(tag_mapping)) + return tag_mapping, {} def migrate_documents_folders(cr, env, lang, tag_mapping):