diff --git a/front/migration_status.php b/front/migration_status.php index 0b472ab1..5e7d8d22 100644 --- a/front/migration_status.php +++ b/front/migration_status.php @@ -34,14 +34,23 @@ // Check if user has admin rights Session::checkRight('config', UPDATE); +/** @var array $CFG_GLPI */ /** @var DBmysql $DB */ -global $DB; +global $CFG_GLPI, $DB; + +// Handle rename POST action +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['type_id'], $_POST['new_name'])) { + $type_id = (int) $_POST['type_id']; + $new_name = (string) $_POST['new_name']; + PluginGenericobjectType::renameType($type_id, $new_name); + Html::redirect($CFG_GLPI['root_doc'] . '/plugins/genericobject/front/migration_status.php'); +} // Get all GenericObject types $genericobject_types = []; if ($DB->tableExists(PluginGenericobjectType::getTable())) { $query = [ - 'SELECT' => ['itemtype', 'name'], + 'SELECT' => ['id', 'name'], 'FROM' => PluginGenericobjectType::getTable(), ]; $request = $DB->request($query); @@ -70,8 +79,9 @@ // Render the template content TemplateRenderer::getInstance()->display('@genericobject/migration_status.html.twig', [ - 'genericobject_types' => $genericobject_types, - 'customassets' => $customassets, + 'genericobject_types' => $genericobject_types, + 'customassets' => $customassets, + 'reserved_names' => array_map('strtolower', PluginGenericobjectType::getReservedNames()), ]); // Display GLPI footer diff --git a/inc/type.class.php b/inc/type.class.php index c5f38a9c..b931e666 100644 --- a/inc/type.class.php +++ b/inc/type.class.php @@ -52,6 +52,8 @@ class PluginGenericobjectType extends CommonDBTM public const CAN_OPEN_TICKET = 1024; + public const MAX_TYPE_NAME_LENGTH = 25; + public $dohistory = true; public static $rightname = 'plugin_genericobject_types'; @@ -749,80 +751,238 @@ private static function normalizeNamesAndItemtypes(Migration $migration) continue; } - self::updateNameAndItemtype( + self::applyTypeRename( $migration, + $type['id'], $old_name, $new_name, $old_itemtype, $new_itemtype, ); + } - $DB->update( - self::getTable(), - [ - 'name' => $new_name, - 'itemtype' => $new_itemtype, - ], - ['id' => $type['id']], - ); + ProfileRight::cleanAllPossibleRights(); + } - $DB->update( - self::getTable(), - [ - 'linked_itemtypes' => new QueryExpression( - 'REPLACE(' - . $DB->quoteName('linked_itemtypes') - . ',' - . $DB->quoteValue('"' . $old_itemtype . '"') // itemtype is surrounded by quotes - . ',' - . $DB->quoteValue('"' . $new_itemtype . '"') // itemtype is surrounded by quotes - . ')', - ), - ], - ['linked_itemtypes' => ['LIKE', '%"' . $old_itemtype . '"%']], - ); + /** + * Apply a full rename for a single type: update files, database tables, + * foreign keys, relation tables, linked_itemtypes, and related dropdowns. + * + * @param Migration $migration + * @param int $type_id ID of the type in glpi_plugin_genericobject_types + * @param string $old_name Current type name + * @param string $new_name New type name + * @param string $old_itemtype Current itemtype class name + * @param string $new_itemtype New itemtype class name + */ + private static function applyTypeRename( + Migration $migration, + int $type_id, + string $old_name, + string $new_name, + string $old_itemtype, + string $new_itemtype, + ): void { + /** @var DBmysql $DB */ + global $DB; - // Handle dropdowns related to itemtype - $table = getTableForItemType($new_itemtype); - $fields = $DB->listFields($table); - foreach ($fields as $field => $options) { - if (preg_match("/s_id$/", $field)) { - $dropdown_old_table = getTableNameForForeignKeyField($field); - - if (!preg_match('/^glpi_plugin_genericobject_/', $dropdown_old_table)) { - continue; - } - - $dropdown_old_name = getSingular( - str_replace( - "glpi_plugin_genericobject_", - "", - $dropdown_old_table, - ), - ); - $dropdown_old_itemtype = 'PluginGenericobject' . ucfirst($dropdown_old_name); - $dropdown_new_name = self::filterInput($dropdown_old_name); - $dropdown_new_itemtype = self::getClassByName($dropdown_new_name); - - if ( - $dropdown_old_name == $dropdown_new_name - && $dropdown_old_itemtype == $dropdown_new_itemtype - ) { - continue; - } - - self::updateNameAndItemtype( - $migration, - $dropdown_old_name, - $dropdown_new_name, - $dropdown_old_itemtype, - $dropdown_new_itemtype, - ); + self::updateNameAndItemtype( + $migration, + $old_name, + $new_name, + $old_itemtype, + $new_itemtype, + ); + + $DB->update( + self::getTable(), + [ + 'name' => $new_name, + 'itemtype' => $new_itemtype, + ], + ['id' => $type_id], + ); + + $DB->update( + self::getTable(), + [ + 'linked_itemtypes' => new QueryExpression( + 'REPLACE(' + . $DB->quoteName('linked_itemtypes') + . ',' + . $DB->quoteValue('"' . $old_itemtype . '"') // itemtype is surrounded by quotes + . ',' + . $DB->quoteValue('"' . $new_itemtype . '"') // itemtype is surrounded by quotes + . ')', + ), + ], + ['linked_itemtypes' => ['LIKE', '%"' . $old_itemtype . '"%']], + ); + + // Handle dropdowns related to itemtype + $table = getTableForItemType($new_itemtype); + $fields = $DB->listFields($table); + foreach ($fields as $field => $options) { + if (preg_match("/s_id$/", $field)) { + $dropdown_old_table = getTableNameForForeignKeyField($field); + + if (!preg_match('/^glpi_plugin_genericobject_/', $dropdown_old_table)) { + continue; } + + $dropdown_old_name = getSingular( + str_replace( + "glpi_plugin_genericobject_", + "", + $dropdown_old_table, + ), + ); + $dropdown_old_itemtype = 'PluginGenericobject' . ucfirst($dropdown_old_name); + $dropdown_new_name = self::filterInput($dropdown_old_name); + $dropdown_new_itemtype = self::getClassByName($dropdown_new_name); + + if ( + $dropdown_old_name == $dropdown_new_name + && $dropdown_old_itemtype == $dropdown_new_itemtype + ) { + continue; + } + + self::updateNameAndItemtype( + $migration, + $dropdown_old_name, + $dropdown_new_name, + $dropdown_old_itemtype, + $dropdown_new_itemtype, + ); } } + } + + /** + * Rename a genericobject type. + * + * Updates the type name, itemtype, all generated files, database tables, + * foreign keys, and all relation tables where the itemtype is referenced. + * + * @param int $type_id ID of the type in glpi_plugin_genericobject_types + * @param string $new_name New name for the type (will be filtered) + * + * @return bool True on success, false on failure + */ + public static function renameType(int $type_id, string $new_name): bool + { + /** @var DBmysql $DB */ + global $DB; + + $type = new self(); + if (!$type->getFromDB($type_id)) { + Session::addMessageAfterRedirect( + __s('Type not found.', 'genericobject'), + false, + ERROR, + ); + return false; + } + + $old_name = $type->fields['name']; + $new_name = self::filterInput($new_name); + + if ($new_name === '') { + Session::addMessageAfterRedirect( + __s('The new name cannot be empty.', 'genericobject'), + false, + ERROR, + ); + return false; + } + + $existing = $DB->request([ + 'FROM' => self::getTable(), + 'WHERE' => [ + 'name' => $new_name, + 'NOT' => ['id' => $type_id], + ], + ]); + if ($existing->numrows() > 0) { + Session::addMessageAfterRedirect( + __s('A type with this name already exists.', 'genericobject'), + false, + ERROR, + ); + return false; + } + + $manager = \Glpi\Asset\AssetDefinitionManager::getInstance(); + $reserved_pattern = $manager->getReservedSystemNamesPattern(); + if (preg_match($reserved_pattern, $new_name) === 1) { + Session::addMessageAfterRedirect( + __s('This name is reserved by a native GLPI asset type.', 'genericobject'), + false, + ERROR, + ); + return false; + } + + $new_system_name = self::getSystemName($new_name); + if (strlen($new_system_name) > self::MAX_TYPE_NAME_LENGTH) { + Session::addMessageAfterRedirect( + sprintf( + __s('The name "%s" is too long. The maximum allowed length is %d characters.', 'genericobject'), + $new_system_name, + self::MAX_TYPE_NAME_LENGTH, + ), + false, + ERROR, + ); + return false; + } + + if ($new_name === $old_name) { + return true; + } + + $old_itemtype = $type->fields['itemtype']; + $new_itemtype = self::getClassByName($new_name); - ProfileRight::cleanAllPossibleRights(); // Clean all possible rights are their name may have change + $migration = new Migration(PLUGIN_GENERICOBJECT_VERSION); + self::applyTypeRename( + $migration, + $type_id, + $old_name, + $new_name, + $old_itemtype, + $new_itemtype, + ); + ProfileRight::cleanAllPossibleRights(); + $migration->executeMigration(); + + Session::addMessageAfterRedirect( + sprintf( + __s('Type "%s" has been renamed to "%s".', 'genericobject'), + $old_name, + $new_name, + ), + false, + INFO, + ); + + return true; + } + + /** + * Get the list of reserved GLPI core asset names. + * + * @return string[] + */ + public static function getReservedNames(): array + { + $manager = \Glpi\Asset\AssetDefinitionManager::getInstance(); + $pattern = $manager->getReservedSystemNamesPattern(); + if (preg_match('/\(([^)]+)\)/', $pattern, $matches)) { + return explode('|', $matches[1]); + } + return []; } /** @@ -847,7 +1007,9 @@ private static function updateNameAndItemtype( global $DB; if ($old_itemtype != $new_itemtype && !str_starts_with($old_itemtype, 'Glpi\\CustomAsset\\')) { + self::renameItemtypeForFieldsPlugin($old_itemtype, $new_itemtype); $migration->renameItemtype($old_itemtype, $new_itemtype); + self::applyPluginsTypeRename($migration, $old_itemtype, $new_itemtype); $migration->executeMigration(); // Execute migration to flush updates on tables that may be renamed } @@ -963,4 +1125,153 @@ private static function updateNameAndItemtype( ); } } + + /** + * Rename itemtype for fields plugin and all its references. + */ + private static function renameItemtypeForFieldsPlugin( + string $old_itemtype, + string $new_itemtype, + ): void + { + /** @var DBmysql $DB */ + global $DB; + + $old_table = getTableForItemType($old_itemtype); + $new_table = getTableForItemType($new_itemtype); + $old_fkey = getForeignKeyFieldForTable($old_table); + $new_fkey = getForeignKeyFieldForTable($new_table); + + // Get all foreign key columns referencing the old itemtype + $fkey_column_iterator = $DB->request( + [ + 'SELECT' => [ + 'table_name AS TABLE_NAME', + 'column_name AS COLUMN_NAME', + ], + 'FROM' => 'information_schema.columns', + 'WHERE' => [ + 'table_schema' => $DB->dbdefault, + 'table_name' => ['LIKE', 'glpi\\_plugin\\_fields\\_%'], + 'OR' => [ + ['column_name' => $old_fkey], + ['column_name' => ['LIKE', $old_fkey . '_%']], + ], + ], + ] + ); + foreach ($fkey_column_iterator as $fkey_column) { + $fkey_table = $fkey_column['TABLE_NAME']; + $fkey_oldname = $fkey_column['COLUMN_NAME']; + $fkey_newname = preg_replace('/^' . preg_quote($old_fkey, '/') . '/', $new_fkey, $fkey_oldname); + + // Check if new foreign key name already exists in the table + if ($DB->fieldExists($fkey_table, $fkey_newname)) { + throw new RuntimeException( + sprintf( + 'Field "%s" cannot be renamed in table "%s" as "%s" is field already exists.', + $fkey_oldname, + $fkey_table, + $fkey_newname + ) + ); + } + + // Rename the foreign key column immediately so it is already updated when renameItemtype() scans all tables + if (!$DB->doQuery("ALTER TABLE `{$fkey_table}` RENAME COLUMN `{$fkey_oldname}` TO `{$fkey_newname}`")) { + throw new RuntimeException( + sprintf( + 'Failed to rename field "%s" to "%s" in table "%s": %s', + $fkey_oldname, + $fkey_newname, + $fkey_table, + $DB->error(), + ) + ); + } + } + + // Update the 'type' column in plugin fields that reference this itemtype + if ($DB->tableExists('glpi_plugin_fields_fields')) { + $DB->update( + 'glpi_plugin_fields_fields', + [ + 'type' => new QueryExpression( + 'REPLACE(' + . $DB->quoteName('type') + . ', ' . $DB->quoteValue('dropdown-' . $old_itemtype) + . ', ' . $DB->quoteValue('dropdown-' . $new_itemtype) + . ')', + ), + 'name' => new QueryExpression( + 'REPLACE(' + . $DB->quoteName('name') + . ', ' . $DB->quoteValue($old_fkey) + . ', ' . $DB->quoteValue($new_fkey) + . ')', + ), + ], + ['type' => ['LIKE', 'dropdown-' . $old_itemtype . '%']], + ); + } + } + + /** + * Update all plugin data when a genericobject type is renamed. + * + * @param Migration $migration + * @param string $old_itemtype Old itemtype class name + * @param string $new_itemtype New itemtype class name + */ + private static function applyPluginsTypeRename( + Migration $migration, + string $old_itemtype, + string $new_itemtype, + ): void { + /** @var DBmysql $DB */ + global $DB; + + // Get all plugin tables + $tables_result = $DB->doQuery("SHOW TABLES LIKE 'glpi\\_plugin\\_%'"); + if (!$tables_result) { + return; + } + + $table_names = []; + while ($row = $DB->fetchRow($tables_result)) { + $table_name = $row[0]; + + // Check if table name contains old itemtype and rename it + if (str_contains($table_name, strtolower($old_itemtype))) { + $new_table_name = str_replace(strtolower($old_itemtype), strtolower($new_itemtype), $table_name); + $migration->renameTable($table_name, $new_table_name); + $table_name = $new_table_name; + } + + $table_names[] = $table_name; + } + + $migration->executeMigration(); + + // Update itemtypes field by replacing old itemtype by new itemtype + foreach ($table_names as $table_name) { + if (!$DB->fieldExists($table_name, 'itemtypes')) { + continue; + } + + $DB->update( + $table_name, + [ + 'itemtypes' => new QueryExpression( + 'REPLACE(' + . $DB->quoteName('itemtypes') + . ', ' . $DB->quoteValue(json_encode($old_itemtype)) + . ', ' . $DB->quoteValue(json_encode($new_itemtype)) + . ')', + ), + ], + ['itemtypes' => ['LIKE', '%' . $old_itemtype . '%']], + ); + } + } } diff --git a/templates/migration_status.html.twig b/templates/migration_status.html.twig index 8ec5fe7e..57df4f4f 100644 --- a/templates/migration_status.html.twig +++ b/templates/migration_status.html.twig @@ -26,6 +26,19 @@ # ------------------------------------------------------------------------- #} +{% import 'components/alerts_macros.html.twig' as alerts %} + +{% set conflict_count = 0 %} +{% set too_long_count = 0 %} +{% for genericobject_type in genericobject_types %} + {% if genericobject_type.name|lower in reserved_names and customassets[genericobject_type.name] is not defined %} + {% set conflict_count = conflict_count + 1 %} + {% endif %} + {% if call('PluginGenericobjectType::getSystemName', [genericobject_type.name])|length > constant('PluginGenericobjectType::MAX_TYPE_NAME_LENGTH') and customassets[genericobject_type.name] is not defined %} + {% set too_long_count = too_long_count + 1 %} + {% endif %} +{% endfor %} + {# Page Header #}
@@ -37,20 +50,36 @@
-
- {# EOL Warning Banner #} -
-
- -
-
{{ __('End of Life Notice', 'genericobject') }}
-

- {{ __('GenericObject v3.0.0 is an End-of-Life version. All custom asset creation functionality has been moved to GLPI 11 native asset definition.', 'genericobject') }} -

-
-
-
+
+ {# EOL Warning Alert #} + {{ alerts.alert_warning( + __('End of Life Notice', 'genericobject'), + [__('GenericObject v3.0.0 is an End-of-Life version. All custom asset creation functionality has been moved to GLPI 11 native asset definition.', 'genericobject')] + ) }} + + {# Rename Info Alert #} + {{ alerts.alert_info( + __('Name Conflict?', 'genericobject'), + [__('If a GenericObject type shares the same name as a native GLPI asset (e.g. Printer, Computer), migration will fail. Use the Rename button on the relevant card below to resolve the conflict before migrating.', 'genericobject')] + ) }} + + {# Name Too Long Alert — only shown when types exceed the max name length #} + {% if too_long_count > 0 %} + {% set too_long_message = (too_long_count ~ ' ' ~ __('type(s) have names that exceed the maximum allowed length of %d characters and cannot be automatically renamed. Please rename them manually before migrating.', 'genericobject'))|format(constant('PluginGenericobjectType::MAX_TYPE_NAME_LENGTH')) %} + {{ alerts.alert_danger( + __('Name too long', 'genericobject') ~ ' (' ~ too_long_count ~ ')', + [too_long_message] + ) }} + {% endif %} + + {# Name Conflict Alert — only shown when conflicts exist #} + {% if conflict_count > 0 %} + {% set conflict_message = conflict_count ~ ' ' ~ __('type(s) cannot be migrated because their names are reserved by native GLPI assets. Use the Rename button on the cards below to fix them before migrating.', 'genericobject') %} + {{ alerts.alert_danger( + __('Name Conflict detected', 'genericobject') ~ ' (' ~ conflict_count ~ ')', + [conflict_message] + ) }} + {% endif %} {# Migration Status Card #}
@@ -81,20 +110,39 @@
{% for key, genericobject_type in paginated_types %} + {% set is_conflicting = genericobject_type.name|lower in reserved_names and customassets[genericobject_type.name] is not defined %} + {% set is_too_long = call('PluginGenericobjectType::getSystemName', [genericobject_type.name])|length > constant('PluginGenericobjectType::MAX_TYPE_NAME_LENGTH') and customassets[genericobject_type.name] is not defined %} + {% set modal_id = 'rename-modal-' ~ genericobject_type.id %}
- {% if customassets[genericobject_type.name] is defined and customassets[genericobject_type.name].icon %} + {% if customassets[genericobject_type.name] is defined %} + bg-success bg-opacity-10 text-success + {% elseif is_conflicting or is_too_long %} + bg-warning bg-opacity-10 text-warning + {% else %} + bg-danger bg-opacity-10 text-danger + {% endif %}" + > + {% if is_conflicting %} + + {% elseif is_too_long %} + + {% elseif customassets[genericobject_type.name] is defined and customassets[genericobject_type.name].icon %} {% else %} {% endif %}

{{ genericobject_type.name }} + {% if is_conflicting %} + + {{ __('Conflict', 'genericobject') }} + + {% elseif is_too_long %} + + {{ __('Name too long', 'genericobject') }} + + {% endif %}

@@ -111,12 +159,12 @@
{% else %}
- - {{ __('Not Migrated', 'genericobject') }} + {% if is_conflicting or is_too_long %} + + {{ __('Cannot Migrate', 'genericobject') }} + {% else %} + + {{ __('Not Migrated', 'genericobject') }} + {% endif %}
+ {% if is_conflicting %} +
+ + + {{ __('The name "%s" is reserved by a native GLPI asset type.')|format(genericobject_type.name) }} + +
+ {% elseif is_too_long %} +
+ + + {{ __('The name "%s" exceeds the maximum allowed length of %d characters. Please rename it manually.')|format(genericobject_type.name, constant('PluginGenericobjectType::MAX_TYPE_NAME_LENGTH')) }} + +
+ {% endif %}
{{ __('Items Migrated:', 'genericobject') }} 0 @@ -136,6 +204,13 @@ {{ __('View Items', 'genericobject') }} +
+ + {# Rename Modal for non-migrated types #} + {% if customassets[genericobject_type.name] is not defined %} + + {% endif %} + {% endfor %}
{# Display pagination controls at the bottom #} @@ -165,7 +303,7 @@ {{ __('How to Migrate', 'genericobject') }}
-
+
@@ -212,6 +350,7 @@
+
{# Next Steps Card #}
@@ -276,6 +415,40 @@ }