N°8760 - Audit uninstall of extensions that declare final classes

N°8760 - be able to list modules based on extension choices
refactoring: move some classes in a moduleinstallation folder (coming
namespace)

N°8760 - module dependency check applied before audit

N°8760 - make dependency check work during audit

N°8760 - fix ci

N°8760 - fix ci

N°8760 - add GetCreatedIn to get module name based on DBObject class - everything stored in MetaModel during compilation and autoload

N°8760 - be able to describe from which module a datamodel class comes via MetaModel created_in field

N°8760 - rename GetCreatedIn <- GetModuleName + compute module name live instead having complex stuff in MetaModel/compilation

temp review 1

review: renaming InstallationChoicesToModuleConverter

review: renaming InstallationChoicesToModuleConverter

review: ModuleDiscovery:GetModulesOrderedByDependencies replacing deprecated GetAvailableModules method

ci: fix typo

cleanup

review: rework InstallationChoicesToModuleConverter

N°8760 - review tests
This commit is contained in:
odain
2026-01-21 17:00:24 +01:00
parent bb6248a6e7
commit 77626f8159
30 changed files with 1139 additions and 119 deletions

View File

@@ -0,0 +1,130 @@
<?php
require_once __DIR__.'/ModuleInstallationRepository.php';
class AnalyzeInstallation
{
private static AnalyzeInstallation $oInstance;
private ?array $aAvailableModules = null;
protected function __construct()
{
}
final public static function GetInstance(): AnalyzeInstallation
{
if (!isset(self::$oInstance)) {
self::$oInstance = new AnalyzeInstallation();
}
return self::$oInstance;
}
final public static function SetInstance(?AnalyzeInstallation $oInstance): void
{
self::$oInstance = $oInstance;
}
/**
* Analyzes the current installation and the possibilities
*
* @param null|Config $oConfig Defines the target environment (DB)
* @param mixed $modulesPath Either a single string or an array of absolute paths
* @param bool $bAbortOnMissingDependency ...
* @param array $aModulesToLoad List of modules to search for, defaults to all if omitted
*
* @return array Array with the following format:
* array =>
* 'iTop' => array(
* 'installed_version' => ... (could be empty in case of a fresh install)
* 'available_version => ...
* )
* <module_name> => array(
* 'installed_version' => ...
* 'available_version' => ...
* 'install' => array(
* 'flag' => SETUP_NEVER | SETUP_OPTIONAL | SETUP_MANDATORY
* 'message' => ...
* )
* 'uninstall' => array(
* 'flag' => SETUP_NEVER | SETUP_OPTIONAL | SETUP_MANDATORY
* 'message' => ...
* )
* 'label' => ...
* 'dependencies' => array(<module1>, <module2>, ...)
* 'visible' => true | false
* )
* )
* @throws \Exception
*/
public function AnalyzeInstallation(?Config $oConfig, mixed $modulesPath, bool $bAbortOnMissingDependency = false, ?array $aModulesToLoad = null)
{
$aRes = [
ROOT_MODULE => [
'installed_version' => '',
'available_version' => ITOP_VERSION_FULL,
'name_code' => ITOP_APPLICATION,
],
];
$aDirs = is_array($modulesPath) ? $modulesPath : [$modulesPath];
if (! is_null($this->aAvailableModules)) {
//test only
$aAvailableModules = $this->aAvailableModules;
} else {
$aAvailableModules = ModuleDiscovery::GetModulesOrderedByDependencies($aDirs, $bAbortOnMissingDependency, $aModulesToLoad);
}
foreach ($aAvailableModules as $sModuleId => $aModuleInfo) {
list($sModuleName, $sModuleVersion) = ModuleDiscovery::GetModuleName($sModuleId);
$aModuleInfo['installed_version'] = '';
$aModuleInfo['available_version'] = $sModuleVersion;
if ($aModuleInfo['mandatory']) {
$aModuleInfo['install'] = [
'flag' => MODULE_ACTION_MANDATORY,
'message' => 'the module is part of the application',
];
} else {
$aModuleInfo['install'] = [
'flag' => MODULE_ACTION_OPTIONAL,
'message' => '',
];
}
$aRes[$sModuleName] = $aModuleInfo;
}
$aCurrentlyInstalledModules = ModuleInstallationRepository::GetInstance()->ReadComputeInstalledModules($oConfig);
// Adjust the list of proposed modules
foreach ($aCurrentlyInstalledModules as $sModuleName => $aModuleDB) {
if ($sModuleName == ROOT_MODULE) {
$aRes[$sModuleName]['installed_version'] = $aModuleDB['version'];
continue;
}
if (!array_key_exists($sModuleName, $aRes)) {
// A module was installed, it is not proposed in the new build... skip
continue;
}
$aRes[$sModuleName]['installed_version'] = $aModuleDB['version'];
if ($aRes[$sModuleName]['mandatory']) {
$aRes[$sModuleName]['uninstall'] = [
'flag' => MODULE_ACTION_IMPOSSIBLE,
'message' => 'the module is part of the application',
];
} else {
$aRes[$sModuleName]['uninstall'] = [
'flag' => MODULE_ACTION_OPTIONAL,
'message' => '',
];
}
}
return $aRes;
}
}

View File

@@ -0,0 +1,215 @@
<?php
/**
* @copyright Copyright (C) 2010-2025 Combodo SARL
* @license http://opensource.org/licenses/AGPL-3.0
*/
use Combodo\iTop\Setup\ModuleDependency\DependencyExpression;
require_once __DIR__.'/ModuleInstallationException.php';
require_once(APPROOT.'/setup/moduledependency/module.class.inc.php');
class InstallationChoicesToModuleConverter
{
private static ?InstallationChoicesToModuleConverter $oInstance;
protected function __construct()
{
}
final public static function GetInstance(): InstallationChoicesToModuleConverter
{
if (!isset(self::$oInstance)) {
self::$oInstance = new InstallationChoicesToModuleConverter();
}
return self::$oInstance;
}
final public static function SetInstance(?InstallationChoicesToModuleConverter $oInstance): void
{
self::$oInstance = $oInstance;
}
/**
* @param array $aInstallationChoices
* @param array $aSearchDirs
*
* @return array
* @throws \ModuleInstallationException
*/
public function GetModules(array $aInstallationChoices, array $aSearchDirs, ?string $sInstallationFilePath = null): array
{
$aPackageModules = ModuleDiscovery::GetAllModules($aSearchDirs);
$bInstallationFileProvided = ! is_null($sInstallationFilePath) && is_file($sInstallationFilePath);
if ($bInstallationFileProvided) {
$oXMLParameters = new XMLParameters($sInstallationFilePath);
$aSteps = $oXMLParameters->Get('steps', []);
if (!is_array($aSteps)) {
return [];
}
$aInstalledModuleNames = $this->FindInstalledPackageModules($aPackageModules, $aInstallationChoices, $aSteps);
} else {
$aInstalledModuleNames = $this->FindInstalledPackageModules($aPackageModules, $aInstallationChoices);
}
$aInstalledModules = [];
foreach (array_keys($aPackageModules) as $sModuleId) {
list($sModuleName) = ModuleDiscovery::GetModuleName($sModuleId);
if (in_array($sModuleName, $aInstalledModuleNames)) {
$aInstalledModules[] = $sModuleId;
}
}
return $aInstalledModules;
}
private function FindInstalledPackageModules(array $aPackageModules, array $aInstallationChoices, array $aInstallationDescription = null): array
{
$aInstalledModules = [];
$this->ProcessDefaultModules($aPackageModules, $aInstalledModules);
if (is_null($aInstallationDescription)) {
//in legacy usecase: choices are flat modules list already
foreach ($aInstallationChoices as $sModuleName) {
$aInstalledModules[$sModuleName] = true;
}
} else {
$this->GetModuleNamesFromInstallationChoices($aInstallationChoices, $aInstallationDescription, $aInstalledModules);
}
$this->ProcessAutoSelectModules($aPackageModules, $aInstalledModules);
return array_keys($aInstalledModules);
}
private function IsDefaultModule(string $sModuleId, array $aModule): bool
{
if (($sModuleId === ROOT_MODULE)) {
return false;
}
if (isset($aModule['auto_select'])) {
return false;
}
if ($aModule['category'] === 'authentication') {
return true;
}
return !$aModule['visible'];
}
private function ProcessDefaultModules(array &$aPackageModules, array &$aInstalledModules): void
{
foreach ($aPackageModules as $sModuleId => $aModule) {
if ($this->IsDefaultModule($sModuleId, $aModule)) {
list($sModuleName) = ModuleDiscovery::GetModuleName($sModuleId);
$aInstalledModules[$sModuleName] = true;
unset($aPackageModules[$sModuleId]);
}
}
}
private function IsAutoSelectedModule(array $aInstalledModules, string $sModuleId, array $aModule): bool
{
if (($sModuleId === ROOT_MODULE)) {
return false;
}
if (!isset($aModule['auto_select'])) {
return false;
}
try {
SetupInfo::SetSelectedModules($aInstalledModules);
return DependencyExpression::GetPhpExpressionEvaluator()->ParseAndEvaluateBooleanExpression($aModule['auto_select']);
} catch (Exception $e) {
IssueLog::Error('Error evaluating module auto-select', null, [
'module' => $sModuleId,
'error' => $e->getMessage(),
'evaluated code' => $aModule['auto_select'],
'stacktrace' => $e->getTraceAsString(),
]);
}
return false;
}
private function ProcessAutoSelectModules(array $aPackageModules, array &$aInstalledModules): void
{
foreach ($aPackageModules as $sModuleId => $aModule) {
if ($this->IsAutoSelectedModule($aInstalledModules, $sModuleId, $aModule)) {
list($sModuleName) = ModuleDiscovery::GetModuleName($sModuleId);
$aInstalledModules[$sModuleName] = true;
}
}
}
private function GetModuleNamesFromInstallationChoices(array $aInstallationChoices, array $aInstallationDescription, array &$aModuleNames): void
{
foreach ($aInstallationDescription as $aStepInfo) {
$aOptions = $aStepInfo['options'] ?? null;
if (is_array($aOptions)) {
foreach ($aOptions as $aChoiceInfo) {
$this->ProcessSelectedChoice($aInstallationChoices, $aChoiceInfo, $aModuleNames);
}
}
$aOptions = $aStepInfo['alternatives'] ?? null;
if (is_array($aOptions)) {
foreach ($aOptions as $aChoiceInfo) {
$this->ProcessSelectedChoice($aInstallationChoices, $aChoiceInfo, $aModuleNames);
}
}
}
}
private function ProcessSelectedChoice(array $aInstallationChoices, array $aChoiceInfo, array &$aInstalledModules)
{
if (!is_array($aChoiceInfo)) {
return;
}
$sMandatory = $aChoiceInfo['mandatory'] ?? 'false';
$aCurrentModules = $aChoiceInfo['modules'] ?? [];
$sExtensionCode = $aChoiceInfo['extension_code'];
$bSelected = ($sMandatory === 'true') || in_array($sExtensionCode, $aInstallationChoices);
if (!$bSelected) {
return;
}
foreach ($aCurrentModules as $sModuleId) {
$aInstalledModules[$sModuleId] = true;
}
$aAlternatives = $aChoiceInfo['alternatives'] ?? null;
if (is_array($aAlternatives)) {
foreach ($aAlternatives as $aSubChoiceInfo) {
$this->ProcessSelectedChoice($aInstallationChoices, $aSubChoiceInfo, $aInstalledModules);
}
}
$aSubOptionsChoiceInfo = $aChoiceInfo['sub_options'] ?? null;
if (is_array($aSubOptionsChoiceInfo)) {
$aSubOptions = $aSubOptionsChoiceInfo['options'] ?? null;
if (is_array($aSubOptions)) {
foreach ($aSubOptions as $aSubChoiceInfo) {
$this->ProcessSelectedChoice($aInstallationChoices, $aSubChoiceInfo, $aInstalledModules);
}
}
$aSubAlternatives = $aSubOptionsChoiceInfo['alternatives'] ?? null;
if (is_array($aSubAlternatives)) {
foreach ($aSubAlternatives as $aSubChoiceInfo) {
$this->ProcessSelectedChoice($aInstallationChoices, $aSubChoiceInfo, $aInstalledModules);
}
}
}
}
}

View File

@@ -0,0 +1,5 @@
<?php
class ModuleInstallationException extends Exception
{
}

View File

@@ -0,0 +1,238 @@
<?php
class ModuleInstallationRepository
{
private static ModuleInstallationRepository $oInstance;
protected function __construct()
{
}
final public static function GetInstance(): ModuleInstallationRepository
{
if (!isset(self::$oInstance)) {
self::$oInstance = new ModuleInstallationRepository();
}
return self::$oInstance;
}
final public static function SetInstance(?ModuleInstallationRepository $oInstance): void
{
self::$oInstance = $oInstance;
}
private ?array $aSelectInstall = null;
/**
* @param \Config|null $oConfig
* @return array
*/
public function ReadComputeInstalledModules(?Config $oConfig): array
{
$aSelectInstall = [];
try {
$aSelectInstall = $this->ReadFromDB($oConfig);
} catch (MySQLException $e) {
// No database or erroneous information
}
return $this->ComputeInstalledModules($aSelectInstall);
}
/**
* @param \Config|null $oConfig
* @return array
* @throws \MySQLException
* @throws \MySQLQueryHasNoResultException
*/
public function ReadFromDB(?Config $oConfig): array
{
if (is_null($oConfig)) {
return [];
}
if (! is_null($this->aSelectInstall)) {
//test only
return $this->aSelectInstall;
}
CMDBSource::InitFromConfig($oConfig);
//read db module installations
$tableWithPrefix = $this->GetTableWithPrefix($oConfig);
$iRootId = CMDBSource::QueryToScalar("SELECT max(parent_id) FROM $tableWithPrefix");
// Get the latest installed modules, without the "root" ones (iTop version and datamodel version)
$sSQL = <<<SQL
SELECT * FROM $tableWithPrefix
WHERE
parent_id='$iRootId'
OR id='$iRootId'
SQL;
return CMDBSource::QueryToArray($sSQL);
}
private function GetTableWithPrefix(Config $oConfig)
{
$sPrefix = $oConfig->Get('db_subname');
if (utils::IsNullOrEmptyString($sPrefix)) {
return "priv_module_install";
}
return "{$sPrefix}priv_module_install";
}
/**
* @param \Config $oConfig
*
* @return array|false
*/
public function GetApplicationVersion(Config $oConfig)
{
try {
CMDBSource::InitFromConfig($oConfig);
$tableWithPrefix = $this->GetTableWithPrefix($oConfig);
$sSQLQuery = "SELECT * FROM $tableWithPrefix";
$aSelectInstall = CMDBSource::QueryToArray($sSQLQuery);
} catch (MySQLException $e) {
// No database or erroneous information
SetupLog::Error(
'Can not connect to the database',
null,
[
'host' => $oConfig->Get('db_host'),
'user' => $oConfig->Get('db_user'),
'pwd:' => $oConfig->Get('db_pwd'),
'db name' => $oConfig->Get('db_name'),
'msg' => $e->getMessage(),
]
);
return false;
}
$aResult = [];
// Scan the list of installed modules to get the version of the 'ROOT' module which holds the main application version
foreach ($aSelectInstall as $aInstall) {
$sModuleVersion = $aInstall['version'];
if ($sModuleVersion == '') {
// Though the version cannot be empty in iTop 2.0, it used to be possible
// therefore we have to put something here or the module will not be considered
// as being installed
$sModuleVersion = '0.0.0';
}
if ($aInstall['parent_id'] == 0) {
if ($aInstall['name'] == DATAMODEL_MODULE) {
$aResult['datamodel_version'] = $sModuleVersion;
$aComments = json_decode($aInstall['comment'], true);
if (is_array($aComments)) {
$aResult = array_merge($aResult, $aComments);
}
} else {
$aResult['product_name'] = $aInstall['name'];
$aResult['product_version'] = $sModuleVersion;
}
}
}
if (!array_key_exists('datamodel_version', $aResult)) {
// Versions prior to 2.0 did not record the version of the datamodel
// so assume that the datamodel version is equal to the application version
$aResult['datamodel_version'] = $aResult['product_version'];
}
SetupLog::Info(__METHOD__, null, ["product_name" => $aResult['product_name'], "product_version" => $aResult['product_version']]);
return count($aResult) == 0 ? false : $aResult;
}
private function ComputeInstalledModules(array $aSelectInstall): array
{
$aInstallByModule = []; // array of <module> => array ('installed' => timestamp, 'version' => <version>)
//module installation datetime is mostly the same for all modules
//unless there was issue recording things in DB
$sFirstDatetime = null;
$iFirstTime = -1;
foreach ($aSelectInstall as $aInstall) {
//$aInstall['comment']; // unsused
$sDatetime = $aInstall['installed'];
if (is_null($sFirstDatetime)) {
$sFirstDatetime = $sDatetime;
$iFirstTime = strtotime($sDatetime);
$iInstalled = $iFirstTime;
} elseif ($sDatetime === $sFirstDatetime) {
$iInstalled = $iFirstTime;
} else {
$sDatetime = $aInstall['installed'];
$iInstalled = strtotime($sDatetime);
}
$sModuleName = $aInstall['name'];
$sModuleVersion = $aInstall['version'];
if ($sModuleVersion == '') {
// Though the version cannot be empty in iTop 2.0, it used to be possible
// therefore we have to put something here or the module will not be considered
// as being installed
$sModuleVersion = '0.0.0';
}
if ($aInstall['parent_id'] == 0) {
$aInstallByModule[ROOT_MODULE] = [
'installed_version' => $sModuleVersion,
'installed' => $iInstalled,
'version' => $sModuleVersion,
];
} else {
$aInstallByModule[$sModuleName] = [
'installed' => $iInstalled,
'version' => $sModuleVersion,
];
}
}
return $aInstallByModule;
}
/**
* Return previous module installation. offset is applied on parent_id.
* @param $iOffset: by default (offset=0) returns current installation
* @return array
*/
public static function GetPreviousModuleInstallationsByOffset(int $iOffset = 0): array
{
$oFilter = DBObjectSearch::FromOQL('SELECT ModuleInstallation AS mi WHERE mi.parent_id=0 AND mi.name!="datamodel"');
$oSet = new DBObjectSet($oFilter, ['installed' => false]); // Most recent first
$oSet->SetLimit($iOffset + 1);
$iParentId = 0;
while (!is_null($oModuleInstallation = $oSet->Fetch())) {
/** @var \DBObject $oModuleInstallation */
if ($iOffset == 0) {
$iParentId = $oModuleInstallation->Get('id');
break;
}
$iOffset--;
}
if ($iParentId === 0) {
IssueLog::Error("no ITOP_APPLICATION ModuleInstallation found", null, ['offset' => $iOffset]);
throw new \Exception("no ITOP_APPLICATION ModuleInstallation found");
}
$oFilter = DBObjectSearch::FromOQL("SELECT ModuleInstallation AS mi WHERE mi.id=$iParentId OR mi.parent_id=$iParentId");
$oSet = new DBObjectSet($oFilter); // Most recent first
$aRawValues = $oSet->ToArrayOfValues();
$aValues = [];
foreach ($aRawValues as $aRawValue) {
$aValue = [];
foreach ($aRawValue as $sAliasAttCode => $sValue) {
// remove 'mi.' from AttCode
$sAttCode = substr($sAliasAttCode, 3);
$aValue[$sAttCode] = $sValue;
}
$aValues[] = $aValue;
}
return $aValues;
}
}

View File

@@ -0,0 +1,99 @@
<?php
// Copyright (C) 2010-2024 Combodo SAS
//
// This file is part of iTop.
//
// iTop is free software; you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// iTop is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with iTop. If not, see <http://www.gnu.org/licenses/>
/**
* Persistent class ModuleInstallation to record the installed modules
* Log of module installations
*
* @copyright Copyright (C) 2010-2024 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
class ModuleInstallation extends DBObject
{
public static function Init()
{
$aParams =
[
"category" => "core,view_in_gui",
"key_type" => "autoincrement",
'name_attcode' => ['name', 'version'],
"state_attcode" => "",
"reconc_keys" => [],
"db_table" => "priv_module_install",
"db_key_field" => "id",
"db_finalclass_field" => "",
'order_by_default' => ['installed' => false],
];
MetaModel::Init_Params($aParams);
//MetaModel::Init_InheritAttributes();
MetaModel::Init_AddAttribute(new AttributeString("name", ["allowed_values" => null, "sql" => "name", "default_value" => null, "is_null_allowed" => false, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeString("version", ["allowed_values" => null, "sql" => "version", "default_value" => null, "is_null_allowed" => true, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeDateTime("installed", ["allowed_values" => null, "sql" => "installed", "default_value" => null, "is_null_allowed" => true, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeText("comment", ["allowed_values" => null, "sql" => "comment", "default_value" => null, "is_null_allowed" => true, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeExternalKey("parent_id", ["targetclass" => "ModuleInstallation", "jointype" => "", "allowed_values" => null, "sql" => "parent_id", "is_null_allowed" => true, "on_target_delete" => DEL_MANUAL, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeEnum("uninstallable", ["allowed_values" => new ValueSetEnum('yes,no,maybe'), "sql" => "uninstallable", "default_value" => 'yes', "is_null_allowed" => false, "depends_on" => []]));
// Display lists
MetaModel::Init_SetZListItems('details', ['name', 'version', 'installed', 'comment', 'parent_id']); // Attributes to be displayed for the complete details
MetaModel::Init_SetZListItems('list', ['installed', 'comment']); // Attributes to be displayed for a list
// Search criteria
// MetaModel::Init_SetZListItems('standard_search', array('name')); // Criteria of the std search form
// MetaModel::Init_SetZListItems('advanced_search', array('name')); // Criteria of the advanced search form
}
}
/**
* Persistent class ExtensionInstallation to record the installed extensions
* Log of extensions installations
*
* @copyright Copyright (C) 2024 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
class ExtensionInstallation extends cmdbAbstractObject
{
public static function Init()
{
$aParams =
[
"category" => "core,view_in_gui",
"key_type" => "autoincrement",
"name_attcode" => "",
"state_attcode" => "",
"reconc_keys" => [],
"db_table" => "priv_extension_install",
"db_key_field" => "id",
"db_finalclass_field" => "",
];
MetaModel::Init_Params($aParams);
//MetaModel::Init_InheritAttributes();
MetaModel::Init_AddAttribute(new AttributeString("code", ["allowed_values" => null, "sql" => "code", "default_value" => null, "is_null_allowed" => false, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeString("label", ["allowed_values" => null, "sql" => "label", "default_value" => null, "is_null_allowed" => false, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeString("version", ["allowed_values" => null, "sql" => "version", "default_value" => null, "is_null_allowed" => false, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeString("source", ["allowed_values" => null, "sql" => "source", "default_value" => null, "is_null_allowed" => false, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeEnum("uninstallable", ["allowed_values" => new ValueSetEnum('yes,no,maybe'), "sql" => "uninstallable", "default_value" => 'yes', "is_null_allowed" => false, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeDateTime("installed", ["allowed_values" => null, "sql" => "installed", "default_value" => 'NOW()', "is_null_allowed" => false, "depends_on" => []]));
MetaModel::Init_AddAttribute(new AttributeText("description", ["allowed_values" => null, "sql" => "description", "default_value" => null, "is_null_allowed" => true, "depends_on" => []]));
// Display lists
MetaModel::Init_SetZListItems('details', ['code', 'label', 'version', 'installed', 'source']); // Attributes to be displayed for the complete details
MetaModel::Init_SetZListItems('list', ['code', 'label', 'version', 'installed', 'source']); // Attributes to be displayed for a list
MetaModel::Init_SetZListItems('standard_search', ['code', 'label', 'version', 'installed', 'source']); // Attributes to be displayed in the search form
}
}