Merge pull request #89 from cerebrate-project/develop-unstable

Features extension pack
cli-modification-summary
Andras Iklody 2022-05-04 08:46:35 +02:00 committed by GitHub
commit 4f68585ac8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
189 changed files with 11484 additions and 2384 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ logs
tmp
vendor
webroot/theme/node_modules
webroot/scss/*.css
.vscode
docker/run/
.phpunit.result.cache

View File

@ -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,

View File

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
use Phinx\Db\Adapter\MysqlAdapter;
class MailingLists extends AbstractMigration
{
/**
* Change Method.
*
* More information on this method is available here:
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
* @return void
*/
public $autoId = false; // turn off automatic `id` column create. We want it to be `int(10) unsigned`
public function change()
{
$mailinglists = $this->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();
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
use Phinx\Db\Adapter\MysqlAdapter;
class MoreMetaFieldColumns extends AbstractMigration
{
public function change()
{
$metaFieldsTable = $this->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();
}
}

View File

@ -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')

View File

@ -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) {

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
"version": 2
}

View File

@ -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
}
]
}

View File

@ -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([

View File

@ -12,7 +12,6 @@
'type' => 'color',
),
),
'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates,
'submit' => array(
'action' => $this->request->getParam('action')
)

View File

@ -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'

View File

@ -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 = $('<div></div>').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))
}

View File

@ -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'])) {

View File

@ -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();
}
}

View File

@ -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]);
}

View File

@ -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();

View File

@ -0,0 +1,16 @@
<?php
namespace BreadcrumbNavigation;
require_once(APP . 'Controller' . DS . 'Component' . DS . 'Navigation' . DS . 'base.php');
class ApiNavigation extends BaseNavigation
{
function addRoutes()
{
$this->bcf->addRoute('Api', 'index', [
'label' => __('API'),
'url' => '/api/index',
'icon' => 'code'
]);
}
}

View File

@ -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');
}

View File

@ -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)

View File

@ -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' => [

View File

@ -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;
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Controller\Component;
use Cake\Controller\Component;
use Cake\Core\Configure;
use Cake\Http\Exception\MethodNotAllowedException;
use Cake\ORM\TableRegistry;
class NotificationComponent extends Component
{
private $tables = [
'Inbox',
];
public function initialize(array $config): void
{
$this->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;
}
}

View File

@ -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;

View File

@ -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)) {

View File

@ -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)

View File

@ -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');

View File

@ -0,0 +1,358 @@
<?php
namespace App\Controller;
use App\Controller\AppController;
use App\Model\Entity\Individual;
use Cake\Utility\Inflector;
use Cake\Utility\Hash;
use Cake\Utility\Text;
use \Cake\Database\Expression\QueryExpression;
use Cake\Http\Exception\UnauthorizedException;
use Cake\Http\Exception\NotFoundException;
use Cake\ORM\Query;
use Cake\ORM\Entity;
use Exception;
class MailingListsController extends AppController
{
public $filterFields = ['MailingLists.uuid', 'MailingLists.name', 'description', 'releasability'];
public $quickFilterFields = ['MailingLists.uuid', ['MailingLists.name' => 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');
}
}

View File

@ -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,
];
}
}

View File

@ -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)) {

View File

@ -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)

View File

@ -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']) {

147
src/Lib/Tools/CidrTool.php Normal file
View File

@ -0,0 +1,147 @@
<?php
namespace App\Lib\Tools;
class CidrTool
{
/** @var array */
private $ipv4 = [];
/**
* Minimum netmask for IPv4 in list. 33 because maximum netmask is 32..
* @var int
*/
private $minimumIpv4Mask = 33;
/** @var array */
private $ipv6 = [];
public function __construct(array $list)
{
$this->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;
}
}
}
}

View File

@ -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',

View File

@ -103,7 +103,7 @@ class SkeletonConnector extends CommonConnectorTools
'children' => [
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value',

View File

@ -0,0 +1,99 @@
<?php
namespace MetaFieldsTypes;
use Cake\Database\Expression\QueryExpression;
use Cake\ORM\TableRegistry;
use Cake\ORM\Query;
use MetaFieldsTypes\TextType;
use TypeError;
use App\Lib\Tools\CidrTool;
class IPv4Type extends TextType
{
public const OPERATORS = ['contains', 'excludes'];
public const TYPE = 'ipv4';
public function __construct()
{
parent::__construct();
}
/**
* Validate the provided value against the expected type
*
* @param string $value
* @return boolean
*/
public function validate(string $value): bool
{
return $this->_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);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace MetaFieldsTypes;
use MetaFieldsTypes\IPv4Type;
class IPv6Type extends IPv4Type
{
public const TYPE = 'ipv6';
public function __construct()
{
parent::__construct();
}
protected function _isValidIP(string $value): bool
{
return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace MetaFieldsTypes;
use Cake\Database\Expression\QueryExpression;
use Cake\ORM\TableRegistry;
class TextType
{
public const OPERATORS = ['=', '!='];
public const TYPE = 'text';
public function __construct()
{
$this->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;
}
}

View File

@ -0,0 +1,259 @@
<?php
namespace App\Model\Behavior;
use Cake\ORM\Behavior;
use Cake\ORM\Entity;
use Cake\ORM\Query;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
use Cake\Utility\Inflector;
use Cake\Database\Expression\QueryExpression;
class MetaFieldsBehavior extends Behavior
{
protected $_defaultConfig = [
'metaFieldsAssoc' => [
'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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Model\Entity;
use App\Model\Entity\AppModel;
class MailingList extends AppModel
{
protected $_accessible = [
'*' => 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;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Model\Entity;
use App\Model\Entity\AppModel;
use Cake\ORM\Entity;
class MetaTemplateField extends AppModel
{
}

View File

@ -7,6 +7,10 @@ use Cake\Validation\Validator;
use Cake\Core\Configure;
use Cake\Core\Configure\Engine\PhpConfig;
use Cake\ORM\TableRegistry;
use Cake\Utility\Hash;
use Cake\Database\Expression\QueryExpression;
use Cake\ORM\Query;
use Cake\I18n\FrozenTime;
class AppTable extends Table
{
@ -14,6 +18,142 @@ class AppTable extends Table
{
}
public function getStatisticsUsageForModel(Object $table, array $scopes, array $options=[]): array
{
$defaultOptions = [
'limit' => 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');

View File

@ -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');
}

View File

@ -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;
}
}

View File

@ -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');
}

View File

@ -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;

View File

@ -0,0 +1,60 @@
<?php
namespace App\Model\Table;
use App\Model\Table\AppTable;
use Cake\ORM\Table;
use Cake\Validation\Validator;
use Cake\ORM\RulesChecker;
use Cake\ORM\TableRegistry;
class MailingListsTable extends AppTable
{
public function initialize(array $config): void
{
parent::initialize($config);
$this->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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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');
}

View File

@ -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.'),

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Utility\UI;
use Cake\Utility\Inflector;
class IndexSetting
{
public static function getAllSetting($user): array
{
$rawSetting = !empty($user->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()))));
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Utility\UI;
use Cake\Validation\Validator;
class Notification
{
public $text = '';
public $router = null;
public $details = null;
public $icon = 'exclamation-circle';
public $variant = 'primary';
public $datetime = null;
public $_useModal = false;
public $_sidebarId = null;
public function __construct(string $text, array $router, $options = [])
{
$this->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);
}
}

View File

@ -1,4 +1,5 @@
<?php
/**
* Bootstrap Tabs helper
* Options:
@ -81,7 +82,7 @@ class BootstrapHelper extends Helper
return $bsButton->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);
}
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\View\Helper;
use Cake\View\Helper;
class ValueGetterHelper extends Helper
{
public function get($target, $args=[])
{
$value = '';
if (is_callable($target)) {
$value = $this->eval($target, $args);
} else {
$value = h($target);
}
return $value;
}
private function eval($fun, $args=[])
{
return $fun($args);
}
}

View File

@ -1,2 +1,2 @@
<redoc spec-url='<?php echo $url ?>'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
<?= $this->Html->script('redoc.standalone.js') ?>

View File

@ -6,10 +6,14 @@ echo $this->element('genericElements/IndexTable/index_table', [
'children' => [
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'
'searchKey' => 'value',
'allowFilering' => true
],
[
'type' => 'table_action',
]
]
],

View File

@ -17,7 +17,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -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'

View File

@ -7,7 +7,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
'children' => [
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value',

View File

@ -7,7 +7,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
'children' => [
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value',

View File

@ -7,7 +7,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
'children' => [
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value',

View File

@ -24,7 +24,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -29,7 +29,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value',

View File

@ -11,7 +11,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value',

View File

@ -27,7 +27,6 @@
'requirements' => $this->request->getParam('action') === 'edit'
),
),
'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates,
'submit' => array(
'action' => $this->request->getParam('action')
)

View File

@ -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',
@ -28,7 +28,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'table_action',
'table_setting_id' => 'individual_index',
]
]
],
@ -73,7 +72,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
'title' => __('ContactDB Individuals Index'),
'description' => __('A list of individuals known by your Cerebrate instance. This list can get populated either directly, by adding new individuals or by fetching them from trusted remote sources. Additionally, users created for the platform will always have an individual identity.'),
'pull' => 'right',
'actions' => [
[
'url' => '/individuals/view',

View File

@ -42,8 +42,8 @@ $this->userSettingsTable = TableRegistry::getTableLocator()->get('UserSettings')
<?= __('Activity') ?>
</h3>
<div class="row">
<?php foreach ($statistics as $modelName => $statistics) : ?>
<div class="col-sm-6 col-md-5 col-l-4 col-xl-3 mb-3">
<?php foreach ($statistics as $modelName => $statisticForModel) : ?>
<div class="col-sm-6 col-md-5 col-lg-4 col-xl-3 mb-3">
<?php
$exploded = explode('.', $modelName);
$modelForDisplay = $exploded[count($exploded) - 1];
@ -57,9 +57,9 @@ $this->userSettingsTable = TableRegistry::getTableLocator()->get('UserSettings')
);
echo $this->element('widgets/highlight-panel', [
'titleHtml' => $panelTitle,
'number' => $statistics['amount'],
'variation' => $statistics['variation'] ?? '',
'chartData' => $statistics['timeline'] ?? []
'number' => $statisticForModel['created']['amount'],
'variation' => $statisticForModel['created']['variation'] ?? null,
'timeline' => $statisticForModel ?? []
]);
?>
</div>

View File

@ -6,7 +6,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
'children' => [
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -36,7 +36,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -6,7 +6,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
'children' => [
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -0,0 +1,35 @@
<?php
echo $this->element('genericElements/Form/genericForm', [
'data' => [
'description' => __('Mailing list are email distribution lists containing individuals.'),
'model' => 'MailingLists',
'fields' => [
[
'field' => 'name'
],
[
'field' => 'uuid',
'label' => 'UUID',
'type' => 'uuid'
],
[
'field' => 'releasability',
'type' => 'textarea'
],
[
'field' => 'description',
'type' => 'textarea'
],
[
'field' => 'active',
'type' => 'checkbox',
'default' => 1
],
],
'submit' => [
'action' => $this->request->getParam('action')
]
]
]);
?>
</div>

View File

@ -0,0 +1,121 @@
<?php
echo $this->element('genericElements/Form/genericForm', [
'data' => [
'title' => __('Add members to `{0}` [{1}]', h($mailingList->name), h($mailingList->id)),
'model' => 'MailingLists',
'fields' => [
[
'field' => 'individuals',
'type' => 'dropdown',
'multiple' => true,
'select2' => true,
'label' => __('Members'),
'class' => 'select2-input',
'options' => $dropdownData['individuals']
],
[
'field' => 'chosen_emails',
'type' => 'text',
'templates' => ['inputContainer' => '<div class="row mb-3 d-none">{{content}}</div>'],
],
'<div class="alternate-emails-container panel d-none"></div>'
],
'submit' => [
'action' => $this->request->getParam('action')
],
],
]);
?>
</div>
<script>
(function() {
let individuals = {}
$('#individuals-field').on('select2:select select2:unselect', function(e) {
const selected = e.params.data;
fetchIndividual(selected.id).then(() => {
udpateAvailableEmails($(e.target).select2('data'));
})
});
function udpateAvailableEmails(selected) {
const $container = $('.alternate-emails-container')
$container.empty()
$container.toggleClass('d-none', selected.length == 0)
selected.forEach(selectData => {
const individual = individuals[selectData.id]
let formContainers = [genForContainer(`primary-${individual.id}`, individual.email, '<?= __('primary email') ?>', true, true)]
if (individual.alternate_emails !== undefined) {
individual.alternate_emails.forEach(alternateEmail => {
formContainers.push(
genForContainer(alternateEmail.id, alternateEmail.value, `${alternateEmail.meta_template_field.meta_template.namespace} :: ${alternateEmail.field}`, false)
)
})
}
const $individualFullName = $('<div/>').addClass('fw-light fs-5 mt-2').text(individual.full_name)
const $individualContainer = $('<div/>').addClass('individual-container').data('individualid', individual.id)
.append($individualFullName).append(formContainers)
$container.append($individualContainer)
registerChangeListener()
injectSelectedEmailsIntoForm()
});
}
function genForContainer(id, email, email_source, is_primary = true, checked = false) {
const $formContainer = $('<div/>').addClass('form-check ms-2')
$formContainer.append(
$('<input/>').addClass('form-check-input').attr('type', 'checkbox').attr('id', `individual-${id}`)
.attr('value', is_primary ? 'primary' : id).prop('checked', checked),
$('<label/>').addClass('form-check-label').attr('for', `individual-${id}`).append(
$('<span/>').text(email),
$('<span/>').addClass('fw-light fs-8 align-middle ms-2').text(`${email_source}`)
)
)
return $formContainer
}
function registerChangeListener() {
$('.alternate-emails-container .individual-container input')
.off('change.udpate')
.on('change.udpate', injectSelectedEmailsIntoForm)
}
function injectSelectedEmailsIntoForm() {
const selectedEmails = getSelectedEmails()
$('#chosen_emails-field').val(JSON.stringify(selectedEmails))
}
function getSelectedEmails() {
selectedEmails = {}
$('.alternate-emails-container .individual-container').each(function() {
const $individualContainer = $(this)
const individualId = $individualContainer.data('individualid')
selectedEmails[individualId] = []
const $inputs = $individualContainer.find('input:checked').each(function() {
selectedEmails[individualId].push($(this).val())
})
})
return selectedEmails
}
function fetchIndividual(id) {
const urlGet = `/individuals/view/${id}?full=1`
const options = {
statusNode: $('.alternate-emails-container')
}
if (individuals[id] !== undefined) {
return Promise.resolve(individuals[id])
}
return AJAXApi.quickFetchJSON(urlGet, options)
.then(individual => {
individuals[individual.id] = individual
})
.catch((e) => {
UI.toast({
variant: 'danger',
text: '<?= __('Could not fetch individual') ?>'
})
})
}
})()
</script>

View File

@ -0,0 +1,100 @@
<?php
echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'children' => [
[
'type' => 'simple',
'children' => [
'data' => [
'type' => 'simple',
'text' => __('Add mailing list'),
'popover_url' => '/MailingLists/add'
]
]
],
[
'type' => 'search',
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'
],
[
'type' => 'table_action',
'table_setting_id' => 'mailinglist_index',
]
]
],
'fields' => [
[
'name' => '#',
'sort' => 'id',
'class' => 'short',
'data_path' => 'id',
],
[
'name' => __('Name'),
'sort' => 'name',
'data_path' => 'name',
],
[
'name' => __('Owner'),
'data_path' => 'user_id',
'element' => 'user'
],
[
'name' => __('UUID'),
'sort' => 'uuid',
'data_path' => 'uuid',
],
[
'name' => __('Members'),
'data_path' => 'individuals',
'element' => 'count_summary',
],
[
'name' => __('Intended recipients'),
'data_path' => 'recipients',
],
[
'name' => __('Description'),
'data_path' => 'description',
],
[
'name' => __('Active'),
'data_path' => 'active',
'sort' => 'active',
'element' => 'boolean',
],
[
'name' => __('Deleted'),
'data_path' => 'deleted',
'sort' => 'deleted',
'element' => 'boolean',
],
],
'title' => __('Mailing Lists Index'),
'description' => __('Mailing list are email distribution lists containing individuals.'),
'actions' => [
[
'url' => '/mailingLists/view',
'url_params_data_paths' => ['id'],
'icon' => 'eye'
],
[
'open_modal' => '/mailingLists/edit/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'edit'
],
[
'open_modal' => '/mailingLists/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'icon' => 'trash'
],
]
]
]);
?>

View File

@ -0,0 +1,142 @@
<?php
echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $individuals,
'top_bar' => [
'children' => [
[
'type' => 'multi_select_actions',
'children' => [
[
'text' => __('Remove members'),
'variant' => 'danger',
'onclick' => 'removeMembers',
]
],
'data' => [
'id' => [
'value_path' => 'id'
]
]
],
[
'type' => 'simple',
'children' => [
'data' => [
'type' => 'simple',
'text' => __('Add member'),
'popover_url' => '/mailingLists/addIndividual/' . h($mailing_list_id),
'reload_url' => '/mailingLists/listIndividuals/' . h($mailing_list_id)
]
]
],
[
'type' => 'search',
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value',
'additionalUrlParams' => h($mailing_list_id)
],
]
],
'fields' => [
[
'name' => '#',
'sort' => 'id',
'data_path' => 'id',
'url' => '/individuals/view/{{0}}',
'url_vars' => ['id'],
],
[
'name' => __('First name'),
'data_path' => 'first_name',
'url' => '/individuals/view/{{0}}',
'url_vars' => ['id'],
],
[
'name' => __('Last name'),
'data_path' => 'last_name',
'url' => '/individuals/view/{{0}}',
'url_vars' => ['id'],
],
[
'name' => __('Registered Email'),
'data_path' => 'mailinglist_emails',
'element' => 'list',
],
[
'name' => __('UUID'),
'sort' => 'uuid',
'data_path' => 'uuid',
]
],
'actions' => [
[
'open_modal' => '/mailingLists/removeIndividual/' . h($mailing_list_id) . '/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'reload_url' => '/mailingLists/listIndividuals/' . h($mailing_list_id),
'icon' => 'trash'
],
]
]
]);
?>
<script>
function removeMembers(idList, selectedData, $table) {
const successCallback = function([data, modalObject]) {
UI.reload('/mailingLists/listIndividuals/<?= h($mailing_list_id) ?>', UI.getContainerForTable($table), $table)
}
const failCallback = ([data, modalObject]) => {
const tableData = selectedData.map(row => {
entryInError = data.filter(error => error.data.id == row.id)[0]
$faIcon = $('<i class="fa"></i>').addClass(entryInError.success ? 'fa-check text-success' : 'fa-times text-danger')
return [row.id, row.first_name, row.last_name, row.email, entryInError.message, JSON.stringify(entryInError.errors), $faIcon]
});
handleMessageTable(
modalObject.$modal,
['<?= __('ID') ?>', '<?= __('First name') ?>', '<?= __('Last name') ?>', '<?= __('email') ?>', '<?= __('Message') ?>', '<?= __('Error') ?>', '<?= __('State') ?>'],
tableData
)
const $footer = $(modalObject.ajaxApi.statusNode).parent()
modalObject.ajaxApi.statusNode.remove()
const $cancelButton = $footer.find('button[data-bs-dismiss="modal"]')
$cancelButton.text('<?= __('OK') ?>').removeClass('btn-secondary').addClass('btn-primary')
}
UI.submissionModal('/mailingLists/removeIndividual/<?= h($mailing_list_id) ?>', successCallback, failCallback).then(([modalObject, ajaxApi]) => {
const $idsInput = modalObject.$modal.find('form').find('input#ids-field')
$idsInput.val(JSON.stringify(idList))
const tableData = selectedData.map(row => {
return [row.id, row.first_name, row.last_name, row.email]
});
handleMessageTable(
modalObject.$modal,
['<?= __('ID') ?>', '<?= __('First name') ?>', '<?= __('Last name') ?>', '<?= __('email') ?>'],
tableData
)
})
function constructMessageTable(header, data) {
return HtmlHelper.table(
header,
data, {
small: true,
borderless: true,
tableClass: ['message-table', 'mt-4 mb-0'],
}
)
}
function handleMessageTable($modal, header, data) {
const $modalBody = $modal.find('.modal-body')
const $messageTable = $modalBody.find('table.message-table')
const messageTableHTML = constructMessageTable(header, data)[0].outerHTML
if ($messageTable.length) {
$messageTable.html(messageTableHTML)
} else {
$modalBody.append(messageTableHTML)
}
}
}
</script>

View File

@ -0,0 +1,53 @@
<?php
echo $this->element(
'/genericElements/SingleViews/single_view',
[
'data' => $entity,
'fields' => [
[
'key' => __('ID'),
'path' => 'id'
],
[
'key' => __('UUID'),
'path' => 'uuid'
],
[
'key' => __('Name'),
'path' => 'name'
],
[
'key' => __('Owner'),
'path' => 'user_id',
'url' => '/users/view/{{0}}',
'url_vars' => 'user_id'
],
[
'key' => __('Releasability'),
'path' => 'releasability'
],
[
'key' => __('Description'),
'path' => 'description'
],
[
'key' => __('Active'),
'path' => 'active',
'type' => 'boolean'
],
[
'key' => __('Deleted'),
'path' => 'deleted',
'type' => 'boolean'
]
],
'children' => [
[
'url' => '/mailingLists/listIndividuals/{{0}}',
'url_params' => ['id'],
'title' => __('Individuals'),
'collapsed' => 'show',
]
]
]
);

View File

@ -6,7 +6,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
'children' => [
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'
@ -34,7 +34,12 @@ echo $this->element('genericElements/IndexTable/index_table', [
'name' => __('Validation regex'),
'sort' => 'regex',
'data_path' => 'regex'
]
],
[
'name' => __('Field Usage'),
'sort' => 'counter',
'data_path' => 'counter',
],
],
'title' => __('Meta Template Fields'),
'description' => __('The various fields that the given template contans. When a meta template is enabled, the fields are automatically appended to the appropriate object.'),

View File

@ -0,0 +1,42 @@
<?php
use Cake\Utility\Inflector;
use Cake\Routing\Router;
$urlNewestMetaTemplate = Router::url([
'controller' => 'metaTemplates',
'action' => 'view',
$newestMetaTemplate->id
]);
$bodyHtml = '';
$bodyHtml .= sprintf('<div><span>%s: </span><span class="fw-bold">%s</span></div>', __('Current version'), h($metaTemplate->version));
$bodyHtml .= sprintf('<div><span>%s: </span><a href="%s" target="_blank" class="fw-bold">%s</a></div>', __('Newest version'), $urlNewestMetaTemplate, h($newestMetaTemplate->version));
$bodyHtml .= sprintf('<h4 class="my-2">%s</h4>', __('Entities with meta-fields to be updated:'));
$bodyHtml .= '<ul>';
foreach ($entities as $entity) {
$url = Router::url([
'controller' => Inflector::pluralize($metaTemplate->scope),
'action' => 'view',
$entity->id
]);
$bodyHtml .= sprintf(
'<li><a href="%s" target="_blank">%s</a> <span class="fw-light">%s<span></li>',
$url,
__('{0}::{1}', h(Inflector::humanize($metaTemplate->scope)), $entity->id),
__('has {0} meta-fields to update', count($entity->meta_fields))
);
}
if ($amountOfEntitiesToUpdate > 10) {
$bodyHtml .= sprintf('<li class="list-inline-item fw-light fs-7">%s</li>', __('{0} more entities', h(10 - $amountOfEntitiesToUpdate)));
}
$bodyHtml .= '</ul>';
echo $this->Bootstrap->modal([
'titleHtml' => __('{0} has a new meta-template and meta-fields to be updated', sprintf('<i class="me-1">%s</i>', h($metaTemplate->name))),
'bodyHtml' => $bodyHtml,
'size' => 'lg',
'type' => 'ok-only',
]);
?>

View File

@ -1,5 +1,34 @@
<?php
use Cake\Utility\Hash;
if (!empty($updateableTemplates['new'])) {
$alertHtml = sprintf(
'<strong>%s</strong> %s',
__('New meta-templates available!'),
__n('There is one new template on disk that can be loaded in the database', 'There are {0} new templates on disk that can be loaded in the database:', count($updateableTemplates['new']), count($updateableTemplates['new']))
);
$alertList = [];
$alertList = Hash::extract($updateableTemplates['new'], '{s}.template');
$alertList = array_map(function($entry) {
return sprintf('%s:%s %s',
h($entry['namespace']),
h($entry['name']),
$this->Bootstrap->button([
'variant' => 'link',
'size' => 'sm',
'icon' => 'download',
'title' => __('Create this template'),
'params' => [
'onclick' => "UI.submissionModalForIndex('/metaTemplates/createNewTemplate/{$entry['uuid']}', '/meta-templates')"
]
])
);
}, $alertList);
$alertHtml .= $this->Html->nestedList($alertList);
}
echo $this->element('genericElements/IndexTable/index_table', [
'notice' => !empty($alertHtml) ? ['html' => $alertHtml, 'variant' => 'warning',] : false,
'data' => [
'data' => $data,
'top_bar' => [
@ -10,7 +39,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'
@ -129,21 +158,60 @@ echo $this->element('genericElements/IndexTable/index_table', [
'sort' => 'namespace',
'data_path' => 'namespace',
],
[
'name' => __('Version'),
'sort' => 'version',
'data_path' => 'version',
],
[
'name' => __('UUID'),
'sort' => 'uuid',
'data_path' => 'uuid'
]
],
],
'title' => __('Meta Field Templates'),
'description' => __('The various templates used to enrich certain objects by a set of standardised fields.'),
'pull' => 'right',
'actions' => [
[
'url' => '/metaTemplates/view',
'url_params_data_paths' => ['id'],
'icon' => 'eye'
],
[
'open_modal' => '/metaTemplates/update/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'title' => __('Update Meta-Template'),
'icon' => 'download',
'complex_requirement' => [
'function' => function ($row, $options) {
return empty($row['updateStatus']['up-to-date']) && empty($row['updateStatus']['to-existing']);
}
]
],
[
'open_modal' => '/metaTemplates/getMetaFieldsToUpdate/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'title' => __('Get meta-fields that should be moved to the newest version of this meta-template'),
'icon' => 'exclamation-triangle',
'variant' => 'warning',
'complex_requirement' => [
'function' => function ($row, $options) {
return !empty($row['updateStatus']['to-existing']) && empty($row['updateStatus']['can-be-removed']);
}
]
],
[
'open_modal' => '/metaTemplates/delete/[onclick_params_data_path]',
'modal_params_data_path' => 'id',
'title' => __('Get meta-fields that should be moved to the newest version of this meta-template'),
'icon' => 'trash',
'variant' => 'success',
'complex_requirement' => [
'function' => function ($row, $options) {
return !empty($row['updateStatus']['to-existing']) && !empty($row['updateStatus']['can-be-removed']);
}
]
],
]
]
]);

View File

@ -0,0 +1,109 @@
<?php
use Cake\Utility\Inflector;
use Cake\Routing\Router;
?>
<h3><?= h($oldMetaTemplate->name) ?></h3>
<div class="container-fluid">
<div class="row gx-2">
<div class="col">
<div class="panel">
<h4 class="d-flex justify-content-between align-items-center">
<?php
$url = Router::url([
'action' => 'view',
$oldMetaTemplate->id
]);
?>
<a href="<?= $url ?>" class="text-decoration-none" target="__blank"><?= __('Version {0}', h($oldMetaTemplate->version)) ?></a>
<?=
$this->Bootstrap->badge([
'text' => __('Data to be migrated over'),
'variant' => 'danger',
'class' => 'fs-7'
])
?>
</h4>
<div>
<?=
$this->element('MetaTemplates/migrationToNewVersionForm', [
'metaTemplate' => $oldMetaTemplate,
'entity' => $entity,
])
?>
</div>
</div>
</div>
<div class="col pt-4 d-flex justify-content-center" style="max-width: 32px;">
<?= $this->Bootstrap->icon('arrow-alt-circle-right') ?>
</div>
<div class="col">
<div class="panel">
<h4 class="d-flex justify-content-between align-items-center">
<?php
$url = Router::url([
'action' => 'view',
$newMetaTemplate->id
]);
?>
<a href="<?= $url ?>" class="text-decoration-none" target="__blank"><?= __('Version {0}', h($newMetaTemplate->version)) ?></a>
<?=
$this->Bootstrap->badge([
'text' => __('Data to be saved'),
'variant' => 'success',
'class' => 'fs-7'
])
?>
</h4>
<div class="to-save-container">
<?=
$this->element('MetaTemplates/migrationToNewVersionForm', [
'metaTemplate' => $newMetaTemplate,
'entity' => $entity,
])
?>
</div>
</div>
</div>
</div>
<div class="d-flex flex-row-reverse">
<?=
$this->Bootstrap->button([
'text' => __('Update to version {0}', h($newMetaTemplate->version)),
'variant' => 'success',
'params' => [
'onclick' => 'submitMigration()'
]
])
?>
</div>
</div>
<?php
echo $this->Html->scriptBlock(sprintf(
'var csrfToken = %s;',
json_encode($this->request->getAttribute('csrfToken'))
));
?>
<script>
$(document).ready(function() {
const movedMetaTemplateFields = <?= json_encode($movedMetaTemplateFields) ?>;
const oldMetaTemplateID = <?= h($oldMetaTemplate->id) ?>;
movedMetaTemplateFields.forEach(metaTemplateId => {
let validInputPath = `MetaTemplates.${oldMetaTemplateID}.meta_template_fields.${movedMetaTemplateFields}`
const $inputs = $(`input[field^="${validInputPath}"]`)
$inputs.addClass('is-valid');
});
})
function submitMigration() {
const $form = $('.to-save-container form')
console.log($form.attr('action'));
AJAXApi.quickPostForm($form[0]).then((postResult) => {
if (postResult.additionalData.redirect.url !== undefined) {
window.location = postResult.additionalData.redirect.url
}
})
}
</script>

View File

@ -0,0 +1,72 @@
<?php
use App\Model\Table\MetaTemplatesTable;
$bodyHtml = '';
$modalType = 'confirm';
$modalSize = 'lg';
if ($updateStatus['up-to-date']) {
$bodyHtml .= $this->Bootstrap->alert([
'variant' => 'success',
'text' => __('This meta-template is already up-to-date!'),
'dismissible' => false,
]);
$modalType = 'ok-only';
} else {
if ($updateStatus['automatically-updateable']) {
$bodyHtml .= $this->Bootstrap->alert([
'variant' => 'success',
'html' => __('This meta-template can be updated to version {0} (current: {1}).', sprintf('<strong>%s</strong>', h($templateOnDisk['version'])), h($metaTemplate->version)),
'dismissible' => false,
]);
$form = $this->element('genericElements/Form/genericForm', [
'entity' => null,
'ajax' => false,
'raw' => true,
'data' => [
'model' => 'MetaTemplate',
'fields' => [
[
'field' => 'update_strategy',
'type' => 'checkbox',
'value' => MetaTemplatesTable::UPDATE_STRATEGY_CREATE_NEW,
'checked' => true,
]
],
'submit' => [
'action' => $this->request->getParam('action')
],
]
]);
$bodyHtml .= sprintf('<div class="d-none">%s</div>', $form);
} else {
$modalSize = 'xl';
$bodyHtml .= $this->Bootstrap->alert([
'variant' => 'warning',
'text' => __('Updating to version {0} cannot be done automatically as it introduces some conflicts.', h($templateOnDisk['version'])),
'dismissible' => false,
]);
$conflictTable = $this->element('MetaTemplates/conflictTable', [
'templateStatus' => $templateStatus,
'metaTemplate' => $metaTemplate,
'templateOnDisk' => $templateOnDisk,
]);
$bodyHtml .= $this->Bootstrap->collapse([
'title' => __('View conflicts'),
'open' => false
], $conflictTable);
$bodyHtml .= $this->element('MetaTemplates/conflictResolution', [
'templateStatus' => $templateStatus,
'metaTemplate' => $metaTemplate,
'templateOnDisk' => $templateOnDisk,
]);
}
}
echo $this->Bootstrap->modal([
'title' => __('Update Meta Templates #{0} ?', h($metaTemplate->id)),
'bodyHtml' => $bodyHtml,
'size' => $modalSize,
'type' => $modalType,
'confirmText' => __('Update meta-templates'),
]);
?>

View File

@ -0,0 +1,119 @@
<?php
use Cake\Routing\Router;
$bodyHtml = '';
$modalType = 'confirm';
$modalSize = 'lg';
$tableHtml = '<table class="table"><thead><tr>';
$tableHtml .= sprintf('<th class="text-nowrap">%s</th>', __('ID'));
$tableHtml .= sprintf('<th class="text-nowrap">%s</th>', __('Template'));
$tableHtml .= sprintf('<th class="text-nowrap">%s</th>', __('Version'));
$tableHtml .= sprintf('<th class="text-nowrap">%s</th>', __('New Template'));
$tableHtml .= sprintf('<th class="text-nowrap">%s</th>', __('Update available'));
$tableHtml .= sprintf('<th class="text-nowrap">%s</th>', __('Has Conflicts'));
$tableHtml .= sprintf('<th class="text-nowrap">%s</th>', __('Will be updated'));
$tableHtml .= '</tr></thead><tbody>';
$numberOfUpdates = 0;
$numberOfSkippedUpdates = 0;
foreach ($templatesUpdateStatus as $uuid => $status) {
$tableHtml .= '<tr>';
if (!empty($status['new'])) {
$tableHtml .= sprintf('<td>%s</td>', __('N/A'));
} else {
$tableHtml .= sprintf('<td><a href="%s">%s</a></td>',
Router::url(['controller' => 'MetaTemplates', 'action' => 'view', 'plugin' => null, h($status['existing_template']->id)]),
h($status['existing_template']->id)
);
}
if (!empty($status['new'])) {
$tableHtml .= sprintf('<td>%s</td>', h($uuid));
} else {
$tableHtml .= sprintf('<td><a href="%s">%s</a></td>',
Router::url(['controller' => 'MetaTemplates', 'action' => 'view', 'plugin' => null, h($status['existing_template']->id)]),
h($status['existing_template']->name)
);
}
if (!empty($status['new'])) {
$tableHtml .= sprintf('<td>%s</td>', __('N/A'));
} else {
$tableHtml .= sprintf('<td>%s %s %s</td>',
h($status['current_version']),
$this->Bootstrap->icon('arrow-right', ['class' => 'fs-8']),
h($status['next_version'])
);
}
if (!empty($status['new'])) {
$numberOfUpdates += 1;
$tableHtml .= sprintf('<td>%s</td>', $this->Bootstrap->icon('check'));
} else {
$tableHtml .= sprintf('<td>%s</td>', $this->Bootstrap->icon('times'));
}
if (!empty($status['new'])) {
$tableHtml .= sprintf('<td>%s</td>', __('N/A'));
} else {
$tableHtml .= sprintf('<td>%s</td>', empty($status['up-to-date']) ? $this->Bootstrap->icon('check') : $this->Bootstrap->icon('times'));
}
if (!empty($status['new'])) {
$tableHtml .= sprintf('<td>%s</td>', __('N/A'));
} else {
$tableHtml .= sprintf('<td>%s</td>', !empty($status['conflicts']) ? $this->Bootstrap->icon('check') : $this->Bootstrap->icon('times'));
}
if (!empty($status['new'])) {
$tableHtml .= sprintf('<td>%s</td>', $this->Bootstrap->icon('check', ['class' => 'text-success']));
} else {
// Depends on the strategy used by the update_all function. Right now, every update create a brand new template
// leaving existing data untouched. So regardless of the conflict, the new template will be created
if (!empty($status['new']) || empty($status['up-to-date'])) {
$numberOfUpdates += 1;
$tableHtml .= sprintf('<td>%s</td>', $this->Bootstrap->icon('check', ['class' => 'text-success']));
} else {
$numberOfSkippedUpdates += 1;
$tableHtml .= sprintf('<td>%s</td>', $this->Bootstrap->icon('times', ['class' => 'text-danger']));
}
}
$tableHtml .= '</tr>';
}
$tableHtml .= '</tbody></table>';
if (empty($numberOfSkippedUpdates) && empty($numberOfUpdates)) {
$bodyHtml .= $this->Bootstrap->alert([
'variant' => 'success',
'text' => __('All meta-templates are already up-to-date!'),
'dismissible' => false,
]);
$modalType = 'ok-only';
} elseif ($numberOfSkippedUpdates == 0) {
$bodyHtml .= $this->Bootstrap->alert([
'variant' => 'success',
'text' => __('All {0} meta-templates can be updated', $numberOfUpdates),
'dismissible' => false,
]);
} else {
$modalSize = 'xl';
$alertHtml = '';
if (!empty($numberOfUpdates)) {
$alertHtml .= sprintf('<div>%s</div>', __('{0} meta-templates can be updated.', sprintf('<strong>%s</strong>', $numberOfUpdates)));
}
if (!empty($numberOfSkippedUpdates)) {
$alertHtml .= sprintf('<div>%s</div>', __('{0} meta-templates will be skipped.', sprintf('<strong>%s</strong>', $numberOfSkippedUpdates)));
$alertHtml .= sprintf('<div>%s</div>', __('You can still choose the update strategy when updating each conflicting template manually.'));
}
$bodyHtml .= $this->Bootstrap->alert([
'variant' => 'warning',
'html' => $alertHtml,
'dismissible' => false,
]);
}
$bodyHtml .= $tableHtml;
echo $this->Bootstrap->modal([
'title' => h($title),
'bodyHtml' => $bodyHtml,
'size' => $modalSize,
'type' => $modalType,
'confirmText' => __('Update meta-templates'),
'confirmFunction' => 'updateMetaTemplate',
]);
?>

View File

@ -43,7 +43,8 @@ echo $this->element(
[
'url' => '/MetaTemplateFields/index?meta_template_id={{0}}',
'url_params' => ['id'],
'title' => __('Fields')
'title' => __('Fields'),
'collapsed' => 'show',
]
]
]

View File

@ -7,7 +7,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
'children' => [
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -18,7 +18,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -25,7 +25,6 @@
'field' => 'type'
)
),
'metaTemplates' => empty($metaTemplates) ? [] : $metaTemplates,
'submit' => array(
'action' => $this->request->getParam('action')
)

View File

@ -44,6 +44,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
'name' => __('Name'),
'class' => 'short',
'data_path' => 'name',
'sort' => 'name',
],
[
'name' => __('UUID'),
@ -67,14 +68,17 @@ echo $this->element('genericElements/IndexTable/index_table', [
[
'name' => __('Nationality'),
'data_path' => 'nationality',
'sort' => 'nationality',
],
[
'name' => __('Sector'),
'data_path' => 'sector',
'sort' => 'sector',
],
[
'name' => __('Type'),
'data_path' => 'type',
'sort' => 'type',
],
[
'name' => __('Tags'),
@ -84,7 +88,6 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
'title' => __('ContactDB Organisation Index'),
'description' => __('A list of organisations known by your Cerebrate instance. This list can get populated either directly, by adding new organisations or by fetching them from trusted remote sources.'),
'pull' => 'right',
'actions' => [
[
'url' => '/organisations/view',

View File

@ -48,8 +48,7 @@ echo $this->element(
'scope' => 'organisations'
]
],
'metaTemplates' => empty($metaFields) ? [] : $metaFields,
'combinedFieldsView' => true,
'combinedFieldsView' => false,
'children' => []
]
);

View File

@ -29,7 +29,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value',

View File

@ -11,7 +11,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value',

View File

@ -17,7 +17,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -17,7 +17,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -18,7 +18,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -22,7 +22,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -1,12 +1,16 @@
<?php
use Cake\Core\Configure;
$passwordRequired = false;
$showPasswordField = false;
if ($this->request->getParam('action') === 'add') {
$dropdownData['individual'] = ['new' => __('New individual')] + $dropdownData['individual'];
if (!Configure::check('password_auth.enabled') || Configure::read('password_auth.enabled')) {
$passwordRequired = 'required';
}
}
if (!Configure::check('password_auth.enabled') || Configure::read('password_auth.enabled')) {
$showPasswordField = true;
}
echo $this->element('genericElements/Form/genericForm', [
'data' => [
'description' => __('Roles define global rules for a set of users, including first and foremost access controls to certain functionalities.'),
@ -62,7 +66,7 @@
'required' => $passwordRequired,
'autocomplete' => 'new-password',
'value' => '',
'requirements' => (bool)$passwordRequired
'requirements' => $showPasswordField,
],
[
'field' => 'confirm_password',
@ -70,7 +74,7 @@
'type' => 'password',
'required' => $passwordRequired,
'autocomplete' => 'off',
'requirements' => (bool)$passwordRequired
'requirements' => $showPasswordField,
],
[
'field' => 'role_id',

View File

@ -17,7 +17,7 @@ echo $this->element('genericElements/IndexTable/index_table', [
],
[
'type' => 'search',
'button' => __('Filter'),
'button' => __('Search'),
'placeholder' => __('Enter value to search'),
'data' => '',
'searchKey' => 'value'

View File

@ -0,0 +1,107 @@
<?php
$create_new_allowed = true;
$keep_all_allowed = false;
$delete_all_allowed = false;
$totalAllowed = $create_new_allowed + $keep_all_allowed + $delete_all_allowed;
$maxWidth = 99 - ($create_new_allowed ? 33 : 0) - ($keep_all_allowed ? 33 : 0) - ($delete_all_allowed ? 33 : 0);
$form = $this->element('genericElements/Form/genericForm', [
'entity' => null,
'ajax' => false,
'raw' => true,
'data' => [
'model' => 'MetaTemplate',
'fields' => [
[
'field' => 'update_strategy',
'type' => 'radio',
'options' => [
['value' => 'create_new', 'text' => 'create_new', 'id' => 'radio_create_new'],
['value' => 'keep_both', 'text' => 'keep_both', 'id' => 'radio_keep_both'],
['value' => 'delete', 'text' => 'delete', 'id' => 'radio_delete'],
],
]
],
'submit' => [
'action' => $this->request->getParam('action')
],
]
]);
?>
<div class="conflict-resolution-picker">
<div class="mt-3 d-flex justify-content-center">
<div class="btn-group justify-content-center" role="group" aria-label="Basic radio toggle button group">
<?php if ($create_new_allowed) : ?>
<input type="radio" class="btn-check" name="btnradio" id="btnradio1" autocomplete="off" value="create_new" checked>
<label class="btn btn-outline-primary mw-<?= $maxWidth ?>" for="btnradio1">
<div>
<h5 class="mb-3"><?= __('Create new template') ?></h5>
<ul class="text-start fs-7">
<li><?= __('A new meta-template will be created and made default.') ?></li>
<li><?= __('The old meta-template will remain untouched.') ?></li>
<li><?= __('Migration of meta-fields to this newer template can be done manually via the UI.') ?></li>
</ul>
</div>
</label>
<?php endif; ?>
<?php if ($keep_all_allowed) : ?>
<input type="radio" class="btn-check" name="btnradio" id="btnradio2" autocomplete="off" value="keep_both">
<label class="btn btn-outline-warning mw-<?= $maxWidth ?>" for="btnradio2">
<div>
<h5 class="mb-3"><?= __('Update non-conflicting') ?></h5>
<ul class="text-start fs-7">
<li><?= __('Meta-fields not having conflicts will be migrated to the new meta-template.') ?></li>
<li><?= __('Meta-fields having a conflicts will stay on their current meta-template.') ?></li>
<li><?= __('Conflicts can be taken care of manually via the UI.') ?></li>
</ul>
</div>
</label>
<?php endif; ?>
<?php if ($delete_all_allowed) : ?>
<input type="radio" class="btn-check" name="btnradio" id="btnradio3" autocomplete="off" value="delete">
<label class="btn btn-outline-danger mw-<?= $maxWidth ?>" for="btnradio3">
<div>
<h5 class="mb-3"><?= __('Delete conflicting fields') ?></h5>
<ul class="text-start fs-7">
<li><?= __('Meta-fields not satisfying the new meta-template definition will be deleted.') ?></li>
<li><?= __('All other meta-fields will be upgraded to the new meta-template.') ?></li>
</ul>
</div>
</label>
<?php endif; ?>
</div>
</div>
</div>
<div class="d-none conflict-resolution-form-container">
<?= $form ?>
</div>
<script>
(function() {
const $form = $('.conflict-resolution-form-container form')
const $create = $form.find('input#radio_create_new')
const $keep = $form.find('input#radio_keep_both')
const $delete = $form.find('input#radio_delete')
$(document).ready(function() {
$('.conflict-resolution-picker').find('input[type="radio"]').change(function() {
updateSelected($(this).val())
})
updateSelected('create_new')
})
function updateSelected(choice) {
if (choice == 'keep_both') {
$keep.prop('checked', true)
} else if (choice == 'delete') {
$delete.prop('checked', true)
} else if (choice == 'create_new') {
$create.prop('checked', true)
}
}
}())
</script>

Some files were not shown because too many files have changed in this diff Show More