diff --git a/.gitignore b/.gitignore index ce8fde9..4c169ac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ logs tmp vendor webroot/theme/node_modules +webroot/scss/*.css .vscode docker/run/ .phpunit.result.cache diff --git a/composer.json b/composer.json index 6d48735..1b826a2 100644 --- a/composer.json +++ b/composer.json @@ -19,9 +19,10 @@ "cakephp/bake": "^2.0.3", "cakephp/cakephp-codesniffer": "~4.0.0", "cakephp/debug_kit": "^4.0", + "cebe/php-openapi": "^1.6", "fzaninotto/faker": "^1.9", "josegonzalez/dotenv": "^3.2", - "league/openapi-psr7-validator": "^0.16.4", + "league/openapi-psr7-validator": "^0.17", "phpunit/phpunit": "^8.5", "psy/psysh": "@stable", "wiremock-php/wiremock-php": "^2.32" @@ -58,6 +59,11 @@ "nohup sh ./tests/Helper/wiremock/start.sh >/dev/null 2>&1 &", "phpunit", "sh ./tests/Helper/wiremock/stop.sh" + ], + "migrate": [ + "./bin/cake migrations migrate", + "./bin/cake migrations migrate -p tags", + "./bin/cake migrations migrate -p ADmad/SocialAuth" ] }, "prefer-stable": true, diff --git a/config/Migrations/20211025100313_MailingLists.php b/config/Migrations/20211025100313_MailingLists.php new file mode 100644 index 0000000..cabc846 --- /dev/null +++ b/config/Migrations/20211025100313_MailingLists.php @@ -0,0 +1,154 @@ +table('mailing_lists', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci', + ]); + $mailinglists + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'limit' => 10, + 'signed' => false, + ]) + ->addPrimaryKey('id') + ->addColumn('uuid', 'uuid', [ + 'default' => null, + 'null' => false, + ]) + ->addColumn('name', 'string', [ + 'default' => null, + 'null' => false, + 'limit' => 191, + 'comment' => 'The name of the mailing list', + ]) + ->addColumn('recipients', 'string', [ + 'default' => null, + 'null' => true, + 'limit' => 191, + 'comment' => 'Human-readable description of who the intended recipients.', + ]) + ->addColumn('description', 'text', [ + 'default' => null, + 'null' => true, + 'comment' => 'Additional description of the mailing list' + ]) + ->addColumn('user_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10, + ]) + ->addColumn('active', 'boolean', [ + 'default' => 0, + 'null' => false, + ]) + ->addColumn('deleted', 'boolean', [ + 'default' => 0, + 'null' => false, + ]) + ->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => false, + ]) + ->addColumn('modified', 'datetime', [ + 'default' => null, + 'null' => false, + ]); + + + $mailinglists->addForeignKey('user_id', 'users', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']); + + $mailinglists->addIndex(['uuid'], ['unique' => true]) + ->addIndex('name') + ->addIndex('recipients') + ->addIndex('user_id') + ->addIndex('active') + ->addIndex('deleted') + ->addIndex('created') + ->addIndex('modified'); + + $mailinglists->create(); + + + $mailinglists_individuals = $this->table('mailing_lists_individuals', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci', + ]); + + $mailinglists_individuals + ->addColumn('id', 'integer', [ + 'autoIncrement' => true, + 'limit' => 10, + 'signed' => false, + ]) + ->addPrimaryKey('id') + ->addColumn('mailing_list_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10, + ]) + ->addColumn('individual_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10, + ]) + ->addColumn('include_primary_email', 'boolean', [ + 'default' => 1, + 'null' => false, + 'comment' => 'Should the primary email address by included in the mailing list' + ]) + ->addForeignKey('mailing_list_id', 'mailing_lists', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->addForeignKey('individual_id', 'individuals', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']); + + $mailinglists_individuals->addIndex(['mailing_list_id', 'individual_id'], ['unique' => true]); + + $mailinglists_individuals->create(); + + + $mailinglists_metafields = $this->table('mailing_lists_meta_fields', [ + 'signed' => false, + 'collation' => 'utf8mb4_unicode_ci', + ]); + + $mailinglists_metafields + ->addColumn('mailing_list_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10, + ]) + ->addColumn('meta_field_id', 'integer', [ + 'default' => null, + 'null' => true, + 'signed' => false, + 'length' => 10, + ]) + ->addPrimaryKey(['mailing_list_id', 'meta_field_id']) + ->addForeignKey('mailing_list_id', 'mailing_lists', 'id', ['delete'=> 'CASCADE', 'update'=> 'CASCADE']) + ->addForeignKey('meta_field_id', 'meta_fields', 'id', ['delete'=> 'CASCADE', 'update'=> 'CASCADE']); + + $mailinglists_metafields->create(); + } +} + diff --git a/config/Migrations/20211104072514_MoreMetaFieldColumns.php b/config/Migrations/20211104072514_MoreMetaFieldColumns.php new file mode 100644 index 0000000..336c273 --- /dev/null +++ b/config/Migrations/20211104072514_MoreMetaFieldColumns.php @@ -0,0 +1,44 @@ +table('meta_fields'); + + $metaFieldsTable + ->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => false, + ]) + ->addColumn('modified', 'datetime', [ + 'default' => null, + 'null' => false, + ]) + ->update(); + + $metaFieldsTable + ->addIndex('created') + ->addIndex('modified'); + + $metaTemplateFieldsTable = $this->table('meta_template_fields') + ->addColumn('counter', 'integer', [ + 'default' => 0, + 'length' => 11, + 'null' => false, + 'signed' => false, + 'comment' => 'Field used by the CounterCache behaviour to count the occurence of meta_template_fields' + ]) + ->addForeignKey('meta_template_id', 'meta_templates', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->update(); + + $metaTemplate = $this->table('meta_templates') + ->removeIndex(['uuid']) + ->addIndex(['uuid', 'version']) + ->update(); + } +} \ No newline at end of file diff --git a/libraries/default/InboxProcessors/LocalToolInboxProcessor.php b/libraries/default/InboxProcessors/LocalToolInboxProcessor.php index 8852f9e..2dacab8 100644 --- a/libraries/default/InboxProcessors/LocalToolInboxProcessor.php +++ b/libraries/default/InboxProcessors/LocalToolInboxProcessor.php @@ -159,9 +159,9 @@ class LocalToolInboxProcessor extends GenericInboxProcessor { return $validator ->requirePresence('connectorName') - ->notEmpty('connectorName', 'The connector name must be provided') + ->notEmptyString('connectorName', 'The connector name must be provided') ->requirePresence('cerebrateURL') - ->notEmpty('cerebrateURL', 'A url must be provided') + ->notEmptyString('cerebrateURL', 'A url must be provided') ->requirePresence('local_tool_id') ->numeric('local_tool_id', 'A local_tool_id must be provided') ->requirePresence('remote_tool_id') diff --git a/libraries/default/InboxProcessors/UserInboxProcessor.php b/libraries/default/InboxProcessors/UserInboxProcessor.php index 53312d5..abb9550 100644 --- a/libraries/default/InboxProcessors/UserInboxProcessor.php +++ b/libraries/default/InboxProcessors/UserInboxProcessor.php @@ -37,13 +37,13 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr protected function addValidatorRules($validator) { return $validator - ->notEmpty('username', 'A username must be provided.') + ->notEmptyString('username', 'A username must be provided.') ->add('email', 'validFormat', [ 'rule' => 'email', 'message' => 'E-mail must be valid' ]) - ->notEmpty('first_name', 'A first name must be provided') - ->notEmpty('last_name', 'A last name must be provided') + ->notEmptyString('first_name', 'A first name must be provided') + ->notEmptyString('last_name', 'A last name must be provided') ->add('password', 'password_complexity', [ 'rule' => function($value, $context) { if (!preg_match('/^((?=.*\d)|(?=.*\W+))(?![\n])(?=.*[A-Z])(?=.*[a-z]).*$|.{16,}/s', $value) || strlen($value) < 12) { diff --git a/libraries/default/meta_fields/cerebrate_csirt_constituency.json b/libraries/default/meta_fields/cerebrate_csirt_constituency.json new file mode 100644 index 0000000..317e939 --- /dev/null +++ b/libraries/default/meta_fields/cerebrate_csirt_constituency.json @@ -0,0 +1,46 @@ +{ + "name": "CSIRT Constituency", + "namespace": "cerebrate", + "description": "Template meant to collect data about the constituency of a CSIRT", + "version": 1, + "scope": "organisation", + "uuid": "faca6acc-23e0-4585-8fd8-4379e3a6250d", + "source": "Cerebrate", + "metaFields": [ + { + "field": "IPv4 address", + "type": "ipv4", + "multiple": true + }, + { + "field": "IPv6 address", + "type": "ipv6", + "multiple": true + }, + { + "field": "AS Number", + "type": "text", + "multiple": true + }, + { + "field": "Domain", + "type": "text", + "multiple": true + }, + { + "field": "Country", + "type": "text", + "multiple": true + }, + { + "field": "Country Code", + "type": "text", + "multiple": true + }, + { + "field": "Sector", + "type": "text", + "multiple": true + } + ] +} diff --git a/libraries/default/meta_fields/cerebrate_individual_extended.json b/libraries/default/meta_fields/cerebrate_individual_extended.json new file mode 100644 index 0000000..e3999c1 --- /dev/null +++ b/libraries/default/meta_fields/cerebrate_individual_extended.json @@ -0,0 +1,21 @@ +{ + "name": "Cerebrate Individuals extended", + "namespace": "cerebrate", + "description": "Template to extend fields of individuals", + "version": 2, + "scope": "individual", + "uuid": "3bc374c8-3cdd-4900-823e-cc9100ad5179", + "source": "Cerebrate", + "metaFields": [ + { + "field": "alternate_email", + "type": "text", + "multiple": true + }, + { + "field": "mobile_phone", + "type": "text", + "multiple": true + } + ] +} diff --git a/libraries/default/meta_fields/enisa-csirt-inventory.json b/libraries/default/meta_fields/enisa-csirt-inventory.json index 9171a5c..cb89aa9 100644 --- a/libraries/default/meta_fields/enisa-csirt-inventory.json +++ b/libraries/default/meta_fields/enisa-csirt-inventory.json @@ -9,7 +9,7 @@ { "field": "website", "type": "text", - "regex": "(http(s)?:\\\/\\\/.)?(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&\/\/=]*)" + "regex": "https?:\\\/\\\/.+" }, { "field": "enisa-geo-group", @@ -50,7 +50,7 @@ { "field": "email", "type": "text", - "regex": "(?:[a-z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])" + "regex": "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+" }, { "field": "country-name", @@ -70,5 +70,5 @@ "scope": "organisation", "source": "enisa.europa.eu/topics/csirts-in-europe/csirt-inventory/certs-by-country-interactive-map", "uuid": "089c68c7-d97e-4f21-a798-159cd10f7864", - "version": 1 -} \ No newline at end of file + "version": 2 +} diff --git a/libraries/default/meta_fields/it_infrastructure_and_services.json b/libraries/default/meta_fields/it_infrastructure_and_services.json new file mode 100644 index 0000000..56e5867 --- /dev/null +++ b/libraries/default/meta_fields/it_infrastructure_and_services.json @@ -0,0 +1,36 @@ +{ + "name": "IT infrastructure and services", + "namespace": "cerebrate", + "description": "Offers the possiblity to register the IP of part of the infrastructure or services.", + "version": 2, + "scope": "organisation", + "uuid": "a7674718-57c8-40e7-969e-d26ca911cb4a", + "source": "Cerebrate", + "metaFields": [ + { + "field": "Microsoft Exchange Server IP", + "type": "text", + "multiple": true + }, + { + "field": "Microsfot Office 365 IP", + "type": "text", + "multiple": true + }, + { + "field": "Microsoft SharePoint IP", + "type": "text", + "multiple": true + }, + { + "field": "Microsoft Active Directory IP", + "type": "text", + "multiple": true + }, + { + "field": "Proxy IP", + "type": "text", + "multiple": true + } + ] +} diff --git a/plugins/Tags/src/View/Helper/TagHelper.php b/plugins/Tags/src/View/Helper/TagHelper.php index ab9122e..71b48d7 100644 --- a/plugins/Tags/src/View/Helper/TagHelper.php +++ b/plugins/Tags/src/View/Helper/TagHelper.php @@ -33,7 +33,7 @@ class TagHelper extends Helper 'data-text-colour' => h($tag['text_colour']), ]; }, $options['allTags']) : []; - $classes = ['tag-input', 'flex-grow-1']; + $classes = ['select2-input', 'flex-grow-1']; $url = ''; if (!empty($this->getConfig('editable'))) { $url = $this->Url->build([ diff --git a/plugins/Tags/templates/Tags/add.php b/plugins/Tags/templates/Tags/add.php index 89eb5e9..c8ecfe8 100644 --- a/plugins/Tags/templates/Tags/add.php +++ b/plugins/Tags/templates/Tags/add.php @@ -12,7 +12,6 @@ 'type' => 'color', ), ), - 'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates, 'submit' => array( 'action' => $this->request->getParam('action') ) diff --git a/plugins/Tags/templates/Tags/index.php b/plugins/Tags/templates/Tags/index.php index 4f1a041..5f2fded 100644 --- a/plugins/Tags/templates/Tags/index.php +++ b/plugins/Tags/templates/Tags/index.php @@ -20,7 +20,7 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value' diff --git a/plugins/Tags/webroot/js/tagging.js b/plugins/Tags/webroot/js/tagging.js index bcd9c3d..4f32b46 100644 --- a/plugins/Tags/webroot/js/tagging.js +++ b/plugins/Tags/webroot/js/tagging.js @@ -23,7 +23,7 @@ function createTagPicker(clicked) { const $clicked = $(clicked) const $container = $clicked.closest('.tag-container') - const $select = $container.parent().find('select.tag-input').removeClass('d-none') + const $select = $container.parent().find('select.select2-input').removeClass('d-none') closePicker($select, $container) const $pickerContainer = $('
').addClass(['picker-container', 'd-flex']) @@ -90,7 +90,7 @@ function refreshTagList(apiResult, $container) { } function initSelect2Pickers() { - $('select.tag-input').each(function() { + $('select.select2-input').each(function() { if (!$(this).hasClass("select2-hidden-accessible")) { initSelect2Picker($(this)) } diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 2438735..dc5b81f 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -79,6 +79,9 @@ class AppController extends Controller $this->loadComponent('Navigation', [ 'request' => $this->request, ]); + $this->loadComponent('Notification', [ + 'request' => $this->request, + ]); if (Configure::read('debug')) { Configure::write('DebugKit.panels', ['DebugKit.Packages' => true]); Configure::write('DebugKit.forceEnable', true); @@ -112,7 +115,6 @@ class AppController extends Controller } unset($user['password']); $this->ACL->setUser($user); - $this->Navigation->genBreadcrumbs($user); $this->request->getSession()->write('authUser', $user); $this->isAdmin = $user['role']['perm_admin']; if (!$this->ParamHandler->isRest()) { @@ -135,7 +137,6 @@ class AppController extends Controller $this->ACL->checkAccess(); if (!$this->ParamHandler->isRest()) { - $this->set('breadcrumb', $this->Navigation->getBreadcrumb()); $this->set('ajax', $this->request->is('ajax')); $this->request->getParam('prefix'); $this->set('baseurl', Configure::read('App.fullBaseUrl')); @@ -154,6 +155,16 @@ class AppController extends Controller } } + public function beforeRender(EventInterface $event) + { + if (!empty($this->request->getAttribute('identity'))) { + if (!$this->ParamHandler->isRest()) { + $this->set('breadcrumb', $this->Navigation->getBreadcrumb()); + $this->set('notifications', $this->Notification->getNotifications()); + } + } + } + private function authApiUser(): void { if (!empty($_SERVER['HTTP_AUTHORIZATION']) && strlen($_SERVER['HTTP_AUTHORIZATION'])) { diff --git a/src/Controller/AuditLogsController.php b/src/Controller/AuditLogsController.php index 27bee73..e72327d 100644 --- a/src/Controller/AuditLogsController.php +++ b/src/Controller/AuditLogsController.php @@ -11,8 +11,8 @@ use Cake\Core\Configure; class AuditLogsController extends AppController { - public $filterFields = ['model_id', 'model', 'request_action', 'user_id', 'title']; - public $quickFilterFields = ['model', 'request_action', 'title']; + public $filterFields = ['model_id', 'model', 'request_action', 'user_id', 'model_title']; + public $quickFilterFields = ['model', 'request_action', 'model_title']; public $containFields = ['Users']; public function index() @@ -31,6 +31,11 @@ class AuditLogsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'Administration'); } + + public function filtering() + { + $this->CRUD->filtering(); + } + } diff --git a/src/Controller/Component/ACLComponent.php b/src/Controller/Component/ACLComponent.php index 57180c2..f296030 100644 --- a/src/Controller/Component/ACLComponent.php +++ b/src/Controller/Component/ACLComponent.php @@ -47,7 +47,8 @@ class ACLComponent extends Component 'view' => ['*'] ], 'AuditLogs' => [ - 'index' => ['perm_admin'] + 'filtering' => ['perm_admin'], + 'index' => ['perm_admin'], ], 'AuthKeys' => [ 'add' => ['*'], @@ -119,16 +120,31 @@ class ACLComponent extends Component 'view' => ['perm_admin'], 'viewConnector' => ['perm_admin'] ], + 'MailingLists' => [ + "add" => ['perm_org_admin'], + "addIndividual" => ['perm_org_admin'], + "delete" => ['perm_org_admin'], + "edit" => ['perm_org_admin'], + "index" => ['*'], + "listIndividuals" => ['perm_org_admin'], + "removeIndividual" => ['perm_org_admin'], + "view" => ['*'], + ], 'MetaTemplateFields' => [ 'index' => ['perm_admin'] ], 'MetaTemplates' => [ + 'createNewTemplate' => ['perm_admin'], + 'delete' => ['perm_admin'], 'disable' => ['perm_admin'], 'enable' => ['perm_admin'], + 'getMetaFieldsToUpdate' => ['perm_admin'], 'index' => ['perm_admin'], + 'migrateOldMetaTemplateToNewestVersionForEntity' => ['perm_admin'], 'update' => ['perm_admin'], + 'updateAllTemplates' => ['perm_admin'], 'toggle' => ['perm_admin'], - 'view' => ['perm_admin'] + 'view' => ['perm_admin'], ], 'Organisations' => [ 'add' => ['perm_admin'], @@ -559,7 +575,6 @@ class ACLComponent extends Component continue; } } - $menu[$group][$subMenuElementName]['children'] = array_values($menu[$group][$subMenuElementName]['children']); if (empty($menu[$group][$subMenuElementName]['children'])) { unset($subMenu[$subMenuElementName]); } diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 41fe9d1..a09040c 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -6,10 +6,14 @@ use Cake\Controller\Component; use Cake\Error\Debugger; use Cake\Utility\Hash; use Cake\Utility\Inflector; +use Cake\Utility\Text; use Cake\View\ViewBuilder; use Cake\ORM\TableRegistry; +use Cake\Routing\Router; use Cake\Http\Exception\MethodNotAllowedException; use Cake\Http\Exception\NotFoundException; +use Cake\Collection\Collection; +use App\Utility\UI\IndexSetting; class CRUDComponent extends Component { @@ -33,6 +37,8 @@ class CRUDComponent extends Component $options['filters'] = []; } $options['filters'][] = 'quickFilter'; + } else { + $options['quickFilters'] = []; } $options['filters'][] = 'filteringLabel'; if ($this->taggingSupported()) { @@ -44,12 +50,13 @@ class CRUDComponent extends Component $optionFilters[] = "{$filter} !="; } $params = $this->Controller->ParamHandler->harvestParams($optionFilters); + $params = $this->fakeContextFilter($options, $params); $query = $this->Table->find(); if (!empty($options['filterFunction'])) { $query = $options['filterFunction']($query); } $query = $this->setFilters($params, $query, $options); - $query = $this->setQuickFilters($params, $query, empty($options['quickFilters']) ? [] : $options['quickFilters']); + $query = $this->setQuickFilters($params, $query, $options); if (!empty($options['conditions'])) { $query->where($options['conditions']); } @@ -73,37 +80,85 @@ class CRUDComponent extends Component } if (isset($options['afterFind'])) { $function = $options['afterFind']; - if (is_callable($options['afterFind'])) { - $function = $options['afterFind']; - $data->each(function($value, $key) use ($function) { + if (is_callable($function)) { + $data = $data->map(function($value, $key) use ($function) { return $function($value); + })->filter(function ($value) { + return $value !== false; }); } else { $t = $this->Table; - $data->each(function($value, $key) use ($t, $function) { + $data = $data->map(function($value, $key) use ($t, $function) { return $t->$function($value); + })->filter(function ($value) { + return $value !== false; }); } } $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json'); } else { + if ($this->metaFieldsSupported()) { + $query = $this->includeRequestedMetaFields($query); + } $this->Controller->loadComponent('Paginator'); $data = $this->Controller->Paginator->paginate($query, $this->Controller->paginate ?? []); if (isset($options['afterFind'])) { $function = $options['afterFind']; - if (is_callable($options['afterFind'])) { - $function = $options['afterFind']; - $data->each(function($value, $key) use ($function) { + if (is_callable($function)) { + $data = $data->map(function($value, $key) use ($function) { return $function($value); + })->filter(function($value) { + return $value !== false; }); } else { $t = $this->Table; - $data->each(function($value, $key) use ($t, $function) { + $data = $data->map(function($value, $key) use ($t, $function) { return $t->$function($value); + })->filter(function ($value) { + return $value !== false; }); } } $this->setFilteringContext($options['contextFilters'] ?? [], $params); + if ($this->metaFieldsSupported()) { + $data = $data->toArray(); + $metaTemplates = $this->getMetaTemplates()->toArray(); + foreach ($data as $i => $row) { + $data[$i] = $this->attachMetaTemplatesIfNeeded($row, $metaTemplates); + } + $this->Controller->set('meta_templates', $metaTemplates); + } + if (true) { // check if stats are requested + $modelStatistics = []; + if ($this->Table->hasBehavior('Timestamp')) { + $modelStatistics = $this->Table->getActivityStatisticsForModel( + $this->Table, + !is_numeric($this->request->getQuery('statistics_days')) ? 7 : $this->request->getQuery('statistics_days') + ); + } + if (!empty($options['statisticsFields'])) { + $statIncludeRemaining = $this->request->getQuery('statistics_include_remainging', true); + if (is_string($statIncludeRemaining)) { + $statIncludeRemaining = $statIncludeRemaining == 'true' ? true : false; + } + $statIgnoreNull = $this->request->getQuery('statistics_ignore_null', true); + if (is_string($statIgnoreNull)) { + $statIgnoreNull = $statIgnoreNull == 'true' ? true : false; + } + $statsOptions = [ + 'limit' => !is_numeric($this->request->getQuery('statistics_entry_amount')) ? 5 : $this->request->getQuery('statistics_entry_amount'), + 'includeOthers' => $statIncludeRemaining, + 'ignoreNull' => $statIgnoreNull, + ]; + $modelStatistics['usage'] = $this->Table->getStatisticsUsageForModel( + $this->Table, + $options['statisticsFields'], + $statsOptions + ); + } + $this->Controller->set('modelStatistics', $modelStatistics); + } + $this->Controller->set('model', $this->Table); $this->Controller->set('data', $data); } } @@ -113,8 +168,23 @@ class CRUDComponent extends Component if ($this->taggingSupported()) { $this->Controller->set('taggingEnabled', true); $this->setAllTags(); + } else { + $this->Controller->set('taggingEnabled', false); + } + if ($this->metaFieldsSupported()) { + $metaTemplates = $this->getMetaTemplates()->toArray(); + $this->Controller->set('metaFieldsEnabled', true); + $this->Controller->set('metaTemplates', $metaTemplates); + } else { + $this->Controller->set('metaFieldsEnabled', false); } $filters = !empty($this->Controller->filterFields) ? $this->Controller->filterFields : []; + $typeHandlers = $this->Table->getBehavior('MetaFields')->getTypeHandlers(); + $typeHandlersOperators = []; + foreach ($typeHandlers as $type => $handler) { + $typeHandlersOperators[$type] = $handler::OPERATORS; + } + $this->Controller->set('typeHandlersOperators', $typeHandlersOperators); $this->Controller->set('filters', $filters); $this->Controller->viewBuilder()->setLayout('ajax'); $this->Controller->render('/genericTemplates/filters'); @@ -135,28 +205,40 @@ class CRUDComponent extends Component return false; } - private function getMetaTemplates() + private function getMetaTemplates(array $metaTemplateConditions=[]) { $metaTemplates = []; - if (!empty($this->Table->metaFields)) { - $metaQuery = $this->MetaTemplates->find(); - $metaQuery - ->order(['is_default' => 'DESC']) - ->where([ - 'scope' => $this->Table->metaFields, - 'enabled' => 1 - ]); - $metaQuery->contain(['MetaTemplateFields']); - $metaTemplates = $metaQuery->all(); + if (!$this->metaFieldsSupported()) { + throw new \Exception(__("Table {$this->TableAlias} does not support meta_fields")); } - $this->Controller->set('metaTemplates', $metaTemplates); - return true; + $metaFieldsBehavior = $this->Table->getBehavior('MetaFields'); + $metaQuery = $this->MetaTemplates->find(); + $metaQuery + ->order(['is_default' => 'DESC']) + ->where(array_merge( + $metaTemplateConditions, + ['scope' => $metaFieldsBehavior->getScope(), ] + )) + ->contain('MetaTemplateFields') + ->formatResults(function (\Cake\Collection\CollectionInterface $metaTemplates) { // Set meta-template && meta-template-fields indexed by their ID + return $metaTemplates + ->map(function ($metaTemplate) { + $metaTemplate->meta_template_fields = Hash::combine($metaTemplate->meta_template_fields, '{n}.id', '{n}'); + return $metaTemplate; + }) + ->indexBy('id'); + }); + $metaTemplates = $metaQuery->all(); + return $metaTemplates; } public function add(array $params = []): void { - $this->getMetaTemplates(); $data = $this->Table->newEmptyEntity(); + if ($this->metaFieldsSupported()) { + $metaTemplates = $this->getMetaTemplates(); + $data = $this->attachMetaTemplatesIfNeeded($data, $metaTemplates->toArray()); + } if ($this->request->is('post')) { $patchEntityParams = [ 'associated' => [], @@ -175,6 +257,11 @@ class CRUDComponent extends Component throw new NotFoundException(__('Could not save {0} due to the marshaling failing. Your input is bad and you should feel bad.', $this->ObjectAlias)); } } + if ($this->metaFieldsSupported()) { + $massagedData = $this->massageMetaFields($data, $input, $metaTemplates); + unset($input['MetaTemplates']); // Avoid MetaTemplates to be overriden when patching entity + $data = $massagedData['entity']; + } $data = $this->Table->patchEntity($data, $input, $patchEntityParams); if (isset($params['beforeSave'])) { $data = $params['beforeSave']($data); @@ -188,9 +275,6 @@ class CRUDComponent extends Component $params['afterSave']($data); } $message = __('{0} added.', $this->ObjectAlias); - if (!empty($input['metaFields'])) { - $this->saveMetaFields($data->id, $input); - } if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($savedData, 'json'); } else if ($this->Controller->ParamHandler->isAjax()) { @@ -215,7 +299,7 @@ class CRUDComponent extends Component $message = __( '{0} could not be added.{1}', $this->ObjectAlias, - empty($validationMessage) ? '' : PHP_EOL . __('Reason:{0}', $validationMessage) + empty($validationMessage) ? '' : PHP_EOL . __('Reason: {0}', $validationMessage) ); if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($message, 'json'); @@ -255,9 +339,20 @@ class CRUDComponent extends Component foreach ($data->getErrors() as $field => $errorData) { $errorMessages = []; foreach ($errorData as $key => $value) { - $errorMessages[] = $value; + if (is_array($value)) { + $extracted = Hash::extract($value, "{s}.{s}"); + if (!empty($extracted)) { + $errorMessages[] = implode('& ', $extracted); + } + } else { + if (!empty($value)) { + $errorMessages[] = $value; + } + } + } + if (!empty($errorMessages)) { + $validationMessage .= __('{0}: {1}', $field, implode(',', $errorMessages)); } - $validationMessage .= __('{0}: {1}', $field, implode(',', $errorMessages)); } } return $validationMessage; @@ -268,6 +363,93 @@ class CRUDComponent extends Component $this->Table->saveMetaFields($id, $input, $this->Table); } + // prune empty values and marshall fields + public function massageMetaFields($entity, $input, $allMetaTemplates=[]) + { + if (empty($input['MetaTemplates']) || !$this->metaFieldsSupported()) { + return ['entity' => $entity, 'metafields_to_delete' => []]; + } + + $metaFieldsTable = TableRegistry::getTableLocator()->get('MetaFields'); + $metaFieldsIndex = []; + if (empty($metaTemplates)) { + $allMetaTemplates = $this->getMetaTemplates()->toArray(); + } + if (!empty($entity->meta_fields)) { + foreach ($entity->meta_fields as $i => $metaField) { + $metaFieldsIndex[$metaField->id] = $i; + } + } else { + $entity->meta_fields = []; + } + + $metaFieldsToDelete = []; + foreach ($input['MetaTemplates'] as $template_id => $template) { + foreach ($template['meta_template_fields'] as $meta_template_field_id => $meta_template_field) { + $rawMetaTemplateField = $allMetaTemplates[$template_id]['meta_template_fields'][$meta_template_field_id]; + foreach ($meta_template_field['metaFields'] as $meta_field_id => $meta_field) { + if ($meta_field_id == 'new') { // create new meta_field + $new_meta_fields = $meta_field; + foreach ($new_meta_fields as $new_value) { + if (!empty($new_value)) { + $metaField = $metaFieldsTable->newEmptyEntity(); + $metaFieldsTable->patchEntity($metaField, [ + 'value' => $new_value, + 'scope' => $this->Table->getBehavior('MetaFields')->getScope(), + 'field' => $rawMetaTemplateField->field, + 'meta_template_id' => $rawMetaTemplateField->meta_template_id, + 'meta_template_field_id' => $rawMetaTemplateField->id, + 'parent_id' => $entity->id, + 'uuid' => Text::uuid(), + ]); + $entity->meta_fields[] = $metaField; + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[] = $metaField; + } + } + } else { + $new_value = $meta_field['value']; + if (!empty($new_value)) { // update meta_field and attach validation errors + if (isset($metaFieldsIndex[$meta_field_id])) { + $index = $metaFieldsIndex[$meta_field_id]; + if ($entity->meta_fields[$index]->value != $new_value) { // nothing to do, value hasn't changed + $metaFieldsTable->patchEntity($entity->meta_fields[$index], [ + 'value' => $new_value, 'meta_template_field_id' => $rawMetaTemplateField->id + ], ['value']); + $metaFieldsTable->patchEntity( + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[$meta_field_id], + ['value' => $new_value, 'meta_template_field_id' => $rawMetaTemplateField->id], + ['value'] + ); + } + } else { // metafield comes from a second post where the temporary entity has already been created + $metaField = $metaFieldsTable->newEmptyEntity(); + $metaFieldsTable->patchEntity($metaField, [ + 'value' => $new_value, + 'scope' => $this->Table->getBehavior('MetaFields')->getScope(), // get scope from behavior + 'field' => $rawMetaTemplateField->field, + 'meta_template_id' => $rawMetaTemplateField->meta_template_id, + 'meta_template_field_id' => $rawMetaTemplateField->id, + 'parent_id' => $entity->id, + 'uuid' => Text::uuid(), + ]); + $entity->meta_fields[] = $metaField; + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[] = $metaField; + } + } else { // Metafield value is empty, indicating the field should be removed + $index = $metaFieldsIndex[$meta_field_id]; + $metaFieldsToDelete[] = $entity->meta_fields[$index]; + unset($entity->meta_fields[$index]); + unset($entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[$meta_field_id]); + } + } + } + } + } + + $entity->setDirty('meta_fields', true); + return ['entity' => $entity, 'metafields_to_delete' => $metaFieldsToDelete]; + } + private function __massageInput($params) { $input = $this->request->getData(); @@ -291,23 +473,38 @@ class CRUDComponent extends Component if (empty($id)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } - $this->getMetaTemplates(); if ($this->taggingSupported()) { $params['contain'][] = 'Tags'; $this->setAllTags(); } - $data = $this->Table->find()->where(['id' => $id]); - if (!empty($params['conditions'])) { - $data->where($params['conditions']); + if ($this->metaFieldsSupported()) { + if (empty($params['contain'])) { + $params['contain'] = []; + } + if (is_array($params['contain'])) { + $params['contain'][] = 'MetaFields'; + } else { + $params['contain'] = [$params['contain'], 'MetaFields']; + } } - $data = $data->first(); + $query = $this->Table->find()->where(['id' => $id]); + if (!empty($params['contain'])) { + $query->contain($params['contain']); + } + if (!empty($params['conditions'])) { + $query->where($params['conditions']); + } + $data = $query->first(); if (isset($params['afterFind'])) { $data = $params['afterFind']($data, $params); } if (empty($data)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } - $data = $this->getMetaFields($id, $data); + if ($this->metaFieldsSupported()) { + $metaTemplates = $this->getMetaTemplates(); + $data = $this->attachMetaTemplatesIfNeeded($data, $metaTemplates->toArray()); + } if ($this->request->is(['post', 'put'])) { $patchEntityParams = [ 'associated' => [] @@ -322,6 +519,12 @@ class CRUDComponent extends Component throw new NotFoundException(__('Could not save {0} due to the marshaling failing. Your input is bad and you should feel bad.', $this->ObjectAlias)); } } + if ($this->metaFieldsSupported()) { + $massagedData = $this->massageMetaFields($data, $input, $metaTemplates); + unset($input['MetaTemplates']); // Avoid MetaTemplates to be overriden when patching entity + $data = $massagedData['entity']; + $metaFieldsToDelete = $massagedData['metafields_to_delete']; + } $data = $this->Table->patchEntity($data, $input, $patchEntityParams); if (isset($params['beforeSave'])) { $data = $params['beforeSave']($data); @@ -331,14 +534,13 @@ class CRUDComponent extends Component } $savedData = $this->Table->save($data); if ($savedData !== false) { + if ($this->metaFieldsSupported() && !empty($metaFieldsToDelete)) { + $this->Table->MetaFields->unlink($savedData, $metaFieldsToDelete); + } if (isset($params['afterSave'])) { $params['afterSave']($data); } $message = __('{0} `{1}` updated.', $this->ObjectAlias, $savedData->{$this->Table->getDisplayField()}); - if (!empty($input['metaFields'])) { - $this->MetaFields->deleteAll(['scope' => $this->Table->metaFields, 'parent_id' => $savedData->id]); - $this->saveMetaFields($savedData->id, $input); - } if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($savedData, 'json'); } else if ($this->Controller->ParamHandler->isAjax()) { @@ -353,11 +555,11 @@ class CRUDComponent extends Component } } else { $validationErrors = $data->getErrors(); - $validationMessage = $this->prepareValidationMessage($validationErrors); + $validationMessage = $this->prepareValidationError($data); $message = __( '{0} could not be modified.{1}', $this->ObjectAlias, - empty($validationMessage) ? '' : PHP_EOL . __('Reason:{0}', $validationMessage) + empty($validationMessage) ? '' : PHP_EOL . __('Reason: {0}', $validationMessage) ); if ($this->Controller->ParamHandler->isRest()) { } else if ($this->Controller->ParamHandler->isAjax()) { @@ -376,10 +578,10 @@ class CRUDComponent extends Component public function attachMetaData($id, $data) { - if (empty($this->Table->metaFields)) { - return $data; + if (!$this->metaFieldsSupported()) { + throw new \Exception(__("Table {$this->TableAlias} does not support meta_fields")); } - $metaFieldScope = $this->Table->metaFields; + $metaFieldScope = $this->Table->getBehavior('MetaFields')->getScope(); $query = $this->MetaTemplates->find()->where(['MetaTemplates.scope' => $metaFieldScope]); $query->contain(['MetaTemplateFields.MetaFields' => function ($q) use ($id, $metaFieldScope) { return $q->where(['MetaFields.scope' => $metaFieldScope, 'MetaFields.parent_id' => $id]); @@ -408,21 +610,80 @@ class CRUDComponent extends Component return $metaTemplates; } - public function getMetaFields($id, $data) + public function getMetaFields($id) { - if (empty($this->Table->metaFields)) { - return $data; + if (!$this->metaFieldsSupported()) { + throw new \Exception(__("Table {$this->TableAlias} does not support meta_fields")); } $query = $this->MetaFields->find(); - $query->where(['MetaFields.scope' => $this->Table->metaFields, 'MetaFields.parent_id' => $id]); + $query->where(['MetaFields.scope' => $this->Table->getBehavior('MetaFields')->getScope(), 'MetaFields.parent_id' => $id]); $metaFields = $query->all(); - $data['metaFields'] = []; - foreach($metaFields as $metaField) { - $data['metaFields'][$metaField->meta_template_id][$metaField->field] = $metaField->value; + $data = []; + foreach ($metaFields as $metaField) { + if (empty($data[$metaField->meta_template_id][$metaField->meta_template_field_id])) { + $data[$metaField->meta_template_id][$metaField->meta_template_field_id] = []; + } + $data[$metaField->meta_template_id][$metaField->meta_template_field_id][$metaField->id] = $metaField; } return $data; } + public function attachMetaTemplates($data, $metaTemplates, $pruneEmptyDisabled=true) + { + $this->MetaTemplates = TableRegistry::getTableLocator()->get('MetaTemplates'); + $metaFields = []; + if (!empty($data->id)) { + $metaFields = $this->getMetaFields($data->id); + } + foreach ($metaTemplates as $i => $metaTemplate) { + if (isset($metaFields[$metaTemplate->id])) { + foreach ($metaTemplate->meta_template_fields as $j => $meta_template_field) { + if (isset($metaFields[$metaTemplate->id][$meta_template_field->id])) { + $metaTemplates[$metaTemplate->id]->meta_template_fields[$j]['metaFields'] = $metaFields[$metaTemplate->id][$meta_template_field->id]; + } else { + $metaTemplates[$metaTemplate->id]->meta_template_fields[$j]['metaFields'] = []; + } + } + } else { + if (!empty($pruneEmptyDisabled) && !$metaTemplate->enabled) { + unset($metaTemplates[$i]); + } + } + $newestTemplate = $this->MetaTemplates->getNewestVersion($metaTemplate); + if (!empty($newestTemplate) && !empty($metaTemplates[$i])) { + $metaTemplates[$i]['hasNewerVersion'] = $newestTemplate; + } + } + $data['MetaTemplates'] = $metaTemplates; + return $data; + } + + protected function includeRequestedMetaFields($query) + { + $user = $this->Controller->ACL->getUser(); + $tableSettings = IndexSetting::getTableSetting($user, $this->Table); + if (empty($tableSettings['visible_meta_column'])) { + return $query; + } + $containConditions = ['OR' => []]; + $requestedMetaFields = []; + foreach ($tableSettings['visible_meta_column'] as $template_id => $fields) { + $containConditions['OR'][] = [ + 'meta_template_id' => $template_id, + 'meta_template_field_id IN' => array_map('intval', $fields), + ]; + foreach ($fields as $field) { + $requestedMetaFields[] = ['template_id' => $template_id, 'meta_template_field_id' => intval($field)]; + } + } + $this->Controller->set('requestedMetaFields', $requestedMetaFields); + return $query->contain([ + 'MetaFields' => [ + 'conditions' => $containConditions + ] + ]); + } + public function view(int $id, array $params = []): void { if (empty($id)) { @@ -433,12 +694,24 @@ class CRUDComponent extends Component $params['contain'][] = 'Tags'; $this->setAllTags(); } + if ($this->metaFieldsSupported()) { + if (!empty($this->request->getQuery('full'))) { + $params['contain']['MetaFields'] = ['MetaTemplateFields' => 'MetaTemplates']; + } else { + $params['contain'][] = 'MetaFields'; + } + } $data = $this->Table->get($id, $params); if (empty($data)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } - $data = $this->attachMetaData($id, $data); + if ($this->metaFieldsSupported() && !empty($data['meta_fields'])) { + $usedMetaTemplateIDs = array_values(array_unique(Hash::extract($data['meta_fields'], '{n}.meta_template_id'))); + $data = $this->attachMetaTemplatesIfNeeded($data, null, [ + 'id IN' => $usedMetaTemplateIDs + ]); + } if (isset($params['afterFind'])) { $data = $params['afterFind']($data); } @@ -451,18 +724,38 @@ class CRUDComponent extends Component $this->Controller->set('entity', $data); } + public function attachMetaTemplatesIfNeeded($data, array $metaTemplates = null, array $metaTemplateConditions=[]) + { + if (!$this->metaFieldsSupported()) { + return $data; + } + if (!is_null($metaTemplates)) { + // We might be in the case where $metaTemplates gets re-used in a while loop + // We deep copy the meta-template so that the data attached is not preserved for the next iteration + $metaTemplates = array_map(function ($metaTemplate) { + $tmpEntity = $this->MetaTemplates->newEntity($metaTemplate->toArray()); + $tmpEntity['meta_template_fields'] = Hash::combine($tmpEntity['meta_template_fields'], '{n}.id', '{n}'); // newEntity resets array indexing, see https://github.com/cakephp/cakephp/blob/32e3c532fea8abe2db8b697f07dfddf4dfc134ca/src/ORM/Marshaller.php#L369 + return $tmpEntity; + }, $metaTemplates); + } else { + $metaTemplates = $this->getMetaTemplates($metaTemplateConditions)->toArray(); + } + $data = $this->attachMetaTemplates($data, $metaTemplates); + return $data; + } + public function delete($id=false, $params=[]): void { if ($this->request->is('get')) { if(!empty($id)) { - $data = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); + $query = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); if (!empty($params['conditions'])) { - $data->where($params['conditions']); + $query->where($params['conditions']); } if (!empty($params['contain'])) { - $data->contain($params['contain']); + $query->contain($params['contain']); } - $data = $data->first(); + $data = $query->first(); if (empty($data)) { throw new NotFoundException(__('Invalid {0}.', $this->ObjectAlias)); } @@ -483,18 +776,20 @@ class CRUDComponent extends Component $isBulk = count($ids) > 1; $bulkSuccesses = 0; foreach ($ids as $id) { - $skipExecution = false; - $data = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); + $query = $this->Table->find()->where([$this->Table->getAlias() . '.id' => $id]); if (!empty($params['conditions'])) { - $data->where($params['conditions']); + $query->where($params['conditions']); } if (!empty($params['contain'])) { - $data->contain($params['contain']); + $query->contain($params['contain']); } - $data = $data->first(); + $data = $query->first(); if (isset($params['beforeSave'])) { try { $data = $params['beforeSave']($data); + if ($data === false) { + throw new NotFoundException(__('Could not save {0} due to the input failing to meet expectations. Your input is bad and you should feel bad.', $this->ObjectAlias)); + } } catch (Exception $e) { $data = false; } @@ -515,28 +810,32 @@ class CRUDComponent extends Component __('{0} deleted.', $this->ObjectAlias), __('All {0} have been deleted.', Inflector::pluralize($this->ObjectAlias)), __('Could not delete {0}.', $this->ObjectAlias), - __('{0} / {1} {2} have been deleted.', + __( + '{0} / {1} {2} have been deleted.', $bulkSuccesses, count($ids), Inflector::pluralize($this->ObjectAlias) ) ); - $this->setResponseForController('delete', $bulkSuccesses, $message, $data); + $additionalData = []; + if ($bulkSuccesses > 0) { + $additionalData['redirect'] = Router::url(['controller' => $this->Controller->getName(), 'action' => 'index']); + } + $this->setResponseForController('delete', $bulkSuccesses, $message, $data, null, $additionalData); } - $this->Controller->set('metaGroup', 'ContactDB'); $this->Controller->set('scope', 'users'); $this->Controller->viewBuilder()->setLayout('ajax'); $this->Controller->render('/genericTemplates/delete'); } - public function tag($id=false): void + public function tag($id = false): void { if (!$this->taggingSupported()) { throw new Exception("Table {$this->TableAlias} does not support tagging"); } if ($this->request->is('get')) { $this->setAllTags(); - if(!empty($id)) { + if (!empty($id)) { $params = [ 'contain' => 'Tags', ]; @@ -576,7 +875,8 @@ class CRUDComponent extends Component __('{0} tagged with `{1}`.', $this->ObjectAlias, $input['tag_list']), __('All {0} have been tagged.', Inflector::pluralize($this->ObjectAlias)), __('Could not tag {0} with `{1}`.', $this->ObjectAlias, $input['tag_list']), - __('{0} / {1} {2} have been tagged.', + __( + '{0} / {1} {2} have been tagged.', $bulkSuccesses, count($ids), Inflector::pluralize($this->ObjectAlias) @@ -588,14 +888,14 @@ class CRUDComponent extends Component $this->Controller->render('/genericTemplates/tagForm'); } - public function untag($id=false): void + public function untag($id = false): void { if (!$this->taggingSupported()) { throw new Exception("Table {$this->TableAlias} does not support tagging"); } if ($this->request->is('get')) { $this->setAllTags(); - if(!empty($id)) { + if (!empty($id)) { $params = [ 'contain' => 'Tags', ]; @@ -637,7 +937,8 @@ class CRUDComponent extends Component __('{0} untagged with `{1}`.', $this->ObjectAlias, implode(', ', $tagsToRemove)), __('All {0} have been untagged.', Inflector::pluralize($this->ObjectAlias)), __('Could not untag {0} with `{1}`.', $this->ObjectAlias, $input['tag_list']), - __('{0} / {1} {2} have been untagged.', + __( + '{0} / {1} {2} have been untagged.', $bulkSuccesses, count($ids), Inflector::pluralize($this->ObjectAlias) @@ -672,13 +973,16 @@ class CRUDComponent extends Component $this->Controller->render('/genericTemplates/tag'); } - public function setResponseForController($action, $success, $message, $data=[], $errors=null) + public function setResponseForController($action, $success, $message, $data = [], $errors = null, $additionalData = []) { if ($success) { if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json'); } elseif ($this->Controller->ParamHandler->isAjax()) { - $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxSuccessResponse($this->ObjectAlias, $action, $data, $message); + if (!empty($additionalData['redirect'])) { // If a redirection occurs, we need to make sure the flash message gets displayed + $this->Controller->Flash->success($message); + } + $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxSuccessResponse($this->ObjectAlias, $action, $data, $message, $additionalData); } else { $this->Controller->Flash->success($message); $this->Controller->redirect($this->Controller->referer()); @@ -687,6 +991,9 @@ class CRUDComponent extends Component if ($this->Controller->ParamHandler->isRest()) { $this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json'); } elseif ($this->Controller->ParamHandler->isAjax()) { + if (!empty($additionalData['redirect'])) { // If a redirection occurs, we need to make sure the flash message gets displayed + $this->Controller->Flash->error($message); + } $this->Controller->ajaxResponsePayload = $this->RestResponse->ajaxFailResponse($this->ObjectAlias, $action, $data, $message, !is_null($errors) ? $errors : $data->getErrors()); } else { $this->Controller->Flash->error($message); @@ -712,7 +1019,7 @@ class CRUDComponent extends Component * @return Array The ID converted to a list or the list of provided IDs from the request * @throws NotFoundException when no ID could be found */ - public function getIdsOrFail($id=false): Array + public function getIdsOrFail($id = false): array { $params = $this->Controller->ParamHandler->harvestParams(['ids']); if (!empty($params['ids'])) { @@ -763,22 +1070,27 @@ class CRUDComponent extends Component return $massagedFilters; } - public function setQuickFilters(array $params, \Cake\ORM\Query $query, array $quickFilterFields): \Cake\ORM\Query + public function setQuickFilters(array $params, \Cake\ORM\Query $query, array $options): \Cake\ORM\Query { + $quickFilterFields = $options['quickFilters']; $queryConditions = []; $this->Controller->set('quickFilter', empty($quickFilterFields) ? [] : $quickFilterFields); + if ($this->metaFieldsSupported() && !empty($options['quickFilterForMetaField']['enabled'])) { + $this->Controller->set('quickFilterForMetaField', [ + 'enabled' => $options['quickFilterForMetaField']['enabled'] ?? false, + 'wildcard_search' => $options['quickFilterForMetaField']['enabled'] ?? false, + ]); + } if (!empty($params['quickFilter']) && !empty($quickFilterFields)) { $this->Controller->set('quickFilterValue', $params['quickFilter']); - foreach ($quickFilterFields as $filterField) { - $likeCondition = false; - if (is_array($filterField)) { - $likeCondition = reset($filterField); - $filterFieldName = array_key_first($filterField); - $queryConditions[$filterFieldName . ' LIKE'] = '%' . $params['quickFilter'] .'%'; - } else { - $queryConditions[$filterField] = $params['quickFilter']; - } + $queryConditions = $this->genQuickFilterConditions($params, $quickFilterFields); + + if ($this->metaFieldsSupported() && !empty($options['quickFilterForMetaField']['enabled'])) { + $searchValue = !empty($options['quickFilterForMetaField']['wildcard_search']) ? "%{$params['quickFilter']}%" : $params['quickFilter']; + $metaFieldConditions = $this->Table->buildMetaFieldQuerySnippetForMatchingParent(['value' => $searchValue]); + $queryConditions[] = $metaFieldConditions; } + $query->where(['OR' => $queryConditions]); } else { $this->Controller->set('quickFilterValue', ''); @@ -786,6 +1098,25 @@ class CRUDComponent extends Component return $query; } + public function genQuickFilterConditions(array $params, array $quickFilterFields): array + { + $queryConditions = []; + foreach ($quickFilterFields as $filterField) { + if (is_array($filterField)) { + reset($filterField); + $filterFieldName = array_key_first($filterField); + if (!empty($filterField[$filterFieldName])) { + $queryConditions[$filterFieldName . ' LIKE'] = '%' . $params['quickFilter'] . '%'; + } else { + $queryConditions[$filterField] = $params['quickFilter']; + } + } else { + $queryConditions[$filterField] = $params['quickFilter']; + } + } + return $queryConditions; + } + protected function setFilters($params, \Cake\ORM\Query $query, array $options): \Cake\ORM\Query { $filteringLabel = !empty($params['filteringLabel']) ? $params['filteringLabel'] : ''; @@ -793,7 +1124,7 @@ class CRUDComponent extends Component $filteringTags = !empty($params['filteringTags']) && $this->taggingSupported() ? $params['filteringTags'] : ''; unset($params['filteringTags']); $customFilteringFunction = ''; - $chosenFilter = ''; + $chosenFilter = []; if (!empty($options['contextFilters']['custom'])) { foreach ($options['contextFilters']['custom'] as $filter) { if ($filter['label'] == $filteringLabel) { @@ -816,10 +1147,10 @@ class CRUDComponent extends Component } if (!empty($params['simpleFilters'])) { foreach ($params['simpleFilters'] as $filter => $filterValue) { - $activeFilters[$filter] = $filterValue; if ($filter === 'quickFilter') { continue; } + $activeFilters[$filter] = $filterValue; if (is_array($filterValue)) { $query->where([($filter . ' IN') => $filterValue]); } else { @@ -841,20 +1172,39 @@ class CRUDComponent extends Component $query = $this->setTagFilters($query, $filteringTags); } + if ($this->metaFieldsSupported()) { + $filteringMetaFields = $this->getMetaFieldFiltersFromQuery(); + if (!empty($filteringMetaFields)) { + $activeFilters['filteringMetaFields'] = $filteringMetaFields; + } + $query = $this->setMetaFieldFilters($query, $filteringMetaFields); + } + $this->Controller->set('activeFilters', $activeFilters); return $query; } + protected function setMetaFieldFilters($query, $metaFieldFilters) + { + $metaFieldConditions = $this->Table->buildMetaFieldQuerySnippetForMatchingParent($metaFieldFilters); + $query->where($metaFieldConditions); + + return $query; + } + protected function setTagFilters($query, $tags) { $modelAlias = $this->Table->getAlias(); $subQuery = $this->Table->find('tagged', [ 'name' => $tags, - 'forceAnd' => true + 'OperatorAND' => true ])->select($modelAlias . '.id'); return $query->where([$modelAlias . '.id IN' => $subQuery]); } + // FIXME: Adding related condition with association having `through` setup might include duplicate in the result set + // We should probably rely on `innerJoinWith` and perform deduplication via `distinct` + // Or grouping by primary key for the main model (however this is not optimal/efficient/clean) protected function setNestedRelatedCondition($query, $filterParts, $filterValue) { $modelName = $filterParts[0]; @@ -863,7 +1213,7 @@ class CRUDComponent extends Component $query = $this->setRelatedCondition($query, $modelName, $fieldName, $filterValue); } else { $filterParts = array_slice($filterParts, 1); - $query = $query->matching($modelName, function(\Cake\ORM\Query $q) use ($filterParts, $filterValue) { + $query = $query->matching($modelName, function (\Cake\ORM\Query $q) use ($filterParts, $filterValue) { return $this->setNestedRelatedCondition($q, $filterParts, $filterValue); }); } @@ -872,7 +1222,7 @@ class CRUDComponent extends Component protected function setRelatedCondition($query, $modelName, $fieldName, $filterValue) { - return $query->matching($modelName, function(\Cake\ORM\Query $q) use ($fieldName, $filterValue) { + return $query->matching($modelName, function (\Cake\ORM\Query $q) use ($fieldName, $filterValue) { return $this->setValueCondition($q, $fieldName, $filterValue); }); } @@ -891,8 +1241,14 @@ class CRUDComponent extends Component protected function setFilteringContext($contextFilters, $params) { $filteringContexts = []; - if (!isset($contextFilters['allow_all']) || $contextFilters['allow_all']) { - $filteringContexts[] = ['label' => __('All')]; + if ( + !isset($contextFilters['_all']) || + !isset($contextFilters['_all']['enabled']) || + !empty($contextFilters['_all']['enabled']) + ) { + $filteringContexts[] = [ + 'label' => !empty($contextFilters['_all']['text']) ? h($contextFilters['_all']['text']) : __('All') + ]; } if (!empty($contextFilters['fields'])) { foreach ($contextFilters['fields'] as $field) { @@ -918,6 +1274,28 @@ class CRUDComponent extends Component $this->Controller->set('filteringContexts', $filteringContexts); } + /** + * Create a fake filtering label set to the filter to be used by default if the request does not supply one + * This fake filtering label will then be used to set approriate filters on the query + * + * @param array $options CRUD options + * @param array $params Collected params from the request + * @return array + */ + protected function fakeContextFilter($options, $params): array + { + if (empty($params['filteringLabel']) && !empty($options['contextFilters']['custom'])) { + foreach ($options['contextFilters']['custom'] as $contextFilter) { + if (!empty($contextFilter['default'])) { + $params['filteringLabel'] = $contextFilter['label']; + $this->Controller->set('fakeFilteringLabel', $contextFilter['label']); + break; + } + } + } + return $params; + } + public function setParentConditionsForMetaFields($query, array $metaConditions) { $metaTemplates = $this->MetaFields->MetaTemplates->find('list', [ @@ -942,7 +1320,7 @@ class CRUDComponent extends Component throw new Exception('Invalid passed conditions'); } foreach ($metaANDConditions as $i => $conditions) { - $metaANDConditions[$i]['scope'] = $this->Table->metaFields; + $metaANDConditions[$i]['scope'] = $this->Table->getBehavior('MetaFields')->getScope(); } $firstCondition = $this->prefixConditions('MetaFields', $metaANDConditions[0]); $conditionsToJoin = array_slice($metaANDConditions, 1); @@ -974,6 +1352,11 @@ class CRUDComponent extends Component return $this->Table->behaviors()->has('Tag'); } + public function metaFieldsSupported() + { + return $this->Table->hasBehavior('MetaFields'); + } + public function setAllTags() { $this->Tags = TableRegistry::getTableLocator()->get('Tags.Tags'); @@ -999,7 +1382,8 @@ class CRUDComponent extends Component } $savedData = $this->Table->save($data); if ($savedData !== false) { - $message = __('{0} field {1}. (ID: {2} {3})', + $message = __( + '{0} field {1}. (ID: {2} {3})', $fieldName, $data->{$fieldName} ? __('enabled') : __('disabled'), Inflector::humanize($this->ObjectAlias), @@ -1023,7 +1407,7 @@ class CRUDComponent extends Component $message = __( '{0} could not be modified.{1}', $this->ObjectAlias, - empty($validationMessage) ? '' : ' ' . __('Reason:{0}', $validationMessage) + empty($validationMessage) ? '' : ' ' . __('Reason: {0}', $validationMessage) ); if ($this->Controller->ParamHandler->isRest()) { } else if ($this->Controller->ParamHandler->isAjax()) { @@ -1081,9 +1465,9 @@ class CRUDComponent extends Component [$this->Table->getAlias() => $this->Table->getTable()], [sprintf('%s.id = %s.%s', $this->Table->getAlias(), $associatedTable->getAlias(), $association->getForeignKey())] ) - ->where([ - ["${field} IS NOT" => NULL] - ]); + ->where([ + ["${field} IS NOT" => NULL] + ]); } else if ($associationType == 'manyToOne') { $fieldToExtract = sprintf('%s.%s', Inflector::singularize(strtolower($model)), $subField); $query = $this->Table->find()->contain($model); @@ -1100,6 +1484,25 @@ class CRUDComponent extends Component ->toList(); } + private function getMetaFieldFiltersFromQuery(): array + { + $filters = []; + foreach ($this->request->getQueryParams() as $filterName => $value) { + $prefix = '_metafield'; + if (substr($filterName, 0, strlen($prefix)) === $prefix) { + $dissected = explode('_', substr($filterName, strlen($prefix))); + if (count($dissected) == 3) { // Skip if template_id or template_field_id not provided + $filters[] = [ + 'meta_template_id' => intval($dissected[1]), + 'meta_template_field_id' => intval($dissected[2]), + 'value' => $value, + ]; + } + } + } + return $filters; + } + private function renderViewInVariable($templateRelativeName, $data) { $builder = new ViewBuilder(); diff --git a/src/Controller/Component/Navigation/Api.php b/src/Controller/Component/Navigation/Api.php new file mode 100644 index 0000000..022f9fc --- /dev/null +++ b/src/Controller/Component/Navigation/Api.php @@ -0,0 +1,16 @@ +bcf->addRoute('Api', 'index', [ + 'label' => __('API'), + 'url' => '/api/index', + 'icon' => 'code' + ]); + } +} diff --git a/src/Controller/Component/Navigation/MetaTemplates.php b/src/Controller/Component/Navigation/MetaTemplates.php index 91ba4eb..ca83ef9 100644 --- a/src/Controller/Component/Navigation/MetaTemplates.php +++ b/src/Controller/Component/Navigation/MetaTemplates.php @@ -12,21 +12,52 @@ class MetaTemplatesNavigation extends BaseNavigation $this->bcf->addRoute('MetaTemplates', 'view', $this->bcf->defaultCRUD('MetaTemplates', 'view')); $this->bcf->addRoute('MetaTemplates', 'enable', [ 'label' => __('Enable'), - 'icon' => 'check', + 'icon' => 'check-square', 'url' => '/metaTemplates/enable/{{id}}/enabled', 'url_vars' => ['id' => 'id'], ]); $this->bcf->addRoute('MetaTemplates', 'set_default', [ 'label' => __('Set as default'), - 'icon' => 'check', + 'icon' => 'check-square', 'url' => '/metaTemplates/toggle/{{id}}/default', 'url_vars' => ['id' => 'id'], ]); + + $totalUpdateCount = 0; + if (!empty($this->viewVars['updateableTemplates']['automatically-updateable']) && !empty($this->viewVars['updateableTemplates']['new'])) { + $udpateCount = count($this->viewVars['updateableTemplates']['automatically-updateable']) ?? 0; + $newCount = count($this->viewVars['updateableTemplates']['new']) ?? 0; + $totalUpdateCount = $udpateCount + $newCount; + } + $updateRouteConfig = [ + 'label' => __('Update all templates'), + 'icon' => 'download', + 'url' => '/metaTemplates/updateAllTemplates', + ]; + if ($totalUpdateCount > 0) { + $updateRouteConfig['badge'] = [ + 'text' => h($totalUpdateCount), + 'variant' => 'warning', + 'title' => __('There are {0} new meta-template(s) and {1} update(s) available', h($newCount), h($udpateCount)), + ]; + } + $this->bcf->addRoute('MetaTemplates', 'update_all_templates', $updateRouteConfig); + $this->bcf->addRoute('MetaTemplates', 'update', [ + 'label' => __('Update template'), + 'icon' => 'download', + 'url' => '/metaTemplates/update', + ]); + $this->bcf->addRoute('MetaTemplates', 'prune_outdated_template', [ + 'label' => __('Prune outdated template'), + 'icon' => 'trash', + 'url' => '/metaTemplates/prune_outdated_template', + ]); } public function addParents() { $this->bcf->addParent('MetaTemplates', 'view', 'MetaTemplates', 'index'); + $this->bcf->addParent('MetaTemplates', 'update', 'MetaTemplates', 'index'); } public function addLinks() @@ -36,6 +67,42 @@ class MetaTemplatesNavigation extends BaseNavigation public function addActions() { + $totalUpdateCount = 0; + if (!empty($this->viewVars['updateableTemplates']['not-up-to-date']) && !empty($this->viewVars['updateableTemplates']['new'])) { + $udpateCount = count($this->viewVars['updateableTemplates']['not-up-to-date']) ?? 0; + $newCount = count($this->viewVars['updateableTemplates']['new']) ?? 0; + $totalUpdateCount = $udpateCount + $newCount; + } + $updateAllActionConfig = [ + 'label' => __('Update template'), + 'url' => '/metaTemplates/updateAllTemplates', + 'url_vars' => ['id' => 'id'], + ]; + if ($totalUpdateCount > 0) { + $updateAllActionConfig['badge'] = [ + 'text' => h($totalUpdateCount), + 'variant' => 'warning', + 'title' => __('There are {0} new meta-template(s) and {1} update(s) available', h($newCount), h($udpateCount)), + ]; + } + $this->bcf->addAction('MetaTemplates', 'index', 'MetaTemplates', 'update_all_templates', $updateAllActionConfig); + $this->bcf->addAction('MetaTemplates', 'index', 'MetaTemplates', 'prune_outdated_template', [ + 'label' => __('Prune outdated template'), + 'url' => '/metaTemplates/prune_outdated_template', + ]); + + if (empty($this->viewVars['updateableTemplates']['up-to-date'])) { + $this->bcf->addAction('MetaTemplates', 'view', 'MetaTemplates', 'update', [ + 'label' => __('Update template'), + 'url' => '/metaTemplates/update/{{id}}', + 'url_vars' => ['id' => 'id'], + 'variant' => 'warning', + 'badge' => [ + 'variant' => 'warning', + 'title' => __('Update available') + ] + ]); + } $this->bcf->addAction('MetaTemplates', 'view', 'MetaTemplates', 'enable'); $this->bcf->addAction('MetaTemplates', 'view', 'MetaTemplates', 'set_default'); } diff --git a/src/Controller/Component/Navigation/base.php b/src/Controller/Component/Navigation/base.php index b426ab5..30f904a 100644 --- a/src/Controller/Component/Navigation/base.php +++ b/src/Controller/Component/Navigation/base.php @@ -5,12 +5,14 @@ class BaseNavigation { protected $bcf; protected $request; + protected $viewVars; public $currentUser; - public function __construct($bcf, $request) + public function __construct($bcf, $request, $viewVars) { $this->bcf = $bcf; $this->request = $request; + $this->viewVars = $viewVars; } public function setCurrentUser($currentUser) diff --git a/src/Controller/Component/Navigation/sidemenu.php b/src/Controller/Component/Navigation/sidemenu.php index 56a0154..e9b9281 100644 --- a/src/Controller/Component/Navigation/sidemenu.php +++ b/src/Controller/Component/Navigation/sidemenu.php @@ -38,6 +38,11 @@ class Sidemenu { 'label' => __('Sharing Groups'), 'icon' => $this->iconTable['SharingGroups'], 'url' => '/sharingGroups/index', + ], + 'MailingLists' => [ + 'label' => __('Mailing Lists'), + 'icon' => $this->iconTable['MailingLists'], + 'url' => '/mailingLists/index', ] ], __('Synchronisation') => [ @@ -46,11 +51,6 @@ class Sidemenu { 'icon' => $this->iconTable['Broods'], 'url' => '/broods/index', ], - 'API' => [ - 'label' => __('API'), - 'icon' => $this->iconTable['API'], - 'url' => '/api/index', - ], ], __('Administration') => [ 'Roles' => [ @@ -78,13 +78,13 @@ class Sidemenu { 'icon' => $this->iconTable['Inbox'], 'url' => '/inbox/index', 'children' => [ - 'index' => [ + 'inbox' => [ 'url' => '/inbox/index', - 'label' => __('Inbox') + 'label' => __('Inbox'), ], 'outbox' => [ 'url' => '/outbox/index', - 'label' => __('Outbox') + 'label' => __('Outbox'), ], ] ], @@ -125,6 +125,11 @@ class Sidemenu { ], ] ], + 'API' => [ + 'label' => __('API'), + 'icon' => $this->iconTable['API'], + 'url' => '/api/index', + ], ], 'Open' => [ 'Organisations' => [ diff --git a/src/Controller/Component/NavigationComponent.php b/src/Controller/Component/NavigationComponent.php index 67fb39c..9df48be 100644 --- a/src/Controller/Component/NavigationComponent.php +++ b/src/Controller/Component/NavigationComponent.php @@ -25,6 +25,7 @@ class NavigationComponent extends Component 'Organisations' => 'building', 'EncryptionKeys' => 'key', 'SharingGroups' => 'user-friends', + 'MailingLists' => 'mail-bulk', 'Broods' => 'network-wired', 'Roles' => 'id-badge', 'Users' => 'users', @@ -43,10 +44,9 @@ class NavigationComponent extends Component $this->request = $config['request']; } - public function genBreadcrumbs(\App\Model\Entity\User $user) + public function beforeRender($event) { - $this->currentUser = $user; - $this->breadcrumb = $this->fullBreadcrumb = $this->genBreadcrumb(); + $this->fullBreadcrumb = $this->genBreadcrumb(); } public function getSideMenu(): array @@ -141,7 +141,8 @@ class NavigationComponent extends Component $navigationClassname = str_replace('.php', '', $navigationFile); require_once(APP . 'Controller' . DS . 'Component' . DS . 'Navigation' . DS . $navigationFile); $reflection = new \ReflectionClass("BreadcrumbNavigation\\{$navigationClassname}Navigation"); - $navigationClasses[$navigationClassname] = $reflection->newInstance($bcf, $request); + $viewVars = $this->_registry->getController()->viewBuilder()->getVars(); + $navigationClasses[$navigationClassname] = $reflection->newInstance($bcf, $request, $viewVars); $navigationClasses[$navigationClassname]->setCurrentUser($this->currentUser); } return $navigationClasses; @@ -161,6 +162,7 @@ class NavigationComponent extends Component 'Tags', 'LocalTools', 'UserSettings', + 'MailingLists', ]; foreach ($CRUDControllers as $controller) { $bcf->setDefaultCRUDForModel($controller); @@ -250,6 +252,7 @@ class BreadcrumbFactory $routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'icon'); $routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'label'); $routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'textGetter'); + $routeConfig = $this->addIfNotEmpty($routeConfig, $config, 'badge'); return $routeConfig; } diff --git a/src/Controller/Component/NotificationComponent.php b/src/Controller/Component/NotificationComponent.php new file mode 100644 index 0000000..33c460b --- /dev/null +++ b/src/Controller/Component/NotificationComponent.php @@ -0,0 +1,48 @@ +request = $config['request']; + $this->Controller = $this->getController(); + } + + public function getNotifications(): array + { + $notifications = []; + $notifications = $this->collectNotificationsFromTables(); + return $notifications; + } + + private function collectNotificationsFromTables(): array + { + $notifications = []; + foreach ($this->tables as $tableName) { + $table = TableRegistry::getTableLocator()->get($tableName); + $tableNotifications = $this->collectNotificationFromTable($table); + $notifications = array_merge($notifications, $tableNotifications); + } + return $notifications; + } + + private function collectNotificationFromTable($table): array + { + $notifications = []; + if (method_exists($table, 'collectNotifications')) { + $notifications = $table->collectNotifications($this->Controller->ACL->getUser()); + } + return $notifications; + } +} diff --git a/src/Controller/Component/ParamHandlerComponent.php b/src/Controller/Component/ParamHandlerComponent.php index 92260fd..bb004fa 100644 --- a/src/Controller/Component/ParamHandlerComponent.php +++ b/src/Controller/Component/ParamHandlerComponent.php @@ -27,15 +27,15 @@ class ParamHandlerComponent extends Component $queryString = str_replace('.', '_', $filter); $queryString = str_replace(' ', '_', $queryString); if ($this->request->getQuery($queryString) !== null) { - $parsedParams[$filter] = $this->request->getQuery($queryString); - continue; - } - if (($this->request->getQuery($filter)) !== null) { - $parsedParams[$filter] = $this->request->getQuery($filter); + if (is_array($this->request->getQuery($queryString))) { + $parsedParams[$filter] = array_map('trim', $this->request->getQuery($queryString)); + } else { + $parsedParams[$filter] = trim($this->request->getQuery($queryString)); + } continue; } if (($this->request->is('post') || $this->request->is('put')) && $this->request->getData($filter) !== null) { - $parsedParams[$filter] = $this->request->getData($filter); + $parsedParams[$filter] = trim($this->request->getData($filter)); } } return $parsedParams; diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index 803f180..d0376db 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -17,6 +17,7 @@ class EncryptionKeysController extends AppController public $filterFields = ['owner_model', 'owner_id', 'encryption_key']; public $quickFilterFields = ['encryption_key']; public $containFields = ['Individuals', 'Organisations']; + public $statisticsFields = ['type']; public function index() { @@ -28,7 +29,8 @@ class EncryptionKeysController extends AppController 'type' ] ], - 'contain' => $this->containFields + 'contain' => $this->containFields, + 'statisticsFields' => $this->statisticsFields, ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { diff --git a/src/Controller/IndividualsController.php b/src/Controller/IndividualsController.php index 996a886..cc5a8fa 100644 --- a/src/Controller/IndividualsController.php +++ b/src/Controller/IndividualsController.php @@ -16,25 +16,22 @@ class IndividualsController extends AppController public $quickFilterFields = ['uuid', ['email' => true], ['first_name' => true], ['last_name' => true], 'position']; public $filterFields = ['uuid', 'email', 'first_name', 'last_name', 'position', 'Organisations.id', 'Alignments.type']; public $containFields = ['Alignments' => 'Organisations']; + public $statisticsFields = ['position']; public function index() { $this->CRUD->index([ 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, - 'contextFilters' => [ - 'fields' => [ - 'Alignments.type' - ] - ], - 'contain' => $this->containFields + 'quickFilterForMetaField' => ['enabled' => true, 'wildcard_search' => true], + 'contain' => $this->containFields, + 'statisticsFields' => $this->statisticsFields, ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; } $this->set('alignmentScope', 'individuals'); - $this->set('metaGroup', 'ContactDB'); } public function filtering() @@ -49,7 +46,6 @@ class IndividualsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'ContactDB'); } public function view($id) @@ -59,7 +55,6 @@ class IndividualsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'ContactDB'); } public function edit($id) @@ -69,7 +64,6 @@ class IndividualsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'ContactDB'); $this->render('add'); } @@ -80,7 +74,6 @@ class IndividualsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'ContactDB'); } public function tag($id) diff --git a/src/Controller/InstanceController.php b/src/Controller/InstanceController.php index f136251..5caf6ee 100644 --- a/src/Controller/InstanceController.php +++ b/src/Controller/InstanceController.php @@ -21,7 +21,6 @@ class InstanceController extends AppController public function home() { - // $this->set('md', file_get_contents(ROOT . '/README.md')); $statistics = $this->Instance->getStatistics(); $this->set('statistics', $statistics); } @@ -44,7 +43,7 @@ class InstanceController extends AppController } $data = []; if (!empty($searchValue)) { - $data = $this->Instance->searchAll($searchValue, $limit, $model); + $data = $this->Instance->searchAll($searchValue, $this->ACL->getUser(), $limit, $model); } if ($this->ParamHandler->isRest()) { return $this->RestResponse->viewData($data, 'json'); diff --git a/src/Controller/MailingListsController.php b/src/Controller/MailingListsController.php new file mode 100644 index 0000000..e8e6a7c --- /dev/null +++ b/src/Controller/MailingListsController.php @@ -0,0 +1,358 @@ + true], ['description' => true], ['releasability' => true]]; + public $containFields = ['Users', 'Individuals', 'MetaFields']; + public $statisticsFields = ['active']; + + public function index() + { + $currentUser = $this->ACL->getUser(); + $this->CRUD->index([ + 'contain' => $this->containFields, + 'filters' => $this->filterFields, + 'quickFilters' => $this->quickFilterFields, + 'statisticsFields' => $this->statisticsFields, + 'afterFind' => function ($row) use ($currentUser) { + if (empty($currentUser['role']['perm_admin']) || $row['user_id'] != $currentUser['id']) { + if (!$this->MailingLists->isIndividualListed($currentUser['individual_id'], $row)) { + $row = false; + } + } + return $row; + } + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function add() + { + $currentUser = $this->ACL->getUser(); + $this->CRUD->add([ + 'override' => [ + 'user_id' => $currentUser['id'] + ], + 'beforeSave' => function ($data) use ($currentUser) { + return $data; + } + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function view($id) + { + $currentUser = $this->ACL->getUser(); + $this->CRUD->view($id, [ + 'contain' => $this->containFields, + 'afterFind' => function($data) use ($currentUser) { + if (empty($currentUser['role']['perm_admin']) || $data['user_id'] != $currentUser['id']) { + if (!$this->MailingLists->isIndividualListed($currentUser['individual_id'], $data)) { + $data = []; + } + } + return $data; + }, + ]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function edit($id = false) + { + $currentUser = $this->ACL->getUser(); + $params = []; + if (empty($currentUser['role']['perm_admin'])) { + $params['conditions'] = ['user_id' => $currentUser['id']]; + } + $this->CRUD->edit($id, $params); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + $this->render('add'); + } + + public function delete($id) + { + $currentUser = $this->ACL->getUser(); + if (empty($currentUser['role']['perm_admin'])) { + $params['conditions'] = ['user_id' => $currentUser['id']]; + } + $this->CRUD->delete($id, $params); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + + public function listIndividuals($mailinglist_id) + { + $currentUser = $this->ACL->getUser(); + $quickFilter = [ + 'uuid', + ['first_name' => true], + ['last_name' => true], + ]; + $quickFilterUI = array_merge($quickFilter, [ + ['Registered emails' => true], + ]); + $filters = ['uuid', 'first_name', 'last_name', 'quickFilter']; + $queryParams = $this->ParamHandler->harvestParams($filters); + $activeFilters = $queryParams['quickFilter'] ?? []; + + $mailingList = $this->MailingLists->find() + ->where(['MailingLists.id' => $mailinglist_id]) + ->contain(['MetaFields', 'Individuals']) + ->first(); + + if (is_null($mailingList)) { + throw new NotFoundException(__('Invalid {0}.', Inflector::singularize($this->MailingLists->getAlias()))); + } + if (empty($currentUser['role']['perm_admin']) || $mailingList['user_id'] != $currentUser['id']) { + if (!$this->MailingLists->isIndividualListed($currentUser['individual_id'], $mailingList)) { + throw new NotFoundException(__('Invalid {0}.', Inflector::singularize($this->MailingLists->getAlias()))); + } + } + + $filteringActive = !empty($queryParams['quickFilter']); + $matchingMetaFieldParentIDs = []; + if ($filteringActive) { + // Collect individuals having a matching meta_field for the requested search value + foreach ($mailingList->meta_fields as $metaField) { + if ( + empty($queryParams['quickFilter']) || + ( + str_contains($metaField->field, 'email') && + str_contains($metaField->value, $queryParams['quickFilter']) + ) + ) { + $matchingMetaFieldParentIDs[$metaField->parent_id] = true; + } + } + } + $matchingMetaFieldParentIDs = array_keys($matchingMetaFieldParentIDs); + unset($mailingList['individuals']); // reset loaded individuals for the filtering to take effect + // Perform filtering based on the searched values (supports emails from metafield or individual) + $mailingList = $this->MailingLists->loadInto($mailingList, [ + 'Individuals' => function (Query $q) use ($queryParams, $quickFilter, $filteringActive, $matchingMetaFieldParentIDs) { + $conditions = []; + if (!empty($queryParams)) { + $conditions = $this->CRUD->genQuickFilterConditions($queryParams, $quickFilter); + } + if ($filteringActive && !empty($matchingMetaFieldParentIDs)) { + $conditions[] = function (QueryExpression $exp) use ($matchingMetaFieldParentIDs) { + return $exp->in('Individuals.id', $matchingMetaFieldParentIDs); + }; + } + if ($filteringActive && !empty($queryParams['quickFilter'])) { + $conditions[] = [ + 'MailingListsIndividuals.include_primary_email' => true, + 'Individuals.email LIKE' => "%{$queryParams['quickFilter']}%" + ]; + } + $q->where([ + 'OR' => $conditions + ]); + return $q; + } + ]); + $mailingList->injectRegisteredEmailsIntoIndividuals(); + if ($this->ParamHandler->isRest()) { + return $this->RestResponse->viewData($mailingList->individuals, 'json'); + } + $individuals = $this->CustomPagination->paginate($mailingList->individuals); + $this->set('mailing_list_id', $mailinglist_id); + $this->set('quickFilter', $quickFilterUI); + $this->set('activeFilters', $activeFilters); + $this->set('quickFilterValue', $queryParams['quickFilter'] ?? ''); + $this->set('individuals', $individuals); + } + + public function addIndividual($mailinglist_id) + { + $currentUser = $this->ACL->getUser(); + $params = [ + 'contain' => ['Individuals', 'MetaFields'] + ]; + if (empty($currentUser['role']['perm_admin'])) { + $params['conditions'] = ['user_id' => $currentUser['id']]; + } + $mailingList = $this->MailingLists->get($mailinglist_id, $params); + $linkedIndividualsIDs = Hash::extract($mailingList, 'individuals.{n}.id'); + $conditions = []; + if (!empty($linkedIndividualsIDs)) { + $conditions = [ + 'id NOT IN' => $linkedIndividualsIDs + ]; + } + $dropdownData = [ + 'individuals' => $this->MailingLists->Individuals->getTarget()->find() + ->order(['first_name' => 'asc']) + ->where($conditions) + ->all() + ->combine('id', 'full_name') + ->toArray() + ]; + if ($this->request->is('post') || $this->request->is('put')) { + $memberIDs = $this->request->getData()['individuals']; + $chosen_emails = $this->request->getData()['chosen_emails']; + if (!empty($chosen_emails)) { + $chosen_emails = json_decode($chosen_emails, true); + $chosen_emails = !is_null($chosen_emails) ? $chosen_emails : []; + } else { + $chosen_emails = []; + } + $members = $this->MailingLists->Individuals->getTarget()->find()->where([ + 'id IN' => $memberIDs + ])->all()->toArray(); + $memberToLink = []; + foreach ($members as $i => $member) { + $includePrimary = in_array('primary', $chosen_emails[$member->id]); + $chosen_emails[$member->id] = array_filter($chosen_emails[$member->id], function($entry) { + return $entry != 'primary'; + }); + $members[$i]->_joinData = new Entity(['include_primary_email' => $includePrimary]); + if (!in_array($member->id, $linkedIndividualsIDs)) { // individual are not already in the list + $memberToLink[] = $members[$i]; + } + } + + // save new individuals + if (!empty($memberToLink)) { + $success = (bool)$this->MailingLists->Individuals->link($mailingList, $memberToLink); + if ($success && !empty($chosen_emails[$member->id])) { // Include any remaining emails from the metaFields + $emailsFromMetaFields = $this->MailingLists->MetaFields->find()->where([ + 'id IN' => $chosen_emails[$member->id] + ])->all()->toArray(); + $success = (bool)$this->MailingLists->MetaFields->link($mailingList, $emailsFromMetaFields); + } + } + + if ($success) { + $message = __n('{0} individual added to the mailing list.', '{0} Individuals added to the mailing list.', count($members), count($members)); + $mailingList = $this->MailingLists->get($mailingList->id); + } else { + $message = __n('The individual could not be added to the mailing list.', 'The Individuals could not be added to the mailing list.', count($members)); + } + $this->CRUD->setResponseForController('add_individuals', $success, $message, $mailingList, $mailingList->getErrors()); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + $this->set(compact('dropdownData')); + $this->set('mailinglist_id', $mailinglist_id); + $this->set('mailingList', $mailingList); + } + + public function removeIndividual($mailinglist_id, $individual_id=null) + { + $currentUser = $this->ACL->getUser(); + $params = [ + 'contain' => ['Individuals', 'MetaFields'] + ]; + if (empty($currentUser['role']['perm_admin'])) { + $params['conditions'] = ['user_id' => $currentUser['id']]; + } + $mailingList = $this->MailingLists->get($mailinglist_id, $params); + $individual = []; + if (!is_null($individual_id)) { + $individual = $this->MailingLists->Individuals->get($individual_id); + } + if ($this->request->is('post') || $this->request->is('delete')) { + $success = false; + if (!is_null($individual_id)) { + $individualToRemove = $this->MailingLists->Individuals->get($individual_id); + $metaFieldsIDsToRemove = Hash::extract($mailingList, 'meta_fields.{n}.id'); + if (!empty($metaFieldsIDsToRemove)) { + $metaFieldsToRemove = $this->MailingLists->MetaFields->find()->where([ + 'id IN' => $metaFieldsIDsToRemove, + 'parent_id' => $individual_id, + ])->all()->toArray(); + } + $success = (bool)$this->MailingLists->Individuals->unlink($mailingList, [$individualToRemove]); + if ($success && !empty($metaFieldsToRemove)) { + $success = (bool)$this->MailingLists->MetaFields->unlink($mailingList, $metaFieldsToRemove); + } + if ($success) { + $message = __('{0} removed from the mailing list.', $individualToRemove->full_name); + $mailingList = $this->MailingLists->get($mailingList->id); + } else { + $message = __n('{0} could not be removed from the mailing list.', $individual->full_name); + } + $this->CRUD->setResponseForController('remove_individuals', $success, $message, $mailingList, $mailingList->getErrors()); + } else { + $params = $this->ParamHandler->harvestParams(['ids']); + if (!empty($params['ids'])) { + $params['ids'] = json_decode($params['ids']); + } + if (empty($params['ids'])) { + throw new NotFoundException(__('Invalid {0}.', Inflector::singularize($this->MailingLists->Individuals->getAlias()))); + } + $individualsToRemove = $this->MailingLists->Individuals->find()->where([ + 'id IN' => array_map('intval', $params['ids']) + ])->all()->toArray(); + $metaFieldsIDsToRemove = Hash::extract($mailingList, 'meta_fields.{n}.id'); + if (!empty($metaFieldsIDsToRemove)) { + $metaFieldsToRemove = $this->MailingLists->MetaFields->find()->where([ + 'id IN' => $metaFieldsIDsToRemove, + ])->all()->toArray(); + } + $unlinkSuccesses = 0; + foreach ($individualsToRemove as $individualToRemove) { + $success = (bool)$this->MailingLists->Individuals->unlink($mailingList, [$individualToRemove]); + $results[] = $success; + if ($success) { + $unlinkSuccesses++; + } + } + $mailingList = $this->MailingLists->get($mailingList->id); + $success = $unlinkSuccesses == count($individualsToRemove); + $message = __( + '{0} {1} have been removed.', + $unlinkSuccesses == count($individualsToRemove) ? __('All') : sprintf('%s / %s', $unlinkSuccesses, count($individualsToRemove)), + Inflector::singularize($this->MailingLists->Individuals->getAlias()) + ); + $this->CRUD->setResponseForController('remove_individuals', $success, $message, $mailingList, []); + } + + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + $this->set('mailinglist_id', $mailinglist_id); + $this->set('mailingList', $mailingList); + if (!empty($individual)) { + $this->set('deletionText', __('Are you sure you want to remove `{0} ({1})` from the mailing list?', $individual->full_name, $individual->email)); + } else { + $this->set('deletionText', __('Are you sure you want to remove multiples individuals from the mailing list?')); + } + $this->set('postLinkParameters', ['action' => 'removeIndividual', $mailinglist_id, $individual_id]); + $this->viewBuilder()->setLayout('ajax'); + $this->render('/genericTemplates/delete'); + } +} diff --git a/src/Controller/MetaTemplatesController.php b/src/Controller/MetaTemplatesController.php index 904ac8d..0a863e0 100644 --- a/src/Controller/MetaTemplatesController.php +++ b/src/Controller/MetaTemplatesController.php @@ -5,62 +5,285 @@ namespace App\Controller; use App\Controller\AppController; use Cake\Utility\Hash; use Cake\Utility\Text; +use Cake\Utility\Inflector; +use Cake\ORM\TableRegistry; use \Cake\Database\Expression\QueryExpression; +use Cake\Http\Exception\NotFoundException; +use Cake\Http\Exception\MethodNotAllowedException; +use Cake\Routing\Router; + class MetaTemplatesController extends AppController { - public $quickFilterFields = ['name', 'uuid', 'scope']; + public $quickFilterFields = [['name' => true], 'uuid', ['scope' => true]]; public $filterFields = ['name', 'uuid', 'scope', 'namespace']; public $containFields = ['MetaTemplateFields']; - public function update() + public function updateAllTemplates() { if ($this->request->is('post')) { - $result = $this->MetaTemplates->update(); + $result = $this->MetaTemplates->updateAllTemplates(); if ($this->ParamHandler->isRest()) { return $this->RestResponse->viewData($result, 'json'); } else { - $this->Flash->success(__('{0} templates updated.', count($result))); - $this->redirect($this->referer()); + if ($result['success']) { + $message = __n('{0} templates updated.', 'The template has been updated.', empty($template_id), $result['files_processed']); + } else { + $message = __n('{0} templates could not be updated.', 'The template could not be updated.', empty($template_id), $result['files_processed']); + } + $this->CRUD->setResponseForController('updateAllTemplate', $result['success'], $message, $result['files_processed'], $result['update_errors'], ['redirect' => $this->referer()]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } } } else { if (!$this->ParamHandler->isRest()) { - $this->set('title', __('Update Meta Templates')); - $this->set('question', __('Are you sure you wish to update the Meta Template definitions?')); - $this->set('actionName', __('Update')); - $this->set('path', ['controller' => 'metaTemplates', 'action' => 'update']); + $this->set('title', __('Update All Meta Templates')); + $this->set('question', __('Are you sure you wish to update all the Meta Template definitions')); + $templatesUpdateStatus = $this->MetaTemplates->getUpdateStatusForTemplates(); + $this->set('templatesUpdateStatus', $templatesUpdateStatus); + $this->render('updateAll'); + } + } + } + + /** + * Update the provided template or all templates + * + * @param int|null $template_id + */ + public function update($template_id=null) + { + $metaTemplate = false; + if (!is_null($template_id)) { + if (!is_numeric($template_id)) { + throw new NotFoundException(__('Invalid {0} for provided ID.', $this->MetaTemplates->getAlias(), $template_id)); + } + $metaTemplate = $this->MetaTemplates->find()->where([ + 'id' => $template_id + ])->contain(['MetaTemplateFields'])->first(); + + if (empty($metaTemplate)) { + throw new NotFoundException(__('Invalid {0} {1}.', $this->MetaTemplates->getAlias(), $template_id)); + } + } + if ($this->request->is('post')) { + $params = $this->ParamHandler->harvestParams(['update_strategy']); + $updateStrategy = $params['update_strategy'] ?? null; + $result = $this->MetaTemplates->update($metaTemplate, $updateStrategy); + if ($this->ParamHandler->isRest()) { + return $this->RestResponse->viewData($result, 'json'); + } else { + if ($result['success']) { + $message = __n('{0} templates updated.', 'The template has been updated.', empty($template_id), $result['files_processed']); + } else { + $message = __n('{0} templates could not be updated.', 'The template could not be updated.', empty($template_id), $result['files_processed']); + } + $this->CRUD->setResponseForController('update', $result['success'], $message, $result['files_processed'], $result['update_errors'], ['redirect' => $this->referer()]); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + } else { + if (!$this->ParamHandler->isRest()) { + if (!empty($metaTemplate)) { + $this->set('metaTemplate', $metaTemplate); + $statuses = $this->setUpdateStatus($metaTemplate->id); + $this->set('updateStatus', $this->MetaTemplates->computeFullUpdateStatusForMetaTemplate($statuses['templateStatus'], $metaTemplate)); + } else { + $this->set('title', __('Update All Meta Templates')); + $this->set('question', __('Are you sure you wish to update all the Meta Template definitions')); + $templatesUpdateStatus = $this->MetaTemplates->getUpdateStatusForTemplates(); + $this->set('templatesUpdateStatus', $templatesUpdateStatus); + $this->render('updateAll'); + } + } + } + } + + /** + * Create a new template by loading the template on the disk having the provided UUID. + * + * @param string $uuid + */ + public function createNewTemplate(string $uuid) + { + if ($this->request->is('post')) { + $result = $this->MetaTemplates->createNewTemplate($uuid); + if ($this->ParamHandler->isRest()) { + return $this->RestResponse->viewData($result, 'json'); + } else { + if ($result['success']) { + $message = __('The template {0} has been created.', $uuid); + } else { + $message = __('The template {0} could not be created.', $uuid); + } + $this->CRUD->setResponseForController('createNewTemplate', $result['success'], $message, $result['files_processed'], $result['update_errors']); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + } else { + if (!$this->ParamHandler->isRest()) { + $this->set('title', __('Create Meta Template')); + $this->set('question', __('Are you sure you wish to load the meta template with UUID: {0} in the database', h($uuid))); + $this->set('actionName', __('Create template')); + $this->set('path', ['controller' => 'meta-templates', 'action' => 'create_new_template', $uuid]); $this->render('/genericTemplates/confirm'); } } } + public function getMetaFieldsToUpdate($template_id) + { + $metaTemplate = $this->MetaTemplates->get($template_id); + $newestMetaTemplate = $this->MetaTemplates->getNewestVersion($metaTemplate); + $amountOfEntitiesToUpdate = 0; + $entities = $this->MetaTemplates->getEntitiesHavingMetaFieldsFromTemplate($template_id, 10, $amountOfEntitiesToUpdate); + $this->set('metaTemplate', $metaTemplate); + $this->set('newestMetaTemplate', $newestMetaTemplate); + $this->set('entities', $entities); + $this->set('amountOfEntitiesToUpdate', $amountOfEntitiesToUpdate); + } + + public function migrateOldMetaTemplateToNewestVersionForEntity($template_id, $entity_id) + { + $metaTemplate = $this->MetaTemplates->get($template_id, [ + 'contain' => ['MetaTemplateFields'] + ]); + $newestMetaTemplate = $this->MetaTemplates->getNewestVersion($metaTemplate, true); + $entity = $this->MetaTemplates->getEntity($metaTemplate, $entity_id); + $conditions = [ + 'MetaFields.meta_template_id IN' => [$metaTemplate->id, $newestMetaTemplate->id], + 'MetaFields.scope' => $metaTemplate->scope, + ]; + $keyedMetaFields = $this->MetaTemplates->getKeyedMetaFieldsForEntity($entity_id, $conditions); + if (empty($keyedMetaFields[$metaTemplate->id])) { + throw new NotFoundException(__('Invalid {0}. This entities does not have meta-fields to be moved to a newer template.', $this->MetaTemplates->getAlias())); + } + $mergedMetaFields = $this->MetaTemplates->insertMetaFieldsInMetaTemplates($keyedMetaFields, [$metaTemplate, $newestMetaTemplate]); + $entity['MetaTemplates'] = $mergedMetaFields; + if ($this->request->is('post') || $this->request->is('put')) { + $className = Inflector::camelize(Inflector::pluralize($newestMetaTemplate->scope)); + $entityTable = TableRegistry::getTableLocator()->get($className); + $inputData = $this->request->getData(); + $massagedData = $this->MetaTemplates->massageMetaFieldsBeforeSave($entity, $inputData, $newestMetaTemplate); + unset($inputData['MetaTemplates']); // Avoid MetaTemplates to be overriden when patching entity + $data = $massagedData['entity']; + $metaFieldsToDelete = $massagedData['metafields_to_delete']; + foreach ($entity->meta_fields as $i => $metaField) { + if ($metaField->meta_template_id == $template_id) { + $metaFieldsToDelete[] = $entity->meta_fields[$i]; + } + } + $data = $entityTable->patchEntity($data, $inputData); + $savedData = $entityTable->save($data); + if ($savedData !== false) { + if (!empty($metaFieldsToDelete)) { + $entityTable->MetaFields->unlink($savedData, $metaFieldsToDelete); + } + $message = __('Data on old meta-template has been migrated to newest meta-template'); + } else { + $message = __('Could not migrate data to newest meta-template'); + } + $this->CRUD->setResponseForController( + 'migrateOldMetaTemplateToNewestVersionForEntity', + $savedData !== false, + $message, + $savedData, + [], + [ + 'redirect' => [ + 'controller' => $className, + 'action' => 'view', $entity_id, + 'url' => Router::url(['controller' => $className, 'action' => 'view', $entity_id]) + ] + ] + ); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } + } + $conflicts = $this->MetaTemplates->getMetaFieldsConflictsUnderTemplate($entity->meta_fields, $newestMetaTemplate); + foreach ($conflicts as $conflict) { + if (!empty($conflict['existing_meta_template_field'])) { + $existingMetaTemplateField = $conflict['existing_meta_template_field']; + foreach ($existingMetaTemplateField->metaFields as $metaField) { + $metaField->setError('value', implode(', ', $existingMetaTemplateField->conflicts)); + } + } + } + // automatically convert non-conflicting fields to new meta-template + $movedMetaTemplateFields = []; + foreach ($metaTemplate->meta_template_fields as $metaTemplateField) { + if (!empty($conflicts[$metaTemplateField->field]['conflicts'])) { + continue; + } + foreach ($newestMetaTemplate->meta_template_fields as $i => $newMetaTemplateField) { + if ($metaTemplateField->field == $newMetaTemplateField->field && empty($newMetaTemplateField->metaFields)) { + $movedMetaTemplateFields[] = $metaTemplateField->id; + $copiedMetaFields = array_map(function ($e) use ($newMetaTemplateField) { + $e = $e->toArray(); + $e['meta_template_id'] = $newMetaTemplateField->meta_template_id; + $e['meta_template_field_id'] = $newMetaTemplateField->id; + unset($e['id']); + return $e; + }, $metaTemplateField->metaFields); + $newMetaTemplateField->metaFields = $this->MetaTemplates->MetaTemplateFields->MetaFields->newEntities($copiedMetaFields); + } + } + } + $this->set('oldMetaTemplate', $metaTemplate); + $this->set('newMetaTemplate', $newestMetaTemplate); + $this->set('entity', $entity); + $this->set('conflicts', $conflicts); + $this->set('movedMetaTemplateFields', $movedMetaTemplateFields); + } + public function index() { + $templatesUpdateStatus = $this->MetaTemplates->getUpdateStatusForTemplates(); $this->CRUD->index([ 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, 'contextFilters' => [ - 'fields' => ['scope'], 'custom' => [ [ - 'label' => __('Contact DB'), - 'filterCondition' => ['scope' => ['individual', 'organisation']] - ], - [ - 'label' => __('Namespace CNW'), - 'filterCondition' => ['namespace' => 'cnw'] + 'default' => true, + 'label' => __('Latest Templates'), + 'filterConditionFunction' => function ($query) { + return $query->where([ + 'id IN' => $this->MetaTemplates->genQueryForAllNewestVersionIDs() + ]); + } ], ] ], - 'contain' => $this->containFields + 'contain' => $this->containFields, + 'afterFind' => function($metaTemplate) use ($templatesUpdateStatus) { + if (!empty($templatesUpdateStatus[$metaTemplate->uuid])) { + $templateStatus = $this->MetaTemplates->getStatusForMetaTemplate($templatesUpdateStatus[$metaTemplate->uuid]['template'], $metaTemplate); + $metaTemplate->set('updateStatus', $this->MetaTemplates->computeFullUpdateStatusForMetaTemplate($templateStatus, $metaTemplate)); + } + return $metaTemplate; + } ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; } + $updateableTemplates = [ + 'not-up-to-date' => $this->MetaTemplates->getNotUpToDateTemplates(), + 'can-be-removed' => $this->MetaTemplates->getCanBeRemovedTemplates(), + 'new' => $this->MetaTemplates->getNewTemplates(), + ]; $this->set('defaultTemplatePerScope', $this->MetaTemplates->getDefaultTemplatePerScope()); $this->set('alignmentScope', 'individuals'); - $this->set('metaGroup', 'Administration'); + $this->set('updateableTemplates', $updateableTemplates); } public function view($id) @@ -72,7 +295,25 @@ class MetaTemplatesController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'Administration'); + $this->setUpdateStatus($id); + } + + public function delete($id) + { + $metaTemplate = $this->MetaTemplates->get($id, [ + 'contain' => ['MetaTemplateFields'] + ]); + $templateOnDisk = $this->MetaTemplates->readTemplateFromDisk($metaTemplate->uuid); + $templateStatus = $this->MetaTemplates->getStatusForMetaTemplate($templateOnDisk, $metaTemplate); + if (empty($templateStatus['can-be-removed'])) { + throw new MethodNotAllowedException(__('This meta-template cannot be removed')); + } + $this->set('deletionText', __('The meta-template "{0}" has no meta-field and can be safely removed.', h($templateStatus['existing_template']->name))); + $this->CRUD->delete($id); + $responsePayload = $this->CRUD->getResponsePayload(); + if (!empty($responsePayload)) { + return $responsePayload; + } } public function toggle($id, $fieldName = 'enabled') @@ -89,4 +330,35 @@ class MetaTemplatesController extends AppController return $responsePayload; } } + + private function getUpdateStatus($id): array + { + $metaTemplate = $this->MetaTemplates->get($id, [ + 'contain' => ['MetaTemplateFields'] + ]); + $templateOnDisk = $this->MetaTemplates->readTemplateFromDisk($metaTemplate->uuid); + $templateStatus = $this->MetaTemplates->getStatusForMetaTemplate($templateOnDisk, $metaTemplate); + return $templateStatus; + } + + /** + * Retreive the template stored on disk and compute the status for the provided template id. + * + * @param [type] $id + * @return array + */ + private function setUpdateStatus($template_id): array + { + $metaTemplate = $this->MetaTemplates->get($template_id, [ + 'contain' => ['MetaTemplateFields'] + ]); + $templateOnDisk = $this->MetaTemplates->readTemplateFromDisk($metaTemplate->uuid); + $templateStatus = $this->MetaTemplates->getStatusForMetaTemplate($templateOnDisk, $metaTemplate); + $this->set('templateOnDisk', $templateOnDisk); + $this->set('templateStatus', $templateStatus); + return [ + 'templateOnDisk' => $templateOnDisk, + 'templateStatus' => $templateStatus, + ]; + } } diff --git a/src/Controller/OrganisationsController.php b/src/Controller/OrganisationsController.php index a45f5f3..3591a68 100644 --- a/src/Controller/OrganisationsController.php +++ b/src/Controller/OrganisationsController.php @@ -16,12 +16,14 @@ class OrganisationsController extends AppController public $quickFilterFields = [['name' => true], 'uuid', 'nationality', 'sector', 'type', 'url']; public $filterFields = ['name', 'uuid', 'nationality', 'sector', 'type', 'url', 'Alignments.id', 'MetaFields.field', 'MetaFields.value', 'MetaFields.MetaTemplates.name']; public $containFields = ['Alignments' => 'Individuals']; + public $statisticsFields = ['nationality', 'sector']; public function index() { $this->CRUD->index([ 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, + 'quickFilterForMetaField' => ['enabled' => true, 'wildcard_search' => true], 'contextFilters' => [ 'custom' => [ [ @@ -59,7 +61,8 @@ class OrganisationsController extends AppController ] ], ], - 'contain' => $this->containFields + 'contain' => $this->containFields, + 'statisticsFields' => $this->statisticsFields, ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { diff --git a/src/Controller/SharingGroupsController.php b/src/Controller/SharingGroupsController.php index e03aee5..15d41e8 100644 --- a/src/Controller/SharingGroupsController.php +++ b/src/Controller/SharingGroupsController.php @@ -19,20 +19,32 @@ class SharingGroupsController extends AppController { $currentUser = $this->ACL->getUser(); $conditions = []; - if (empty($currentUser['role']['perm_admin'])) { - $conditions['SharingGroups.organisation_id'] = $currentUser['organisation_id']; - } $this->CRUD->index([ 'contain' => $this->containFields, 'filters' => $this->filterFields, 'quickFilters' => $this->quickFilterFields, - 'conditions' => $conditions + 'conditions' => $conditions, + 'afterFind' => function ($row) use ($currentUser) { + if (empty($currentUser['role']['perm_admin'])) { + $orgFound = false; + if (!empty($row['sharing_group_orgs'])) { + foreach ($row['sharing_group_orgs'] as $org) { + if ($org['id'] === $currentUser['organisation_id']) { + $orgFound = true; + } + } + } + if ($row['organisation_id'] !== $currentUser['organisation_id'] && !$orgFound) { + return false; + } + } + return $row; + } ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'Trust Circles'); } public function add() @@ -57,7 +69,6 @@ class SharingGroupsController extends AppController return $responsePayload; } $this->set(compact('dropdownData')); - $this->set('metaGroup', 'Trust Circles'); } public function view($id) @@ -67,7 +78,7 @@ class SharingGroupsController extends AppController 'contain' => ['SharingGroupOrgs', 'Organisations', 'Users' => ['fields' => ['id', 'username']]], 'afterFind' => function($data) use ($currentUser) { if (empty($currentUser['role']['perm_admin'])) { - $orgFround = false; + $orgFound = false; if (!empty($data['sharing_group_orgs'])) { foreach ($data['sharing_group_orgs'] as $org) { if ($org['id'] === $currentUser['organisation_id']) { @@ -75,7 +86,7 @@ class SharingGroupsController extends AppController } } } - if ($data['organisation_id'] !== $currentUser['organisation_id'] && !$orgFround) { + if ($data['organisation_id'] !== $currentUser['organisation_id'] && !$orgFound) { return null; } } @@ -86,7 +97,6 @@ class SharingGroupsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'Trust Circles'); } public function edit($id = false) @@ -106,13 +116,13 @@ class SharingGroupsController extends AppController 'organisation' => $this->getAvailableOrgForSg($this->ACL->getUser()) ]; $this->set(compact('dropdownData')); - $this->set('metaGroup', 'Trust Circles'); $this->render('add'); } public function delete($id) { $currentUser = $this->ACL->getUser(); + $params = []; if (empty($currentUser['role']['perm_admin'])) { $params['conditions'] = ['organisation_id' => $currentUser['organisation_id']]; } @@ -121,7 +131,6 @@ class SharingGroupsController extends AppController if (!empty($responsePayload)) { return $responsePayload; } - $this->set('metaGroup', 'Trust Circles'); } public function addOrg($id) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 27687ba..800dbd8 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -2,10 +2,7 @@ namespace App\Controller; use App\Controller\AppController; -use Cake\Utility\Hash; -use Cake\Utility\Text; use Cake\ORM\TableRegistry; -use \Cake\Database\Expression\QueryExpression; use Cake\Http\Exception\UnauthorizedException; use Cake\Http\Exception\MethodNotAllowedException; use Cake\Core\Configure; @@ -163,11 +160,6 @@ class UsersController extends AppController } $params = [ - 'get' => [ - 'fields' => [ - 'id', 'individual_id', 'role_id', 'disabled', 'username' - ] - ], 'removeEmpty' => [ 'password' ], @@ -175,12 +167,15 @@ class UsersController extends AppController 'password', 'confirm_password' ] ]; - if (!empty($this->ACL->getUser()['role']['perm_admin'])) { + if ($this->request->is(['get'])) { + $params['fields'] = array_merge($params['fields'], ['individual_id', 'role_id', 'disabled', 'username']); + } + if ($this->request->is(['post', 'put']) && !empty($this->ACL->getUser()['role']['perm_admin'])) { $params['fields'][] = 'individual_id'; $params['fields'][] = 'role_id'; $params['fields'][] = 'organisation_id'; $params['fields'][] = 'disabled'; - } else if (!empty($this->ACL->getUser()['role']['perm_org_admin'])) { + } else if ($this->request->is(['post', 'put']) && !empty($this->ACL->getUser()['role']['perm_org_admin'])) { $params['fields'][] = 'role_id'; $params['fields'][] = 'disabled'; if (!$currentUser['role']['perm_admin']) { diff --git a/src/Lib/Tools/CidrTool.php b/src/Lib/Tools/CidrTool.php new file mode 100644 index 0000000..dda7f87 --- /dev/null +++ b/src/Lib/Tools/CidrTool.php @@ -0,0 +1,147 @@ +filterInputList($list); + } + + /** + * @param string $value IPv4 or IPv6 address or range + * @return false|string + */ + public function contains($value) + { + $valueMask = null; + if (strpos($value, '/') !== false) { + list($value, $valueMask) = explode('/', $value); + } + + $match = false; + if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + // This code converts IP address to all possible CIDRs that can contains given IP address + // and then check if given hash table contains that CIDR. + $ip = ip2long($value); + // Start from 1, because doesn't make sense to check 0.0.0.0/0 match + for ($bits = $this->minimumIpv4Mask; $bits <= 32; $bits++) { + $mask = -1 << (32 - $bits); + $needle = long2ip($ip & $mask) . "/$bits"; + if (isset($this->ipv4[$needle])) { + $match = $needle; + break; + } + } + } elseif (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $value = unpack('n*', inet_pton($value)); + foreach ($this->ipv6 as $netmask => $lv) { + foreach ($lv as $l) { + if ($this->ipv6InCidr($value, $l, $netmask)) { + $match = inet_ntop($l) . "/$netmask"; + break; + } + } + } + } + + if ($match && $valueMask) { + $matchMask = explode('/', $match)[1]; + if ($valueMask < $matchMask) { + return false; + } + } + + return $match; + } + + /** + * @param string $cidr + * @return bool + */ + public static function validate($cidr) + { + $parts = explode('/', $cidr, 2); + $ipBytes = inet_pton($parts[0]); + if ($ipBytes === false) { + return false; + } + + $maximumNetmask = strlen($ipBytes) === 4 ? 32 : 128; + if (isset($parts[1]) && ($parts[1] > $maximumNetmask || $parts[1] < 0)) { + return false; // Netmask part of CIDR is invalid + } + + return true; + } + + /** + * Using solution from https://github.com/symfony/symfony/blob/master/src/Symfony/Component/HttpFoundation/IpUtils.php + * + * @param array $ip + * @param string $cidr + * @param int $netmask + * @return bool + */ + private function ipv6InCidr($ip, $cidr, $netmask) + { + $bytesAddr = unpack('n*', $cidr); + for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; ++$i) { + $left = $netmask - 16 * ($i - 1); + $left = ($left <= 16) ? $left : 16; + $mask = ~(0xffff >> $left) & 0xffff; + if (($bytesAddr[$i] & $mask) != ($ip[$i] & $mask)) { + return false; + } + } + + return true; + } + + /** + * Filter out invalid IPv4 or IPv4 CIDR and append maximum netmask if no netmask is given. + * @param array $list + */ + private function filterInputList(array $list) + { + foreach ($list as $v) { + $parts = explode('/', $v, 2); + $ipBytes = inet_pton($parts[0]); + if ($ipBytes === false) { + continue; // IP address part of CIDR is invalid + } + $maximumNetmask = strlen($ipBytes) === 4 ? 32 : 128; + + if (isset($parts[1]) && ($parts[1] > $maximumNetmask || $parts[1] < 0)) { + // Netmask part of CIDR is invalid + continue; + } + + $mask = isset($parts[1]) ? $parts[1] : $maximumNetmask; + if ($maximumNetmask === 32) { + if ($mask < $this->minimumIpv4Mask) { + $this->minimumIpv4Mask = (int)$mask; + } + if (!isset($parts[1])) { + $v = "$v/$maximumNetmask"; // If CIDR doesnt contains '/', we will consider CIDR as /32 + } + $this->ipv4[$v] = true; + } else { + $this->ipv6[$mask][] = $ipBytes; + } + } + } +} diff --git a/src/Lib/default/local_tool_connectors/MispConnector.php b/src/Lib/default/local_tool_connectors/MispConnector.php index 66f5024..a5adb59 100644 --- a/src/Lib/default/local_tool_connectors/MispConnector.php +++ b/src/Lib/default/local_tool_connectors/MispConnector.php @@ -305,70 +305,71 @@ class MispConnector extends CommonConnectorTools $response = $this->getData('/servers/serverSettings', $params); $data = $response->getJson(); if (!empty($data['finalSettings'])) { - $finalSettings = [ - 'type' => 'index', - 'data' => [ - 'data' => $data['finalSettings'], - 'skip_pagination' => 1, - 'top_bar' => [ - 'children' => [ + $finalSettings = [ + 'type' => 'index', + 'data' => [ + 'data' => $data['finalSettings'], + 'skip_pagination' => 1, + 'top_bar' => [ + 'children' => [ + [ + 'type' => 'search', + 'button' => __('Search'), + 'placeholder' => __('Enter value to search'), + 'data' => '', + 'searchKey' => 'value', + 'additionalUrlParams' => $urlParams + ] + ] + ], + 'fields' => [ [ - 'type' => 'search', - 'button' => __('Filter'), - 'placeholder' => __('Enter value to search'), - 'data' => '', - 'searchKey' => 'value', - 'additionalUrlParams' => $urlParams + 'name' => 'Setting', + 'sort' => 'setting', + 'data_path' => 'setting', + ], + [ + 'name' => 'Criticality', + 'sort' => 'level', + 'data_path' => 'level', + 'arrayData' => [ + 0 => 'Critical', + 1 => 'Recommended', + 2 => 'Optional' + ], + 'element' => 'array_lookup_field' + ], + [ + 'name' => __('Value'), + 'sort' => 'value', + 'data_path' => 'value', + 'options' => 'options' + ], + [ + 'name' => __('Type'), + 'sort' => 'type', + 'data_path' => 'type', + ], + [ + 'name' => __('Error message'), + 'sort' => 'errorMessage', + 'data_path' => 'errorMessage', + ] + ], + 'title' => false, + 'description' => false, + 'pull' => 'right', + 'actions' => [ + [ + 'open_modal' => '/localTools/action/' . h($params['connection']['id']) . '/modifySettingAction?setting={{0}}', + 'modal_params_data_path' => ['setting'], + 'icon' => 'download', + 'reload_url' => '/localTools/action/' . h($params['connection']['id']) . '/ServerSettingsAction' ] ] ], - 'fields' => [ - [ - 'name' => 'Setting', - 'sort' => 'setting', - 'data_path' => 'setting', - ], - [ - 'name' => 'Criticality', - 'sort' => 'level', - 'data_path' => 'level', - 'arrayData' => [ - 0 => 'Critical', - 1 => 'Recommended', - 2 => 'Optional' - ], - 'element' => 'array_lookup_field' - ], - [ - 'name' => __('Value'), - 'sort' => 'value', - 'data_path' => 'value', - 'options' => 'options' - ], - [ - 'name' => __('Type'), - 'sort' => 'type', - 'data_path' => 'type', - ], - [ - 'name' => __('Error message'), - 'sort' => 'errorMessage', - 'data_path' => 'errorMessage', - ] - ], - 'title' => false, - 'description' => false, - 'pull' => 'right', - 'actions' => [ - [ - 'open_modal' => '/localTools/action/' . h($params['connection']['id']) . '/modifySettingAction?setting={{0}}', - 'modal_params_data_path' => ['setting'], - 'icon' => 'download', - 'reload_url' => '/localTools/action/' . h($params['connection']['id']) . '/ServerSettingsAction' - ] - ] - ] - ]; + ]; + if (!empty($params['quickFilter'])) { $needle = strtolower($params['quickFilter']); foreach ($finalSettings['data']['data'] as $k => $v) { @@ -404,7 +405,7 @@ class MispConnector extends CommonConnectorTools 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', @@ -494,7 +495,7 @@ class MispConnector extends CommonConnectorTools 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', @@ -572,7 +573,7 @@ class MispConnector extends CommonConnectorTools 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', @@ -640,7 +641,7 @@ class MispConnector extends CommonConnectorTools 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php b/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php index 362c514..eefbd7a 100644 --- a/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php +++ b/src/Lib/default/local_tool_connectors/SkeletonConnectorExample.php @@ -103,7 +103,7 @@ class SkeletonConnector extends CommonConnectorTools 'children' => [ [ 'type' => 'search', - 'button' => __('Filter'), + 'button' => __('Search'), 'placeholder' => __('Enter value to search'), 'data' => '', 'searchKey' => 'value', diff --git a/src/Lib/default/meta_fields_types/IPv4Type.php b/src/Lib/default/meta_fields_types/IPv4Type.php new file mode 100644 index 0000000..c25acd7 --- /dev/null +++ b/src/Lib/default/meta_fields_types/IPv4Type.php @@ -0,0 +1,99 @@ +_isValidIP($value) || $this->_isValidIP(explode('/', $value)[0]); + } + + public function setQueryExpression(QueryExpression $exp, string $searchValue, \App\Model\Entity\MetaTemplateField $metaTemplateField): QueryExpression + { + if (strpos($searchValue, '%') !== false) { + $textHandler = new TextType(); // we are wildcard filtering, use text filter instead + return $textHandler->setQueryExpression($exp, $searchValue, $metaTemplateField); + } + $allMetaValues = $this->fetchAllValuesForThisType([], $metaTemplateField); + $isNegation = false; + if (substr($searchValue, 0, 1) == '!') { + $searchValue = substr($searchValue, 1); + $isNegation = true; + } + + foreach ($allMetaValues as $fieldID => $ip) { + $cidrTool = new CidrTool([$ip]); + if ($cidrTool->contains($searchValue) === false) { + if (!$isNegation) { + unset($allMetaValues[$fieldID]); + } + } else if ($isNegation) { + unset($allMetaValues[$fieldID]); + } + } + $matchingIDs = array_keys($allMetaValues); + if (!empty($matchingIDs)) { + $exp->in('MetaFields.id', $matchingIDs); + } else { + $exp->eq('MetaFields.id', -1); // No matching meta-fields, generate an impossible condition to return nothing + } + return $exp; + } + + protected function fetchAllMetatemplateFieldsIdForThisType(\App\Model\Entity\MetaTemplateField $metaTemplateField = null): Query + { + $this->MetaTemplateFields = TableRegistry::getTableLocator()->get('MetaTemplateFields'); + $conditions = []; + if (!is_null($metaTemplateField)) { + $conditions['id'] = $metaTemplateField->id; + } else { + $conditions['type'] = $this::TYPE; + } + $query = $this->MetaTemplateFields->find()->select(['id']) + ->distinct() + ->where($conditions); + return $query; + } + + protected function fetchAllValuesForThisType(array $conditions=[], \App\Model\Entity\MetaTemplateField $metaTemplateField=null): array + { + $metaTemplateFieldsIDs = $this->fetchAllMetatemplateFieldsIdForThisType($metaTemplateField); + if (empty($metaTemplateFieldsIDs)) { + return []; + } + $conditions = array_merge($conditions, ['meta_template_field_id IN' => $metaTemplateFieldsIDs]); + $allMetaValues = $this->MetaFields->find('list', [ + 'keyField' => 'id', + 'valueField' => 'value' + ])->where($conditions)->toArray(); + return $allMetaValues; + } + + protected function _isValidIP(string $value): bool + { + return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); + } +} diff --git a/src/Lib/default/meta_fields_types/IPv6Type.php b/src/Lib/default/meta_fields_types/IPv6Type.php new file mode 100644 index 0000000..f5d181b --- /dev/null +++ b/src/Lib/default/meta_fields_types/IPv6Type.php @@ -0,0 +1,21 @@ +MetaFields = TableRegistry::getTableLocator()->get('MetaFields'); + } + + public function validate(string $value): bool + { + return is_string($value); + } + + public function setQueryExpression(QueryExpression $exp, string $searchValue, \App\Model\Entity\MetaTemplateField $metaTemplateField): QueryExpression + { + $field = 'MetaFields.value'; + if (substr($searchValue, 0, 1) == '!') { + $searchValue = substr($searchValue, 1); + $exp->notEq($field, $searchValue); + } else if (strpos($searchValue, '%') !== false) { + $exp->like($field, $searchValue); + } else { + $exp->eq($field, $searchValue); + } + return $exp; + } +} diff --git a/src/Model/Behavior/MetaFieldsBehavior.php b/src/Model/Behavior/MetaFieldsBehavior.php new file mode 100644 index 0000000..6f3a46e --- /dev/null +++ b/src/Model/Behavior/MetaFieldsBehavior.php @@ -0,0 +1,259 @@ + [ + 'className' => 'MetaFields', + 'foreignKey' => 'parent_id', + 'bindingKey' => 'id', + 'dependent' => true, + 'cascadeCallbacks' => true, + 'saveStrategy' => 'append', + 'propertyName' => 'meta_fields', + ], + 'modelAssoc' => [ + 'foreignKey' => 'parent_id', + 'bindingKey' => 'id', + ], + 'metaTemplateFieldCounter' => ['counter'], + + 'implementedEvents' => [ + 'Model.beforeMarshal' => 'beforeMarshal', + 'Model.beforeFind' => 'beforeFind', + 'Model.beforeSave' => 'beforeSave', + ], + 'implementedMethods' => [ + 'normalizeMetafields' => 'normalizeMetafields', + 'buildMetaFieldQuerySnippetForMatchingParent' => 'buildQuerySnippetForMatchingParent', + ], + 'implementedFinders' => [ + 'metafield' => 'findMetafield', + ], + ]; + + private $aliasScope = null; + private $typeHandlers = []; + + public function initialize(array $config): void + { + $this->bindAssociations(); + $this->_metaTemplateFieldTable = $this->_table->MetaFields->MetaTemplateFields; + $this->_metaTemplateTable = $this->_table->MetaFields->MetaTemplates; + $this->loadTypeHandlers(); + } + + private function loadTypeHandlers() + { + if (empty($this->typeHandlers)) { + $this->typeHandlers = $this->_metaTemplateFieldTable->getTypeHandlers(); + } + } + + public function getTypeHandlers(): array + { + return $this->typeHandlers; + } + + public function getScope() + { + if (is_null($this->aliasScope)) { + $this->aliasScope = Inflector::underscore(Inflector::singularize($this->_table->getAlias())); + } + return $this->aliasScope; + + } + + public function bindAssociations() + { + $config = $this->getConfig(); + $metaFieldsAssoc = $config['metaFieldsAssoc']; + $modelAssoc = $config['modelAssoc']; + + $table = $this->_table; + $tableAlias = $this->_table->getAlias(); + + $assocConditions = [ + 'MetaFields.scope' => $this->getScope() + ]; + if (!$table->hasAssociation('MetaFields')) { + $table->hasMany('MetaFields', array_merge( + $metaFieldsAssoc, + [ + 'conditions' => $assocConditions + ] + )); + } + + if (!$table->MetaFields->hasAssociation($tableAlias)) { + $table->MetaFields->belongsTo($tableAlias, array_merge( + $modelAssoc, + [ + 'className' => get_class($table), + ] + )); + } + } + + public function beforeMarshal($event, $data, $options) + { + $property = $this->getConfig('metaFieldsAssoc.propertyName'); + $options['accessibleFields'][$property] = true; + $options['associated']['MetaFields']['accessibleFields']['id'] = true; + + if (isset($data[$property])) { + if (!empty($data[$property])) { + $data[$property] = $this->normalizeMetafields($data[$property]); + } + } + } + + public function beforeSave($event, $entity, $options) + { + if (empty($entity->metaFields)) { + return; + } + } + + public function normalizeMetafields($metaFields) + { + return $metaFields; + } + + /** + * Usage: + * $this->{$model}->find('metaField', [ + * ['meta_template_id' => 1, 'field' => 'email', 'value' => '%@domain.test'], + * ['meta_template_id' => 1, 'field' => 'country_code', 'value' => '!LU'], + * ['meta_template_id' => 1, 'field' => 'time_zone', 'value' => 'UTC+2'], + * ]) + * $this->{$model}->find('metaField', [ + * 'AND' => [ + * ['meta_template_id' => 1, 'field' => 'email', 'value' => '%@domain.test'], + * 'OR' => [ + * ['meta_template_id' => 1, 'field' => 'time_zone', 'value' => 'UTC+1'], + * ['meta_template_id' => 1, 'field' => 'time_zone', 'value' => 'UTC+2'], + * ], + * ], + * ]) + */ + public function findMetafield(Query $query, array $filters) + { + $conditions = $this->buildQuerySnippetForMatchingParent($filters); + $query->where($conditions); + return $query; + } + + public function buildQuerySnippetForMatchingParent(array $filters): array + { + if (empty($filters)) { + return []; + } + if (count(array_filter(array_keys($filters), 'is_string'))) { + $filters = [$filters]; + } + $conjugatedFilters = $this->buildConjugatedFilters($filters); + $conditions = $this->buildConjugatedQuerySnippet($conjugatedFilters); + return $conditions; + } + + protected function buildConjugatedFilters(array $filters): array + { + $conjugatedFilters = []; + foreach ($filters as $operator => $subFilters) { + if (is_numeric($operator)) { + $conjugatedFilters[] = $subFilters; + } else { + if (!empty($subFilters)) { + $conjugatedFilters[$operator] = $this->buildConjugatedFilters($subFilters); + } + } + } + return $conjugatedFilters; + } + + protected function buildConjugatedQuerySnippet(array $conjugatedFilters, string $parentOperator='AND'): array + { + $conditions = []; + if (empty($conjugatedFilters['AND']) && empty($conjugatedFilters['OR'])) { + if (count(array_filter(array_keys($conjugatedFilters), 'is_string')) > 0) { + $conditions = $this->buildComposedQuerySnippet([$conjugatedFilters]); + } else { + $conditions = $this->buildComposedQuerySnippet($conjugatedFilters, $parentOperator); + } + } else { + foreach ($conjugatedFilters as $subOperator => $subFilter) { + $conditions[$subOperator] = $this->buildConjugatedQuerySnippet($subFilter, $subOperator); + } + } + return $conditions; + } + + protected function buildComposedQuerySnippet(array $filters, string $operator='AND'): array + { + $conditions = []; + foreach ($filters as $filterOperator => $filter) { + $subQuery = $this->buildQuerySnippet($filter, true); + $modelAlias = $this->_table->getAlias(); + $conditions[$operator][] = [$modelAlias . '.id IN' => $subQuery]; + } + return $conditions; + } + + + protected function setQueryExpressionForTextField(QueryExpression $exp, string $field, string $value): QueryExpression + { + if (substr($value, 0, 1) == '!') { + $value = substr($value, 1); + $exp->notEq($field, $value); + } else if (strpos($value, '%') !== false) { + $exp->like($field, $value); + } else { + $exp->eq($field, $value); + } + return $exp; + } + + protected function buildQuerySnippet(array $filter): Query + { + $metaTemplateField = !empty($filter['meta_template_field_id']) ? $this->_metaTemplateFieldTable->get($filter['meta_template_field_id']) : null; + $whereClosure = function (QueryExpression $exp) use ($filter, $metaTemplateField) { + foreach ($filter as $column => $value) { + $keyedColumn = 'MetaFields.' . $column; + if ($column == 'value') { + $this->setQueryExpressionForField($exp, $keyedColumn, $value, $metaTemplateField); + } else { + $this->setQueryExpressionForTextField($exp, $keyedColumn, $value); + } + } + return $exp; + }; + + $foreignKey = $this->getConfig('modelAssoc.foreignKey'); + $query = $this->_table->MetaFields->find() + ->select('MetaFields.' . $foreignKey) + ->where($whereClosure); + return $query; + } + + protected function setQueryExpressionForField(QueryExpression $exp, string $field, string $value, \App\Model\Entity\MetaTemplateField $metaTemplateField=null): QueryExpression + { + if (!is_null($metaTemplateField) && isset($this->typeHandlers[$metaTemplateField->type])) { + $exp = $this->typeHandlers[$metaTemplateField->type]->setQueryExpression($exp, $value, $metaTemplateField); + } else { + $exp = $this->setQueryExpressionForTextField($exp, $field, $value); + } + return $exp; + } +} diff --git a/src/Model/Entity/Individual.php b/src/Model/Entity/Individual.php index dcf932f..cd1cc9d 100644 --- a/src/Model/Entity/Individual.php +++ b/src/Model/Entity/Individual.php @@ -19,7 +19,7 @@ class Individual extends AppModel 'created' => true, ]; - protected $_virtual = ['full_name']; + protected $_virtual = ['full_name', 'alternate_emails']; protected function _getFullName() { @@ -28,4 +28,17 @@ class Individual extends AppModel } return sprintf("%s %s", $this->first_name, $this->last_name); } + + protected function _getAlternateEmails() + { + $emails = []; + if (!empty($this->meta_fields)) { + foreach ($this->meta_fields as $metaField) { + if (str_contains($metaField->field, 'email')) { + $emails[] = $metaField; + } + } + } + return $emails; + } } diff --git a/src/Model/Entity/MailingList.php b/src/Model/Entity/MailingList.php new file mode 100644 index 0000000..02ff121 --- /dev/null +++ b/src/Model/Entity/MailingList.php @@ -0,0 +1,51 @@ + true, + 'id' => false, + 'uuid' => false, + 'user_id' => false, + ]; + + protected $_accessibleOnNew = [ + 'uuid' => true, + 'user_id' => true, + ]; + + private $metaFieldsByParentId = []; + + public function injectRegisteredEmailsIntoIndividuals() + { + if (empty($this->individuals)) { + return; + } + if (!empty($this->meta_fields)) { + foreach ($this->meta_fields as $meta_field) { + $this->metaFieldsByParentId[$meta_field->parent_id][] = $meta_field; + } + } + foreach ($this->individuals as $i => $individual) { + $this->individuals[$i]->mailinglist_emails = $this->collectEmailsForMailingList($individual); + } + } + + protected function collectEmailsForMailingList($individual) + { + $emails = []; + if (!empty($individual['_joinData']) && !empty($individual['_joinData']['include_primary_email'])) { + $emails[] = $individual->email; + } + if (!empty($this->metaFieldsByParentId[$individual->id])) { + foreach ($this->metaFieldsByParentId[$individual->id] as $metaField) { + $emails[] = $metaField->value; + } + } + return $emails; + } +} diff --git a/src/Model/Entity/MetaTemplateField.php b/src/Model/Entity/MetaTemplateField.php new file mode 100644 index 0000000..7baf30a --- /dev/null +++ b/src/Model/Entity/MetaTemplateField.php @@ -0,0 +1,11 @@ + 5, + 'includeOthers' => true, + 'ignoreNull' => true, + ]; + $options = $this->getOptions($defaultOptions, $options); + $stats = []; + foreach ($scopes as $scope) { + $queryTopUsage = $table->find(); + $queryTopUsage + ->select([ + $scope, + 'count' => $queryTopUsage->func()->count('id'), + ]); + if ($queryTopUsage->getDefaultTypes()[$scope] != 'boolean') { + $queryTopUsage->where(function (QueryExpression $exp) use ($scope) { + return $exp + ->isNotNull($scope) + ->notEq($scope, ''); + }); + } + $queryTopUsage + ->group($scope) + ->order(['count' => 'DESC']) + ->limit($options['limit']) + ->page(1) + ->enableHydration(false); + $topUsage = $queryTopUsage->toList(); + $stats[$scope] = $topUsage; + if ( + !empty($options['includeOthers']) && !empty($topUsage) && + $queryTopUsage->getDefaultTypes()[$scope] != 'boolean' // No need to get others as we only have 2 possibilities already considered + ) { + $queryOthersUsage = $table->find(); + $queryOthersUsage + ->select([ + 'count' => $queryOthersUsage->func()->count('id'), + ]) + ->where(function (QueryExpression $exp, Query $query) use ($topUsage, $scope, $options) { + if (!empty($options['ignoreNull'])) { + return $exp + ->isNotNull($scope) + ->notEq($scope, '') + ->notIn($scope, Hash::extract($topUsage, "{n}.{$scope}")); + } else { + return $exp->or([ + $query->newExpr()->isNull($scope), + $query->newExpr()->eq($scope, ''), + $query->newExpr()->notIn($scope, Hash::extract($topUsage, "{n}.{$scope}")), + ]); + } + }) + ->enableHydration(false); + $othersUsage = $queryOthersUsage->toList(); + if (!empty($othersUsage)) { + $stats[$scope][] = [ + $scope => __('Others'), + 'count' => $othersUsage[0]['count'], + ]; + } + } + } + return $stats; + } + + private function getOptions($defaults=[], $options=[]): array + { + return array_merge($defaults, $options); + } + + // Move this into a tool + public function getActivityStatisticsForModel(Object $table, int $days = 30): array + { + $statistics = []; + if ($table->hasBehavior('Timestamp')) { + if ($table->getSchema()->getColumnType('created') == 'datetime') { + $statistics['created'] = $this->getActivityStatistic($table, $days, 'created'); + } + if ($table->getSchema()->getColumnType('modified') == 'datetime') { + $statistics['modified'] = $this->getActivityStatistic($table, $days, 'modified'); + } + } + return $statistics; + } + + public function getActivityStatistic(Object $table, int $days = 30, string $field = 'modified', bool $includeTimeline = true): array + { + $statistics = []; + $statistics['days'] = $days; + $statistics['amount'] = $table->find()->all()->count(); + if ($table->behaviors()->has('Timestamp') && $includeTimeline) { + $statistics['timeline'] = $this->buildTimeline($table, $days, $field); + $statistics['variation'] = $table->find()->where(["{$field} >" => FrozenTime::now()->subDays($days)])->all()->count(); + } else { + $statistics['timeline'] = []; + $statistics['variation'] = 0; + } + return $statistics; + } + + public function buildTimeline(Object $table, int $days = 30, string $field = 'modified'): array + { + $timeline = []; + $authorizedFields = ['modified', 'created']; + if ($table->behaviors()->has('Timestamp')) { + if (!in_array($field, $authorizedFields)) { + throw new MethodNotAllowedException(__('Cannot construct timeline for field `{0}`', $field)); + } + $days = $days - 1; + $query = $table->find(); + $query->select([ + 'count' => $query->func()->count('id'), + 'date' => "DATE({$field})", + ]) + ->where(["{$field} >" => FrozenTime::now()->subDays($days)]) + ->group(['date']) + ->order(['date']); + $data = $query->all()->toArray(); + $interval = new \DateInterval('P1D'); + $period = new \DatePeriod(FrozenTime::now()->subDays($days), $interval, FrozenTime::now()->addDays(1)); + foreach ($period as $date) { + $timeline[$date->format("Y-m-d")] = [ + 'time' => $date->format("Y-m-d"), + 'count' => 0 + ]; + } + foreach ($data as $entry) { + $timeline[$entry->date]['count'] = $entry->count; + } + $timeline = array_values($timeline); + } + return $timeline; + } + public function saveMetaFields($id, $input) { $this->MetaFields = TableRegistry::getTableLocator()->get('MetaFields'); diff --git a/src/Model/Table/AuditLogsTable.php b/src/Model/Table/AuditLogsTable.php index 7987af6..e6f6f95 100644 --- a/src/Model/Table/AuditLogsTable.php +++ b/src/Model/Table/AuditLogsTable.php @@ -43,14 +43,8 @@ class AuditLogsTable extends AppTable public function initialize(array $config): void { parent::initialize($config); - $this->addBehavior('UUID'); - $this->addBehavior('Timestamp', [ - 'Model.beoreSave' => [ - 'created_at' => 'new' - ] - ]); + $this->addBehavior('Timestamp'); $this->belongsTo('Users'); - $this->setDisplayField('info'); $this->compressionEnabled = Configure::read('Cerebrate.log_new_audit_compress') && function_exists('brotli_compress'); } diff --git a/src/Model/Table/InboxTable.php b/src/Model/Table/InboxTable.php index a2c0e2c..0617cea 100644 --- a/src/Model/Table/InboxTable.php +++ b/src/Model/Table/InboxTable.php @@ -9,6 +9,8 @@ use Cake\ORM\RulesChecker; use Cake\Validation\Validator; use Cake\Http\Exception\NotFoundException; +use App\Utility\UI\Notification; + Type::map('json', 'Cake\Database\Type\JsonType'); class InboxTable extends AppTable @@ -89,4 +91,44 @@ class InboxTable extends AppTable $savedEntry = $this->save($entryData); return $savedEntry; } + + public function collectNotifications(\App\Model\Entity\User $user): array + { + $allNotifications = []; + $inboxNotifications = $this->getNotificationsForUser($user); + foreach ($inboxNotifications as $notification) { + $title = __('New message'); + $details = $notification->title; + $router = [ + 'controller' => 'inbox', + 'action' => 'process', + 'plugin' => null, + $notification->id + ]; + $allNotifications[] = (new Notification($title, $router, [ + 'icon' => 'envelope', + 'details' => $details, + 'datetime' => $notification->created, + 'variant' => 'warning', + '_useModal' => true, + '_sidebarId' => 'inbox', + ]))->get(); + } + return $allNotifications; + } + + public function getNotificationsForUser(\App\Model\Entity\User $user): array + { + $query = $this->find(); + $conditions = []; + if ($user['role']['perm_admin']) { + // Admin will not see notifications if it doesn't belong to them. They can see process the message from the inbox + $conditions['Inbox.user_id IS'] = null; + } else { + $conditions['Inbox.user_id'] = $user->id; + } + $query->where($conditions); + $notifications = $query->all()->toArray(); + return $notifications; + } } diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php index de5b8e8..6ee0fd7 100644 --- a/src/Model/Table/IndividualsTable.php +++ b/src/Model/Table/IndividualsTable.php @@ -10,15 +10,15 @@ use Cake\ORM\Query; class IndividualsTable extends AppTable { - public $metaFields = 'individual'; - public function initialize(array $config): void { parent::initialize($config); $this->addBehavior('UUID'); $this->addBehavior('Timestamp'); $this->addBehavior('Tags.Tag'); + $this->addBehavior('MetaFields'); $this->addBehavior('AuditLog'); + $this->hasMany( 'Alignments', [ @@ -39,6 +39,10 @@ class IndividualsTable extends AppTable $this->belongsToMany('Organisations', [ 'through' => 'Alignments', ]); + $this->belongsToMany('MailingLists', [ + 'through' => 'mailing_lists_individuals', + ]); + $this->setDisplayField('email'); } diff --git a/src/Model/Table/InstanceTable.php b/src/Model/Table/InstanceTable.php index 299536b..624fe7a 100644 --- a/src/Model/Table/InstanceTable.php +++ b/src/Model/Table/InstanceTable.php @@ -9,18 +9,18 @@ use Cake\Validation\Validator; use Migrations\Migrations; use Cake\Filesystem\Folder; use Cake\Http\Exception\MethodNotAllowedException; -use Cake\I18n\FrozenTime; class InstanceTable extends AppTable { protected $activePlugins = ['Tags', 'ADmad/SocialAuth']; - public $seachAllTables = ['Broods', 'Individuals', 'Organisations', 'SharingGroups', 'Users', 'EncryptionKeys', ]; + public $seachAllTables = []; public function initialize(array $config): void { parent::initialize($config); $this->addBehavior('AuditLog'); $this->setDisplayField('name'); + $this->setSearchAllTables(); } public function validationDefault(Validator $validator): Validator @@ -28,88 +28,121 @@ class InstanceTable extends AppTable return $validator; } - public function getStatistics($days=30): array + public function setSearchAllTables(): void + { + $this->seachAllTables = [ + 'Broods' => ['conditions' => false, 'afterFind' => false], + 'Individuals' => ['conditions' => false, 'afterFind' => false], + 'Organisations' => ['conditions' => false, 'afterFind' => false], + 'SharingGroups' => [ + 'conditions' => false, + 'afterFind' => function($result, $user) { + foreach ($result as $i => $row) { + if (empty($user['role']['perm_admin'])) { + $orgFound = false; + if (!empty($row['sharing_group_orgs'])) { + foreach ($row['sharing_group_orgs'] as $org) { + if ($org['id'] === $user['organisation_id']) { + $orgFound = true; + } + } + } + if ($row['organisation_id'] !== $user['organisation_id'] && !$orgFound) { + unset($result[$i]); + } + } + } + return $result; + }, + ], + 'Users' => [ + 'conditions' => function($user) { + $conditions = []; + if (empty($user['role']['perm_admin'])) { + $conditions['Users.organisation_id'] = $user['organisation_id']; + } + return $conditions; + }, + 'afterFind' => function ($result, $user) { + return $result; + }, + ], + 'EncryptionKeys' => ['conditions' => false, 'afterFind' => false], + ]; + } + + public function getStatistics(int $days=30): array { $models = ['Individuals', 'Organisations', 'Alignments', 'EncryptionKeys', 'SharingGroups', 'Users', 'Broods', 'Tags.Tags']; foreach ($models as $model) { $table = TableRegistry::getTableLocator()->get($model); - $statistics[$model]['amount'] = $table->find()->all()->count(); - if ($table->behaviors()->has('Timestamp')) { - $query = $table->find(); - $query->select([ - 'count' => $query->func()->count('id'), - 'date' => 'DATE(modified)', - ]) - ->where(['modified >' => FrozenTime::now()->subDays($days)]) - ->group(['date']) - ->order(['date']); - $data = $query->toArray(); - $interval = new \DateInterval('P1D'); - $period = new \DatePeriod(FrozenTime::now()->subDays($days), $interval, FrozenTime::now()); - $timeline = []; - foreach ($period as $date) { - $timeline[$date->format("Y-m-d")] = [ - 'time' => $date->format("Y-m-d"), - 'count' => 0 - ]; - } - foreach ($data as $entry) { - $timeline[$entry->date]['count'] = $entry->count; - } - $statistics[$model]['timeline'] = array_values($timeline); - - $startCount = $table->find()->where(['modified <' => FrozenTime::now()->subDays($days)])->all()->count(); - $endCount = $statistics[$model]['amount']; - $statistics[$model]['variation'] = $endCount - $startCount; - } else { - $statistics[$model]['timeline'] = []; - $statistics[$model]['variation'] = 0; - } + $statistics[$model] = $this->getActivityStatisticsForModel($table, $days); } return $statistics; } - public function searchAll($value, $limit=5, $model=null) + public function searchAll($value, $user, $limit=5, $model=null) { $results = []; - - // search in metafields. FIXME: To be replaced by the meta-template system - $metaFieldTable = TableRegistry::get('MetaFields'); - $query = $metaFieldTable->find()->where([ - 'value LIKE' => '%' . $value . '%' - ]); - $results['MetaFields']['amount'] = $query->count(); - $result = $query->limit($limit)->all()->toList(); - if (!empty($result)) { - $results['MetaFields']['entries'] = $result; - } - $models = $this->seachAllTables; if (!is_null($model)) { - if (in_array($model, $this->seachAllTables)) { - $models = [$model]; + if (in_array($model, array_keys($this->seachAllTables))) { + $models = [$model => $this->seachAllTables[$model]]; } else { return $results; // Cannot search in this model } } - foreach ($models as $tableName) { + + // search in metafields. FIXME?: Use meta-fields type handler to search for meta-field values + if (is_null($model)) { + $metaFieldTable = TableRegistry::get('MetaFields'); + $query = $metaFieldTable->find()->where([ + 'value LIKE' => '%' . $value . '%' + ]); + $results['MetaFields']['amount'] = $query->count(); + $result = $query->limit($limit)->all()->toList(); + if (!empty($result)) { + $results['MetaFields']['entries'] = $result; + } + } + + foreach ($models as $tableName => $tableConfig) { $controller = $this->getController($tableName); $table = TableRegistry::get($tableName); $query = $table->find(); - $quickFilterOptions = $this->getQuickFiltersFieldsFromController($controller); + $quickFilters = $this->getQuickFiltersFieldsFromController($controller); $containFields = $this->getContainFieldsFromController($controller); - if (empty($quickFilterOptions)) { + if (empty($quickFilters)) { continue; // make sure we are filtering on something } $params = ['quickFilter' => $value]; + $quickFilterOptions = ['quickFilters' => $quickFilters]; $query = $controller->CRUD->setQuickFilters($params, $query, $quickFilterOptions); + if (!empty($tableConfig['conditions'])) { + $whereClause = []; + if (is_callable($tableConfig['conditions'])) { + $whereClause = $tableConfig['conditions']($user); + } else { + $whereClause = $tableConfig['conditions']; + } + $query->where($whereClause); + } if (!empty($containFields)) { $query->contain($containFields); } - $results[$tableName]['amount'] = $query->count(); + if (!empty($tableConfig['contain'])) { + $query->contain($tableConfig['contain']); + } + if (empty($tableConfig['afterFind'])) { + $results[$tableName]['amount'] = $query->count(); + } $result = $query->limit($limit)->all()->toList(); if (!empty($result)) { + if (!empty($tableConfig['afterFind'])) { + $result = $tableConfig['afterFind']($result, $user); + } $results[$tableName]['entries'] = $result; + $results[$tableName]['amount'] = count($result); } } return $results; @@ -119,7 +152,7 @@ class InstanceTable extends AppTable { $controllerName = "\\App\\Controller\\{$name}Controller"; if (!class_exists($controllerName)) { - throw new MethodNotAllowedException(__('Model `{0}` does not exists', $model)); + throw new MethodNotAllowedException(__('Model `{0}` does not exists', $name)); } $controller = new $controllerName; return $controller; diff --git a/src/Model/Table/MailingListsTable.php b/src/Model/Table/MailingListsTable.php new file mode 100644 index 0000000..7b0a239 --- /dev/null +++ b/src/Model/Table/MailingListsTable.php @@ -0,0 +1,60 @@ +addBehavior('UUID'); + $this->addBehavior('Timestamp'); + $this->belongsTo( + 'Users' + ); + + $this->belongsToMany('Individuals', [ + 'joinTable' => 'mailing_lists_individuals', + ]); + // Change to HasMany? + $this->belongsToMany('MetaFields'); + + $this->setDisplayField('name'); + } + + public function validationDefault(Validator $validator): Validator + { + $validator + ->requirePresence(['name', 'releasability'], 'create'); + return $validator; + } + + public function buildRules(RulesChecker $rules): RulesChecker + { + return $rules; + } + + public function isIndividualListed($individual, \App\Model\Entity\MailingList $mailinglist): bool + { + $found = false; + if (empty($mailinglist['individuals'])) { + return false; + } + $individual_id_to_find = $individual; + if (is_object($individual)) { + $individual_id_to_find = $individual['id']; + } + foreach ($mailinglist['individuals'] as $individual) { + if ($individual['id'] == $individual_id_to_find) { + return true; + } + } + return $found; + } +} \ No newline at end of file diff --git a/src/Model/Table/MetaFieldsTable.php b/src/Model/Table/MetaFieldsTable.php index 0148ffd..8c5a6d0 100644 --- a/src/Model/Table/MetaFieldsTable.php +++ b/src/Model/Table/MetaFieldsTable.php @@ -5,6 +5,7 @@ namespace App\Model\Table; use App\Model\Table\AppTable; use Cake\ORM\Table; use Cake\Validation\Validator; +use Cake\ORM\RulesChecker; class MetaFieldsTable extends AppTable { @@ -12,11 +13,17 @@ class MetaFieldsTable extends AppTable { parent::initialize($config); $this->addBehavior('UUID'); + $this->addBehavior('Timestamp'); + $this->addBehavior('CounterCache', [ + 'MetaTemplateFields' => ['counter'] + ]); + $this->addBehavior('AuditLog'); $this->addBehavior('Timestamp'); - $this->setDisplayField('field'); $this->belongsTo('MetaTemplates'); $this->belongsTo('MetaTemplateFields'); + + $this->setDisplayField('field'); } public function validationDefault(Validator $validator): Validator @@ -30,7 +37,59 @@ class MetaFieldsTable extends AppTable ->notEmptyString('meta_template_field_id') ->requirePresence(['scope', 'field', 'value', 'uuid', 'meta_template_id', 'meta_template_field_id'], 'create'); - // add validation regex + $validator->add('value', 'validMetaField', [ + 'rule' => 'isValidMetaField', + 'message' => __('The provided value doesn\'t pass the validation check for its meta-template.'), + 'provider' => 'table', + ]); + return $validator; } + + public function isValidMetaField($value, array $context) + { + $metaFieldsTable = $context['providers']['table']; + $entityData = $context['data']; + $metaTemplateField = $metaFieldsTable->MetaTemplateFields->get($entityData['meta_template_field_id']); + return $this->isValidMetaFieldForMetaTemplateField($value, $metaTemplateField); + } + + public function isValidMetaFieldForMetaTemplateField($value, $metaTemplateField) + { + $typeValid = $this->isValidType($value, $metaTemplateField); + if ($typeValid !== true) { + return $typeValid; + } + if (!empty($metaTemplateField['regex'])) { + return $this->isValidRegex($value, $metaTemplateField); + } + return true; + } + + public function isValidType($value, $metaTemplateField) + { + if (empty($value)) { + return __('Metafield value cannot be empty.'); + } + $typeHandler = $this->MetaTemplateFields->getTypeHandler($metaTemplateField['type']); + if (!empty($typeHandler)) { + $success = $typeHandler->validate($value); + return $success ? true : __('Metafields value `{0}` for `{1}` doesn\'t pass type validation.', $value, $metaTemplateField['field']); + } + /* + We should not end-up in this case. But if someone creates a new type without his handler, + we consider its type to be a valid text. + */ + return true; + } + + public function isValidRegex($value, $metaTemplateField) + { + + $re = $metaTemplateField['regex']; + if (!preg_match("/^$re$/m", $value)) { + return __('Metafield value `{0}` for `{1}` doesn\'t pass regex validation.', $value, $metaTemplateField['field']); + } + return true; + } } diff --git a/src/Model/Table/MetaTemplateFieldsTable.php b/src/Model/Table/MetaTemplateFieldsTable.php index 453690a..626a96c 100644 --- a/src/Model/Table/MetaTemplateFieldsTable.php +++ b/src/Model/Table/MetaTemplateFieldsTable.php @@ -6,16 +6,37 @@ use App\Model\Table\AppTable; use Cake\ORM\Table; use Cake\Validation\Validator; +use MetaFieldsTypes\TextType; +use MetaFieldsTypes\IPv4Type; +use MetaFieldsTypes\IPv6Type; +require_once(APP . 'Lib' . DS . 'default' . DS . 'meta_fields_types' . DS . 'TextType.php'); +require_once(APP . 'Lib' . DS . 'default' . DS . 'meta_fields_types' . DS . 'IPv4Type.php'); +require_once(APP . 'Lib' . DS . 'default' . DS . 'meta_fields_types' . DS . 'IPv6Type.php'); + class MetaTemplateFieldsTable extends AppTable { + private $typeHandlers = []; + public function initialize(array $config): void { parent::initialize($config); + $this->BelongsTo( 'MetaTemplates' ); $this->hasMany('MetaFields'); + $this->setDisplayField('field'); + $this->loadTypeHandlers(); + } + + public function beforeSave($event, $entity, $options) + { + if (empty($entity->meta_template_id)) { + $event->stopPropagation(); + $event->setResult(false); + return; + } } public function validationDefault(Validator $validator): Validator @@ -23,9 +44,31 @@ class MetaTemplateFieldsTable extends AppTable $validator ->notEmptyString('field') ->notEmptyString('type') - ->numeric('meta_template_id') - ->notBlank('meta_template_id') - ->requirePresence(['meta_template_id', 'field', 'type'], 'create'); + ->requirePresence(['field', 'type'], 'create'); return $validator; } + + public function loadTypeHandlers(): void + { + if (empty($this->typeHandlers)) { + $typeHandlers = [ + new TextType(), + new IPv4Type(), + new IPv6Type(), + ]; + foreach ($typeHandlers as $handler) { + $this->typeHandlers[$handler::TYPE] = $handler; + } + } + } + + public function getTypeHandlers(): array + { + return $this->typeHandlers; + } + + public function getTypeHandler($type) + { + return $this->typeHandlers[$type] ?? false; + } } diff --git a/src/Model/Table/MetaTemplatesTable.php b/src/Model/Table/MetaTemplatesTable.php index 9e01ccd..77df33d 100644 --- a/src/Model/Table/MetaTemplatesTable.php +++ b/src/Model/Table/MetaTemplatesTable.php @@ -4,11 +4,30 @@ namespace App\Model\Table; use App\Model\Table\AppTable; use Cake\ORM\Table; +use Cake\ORM\TableRegistry; use Cake\Validation\Validator; +use Cake\Utility\Hash; +use Cake\Utility\Inflector; +use Cake\Utility\Text; +use Cake\Filesystem\File; +use Cake\Filesystem\Folder; class MetaTemplatesTable extends AppTable { - public $metaFields = true; + public const TEMPLATE_PATH = [ + ROOT . '/libraries/default/meta_fields/', + ROOT . '/libraries/custom/meta_fields/' + ]; + + public const UPDATE_STRATEGY_CREATE_NEW = 'create_new'; + public const UPDATE_STRATEGY_UPDATE_EXISTING = 'update_existing'; + public const UPDATE_STRATEGY_KEEP_BOTH = 'keep_both'; + public const UPDATE_STRATEGY_DELETE = 'delete_all'; + + public const DEFAULT_STRATEGY = MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW; + public const ALLOWED_STRATEGIES = [MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW]; + + private $templatesOnDisk = null; public function initialize(array $config): void { @@ -17,7 +36,10 @@ class MetaTemplatesTable extends AppTable $this->hasMany( 'MetaTemplateFields', [ - 'foreignKey' => 'meta_template_id' + 'foreignKey' => 'meta_template_id', + 'saveStrategy' => 'replace', + 'dependent' => true, + 'cascadeCallbacks' => true, ] ); $this->setDisplayField('name'); @@ -36,40 +58,619 @@ class MetaTemplatesTable extends AppTable return $validator; } - public function update() + public function isStrategyAllowed(string $strategy): bool { - $paths = [ - ROOT . '/libraries/default/meta_fields/', - ROOT . '/libraries/custom/meta_fields/' - ]; + return in_array($strategy, MetaTemplatesTable::ALLOWED_STRATEGIES); + } + + /** + * Load the templates stored on the disk update and create them in the database without touching at the existing ones + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return array The update result containing potential errors and the successes + */ + public function updateAllTemplates(): array + { + $updatesErrors = []; $files_processed = []; - foreach ($paths as $path) { + $templatesOnDisk = $this->readTemplatesFromDisk(); + $templatesUpdateStatus = $this->getUpdateStatusForTemplates(); + foreach ($templatesOnDisk as $template) { + $errors = []; + $success = false; + $updateStatus = $templatesUpdateStatus[$template['uuid']]; + if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW) && $updateStatus['new']) { + $success = $this->saveNewMetaTemplate($template, $errors); + } + if ($success) { + $files_processed[] = $template['uuid']; + } else { + $updatesErrors[] = $errors; + } + } + $results = [ + 'update_errors' => $updatesErrors, + 'files_processed' => $files_processed, + 'success' => !empty($files_processed), + ]; + return $results; + } + + /** + * Load the template stored on the disk for the provided meta-template and update it using the optional strategy. + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @param string|null $strategy The strategy to be used when updating templates with conflicts + * @return array The update result containing potential errors and the successes + */ + public function update($metaTemplate, $strategy = null): array + { + $files_processed = []; + $updatesErrors = []; + $templateOnDisk = $this->readTemplateFromDisk($metaTemplate->uuid); + $templateStatus = $this->getStatusForMetaTemplate($templateOnDisk, $metaTemplate); + $updateStatus = $this->computeFullUpdateStatusForMetaTemplate($templateStatus, $metaTemplate); + $errors = []; + $success = false; + if ($updateStatus['up-to-date']) { + $errors['message'] = __('Meta-template already up-to-date'); + $success = true; + } else if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW) && $updateStatus['is-new']) { + $success = $this->saveNewMetaTemplate($templateOnDisk, $errors); + } else if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_UPDATE_EXISTING) && $updateStatus['automatically-updateable']) { + $success = $this->updateMetaTemplate($metaTemplate, $templateOnDisk, $errors); + } else if (!$updateStatus['up-to-date'] && (!is_null($strategy) && !$this->isStrategyAllowed($strategy))) { + $errors['message'] = __('Cannot update meta-template, update strategy not allowed'); + } else if (!$updateStatus['up-to-date']) { + $strategy = is_null($strategy) ? MetaTemplatesTable::DEFAULT_STRATEGY : $strategy; + $success = $this->updateMetaTemplateWithStrategyRouter($metaTemplate, $templateOnDisk, $strategy, $errors); + } else { + $errors['message'] = __('Could not update. Something went wrong.'); + } + if ($success) { + $files_processed[] = $templateOnDisk['uuid']; + } + if (!empty($errors)) { + $updatesErrors[] = $errors; + } + $results = [ + 'update_errors' => $updatesErrors, + 'files_processed' => $files_processed, + 'success' => !empty($files_processed), + ]; + return $results; + } + + /** + * Load the templates stored on the disk update and create the one having the provided UUID in the database + * Will do nothing if the UUID is already known + * + * @param string $uuid + * @return array The update result containing potential errors and the successes + */ + public function createNewTemplate(string $uuid): array + { + $templateOnDisk = $this->readTemplateFromDisk($uuid); + $templateStatus = $this->getUpdateStatusForTemplate($templateOnDisk); + $errors = []; + $updatesErrors = []; + $files_processed = []; + $savedMetaTemplate = null; + $success = false; + if (empty($templateStatus['new'])) { + $error['message'] = __('Template UUID already exists'); + $success = true; + } else if ($this->isStrategyAllowed(MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW)) { + $success = $this->saveNewMetaTemplate($templateOnDisk, $errors, $savedMetaTemplate); + } else { + $errors['message'] = __('Could not create template. Something went wrong.'); + } + if ($success) { + $files_processed[] = $templateOnDisk['uuid']; + } + if (!empty($errors)) { + $updatesErrors[] = $errors; + } + $results = [ + 'update_errors' => $updatesErrors, + 'files_processed' => $files_processed, + 'success' => !empty($files_processed), + ]; + return $results; + } + + /** + * Load the templates stored on the disk and compute their update status. + * Only compute the result if an UUID is provided + * + * @param string|null $template_uuid + * @return array + */ + public function getUpdateStatusForTemplates(): array + { + $errors = []; + $templateUpdatesStatus = []; + $templates = $this->readTemplatesFromDisk($errors); + foreach ($templates as $template) { + $templateUpdatesStatus[$template['uuid']] = $this->getUpdateStatusForTemplate($template); + } + return $templateUpdatesStatus; + } + + + /** + * Checks if the template is update-to-date from the provided update status + * + * @param array $updateStatus + * @return boolean + */ + public function isUpToDate(array $updateStatus): bool + { + return !empty($updateStatus['up-to-date']) || !empty($updateStatus['new']); + } + + /** + * Checks if the template is updateable automatically from the provided update status + * + * @param array $updateStatus + * @return boolean + */ + public function isAutomaticallyUpdateable(array $updateStatus): bool + { + return !empty($updateStatus['automatically-updateable']); + } + + /** + * Checks if the template is new (and not loaded in the database yet) from the provided update status + * + * @param array $updateStatus + * @return boolean + */ + public function isNew(array $updateStatus): bool + { + return $updateStatus['new']; + } + + /** + * Checks if the template has no conflicts that would prevent an automatic update from the provided update status + * + * @param array $updateStatus + * @return boolean + */ + public function hasNoConflict(array $updateStatus): bool + { + return $this->hasConflict($updateStatus); + } + + /** + * Checks if the template has conflict preventing an automatic update from the provided update status + * + * @param array $updateStatus + * @return boolean + */ + public function hasConflict(array $updateStatus): bool + { + return empty($updateStatus['automatically-updateable']) && empty($updateStatus['up-to-date']) && empty($updateStatus['new']); + } + + /** + * Checks if the metaTemplate can be updated to a newer version loaded in the database + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return boolean + */ + public function isUpdateableToExistingMetaTemplate(\App\Model\Entity\MetaTemplate $metaTemplate): bool + { + $newestTemplate = $this->getNewestVersion($metaTemplate); + return !empty($newestTemplate); + } + + /** + * Checks if the template can be removed from the database for the provided update status. + * A template can be removed if a newer version is already loaded in the database and no meta-fields are using it. + * + * @param array $updateStatus + * @return boolean + */ + public function isRemovable(array $updateStatus): bool + { + return !empty($updateStatus['can-be-removed']); + } + + /** + * Compute the state from the provided update status and metaTemplate + * + * @param array $updateStatus + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return array + */ + public function computeFullUpdateStatusForMetaTemplate(array $updateStatus, \App\Model\Entity\MetaTemplate $metaTemplate): array + { + return [ + 'up-to-date' => $this->isUpToDate($updateStatus), + 'automatically-updateable' => $this->isAutomaticallyUpdateable($updateStatus), + 'is-new' => $this->isNew($updateStatus), + 'has-conflict' => $this->hasConflict($updateStatus), + 'to-existing' => $this->isUpdateableToExistingMetaTemplate($metaTemplate), + 'can-be-removed' => $this->isRemovable($updateStatus), + ]; + } + + /** + * Get the update status of meta-templates that are up-to-date in regards to the template stored on the disk. + * + * @param array|null $updateStatus + * @return array The list of update status for up-to-date templates + */ + public function getUpToDateTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $uuid => $updateStatus) { + if (!$this->isUpToDate($updateStatus)) { + unset($updatesStatus[$uuid]); + } + } + return $updatesStatus; + } + + /** + * Get the update status of meta-templates that are not up-to-date in regards to the template stored on the disk. + * + * @param array|null $updateResult + * @return array The list of update status for non up-to-date templates + */ + public function getNotUpToDateTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $uuid => $updateStatus) { + if ($this->isUpToDate($updateStatus)) { + unset($updatesStatus[$uuid]); + } + } + return $updatesStatus; + } + + /** + * Get the update status of meta-templates that are automatically updateable in regards to the template stored on the disk. + * + * @param array|null $updateResult + * @return array The list of update status for non up-to-date templates + */ + public function getAutomaticallyUpdateableTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $uuid => $updateStatus) { + if (!$this->isAutomaticallyUpdateable($updateStatus)) { + unset($updatesStatus[$uuid]); + } + } + return $updatesStatus; + } + + /** + * Get the update status of meta-templates that are new in regards to the template stored on the disk. + * + * @param array|null $updateResult + * @return array The list of update status for new templates + */ + public function getNewTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $uuid => $updateStatus) { + if (!$this->isNew($updateStatus)) { + unset($updatesStatus[$uuid]); + } + } + return $updatesStatus; + } + + /** + * Get the update status of meta-templates that have conflict preventing an automatic update in regards to the template stored on the disk. + * + * @param array|null $updateResult + * @return array The list of update status for new templates + */ + public function getConflictTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $uuid => $updateStatus) { + if (!$this->hasConflict($updateStatus)) { + unset($updatesStatus[$uuid]); + } + } + return $updatesStatus; + } + + /** + * Get the latest (having the higher version) meta-template loaded in the database for the provided meta-template + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @param boolean $full + * @return \App\Model\Entity\MetaTemplate|null + */ + public function getNewestVersion(\App\Model\Entity\MetaTemplate $metaTemplate, bool $full = false) + { + $query = $this->find()->where([ + 'uuid' => $metaTemplate->uuid, + 'id !=' => $metaTemplate->id, + 'version >=' => $metaTemplate->version, + ]) + ->order(['version' => 'DESC']); + if ($full) { + $query->contain(['MetaTemplateFields']); + } + $newestTemplate = $query->first(); + return $newestTemplate; + } + + /** + * Generate and return a query (to be used as a subquery) resolving to the IDs of the latest version of a saved meta-template + * + * @return \Cake\ORM\Query + */ + public function genQueryForAllNewestVersionIDs(): \Cake\ORM\Query + { + /** + * SELECT a.id FROM meta_templates a INNER JOIN ( + * SELECT uuid, MAX(version) maxVersion FROM meta_templates GROUP BY uuid + * ) b on a.uuid = b.uuid AND a.version = b.maxVersion; + */ + $query = $this->find() + ->select([ + 'id' + ]) + ->join([ + 't' => [ + 'table' => '(SELECT uuid, MAX(version) AS maxVersion FROM meta_templates GROUP BY uuid)', + 'type' => 'INNER', + 'conditions' => [ + 't.uuid = MetaTemplates.uuid', + 't.maxVersion = MetaTemplates.version' + ], + ], + ]); + return $query; + } + + /** + * Get the update status of meta-templates that can be removed. + * + * @param array|null $updateResult + * @return array The list of update status for new templates + */ + public function getCanBeRemovedTemplates($updatesStatus = null): array + { + $updatesStatus = is_null($updatesStatus) ? $this->getUpdateStatusForTemplates() : $updatesStatus; + foreach ($updatesStatus as $i => $updateStatus) { + if (!$this->isRemovable($updateStatus)) { + unset($updatesStatus[$i]); + } + } + return $updatesStatus; + } + + /** + * Reads all template stored on the disk and parse them + * + * @param array|null $errors Contains errors while parsing the meta-templates + * @return array The parsed meta-templates stored on the disk + */ + public function readTemplatesFromDisk(&$errors = []): array + { + if (!is_null($this->templatesOnDisk)) { + return $this->templatesOnDisk; + } + $templates = []; + $errors = []; + foreach (self::TEMPLATE_PATH as $path) { if (is_dir($path)) { $files = scandir($path); foreach ($files as $k => $file) { if (substr($file, -5) === '.json') { - if ($this->loadMetaFile($path . $file) === true) { - $files_processed[] = $file; + $errorMessage = ''; + $template = $this->decodeTemplateFromDisk($path . $file, $errorMessage); + if (!empty($template)) { + $templates[] = $template; + } else { + $errors[] = $errorMessage; } } } } } - return $files_processed; + $this->templatesOnDisk = $templates; + return $templates; } - public function getTemplate($id) + /** + * Read and parse the meta-template stored on disk having the provided UUID + * + * @param string $uuid + * @param string $error Contains the error while parsing the meta-template + * @return array|null The meta-template or null if not templates matche the provided UUID + */ + public function readTemplateFromDisk(string $uuid, &$error = ''): ?array { - $query = $this->find(); - $query->where(['id' => $id]); - $template = $query->first(); - if (empty($template)) { - throw new NotFoundException(__('Invalid template ID specified.')); + foreach (self::TEMPLATE_PATH as $path) { + if (is_dir($path)) { + $files = scandir($path); + foreach ($files as $k => $file) { + if (substr($file, -5) === '.json') { + $errorMessage = ''; + $template = $this->decodeTemplateFromDisk($path . $file, $errorMessage); + if (!empty($template) && $template['uuid'] == $uuid) { + return $template; + } + } + } + } } - return $template; + $error = __('Could not find meta-template with UUID {0}', $uuid); + return null; } - public function getDefaultTemplatePerScope(String $scope = '') + /** + * Read and decode the meta-template located at the provided path + * + * @param string $filePath + * @param string $errorMessage + * @return array|null The meta-template or null if there was an error while trying to decode + */ + public function decodeTemplateFromDisk(string $filePath, &$errorMessage = ''): ?array + { + $file = new File($filePath, false); + if ($file->exists()) { + $filename = $file->name(); + $content = $file->read(); + if (empty($content)) { + $errorMessage = __('Could not read template file `{0}`.', $filename); + return null; + } + $metaTemplate = json_decode($content, true); + if (empty($metaTemplate)) { + $errorMessage = __('Could not load template file `{0}`. Error while decoding the template\'s JSON', $filename); + return null; + } + if (empty($metaTemplate['uuid']) || empty($metaTemplate['version'])) { + $errorMessage = __('Could not load template file. Invalid template file. Missing template UUID or version'); + return null; + } + return $metaTemplate; + } + $errorMessage = __('File does not exists'); + return null; + } + + /** + * Collect all enties having meta-fields belonging to the provided template + * + * @param integer $template_id + * @param integer|bool $limit The limit of entities to be returned. Pass null to be ignore the limit + * @return array List of entities + */ + public function getEntitiesHavingMetaFieldsFromTemplate(int $metaTemplateId, $limit=10, int &$totalAmount=0): array + { + $metaTemplate = $this->get($metaTemplateId); + $queryParentEntities = $this->MetaTemplateFields->MetaFields->find(); + $queryParentEntities + ->select(['parent_id']) + ->where([ + 'meta_template_id' => $metaTemplateId + ]) + ->group(['parent_id']); + + $entitiesTable = $this->getTableForMetaTemplateScope($metaTemplate); + $entityQuery = $entitiesTable->find() + ->where(['id IN' => $queryParentEntities]) + ->contain([ + 'MetaFields' => [ + 'conditions' => [ + 'meta_template_id' => $metaTemplateId + ] + ] + ]); + if (!is_null($limit)) { + $totalAmount = $entityQuery->all()->count(); + $entityQuery->limit($limit); + } + $entities = $entityQuery->all()->toList(); + return $entities; + } + + /** + * Get the table linked to the meta-template + * + * @param \App\Model\Entity\MetaTemplate|string $metaTemplate + * @return \App\Model\Table\AppTable + */ + private function getTableForMetaTemplateScope($metaTemplateOrScope): \App\Model\Table\AppTable + { + if (is_string($metaTemplateOrScope)) { + $scope = $metaTemplateOrScope; + } else { + $scope = $metaTemplateOrScope->scope; + } + $entitiesClassName = Inflector::camelize(Inflector::pluralize($scope)); + $entitiesTable = TableRegistry::getTableLocator()->get($entitiesClassName); + return $entitiesTable; + } + + /** + * Get the meta-field keyed by their template_id and meta_template_id belonging to the provided entity + * + * @param integer $entity_id The entity for which the meta-fields belongs to + * @param array $conditions Additional conditions to be passed to the meta-fields query + * @return array The associated array containing the meta-fields keyed by their meta-template and meta-template-field IDs + */ + public function getKeyedMetaFieldsForEntity(int $entity_id, array $conditions = []): array + { + $query = $this->MetaTemplateFields->MetaFields->find(); + $query->where(array_merge( + $conditions, + [ + 'MetaFields.parent_id' => $entity_id + ] + )); + $metaFields = $query->all(); + $keyedMetaFields = []; + foreach ($metaFields as $metaField) { + if (empty($keyedMetaFields[$metaField->meta_template_id][$metaField->meta_template_field_id])) { + $keyedMetaFields[$metaField->meta_template_id][$metaField->meta_template_field_id] = []; + } + $keyedMetaFields[$metaField->meta_template_id][$metaField->meta_template_field_id][$metaField->id] = $metaField; + } + return $keyedMetaFields; + } + + /** + * Insert the keyed meta-fields into the provided meta-templates + * + * @param array $keyedMetaFields An associative array containing the meta-fields keyed by their meta-template and meta-template-field IDs + * @param array $metaTemplates List of meta-templates + * @return array The list of meta-template with the meta-fields inserted + */ + public function insertMetaFieldsInMetaTemplates(array $keyedMetaFields, array $metaTemplates): array + { + $merged = []; + foreach ($metaTemplates as $metaTemplate) { + $metaTemplate['meta_template_fields'] = Hash::combine($metaTemplate['meta_template_fields'], '{n}.id', '{n}'); + $merged[$metaTemplate->id] = $metaTemplate; + if (isset($keyedMetaFields[$metaTemplate->id])) { + foreach ($metaTemplate->meta_template_fields as $j => $meta_template_field) { + if (isset($keyedMetaFields[$metaTemplate->id][$meta_template_field->id])) { + $merged[$metaTemplate->id]->meta_template_fields[$j]['metaFields'] = $keyedMetaFields[$metaTemplate->id][$meta_template_field->id]; + } else { + $merged[$metaTemplate->id]->meta_template_fields[$j]['metaFields'] = []; + } + } + } + } + return $merged; + } + + /** + * Retreive the entity associated for the provided meta-template and id + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @param integer $entity_id + * @return \App\Model\Entity\AppModel + */ + public function getEntity(\App\Model\Entity\MetaTemplate $metaTemplate, int $entity_id): \App\Model\Entity\AppModel + { + $entitiesTable = $this->getTableForMetaTemplateScope($metaTemplate); + $entity = $entitiesTable->get($entity_id, [ + 'contain' => 'MetaFields' + ]); + return $entity; + } + + /** + * Collect the unique default template for each scope + * + * @param string|null $scope + * @return array The list of default template + */ + public function getDefaultTemplatePerScope($scope = null): array { $query = $this->find('list', [ 'keyField' => 'scope', @@ -83,54 +684,595 @@ class MetaTemplatesTable extends AppTable return $query->all()->toArray(); } - public function removeDefaultFlag(String $scope) + /** + * Remove the default flag for all meta-templates belonging to the provided scope + * + * @param string $scope + * @return int the number of updated rows + */ + public function removeDefaultFlag(string $scope): int { - $this->updateAll( + return $this->updateAll( ['is_default' => false], ['scope' => $scope] ); } - public function loadMetaFile(String $filePath) + /** + * Check if the provided template can be saved in the database without creating duplicate template in regards to the UUID and version + * + * @param array $template + * @return boolean + */ + public function canBeSavedWithoutDuplicates(array $template): bool { - if (file_exists($filePath)) { - $contents = file_get_contents($filePath); - $metaTemplate = json_decode($contents, true); - if (!empty($metaTemplate) && !empty($metaTemplate['uuid']) && !empty($metaTemplate['version'])) { - $query = $this->find(); - $query->where(['uuid' => $metaTemplate['uuid']]); - $template = $query->first(); - if (empty($template)) { - $template = $this->newEntity($metaTemplate); - $result = $this->save($template); - if (!$result) { - return __('Something went wrong, could not create the template.'); - } - } else { - if ($template->version >= $metaTemplate['version']) { - return false; - } - foreach (['version', 'source', 'name', 'namespace', 'scope', 'description'] as $field) { - $template->{$field} = $metaTemplate[$field]; - } - $result = $this->save($template); - if (!$result) { - return __('Something went wrong, could not update the template.'); - return false; - } - } - if ($result) { - $this->MetaTemplateFields->deleteAll(['meta_template_id' => $template->id]); - foreach ($metaTemplate['metaFields'] as $metaField) { - $metaField['meta_template_id'] = $template->id; - $metaField = $this->MetaTemplateFields->newEntity($metaField); - $this->MetaTemplateFields->save($metaField); - } + $query = $this->find()->where([ + 'uuid' => $template['uuid'], + 'version' => $template['version'], + ]); + return $query->count() == 0; + } + /** + * Create and save the provided template in the database + * + * @param array $template The template to be saved + * @param array $errors The list of errors that occured during the save process + * @param \App\Model\Entity\MetaTemplate $savedMetaTemplate The metaTemplate entity that has just been saved + * @return boolean True if the save was successful, False otherwise + */ + public function saveNewMetaTemplate(array $template, array &$errors = [], \App\Model\Entity\MetaTemplate &$savedMetaTemplate = null): bool + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); + } + $template['meta_template_fields'] = $template['metaFields']; + unset($template['metaFields']); + $metaTemplate = $this->newEntity($template, [ + 'associated' => ['MetaTemplateFields'] + ]); + $tmp = $this->save($metaTemplate, [ + 'associated' => ['MetaTemplateFields'] + ]); + if ($tmp === false) { + $errors[] = new UpdateError(false, __('Could not save the template.'), $metaTemplate->getErrors()); + return false; + } + $savedMetaTemplate = $tmp; + return true; + } + + /** + * Update an existing meta-template and save it in the database + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate The meta-template to update + * @param array $template The template to use to update the existing meta-template + * @param array $errors + * @return boolean True if the save was successful, False otherwise + */ + public function updateMetaTemplate(\App\Model\Entity\MetaTemplate $metaTemplate, array $template, array &$errors = []): bool + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); + } + if (is_string($metaTemplate)) { + $errors[] = new UpdateError(false, $metaTemplate); + return false; + } + $metaTemplate = $this->patchEntity($metaTemplate, $template, [ + 'associated' => ['MetaTemplateFields'] + ]); + $metaTemplate = $this->save($metaTemplate, [ + 'associated' => ['MetaTemplateFields'] + ]); + if (!empty($metaTemplate)) { + $errors[] = new UpdateError(false, __('Could not save the template.'), $metaTemplate->getErrors()); + return false; + } + return true; + } + + /** + * Update an existing meta-template with the provided strategy and save it in the database + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate The meta-template to update + * @param array $template The template to use to update the existing meta-template + * @param string $strategy The strategy to use when handling update conflicts + * @param array $errors + * @return boolean True if the save was successful, False otherwise + */ + public function updateMetaTemplateWithStrategyRouter(\App\Model\Entity\MetaTemplate $metaTemplate, array $template, string $strategy, array &$errors = []): bool + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); + } + if (is_string($metaTemplate)) { + $errors[] = new UpdateError(false, $metaTemplate); + return false; + } + if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_KEEP_BOTH) { + $result = $this->executeStrategyKeep($template, $metaTemplate); + } else if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_DELETE) { + $result = $this->executeStrategyDeleteAll($template, $metaTemplate); + } else if ($strategy == MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW) { + $result = $this->executeStrategyCreateNew($template, $metaTemplate); + } else { + $errors[] = new UpdateError(false, __('Invalid strategy {0}', $strategy)); + return false; + } + if (is_string($result)) { + $errors[] = new UpdateError(false, $result); + return false; + } + return true; + } + + /** + * Execute the `keep_both` update strategy by creating a new meta-template and moving non-conflicting entities to this one. + * Strategy: + * - Old template remains untouched + * - Create new template + * - Migrate all non-conflicting meta-fields for one entity to the new template + * - Keep all the conflicting meta-fields for one entity on the old template + * + * @param array $template + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return bool|string If the new template could be saved or the error message + */ + public function executeStrategyKeep(array $template, \App\Model\Entity\MetaTemplate $metaTemplate) + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); + } + $conflicts = $this->getMetaTemplateConflictsForMetaTemplate($metaTemplate, $template); + $blockingConflict = Hash::extract($conflicts, '{s}.conflicts'); + $errors = []; + if (empty($blockingConflict)) { // No conflict, everything can be updated without special care + $this->updateMetaTemplate($metaTemplate, $template, $errors); + return !empty($errors) ? $errors[0] : true; + } + $entities = $this->getEntitiesHavingMetaFieldsFromTemplate($metaTemplate->id, null); + + $conflictingEntities = []; + foreach ($entities as $entity) { + $conflicts = $this->getMetaFieldsConflictsUnderTemplate($entity['meta_fields'], $template); + if (!empty($conflicts)) { + $conflictingEntities[$entity->id] = $entity->id; + } + } + if (empty($conflictingEntities)) { + $this->updateMetaTemplate($metaTemplate, $template, $errors); + return !empty($errors) ? $errors[0] : true; + } + $template['is_default'] = $metaTemplate['is_default']; + $template['enabled'] = $metaTemplate['enabled']; + if ($metaTemplate->is_default) { + $metaTemplate->set('is_default', false); + $this->save($metaTemplate); + } + $savedMetaTemplate = null; + $this->saveNewMetaTemplate($template, $errors, $savedMetaTemplate); + if (!empty($savedMetaTemplate)) { + $savedMetaTemplateFieldByName = Hash::combine($savedMetaTemplate['meta_template_fields'], '{n}.field', '{n}'); + foreach ($entities as $entity) { + if (empty($conflictingEntities[$entity->id])) { // conflicting entities remain untouched + foreach ($entity['meta_fields'] as $metaField) { + $savedMetaTemplateField = $savedMetaTemplateFieldByName[$metaField->field]; + $this->supersedeMetaFieldWithMetaTemplateField($metaField, $savedMetaTemplateField); + } } } - return true; + } else { + return $errors[0]->message; + } + return true; + } + + /** + * Execute the `delete_all` update strategy by updating the meta-template and deleting all conflicting meta-fields. + * Strategy: + * - Delete conflicting meta-fields + * - Update template to the new version + * + * @param array $template + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return bool|string If the new template could be saved or the error message + */ + public function executeStrategyDeleteAll(array $template, \App\Model\Entity\MetaTemplate $metaTemplate) + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); + } + $errors = []; + $conflicts = $this->getMetaTemplateConflictsForMetaTemplate($metaTemplate, $template); + $blockingConflict = Hash::extract($conflicts, '{s}.conflicts'); + if (empty($blockingConflict)) { // No conflict, everything can be updated without special care + $this->updateMetaTemplate($metaTemplate, $template, $errors); + return !empty($errors) ? $errors[0] : true; + } + $entities = $this->getEntitiesHavingMetaFieldsFromTemplate($metaTemplate->id, null); + + foreach ($entities as $entity) { + $conflicts = $this->getMetaFieldsConflictsUnderTemplate($entity['meta_fields'], $template); + $deletedCount = $this->MetaTemplateFields->MetaFields->deleteAll([ + 'id IN' => $conflicts + ]); + } + $this->updateMetaTemplate($metaTemplate, $template, $errors); + return !empty($errors) ? $errors[0] : true; + } + + /** + * Execute the `create_new` update strategy by creating a new meta-template + * Strategy: + * - Create a new meta-template + * - Make the new meta-template `default` and `enabled` if previous template had these states + * - Turn of these states on the old meta-template + * + * @param array $template + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return bool|string If the new template could be saved or the error message + */ + public function executeStrategyCreateNew(array $template, \App\Model\Entity\MetaTemplate $metaTemplate) + { + if (!$this->canBeSavedWithoutDuplicates($template)) { + $errors[] = new UpdateError(false, __('Could not save the template. A template with this UUID and version already exists'), ['A template with UUID and version already exists']); + } + $errors = []; + $template['is_default'] = $metaTemplate->is_default; + $template['enabled'] = $metaTemplate->enabled; + $savedMetaTemplate = null; + $success = $this->saveNewMetaTemplate($template, $errors, $savedMetaTemplate); + if ($success) { + if ($metaTemplate->is_default) { + $metaTemplate->set('is_default', false); + $metaTemplate->set('enabled', false); + $this->save($metaTemplate); + } + } + return !empty($errors) ? $errors[0] : true; + } + + /** + * Supersede a meta-fields's meta-template-field with the provided one. + * + * @param \App\Model\Entity\MetaField $metaField + * @param \App\Model\Entity\MetaTemplateField $savedMetaTemplateField + * @return bool True if the replacement was a success, False otherwise + */ + public function supersedeMetaFieldWithMetaTemplateField(\App\Model\Entity\MetaField $metaField, \App\Model\Entity\MetaTemplateField $savedMetaTemplateField): bool + { + $metaField->set('meta_template_id', $savedMetaTemplateField->meta_template_id); + $metaField->set('meta_template_field_id', $savedMetaTemplateField->id); + $metaField = $this->MetaTemplateFields->MetaFields->save($metaField); + return !empty($metaField); + } + + /** + * Compute the validity of the provided meta-fields under the provided meta-template + * + * @param \App\Model\Entity\MetaField[] $metaFields + * @param array|\App\Model\Entity\MetaTemplate $template + * @return \App\Model\Entity\MetaField[] The list of conflicting meta-fields under the provided template + */ + public function getMetaFieldsConflictsUnderTemplate(array $metaFields, $template): array + { + if (!is_array($template) && get_class($template) == 'App\Model\Entity\MetaTemplate') { + $metaTemplateFields = $template->meta_template_fields; + $existingMetaTemplate = true; + } else { + $metaTemplateFields = $template['metaFields']; + } + $conflicting = []; + $metaTemplateFieldByName = []; + foreach ($metaTemplateFields as $metaTemplateField) { + if (!is_array($template)) { + $metaTemplateField = $metaTemplateField->toArray(); + } + $metaTemplateFieldByName[$metaTemplateField['field']] = $this->MetaTemplateFields->newEntity($metaTemplateField); + } + foreach ($metaFields as $metaField) { + if ($existingMetaTemplate && $metaField->meta_template_id != $template->id) { + continue; + } + $isValid = $this->MetaTemplateFields->MetaFields->isValidMetaFieldForMetaTemplateField( + $metaField->value, + $metaTemplateFieldByName[$metaField->field] + ); + if ($isValid !== true) { + $conflicting[] = $metaField; + } + } + return $conflicting; + } + + /** + * Compute the potential conflict that would be introduced by updating an existing meta-template-field with the provided one. + * This will go through all instanciation of the existing meta-template-field and checking their validity against the provided one. + * + * @param \App\Model\Entity\MetaTemplateField $metaTemplateField + * @param array $templateField + * @return array + */ + public function computeExistingMetaTemplateFieldConflictForMetaTemplateField(\App\Model\Entity\MetaTemplateField $metaTemplateField, array $templateField, string $scope): array + { + $result = [ + 'automatically-updateable' => true, + 'conflicts' => [], + 'conflictingEntities' => [], + ]; + if ($metaTemplateField->multiple && $templateField['multiple'] == false) { // Field is no longer multiple + $query = $this->MetaTemplateFields->MetaFields->find(); + $query + ->enableHydration(false) + ->select([ + 'parent_id', + 'meta_template_field_id', + 'count' => $query->func()->count('meta_template_field_id'), + ]) + ->where([ + 'meta_template_field_id' => $metaTemplateField->id, + ]) + ->group(['parent_id']) + ->having(['count >' => 1]); + $conflictingStatus = $query->all()->toList(); + if (!empty($conflictingStatus)) { + $result['automatically-updateable'] = false; + $result['conflicts'][] = __('This field is no longer multiple and is being that way'); + $result['conflictingEntities'] = Hash::extract($conflictingStatus, '{n}.parent_id'); + } + } + if (!empty($templateField['regex']) && $templateField['regex'] != $metaTemplateField->regex) { + $entitiesWithMetaFieldQuery = $this->MetaTemplateFields->MetaFields->find(); + $entitiesWithMetaFieldQuery + ->enableHydration(false) + ->select([ + 'parent_id', + ]) + ->where([ + 'meta_template_field_id' => $metaTemplateField->id, + ]); + $entitiesTable = $this->getTableForMetaTemplateScope($scope); + $entities = $entitiesTable->find() + ->where(['id IN' => $entitiesWithMetaFieldQuery]) + ->contain([ + 'MetaFields' => [ + 'conditions' => [ + 'MetaFields.meta_template_field_id' => $metaTemplateField->id + ] + ] + ]) + ->all()->toList(); + $conflictingEntities = []; + foreach ($entities as $entity) { + foreach ($entity['meta_fields'] as $metaField) { + $isValid = $this->MetaTemplateFields->MetaFields->isValidMetaFieldForMetaTemplateField( + $metaField->value, + $templateField + ); + if ($isValid !== true) { + $conflictingEntities[] = $entity->id; + break; + } + } + } + + if (!empty($conflictingEntities)) { + $result['automatically-updateable'] = $result['automatically-updateable'] && false; + $result['conflicts'][] = __('This field is instantiated with values not passing the validation anymore'); + $result['conflictingEntities'] = array_merge($result['conflictingEntities'], $conflictingEntities); + } + } + return $result; + } + + /** + * Check the conflict that would be introduced if the metaTemplate would be updated to the provided template + * + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @param \App\Model\Entity\MetaTemplate|array $template + * @return array + */ + public function getMetaTemplateConflictsForMetaTemplate(\App\Model\Entity\MetaTemplate $metaTemplate, $template): array + { + $templateMetaFields = []; + if (!is_array($template) && get_class($template) == 'App\Model\Entity\MetaTemplate') { + $templateMetaFields = $template->meta_template_fields; + } else { + $templateMetaFields = $template['metaFields']; + } + $conflicts = []; + $existingMetaTemplateFields = Hash::combine($metaTemplate->toArray(), 'meta_template_fields.{n}.field'); + foreach ($templateMetaFields as $newMetaField) { + foreach ($metaTemplate->meta_template_fields as $metaField) { + if ($newMetaField['field'] == $metaField->field) { + unset($existingMetaTemplateFields[$metaField->field]); + $metaFieldArray = !is_array($newMetaField) && get_class($newMetaField) == 'App\Model\Entity\MetaTemplateField' ? $newMetaField->toArray() : $newMetaField; + $templateConflictsForMetaField = $this->computeExistingMetaTemplateFieldConflictForMetaTemplateField($metaField, $metaFieldArray, $metaTemplate->scope); + if (!$templateConflictsForMetaField['automatically-updateable']) { + $conflicts[$metaField->field] = $templateConflictsForMetaField; + $conflicts[$metaField->field]['existing_meta_template_field'] = $metaField; + $conflicts[$metaField->field]['existing_meta_template_field']['conflicts'] = $templateConflictsForMetaField['conflicts']; + } + } + } + } + if (!empty($existingMetaTemplateFields)) { + foreach ($existingMetaTemplateFields as $field => $tmp) { + $conflicts[$field] = [ + 'automatically-updateable' => false, + 'conflicts' => [__('This field is intended to be removed')], + ]; + } + } + return $conflicts; + } + + /** + * Get update status for the latest meta-template in the database for the provided template + * + * @param array $template + * @param \App\Model\Entity\MetaTemplate $metaTemplate $metaTemplate + * @return array + */ + public function getUpdateStatusForTemplate(array $template): array + { + $updateStatus = [ + 'new' => true, + 'up-to-date' => false, + 'automatically-updateable' => false, + 'conflicts' => [], + 'template' => $template, + ]; + $query = $this->find() + ->contain('MetaTemplateFields') + ->where([ + 'uuid' => $template['uuid'], + ]) + ->order(['version' => 'DESC']); + $metaTemplate = $query->first(); + if (!empty($metaTemplate)) { + $updateStatus = array_merge( + $updateStatus, + $this->getStatusForMetaTemplate($template, $metaTemplate) + ); + } + return $updateStatus; + } + + /** + * Get update status for the meta-template stored in the database and the provided template + * + * @param array $template + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return array + */ + public function getStatusForMetaTemplate(array $template, \App\Model\Entity\MetaTemplate $metaTemplate): array + { + $updateStatus = []; + $updateStatus['existing_template'] = $metaTemplate; + $updateStatus['current_version'] = $metaTemplate->version; + $updateStatus['next_version'] = $template['version']; + $updateStatus['new'] = false; + if ($metaTemplate->version >= $template['version']) { + $updateStatus['up-to-date'] = true; + $updateStatus['automatically-updateable'] = false; + $updateStatus['conflicts'][] = __('Could not update the template. Local version is equal or newer.'); + return $updateStatus; } + $conflicts = $this->getMetaTemplateConflictsForMetaTemplate($metaTemplate, $template); + if (!empty($conflicts)) { + $updateStatus['conflicts'] = $conflicts; + } else { + $updateStatus['automatically-updateable'] = true; + } + $updateStatus['meta_field_amount'] = $this->MetaTemplateFields->MetaFields->find()->where(['meta_template_id' => $metaTemplate->id])->count(); + $updateStatus['can-be-removed'] = empty($updateStatus['meta_field_amount']) && empty($updateStatus['to-existing']); + return $updateStatus; + } + + /** + * Massages the meta-fields of an entity based on the input + * - If the keyed ID of the input meta-field is new, a new meta-field entity is created + * - If the input meta-field's value is empty for an existing meta-field, the existing meta-field is marked as to be deleted + * - If the input meta-field already exists, patch the entity and attach the validation errors + * + * @param \App\Model\Entity\AppModel $entity + * @param array $input + * @param \App\Model\Entity\MetaTemplate $metaTemplate + * @return array An array containing the entity with its massaged meta-fields and the meta-fields that should be deleted + */ + public function massageMetaFieldsBeforeSave(\App\Model\Entity\AppModel $entity, array $input, \App\Model\Entity\MetaTemplate $metaTemplate): array + { + $metaFieldsTable = $this->MetaTemplateFields->MetaFields; + $className = Inflector::camelize(Inflector::pluralize($metaTemplate->scope)); + $entityTable = TableRegistry::getTableLocator()->get($className); + $metaFieldsIndex = []; + if (!empty($entity->meta_fields)) { + foreach ($entity->meta_fields as $i => $metaField) { + $metaFieldsIndex[$metaField->id] = $i; + } + } else { + $entity->meta_fields = []; + } + + $metaFieldsToDelete = []; + foreach ($input['MetaTemplates'] as $template_id => $template) { + foreach ($template['meta_template_fields'] as $meta_template_field_id => $meta_template_field) { + $rawMetaTemplateField = $metaTemplate->meta_template_fields[$meta_template_field_id]; + foreach ($meta_template_field['metaFields'] as $meta_field_id => $meta_field) { + if ($meta_field_id == 'new') { // create new meta_field + $new_meta_fields = $meta_field; + foreach ($new_meta_fields as $new_value) { + if (!empty($new_value)) { + $metaField = $metaFieldsTable->newEmptyEntity(); + $metaFieldsTable->patchEntity($metaField, [ + 'value' => $new_value, + 'scope' => $entityTable->getBehavior('MetaFields')->getScope(), + 'field' => $rawMetaTemplateField->field, + 'meta_template_id' => $rawMetaTemplateField->meta_template_id, + 'meta_template_field_id' => $rawMetaTemplateField->id, + 'parent_id' => $entity->id, + 'uuid' => Text::uuid(), + ]); + $entity->meta_fields[] = $metaField; + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[] = $metaField; + } + } + } else { + $new_value = $meta_field['value']; + if (!empty($new_value)) { // update meta_field and attach validation errors + if (!empty($metaFieldsIndex[$meta_field_id])) { + $index = $metaFieldsIndex[$meta_field_id]; + $metaFieldsTable->patchEntity($entity->meta_fields[$index], [ + 'value' => $new_value, 'meta_template_field_id' => $rawMetaTemplateField->id + ], ['value']); + $metaFieldsTable->patchEntity( + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[$meta_field_id], + ['value' => $new_value, 'meta_template_field_id' => $rawMetaTemplateField->id], + ['value'] + ); + } else { // metafield comes from a second POST where the temporary entity has already been created + $metaField = $metaFieldsTable->newEmptyEntity(); + $metaFieldsTable->patchEntity($metaField, [ + 'value' => $new_value, + 'scope' => $entityTable->getBehavior('MetaFields')->getScope(), // get scope from behavior + 'field' => $rawMetaTemplateField->field, + 'meta_template_id' => $rawMetaTemplateField->meta_template_id, + 'meta_template_field_id' => $rawMetaTemplateField->id, + 'parent_id' => $entity->id, + 'uuid' => Text::uuid(), + ]); + $entity->meta_fields[] = $metaField; + $entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[] = $metaField; + } + } else { // Metafield value is empty, indicating the field should be removed + $index = $metaFieldsIndex[$meta_field_id]; + $metaFieldsToDelete[] = $entity->meta_fields[$index]; + unset($entity->meta_fields[$index]); + unset($entity->MetaTemplates[$template_id]->meta_template_fields[$meta_template_field_id]->metaFields[$meta_field_id]); + } + } + } + } + } + + $entity->setDirty('meta_fields', true); + return ['entity' => $entity, 'metafields_to_delete' => $metaFieldsToDelete]; + } +} + +class UpdateError +{ + public $success; + public $message = ''; + public $errors = []; + + public function __construct($success = false, $message = '', $errors = []) + { + $this->success = $success; + $this->message = $message; + $this->errors = $errors; } } diff --git a/src/Model/Table/OrganisationsTable.php b/src/Model/Table/OrganisationsTable.php index 8cd7dec..723b46a 100644 --- a/src/Model/Table/OrganisationsTable.php +++ b/src/Model/Table/OrganisationsTable.php @@ -9,8 +9,6 @@ use Cake\Error\Debugger; class OrganisationsTable extends AppTable { - public $metaFields = 'organisation'; - public function initialize(array $config): void { parent::initialize($config); @@ -33,14 +31,7 @@ class OrganisationsTable extends AppTable 'conditions' => ['owner_model' => 'organisation'] ] ); - $this->hasMany( - 'MetaFields', - [ - 'dependent' => true, - 'foreignKey' => 'parent_id', - 'conditions' => ['MetaFields.scope' => 'organisation'] - ] - ); + $this->addBehavior('MetaFields'); $this->setDisplayField('name'); } diff --git a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php index 9cf755d..801885c 100644 --- a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php +++ b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php @@ -153,7 +153,7 @@ class CerebrateSettingsProvider extends BaseSettingsProvider 'Providers' => [ 'PasswordAuth' => [ 'password_auth.enabled' => [ - 'name' => 'Disable password authentication', + 'name' => 'Enable password authentication', 'type' => 'boolean', 'severity' => 'warning', 'description' => __('Enable username/password authentication.'), diff --git a/src/Utility/UI/IndexSetting.php b/src/Utility/UI/IndexSetting.php new file mode 100644 index 0000000..ac3560e --- /dev/null +++ b/src/Utility/UI/IndexSetting.php @@ -0,0 +1,30 @@ +user_settings_by_name['ui.table_setting']['value']) ? json_decode($user->user_settings_by_name['ui.table_setting']['value'], true) : []; + return $rawSetting; + } + + public static function getTableSetting($user, $tableId): array + { + $rawSetting = IndexSetting::getAllSetting($user); + if (is_object($tableId)) { + $tableId = IndexSetting::getIDFromTable($tableId); + } + $tableSettings = !empty($rawSetting[$tableId]) ? $rawSetting[$tableId] : []; + return $tableSettings; + } + + public static function getIDFromTable(Object $table): string + { + return sprintf('%s_index', Inflector::variable(Inflector::singularize(($table->getAlias())))); + } +} \ No newline at end of file diff --git a/src/Utility/UI/Notification.php b/src/Utility/UI/Notification.php new file mode 100644 index 0000000..a6bf05b --- /dev/null +++ b/src/Utility/UI/Notification.php @@ -0,0 +1,49 @@ +text = $text; + $this->router = $router; + foreach ($options as $key => $value) { + $this->{$key} = $value; + } + $this->validate(); + } + + public function get(): array + { + if (empty($errors)) { + return (array) $this; + } + return null; + } + + private function validate() + { + $validator = new Validator(); + + $validator + ->requirePresence('title', 'create') + ->notEmptyString('title'); + + return $validator->validate((array) $this); + } +} diff --git a/src/View/Helper/BootstrapHelper.php b/src/View/Helper/BootstrapHelper.php index 1b1db20..3ff283a 100644 --- a/src/View/Helper/BootstrapHelper.php +++ b/src/View/Helper/BootstrapHelper.php @@ -1,4 +1,5 @@ button(); } - public function icon($icon, $options=[]) + public function icon($icon, $options = []) { $bsIcon = new BoostrapIcon($icon, $options); return $bsIcon->icon(); @@ -98,7 +99,7 @@ class BootstrapHelper extends Helper $bsModal = new BoostrapModal($options); return $bsModal->modal(); } - + public function card($options) { $bsCard = new BoostrapCard($options); @@ -117,6 +118,12 @@ class BootstrapHelper extends Helper return $bsCollapse->collapse(); } + public function accordion($options, $content) + { + $bsAccordion = new BoostrapAccordion($options, $content, $this); + return $bsAccordion->accordion(); + } + public function progressTimeline($options) { $bsProgressTimeline = new BoostrapProgressTimeline($options, $this); @@ -129,7 +136,7 @@ class BootstrapHelper extends Helper return $bsListGroup->listGroup(); } - public function genNode($node, $params=[], $content='') + public function genNode($node, $params = [], $content = '') { return BootstrapGeneric::genNode($node, $params, $content); } @@ -140,6 +147,12 @@ class BootstrapHelper extends Helper return $bsSwitch->switch(); } + public function notificationBubble($options) + { + $bsNotificationBubble = new BoostrapNotificationBuble($options, $this); + return $bsNotificationBubble->notificationBubble(); + } + public function dropdownMenu($options) { $bsDropdownMenu = new BoostrapDropdownMenu($options, $this); @@ -177,12 +190,12 @@ class BootstrapGeneric } } - public static function genNode($node, $params=[], $content="") + public static function genNode($node, $params = [], $content = "") { return sprintf('<%s %s>%s%s>', $node, BootstrapGeneric::genHTMLParams($params), $content, $node); } - protected static function openNode($node, $params=[]) + protected static function openNode($node, $params = []) { return sprintf('<%s %s>', $node, BootstrapGeneric::genHTMLParams($params)); } @@ -249,7 +262,8 @@ class BootstrapTabs extends BootstrapGeneric ]; private $bsClasses = null; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'justify' => [false, 'center', 'end'], 'body-variant' => array_merge(BootstrapGeneric::$variants, ['']), @@ -284,7 +298,7 @@ class BootstrapTabs extends BootstrapGeneric $this->options['pills'] = true; $this->options['card'] = true; } - + if ($this->options['pills']) { $this->bsClasses['nav'][] = 'nav-pills'; if ($this->options['vertical']) { @@ -307,7 +321,7 @@ class BootstrapTabs extends BootstrapGeneric $this->bsClasses['nav'][] = 'nav-justify'; } - $activeTab = 0; + $activeTab = array_key_first($this->data['navs']); foreach ($this->data['navs'] as $i => $nav) { if (!is_array($nav)) { $this->data['navs'][$i] = ['text' => $nav]; @@ -386,29 +400,31 @@ class BootstrapTabs extends BootstrapGeneric "border-{$this->options['header-border-variant']}" ] )]); - $html .= $this->openNode('div', ['class' => array_merge( - [ - ($this->options['vertical-size'] != 'auto' ? 'col-' . $this->options['vertical-size'] : ''), - ($this->options['card'] ? 'card-header border-end' : '') - ], - [ - "bg-{$this->options['header-variant']}", - "text-{$this->options['header-text-variant']}", - "border-{$this->options['header-border-variant']}" - ])]); - $html .= $this->genNav(); - $html .= $this->closeNode('div'); - $html .= $this->openNode('div', ['class' => array_merge( - [ - ($this->options['vertical-size'] != 'auto' ? 'col-' . (12 - $this->options['vertical-size']) : ''), - ($this->options['card'] ? 'card-body2' : '') - ], - [ - "bg-{$this->options['body-variant']}", - "text-{$this->options['body-text-variant']}" - ])]); - $html .= $this->genContent(); - $html .= $this->closeNode('div'); + $html .= $this->openNode('div', ['class' => array_merge( + [ + ($this->options['vertical-size'] != 'auto' ? 'col-' . $this->options['vertical-size'] : ''), + ($this->options['card'] ? 'card-header border-end' : '') + ], + [ + "bg-{$this->options['header-variant']}", + "text-{$this->options['header-text-variant']}", + "border-{$this->options['header-border-variant']}" + ] + )]); + $html .= $this->genNav(); + $html .= $this->closeNode('div'); + $html .= $this->openNode('div', ['class' => array_merge( + [ + ($this->options['vertical-size'] != 'auto' ? 'col-' . (12 - $this->options['vertical-size']) : ''), + ($this->options['card'] ? 'card-body2' : '') + ], + [ + "bg-{$this->options['body-variant']}", + "text-{$this->options['body-text-variant']}" + ] + )]); + $html .= $this->genContent(); + $html .= $this->closeNode('div'); $html .= $this->closeNode('div'); return $html; } @@ -479,7 +495,8 @@ class BootstrapTabs extends BootstrapGeneric } } -class BoostrapAlert extends BootstrapGeneric { +class BoostrapAlert extends BootstrapGeneric +{ private $defaultOptions = [ 'text' => '', 'html' => null, @@ -490,7 +507,8 @@ class BoostrapAlert extends BootstrapGeneric { private $bsClasses = null; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'variant' => BootstrapGeneric::$variants, ]; @@ -537,11 +555,12 @@ class BoostrapAlert extends BootstrapGeneric { private function genContent() { - return !is_null($this->options['html']) ? $this->options['html'] : $this->options['text']; + return !is_null($this->options['html']) ? $this->options['html'] : h($this->options['text']); } } -class BoostrapTable extends BootstrapGeneric { +class BoostrapTable extends BootstrapGeneric +{ private $defaultOptions = [ 'striped' => true, 'bordered' => true, @@ -556,7 +575,8 @@ class BoostrapTable extends BootstrapGeneric { private $bsClasses = null; - function __construct($options, $data, $btHelper) { + function __construct($options, $data, $btHelper) + { $this->allowedOptionValues = [ 'variant' => array_merge(BootstrapGeneric::$variants, ['']) ]; @@ -597,7 +617,7 @@ class BoostrapTable extends BootstrapGeneric { $html .= $this->genCaption(); $html .= $this->genHeader(); $html .= $this->genBody(); - + $html .= $this->closeNode('table'); return $html; } @@ -645,7 +665,7 @@ class BoostrapTable extends BootstrapGeneric { private function genRow($row, $rowIndex) { - $html = $this->openNode('tr',[ + $html = $this->openNode('tr', [ 'class' => [ !empty($row['_rowVariant']) ? "table-{$row['_rowVariant']}" : '' ] @@ -694,7 +714,8 @@ class BoostrapTable extends BootstrapGeneric { } } -class BoostrapListTable extends BootstrapGeneric { +class BoostrapListTable extends BootstrapGeneric +{ private $defaultOptions = [ 'striped' => true, 'bordered' => false, @@ -708,7 +729,8 @@ class BoostrapListTable extends BootstrapGeneric { private $bsClasses = null; - function __construct($options, $data, $btHelper) { + function __construct($options, $data, $btHelper) + { $this->allowedOptionValues = [ 'variant' => array_merge(BootstrapGeneric::$variants, ['']) ]; @@ -749,7 +771,7 @@ class BoostrapListTable extends BootstrapGeneric { $html .= $this->genCaption(); $html .= $this->genBody(); - + $html .= $this->closeNode('table'); return $html; } @@ -773,11 +795,11 @@ class BoostrapListTable extends BootstrapGeneric { $rowValue = $this->genCell($field); $rowKey = $this->genNode('th', [ 'class' => [ - 'col-sm-2' + 'col-4 col-sm-2' ], 'scope' => 'row' ], h($field['key'])); - $row = $this->genNode('tr',[ + $row = $this->genNode('tr', [ 'class' => [ 'd-flex', !empty($field['_rowVariant']) ? "table-{$field['_rowVariant']}" : '' @@ -786,7 +808,7 @@ class BoostrapListTable extends BootstrapGeneric { return $row; } - private function genCell($field=[]) + private function genCell($field = []) { if (isset($field['raw'])) { $cellContent = h($field['raw']); @@ -802,7 +824,7 @@ class BoostrapListTable extends BootstrapGeneric { } return $this->genNode('td', [ 'class' => [ - 'col-sm-10', + 'col-8 col-sm-10', !empty($field['_cellVariant']) ? "bg-{$field['_cellVariant']}" : '' ] ], $cellContent); @@ -821,7 +843,8 @@ class BoostrapListTable extends BootstrapGeneric { private function getElementPath($type) { - return sprintf('%s%sField', + return sprintf( + '%s%sField', $this->options['elementsRootPath'] ?? '', $type ); @@ -833,7 +856,8 @@ class BoostrapListTable extends BootstrapGeneric { } } -class BoostrapButton extends BootstrapGeneric { +class BoostrapButton extends BootstrapGeneric +{ private $defaultOptions = [ 'id' => '', 'text' => '', @@ -853,10 +877,11 @@ class BoostrapButton extends BootstrapGeneric { private $bsClasses = []; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'variant' => array_merge(BootstrapGeneric::$variants, ['link', 'text']), - 'size' => ['', 'sm', 'lg'], + 'size' => ['', 'xs', 'sm', 'lg'], 'type' => ['button', 'submit', 'reset'] ]; if (empty($options['class'])) { @@ -871,6 +896,10 @@ class BoostrapButton extends BootstrapGeneric { $this->options = array_merge($this->defaultOptions, $options); $this->checkOptionValidity(); + if (!empty($this->options['id'])) { + $this->options['params']['id'] = $this->options['id']; + } + $this->bsClasses[] = 'btn'; if ($this->options['outline']) { $this->bsClasses[] = "btn-outline-{$this->options['variant']}"; @@ -942,7 +971,8 @@ class BoostrapButton extends BootstrapGeneric { } } -class BoostrapBadge extends BootstrapGeneric { +class BoostrapBadge extends BootstrapGeneric +{ private $defaultOptions = [ 'text' => '', 'variant' => 'primary', @@ -951,7 +981,8 @@ class BoostrapBadge extends BootstrapGeneric { 'class' => [], ]; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'variant' => BootstrapGeneric::$variants, ]; @@ -961,6 +992,7 @@ class BoostrapBadge extends BootstrapGeneric { private function processOptions($options) { $this->options = array_merge($this->defaultOptions, $options); + $this->options['class'] = is_array($this->options['class']) ? $this->options['class'] : [$this->options['class']]; $this->checkOptionValidity(); } @@ -985,13 +1017,17 @@ class BoostrapBadge extends BootstrapGeneric { } } -class BoostrapIcon extends BootstrapGeneric { +class BoostrapIcon extends BootstrapGeneric +{ private $icon = ''; private $defaultOptions = [ 'class' => [], + 'title' => '', + 'params' => [], ]; - function __construct($icon, $options=[]) { + function __construct($icon, $options = []) + { $this->icon = $icon; $this->processOptions($options); } @@ -1009,17 +1045,19 @@ class BoostrapIcon extends BootstrapGeneric { private function genIcon() { - $html = $this->genNode('span', [ + $html = $this->genNode('span', array_merge([ 'class' => array_merge( is_array($this->options['class']) ? $this->options['class'] : [$this->options['class']], ["fa fa-{$this->icon}"] ), - ]); + 'title' => h($this->options['title']) + ], $this->options['params'])); return $html; } } -class BoostrapModal extends BootstrapGeneric { +class BoostrapModal extends BootstrapGeneric +{ private $defaultOptions = [ 'size' => '', 'centered' => true, @@ -1044,10 +1082,11 @@ class BoostrapModal extends BootstrapGeneric { private $bsClasses = null; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'size' => ['sm', 'lg', 'xl', ''], - 'type' => ['ok-only','confirm','confirm-success','confirm-warning','confirm-danger', 'custom'], + 'type' => ['ok-only', 'confirm', 'confirm-success', 'confirm-warning', 'confirm-danger', 'custom'], 'variant' => array_merge(BootstrapGeneric::$variants, ['']), ]; $this->processOptions($options); @@ -1066,6 +1105,7 @@ class BoostrapModal extends BootstrapGeneric { private function genModal() { + $this->options['modalClass'] = !empty($this->options['modalClass']) && !is_array($this->options['modalClass']) ? [$this->options['modalClass']] : $this->options['modalClass']; $dialog = $this->openNode('div', [ 'class' => array_merge( ['modal-dialog', (!empty($this->options['size'])) ? "modal-{$this->options['size']}" : ''], @@ -1126,7 +1166,8 @@ class BoostrapModal extends BootstrapGeneric { return $footer; } - private function getFooterBasedOnType() { + private function getFooterBasedOnType() + { if ($this->options['type'] == 'ok-only') { return $this->getFooterOkOnly(); } else if (str_contains($this->options['type'], 'confirm')) { @@ -1238,7 +1279,7 @@ class BoostrapCard extends BootstrapGeneric 'card', !empty($this->options['variant']) ? "bg-{$this->options['variant']}" : '', !empty($this->options['variant']) ? $this->getTextClassForVariant($this->options['variant']) : '', - h($this->options['class']), + h(is_array($this->options['class']) ? implode(' ', $this->options['class']) : $this->options['class']), ], ], implode('', [$this->genHeader(), $this->genBody(), $this->genFooter()])); return $card; @@ -1290,7 +1331,8 @@ class BoostrapCard extends BootstrapGeneric } } -class BoostrapSwitch extends BootstrapGeneric { +class BoostrapSwitch extends BootstrapGeneric +{ private $defaultOptions = [ 'label' => '', 'variant' => 'primary', @@ -1301,7 +1343,8 @@ class BoostrapSwitch extends BootstrapGeneric { 'attrs' => [], ]; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'variant' => BootstrapGeneric::$variants, ]; @@ -1344,7 +1387,69 @@ class BoostrapSwitch extends BootstrapGeneric { } } -class BoostrapProgress extends BootstrapGeneric { +class BoostrapNotificationBuble extends BootstrapGeneric +{ + private $defaultOptions = [ + 'label' => '', + 'variant' => 'warning', + 'borderVariant' => 'ligth', + 'title' => 'Notification', + 'class' => [], + 'attrs' => [], + ]; + + function __construct($options) + { + $this->allowedOptionValues = [ + 'variant' => BootstrapGeneric::$variants, + ]; + $this->defaultOptions['label'] = __('New notifications'); + $this->processOptions($options); + } + + private function processOptions($options) + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + if (!empty($this->options['attrs']['style'])) { + $this->options['attrs']['style'] += 'box-shadow: 0 0.125rem 0.25rem #00000050;'; + } else { + $this->options['attrs']['style'] = 'box-shadow: 0 0.125rem 0.25rem #00000050;'; + } + } + + public function notificationBubble() + { + return $this->genNotificationBubble(); + } + + private function genNotificationBubble() + { + $tmpId = 'tmp-' . mt_rand(); + $html = $this->genNode('span', [ + 'id' => $tmpId, + 'class' => [ + 'position-absolute', + 'top-0', + 'start-100', + 'translate-middle', + 'p-1', + 'border border-2 rounded-circle', + "border-{$this->options['borderVariant']}", + "bg-{$this->options['variant']}", + ], + 'title' => $this->options['title'] + ], $this->genNode('span', [ + 'class' => [ + ], + $this->options['label'] + ])); + return $html; + } +} + +class BoostrapProgress extends BootstrapGeneric +{ private $defaultOptions = [ 'value' => 0, 'total' => 100, @@ -1357,7 +1462,8 @@ class BoostrapProgress extends BootstrapGeneric { 'label' => true ]; - function __construct($options) { + function __construct($options) + { $this->allowedOptionValues = [ 'variant' => BootstrapGeneric::$variants, ]; @@ -1389,7 +1495,7 @@ class BoostrapProgress extends BootstrapGeneric { $this->options['animated'] ? 'progress-bar-animated' : '', ], 'role' => "progressbar", - 'aria-valuemin' => "0", 'aria-valuemax' => "100",'aria-valuenow' => $percentage, + 'aria-valuemin' => "0", 'aria-valuemax' => "100", 'aria-valuenow' => $percentage, 'style' => "${widthStyle}", 'title' => $this->options['title'] ], $label); @@ -1404,13 +1510,15 @@ class BoostrapProgress extends BootstrapGeneric { } } -class BoostrapCollapse extends BootstrapGeneric { +class BoostrapCollapse extends BootstrapGeneric +{ private $defaultOptions = [ - 'text' => '', + 'title' => '', 'open' => false, ]; - function __construct($options, $content, $btHelper) { + function __construct($options, $content, $btHelper) + { $this->allowedOptionValues = []; $this->processOptions($options); $this->content = $content; @@ -1461,7 +1569,98 @@ class BoostrapCollapse extends BootstrapGeneric { } } -class BoostrapProgressTimeline extends BootstrapGeneric { +class BoostrapAccordion extends BootstrapGeneric +{ + private $defaultOptions = [ + 'stayOpen' => true, + 'class' => [], + ]; + + function __construct($options, $content, $btHelper) + { + $this->allowedOptionValues = []; + $this->content = $content; + $this->btHelper = $btHelper; + $this->processOptions($options); + } + + private function processOptions($options) + { + $this->options = array_merge($this->defaultOptions, $options); + $this->checkOptionValidity(); + if (!is_array($this->options['class']) && !empty($this->options['class'])) { + $this->options['class'] = [$this->options['class']]; + } + $this->seed = 'acc-' . mt_rand(); + $this->contentSeeds = []; + foreach ($this->content as $accordionItem) { + $this->contentSeeds[] = mt_rand(); + } + } + + public function accordion() + { + return $this->genAccordion(); + } + + private function genHeader($accordionItem, $i) + { + $html = $this->openNode('h2', [ + 'class' => ['accordion-header'], + 'id' => 'head-' . $this->contentSeeds[$i] + ]); + $content = !empty($accordionItem['header']['html']) ? $accordionItem['header']['html'] : h($accordionItem['header']['title'] ?? '- no title -'); + $buttonOptions = [ + 'class' => array_merge(['accordion-button', empty($accordionItem['_open']) ? 'collapsed' : ''], $accordionItem['header']['__class'] ?? []), + 'type' => 'button', + 'data-bs-toggle' => 'collapse', + 'data-bs-target' => '#body-' . $this->contentSeeds[$i], + 'aria-expanded' => 'false', + 'aria-controls' => 'body-' . $this->contentSeeds[$i], + ]; + $html .= $this->genNode('button', $buttonOptions, $content); + $html .= $this->closeNode(('h2')); + return $html; + } + + private function genBody($accordionItem, $i) + { + $content = $this->genNode('div', [ + 'class' => ['accordion-body'] + ], $accordionItem['body']); + $divOptions = [ + 'class' => array_merge(['accordion-collapse collapse', empty($accordionItem['_open']) ? '' : 'show'], $accordionItem['body']['__class'] ?? []), + 'id' => 'body-' . $this->contentSeeds[$i], + 'aria-labelledby' => 'head-' . $this->contentSeeds[$i], + ]; + if (!empty($this->options['stayOpen'])) { + $divOptions['data-bs-parent'] = '#' . $this->seed; + } + $html = $this->genNode('div', $divOptions, $content); + return $html; + } + + private function genAccordion() + { + $html = $this->openNode('div', [ + 'class' => array_merge(['accordion'], $this->options['class']), + 'id' => $this->seed + ]); + foreach ($this->content as $i => $accordionItem) { + $html .= $this->openNode('div', [ + 'class' => array_merge(['accordion-item'], $accordionItem['__class'] ?? []) + ]); + $html .= $this->genHeader($accordionItem, $i); + $html .= $this->genBody($accordionItem, $i); + $html .= $this->closeNode('div'); + } + $html .= $this->closeNode('div'); + return $html; + } +} + +class BoostrapProgressTimeline extends BootstrapGeneric +{ private $defaultOptions = [ 'steps' => [], 'selected' => 0, @@ -1469,7 +1668,8 @@ class BoostrapProgressTimeline extends BootstrapGeneric { 'variantInactive' => 'secondary', ]; - function __construct($options, $btHelper) { + function __construct($options, $btHelper) + { $this->allowedOptionValues = [ 'variant' => BootstrapGeneric::$variants, 'variantInactive' => BootstrapGeneric::$variants, @@ -1496,7 +1696,7 @@ class BoostrapProgressTimeline extends BootstrapGeneric { !empty($step['icon']) ? h($this->btHelper->FontAwesome->getClass($step['icon'])) : '', $this->getTextClassForVariant($this->options['variant']) ], - ], empty($step['icon']) ? h($i+1) : ''); + ], empty($step['icon']) ? h($i + 1) : ''); $iconContainer = $this->genNode('span', [ 'class' => [ 'd-flex', 'align-items-center', 'justify-content-center', @@ -1518,7 +1718,7 @@ class BoostrapProgressTimeline extends BootstrapGeneric { private function getHorizontalLine($i, $nodeActive, $lineActive) { $stepCount = count($this->options['steps']); - if ($i == $stepCount-1) { + if ($i == $stepCount - 1) { return ''; } $progressBar = (new BoostrapProgress([ @@ -1583,7 +1783,8 @@ class BootstrapListGroup extends BootstrapGeneric private $bsClasses = null; - function __construct($options, $data, $btHelper) { + function __construct($options, $data, $btHelper) + { $this->data = $data; $this->processOptions($options); $this->btHelper = $btHelper; @@ -1685,7 +1886,8 @@ class BoostrapDropdownMenu extends BootstrapGeneric 'submenu_classes' => [], ]; - function __construct($options, $btHelper) { + function __construct($options, $btHelper) + { $this->allowedOptionValues = [ 'direction' => ['start', 'end', 'up', 'down'], 'alignment' => ['start', 'end'], @@ -1714,7 +1916,7 @@ class BoostrapDropdownMenu extends BootstrapGeneric return $this->genDropdownWrapper($this->genDropdownToggleButton(), $this->genDropdownMenu($this->menu)); } - public function genDropdownWrapper($toggle='', $menu='', $direction=null, $classes=null) + public function genDropdownWrapper($toggle = '', $menu = '', $direction = null, $classes = null) { $classes = !is_null($classes) ? $classes : $this->options['dropdown-class']; $direction = !is_null($direction) ? $direction : $this->options['direction']; @@ -1747,7 +1949,7 @@ class BoostrapDropdownMenu extends BootstrapGeneric return $this->btHelper->button($options); } - private function genDropdownMenu($entries, $alignment=null) + private function genDropdownMenu($entries, $alignment = null) { $alignment = !is_null($alignment) ? $alignment : $this->options['alignment']; $html = $this->genNode('div', [ @@ -1775,16 +1977,32 @@ class BoostrapDropdownMenu extends BootstrapGeneric if (!empty($entry['html'])) { return $entry['html']; } + $classes = []; + $icon = ''; + if (!empty($entry['icon'])) { + $icon = $this->btHelper->icon($entry['icon'], ['class' => 'me-2']); + } + $badge = ''; + if (!empty($entry['badge'])) { + $bsBadge = new BoostrapBadge(array_merge( + ['class' => ['ms-auto']], + $entry['badge'] + )); + $badge = $bsBadge->badge(); + } + + if (!empty($entry['header'])) { + return $this->genNode('h6', [ + 'class' => ['dropdown-header',], + ], $icon . h($entry['text']) . $badge); + } $classes = ['dropdown-item']; $params = ['href' => '#']; - $icon = ''; - if (!empty($entry['icon'])) { - $icon = $this->btHelper->icon($entry['icon']); - } if (!empty($entry['menu'])) { $classes[] = 'dropdown-toggle'; + $classes[] = 'd-flex align-items-center'; $params['data-bs-toggle'] = 'dropdown'; $params['aria-haspopup'] = 'true'; $params['aria-expanded'] = 'false'; @@ -1794,13 +2012,11 @@ class BoostrapDropdownMenu extends BootstrapGeneric $params['data-open-form-id'] = mt_rand(); } - $label = $this->genNode('span', [ - 'class' => ['ms-2',], - ], h($entry['text'])); - $content = $icon . $label; + $label = $this->genNode('span', ['class' => 'mx-1'], h($entry['text'])); + $content = $icon . $label . $badge; return $this->genNode('a', array_merge([ 'class' => $classes, ], $params), $content); } -} \ No newline at end of file +} diff --git a/src/View/Helper/ValueGetterHelper.php b/src/View/Helper/ValueGetterHelper.php new file mode 100644 index 0000000..d64bb2f --- /dev/null +++ b/src/View/Helper/ValueGetterHelper.php @@ -0,0 +1,24 @@ +eval($target, $args); + } else { + $value = h($target); + } + return $value; + } + + private function eval($fun, $args=[]) + { + return $fun($args); + } +} \ No newline at end of file diff --git a/templates/Api/index.php b/templates/Api/index.php index 96be4b8..6d6208a 100644 --- a/templates/Api/index.php +++ b/templates/Api/index.php @@ -1,2 +1,2 @@= __('Field name') ?> | += __('Conflict') ?> | += __('Conflicting entities') ?> | +
---|---|---|
= h($fieldName) ?> | ++ = h($conflict) ?> + | ++ $id) { + if ($i > 0) { + echo ', '; + } + if ($i > 10) { + echo sprintf('%s', __('{0} more', count($fieldConflict['conflictingEntities'])-$i)); + break; + } + $url = Router::url([ + 'controller' => Inflector::pluralize($templateStatus['existing_template']->scope), + 'action' => 'view', + $id + ]); + echo sprintf('%s', $url, __('{0} #{1}', h(Inflector::humanize($templateStatus['existing_template']->scope)), h($id))); + } + ?> + | +