diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dc0a19..0bba1dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Add Transifex translations link to README +- Permit to Inject GlpiAsset ## [2.16.0] - 2026-10-05 diff --git a/front/info.form.php b/front/info.form.php index 5f21cb55..3171d332 100644 --- a/front/info.form.php +++ b/front/info.form.php @@ -28,8 +28,6 @@ * ------------------------------------------------------------------------- */ -Session::checkLoginUser(); - /* Update mappings */ if (isset($_POST["update"])) { $model = new PluginDatainjectionModel(); diff --git a/front/model.form.php b/front/model.form.php index 266a47b3..a01921be 100644 --- a/front/model.form.php +++ b/front/model.form.php @@ -28,8 +28,6 @@ * ------------------------------------------------------------------------- */ -Session::checkLoginUser(); - $_GET["id"] ??= ""; $_GET["withtemplate"] ??= ""; diff --git a/front/model.php b/front/model.php index 8a757caa..065d2466 100644 --- a/front/model.php +++ b/front/model.php @@ -28,7 +28,6 @@ * ------------------------------------------------------------------------- */ -Session::checkLoginUser(); Html::header( PluginDatainjectionModel::getTypeName(), diff --git a/front/popup.php b/front/popup.php index 4b6747b9..498ad2f6 100644 --- a/front/popup.php +++ b/front/popup.php @@ -28,8 +28,6 @@ * ------------------------------------------------------------------------- */ -Session::checkLoginUser(); - switch ($_GET["popup"]) { case "preview": $models_id = (int) ($_GET['models_id'] ?? 0); diff --git a/inc/commoninjectionlib.class.php b/inc/commoninjectionlib.class.php index 97d6ceb9..b265310b 100644 --- a/inc/commoninjectionlib.class.php +++ b/inc/commoninjectionlib.class.php @@ -27,11 +27,12 @@ * @link https://github.com/pluginsGLPI/datainjection * ------------------------------------------------------------------------- */ - +use Glpi\Asset\Asset; use Glpi\DBAL\QueryExpression; use Glpi\DBAL\QuerySubQuery; use Glpi\Exception\Http\HttpException; use Glpi\Features\AssignableItem; +use GlpiPlugin\Datainjection\Glpi\Asset\AssetInjection; use function Safe\preg_match; use function Safe\preg_replace; @@ -356,6 +357,15 @@ public static function getItemtypeByInjection($injectionClassName) public static function getItemtypeByInjectionClass($injectionClass) { + if ($injectionClass instanceof AssetInjection) { + // Custom assets all share the "glpi_assets_assets" table, whose canonical + // itemtype is the *abstract* Glpi\Asset\Asset. Resolving through the table + // (getItemTypeForTable) is therefore unreliable and can return that abstract + // class, which is not instantiable. The injection class already knows its + // concrete custom-asset itemtype, so use it directly. + return $injectionClass::getVirtualType(); + } + return Toolbox::ucfirst(getItemTypeForTable($injectionClass->getTable())); } @@ -365,7 +375,10 @@ public static function getItemtypeByInjectionClass($injectionClass) * * @param string $itemtype the itemtype * - * @return PluginDatainjectionInjectionInterface the injection class instance + * Every injection class both implements the interface and extends CommonDBTM, + * so the returned instance exposes the CommonDBTM API (getTable(), ...) too. + * + * @return PluginDatainjectionInjectionInterface&CommonDBTM the injection class instance */ public static function getInjectionClassInstance($itemtype) { @@ -377,7 +390,15 @@ public static function getInjectionClassInstance($itemtype) $injectionClass = ucfirst($itemtype) . 'Injection'; } - if (!is_a($injectionClass, PluginDatainjectionInjectionInterface::class, true)) { + if (!class_exists($injectionClass)) { + plugin_datainjection_creationInjectableAssets(); + } + + + if ( + !is_a($injectionClass, PluginDatainjectionInjectionInterface::class, true) + || !is_a($injectionClass, CommonDBTM::class, true) + ) { throw new HttpException(500, 'Class ' . $injectionClass . ' is not a valid class'); } @@ -626,7 +647,8 @@ private function getFieldValue( case 'dropdown': case 'relation': - $tmptype = getItemTypeForTable($searchOption['table']); + // Custom dropdowns share one table: their itemtype cannot be resolved from it + $tmptype = $searchOption['dropdown_itemtype'] ?? getItemTypeForTable($searchOption['table']); if (!is_a($tmptype, CommonDBTM::class, true)) { return; } @@ -2145,6 +2167,10 @@ private function dataAlreadyInDB($injectionClass, $itemtype) } } + if ($injectionClass instanceof Asset && method_exists($injectionClass, 'getAssetDefinitionID')) { + $where['assets_assetdefinitions_id'] = $injectionClass->getAssetDefinitionID(); + } + //Add additional parameters specific to this itemtype (or function checkPresent exists) if (method_exists($injectionClass, 'checkPresent')) { $extra = $injectionClass->checkPresent($this->values, $options); @@ -2447,8 +2473,13 @@ public static function addToSearchOptions( ); $main_itemtype = self::getItemtypeByInjectionClass($injectionClass); + $main_table = getTableForItemType($main_itemtype); foreach ($type_searchOptions as &$value) { - $value_itemtype = getItemTypeForTable($value['table']); + $value_itemtype = $value['table'] === $main_table + // Custom assets share a table resolved to the abstract Glpi\Asset\Asset, + // whose getTypeName() needs a concrete definition + ? $main_itemtype + : getItemTypeForTable($value['table']); $value_itemtype_name_singular = $value_itemtype::getTypeName(1); $value_itemtype_name_plural = $value_itemtype::getTypeName(Session::getPluralNumber()); if ( diff --git a/inc/info.class.php b/inc/info.class.php index 4527d57e..3e35e1e8 100644 --- a/inc/info.class.php +++ b/inc/info.class.php @@ -359,7 +359,7 @@ public static function showAdditionalInformation( } Dropdown::show( - getItemTypeForTable($option['table']), + $option['dropdown_itemtype'] ?? getItemTypeForTable($option['table']), ['name' => $name, 'value' => $value, ], diff --git a/inc/injectiontype.class.php b/inc/injectiontype.class.php index 8da2105e..215f8559 100644 --- a/inc/injectiontype.class.php +++ b/inc/injectiontype.class.php @@ -28,6 +28,7 @@ * ------------------------------------------------------------------------- */ use Glpi\Exception\Http\HttpException; +use GlpiPlugin\Datainjection\Glpi\Asset\AssetInjection; use function Safe\json_decode; use function Safe\json_encode; @@ -80,6 +81,11 @@ public static function getItemtypes($only_primary = false) } $name .= call_user_func([$type, 'getTypeName']); + + if ($instance instanceof AssetInjection) { + $typename = $instance->getVirtualType(); + } + $values[$typename] = $name; } } @@ -396,7 +402,7 @@ public static function getUsedMappingsOrInfos($options = []) foreach ($options as $option) { if ( isset($option['table']) - && ($option['table'] == getItemTypeForTable($data['itemtype'])) + && ($option['table'] == $injectionClass->getTable()) && ($option['linkfield'] == $data['value']) && ($option['displaytype'] != 'multiline_text') && ($mapping_or_info['value'] != $data['value']) diff --git a/phpstan.neon b/phpstan.neon index db69ea19..275425e8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,6 +9,7 @@ parameters: - ajax - front - inc + - src - hook.php - setup.php scanDirectories: diff --git a/setup.php b/setup.php index 87c38dbf..935c86b9 100644 --- a/setup.php +++ b/setup.php @@ -28,8 +28,13 @@ * ------------------------------------------------------------------------- */ +use Glpi\Asset\AssetDefinition; +use Glpi\Asset\AssetDefinitionManager; +use GlpiPlugin\Datainjection\Glpi\Asset\Capacity\IsInjectableCapacity; + use function Safe\define; use function Safe\mkdir; +use function Safe\preg_match; define('PLUGIN_DATAINJECTION_VERSION', '2.16.0'); @@ -48,6 +53,10 @@ function plugin_init_datainjection() /** @var array $CFG_GLPI */ /** @var array $INJECTABLE_TYPES */ global $PLUGIN_HOOKS, $CFG_GLPI, $INJECTABLE_TYPES; + $CFG_GLPI['injectable_types'] ??= []; + + $asset_definition_manager = AssetDefinitionManager::getInstance(); + $asset_definition_manager->registerCapacity(new IsInjectableCapacity()); $PLUGIN_HOOKS['csrf_compliant']['datainjection'] = true; $PLUGIN_HOOKS['migratetypes']['datainjection'] = 'plugin_datainjection_migratetypes_datainjection'; @@ -85,6 +94,7 @@ function plugin_init_datainjection() $PLUGIN_HOOKS['add_javascript']['datainjection'] = 'js/datainjection.js'; $INJECTABLE_TYPES = []; + } } @@ -114,8 +124,8 @@ function plugin_version_datainjection() function getTypesToInject(): void { /** @var array $INJECTABLE_TYPES */ - /** @var array $PLUGIN_HOOKS */ - global $INJECTABLE_TYPES,$PLUGIN_HOOKS; + /** @var array $CFG_GLPI */ + global $INJECTABLE_TYPES,$CFG_GLPI; if (count($INJECTABLE_TYPES)) { // already populated @@ -218,6 +228,112 @@ function getTypesToInject(): void ]; //Add plugins Plugin::doHook('plugin_datainjection_populate'); + + // Register injectable assets dynamically + plugin_datainjection_registerInjectableAssets(); + + plugin_datainjection_creationInjectableAssets(); +} + + +/** + * Register injection classes for each injectable asset definition + */ +function plugin_datainjection_registerInjectableAssets(): void +{ + /** @var array $INJECTABLE_TYPES */ + /** @var array $CFG_GLPI */ + global $INJECTABLE_TYPES, $CFG_GLPI; + + if (!isset($CFG_GLPI['injectable_types']) || empty($CFG_GLPI['injectable_types'])) { + return; + } + + // For each injectable asset definition, create and register a distinct class + foreach ($CFG_GLPI['injectable_types'] as $definition_id => $itemtype) { + // Get the asset definition to extract the system name + + $definition = AssetDefinition::getById($definition_id); + if ($definition === false || $definition === null) { + continue; + } + + if ($definition->getAssetClassName() === $itemtype) { + // Use the system name to create a nice class name + $system_name = ucfirst($definition->fields['system_name']); + $injection_class = 'PluginDatainjection' . $system_name . 'AssetInjection'; + // Only create if not already registered + $INJECTABLE_TYPES[$injection_class] ??= 'datainjection'; + } + } +} + +/** + * Register injection classes for each injectable asset definition + */ +function plugin_datainjection_creationInjectableAssets(): void +{ + /** @var array $CFG_GLPI */ + global $CFG_GLPI; + + if (!isset($CFG_GLPI['injectable_types']) || empty($CFG_GLPI['injectable_types'])) { + return; + } + + + // For each injectable asset definition, create and register a distinct class + foreach ($CFG_GLPI['injectable_types'] as $definition_id => $itemtype) { + $definition = AssetDefinition::getById($definition_id); + if ($definition === false) { + continue; + } + + // Get the asset definition to extract the system name + if ($definition->getAssetClassName() === $itemtype) { + // Use the system name to create a nice class name + $system_name = ucfirst($definition->fields['system_name']);//strtolower() + $injection_class = 'PluginDatainjection' . $system_name . 'AssetInjection'; + + plugin_datainjection_createAssetInjectionClass($injection_class, $definition_id); + } + } +} + +/** + * Dynamically create an asset injection class + */ +function plugin_datainjection_createAssetInjectionClass(string $class_name, int $definition_id): void +{ + + // Check if class already exists + if (class_exists($class_name)) { + return; + } + + // Create the class dynamically (not final, so it can be extended) + $code = <<. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2007-2023 by DataInjection plugin team. + * @license GPLv2 https://www.gnu.org/licenses/gpl-2.0.html + * @link https://github.com/pluginsGLPI/datainjection + * ------------------------------------------------------------------------- + */ + +namespace GlpiPlugin\Datainjection\Glpi\Asset; + +use CommonDBTM; +use Glpi\Asset\Asset; +use Glpi\Asset\AssetDefinition; +use Glpi\Asset\AssetDefinitionManager; +use Glpi\Asset\CustomFieldDefinition; +use Glpi\Asset\CustomFieldType\BooleanType; +use Glpi\Asset\CustomFieldType\DateTimeType; +use Glpi\Asset\CustomFieldType\DateType; +use Glpi\Asset\CustomFieldType\DropdownType; +use Glpi\Asset\CustomFieldType\NumberType; +use Glpi\Asset\CustomFieldType\StringType; +use Glpi\Asset\CustomFieldType\TextType; +use PluginDatainjectionCommonInjectionLib; +use PluginDatainjectionInjectionInterface; +use Search; + +abstract class AssetInjection extends Asset implements PluginDatainjectionInjectionInterface +{ + protected static string $definition_system_name = ''; + + protected static int $fixed_asset_definition_id = 0; + + public static function getAssetDefinitionID(): int + { + + // If a fixed ID is set (for dynamically created classes), use it + if (static::$fixed_asset_definition_id > 0) { + return static::$fixed_asset_definition_id; + } + + return 0; + } + + /** + * Get the first active asset definition to use as system name + */ + protected static function getAssetDefinitionSystemName(): string + { + // Get first active asset definition from the database + // This ensures we have a valid definition to use + $manager = AssetDefinitionManager::getInstance(); + foreach ($manager->getDefinitions() as $definition) { + if ($definition->fields['id'] == static::getAssetDefinitionID()) { + return $definition->fields['system_name']; + } + } + + // Fallback to a default if none found + return ''; + } + + public static function getTypeName($nb = 0) + { + $manager = AssetDefinitionManager::getInstance(); + foreach ($manager->getDefinitions() as $definition) { + if ($definition->fields['id'] == static::getAssetDefinitionID()) { + return $definition->fields['label']; + } + } + + // Fallback to a default if none found + return ''; + } + + public static function getDefinition(): AssetDefinition + { + if (empty(static::$definition_system_name)) { + static::$definition_system_name = static::getAssetDefinitionSystemName(); + } + + return parent::getDefinition(); + } + + + public static function getVirtualType($classname = null) + { + return 'Glpi\\CustomAsset\\' . static::getAssetDefinitionSystemName() . 'Asset'; + } + + public static function getVirtualTable($classname = null) + { + return getTableForItemType(self::getVirtualType()); + } + + public static function getTable($classname = null) + { + return getTableForItemType(Asset::class); + } + + public static function canCreate(): bool + { + $virtual_type = static::getVirtualType(); + return $virtual_type::canCreate(); + } + + public function isPrimaryType() + { + return true; + } + + public function connectedTo() + { + return []; + } + + public function isNullable($field) + { + return true; + } + + + public static function replaceTableName(array $data): array + { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = self::replaceTableName($value); // récursif + } elseif ($value === self::getTable()) { + $data[$key] = self::getVirtualTable(); + } + } + + return $data; + } + + /** + * @see plugins/datainjection/inc/PluginDatainjectionInjectionInterface::getOptions() + */ + public function getOptions($primary_type = '') + { + + // Build search options manually to avoid registration checks + $tab = Search::getOptions(self::getVirtualType()); + + $tab = self::replaceTableName($tab); + unset($tab[4]); + unset($tab[40]); + + $tab2 = [ + [ + 'id' => '200', + 'table' => static::getTable(), + 'field' => 'assets_assetdefinitions_id', + 'linkfield' => 'assets_assetdefinitions_id', + 'name' => AssetDefinition::getTypeName(), + 'datatype' => 'number', + 'massiveaction' => false, + 'injectable' => true, + ], + ]; + + foreach ($tab2 as $entry) { + $tab[(int) $entry['id']] = $entry; + } + + //Remove some options because some fields cannot be imported + $blacklist = PluginDatainjectionCommonInjectionLib::getBlacklistedOptions(static::class); + $notimportable = ['300', '301']; + $options = []; + $options['ignore_fields'] = array_merge($blacklist, $notimportable); + $options['displaytype'] = ['multiline_text' => [16]]; + + $tab = PluginDatainjectionCommonInjectionLib::addToSearchOptions($tab, $options, $this); + + // Added after the generic filter, which only keeps the core options (id < 1000) + foreach (static::getDefinition()->getCustomFieldDefinitions() as $custom_field) { + $option = $this->getCustomFieldOption($custom_field); + if ($option !== null) { + $tab[$option['id']] = $option; + } + } + + return $tab; + } + + /** + * Injection option of a custom field + * + * The core search option of a custom field cannot be injected: its linkfield is the + * JSON column "custom_fields", shared by every custom field. The value is injected + * as "custom_", which Asset::prepareInputForAdd/Update() stores in it. + * + * @return array|null null for a type that cannot be injected + */ + private function getCustomFieldOption(CustomFieldDefinition $custom_field): ?array + { + $field_type = $custom_field->getFieldType(); + $option = [ + 'id' => $custom_field->getSearchOptionID(), + 'name' => $custom_field->computeFriendlyName(), + 'table' => self::getVirtualTable(), + 'field' => 'custom_' . $custom_field->fields['system_name'], + 'linkfield' => 'custom_' . $custom_field->fields['system_name'], + 'injectable' => PluginDatainjectionCommonInjectionLib::FIELD_INJECTABLE, + ]; + + // DateTimeType extends DateType: test it first + if ($field_type instanceof DateTimeType) { + return $option + ['displaytype' => 'text', 'checktype' => 'datetime', 'datatype' => 'datetime']; + } + + if ($field_type instanceof DateType) { + return $option + ['displaytype' => 'text', 'checktype' => 'date', 'datatype' => 'date']; + } + + if ($field_type instanceof BooleanType) { + return $option + ['displaytype' => 'bool', 'checktype' => 'bool', 'datatype' => 'bool']; + } + + if ($field_type instanceof NumberType) { + return $option + ['displaytype' => 'text', 'checktype' => 'float', 'datatype' => 'decimal']; + } + + if ($field_type instanceof TextType) { + return $option + ['displaytype' => 'multiline_text', 'checktype' => 'text', 'datatype' => 'text']; + } + + // Also URLType, which extends StringType + if ($field_type instanceof StringType) { + return $option + ['displaytype' => 'text', 'checktype' => 'text', 'datatype' => 'string']; + } + + // A multiple dropdown would need a separator in the imported value: not handled + if ( + $field_type instanceof DropdownType + && !($custom_field->fields['field_options']['multiple'] ?? false) + && is_a($custom_field->fields['itemtype'], CommonDBTM::class, true) + ) { + return $option + [ + 'displaytype' => 'dropdown', + 'checktype' => 'text', + 'datatype' => 'dropdown', + // The option keeps the asset table, like the other custom fields: the lib + // finds the dropdown through this itemtype (custom dropdowns share one table) + 'dropdown_itemtype' => $custom_field->fields['itemtype'], + ]; + } + + return null; + } + + + /** + * @param array $fields_toinject array + **/ + public function getValueForAdditionalMandatoryFields($fields_toinject = []) + { + $fields_toinject[self::getVirtualType()]['assets_assetdefinitions_id'] = static::getAssetDefinitionID(); + + return $fields_toinject; + } + + /** + * @see plugins/datainjection/inc/PluginDatainjectionInjectionInterface::addOrUpdateObject() + */ + public function addOrUpdateObject($values = [], $options = []) + { + + $lib = new PluginDatainjectionCommonInjectionLib($this, $values, $options); + $lib->processAddOrUpdate(); + return $lib->getInjectionResults(); + } +} diff --git a/src/Glpi/Asset/Capacity/IsInjectableCapacity.php b/src/Glpi/Asset/Capacity/IsInjectableCapacity.php new file mode 100644 index 00000000..e3bd07ce --- /dev/null +++ b/src/Glpi/Asset/Capacity/IsInjectableCapacity.php @@ -0,0 +1,86 @@ +. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2007-2023 by DataInjection plugin team. + * @license GPLv2 https://www.gnu.org/licenses/gpl-2.0.html + * @link https://github.com/pluginsGLPI/datainjection + * ------------------------------------------------------------------------- + */ + +namespace GlpiPlugin\Datainjection\Glpi\Asset\Capacity; + +use Glpi\Asset\AssetDefinitionManager; +use Glpi\Asset\Capacity\AbstractCapacity; +use Glpi\Asset\CapacityConfig; +use Override; + +final class IsInjectableCapacity extends AbstractCapacity +{ + #[Override] + public function getLabel(): string + { + return __('Injectable', 'datainjection'); + } + + #[Override] + public function getIcon(): string + { + return 'ti ti-download'; + } + + #[Override] + public function getDescription(): string + { + return __("Inject objects list", 'datainjection'); + } + + + public function onClassBootstrap(string $classname, CapacityConfig $config): void + { + global $CFG_GLPI; + + // if (!isset($CFG_GLPI['injectable_types'])) { + // $CFG_GLPI['injectable_types'] = []; + // } + $manager = AssetDefinitionManager::getInstance(); + foreach ($manager->getDefinitions() as $definition) { + $itemtype = $definition->getAssetClassName(); + if ($itemtype == $classname) { + if (!in_array($classname, $CFG_GLPI['injectable_types'])) { + $CFG_GLPI['injectable_types'][$definition->fields['id']] = $classname; + } + } + } + + $this->registerToTypeConfig('injectable_types', $classname); + + // CommonGLPI::registerStandardTab($classname, Infocom::class, 50); + } + + public function getCapacityUsageDescription(string $classname): string + { + // TODO: Implement getCapacityUsageDescription() method. + return ''; + } +} diff --git a/tests/unit/AssetInjectionTest.php b/tests/unit/AssetInjectionTest.php new file mode 100644 index 00000000..010fcb7b --- /dev/null +++ b/tests/unit/AssetInjectionTest.php @@ -0,0 +1,257 @@ +. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2007-2023 by DataInjection plugin team. + * @license GPLv2 https://www.gnu.org/licenses/gpl-2.0.html + * @link https://github.com/pluginsGLPI/datainjection + * ------------------------------------------------------------------------- + */ + +namespace GlpiPlugin\Datainjection\Tests\Unit; + +use Glpi\Asset\AssetDefinitionManager; +use Glpi\Asset\Capacity; +use Glpi\Asset\CustomFieldDefinition; +use Glpi\Asset\CustomFieldType\BooleanType; +use Glpi\Asset\CustomFieldType\DateType; +use Glpi\Asset\CustomFieldType\DropdownType; +use Glpi\Asset\CustomFieldType\NumberType; +use Glpi\Asset\CustomFieldType\StringType; +use Glpi\Asset\CustomFieldType\TextType; +use Glpi\Tests\DbTestCase; +use GlpiPlugin\Datainjection\Glpi\Asset\Capacity\IsInjectableCapacity; +use Location; +use PluginDatainjectionCommonInjectionLib; + +final class AssetInjectionTest extends DbTestCase +{ + /** + * End-to-end injection into a custom GlpiAsset definition: + * enable IsInjectableCapacity on a definition, let the plugin generate the + * dedicated AssetInjection subclass, inject a row through it and assert the + * resulting custom asset item is persisted and tied to the definition. + */ + public function testRowIsInjectedIntoInjectableCustomAssetDefinition(): void + { + // The AssetDefinitionManager singleton is rebuilt between tests and only + // carries core capacities; the plugin capacity is registered from + // plugin_init_datainjection() at runtime, so re-register it explicitly here. + $manager = AssetDefinitionManager::getInstance(); + $manager->registerCapacity(new IsInjectableCapacity()); + + // Create a custom asset definition with the "Injectable" capacity enabled. + $system_name = 'Injectableasset'; + $definition = $this->initAssetDefinition( + $system_name, + [new Capacity(name: IsInjectableCapacity::class)], + ); + // Log in once the definition exists: the session then loads the rights it grants + // to the profile, which the injection checks before creating the asset + $this->login(); + + $asset_itemtype = $definition->getAssetClassName(); + + // Reproduce the runtime registration pipeline that turns an injectable + // definition into a concrete injection class: + // - bootDefinitions() re-reads the new definition and triggers + // IsInjectableCapacity::onClassBootstrap(), which populates injectable_types; + // - the two registration helpers then eval() the dedicated AssetInjection subclass. + /** @var array $CFG_GLPI */ + global $CFG_GLPI; + $CFG_GLPI['injectable_types'] = []; + $manager->bootDefinitions(); + + self::assertArrayHasKey( + $definition->getID(), + $CFG_GLPI['injectable_types'], + 'IsInjectableCapacity should register the definition as an injectable type', + ); + self::assertSame($asset_itemtype, $CFG_GLPI['injectable_types'][$definition->getID()]); + + plugin_datainjection_registerInjectableAssets(); + plugin_datainjection_creationInjectableAssets(); + + // The subclass name is built exactly the way setup.php builds it. + $injection_classname = 'PluginDatainjection' . ucfirst($system_name) . 'AssetInjection'; + self::assertTrue( + class_exists($injection_classname), + "Injection class {$injection_classname} should have been generated", + ); + + $injection = new $injection_classname(); + + // The lib keys both the injected values and the results by the itemtype it + // derives from the injection class; reuse that exact value to avoid any + // table round-trip mismatch. + $primary_type = PluginDatainjectionCommonInjectionLib::getItemtypeByInjectionClass($injection); + self::assertSame($asset_itemtype, $primary_type); + + $asset_name = 'Injected custom asset'; + $results = $injection->addOrUpdateObject( + [ + $primary_type => [ + 'name' => $asset_name, + ], + ], + [ + 'rights' => [ + 'can_add' => true, + 'can_update' => true, + 'add_dropdown' => true, + ], + 'mandatory_fields' => [ + $primary_type => ['name' => true], + ], + // The session of login() only sees the test entities: the injection + // checks the CREATE right on the target entity + 'entities_id' => $this->getTestRootEntity(true), + ], + ); + + self::assertSame(PluginDatainjectionCommonInjectionLib::SUCCESS, $results['status']); + self::assertSame(PluginDatainjectionCommonInjectionLib::IMPORT_ADD, $results['type']); + + $asset_id = $results[$primary_type] ?? null; + self::assertNotNull($asset_id, 'The injection result should expose the created asset id'); + self::assertGreaterThan(0, (int) $asset_id); + + // The row must exist as a real custom asset, tied to our definition. + $asset = new $asset_itemtype(); + self::assertTrue($asset->getFromDB((int) $asset_id)); + self::assertSame($asset_name, $asset->fields['name']); + self::assertSame( + $definition->getID(), + (int) $asset->fields['assets_assetdefinitions_id'], + ); + } + + /** + * Custom fields are stored in a JSON column shared by all of them: each one must be + * offered as its own injectable field and saved through "custom_". + */ + public function testCustomFieldsAreInjected(): void + { + $manager = AssetDefinitionManager::getInstance(); + $manager->registerCapacity(new IsInjectableCapacity()); + + $definition = $this->initAssetDefinition( + 'Injectablecustomfields', + [new Capacity(name: IsInjectableCapacity::class)], + ); + $fields = [ + 'serial_ref' => StringType::class, + 'notes' => TextType::class, + 'quantity' => NumberType::class, + 'delivery' => DateType::class, + 'is_spare' => BooleanType::class, + ]; + foreach ($fields as $system_name => $type) { + $this->createItem(CustomFieldDefinition::class, [ + 'assets_assetdefinitions_id' => $definition->getID(), + 'system_name' => $system_name, + 'label' => ucfirst($system_name), + 'type' => $type, + ]); + } + + $this->createItem(CustomFieldDefinition::class, [ + 'assets_assetdefinitions_id' => $definition->getID(), + 'system_name' => 'room', + 'label' => 'Room', + 'type' => DropdownType::class, + 'itemtype' => Location::class, + ]); + $this->createItem(CustomFieldDefinition::class, [ + 'assets_assetdefinitions_id' => $definition->getID(), + 'system_name' => 'tags', + 'label' => 'Tags', + 'type' => DropdownType::class, + 'itemtype' => Location::class, + 'field_options' => ['multiple' => true], + ], ['field_options']); + $definition->getFromDB($definition->getID()); + $this->login(); + + /** @var array $CFG_GLPI */ + global $CFG_GLPI; + $CFG_GLPI['injectable_types'] = []; + $manager->bootDefinitions(); + plugin_datainjection_registerInjectableAssets(); + plugin_datainjection_creationInjectableAssets(); + + $injection_classname = 'PluginDatainjectionInjectablecustomfieldsAssetInjection'; + $injection = new $injection_classname(); + $primary_type = PluginDatainjectionCommonInjectionLib::getItemtypeByInjectionClass($injection); + + // Every custom field but the multiple dropdown is offered, under its own linkfield + $linkfields = array_column($injection->getOptions($primary_type), 'linkfield'); + foreach (['serial_ref', 'notes', 'quantity', 'delivery', 'is_spare', 'room'] as $system_name) { + self::assertContains('custom_' . $system_name, $linkfields); + } + + self::assertNotContains('custom_tags', $linkfields); + self::assertNotContains('custom_fields', $linkfields); + + $results = $injection->addOrUpdateObject( + [ + $primary_type => [ + 'name' => 'Asset with custom fields', + 'custom_serial_ref' => 'REF-42', + 'custom_notes' => "first line\nsecond line", + 'custom_quantity' => '7', + 'custom_delivery' => '2026-10-23', + 'custom_is_spare' => '1', + 'custom_room' => 'Injected room', + ], + ], + [ + 'rights' => [ + 'can_add' => true, + 'can_update' => true, + 'add_dropdown' => true, + ], + 'mandatory_fields' => [ + $primary_type => ['name' => true], + ], + 'entities_id' => $this->getTestRootEntity(true), + ], + ); + + self::assertSame(PluginDatainjectionCommonInjectionLib::SUCCESS, $results['status']); + + $asset = new ($definition->getAssetClassName())(); + self::assertTrue($asset->getFromDB((int) $results[$primary_type])); + + // The room did not exist: add_dropdown allowed to create it + $location = new Location(); + self::assertTrue($location->getFromDBByCrit(['name' => 'Injected room'])); + + self::assertSame('REF-42', $asset->fields['custom_serial_ref']); + self::assertSame("first line\nsecond line", $asset->fields['custom_notes']); + self::assertEquals(7, $asset->fields['custom_quantity']); + self::assertSame('2026-10-23', $asset->fields['custom_delivery']); + self::assertTrue((bool) $asset->fields['custom_is_spare']); + self::assertEquals($location->getID(), $asset->fields['custom_room']); + } +}