diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 13:17:31 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 13:17:31 +0000 |
commit | f66ab8dae2f3d0418759f81a3a64dc9517a62449 (patch) | |
tree | fbff2135e7013f196b891bbde54618eb050e4aaf /library/Director/Web/Form | |
parent | Initial commit. (diff) | |
download | icingaweb2-module-director-f66ab8dae2f3d0418759f81a3a64dc9517a62449.tar.xz icingaweb2-module-director-f66ab8dae2f3d0418759f81a3a64dc9517a62449.zip |
Adding upstream version 1.10.2.upstream/1.10.2
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Director/Web/Form')
28 files changed, 5196 insertions, 0 deletions
diff --git a/library/Director/Web/Form/ClickHereForm.php b/library/Director/Web/Form/ClickHereForm.php new file mode 100644 index 0000000..abba9d7 --- /dev/null +++ b/library/Director/Web/Form/ClickHereForm.php @@ -0,0 +1,31 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use gipfl\Translation\TranslationHelper; +use gipfl\Web\InlineForm; + +class ClickHereForm extends InlineForm +{ + use TranslationHelper; + + protected $hasBeenClicked = false; + + protected function assemble() + { + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('here'), + 'class' => 'link-button' + ]); + } + + public function hasBeenClicked() + { + return $this->hasBeenClicked; + } + + public function onSuccess() + { + $this->hasBeenClicked = true; + } +} diff --git a/library/Director/Web/Form/CloneImportSourceForm.php b/library/Director/Web/Form/CloneImportSourceForm.php new file mode 100644 index 0000000..0849dd4 --- /dev/null +++ b/library/Director/Web/Form/CloneImportSourceForm.php @@ -0,0 +1,72 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Module\Director\Data\Exporter; +use ipl\Html\Form; +use ipl\Html\FormDecorator\DdDtDecorator; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Url; +use Icinga\Module\Director\Objects\ImportSource; + +class CloneImportSourceForm extends Form +{ + use TranslationHelper; + + /** @var ImportSource */ + protected $source; + + /** @var ImportSource|null */ + protected $newSource; + + public function __construct(ImportSource $source) + { + $this->setDefaultElementDecorator(new DdDtDecorator()); + $this->source = $source; + } + + protected function assemble() + { + $this->addElement('text', 'source_name', [ + 'label' => $this->translate('New name'), + 'value' => $this->source->get('source_name'), + ]); + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Clone') + ]); + } + + /** + * @return \Icinga\Module\Director\Db + */ + protected function getTargetDb() + { + return $this->source->getConnection(); + } + + /** + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function onSuccess() + { + $db = $this->getTargetDb(); + $export = (new Exporter($db))->export($this->source); + $newName = $this->getElement('source_name')->getValue(); + $export->source_name = $newName; + unset($export->originalId); + if (ImportSource::existsWithName($newName, $db)) { + $this->getElement('source_name')->addMessage('Name already exists'); + } + $this->newSource = ImportSource::import($export, $db); + $this->newSource->store(); + } + + public function getSuccessUrl() + { + if ($this->newSource === null) { + return parent::getSuccessUrl(); + } else { + return Url::fromPath('director/importsource', ['id' => $this->newSource->get('id')]); + } + } +} diff --git a/library/Director/Web/Form/CloneSyncRuleForm.php b/library/Director/Web/Form/CloneSyncRuleForm.php new file mode 100644 index 0000000..f90b593 --- /dev/null +++ b/library/Director/Web/Form/CloneSyncRuleForm.php @@ -0,0 +1,76 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Module\Director\Data\Exporter; +use ipl\Html\Form; +use ipl\Html\FormDecorator\DdDtDecorator; +use gipfl\Translation\TranslationHelper; +use gipfl\IcingaWeb2\Url; +use Icinga\Module\Director\Objects\SyncRule; + +class CloneSyncRuleForm extends Form +{ + use TranslationHelper; + + /** @var SyncRule */ + protected $rule; + + /** @var SyncRule|null */ + protected $newRule; + + public function __construct(SyncRule $rule) + { + $this->setDefaultElementDecorator(new DdDtDecorator()); + $this->rule = $rule; + } + + protected function assemble() + { + $this->addElement('text', 'rule_name', [ + 'label' => $this->translate('New name'), + 'value' => $this->rule->get('rule_name'), + ]); + $this->addElement('submit', 'submit', [ + 'label' => $this->translate('Clone') + ]); + } + + /** + * @return \Icinga\Module\Director\Db + */ + protected function getTargetDb() + { + return $this->rule->getConnection(); + } + + /** + * @throws \Icinga\Exception\NotFoundError + * @throws \Icinga\Module\Director\Exception\DuplicateKeyException + */ + public function onSuccess() + { + $db = $this->getTargetDb(); + $exporter = new Exporter($db); + + $export = $exporter->export($this->rule); + $newName = $this->getValue('rule_name'); + $export->rule_name = $newName; + unset($export->originalId); + + if (SyncRule::existsWithName($newName, $db)) { + $this->getElement('rule_name')->addMessage('Name already exists'); + } + $this->newRule = SyncRule::import($export, $db); + $this->newRule->store(); + } + + public function getSuccessUrl() + { + if ($this->newRule === null) { + return parent::getSuccessUrl(); + } else { + return Url::fromPath('director/syncrule', ['id' => $this->newRule->get('id')]); + } + } +} diff --git a/library/Director/Web/Form/CsrfToken.php b/library/Director/Web/Form/CsrfToken.php new file mode 100644 index 0000000..24edf88 --- /dev/null +++ b/library/Director/Web/Form/CsrfToken.php @@ -0,0 +1,53 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +class CsrfToken +{ + /** + * Check whether the given token is valid + * + * @param string $token Token + * + * @return bool + */ + public static function isValid($token) + { + if (strpos($token, '|') === false) { + return false; + } + + list($seed, $token) = explode('|', $elementValue); + + if (!is_numeric($seed)) { + return false; + } + + return $token === hash('sha256', self::getSessionId() . $seed); + } + + /** + * Create a new token + * + * @return string + */ + public static function generate() + { + $seed = mt_rand(); + $token = hash('sha256', self::getSessionId() . $seed); + + return sprintf('%s|%s', $seed, $token); + } + + /** + * Get current session id + * + * TODO: we should do this through our App or Session object + * + * @return string + */ + protected static function getSessionId() + { + return session_id(); + } +} diff --git a/library/Director/Web/Form/DbSelectorForm.php b/library/Director/Web/Form/DbSelectorForm.php new file mode 100644 index 0000000..52fe5ea --- /dev/null +++ b/library/Director/Web/Form/DbSelectorForm.php @@ -0,0 +1,85 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use gipfl\IcingaWeb2\Url; +use Icinga\Web\Response; +use ipl\Html\Form; +use Icinga\Web\Window; + +class DbSelectorForm extends Form +{ + protected $defaultAttributes = [ + 'class' => 'db-selector' + ]; + + protected $allowedNames; + + /** @var Window */ + protected $window; + + protected $response; + + public function __construct(Response $response, Window $window, $allowedNames) + { + $this->response = $response; + $this->window = $window; + $this->allowedNames = $allowedNames; + } + + protected function assemble() + { + $this->addElement('hidden', 'DbSelector', [ + 'value' => 'sent' + ]); + $this->addElement('select', 'db_resource', [ + 'options' => $this->allowedNames, + 'class' => 'autosubmit', + 'value' => $this->getSession()->get('db_resource') + ]); + } + + /** + * A base class should handle this, based on hidden fields + * + * @return bool + */ + public function hasBeenSubmitted() + { + return $this->hasBeenSent() && $this->getRequestParam('DbSelector') === 'sent'; + } + + public function onSuccess() + { + $this->getSession()->set('db_resource', $this->getElement('db_resource')->getValue()); + $this->response->redirectAndExit(Url::fromRequest($this->getRequest())); + } + + protected function getRequestParam($name, $default = null) + { + $request = $this->getRequest(); + if ($request === null) { + return $default; + } + if ($request->getMethod() === 'POST') { + $params = $request->getParsedBody(); + } elseif ($this->getMethod() === 'GET') { + parse_str($request->getUri()->getQuery(), $params); + } else { + $params = []; + } + + if (array_key_exists($name, $params)) { + return $params[$name]; + } + + return $default; + } + /** + * @return \Icinga\Web\Session\SessionNamespace + */ + protected function getSession() + { + return $this->window->getSessionNamespace('director'); + } +} diff --git a/library/Director/Web/Form/Decorator/ViewHelperRaw.php b/library/Director/Web/Form/Decorator/ViewHelperRaw.php new file mode 100644 index 0000000..a3aefbf --- /dev/null +++ b/library/Director/Web/Form/Decorator/ViewHelperRaw.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Decorator; + +use Zend_Form_Decorator_ViewHelper as ViewHelper; +use Zend_Form_Element as Element; + +class ViewHelperRaw extends ViewHelper +{ + public function getValue($element) + { + return $element->getUnfilteredValue(); + } +} diff --git a/library/Director/Web/Form/DirectorForm.php b/library/Director/Web/Form/DirectorForm.php new file mode 100644 index 0000000..145be5b --- /dev/null +++ b/library/Director/Web/Form/DirectorForm.php @@ -0,0 +1,58 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Module\Director\Db; + +abstract class DirectorForm extends QuickForm +{ + /** @var Db */ + protected $db; + + /** + * @param Db $db + * @return $this + */ + public function setDb(Db $db) + { + $this->db = $db; + return $this; + } + + /** + * @return Db + */ + public function getDb() + { + return $this->db; + } + + /** + * @return static + */ + public static function load() + { + return new static([ + 'icingaModule' => Icinga::App()->getModuleManager()->getModule('director') + ]); + } + + public function addBoolean($key, $options, $default = null) + { + if ($default === null) { + return $this->addElement('OptionalYesNo', $key, $options); + } else { + $this->addElement('YesNo', $key, $options); + return $this->getElement($key)->setValue($default); + } + } + + protected function optionalBoolean($key, $label, $description) + { + return $this->addBoolean($key, array( + 'label' => $label, + 'description' => $description + )); + } +} diff --git a/library/Director/Web/Form/DirectorObjectForm.php b/library/Director/Web/Form/DirectorObjectForm.php new file mode 100644 index 0000000..b70bd7b --- /dev/null +++ b/library/Director/Web/Form/DirectorObjectForm.php @@ -0,0 +1,1734 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Exception; +use gipfl\IcingaWeb2\Url; +use Icinga\Authentication\Auth; +use Icinga\Module\Director\Data\Db\DbObjectStore; +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Data\Db\DbObject; +use Icinga\Module\Director\Data\Db\DbObjectWithSettings; +use Icinga\Module\Director\Db\Branch\Branch; +use Icinga\Module\Director\Exception\NestingError; +use Icinga\Module\Director\Hook\IcingaObjectFormHook; +use Icinga\Module\Director\IcingaConfig\StateFilterSet; +use Icinga\Module\Director\IcingaConfig\TypeFilterSet; +use Icinga\Module\Director\Objects\IcingaTemplateChoice; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Util; +use Icinga\Module\Director\Web\Form\Element\ExtensibleSet; +use Icinga\Module\Director\Web\Form\Validate\NamePattern; +use Zend_Form_Element as ZfElement; +use Zend_Form_Element_Select as ZfSelect; + +abstract class DirectorObjectForm extends DirectorForm +{ + const GROUP_ORDER_OBJECT_DEFINITION = 20; + const GROUP_ORDER_RELATED_OBJECTS = 25; + const GROUP_ORDER_ASSIGN = 30; + const GROUP_ORDER_CHECK_EXECUTION = 40; + const GROUP_ORDER_CUSTOM_FIELDS = 50; + const GROUP_ORDER_CUSTOM_FIELD_CATEGORIES = 60; + const GROUP_ORDER_EVENT_FILTERS = 700; + const GROUP_ORDER_EXTRA_INFO = 750; + const GROUP_ORDER_CLUSTERING = 800; + const GROUP_ORDER_BUTTONS = 1000; + + /** @var IcingaObject */ + protected $object; + + /** @var Branch */ + protected $branch; + + protected $objectName; + + protected $className; + + protected $deleteButtonName; + + protected $displayGroups = []; + + protected $resolvedImports; + + protected $listUrl; + + /** @var Auth */ + private $auth; + + private $choiceElements = []; + + protected $preferredObjectType; + + /** @var IcingaObjectFieldLoader */ + protected $fieldLoader; + + private $allowsExperimental; + + private $presetImports; + + private $earlyProperties = array( + // 'imports', + 'check_command', + 'check_command_id', + 'has_agent', + 'command', + 'command_id', + 'event_command', + 'event_command_id', + ); + + public function setPreferredObjectType($type) + { + $this->preferredObjectType = $type; + return $this; + } + + public function setAuth(Auth $auth) + { + $this->auth = $auth; + return $this; + } + + public function getAuth() + { + if ($this->auth === null) { + $this->auth = Auth::getInstance(); + } + return $this->auth; + } + + protected function eventuallyAddNameRestriction($restrictionName) + { + $restrictions = $this->getAuth()->getRestrictions($restrictionName); + if (! empty($restrictions)) { + $this->getElement('object_name')->addValidator( + new NamePattern($restrictions) + ); + } + + return $this; + } + + public function presetImports($imports) + { + if (! empty($imports)) { + if (is_array($imports)) { + $this->presetImports = $imports; + } else { + $this->presetImports = array($imports); + } + } + + return $this; + } + + /** + * @return DbObject|DbObjectWithSettings|IcingaObject + */ + protected function object() + { + if ($this->object === null) { + $values = $this->getValues(); + /** @var DbObject|IcingaObject $class */ + $class = $this->getObjectClassname(); + if ($this->preferredObjectType) { + $values['object_type'] = $this->preferredObjectType; + } + if ($this->presetImports) { + $values['imports'] = $this->presetImports; + } + + $this->object = $class::create($values, $this->db); + } else { + if (! $this->object->hasConnection()) { + $this->object->setConnection($this->db); + } + } + + return $this->object; + } + + protected function extractChoicesFromPost($post) + { + $imports = []; + + foreach ($this->choiceElements as $other) { + $name = $other->getName(); + if (array_key_exists($name, $post)) { + $value = $post[$name]; + if (is_string($value)) { + $imports[] = $value; + } elseif (is_array($value)) { + foreach ($value as $chosen) { + $imports[] = $chosen; + } + } + } + } + + return $imports; + } + + protected function assertResolvedImports() + { + if ($this->resolvedImports !== null) { + return $this->resolvedImports; + } + + $object = $this->object; + + if (! $object instanceof IcingaObject) { + return $this->setResolvedImports(false); + } + if (! $object->supportsImports()) { + return $this->setResolvedImports(false); + } + + if ($this->hasBeenSent()) { + // prefill special properties, required to resolve fields and similar + $post = $this->getRequest()->getPost(); + + $key = 'imports'; + if ($el = $this->getElement($key)) { + if (array_key_exists($key, $post)) { + $imports = $post[$key]; + if (! is_array($imports)) { + $imports = array($imports); + } + $imports = array_filter(array_values(array_merge( + $imports, + $this->extractChoicesFromPost($post) + )), 'strlen'); + + /** @var ZfElement $el */ + $this->populate([$key => $imports]); + $el->setValue($imports); + if (! $this->tryToSetObjectPropertyFromElement($object, $el, $key)) { + return $this->resolvedImports = false; + } + } + } elseif ($this->presetImports) { + $imports = array_values(array_merge( + $this->presetImports, + $this->extractChoicesFromPost($post) + )); + if (! $this->eventuallySetImports($imports)) { + return $this->resolvedImports = false; + } + } else { + if (! empty($this->choiceElements)) { + if (! $this->eventuallySetImports($this->extractChoicesFromPost($post))) { + return $this->resolvedImports = false; + } + } + } + + foreach ($this->earlyProperties as $key) { + if ($el = $this->getElement($key)) { + if (array_key_exists($key, $post)) { + $this->populate([$key => $post[$key]]); + $this->tryToSetObjectPropertyFromElement($object, $el, $key); + } + } + } + } + + try { + $object->listAncestorIds(); + } catch (NestingError $e) { + $this->addUniqueErrorMessage($e->getMessage()); + return $this->resolvedImports = false; + } catch (Exception $e) { + $this->addException($e, 'imports'); + return $this->resolvedImports = false; + } + + return $this->setResolvedImports(); + } + + protected function eventuallySetImports($imports) + { + try { + $this->object()->set('imports', $imports); + return true; + } catch (Exception $e) { + $this->addException($e, 'imports'); + return false; + } + } + + protected function tryToSetObjectPropertyFromElement( + IcingaObject $object, + ZfElement $element, + $key + ) { + $old = null; + try { + $old = $object->get($key); + $object->set($key, $element->getValue()); + $object->resolveUnresolvedRelatedProperties(); + + if ($key === 'imports') { + $object->imports()->getObjects(); + } + return true; + } catch (Exception $e) { + if ($old !== null) { + $object->set($key, $old); + } + $this->addException($e, $key); + return false; + } + } + + public function setResolvedImports($resolved = true) + { + return $this->resolvedImports = $resolved; + } + + public function isObject() + { + return $this->getSentOrObjectValue('object_type') === 'object'; + } + + public function isTemplate() + { + return $this->getSentOrObjectValue('object_type') === 'template'; + } + + // TODO: move to a subform + protected function handleRanges(IcingaObject $object, &$values) + { + if (! $object->supportsRanges()) { + return; + } + + $key = 'ranges'; + $object = $this->object(); + + /* Sample: + + array( + 'monday' => 'eins', + 'tuesday' => '00:00-24:00', + 'sunday' => 'zwei', + ); + + */ + if (array_key_exists($key, $values)) { + $object->ranges()->set($values[$key]); + unset($values[$key]); + } + + foreach ($object->ranges()->getRanges() as $key => $value) { + $this->addRange($key, $value); + } + } + + protected function addToCheckExecutionDisplayGroup($elements) + { + return $this->addElementsToGroup( + $elements, + 'check_execution', + self::GROUP_ORDER_CHECK_EXECUTION, + $this->translate('Check execution') + ); + } + + public function addElementsToGroup($elements, $group, $order, $legend = null) + { + if (! is_array($elements)) { + $elements = array($elements); + } + + // These are optional elements, they might exist or not. We still want + // to see exception for other ones + $skipLegally = array('check_period_id'); + + $skip = array(); + foreach ($elements as $k => $v) { + if (is_string($v)) { + $el = $this->getElement($v); + if (!$el && in_array($v, $skipLegally)) { + $skip[] = $k; + continue; + } + + $elements[$k] = $el; + } + } + + foreach ($skip as $k) { + unset($elements[$k]); + } + + if (! array_key_exists($group, $this->displayGroups)) { + $this->addDisplayGroup($elements, $group, array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => $order, + 'legend' => $legend ?: $group, + )); + $this->displayGroups[$group] = $this->getDisplayGroup($group); + } else { + $this->displayGroups[$group]->addElements($elements); + } + + return $this->displayGroups[$group]; + } + + protected function handleProperties(DbObject $object, &$values) + { + if ($this->hasBeenSent()) { + foreach ($values as $key => $value) { + try { + if ($key === 'imports' && ! empty($this->choiceElements)) { + if (! is_array($value)) { + $value = [$value]; + } + foreach ($this->choiceElements as $element) { + $chosen = $element->getValue(); + if (is_string($chosen)) { + $value[] = $chosen; + } elseif (is_array($chosen)) { + foreach ($chosen as $choice) { + $value[] = $choice; + } + } + } + } + $object->set($key, $value); + if ($object instanceof IcingaObject) { + if ($this->resolvedImports !== false) { + $object->imports()->getObjects(); + } + } + } catch (Exception $e) { + $this->addException($e, $key); + } + } + } + } + + protected function loadInheritedProperties() + { + if ($this->assertResolvedImports()) { + try { + $this->showInheritedProperties($this->object()); + } catch (Exception $e) { + $this->addException($e); + } + } + } + + protected function showInheritedProperties(IcingaObject $object) + { + $inherited = $object->getInheritedProperties(); + $origins = $object->getOriginsProperties(); + + foreach ($inherited as $k => $v) { + if ($v !== null && $k !== 'object_name') { + $el = $this->getElement($k); + if ($el) { + $this->setInheritedValue($el, $inherited->$k, $origins->$k); + } elseif (substr($k, -3) === '_id') { + $k = substr($k, 0, -3); + $el = $this->getElement($k); + if ($el) { + $this->setInheritedValue( + $el, + $object->getRelatedObjectName($k, $v), + $origins->{"${k}_id"} + ); + } + } + } + } + } + + protected function prepareFields($object) + { + if ($this->assertResolvedImports()) { + $this->fieldLoader = new IcingaObjectFieldLoader($object); + $this->fieldLoader->prepareElements($this); + } + + return $this; + } + + protected function setCustomVarValues($values) + { + if ($this->fieldLoader) { + $this->fieldLoader->setValues($values, 'var_'); + } + + return $this; + } + + protected function addFields() + { + if ($this->fieldLoader) { + $this->fieldLoader->addFieldsToForm($this); + $this->onAddedFields(); + } + } + + protected function onAddedFields() + { + } + + // TODO: remove, used in sets I guess + protected function fieldLoader($object) + { + if ($this->fieldLoader === null) { + $this->fieldLoader = new IcingaObjectFieldLoader($object); + } + + return $this->fieldLoader; + } + + protected function isNew() + { + return $this->object === null || ! $this->object->hasBeenLoadedFromDb(); + } + + protected function setButtons() + { + if ($this->isNew()) { + $this->setSubmitLabel( + $this->translate('Add') + ); + } else { + $this->setSubmitLabel( + $this->translate('Store') + ); + $this->addDeleteButton(); + } + } + + /** + * @param bool $importsFirst + * @return $this + */ + protected function groupMainProperties($importsFirst = false) + { + if ($importsFirst) { + $elements = [ + 'imports', + 'object_type', + 'object_name', + ]; + } else { + $elements = [ + 'object_type', + 'object_name', + 'imports', + ]; + } + $elements = array_merge($elements, [ + 'display_name', + 'host', + 'host_id', + 'address', + 'address6', + 'groups', + 'inherited_groups', + 'applied_groups', + 'users', + 'user_groups', + 'apply_to', + 'command_id', // Notification + 'notification_interval', + 'period_id', + 'times_begin', + 'times_end', + 'email', + 'pager', + 'enable_notifications', + 'disable_checks', //Dependencies + 'disable_notifications', + 'ignore_soft_states', + 'apply_for', + 'create_live', + 'disabled', + ]); + + // Add template choices to the main section + /** @var \Zend_Form_Element $el */ + foreach ($this->getElements() as $key => $el) { + if (substr($el->getName(), 0, 6) === 'choice') { + $elements[] = $key; + } + } + + $this->addDisplayGroup($elements, 'object_definition', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_OBJECT_DEFINITION, + 'legend' => $this->translate('Main properties') + )); + + return $this; + } + + protected function setSentValue($name, $value) + { + if ($this->hasBeenSent()) { + $request = $this->getRequest(); + if ($value !== null && $request->isPost() && $request->getPost($name) !== null) { + $request->setPost($name, $value); + } + } + + $this->setElementValue($name, $value); + } + + public function setElementValue($name, $value = null) + { + $el = $this->getElement($name); + if (! $el) { + // Not showing an error, as most object properties do not exist. Not + // yet, because IMO this should be checked. + // $this->addError(sprintf($this->translate('Form element "%s" does not exist'), $name)); + return; + } + + if ($value !== null) { + $el->setValue($value); + } + } + + public function setInheritedValue(ZfElement $el, $inherited, $inheritedFrom) + { + if ($inherited === null) { + return; + } + + $txtInherited = sprintf($this->translate(' (inherited from "%s")'), $inheritedFrom); + if ($el instanceof ZfSelect) { + $multi = $el->getMultiOptions(); + if (is_bool($inherited)) { + $inherited = $inherited ? 'y' : 'n'; + } + if (is_scalar($inherited) && array_key_exists($inherited, $multi)) { + $multi[null] = $multi[$inherited] . $txtInherited; + } else { + $multi[null] = $this->stringifyInheritedValue($inherited) . $txtInherited; + } + $el->setMultiOptions($multi); + } elseif ($el instanceof ExtensibleSet) { + $el->setAttrib('inherited', $inherited); + $el->setAttrib('inheritedFrom', $inheritedFrom); + } else { + if (is_string($inherited) || is_int($inherited)) { + $el->setAttrib('placeholder', $inherited . $txtInherited); + } + } + + // We inherited a value, so no need to require the field + $el->setRequired(false); + } + + protected function stringifyInheritedValue($value) + { + return is_scalar($value) ? $value : substr(json_encode($value), 0, 40); + } + + public function setListUrl($url) + { + $this->listUrl = $url; + return $this; + } + + public function onSuccess() + { + $object = $this->object(); + if ($object->hasBeenModified()) { + if (! $object->hasBeenLoadedFromDb()) { + $this->setHttpResponseCode(201); + } + + $msg = sprintf( + $object->hasBeenLoadedFromDb() + ? $this->translate('The %s has successfully been stored') + : $this->translate('A new %s has successfully been created'), + $this->translate($this->getObjectShortClassName()) + ); + $this->getDbObjectStore()->store($object); + } else { + if ($this->isApiRequest()) { + $this->setHttpResponseCode(304); + } + $msg = $this->translate('No action taken, object has not been modified'); + } + + $this->setObjectSuccessUrl(); + $this->beforeSuccessfulRedirect(); + $this->redirectOnSuccess($msg); + } + + protected function setObjectSuccessUrl() + { + $object = $this->object(); + + if ($object instanceof IcingaObject) { + $params = $object->getUrlParams(); + $url = Url::fromPath($this->getAction()); + if ($url->hasParam('dbResourceName')) { + $params['dbResourceName'] = $url->getParam('dbResourceName'); + } + $this->setSuccessUrl( + 'director/' . strtolower($this->getObjectShortClassName()), + $params + ); + } elseif ($object->hasProperty('id')) { + $this->setSuccessUrl($this->getSuccessUrl()->with('id', $object->getProperty('id'))); + } + } + + protected function beforeSuccessfulRedirect() + { + } + + public function hasElement($name) + { + return $this->getElement($name) !== null; + } + + public function getObject() + { + return $this->object; + } + + public function hasObject() + { + return $this->object !== null; + } + + public function isIcingaObject() + { + if ($this->object !== null) { + return $this->object instanceof IcingaObject; + } + + /** @var DbObject $class */ + $class = $this->getObjectClassname(); + $instance = $class::create(); + + return $instance instanceof IcingaObject; + } + + public function isMultiObjectForm() + { + return false; + } + + public function setObject(DbObject $object) + { + $this->object = $object; + if ($this->db === null) { + /** @var Db $connection */ + $connection = $object->getConnection(); + $this->setDb($connection); + } + + return $this; + } + + protected function getObjectClassname() + { + if ($this->className === null) { + return 'Icinga\\Module\\Director\\Objects\\' + . substr(join('', array_slice(explode('\\', get_class($this)), -1)), 0, -4); + } + + return $this->className; + } + + protected function getObjectShortClassName() + { + if ($this->objectName === null) { + $className = substr(strrchr(get_class($this), '\\'), 1); + if (substr($className, 0, 6) === 'Icinga') { + return substr($className, 6, -4); + } else { + return substr($className, 0, -4); + } + } + + return $this->objectName; + } + + protected function removeFromSet(&$set, $key) + { + unset($set[$key]); + } + + protected function moveUpInSet(&$set, $key) + { + list($set[$key - 1], $set[$key]) = array($set[$key], $set[$key - 1]); + } + + protected function moveDownInSet(&$set, $key) + { + list($set[$key + 1], $set[$key]) = array($set[$key], $set[$key + 1]); + } + + protected function beforeSetup() + { + if (!$this->hasBeenSent()) { + return; + } + + $post = $values = $this->getRequest()->getPost(); + + foreach ($post as $key => $value) { + if (preg_match('/^(.+?)_(\d+)__(MOVE_DOWN|MOVE_UP|REMOVE)$/', $key, $m)) { + $values[$m[1]] = array_filter($values[$m[1]], 'strlen'); + switch ($m[3]) { + case 'MOVE_UP': + $this->moveUpInSet($values[$m[1]], $m[2]); + break; + case 'MOVE_DOWN': + $this->moveDownInSet($values[$m[1]], $m[2]); + break; + case 'REMOVE': + $this->removeFromSet($values[$m[1]], $m[2]); + break; + } + + $this->getRequest()->setPost($m[1], $values[$m[1]]); + } + } + } + + protected function onRequest() + { + if ($this->object !== null) { + $this->setDefaultsFromObject($this->object); + } + $this->prepareFields($this->object()); + IcingaObjectFormHook::callOnSetup($this); + if ($this->hasBeenSent()) { + $this->handlePost(); + } + try { + $this->loadInheritedProperties(); + $this->addFields(); + $this->callOnRequestCallables(); + } catch (Exception $e) { + $this->addUniqueException($e); + + return; + } + + if ($this->shouldBeDeleted()) { + $this->deleteObject($this->object()); + } + } + + protected function handlePost() + { + $object = $this->object(); + + $post = $this->getRequest()->getPost(); + $this->populate($post); + $values = $this->getValues(); + + if ($object instanceof IcingaObject) { + $this->setCustomVarValues($post); + } + + $this->handleProperties($object, $values); + + // TODO: get rid of this + if ($object instanceof IcingaObject) { + $this->handleRanges($object, $values); + } + } + + protected function setDefaultsFromObject(DbObject $object) + { + /** @var ZfElement $element */ + foreach ($this->getElements() as $element) { + $key = $element->getName(); + if ($object->hasProperty($key)) { + $value = $object->get($key); + if ($object instanceof IcingaObject) { + if ($object->propertyIsRelatedSet($key)) { + if (! count((array) $value)) { + continue; + } + } + } + + if ($value !== null && $value !== []) { + $element->setValue($value); + } + } + } + } + + protected function deleteObject($object) + { + if ($object instanceof IcingaObject && $object->hasProperty('object_name')) { + $msg = sprintf( + '%s "%s" has been removed', + $this->translate($this->getObjectShortClassName()), + $object->getObjectName() + ); + } else { + $msg = sprintf( + '%s has been removed', + $this->translate($this->getObjectShortClassName()) + ); + } + + if ($this->listUrl) { + $url = $this->listUrl; + } elseif ($object instanceof IcingaObject && $object->hasProperty('object_name')) { + $url = $object->getOnDeleteUrl(); + } else { + $url = $this->getSuccessUrl()->without( + array('field_id', 'argument_id', 'range', 'range_type') + ); + } + + if ($this->getDbObjectStore()->delete($object)) { + $this->setSuccessUrl($url); + } + $this->redirectOnSuccess($msg); + } + + /** + * @return DbObjectStore + */ + protected function getDbObjectStore() + { + $store = new DbObjectStore($this->getDb(), $this->branch); + return $store; + } + + protected function addDeleteButton($label = null) + { + $object = $this->object; + + if ($label === null) { + $label = $this->translate('Delete'); + } + + $el = $this->createElement('submit', $label) + ->setLabel($label) + ->setDecorators(array('ViewHelper')); + //->removeDecorator('Label'); + + $this->deleteButtonName = $el->getName(); + + if ($object instanceof IcingaObject && $object->isTemplate()) { + if ($cnt = $object->countDirectDescendants()) { + $el->setAttrib('disabled', 'disabled'); + $el->setAttrib( + 'title', + sprintf( + $this->translate('This template is still in use by %d other objects'), + $cnt + ) + ); + } + } elseif ($object instanceof IcingaCommand && $object->isInUse()) { + $el->setAttrib('disabled', 'disabled'); + $el->setAttrib( + 'title', + sprintf( + $this->translate('This Command is still in use by %d other objects'), + $object->countDirectUses() + ) + ); + } + + $this->addElement($el); + + return $this; + } + + public function hasDeleteButton() + { + return $this->deleteButtonName !== null; + } + + public function shouldBeDeleted() + { + if (! $this->hasDeleteButton()) { + return false; + } + + $name = $this->deleteButtonName; + return $this->getSentValue($name) === $this->getElement($name)->getLabel(); + } + + public function abortDeletion() + { + if ($this->hasDeleteButton()) { + $this->setSentValue($this->deleteButtonName, 'ABORTED'); + } + } + + public function getSentOrResolvedObjectValue($name, $default = null) + { + return $this->getSentOrObjectValue($name, $default, true); + } + + public function getSentOrObjectValue($name, $default = null, $resolved = false) + { + // TODO: check whether getSentValue is still needed since element->getValue + // is in place (currently for form element default values only) + + if (!$this->hasObject()) { + if ($this->hasBeenSent()) { + return $this->getSentValue($name, $default); + } else { + if ($name === 'object_type' && $this->preferredObjectType) { + return $this->preferredObjectType; + } + if ($name === 'imports' && $this->presetImports) { + return $this->presetImports; + } + if ($this->valueIsEmpty($val = $this->getValue($name))) { + return $default; + } else { + return $val; + } + } + } + + if ($this->hasBeenSent()) { + if (!$this->valueIsEmpty($value = $this->getSentValue($name))) { + return $value; + } + } + + $object = $this->getObject(); + + if ($object->hasProperty($name)) { + if ($resolved && $object->supportsImports()) { + if ($this->assertResolvedImports()) { + $objectProperty = $object->getResolvedProperty($name); + } else { + $objectProperty = $object->$name; + } + } else { + $objectProperty = $object->$name; + } + } else { + $objectProperty = null; + } + + if ($objectProperty !== null) { + return $objectProperty; + } + + if (($el = $this->getElement($name)) && !$this->valueIsEmpty($val = $el->getValue())) { + return $val; + } + + return $default; + } + + public function loadObject($id) + { + if ($this->branch && $this->branch->isBranch()) { + throw new \RuntimeException('Calling loadObject from form in a branch'); + } + /** @var DbObject $class */ + $class = $this->getObjectClassname(); + if (is_int($id)) { + $this->object = $class::loadWithAutoIncId($id, $this->db); + if ($this->object->getKeyName() === 'id') { + $this->addHidden('id', $id); + } + } else { + $this->object = $class::load($id, $this->db); + } + + + return $this; + } + + protected function addRange($key, $range) + { + $this->addElement('text', 'range_' . $key, array( + 'label' => 'ranges.' . $key, + 'value' => $range->range_value + )); + } + + /** + * @param Db $db + * @return $this + */ + public function setDb(Db $db) + { + if ($this->object !== null) { + $this->object->setConnection($db); + } + + parent::setDb($db); + return $this; + } + + public function optionallyAddFromEnum($enum) + { + return array( + null => $this->translate('- click to add more -') + ) + $enum; + } + + protected function addObjectTypeElement() + { + if (!$this->isNew()) { + return $this; + } + + if ($this->preferredObjectType) { + $this->addHidden('object_type', $this->preferredObjectType); + return $this; + } + + $object = $this->object(); + + if ($object->supportsImports()) { + $templates = $this->enumAllowedTemplates(); + + if (empty($templates) && $this->getObjectShortClassName() !== 'Command') { + $types = array('template' => $this->translate('Template')); + } else { + $types = array( + 'object' => $this->translate('Object'), + 'template' => $this->translate('Template'), + ); + } + } else { + $types = array('object' => $this->translate('Object')); + } + + if ($this->object()->supportsApplyRules()) { + $types['apply'] = $this->translate('Apply rule'); + } + + $this->addElement('select', 'object_type', array( + 'label' => $this->translate('Object type'), + 'description' => $this->translate( + 'What kind of object this should be. Templates allow full access' + . ' to any property, they are your building blocks for "real" objects.' + . ' External objects should usually not be manually created or modified.' + . ' They allow you to work with objects locally defined on your Icinga nodes,' + . ' while not rendering and deploying them with the Director. Apply rules allow' + . ' to assign services, notifications and groups to other objects.' + ), + 'required' => true, + 'multiOptions' => $this->optionalEnum($types), + 'class' => 'autosubmit' + )); + + return $this; + } + + protected function hasObjectType() + { + if (!$this->object()->hasProperty('object_type')) { + return false; + } + + return ! $this->valueIsEmpty($this->getSentOrObjectValue('object_type')); + } + + protected function addZoneElement($all = false) + { + if ($all || $this->isTemplate()) { + $zones = $this->db->enumZones(); + } else { + $zones = $this->db->enumNonglobalZones(); + } + + $this->addElement('select', 'zone_id', array( + 'label' => $this->translate('Cluster Zone'), + 'description' => $this->translate( + 'Icinga cluster zone. Allows to manually override Directors decisions' + . ' of where to deploy your config to. You should consider not doing so' + . ' unless you gained deep understanding of how an Icinga Cluster stack' + . ' works' + ), + 'multiOptions' => $this->optionalEnum($zones) + )); + + return $this; + } + + /** + * @param $type + * @return $this + */ + protected function addChoices($type) + { + if ($this->isTemplate()) { + return $this; + } + + $connection = $this->getDb(); + $choiceType = 'TemplateChoice' . ucfirst($type); + $table = "icinga_$type"; + $choices = IcingaObject::loadAllByType($choiceType, $connection); + $chosenTemplates = $this->getSentOrObjectValue('imports'); + $db = $connection->getDbAdapter(); + if (empty($chosenTemplates)) { + $importedIds = []; + } else { + $importedIds = $db->fetchCol( + $db->select()->from($table, 'id') + ->where('object_name in (?)', (array)$chosenTemplates) + ->where('object_type = ?', 'template') + ); + } + + foreach ($choices as $choice) { + $required = $choice->get('required_template_id'); + if ($required === null || in_array($required, $importedIds, false)) { + $this->addChoiceElement($choice); + } + } + + return $this; + } + + protected function addChoiceElement(IcingaTemplateChoice $choice) + { + $imports = $this->object()->listImportNames(); + $element = $choice->createFormElement($this, $imports); + $this->addElement($element); + $this->choiceElements[$element->getName()] = $element; + return $this; + } + + /** + * @param bool $required + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addImportsElement($required = null) + { + if ($this->presetImports) { + return $this; + } + + if (in_array($this->getObjectShortClassName(), ['TimePeriod', 'ScheduledDowntime'])) { + $required = false; + } else { + $required = $required !== null ? $required : !$this->isTemplate(); + } + $enum = $this->enumAllowedTemplates(); + if (empty($enum)) { + if ($required) { + if ($this->hasBeenSent()) { + $this->addError($this->translate('No template has been chosen')); + } else { + if ($this->hasPermission('director/admin')) { + $html = $this->translate('Please define a related template first'); + } else { + $html = $this->translate('No related template has been provided yet'); + } + $this->addHtml('<p class="warning">' . $html . '</p>'); + } + } + return $this; + } + + $db = $this->getDb()->getDbAdapter(); + $object = $this->object; + if ($object->supportsChoices()) { + $choiceNames = $db->fetchCol( + $db->select()->from( + $this->object()->getTableName(), + 'object_name' + )->where('template_choice_id IS NOT NULL') + ); + } else { + $choiceNames = []; + } + + $type = $object->getShortTableName(); + $this->addElement('extensibleSet', 'imports', array( + 'label' => $this->translate('Imports'), + 'description' => $this->translate( + 'Importable templates, add as many as you want. Please note that order' + . ' matters when importing properties from multiple templates: last one' + . ' wins' + ), + 'required' => $required, + 'spellcheck' => 'false', + 'hideOptions' => $choiceNames, + 'suggest' => "${type}templates", + // 'multiOptions' => $this->optionallyAddFromEnum($enum), + 'sorted' => true, + 'value' => $this->presetImports, + 'class' => 'autosubmit' + )); + + return $this; + } + + protected function addDisabledElement() + { + if ($this->isTemplate()) { + return $this; + } + + $this->addBoolean( + 'disabled', + array( + 'label' => $this->translate('Disabled'), + 'description' => $this->translate('Disabled objects will not be deployed') + ), + 'n' + ); + + return $this; + } + + /** + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addGroupDisplayNameElement() + { + $this->addElement('text', 'display_name', array( + 'label' => $this->translate('Display Name'), + 'description' => $this->translate( + 'An alternative display name for this group. If you wonder how this' + . ' could be helpful just leave it blank' + ) + )); + + return $this; + } + + /** + * @param bool $force + * + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addCheckCommandElements($force = false) + { + if (! $force && ! $this->isTemplate()) { + return $this; + } + + $this->addElement('text', 'check_command', array( + 'label' => $this->translate('Check command'), + 'description' => $this->translate('Check command definition'), + // 'multiOptions' => $this->optionalEnum($this->db->enumCheckcommands()), + 'class' => 'autosubmit director-suggest', // This influences fields + 'data-suggestion-context' => 'checkcommandnames', + 'value' => $this->getObject()->get('check_command') + )); + $this->getDisplayGroup('object_definition') + // ->addElement($this->getElement('check_command_id')) + ->addElement($this->getElement('check_command')); + + $eventCommands = $this->db->enumEventcommands(); + + if (! empty($eventCommands)) { + $this->addElement('select', 'event_command_id', array( + 'label' => $this->translate('Event command'), + 'description' => $this->translate('Event command definition'), + 'multiOptions' => $this->optionalEnum($eventCommands), + 'class' => 'autosubmit', + )); + $this->addToCheckExecutionDisplayGroup('event_command_id'); + } + + return $this; + } + + protected function addCheckExecutionElements($force = false) + { + if (! $force && ! $this->isTemplate()) { + return $this; + } + + $this->addElement( + 'text', + 'check_interval', + array( + 'label' => $this->translate('Check interval'), + 'description' => $this->translate('Your regular check interval') + ) + ); + + $this->addElement( + 'text', + 'retry_interval', + array( + 'label' => $this->translate('Retry interval'), + 'description' => $this->translate( + 'Retry interval, will be applied after a state change unless the next hard state is reached' + ) + ) + ); + + $this->addElement( + 'text', + 'max_check_attempts', + array( + 'label' => $this->translate('Max check attempts'), + 'description' => $this->translate( + 'Defines after how many check attempts a new hard state is reached' + ) + ) + ); + + $this->addElement( + 'text', + 'check_timeout', + array( + 'label' => $this->translate('Check timeout'), + 'description' => $this->translate( + "Check command timeout in seconds. Overrides the CheckCommand's timeout attribute" + ) + ) + ); + + $periods = $this->db->enumTimeperiods(); + + if (!empty($periods)) { + $this->addElement( + 'select', + 'check_period_id', + array( + 'label' => $this->translate('Check period'), + 'description' => $this->translate( + 'The name of a time period which determines when this' + . ' object should be monitored. Not limited by default.' + ), + 'multiOptions' => $this->optionalEnum($periods), + ) + ); + } + + $this->optionalBoolean( + 'enable_active_checks', + $this->translate('Execute active checks'), + $this->translate('Whether to actively check this object') + ); + + $this->optionalBoolean( + 'enable_passive_checks', + $this->translate('Accept passive checks'), + $this->translate('Whether to accept passive check results for this object') + ); + + $this->optionalBoolean( + 'enable_notifications', + $this->translate('Send notifications'), + $this->translate('Whether to send notifications for this object') + ); + + $this->optionalBoolean( + 'enable_event_handler', + $this->translate('Enable event handler'), + $this->translate('Whether to enable event handlers this object') + ); + + $this->optionalBoolean( + 'enable_perfdata', + $this->translate('Process performance data'), + $this->translate('Whether to process performance data provided by this object') + ); + + $this->optionalBoolean( + 'enable_flapping', + $this->translate('Enable flap detection'), + $this->translate('Whether flap detection is enabled on this object') + ); + + $this->addElement( + 'text', + 'flapping_threshold_high', + array( + 'label' => $this->translate('Flapping threshold (high)'), + 'description' => $this->translate( + 'Flapping upper bound in percent for a service to be considered flapping' + ) + ) + ); + + $this->addElement( + 'text', + 'flapping_threshold_low', + array( + 'label' => $this->translate('Flapping threshold (low)'), + 'description' => $this->translate( + 'Flapping lower bound in percent for a service to be considered not flapping' + ) + ) + ); + + $this->optionalBoolean( + 'volatile', + $this->translate('Volatile'), + $this->translate('Whether this check is volatile.') + ); + + $elements = array( + 'check_interval', + 'retry_interval', + 'max_check_attempts', + 'check_timeout', + 'check_period_id', + 'enable_active_checks', + 'enable_passive_checks', + 'enable_notifications', + 'enable_event_handler', + 'enable_perfdata', + 'enable_flapping', + 'flapping_threshold_high', + 'flapping_threshold_low', + 'volatile' + ); + $this->addToCheckExecutionDisplayGroup($elements); + + return $this; + } + + protected function enumAllowedTemplates() + { + $object = $this->object(); + $tpl = $this->db->enumIcingaTemplates($object->getShortTableName()); + if (empty($tpl)) { + return []; + } + + $id = $object->get('id'); + + if (array_key_exists($id, $tpl)) { + unset($tpl[$id]); + } + + return array_combine($tpl, $tpl); + } + + protected function addExtraInfoElements() + { + $this->addElement('textarea', 'notes', array( + 'label' => $this->translate('Notes'), + 'description' => $this->translate( + 'Additional notes for this object' + ), + 'rows' => 2, + 'columns' => 60, + )); + + $this->addElement('text', 'notes_url', array( + 'label' => $this->translate('Notes URL'), + 'description' => $this->translate( + 'An URL pointing to additional notes for this object' + ), + )); + + $this->addElement('text', 'action_url', array( + 'label' => $this->translate('Action URL'), + 'description' => $this->translate( + 'An URL leading to additional actions for this object. Often used' + . ' with Icinga Classic, rarely with Icinga Web 2 as it provides' + . ' far better possibilities to integrate addons' + ), + )); + + $this->addElement('text', 'icon_image', array( + 'label' => $this->translate('Icon image'), + 'description' => $this->translate( + 'An URL pointing to an icon for this object. Try "tux.png" for icons' + . ' relative to public/img/icons or "cloud" (no extension) for items' + . ' from the Icinga icon font' + ), + )); + + $this->addElement('text', 'icon_image_alt', array( + 'label' => $this->translate('Icon image alt'), + 'description' => $this->translate( + 'Alternative text to be shown in case above icon is missing' + ), + )); + + $elements = array( + 'notes', + 'notes_url', + 'action_url', + 'icon_image', + 'icon_image_alt', + ); + + $this->addDisplayGroup($elements, 'extrainfo', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_EXTRA_INFO, + 'legend' => $this->translate('Additional properties') + )); + + return $this; + } + + /** + * Add an assign_filter form element + * + * Forms should use this helper method for objects using the typical + * assign_filter column + * + * @param array $properties Form element properties + * + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addAssignFilter($properties) + { + if (!$this->object || !$this->object->supportsAssignments()) { + return $this; + } + + $this->addFilterElement('assign_filter', $properties); + $el = $this->getElement('assign_filter'); + + $this->addDisplayGroup(array($el), 'assign', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_ASSIGN, + 'legend' => $this->translate('Assign where') + )); + + return $this; + } + + /** + * Add a dataFilter element with fitting decorators + * + * TODO: Evaluate whether parts or all of this could be moved to the element + * class. + * + * @param string $name Element name + * @param array $properties Form element properties + * + * @return $this + * @throws \Zend_Form_Exception + */ + protected function addFilterElement($name, $properties) + { + $this->addElement('dataFilter', $name, $properties); + $el = $this->getElement($name); + + $ddClass = 'full-width'; + if (array_key_exists('required', $properties) && $properties['required']) { + $ddClass .= ' required'; + } + + $el->clearDecorators() + ->addDecorator('ViewHelper') + ->addDecorator('Errors') + ->addDecorator('Description', array('tag' => 'p', 'class' => 'description')) + ->addDecorator('HtmlTag', array( + 'tag' => 'dd', + 'class' => $ddClass, + )); + + return $this; + } + + protected function addEventFilterElements($elements = array('states','types')) + { + if (in_array('states', $elements)) { + $this->addElement('extensibleSet', 'states', array( + 'label' => $this->translate('States'), + 'multiOptions' => $this->optionallyAddFromEnum($this->enumStates()), + 'description' => $this->translate( + 'The host/service states you want to get notifications for' + ), + )); + } + + if (in_array('types', $elements)) { + $this->addElement('extensibleSet', 'types', array( + 'label' => $this->translate('Transition types'), + 'multiOptions' => $this->optionallyAddFromEnum($this->enumTypes()), + 'description' => $this->translate( + 'The state transition types you want to get notifications for' + ), + )); + } + + $this->addDisplayGroup($elements, 'event_filters', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ), + 'order' => self::GROUP_ORDER_EVENT_FILTERS, + 'legend' => $this->translate('State and transition type filters') + )); + + return $this; + } + + /** + * @param string $permission + * @return bool + */ + public function hasPermission($permission) + { + return Util::hasPermission($permission); + } + + public function setBranch(Branch $branch) + { + $this->branch = $branch; + + return $this; + } + + protected function allowsExperimental() + { + // NO, it is NOT a good idea to use this. You'll break your monitoring + // and nobody will help you. + if ($this->allowsExperimental === null) { + $this->allowsExperimental = $this->db->settings()->get( + 'experimental_features' + ) === 'allow'; + } + + return $this->allowsExperimental; + } + + protected function enumStates() + { + $set = new StateFilterSet(); + return $set->enumAllowedValues(); + } + + protected function enumTypes() + { + $set = new TypeFilterSet(); + return $set->enumAllowedValues(); + } +} diff --git a/library/Director/Web/Form/Element/Boolean.php b/library/Director/Web/Form/Element/Boolean.php new file mode 100644 index 0000000..b2402c7 --- /dev/null +++ b/library/Director/Web/Form/Element/Boolean.php @@ -0,0 +1,90 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Zend_Form_Element_Select as ZfSelect; + +/** + * Input control for booleans + */ +class Boolean extends ZfSelect +{ + public $options = array( + null => '- please choose -', + 'y' => 'Yes', + 'n' => 'No', + ); + + public function getValue() + { + $value = $this->getUnfilteredValue(); + + if ($value === 'y' || $value === true) { + return true; + } elseif ($value === 'n' || $value === false) { + return false; + } + + return null; + } + + public function isValid($value, $context = null) + { + if ($value === 'y' || $value === 'n') { + $this->setValue($value); + return true; + } + + return parent::isValid($value, $context); + } + + /** + * @param string $value + * @param string $key + * @codingStandardsIgnoreStart + */ + protected function _filterValue(&$value, &$key) + { + // @codingStandardsIgnoreEnd + if ($value === true) { + $value = 'y'; + } elseif ($value === false) { + $value = 'n'; + } elseif ($value === '') { + $value = null; + } + + parent::_filterValue($value, $key); + } + + public function setValue($value) + { + if ($value === true) { + $value = 'y'; + } elseif ($value === false) { + $value = 'n'; + } elseif ($value === '') { + $value = null; + } + + return parent::setValue($value); + } + + /** + * @codingStandardsIgnoreStart + */ + protected function _translateOption($option, $value) + { + // @codingStandardsIgnoreEnd + if (!isset($this->_translated[$option]) && !empty($value)) { + $this->options[$option] = mt('director', $value); + if ($this->options[$option] === $value) { + return false; + } + $this->_translated[$option] = true; + return true; + } + + return false; + } +} diff --git a/library/Director/Web/Form/Element/DataFilter.php b/library/Director/Web/Form/Element/DataFilter.php new file mode 100644 index 0000000..adae07d --- /dev/null +++ b/library/Director/Web/Form/Element/DataFilter.php @@ -0,0 +1,361 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\Web\Form\IconHelper; +use Exception; + +/** + * Input control for extensible sets + */ +class DataFilter extends FormElement +{ + /** + * Default form view helper to use for rendering + * @var string + */ + public $helper = 'formDataFilter'; + + private $addTo; + + private $removeFilter; + + private $stripFilter; + + /** @var FilterChain */ + private $filter; + + public function getValue() + { + $value = parent::getValue(); + if ($value !== null && $this->isEmpty($value)) { + $value = null; + } + + return $value; + } + + protected function isEmpty(Filter $filter) + { + return $filter->isEmpty() || $this->isEmptyExpression($filter); + } + + protected function isEmptyExpression(Filter $filter) + { + return $filter instanceof FilterExpression && + $filter->getColumn() === '' && + $filter->getExpression() === '""'; // -> json_encode('') + } + + /** + * @inheritdoc + * @codingStandardsIgnoreStart + */ + protected function _filterValue(&$value, &$key) + { + // @codingStandardsIgnoreEnd + try { + if ($value instanceof Filter) { + // OK + } elseif (is_string($value)) { + $value = Filter::fromQueryString($value); + } elseif (is_array($value) || is_null($value)) { + $value = $this->arrayToFilter($value); + } else { + throw new ProgrammingError( + 'Value to be filtered has to be Filter, string, array or null' + ); + } + } catch (Exception $e) { + $value = null; + // TODO: getFile, getLine + // Hint: cannot addMessage at it would loop through getValue + $this->addErrorMessage($e->getMessage()); + $this->_isErrorForced = true; + } + } + + /** + * This method transforms filter form data into a filter + * and reacts on pressed buttons + * + * @param array|null $array + * + * @return FilterChain|null + */ + protected function arrayToFilter($array) + { + if ($array === null) { + return null; + } + + $this->filter = null; + foreach ($array as $id => $entry) { + $filterId = $this->idToFilterId($id); + $sub = $this->entryToFilter($entry); + $this->checkEntryForActions($filterId, $entry); + $parentId = $this->parentIdFor($filterId); + + if ($this->filter === null) { + $this->filter = $sub; + } else { + $this->filter->getById($parentId)->addFilter($sub); + } + } + + $this->removeFilterIfRequested() + ->stripFilterIfRequested() + ->addNewFilterIfRequested() + ->fixNotsWithMultipleChildren(); + + return $this->filter; + } + + protected function removeFilterIfRequested() + { + if ($this->removeFilter !== null) { + if ($this->filter->getById($this->removeFilter)->isRootNode()) { + $this->filter = $this->emptyExpression(); + } else { + $this->filter->removeId($this->removeFilter); + } + } + + return $this; + } + + + protected function stripFilterIfRequested() + { + if ($this->stripFilter !== null) { + $strip = $this->stripFilter; + $subId = $strip . '-1'; + if ($this->filter->getId() === $strip) { + $this->filter = $this->filter->getById($subId); + } else { + $this->filter->replaceById($strip, $this->filter->getById($subId)); + } + } + + return $this; + } + + protected function addNewFilterIfRequested() + { + if ($this->addTo !== null) { + $parent = $this->filter->getById($this->addTo); + + if ($parent instanceof FilterChain) { + if ($parent->isEmpty()) { + $parent->addFilter($this->emptyExpression()); + } else { + $parent->addFilter($this->emptyExpression()); + } + } elseif ($parent instanceof FilterExpression) { + $replacement = Filter::matchAll(clone($parent)); + if ($parent->isRootNode()) { + $this->filter = $replacement; + } else { + $this->filter->replaceById($parent->getId(), $replacement); + } + } + } + + return $this; + } + + protected function fixNotsWithMultipleChildren() + { + $this->filter = $this->fixNotsWithMultipleChildrenForFilter($this->filter); + return $this; + } + + protected function fixNotsWithMultipleChildrenForFilter(Filter $filter) + { + if ($filter instanceof FilterChain) { + if ($filter->getOperatorName() === 'NOT') { + if ($filter->count() > 1) { + $filter = $this->notToNotAnd($filter); + } + } + /** @var Filter $sub */ + foreach ($filter->filters() as $sub) { + $filter->replaceById( + $sub->getId(), + $this->fixNotsWithMultipleChildrenForFilter($sub) + ); + } + } + + return $filter; + } + + protected function notToNotAnd(FilterChain $not) + { + $and = Filter::matchAll(); + foreach ($not->filters() as $sub) { + $and->addFilter(clone($sub)); + } + + return Filter::not($and); + } + + protected function emptyExpression() + { + return Filter::expression('', '=', ''); + } + + protected function parentIdFor($id) + { + if (false === ($pos = strrpos($id, '-'))) { + return '0'; + } else { + return substr($id, 0, $pos); + } + } + + protected function idToFilterId($id) + { + if (! preg_match('/^id_(new_)?(\d+(?:-\d+)*)$/', $id, $m)) { + die('nono' . $id); + } + + return $m[2]; + } + + protected function checkEntryForActions($filterId, $entry) + { + switch ($this->entryAction($entry)) { + case 'cancel': + $this->removeFilter = $filterId; + break; + + case 'minus': + $this->stripFilter = $filterId; + break; + + case 'plus': + case 'angle-double-right': + $this->addTo = $filterId; + break; + } + } + + /** + * Transforms a single submitted form component from an array + * into a Filter object + * + * @param array $entry The array as submitted through the form + * + * @return Filter + */ + protected function entryToFilter($entry) + { + if (array_key_exists('operator', $entry)) { + return Filter::chain($entry['operator']); + } else { + return $this->entryToFilterExpression($entry); + } + } + + protected function entryToFilterExpression($entry) + { + if ($entry['sign'] === 'true') { + return Filter::expression( + $entry['column'], + '=', + json_encode(true) + ); + } elseif ($entry['sign'] === 'false') { + return Filter::expression( + $entry['column'], + '=', + json_encode(false) + ); + } elseif ($entry['sign'] === 'in') { + if (array_key_exists('value', $entry)) { + if (is_array($entry['value'])) { + $value = array_filter($entry['value'], 'strlen'); + } elseif (empty($entry['value'])) { + $value = array(); + } else { + $value = array($entry['value']); + } + } else { + $value = array(); + } + return Filter::expression( + $entry['column'], + '=', + json_encode($value) + ); + } elseif ($entry['sign'] === 'contains') { + $value = array_key_exists('value', $entry) ? $entry['value'] : null; + + return Filter::expression( + json_encode($value), + '=', + $entry['column'] + ); + } else { + $value = array_key_exists('value', $entry) ? $entry['value'] : null; + + return Filter::expression( + $entry['column'], + $entry['sign'], + json_encode($value) + ); + } + } + + protected function entryAction($entry) + { + if (array_key_exists('action', $entry)) { + return IconHelper::instance()->characterIconName($entry['action']); + } + + return null; + } + + protected function hasIncompleteExpressions(Filter $filter) + { + if ($filter instanceof FilterChain) { + foreach ($filter->filters() as $sub) { + if ($this->hasIncompleteExpressions($sub)) { + return true; + } + } + + return false; + } else { + /** @var FilterExpression $filter */ + if ($filter->isRootNode() && $this->isEmptyExpression($filter)) { + return false; + } + + return $filter->getColumn() === ''; + } + } + + public function isValid($value, $context = null) + { + if (! $value instanceof Filter) { + // TODO: try, return false on E + $filter = $this->arrayToFilter($value); + $this->setValue($filter); + } else { + $filter = $value; + } + + if ($this->hasIncompleteExpressions($filter)) { + $this->addError('The configured filter is incomplete'); + return false; + } + + return parent::isValid($value); + } +} diff --git a/library/Director/Web/Form/Element/ExtensibleSet.php b/library/Director/Web/Form/Element/ExtensibleSet.php new file mode 100644 index 0000000..f3c968f --- /dev/null +++ b/library/Director/Web/Form/Element/ExtensibleSet.php @@ -0,0 +1,89 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use InvalidArgumentException; + +/** + * Input control for extensible sets + */ +class ExtensibleSet extends FormElement +{ + /** + * Default form view helper to use for rendering + * @var string + */ + public $helper = 'formIplExtensibleSet'; + + // private $multiOptions; + + public function getValue() + { + $value = parent::getValue(); + if (is_string($value) || is_numeric($value)) { + $value = [$value]; + } elseif ($value === null) { + return $value; + } + if (! is_array($value)) { + throw new InvalidArgumentException(sprintf( + 'ExtensibleSet expects to work with Arrays, got %s', + var_export($value, 1) + )); + } + $value = array_filter($value, 'strlen'); + + if (empty($value)) { + return null; + } + + return $value; + } + + /** + * We do not want one message per entry + * + * @codingStandardsIgnoreStart + */ + protected function _getErrorMessages() + { + return $this->_errorMessages; + // @codingStandardsIgnoreEnd + } + + /** + * @codingStandardsIgnoreStart + */ + protected function _filterValue(&$value, &$key) + { + // @codingStandardsIgnoreEnd + if (is_array($value)) { + $value = array_filter($value, 'strlen'); + } elseif (is_string($value) && !strlen($value)) { + $value = null; + } + + parent::_filterValue($value, $key); + } + + public function isValid($value, $context = null) + { + if ($value === null) { + $value = []; + } + + $value = array_filter($value, 'strlen'); + $this->setValue($value); + if ($this->isRequired() && empty($value)) { + // TODO: translate + $this->addError('You are required to choose at least one element'); + return false; + } + + if ($this->hasErrors()) { + return false; + } + + return parent::isValid($value, $context); + } +} diff --git a/library/Director/Web/Form/Element/FormElement.php b/library/Director/Web/Form/Element/FormElement.php new file mode 100644 index 0000000..c327859 --- /dev/null +++ b/library/Director/Web/Form/Element/FormElement.php @@ -0,0 +1,9 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Zend_Form_Element_Xhtml; + +class FormElement extends Zend_Form_Element_Xhtml +{ +} diff --git a/library/Director/Web/Form/Element/InstanceSummary.php b/library/Director/Web/Form/Element/InstanceSummary.php new file mode 100644 index 0000000..722ad26 --- /dev/null +++ b/library/Director/Web/Form/Element/InstanceSummary.php @@ -0,0 +1,51 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use gipfl\IcingaWeb2\Link; +use ipl\Html\Html; + +/** + * Used by the + */ +class InstanceSummary extends FormElement +{ + public $helper = 'formSimpleNote'; + + /** + * Always ignore this element + * @codingStandardsIgnoreStart + * + * @var boolean + */ + protected $_ignore = true; + // @codingStandardsIgnoreEnd + + private $instances; + + /** @var array will be set via options */ + protected $linkParams; + + public function setValue($value) + { + $this->instances = $value; + return $this; + } + + public function getValue() + { + return Html::tag('span', [ + Html::tag('italic', 'empty'), + ' ', + Link::create('Manage Instances', 'director/data/dictionary', $this->linkParams, [ + 'data-base-target' => '_next', + 'class' => 'icon-forward' + ]) + ]); + } + + public function isValid($value, $context = null) + { + return true; + } +} diff --git a/library/Director/Web/Form/Element/OptionalYesNo.php b/library/Director/Web/Form/Element/OptionalYesNo.php new file mode 100644 index 0000000..7ef6d7f --- /dev/null +++ b/library/Director/Web/Form/Element/OptionalYesNo.php @@ -0,0 +1,22 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +/** + * Input control for booleans, gives y/n + */ +class OptionalYesNo extends Boolean +{ + public function getValue() + { + $value = $this->getUnfilteredValue(); + + if ($value === 'y' || $value === true) { + return 'y'; + } elseif ($value === 'n' || $value === false) { + return 'n'; + } + + return null; + } +} diff --git a/library/Director/Web/Form/Element/SimpleNote.php b/library/Director/Web/Form/Element/SimpleNote.php new file mode 100644 index 0000000..3097e11 --- /dev/null +++ b/library/Director/Web/Form/Element/SimpleNote.php @@ -0,0 +1,34 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Icinga\Module\Director\PlainObjectRenderer; +use ipl\Html\ValidHtml; + +class SimpleNote extends FormElement +{ + public $helper = 'formSimpleNote'; + + /** + * Always ignore this element + * @codingStandardsIgnoreStart + * + * @var boolean + */ + protected $_ignore = true; + // @codingStandardsIgnoreEnd + + public function isValid($value, $context = null) + { + return true; + } + + public function setValue($value) + { + if (is_object($value) && ! $value instanceof ValidHtml) { + $value = 'Unexpected object: ' . PlainObjectRenderer::render($value); + } + + return parent::setValue($value); + } +} diff --git a/library/Director/Web/Form/Element/StoredPassword.php b/library/Director/Web/Form/Element/StoredPassword.php new file mode 100644 index 0000000..fa0545b --- /dev/null +++ b/library/Director/Web/Form/Element/StoredPassword.php @@ -0,0 +1,62 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Zend_Form_Element_Text as ZfText; + +/** + * StoredPassword + * + * This is a special form field and it might look a little bit weird at first + * sight. It's main use-case are stored cleartext passwords a user should be + * allowed to change. + * + * While this might sound simple, it's quite tricky if you try to fulfill the + * following requirements: + * + * - the current password should not be rendered to the HTML page (unless the + * user decides to change it) + * - it must be possible to visually distinct whether a password has been set + * - it should be impossible to "see" the length of the stored password + * - a changed password must be persisted + * - forms might be subject to multiple submissions in case other fields fail. + * If the user changed the password during the first submission attempt, the + * new string should not be lost. + * - all this must happen within the bounds of ZF1 form elements and related + * view helpers. This means that there is no related context available - and + * we do not know whether the form has been submitted and whether the current + * values have been populated from DB + * + * @package Icinga\Module\Director\Web\Form\Element + */ +class StoredPassword extends ZfText +{ + const UNCHANGED = '__UNCHANGED_VALUE__'; + + public $helper = 'formStoredPassword'; + + public function setValue($value) + { + if (\is_array($value) && isset($value['_value'], $value['_sent']) + && $value['_sent'] === 'y' + ) { + $value = $sentValue = $value['_value']; + if ($sentValue !== self::UNCHANGED) { + $this->setAttrib('sentValue', $sentValue); + } + } else { + $sentValue = null; + } + + if ($value === self::UNCHANGED) { + return $this; + } else { + // Workaround for issue with modified DataTypes. This is Director-specific + if (\is_array($value)) { + $value = \json_encode($value); + } + + return parent::setValue((string) $value); + } + } +} diff --git a/library/Director/Web/Form/Element/Text.php b/library/Director/Web/Form/Element/Text.php new file mode 100644 index 0000000..eeb36f1 --- /dev/null +++ b/library/Director/Web/Form/Element/Text.php @@ -0,0 +1,16 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +use Zend_Form_Element_Text as ZfText; + +class Text extends ZfText +{ + public function setValue($value) + { + if (\is_array($value)) { + $value = \json_encode($value); + } + return parent::setValue((string) $value); + } +} diff --git a/library/Director/Web/Form/Element/YesNo.php b/library/Director/Web/Form/Element/YesNo.php new file mode 100644 index 0000000..3e8aaa7 --- /dev/null +++ b/library/Director/Web/Form/Element/YesNo.php @@ -0,0 +1,14 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Element; + +/** + * Input control for booleans, gives y/n + */ +class YesNo extends OptionalYesNo +{ + public $options = array( + 'y' => 'Yes', + 'n' => 'No', + ); +} diff --git a/library/Director/Web/Form/Filter/QueryColumnsFromSql.php b/library/Director/Web/Form/Filter/QueryColumnsFromSql.php new file mode 100644 index 0000000..6f6d475 --- /dev/null +++ b/library/Director/Web/Form/Filter/QueryColumnsFromSql.php @@ -0,0 +1,48 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Filter; + +use Exception; +use Icinga\Data\Db\DbConnection; +use Icinga\Module\Director\Forms\ImportSourceForm; +use Zend_Filter_Interface; + +class QueryColumnsFromSql implements Zend_Filter_Interface +{ + /** @var ImportSourceForm */ + private $form; + + public function __construct(ImportSourceForm $form) + { + $this->form = $form; + } + + public function filter($value) + { + $form = $this->form; + if (empty($value) || $form->hasChangedSetting('query')) { + try { + return implode( + ', ', + $this->getQueryColumns($form->getSentOrObjectSetting('query')) + ); + } catch (Exception $e) { + $this->form->addUniqueException($e); + return ''; + } + } else { + return $value; + } + } + + protected function getQueryColumns($query) + { + $resourceName = $this->form->getSentOrObjectSetting('resource'); + if (! $resourceName) { + return []; + } + $db = DbConnection::fromResourceName($resourceName)->getDbAdapter(); + + return array_keys((array) current($db->fetchAll($query))); + } +} diff --git a/library/Director/Web/Form/FormLoader.php b/library/Director/Web/Form/FormLoader.php new file mode 100644 index 0000000..ea82857 --- /dev/null +++ b/library/Director/Web/Form/FormLoader.php @@ -0,0 +1,43 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Module; +use Icinga\Exception\ProgrammingError; +use RuntimeException; + +class FormLoader +{ + public static function load($name, Module $module = null) + { + if ($module === null) { + try { + $basedir = Icinga::app()->getApplicationDir('forms'); + } catch (ProgrammingError $e) { + throw new RuntimeException($e->getMessage(), 0, $e); + } + $ns = '\\Icinga\\Web\\Forms\\'; + } else { + $basedir = $module->getFormDir(); + $ns = '\\Icinga\\Module\\' . ucfirst($module->getName()) . '\\Forms\\'; + } + if (preg_match('~^[a-z0-9/]+$~i', $name)) { + $parts = preg_split('~/~', $name); + $class = ucfirst(array_pop($parts)) . 'Form'; + $file = sprintf('%s/%s/%s.php', rtrim($basedir, '/'), implode('/', $parts), $class); + if (file_exists($file)) { + require_once($file); + $class = $ns . $class; + $options = array(); + if ($module !== null) { + $options['icingaModule'] = $module; + } + + return new $class($options); + } + } + + throw new RuntimeException(sprintf('Cannot load %s (%s), no such form', $name, $file)); + } +} diff --git a/library/Director/Web/Form/IcingaObjectFieldLoader.php b/library/Director/Web/Form/IcingaObjectFieldLoader.php new file mode 100644 index 0000000..c900edf --- /dev/null +++ b/library/Director/Web/Form/IcingaObjectFieldLoader.php @@ -0,0 +1,628 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Exception; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Filter\FilterChain; +use Icinga\Data\Filter\FilterExpression; +use Icinga\Exception\IcingaException; +use Icinga\Module\Director\Hook\HostFieldHook; +use Icinga\Module\Director\Hook\ServiceFieldHook; +use Icinga\Module\Director\Objects\DirectorDatafieldCategory; +use Icinga\Module\Director\Objects\IcingaCommand; +use Icinga\Module\Director\Objects\IcingaHost; +use Icinga\Module\Director\Objects\IcingaObject; +use Icinga\Module\Director\Objects\DirectorDatafield; +use Icinga\Module\Director\Objects\IcingaService; +use Icinga\Module\Director\Objects\ObjectApplyMatches; +use Icinga\Web\Hook; +use stdClass; +use Zend_Db_Select as ZfSelect; +use Zend_Form_Element as ZfElement; + +class IcingaObjectFieldLoader +{ + protected $form; + + /** @var IcingaObject */ + protected $object; + + /** @var \Icinga\Module\Director\Db */ + protected $connection; + + /** @var \Zend_Db_Adapter_Abstract */ + protected $db; + + /** @var DirectorDatafield[] */ + protected $fields; + + protected $elements; + + protected $forceNull = array(); + + /** @var array Map element names to variable names 'elName' => 'varName' */ + protected $nameMap = array(); + + public function __construct(IcingaObject $object) + { + $this->object = $object; + $this->connection = $object->getConnection(); + $this->db = $this->connection->getDbAdapter(); + } + + public function addFieldsToForm(DirectorObjectForm $form) + { + if ($this->fields || $this->object->supportsFields()) { + $this->attachFieldsToForm($form); + } + + return $this; + } + + public function loadFieldsForMultipleObjects($objects) + { + $fields = array(); + foreach ($objects as $object) { + foreach ($this->prepareObjectFields($object) as $varname => $field) { + $varname = $field->get('varname'); + if (array_key_exists($varname, $fields)) { + if ($field->get('datatype') !== $fields[$varname]->datatype) { + unset($fields[$varname]); + } + + continue; + } + + $fields[$varname] = $field; + } + } + + $this->fields = $fields; + + return $this; + } + + /** + * Set a list of values + * + * Works in a fail-safe way, when a field does not exist the value will be + * silently ignored + * + * @param array $values key/value pairs with variable names and their value + * @param string $prefix An optional prefix that would be stripped from keys + * + * @return IcingaObjectFieldLoader + * + * @throws IcingaException + */ + public function setValues($values, $prefix = null) + { + if (! $this->object->supportsCustomVars()) { + return $this; + } + + if ($prefix === null) { + $len = null; + } else { + $len = strlen($prefix); + } + $vars = $this->object->vars(); + + foreach ($values as $key => $value) { + if ($len !== null) { + if (substr($key, 0, $len) === $prefix) { + $key = substr($key, $len); + } else { + continue; + } + } + + $varName = $this->getElementVarName($prefix . $key); + if ($varName === null) { + // throw new IcingaException( + // 'Cannot set variable value for "%s", got no such element', + // $key + // ); + + // Silently ignore additional fields. One might have switched + // template or command + continue; + } + + $el = $this->getElement($varName); + if ($el === null) { + // throw new IcingaException('No such element %s', $key); + // Same here. + continue; + } + + $el->setValue($value); + $value = $el->getValue(); + if ($value === '' || $value === array()) { + $value = null; + } + + $vars->set($varName, $value); + } + + // Hint: this does currently not happen, as removeFilteredFields did not + // take place yet. This has been added to be on the safe side when + // cleaning things up one future day + foreach ($this->forceNull as $key) { + $vars->set($key, null); + } + + return $this; + } + + /** + * Get the fields for our object + * + * @return DirectorDatafield[] + */ + public function getFields() + { + if ($this->fields === null) { + $this->fields = $this->prepareObjectFields($this->object); + } + + return $this->fields; + } + + /** + * Get the form elements for our fields + * + * @param DirectorObjectForm $form Optional + * + * @return ZfElement[] + */ + public function getElements(DirectorObjectForm $form = null) + { + if ($this->elements === null) { + $this->elements = $this->createElements($form); + $this->setValuesFromObject($this->object); + } + + return $this->elements; + } + + /** + * Prepare the form elements for our fields + * + * @param DirectorObjectForm $form Optional + * + * @return self + */ + public function prepareElements(DirectorObjectForm $form = null) + { + if ($this->object->supportsFields()) { + $this->getElements($form); + } + + return $this; + } + + /** + * Attach our form fields to the given form + * + * This will also create a 'Custom properties' display group + * + * @param DirectorObjectForm $form + */ + protected function attachFieldsToForm(DirectorObjectForm $form) + { + if ($this->fields === null) { + return; + } + $elements = $this->removeFilteredFields($this->getElements($form)); + + foreach ($elements as $element) { + $form->addElement($element); + } + + $this->attachGroupElements($elements, $form); + } + + /** + * @param ZfElement[] $elements + * @param DirectorObjectForm $form + */ + protected function attachGroupElements(array $elements, DirectorObjectForm $form) + { + $categories = []; + $categoriesFetchedById = []; + foreach ($this->fields as $key => $field) { + if ($id = $field->get('category_id')) { + if (isset($categoriesFetchedById[$id])) { + $category = $categoriesFetchedById[$id]; + } else { + $category = DirectorDatafieldCategory::loadWithAutoIncId($id, $form->getDb()); + $categoriesFetchedById[$id] = $category; + } + } elseif ($field->hasCategory()) { + $category = $field->getCategory(); + } else { + continue; + } + $categories[$key] = $category; + } + $prioIdx = \array_flip(\array_keys($categories)); + + foreach ($elements as $key => $element) { + if (isset($categories[$key])) { + $category = $categories[$key]; + $form->addElementsToGroup( + [$element], + 'custom_fields:' . $category->get('category_name'), + DirectorObjectForm::GROUP_ORDER_CUSTOM_FIELD_CATEGORIES + $prioIdx[$key], + $category->get('category_name') + ); + } else { + $form->addElementsToGroup( + [$element], + 'custom_fields', + DirectorObjectForm::GROUP_ORDER_CUSTOM_FIELDS, + $form->translate('Custom properties') + ); + } + } + } + + /** + * @param ZfElement[] $elements + * @return ZfElement[] + */ + protected function removeFilteredFields(array $elements) + { + $filters = array(); + foreach ($this->fields as $key => $field) { + if ($filter = $field->var_filter) { + $filters[$key] = Filter::fromQueryString($filter); + } + } + + $kill = array(); + $columns = array(); + $object = $this->object; + if ($object instanceof IcingaHost) { + $prefix = 'host.vars.'; + } elseif ($object instanceof IcingaService) { + $prefix = 'service.vars.'; + } else { + return $elements; + } + + $object->invalidateResolveCache(); + $vars = $object::fromPlainObject( + $object->toPlainObject(true), + $object->getConnection() + )->getVars(); + + $prefixedVars = (object) array(); + foreach ($vars as $k => $v) { + $prefixedVars->{$prefix . $k} = $v; + } + + foreach ($filters as $key => $filter) { + ObjectApplyMatches::fixFilterColumns($filter); + /** @var $filter FilterChain|FilterExpression */ + foreach ($filter->listFilteredColumns() as $column) { + $column = substr($column, strlen($prefix)); + $columns[$column] = $column; + } + if (! $filter->matches($prefixedVars)) { + $kill[] = $key; + } + } + + $vars = $object->vars(); + foreach ($kill as $key) { + unset($elements[$key]); + $this->forceNull[$key] = $key; + // Hint: this should happen later on, currently execution order is + // a little bit weird + $vars->set($key, null); + } + + foreach ($columns as $col) { + if (array_key_exists($col, $elements)) { + $el = $elements[$col]; + $existingClass = $el->getAttrib('class'); + if ($existingClass !== null && strlen($existingClass)) { + $el->setAttrib('class', $existingClass . ' autosubmit'); + } else { + $el->setAttrib('class', 'autosubmit'); + } + } + } + + return $elements; + } + + protected function getElementVarName($name) + { + if (array_key_exists($name, $this->nameMap)) { + return $this->nameMap[$name]; + } + + return null; + } + + /** + * Get the form element for a specific field by it's variable name + * + * @param string $name + * @return null|ZfElement + */ + protected function getElement($name) + { + $elements = $this->getElements(); + if (array_key_exists($name, $elements)) { + return $this->elements[$name]; + } + + return null; + } + + /** + * Get the form elements based on the given form + * + * @param DirectorObjectForm $form + * + * @return ZfElement[] + */ + protected function createElements(DirectorObjectForm $form) + { + $elements = array(); + + foreach ($this->getFields() as $name => $field) { + $el = $field->getFormElement($form); + $elName = $el->getName(); + if (array_key_exists($elName, $this->nameMap)) { + $form->addErrorMessage(sprintf( + 'Form element name collision, "%s" resolves to "%s", but this is also used for "%s"', + $name, + $elName, + $this->nameMap[$elName] + )); + } + $this->nameMap[$elName] = $name; + $elements[$name] = $el; + } + + return $elements; + } + + /** + * @param IcingaObject $object + */ + protected function setValuesFromObject(IcingaObject $object) + { + foreach ($object->getVars() as $k => $v) { + if ($v !== null && $el = $this->getElement($k)) { + $el->setValue($v); + } + } + } + + protected function mergeFields($listOfFields) + { + // TODO: Merge field for different object, mostly sets + } + + /** + * Create the fields for our object + * + * @param IcingaObject $object + * @return DirectorDatafield[] + */ + protected function prepareObjectFields($object) + { + $fields = $this->loadResolvedFieldsForObject($object); + if ($object->hasRelation('check_command')) { + try { + /** @var IcingaCommand $command */ + $command = $object->getResolvedRelated('check_command'); + } catch (Exception $e) { + // Ignore failures + $command = null; + } + + if ($command) { + $cmdLoader = new static($command); + $cmdFields = $cmdLoader->getFields(); + foreach ($cmdFields as $varname => $field) { + if (! array_key_exists($varname, $fields)) { + $fields[$varname] = $field; + } + } + } + + // TODO -> filters! + } + + return $fields; + } + + /** + * Create the fields for our object + * + * Follows the inheritance logic, resolves all fields and keeps the most + * specific ones. Returns a list of fields indexed by variable name + * + * @param IcingaObject $object + * + * @return DirectorDatafield[] + */ + protected function loadResolvedFieldsForObject(IcingaObject $object) + { + $result = $this->loadDataFieldsForObject( + $object + ); + + $fields = array(); + foreach ($result as $objectId => $varFields) { + foreach ($varFields as $var => $field) { + $fields[$var] = $field; + } + } + + return $fields; + } + + /** + * @param IcingaObject[] $objectList List of objects + * + * @return array + */ + protected function getIdsForObjectList($objectList) + { + $ids = []; + foreach ($objectList as $object) { + if ($object->hasBeenLoadedFromDb()) { + $ids[] = $object->get('id'); + } + } + + return $ids; + } + + public function fetchFieldDetailsForObject(IcingaObject $object) + { + $ids = $object->listAncestorIds(); + if ($id = $object->getProperty('id')) { + $ids[] = $id; + } + return $this->fetchFieldDetailsForIds($ids); + } + + /*** + * @param $objectIds + * + * @return \stdClass[] + */ + protected function fetchFieldDetailsForIds($objectIds) + { + if (empty($objectIds)) { + return []; + } + + $query = $this->prepareSelectForIds($objectIds); + return $this->db->fetchAll($query); + } + + /** + * @param array $ids + * + * @return ZfSelect + */ + protected function prepareSelectForIds(array $ids) + { + $object = $this->object; + + $idColumn = 'f.' . $object->getShortTableName() . '_id'; + + $query = $this->db->select()->from( + array('df' => 'director_datafield'), + array( + 'object_id' => $idColumn, + 'icinga_type' => "('" . $object->getShortTableName() . "')", + 'var_filter' => 'f.var_filter', + 'is_required' => 'f.is_required', + 'id' => 'df.id', + 'category_id' => 'df.category_id', + 'varname' => 'df.varname', + 'caption' => 'df.caption', + 'description' => 'df.description', + 'datatype' => 'df.datatype', + 'format' => 'df.format', + ) + )->join( + array('f' => $object->getTableName() . '_field'), + 'df.id = f.datafield_id', + array() + )->where($idColumn . ' IN (?)', $ids) + ->order('CASE WHEN var_filter IS NULL THEN 0 ELSE 1 END ASC') + ->order('df.caption ASC'); + + return $query; + } + + /** + * Fetches fields for a given object + * + * Gives a list indexed by object id, with each entry being a list of that + * objects DirectorDatafield instances indexed by variable name + * + * @param IcingaObject $object + * + * @return array + */ + public function loadDataFieldsForObject(IcingaObject $object) + { + $res = $this->fetchFieldDetailsForObject($object); + + $result = []; + foreach ($res as $r) { + $id = $r->object_id; + unset($r->object_id); + if (! array_key_exists($id, $result)) { + $result[$id] = new stdClass; + } + + $result[$id]->{$r->varname} = DirectorDatafield::fromDbRow( + $r, + $this->connection + ); + } + + foreach ($this->loadHookedDataFieldForObject($object) as $id => $fields) { + if (array_key_exists($id, $result)) { + foreach ($fields as $varName => $field) { + $result[$id]->$varName = $field; + } + } else { + $result[$id] = $fields; + } + } + + return $result; + } + + /** + * @param IcingaObject $object + * @return array + */ + protected function loadHookedDataFieldForObject(IcingaObject $object) + { + $fields = []; + if ($object instanceof IcingaHost || $object instanceof IcingaService) { + $fields = $this->addHookedFields($object); + } + + return $fields; + } + + /** + * @param IcingaObject $object + * @return mixed + */ + protected function addHookedFields(IcingaObject $object) + { + $fields = []; + /** @var HostFieldHook|ServiceFieldHook $hook */ + $type = ucfirst($object->getShortTableName()); + foreach (Hook::all("Director\\${type}Field") as $hook) { + if ($hook->wants($object)) { + $id = $object->get('id'); + $spec = $hook->getFieldSpec($object); + if (!array_key_exists($id, $fields)) { + $fields[$id] = new stdClass(); + } + $fields[$id]->{$spec->getVarName()} = $spec->toDataField($object); + } + } + return $fields; + } +} diff --git a/library/Director/Web/Form/IconHelper.php b/library/Director/Web/Form/IconHelper.php new file mode 100644 index 0000000..3add09b --- /dev/null +++ b/library/Director/Web/Form/IconHelper.php @@ -0,0 +1,89 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Exception\ProgrammingError; + +/** + * Icon helper class + * + * Should help to reduce redundant icon-lookup code. Currently with hardcoded + * icons only, could easily provide support for all of them as follows: + * + * $confFile = Icinga::app() + * ->getApplicationDir('fonts/fontello-ifont/config.json'); + * + * $font = json_decode(file_get_contents($confFile)); + * // 'icon-' is to be found in $font->css_prefix_text + * foreach ($font->glyphs as $icon) { + * // $icon->css (= 'help') -> 0x . dechex($icon->code) + * } + */ +class IconHelper +{ + private $icons = array( + 'minus' => 'e806', + 'trash' => 'e846', + 'plus' => 'e805', + 'cancel' => 'e804', + 'help' => 'e85b', + 'angle-double-right' => 'e87b', + 'up-big' => 'e825', + 'down-big' => 'e828', + 'down-open' => 'e821', + ); + + private $mappedUtf8Icons; + + private $reversedUtf8Icons; + + private static $instance; + + public function __construct() + { + $this->prepareIconMappings(); + } + + public static function instance() + { + if (self::$instance === null) { + self::$instance = new static; + } + + return self::$instance; + } + + public function characterIconName($character) + { + if (array_key_exists($character, $this->reversedUtf8Icons)) { + return $this->reversedUtf8Icons[$character]; + } else { + throw new ProgrammingError('There is no mapping for the given character'); + } + } + + protected function hexToCharacter($hex) + { + return json_decode('"\u' . $hex . '"'); + } + + public function iconCharacter($name) + { + if (array_key_exists($name, $this->mappedUtf8Icons)) { + return $this->mappedUtf8Icons[$name]; + } else { + return $this->mappedUtf8Icons['help']; + } + } + + protected function prepareIconMappings() + { + $this->mappedUtf8Icons = array(); + $this->reversedUtf8Icons = array(); + foreach ($this->icons as $name => $hex) { + $character = $this->hexToCharacter($hex); + $this->mappedUtf8Icons[$name] = $character; + $this->reversedUtf8Icons[$character] = $name; + } + } +} diff --git a/library/Director/Web/Form/IplElement/ExtensibleSetElement.php b/library/Director/Web/Form/IplElement/ExtensibleSetElement.php new file mode 100644 index 0000000..a4dbb20 --- /dev/null +++ b/library/Director/Web/Form/IplElement/ExtensibleSetElement.php @@ -0,0 +1,570 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\IplElement; + +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Director\IcingaConfig\ExtensibleSet as Set; +use Icinga\Module\Director\Web\Form\IconHelper; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Html; +use gipfl\Translation\TranslationHelper; + +class ExtensibleSetElement extends BaseHtmlElement +{ + use TranslationHelper; + + protected $tag = 'ul'; + + /** @var Set */ + protected $set; + + private $id; + + private $name; + + private $value; + + private $description; + + private $multiOptions; + + private $validOptions; + + private $chosenOptionCount = 0; + + private $suggestionContext; + + private $sorted = false; + + private $disabled = false; + + private $remainingAttribs; + + private $hideOptions = []; + + private $inherited; + + private $inheritedFrom; + + protected $defaultAttributes = [ + 'class' => 'extensible-set' + ]; + + protected function __construct($name) + { + $this->name = $this->id = $name; + } + + public function hideOptions($options) + { + $this->hideOptions = array_merge($this->hideOptions, $options); + return $this; + } + + private function setMultiOptions($options) + { + $this->multiOptions = $options; + $this->validOptions = $this->flattenOptions($options); + } + + protected function isValidOption($option) + { + if ($this->validOptions === null) { + if ($this->suggestionContext === null) { + return true; + } else { + // TODO: ask suggestionContext, if any + return true; + } + } else { + return in_array($option, $this->validOptions); + } + } + + private function disable($disable = true) + { + $this->disabled = (bool) $disable; + } + + private function isDisabled() + { + return $this->disabled; + } + + private function isSorted() + { + return $this->sorted; + } + + public function setValue($value) + { + if ($value instanceof Set) { + $value = $value->toPlainObject(); + } + + if (is_array($value)) { + $value = array_filter($value, 'strlen'); + } + + if (null !== $value && ! is_array($value)) { + throw new ProgrammingError( + 'Got unexpected value, no array: %s', + var_export($value, 1) + ); + } + + $this->value = $value; + return $this; + } + + protected function extractZfInfo(&$attribs = null) + { + if ($attribs === null) { + return; + } + + foreach (['id', 'name', 'descriptions'] as $key) { + if (array_key_exists($key, $attribs)) { + $this->$key = $attribs[$key]; + unset($attribs[$key]); + } + } + if (array_key_exists('disable', $attribs)) { + $this->disable($attribs['disable']); + unset($attribs['disable']); + } + if (array_key_exists('value', $attribs)) { + $this->setValue($attribs['value']); + unset($attribs['value']); + } + if (array_key_exists('inherited', $attribs)) { + $this->inherited = $attribs['inherited']; + unset($attribs['inherited']); + } + if (array_key_exists('inheritedFrom', $attribs)) { + $this->inheritedFrom = $attribs['inheritedFrom']; + unset($attribs['inheritedFrom']); + } + + if (array_key_exists('multiOptions', $attribs)) { + $this->setMultiOptions($attribs['multiOptions']); + unset($attribs['multiOptions']); + } + + if (array_key_exists('hideOptions', $attribs)) { + $this->hideOptions($attribs['hideOptions']); + unset($attribs['hideOptions']); + } + + if (array_key_exists('sorted', $attribs)) { + $this->sorted = (bool) $attribs['sorted']; + unset($attribs['sorted']); + } + + if (array_key_exists('description', $attribs)) { + $this->description = $attribs['description']; + unset($attribs['description']); + } + + if (array_key_exists('suggest', $attribs)) { + $this->suggestionContext = $attribs['suggest']; + unset($attribs['suggest']); + } + + if (! empty($attribs)) { + $this->remainingAttribs = $attribs; + } + } + + /** + * Generates an 'extensible set' element. + * + * @codingStandardsIgnoreEnd + * + * @param string|array $name If a string, the element name. If an + * array, all other parameters are ignored, and the array elements + * are used in place of added parameters. + * + * @param mixed $value The element value. + * + * @param array $attribs Attributes for the element tag. + * + * @return string The element XHTML. + */ + public static function fromZfDingens($name, $value = null, $attribs = null) + { + $el = new static($name); + $el->extractZfInfo($attribs); + $el->setValue($value); + return $el->render(); + } + + protected function assemble() + { + $this->addChosenOptions(); + $this->addAddMore(); + + if ($this->isSorted()) { + $this->getAttributes()->add('class', 'sortable'); + } + if (null !== $this->description) { + $this->addDescription($this->description); + } + } + + private function eventuallyAddAutosuggestion(BaseHtmlElement $element) + { + if ($this->suggestionContext !== null) { + $attrs = $element->getAttributes(); + $attrs->add('class', 'director-suggest'); + $attrs->set([ + 'data-suggestion-context' => $this->suggestionContext, + ]); + } + + return $element; + } + + private function hasAvailableMultiOptions() + { + return count($this->multiOptions) > 1 || strlen(key($this->multiOptions)); + } + + private function addAddMore() + { + $cnt = $this->chosenOptionCount; + + if ($this->multiOptions) { + if (! $this->hasAvailableMultiOptions()) { + return; + } + $field = Html::tag('select', ['class' => 'autosubmit']); + $more = $this->inherited === null + ? $this->translate('- add more -') + : $this->getInheritedInfo(); + $field->add(Html::tag('option', [ + 'value' => '', + 'tabindex' => '-1' + ], $more)); + + foreach ($this->multiOptions as $key => $label) { + if ($key === null) { + $key = ''; + } + if (is_array($label)) { + $optGroup = Html::tag('optgroup', ['label' => $key]); + foreach ($label as $grpKey => $grpLabel) { + $optGroup->add( + Html::tag('option', ['value' => $grpKey], $grpLabel) + ); + } + $field->add($optGroup); + } else { + $option = Html::tag('option', ['value' => $key], $label); + $field->add($option); + } + } + } else { + $field = Html::tag('input', [ + 'type' => 'text', + 'placeholder' => $this->inherited === null + ? $this->translate('Add a new one...') + : $this->getInheritedInfo(), + ]); + } + $field->addAttributes([ + 'id' => $this->id . $this->suffix($cnt), + 'name' => $this->name . '[]', + ]); + $this->eventuallyAddAutosuggestion( + $this->addRemainingAttributes( + $this->eventuallyDisable($field) + ) + ); + if ($cnt !== 0) { // TODO: was === 0?! + $field->getAttributes()->add('class', 'extend-set'); + } + + if ($this->suggestionContext === null) { + $this->add(Html::tag('li', null, [ + $this->createAddNewButton(), + $field + ])); + } else { + $this->add(Html::tag('li', null, [ + $this->newInlineButtons( + $this->renderDropDownButton() + ), + $field + ])); + } + } + + private function getInheritedInfo() + { + if ($this->inheritedFrom === null) { + return \sprintf( + $this->translate('%s (inherited)'), + $this->stringifyInheritedValue() + ); + } else { + return \sprintf( + $this->translate('%s (inherited from %s)'), + $this->stringifyInheritedValue(), + $this->inheritedFrom + ); + } + } + + private function stringifyInheritedValue() + { + if (\is_array($this->inherited)) { + return \implode(', ', $this->inherited); + } else { + return \sprintf( + $this->translate('%s (not an Array!)'), + \var_export($this->inherited, 1) + ); + } + } + + private function createAddNewButton() + { + return $this->newInlineButtons( + $this->eventuallyDisable($this->renderAddButton()) + ); + } + + private function addChosenOptions() + { + if (null === $this->value) { + return; + } + $total = count($this->value); + + foreach ($this->value as $val) { + if (in_array($val, $this->hideOptions)) { + continue; + } + + if ($this->multiOptions !== null) { + if ($this->isValidOption($val)) { + $this->multiOptions = $this->removeOption( + $this->multiOptions, + $val + ); + // TODO: + // $this->removeOption($val); + } + } + + $text = Html::tag('input', [ + 'type' => 'text', + 'name' => $this->name . '[]', + 'id' => $this->id . $this->suffix($this->chosenOptionCount), + 'value' => $val + ]); + $text->getAttributes()->set([ + 'autocomplete' => 'off', + 'autocorrect' => 'off', + 'autocapitalize' => 'off', + 'spellcheck' => 'false', + ]); + + $this->addRemainingAttributes($this->eventuallyDisable($text)); + $this->add(Html::tag('li', null, [ + $this->getOptionButtons($this->chosenOptionCount, $total), + $text + ])); + $this->chosenOptionCount++; + } + } + + private function addRemainingAttributes(BaseHtmlElement $element) + { + if ($this->remainingAttribs !== null) { + $element->getAttributes()->add($this->remainingAttribs); + } + + return $element; + } + + private function eventuallyDisable(BaseHtmlElement $element) + { + if ($this->isDisabled()) { + $this->disableElement($element); + } + + return $element; + } + + private function disableElement(BaseHtmlElement $element) + { + $element->getAttributes()->set('disabled', 'disabled'); + return $element; + } + + private function disableIf(BaseHtmlElement $element, $condition) + { + if ($condition) { + $this->disableElement($element); + } + + return $element; + } + + private function getOptionButtons($cnt, $total) + { + if ($this->isDisabled()) { + return []; + } + $first = $cnt === 0; + $last = $cnt === $total - 1; + $name = $this->name; + $buttons = $this->newInlineButtons(); + if ($this->isSorted()) { + $buttons->add([ + $this->disableIf($this->renderDownButton($name, $cnt), $last), + $this->disableIf($this->renderUpButton($name, $cnt), $first) + ]); + } + + $buttons->add($this->renderDeleteButton($name, $cnt)); + + return $buttons; + } + + protected function newInlineButtons($content = null) + { + return Html::tag('span', ['class' => 'inline-buttons'], $content); + } + + protected function addDescription($description) + { + $this->add( + Html::tag('p', ['class' => 'description'], $description) + ); + } + + private function flattenOptions($options) + { + $flat = array(); + + foreach ($options as $key => $option) { + if (is_array($option)) { + foreach ($option as $k => $o) { + $flat[] = $k; + } + } else { + $flat[] = $key; + } + } + + return $flat; + } + + private function removeOption($options, $option) + { + $unset = array(); + foreach ($options as $key => & $value) { + if (is_array($value)) { + $value = $this->removeOption($value, $option); + if (empty($value)) { + $unset[] = $key; + } + } elseif ($key === $option) { + $unset[] = $key; + } + } + + foreach ($unset as $key) { + unset($options[$key]); + } + + return $options; + } + + private function suffix($cnt) + { + if ($cnt === 0) { + return ''; + } else { + return '_' . $cnt; + } + } + + private function renderDropDownButton() + { + return $this->createRelatedAction( + 'drop-down', + $this->name, + $this->translate('Show available options'), + 'down-open' + ); + } + + private function renderAddButton() + { + return $this->createRelatedAction( + 'add', + // This would interfere with how PHP resolves _POST arrays. So we + // use a fake name for now, that way the button will be ignored and + // behave similar to an auto-submission + 'X_' . $this->name, + $this->translate('Add a new entry'), + 'plus' + ); + } + + private function renderDeleteButton($name, $cnt) + { + return $this->createRelatedAction( + 'remove', + $name . '_' . $cnt, + $this->translate('Remove this entry'), + 'cancel' + ); + } + + private function renderUpButton($name, $cnt) + { + return $this->createRelatedAction( + 'move-up', + $name . '_' . $cnt, + $this->translate('Move up'), + 'up-big' + ); + } + + private function renderDownButton($name, $cnt) + { + return $this->createRelatedAction( + 'move-down', + $name . '_' . $cnt, + $this->translate('Move down'), + 'down-big' + ); + } + + protected function makeActionName($name, $action) + { + return $name . '__' . str_replace('-', '_', strtoupper($action)); + } + + protected function createRelatedAction( + $action, + $name, + $title, + $icon + ) { + $input = Html::tag('input', [ + 'type' => 'submit', + 'class' => ['related-action', 'action-' . $action], + 'name' => $this->makeActionName($name, $action), + 'value' => IconHelper::instance()->iconCharacter($icon), + 'title' => $title + ]); + + return $input; + } +} diff --git a/library/Director/Web/Form/QuickBaseForm.php b/library/Director/Web/Form/QuickBaseForm.php new file mode 100644 index 0000000..8d25ffb --- /dev/null +++ b/library/Director/Web/Form/QuickBaseForm.php @@ -0,0 +1,177 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Module; +use ipl\Html\Html; +use ipl\Html\ValidHtml; +use Zend_Form; + +abstract class QuickBaseForm extends Zend_Form implements ValidHtml +{ + /** + * The Icinga module this form belongs to. Usually only set if the + * form is initialized through the FormLoader + * + * @var Module + */ + protected $icingaModule; + + protected $icingaModuleName; + + private $hintCount = 0; + + public function __construct($options = null) + { + $this->callZfConstructor($this->handleOptions($options)) + ->initializePrefixPaths(); + } + + protected function callZfConstructor($options = null) + { + parent::__construct($options); + return $this; + } + + protected function initializePrefixPaths() + { + $this->addPrefixPathsForDirector(); + if ($this->icingaModule && $this->icingaModuleName !== 'director') { + $this->addPrefixPathsForModule($this->icingaModule); + } + } + + protected function addPrefixPathsForDirector() + { + $module = Icinga::app() + ->getModuleManager() + ->loadModule('director') + ->getModule('director'); + + $this->addPrefixPathsForModule($module); + } + + public function addPrefixPathsForModule(Module $module) + { + $basedir = sprintf( + '%s/%s/Web/Form', + $module->getLibDir(), + ucfirst($module->getName()) + ); + + $this->addPrefixPath( + __NAMESPACE__ . '\\Element\\', + $basedir . '/Element', + static::ELEMENT + ); + + return $this; + } + + public function addHidden($name, $value = null) + { + $this->addElement('hidden', $name); + $el = $this->getElement($name); + $el->setDecorators(array('ViewHelper')); + if ($value !== null) { + $this->setDefault($name, $value); + $el->setValue($value); + } + + return $this; + } + + // TODO: Should be an element + public function addHtmlHint($html, $options = []) + { + return $this->addHtml( + Html::tag('div', ['class' => 'hint'], $html), + $options + ); + } + + public function addHtml($html, $options = []) + { + if ($html instanceof ValidHtml) { + $html = $html->render(); + } + + if (array_key_exists('name', $options)) { + $name = $options['name']; + unset($options['name']); + } else { + $name = '_HINT' . ++$this->hintCount; + } + + $this->addElement('simpleNote', $name, $options); + $this->getElement($name) + ->setValue($html) + ->setIgnore(true) + ->setDecorators(array('ViewHelper')); + + return $this; + } + + public function optionalEnum($enum, $nullLabel = null) + { + if ($nullLabel === null) { + $nullLabel = $this->translate('- please choose -'); + } + + return array(null => $nullLabel) + $enum; + } + + protected function handleOptions($options = null) + { + if ($options === null) { + return $options; + } + + if (array_key_exists('icingaModule', $options)) { + /** @var Module icingaModule */ + $this->icingaModule = $options['icingaModule']; + $this->icingaModuleName = $this->icingaModule->getName(); + unset($options['icingaModule']); + } + + return $options; + } + + public function setIcingaModule(Module $module) + { + $this->icingaModule = $module; + return $this; + } + + protected function loadForm($name, Module $module = null) + { + if ($module === null) { + $module = $this->icingaModule; + } + + return FormLoader::load($name, $module); + } + + protected function valueIsEmpty($value) + { + if ($value === null) { + return true; + } + + if (is_array($value)) { + return empty($value); + } + + return strlen($value) === 0; + } + + public function translate($string) + { + if ($this->icingaModuleName === null) { + return t($string); + } else { + return mt($this->icingaModuleName, $string); + } + } +} diff --git a/library/Director/Web/Form/QuickForm.php b/library/Director/Web/Form/QuickForm.php new file mode 100644 index 0000000..91c8f00 --- /dev/null +++ b/library/Director/Web/Form/QuickForm.php @@ -0,0 +1,641 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +use Icinga\Application\Icinga; +use Icinga\Web\Notification; +use Icinga\Web\Request; +use Icinga\Web\Response; +use Icinga\Web\Url; +use InvalidArgumentException; +use Exception; +use RuntimeException; + +/** + * QuickForm wants to be a base class for simple forms + */ +abstract class QuickForm extends QuickBaseForm +{ + const ID = '__FORM_NAME'; + + const CSRF = '__FORM_CSRF'; + + /** + * The name of this form + */ + protected $formName; + + /** + * Whether the form has been sent + */ + protected $hasBeenSent; + + /** + * Whether the form has been sent + */ + protected $hasBeenSubmitted; + + /** + * The submit caption, element - still tbd + */ + // protected $submit; + + /** + * Our request + */ + protected $request; + + protected $successUrl; + + protected $successMessage; + + protected $submitLabel; + + protected $submitButtonName; + + protected $deleteButtonName; + + protected $fakeSubmitButtonName; + + /** + * Whether form elements have already been created + */ + protected $didSetup = false; + + protected $isApiRequest = false; + + protected $successCallbacks = []; + + protected $calledSuccessCallbacks = false; + + protected $onRequestCallbacks = []; + + protected $calledOnRequestCallbacks = false; + + public function __construct($options = null) + { + parent::__construct($options); + + $this->setMethod('post'); + $this->getActionFromRequest() + ->createIdElement() + ->regenerateCsrfToken() + ->setPreferredDecorators(); + } + + protected function getActionFromRequest() + { + $this->setAction(Url::fromRequest()); + return $this; + } + + protected function setPreferredDecorators() + { + $current = $this->getAttrib('class'); + $current .= ' director-form'; + if ($current) { + $this->setAttrib('class', "$current autofocus"); + } else { + $this->setAttrib('class', 'autofocus'); + } + $this->setDecorators( + array( + 'Description', + array('FormErrors', array('onlyCustomFormErrors' => true)), + 'FormElements', + 'Form' + ) + ); + + return $this; + } + + protected function addSubmitButton($label, $options = []) + { + $el = $this->createElement('submit', $label, $options) + ->setLabel($label) + ->setDecorators(array('ViewHelper')); + $this->submitButtonName = $el->getName(); + $this->setSubmitLabel($label); + $this->addElement($el); + } + + protected function addStandaloneSubmitButton($label, $options = []) + { + $this->addSubmitButton($label, $options); + $this->addDisplayGroup([$this->submitButtonName], 'buttons', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'p')), + ), + 'order' => 1000, + )); + } + + protected function addSubmitButtonIfSet() + { + if (false === ($label = $this->getSubmitLabel())) { + return; + } + + if ($this->submitButtonName && $el = $this->getElement($this->submitButtonName)) { + return; + } + + $this->addSubmitButton($label); + + $fakeEl = $this->createElement('submit', '_FAKE_SUBMIT', array( + 'role' => 'none', + 'tabindex' => '-1', + )) + ->setLabel($label) + ->setDecorators(array('ViewHelper')); + $this->fakeSubmitButtonName = $fakeEl->getName(); + $this->addElement($fakeEl); + + $this->addDisplayGroup( + array($this->fakeSubmitButtonName), + 'fake_button', + array( + 'decorators' => array('FormElements'), + 'order' => 1, + ) + ); + + $this->addButtonDisplayGroup(); + } + + protected function addButtonDisplayGroup() + { + $grp = array( + $this->submitButtonName, + $this->deleteButtonName + ); + $this->addDisplayGroup($grp, 'buttons', array( + 'decorators' => array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'DtDdWrapper', + ), + 'order' => 1000, + )); + } + + protected function addSimpleDisplayGroup($elements, $name, $options) + { + if (! array_key_exists('decorators', $options)) { + $options['decorators'] = array( + 'FormElements', + array('HtmlTag', array('tag' => 'dl')), + 'Fieldset', + ); + } + + return $this->addDisplayGroup($elements, $name, $options); + } + + protected function createIdElement() + { + if ($this->isApiRequest()) { + return $this; + } + $this->detectName(); + $this->addHidden(self::ID, $this->getName()); + $this->getElement(self::ID)->setIgnore(true); + return $this; + } + + public function getSentValue($name, $default = null) + { + $request = $this->getRequest(); + if ($request->isPost() && $this->hasBeenSent()) { + return $request->getPost($name); + } else { + return $default; + } + } + + public function getSubmitLabel() + { + if ($this->submitLabel === null) { + return $this->translate('Submit'); + } + + return $this->submitLabel; + } + + public function setSubmitLabel($label) + { + $this->submitLabel = $label; + return $this; + } + + public function setApiRequest($isApiRequest = true) + { + $this->isApiRequest = $isApiRequest; + return $this; + } + + public function isApiRequest() + { + if ($this->isApiRequest === null) { + if ($this->request === null) { + throw new RuntimeException( + 'Early access to isApiRequest(). This is not possible, sorry' + ); + } + + return $this->getRequest()->isApiRequest(); + } else { + return $this->isApiRequest; + } + } + + public function regenerateCsrfToken() + { + if ($this->isApiRequest()) { + return $this; + } + if (! $element = $this->getElement(self::CSRF)) { + $this->addHidden(self::CSRF, CsrfToken::generate()); + $element = $this->getElement(self::CSRF); + } + $element->setIgnore(true); + + return $this; + } + + public function removeCsrfToken() + { + $this->removeElement(self::CSRF); + return $this; + } + + public function setSuccessUrl($url, $params = null) + { + if (! $url instanceof Url) { + $url = Url::fromPath($url); + } + if ($params !== null) { + $url->setParams($params); + } + $this->successUrl = $url; + return $this; + } + + public function getSuccessUrl() + { + $url = $this->successUrl ?: $this->getAction(); + if (! $url instanceof Url) { + $url = Url::fromPath($url); + } + + return $url; + } + + protected function beforeSetup() + { + } + + public function setup() + { + } + + protected function onSetup() + { + } + + public function setAction($action) + { + if ($action instanceof Url) { + $action = $action->getAbsoluteUrl('&'); + } + + return parent::setAction($action); + } + + public function hasBeenSubmitted() + { + if ($this->hasBeenSubmitted === null) { + $req = $this->getRequest(); + if ($req->isApiRequest()) { + return $this->hasBeenSubmitted = true; + } + if ($req->isPost()) { + if (! $this->hasSubmitButton()) { + return $this->hasBeenSubmitted = $this->hasBeenSent(); + } + + $this->hasBeenSubmitted = $this->pressedButton( + $this->fakeSubmitButtonName, + $this->getSubmitLabel() + ) || $this->pressedButton( + $this->submitButtonName, + $this->getSubmitLabel() + ); + } else { + $this->hasBeenSubmitted = false; + } + } + + return $this->hasBeenSubmitted; + } + + protected function hasSubmitButton() + { + return $this->submitButtonName !== null; + } + + protected function pressedButton($name, $label) + { + $req = $this->getRequest(); + if (! $req->isPost()) { + return false; + } + + $req = $this->getRequest(); + $post = $req->getPost(); + + return array_key_exists($name, $post) + && $post[$name] === $label; + } + + protected function beforeValidation($data = array()) + { + } + + public function prepareElements() + { + if (! $this->didSetup) { + $this->beforeSetup(); + $this->setup(); + $this->onSetup(); + $this->didSetup = true; + } + + return $this; + } + + public function handleRequest(Request $request = null) + { + if ($request === null) { + $request = $this->getRequest(); + } else { + $this->setRequest($request); + } + + $this->prepareElements(); + $this->addSubmitButtonIfSet(); + + if ($this->hasBeenSent()) { + $post = $request->getPost(); + if ($this->hasBeenSubmitted()) { + $this->beforeValidation($post); + if ($this->isValid($post)) { + try { + $this->onSuccess(); + $this->callOnSuccessCallables(); + } catch (Exception $e) { + $this->addException($e); + $this->onFailure(); + } + } else { + $this->onFailure(); + } + } else { + $this->setDefaults($post); + } + } + + return $this; + } + + public function addException(Exception $e, $elementName = null) + { + $msg = $this->getErrorMessageForException($e); + if ($el = $this->getElement($elementName)) { + $el->addError($msg); + } else { + $this->addError($msg); + } + } + + public function addUniqueErrorMessage($msg) + { + if (! in_array($msg, $this->getErrorMessages())) { + $this->addErrorMessage($msg); + } + + return $this; + } + + public function addUniqueException(Exception $e) + { + $msg = $this->getErrorMessageForException($e); + + if (! in_array($msg, $this->getErrorMessages())) { + $this->addErrorMessage($msg); + } + + return $this; + } + + protected function getErrorMessageForException(Exception $e) + { + $file = preg_split('/[\/\\\]/', $e->getFile(), -1, PREG_SPLIT_NO_EMPTY); + $file = array_pop($file); + return sprintf( + '%s (%s:%d)', + $e->getMessage(), + $file, + $e->getLine() + ); + } + + public function onSuccess() + { + $this->redirectOnSuccess(); + } + + /** + * @param callable $callable + * @return $this + */ + public function callOnRequest($callable) + { + if (! is_callable($callable)) { + throw new InvalidArgumentException( + 'callOnRequest() expects a callable' + ); + } + $this->onRequestCallbacks[] = $callable; + + return $this; + } + + protected function callOnRequestCallables() + { + if (! $this->calledOnRequestCallbacks) { + $this->calledOnRequestCallbacks = true; + foreach ($this->onRequestCallbacks as $callable) { + $callable($this); + } + } + } + + /** + * @param callable $callable + * @return $this + */ + public function callOnSuccess($callable) + { + if (! is_callable($callable)) { + throw new InvalidArgumentException( + 'callOnSuccess() expects a callable' + ); + } + $this->successCallbacks[] = $callable; + + return $this; + } + + protected function callOnSuccessCallables() + { + if (! $this->calledSuccessCallbacks) { + $this->calledSuccessCallbacks = true; + foreach ($this->successCallbacks as $callable) { + $callable($this); + } + } + } + + public function setSuccessMessage($message) + { + $this->successMessage = $message; + return $this; + } + + public function getSuccessMessage($message = null) + { + if ($message !== null) { + return $message; + } + if ($this->successMessage === null) { + return t('Form has successfully been sent'); + } + return $this->successMessage; + } + + public function redirectOnSuccess($message = null) + { + if ($this->isApiRequest()) { + // TODO: Set the status line message? + $this->successMessage = $this->getSuccessMessage($message); + $this->callOnSuccessCallables(); + return; + } + + $url = $this->getSuccessUrl(); + $this->callOnSuccessCallables(); + $this->notifySuccess($this->getSuccessMessage($message)); + $this->redirectAndExit($url); + } + + public function onFailure() + { + } + + public function notifySuccess($message = null) + { + if ($message === null) { + $message = t('Form has successfully been sent'); + } + Notification::success($message); + return $this; + } + + public function notifyError($message) + { + Notification::error($message); + return $this; + } + + protected function redirectAndExit($url) + { + /** @var Response $response */ + $response = Icinga::app()->getFrontController()->getResponse(); + $response->redirectAndExit($url); + } + + protected function setHttpResponseCode($code) + { + Icinga::app()->getFrontController()->getResponse()->setHttpResponseCode($code); + return $this; + } + + protected function onRequest() + { + $this->callOnRequestCallables(); + } + + public function setRequest(Request $request) + { + if ($this->request !== null) { + throw new RuntimeException('Unable to set request twice'); + } + + $this->request = $request; + $this->prepareElements(); + $this->onRequest(); + $this->callOnRequestCallables(); + + return $this; + } + + /** + * @return Request + */ + public function getRequest() + { + if ($this->request === null) { + /** @var Request $request */ + $request = Icinga::app()->getFrontController()->getRequest(); + $this->setRequest($request); + } + return $this->request; + } + + public function hasBeenSent() + { + if ($this->hasBeenSent === null) { + + /** @var Request $req */ + if ($this->request === null) { + $req = Icinga::app()->getFrontController()->getRequest(); + } else { + $req = $this->request; + } + + if ($req->isApiRequest()) { + $this->hasBeenSent = true; + } elseif ($req->isPost()) { + $post = $req->getPost(); + $this->hasBeenSent = array_key_exists(self::ID, $post) && + $post[self::ID] === $this->getName(); + } else { + $this->hasBeenSent = false; + } + } + + return $this->hasBeenSent; + } + + protected function detectName() + { + if ($this->formName !== null) { + $this->setName($this->formName); + } else { + $this->setName(get_class($this)); + } + } +} diff --git a/library/Director/Web/Form/QuickSubForm.php b/library/Director/Web/Form/QuickSubForm.php new file mode 100644 index 0000000..2487d35 --- /dev/null +++ b/library/Director/Web/Form/QuickSubForm.php @@ -0,0 +1,36 @@ +<?php + +namespace Icinga\Module\Director\Web\Form; + +abstract class QuickSubForm extends QuickBaseForm +{ + /** + * Whether or not form elements are members of an array + * @codingStandardsIgnoreStart + * @var bool + */ + protected $_isArray = true; + // @codingStandardsIgnoreEnd + + /** + * Load the default decorators + * + * @return $this + */ + public function loadDefaultDecorators() + { + if ($this->loadDefaultDecoratorsIsDisabled()) { + return $this; + } + + $decorators = $this->getDecorators(); + if (empty($decorators)) { + $this->addDecorator('FormElements') + ->addDecorator('HtmlTag', array('tag' => 'dl')) + ->addDecorator('Fieldset') + ->addDecorator('DtDdWrapper'); + } + + return $this; + } +} diff --git a/library/Director/Web/Form/Validate/IsDataListEntry.php b/library/Director/Web/Form/Validate/IsDataListEntry.php new file mode 100644 index 0000000..5762d2e --- /dev/null +++ b/library/Director/Web/Form/Validate/IsDataListEntry.php @@ -0,0 +1,55 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Validate; + +use Icinga\Module\Director\Db; +use Icinga\Module\Director\Objects\DirectorDatalistEntry; +use Zend_Validate_Abstract; + +class IsDataListEntry extends Zend_Validate_Abstract +{ + const INVALID = 'intInvalid'; + + /** @var Db */ + private $db; + + /** @var int */ + private $dataListId; + + public function __construct($dataListId, Db $db) + { + $this->db = $db; + $this->dataListId = (int) $dataListId; + } + + public function isValid($value) + { + if (is_array($value)) { + foreach ($value as $name) { + if (! $this->isListEntry($name)) { + $this->_error(self::INVALID, $value); + + return false; + } + } + + return true; + } + + if ($this->isListEntry($value)) { + return true; + } else { + $this->_error(self::INVALID, $value); + + return false; + } + } + + protected function isListEntry($name) + { + return DirectorDatalistEntry::exists([ + 'list_id' => $this->dataListId, + 'entry_name' => $name, + ], $this->db); + } +} diff --git a/library/Director/Web/Form/Validate/NamePattern.php b/library/Director/Web/Form/Validate/NamePattern.php new file mode 100644 index 0000000..fac44d9 --- /dev/null +++ b/library/Director/Web/Form/Validate/NamePattern.php @@ -0,0 +1,38 @@ +<?php + +namespace Icinga\Module\Director\Web\Form\Validate; + +use Icinga\Module\Director\Restriction\MatchingFilter; +use Zend_Validate_Abstract; + +class NamePattern extends Zend_Validate_Abstract +{ + const INVALID = 'intInvalid'; + + private $filter; + + public function __construct($pattern) + { + if (! is_array($pattern)) { + $pattern = [$pattern]; + } + + $this->filter = MatchingFilter::forPatterns($pattern, 'value'); + + $this->_messageTemplates[self::INVALID] = sprintf( + 'Does not match %s', + (string) $this->filter + ); + } + + public function isValid($value) + { + if ($this->filter->matches((object) ['value' => $value])) { + return true; + } else { + $this->_error(self::INVALID, $value); + + return false; + } + } +} |