From d857856b742daaa5253b2ca87eeec87f2fc0a478 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Fri, 21 Jan 2022 19:16:53 -0500 Subject: [PATCH] Name MVC (CFM-9), CoSetting MVC (CFM-80), and other stuff --- app/config/schema/schema.json | 31 +- app/resources/locales/en_US/controller.po | 3 + app/resources/locales/en_US/enumeration.po | 156 ++++++++ app/resources/locales/en_US/error.po | 24 ++ app/resources/locales/en_US/field.po | 36 ++ app/resources/locales/en_US/operation.po | 10 + app/resources/locales/en_US/result.po | 5 +- app/src/Command/TransmogrifyCommand.php | 105 +++++- app/src/Controller/ApiUsersController.php | 15 - app/src/Controller/AppController.php | 173 ++++++++- app/src/Controller/CoSettingsController.php | 52 +++ .../Component/RegistryAuthComponent.php | 104 +++-- app/src/Controller/CosController.php | 18 - app/src/Controller/CousController.php | 14 - app/src/Controller/DashboardsController.php | 20 +- app/src/Controller/MVEAController.php | 69 ++++ app/src/Controller/NamesController.php | 98 +++++ app/src/Controller/PeopleController.php | 85 +++-- app/src/Controller/StandardController.php | 194 ++++------ app/src/Controller/TypesController.php | 15 - app/src/Lib/Enum/LanguageEnum.php | 79 ++++ app/src/Lib/Enum/PermittedNameFieldsEnum.php | 42 +++ .../Lib/Enum/RequiredAddressFieldsEnum.php | 39 ++ app/src/Lib/Enum/RequiredNameFieldsEnum.php | 35 ++ app/src/Lib/Enum/StandardEnum.php | 2 +- app/src/Lib/Events/ChangelogEventListener.php | 2 +- .../Lib/Events/RuleBuilderEventListener.php | 204 ++++++++++ app/src/Lib/Traits/MVETrait.php | 49 +++ app/src/Lib/Traits/PermissionsTrait.php | 57 +++ app/src/Lib/Traits/PrimaryLinkTrait.php | 149 +++++++- app/src/Lib/Traits/RulesTrait.php | 100 ----- app/src/Lib/Traits/ValidationTrait.php | 104 +++-- app/src/Model/Entity/Co.php | 11 + app/src/Model/Entity/CoSetting.php | 65 ++++ app/src/Model/Entity/Name.php | 41 +- app/src/Model/Table/ApiUsersTable.php | 39 +- app/src/Model/Table/CoSettingsTable.php | 246 ++++++++++++ app/src/Model/Table/CosTable.php | 59 ++- app/src/Model/Table/CousTable.php | 33 +- app/src/Model/Table/DashboardsTable.php | 20 +- app/src/Model/Table/NamesTable.php | 355 +++++++++++++----- app/src/Model/Table/PeopleTable.php | 50 ++- app/src/Model/Table/TypesTable.php | 74 ++-- app/src/View/Helper/FieldHelper.php | 23 +- app/templates/CoSettings/fields.inc | 38 ++ app/templates/Names/columns.inc | 48 +++ app/templates/Names/fields.inc | 49 +++ app/templates/People/columns.inc | 2 +- app/templates/People/fields.inc | 33 +- app/templates/Standard/add-edit-view.php | 4 +- app/templates/Standard/index.php | 42 ++- app/templates/element/breadcrumbs.php | 30 +- app/templates/element/flash/default.php | 2 +- app/templates/element/flash/error.php | 2 +- app/templates/element/flash/success.php | 2 +- 55 files changed, 2692 insertions(+), 665 deletions(-) create mode 100644 app/src/Controller/CoSettingsController.php create mode 100644 app/src/Controller/MVEAController.php create mode 100644 app/src/Controller/NamesController.php create mode 100644 app/src/Lib/Enum/LanguageEnum.php create mode 100644 app/src/Lib/Enum/PermittedNameFieldsEnum.php create mode 100644 app/src/Lib/Enum/RequiredAddressFieldsEnum.php create mode 100644 app/src/Lib/Enum/RequiredNameFieldsEnum.php create mode 100644 app/src/Lib/Events/RuleBuilderEventListener.php create mode 100644 app/src/Lib/Traits/MVETrait.php create mode 100644 app/src/Lib/Traits/PermissionsTrait.php delete mode 100644 app/src/Lib/Traits/RulesTrait.php create mode 100644 app/src/Model/Entity/CoSetting.php create mode 100644 app/src/Model/Table/CoSettingsTable.php create mode 100644 app/templates/CoSettings/fields.inc create mode 100644 app/templates/Names/columns.inc create mode 100644 app/templates/Names/fields.inc diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index d0223ad1e..96b97e7bf 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -32,8 +32,7 @@ }, "indexes": { "cos_i1": { - "columns": [ "name" ], - "unique": true + "columns": [ "name" ] } }, "changelog": true @@ -56,6 +55,29 @@ } }, + "co_settings": { + "comment": "Table definition not yet complete (CFM-80)", + + "columns": { + "id": {}, + "co_id": {}, + "address_required_fields": { "type": "string", "size": 160 }, + "name_default_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "name_permitted_fields": { "type": "string", "size": 160 }, + "name_required_fields": { "type": "string", "size": 160 } + }, + "indexes": { + "co_settings_i1": { "columns": [ "co_id" ]}, + "co_settings_i2": { + "comment": [ + "We don't really need an index, but DBAL will create one for all foreign keys if none exists", + "typeIsDefault will make queries using these columns, but rarely and won't usually have enough rows to need the index" + ], + "columns": [ "name_default_type_id" ] + } + } + }, + "api_users": { "columns": { "id": {}, @@ -121,6 +143,8 @@ }, "external_identities": { + "comment": "XXX most of these fields are going to move to person_roles instead", + "columns": { "id": {}, "person_id": { "notnull": true }, @@ -148,7 +172,8 @@ "suffix": { "type": "string", "size": 32 }, "type_id": {}, "language": { "type": "string", "size": 16 }, - "primary_name": { "type": "boolean" } + "primary_name": { "type": "boolean" }, + "display_name": { "type": "string", "size": 256 } }, "indexes": { "names_i1": { "columns": [ "type_id"] } diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index eba4bf386..e085388fd 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -42,6 +42,9 @@ msgstr "{0,plural,=1{Dashboard} other{Dashboards}}" msgid "ExternalIdentities" msgstr "{0,plural,=1{External Identity} other{External Identities}}" +msgid "Names" +msgstr "{0,plural,=1{Name} other{Names}}" + msgid "People" msgstr "{0,plural,=1{Person} other{People}}" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index cf2b22861..e65ebebd4 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -54,6 +54,162 @@ msgstr "Staff" msgid "EduPersonAffiliationEnum.student" msgstr "Student" +msgid "LanguageEnum.af" +msgstr "Afrikaans" + +msgid "LanguageEnum.ar" +msgstr "Arabic (العربية)" + +msgid "LanguageEnum.bn" +msgstr "Bengali" + +msgid "LanguageEnum.zh-Hans" +msgstr "Chinese - Simplified (简体中文)" + +msgid "LanguageEnum.zh-Hant" +msgstr "Chinese - Traditional (繁體中文)" + +msgid "LanguageEnum.hr" +msgstr "Croatian (Hrvatski)" + +msgid "LanguageEnum.cs" +msgstr "Czech (čeština)" + +msgid "LanguageEnum.da" +msgstr "Danish (Dansk)" + +msgid "LanguageEnum.nl" +msgstr "Dutch (Nederlands) / Flemish" + +msgid "LanguageEnum.en" +msgstr "English" + +msgid "LanguageEnum.et" +msgstr "Estonian (Eesti Keel)" + +msgid "LanguageEnum.fi" +msgstr "Finnish (Suomi)" + +msgid "LanguageEnum.fr" +msgstr "French (Français)" + +msgid "LanguageEnum.de" +msgstr "German (Deutsch)" + +msgid "LanguageEnum.el" +msgstr "Greek (ελληνικά)" + +msgid "LanguageEnum.he" +msgstr "Hebrew (עִבְרִית)" + +msgid "LanguageEnum.hi" +msgstr "Hindi (हिंदी)" + +msgid "LanguageEnum.hu" +msgstr "Hungarian (Magyar)" + +msgid "LanguageEnum.id" +msgstr "Indonesian (Bahasa Indonesia)" + +msgid "LanguageEnum.it" +msgstr "Italian (Italiano)" + +msgid "LanguageEnum.ja" +msgstr "Japanese (日本語)" + +msgid "LanguageEnum.ko" +msgstr "Korean (한국어)" + +msgid "LanguageEnum.lv" +msgstr "Latvian (Latviešu Valoda)" + +msgid "LanguageEnum.lt" +msgstr "Lithuanian (Lietuvių Kalba)" + +msgid "LanguageEnum.ms" +msgstr "Malaysian (Bahasa Malaysia)" + +msgid "LanguageEnum.no" +msgstr "Norwegian (Norsk)" + +msgid "LanguageEnum.pl" +msgstr "Polish (Język Polski)" + +msgid "LanguageEnum.pt" +msgstr "Portuguese (Português)" + +msgid "LanguageEnum.ro" +msgstr "Romanian (Limba Română)" + +msgid "LanguageEnum.ru" +msgstr "Russian (Pyccĸий)" + +msgid "LanguageEnum.sr" +msgstr "Serbian (српски / Srpski)" + +msgid "LanguageEnum.sl" +msgstr "Slovene (Slovenski Jezik)" + +msgid "LanguageEnum.es" +msgstr "Spanish (Español)" + +msgid "LanguageEnum.sv" +msgstr "Swedish (Svenska)" + +msgid "LanguageEnum.tr" +msgstr "Turkish (Türkçe)" + +msgid "LanguageEnum.ur" +msgstr "Urdu (اُردُو)" + +msgid "PermittedNameFieldsEnum.given,family" +msgstr "Given, Family" + +msgid "PermittedNameFieldsEnum.given,middle,family" +msgstr "Given, Middle, Family" + +msgid "PermittedNameFieldsEnum.given,family,suffix" +msgstr "Given, Family, Suffix" + +msgid "PermittedNameFieldsEnum.given,middle,family,suffix" +msgstr "Given, Middle, Family, Suffix" + +msgid "PermittedNameFieldsEnum.honorific,given,family" +msgstr "Honorific, Given, Family" + +msgid "PermittedNameFieldsEnum.honorific,given,middle,family" +msgstr "Honorific, Given, Middle, Family" + +msgid "PermittedNameFieldsEnum.honorific,given,family,suffix" +msgstr "Honorific, Given, Family, Suffix" + +msgid "PermittedNameFieldsEnum.honorific,given,middle,family,suffix" +msgstr "Honorific, Given, Middle, Family, Suffix" + +msgid "RequiredAddressFieldsEnum.country" +msgstr "Country" + +msgid "RequiredAddressFieldsEnum.locality,state" +msgstr "City, State" + +msgid "RequiredAddressFieldsEnum.postal_code" +msgstr "Postal Code" + +msgid "RequiredAddressFieldsEnum.street" +msgstr "Street" + +msgid "RequiredAddressFieldsEnum.street,locality,state,postal_code" +msgstr "Street, City, State, Postal Code" + +msgid "RequiredAddressFieldsEnum.street,locality,state,postal_code,country" +msgstr "Street, City, State, Postal Code, Country" + +msgid "RequiredNameFieldsEnum.given" +msgstr "Given" + +msgid "RequiredNameFieldsEnum.given,family" +msgstr "Given, Family" + msgid "SetBooleanEnum.0" msgstr "Not Set" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index bfbf78b0f..df939edc8 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -60,6 +60,12 @@ msgstr "Username \"{0}\" not found in api_users table" msgid "coid" msgstr "CO ID not found" +msgid "coid.frozen" +msgstr "Cannot change co_id of an existing object" + +msgid "coid.mismatch" +msgstr "Requested CO does not match CO of {0} {1}" + msgid "cou.parent" msgstr "COU Parent ID not valid" @@ -99,9 +105,18 @@ msgstr "Invalid character found" msgid "invalid" msgstr "Invalid value \"{0}\"" +msgid "Names.minimum" +msgstr "At least one name is required" + +msgid "Names.primary_name" +msgstr "Primary Name not found" + msgid "notfound" msgstr "{0} not found" +msgid "notfound.person" +msgstr "No Person or External Identity found" + msgid "notprov" msgstr "{0} not provided" @@ -117,6 +132,12 @@ msgstr "Permission Denied" msgid "primary_link" msgstr "Could not find value for Primary Link {0}" +msgid "rule.ValidateCo.errorField" +msgstr "errorField not set in ruleValidateCO" + +msgid "rule.ValidateCo.mismatch" +msgstr "Foreign key $targetField CO ID {0} does not match primary object CO ID {1}" + msgid "save" msgstr "Save Failed ({0})" @@ -129,5 +150,8 @@ msgstr "Failed to parse file {0}" msgid "Types.inuse" msgstr "Type {0} is in use and cannot be deleted" +msgid "Types.isdefault" +msgstr "Type {0} is in use as a default (via CO Settings)" + msgid "unknown" msgstr "Unknown value \"{0}\"" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 2b89aa995..2a1b8207c 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -35,6 +35,18 @@ msgstr "API Key" msgid "attribute" msgstr "Attribute" +msgid "CoSettings.address_required_fields" +msgstr "Address Required Fields" + +msgid "CoSettings.name_default_type_id" +msgstr "Default Name Type" + +msgid "CoSettings.name_permitted_fields" +msgstr "Name Permitted Fields" + +msgid "CoSettings.name_required_fields" +msgstr "Name Required Fields" + msgid "date_of_birth" msgstr "Date of Birth" @@ -57,12 +69,30 @@ msgstr "Family Name" msgid "given" msgstr "Given Name" +msgid "honorific" +msgstr "Honorific" + +msgid "honorific.desc" +msgstr "(Dr, Hon, etc)" + +msgid "full_name" +msgstr "Full Name" + +msgid "language" +msgstr "Language" + +msgid "middle" +msgstr "Middle" + msgid "name" msgstr "Name" msgid "parent_id" msgstr "Parent" +msgid "primary_name" +msgstr "Primary Name" + msgid "privileged" msgstr "Privileged" @@ -84,6 +114,12 @@ msgstr "Status" msgid "Type.status" msgstr "Suspending a Type will prevent it from being assigned to new attributes, but will not remove it from existing attributes" +msgid "suffix" +msgstr "Suffix" + +msgid "suffix.desc" +msgstr "(Jr, III, etc)" + msgid "username" msgstr "Username" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index d2e369104..0f105b8a3 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -66,6 +66,9 @@ msgstr "Edit" msgid "edit.a" msgstr "Edit {0}" +msgid "edit.ai" +msgstr "Edit {0} ({1})" + msgid "filter" msgstr "Filter" @@ -96,6 +99,9 @@ msgstr "Go to page" msgid "previous" msgstr "Previous" +msgid "primary" +msgstr "Make Primary" + msgid "Types.restore" msgstr "Add/Restore Default Types" @@ -110,3 +116,7 @@ msgstr "View" msgid "view.a" msgstr "View {0}" + +msgid "view.ai" +msgstr "View {0} ({1})" + diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index fcbf499cf..8c1507e53 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -31,4 +31,7 @@ msgid "deleted.a" msgstr "{0} Deleted" msgid "saved" -msgstr "Saved" \ No newline at end of file +msgstr "Saved" + +msgid "Names.primary_name" +msgstr "Primary Name Updated" \ No newline at end of file diff --git a/app/src/Command/TransmogrifyCommand.php b/app/src/Command/TransmogrifyCommand.php index 33c0692cd..13ad7c83d 100644 --- a/app/src/Command/TransmogrifyCommand.php +++ b/app/src/Command/TransmogrifyCommand.php @@ -36,6 +36,7 @@ use Cake\Datasource\ConnectionInterface; use Cake\Datasource\ConnectionManager; use Cake\I18n\FrozenTime; +use Cake\ORM\TableRegistry; use Cake\Utility\Inflector; use Doctrine\DBAL\DriverManager; @@ -46,7 +47,10 @@ class TransmogrifyCommand extends Command { protected $tables = [ 'cos' => [ 'source' => 'cm_cos', - 'displayField' => 'name' + 'displayField' => 'name', + 'addChangelog' => true, + // We don't really need status, but we need something cached for co_settings + 'cache' => [ 'status' ] ], 'types' => [ 'source' => 'cm_co_extended_types', @@ -60,6 +64,38 @@ class TransmogrifyCommand extends Command { ], 'cache' => [ [ 'co_id', 'attribute', 'value' ] ] ], + 'co_settings' => [ + 'source' => 'cm_co_settings', + 'displayField' => 'co_id', + 'addChangelog' => true, + 'booleans' => [], + 'post' => 'insertDefaultSettings', + 'cache' => [ 'co_id' ], + 'fieldMap' => [ + 'permitted_fields_name' => 'name_permitted_fields', + 'required_fields_addr' => 'address_required_fields', + 'required_fields_name' => 'name_required_fields', + // XXX CFM-80 these fields are not yet migrated + // be sure to add appropriate fields to 'booleans' + 'enable_nsf_demo' => null, // CFM-123 + 'disable_expiration' => null, + 'disable_ois_sync' => null, + 'group_validity_sync_window' => null, + 'garbage_collection_interval' => null, + 'enable_normalization' => null, + 'enable_empty_cou' => null, + 'invitation_validity' => null, + 't_and_c_login_mode' => null, + 'sponsor_eligibility' => null, + 'sponsor_co_group_id' => null, + 'theme_stacking' => null, + 'default_co_pipeline_id' => null, // XXX was this ever used? + 'elect_strategy_primary_name' => null, + 'co_dashboard_id' => null, + 'co_theme_id' => null, + 'global_search_limit' => null + ] + ], 'api_users' => [ 'source' => 'cm_api_users', 'displayField' => 'username', @@ -130,6 +166,7 @@ class TransmogrifyCommand extends Command { // Make some objects more easily accessible protected $inconn = null; + protected $outconn = null; /** * Build an Option Parser. @@ -231,7 +268,7 @@ public function execute(Arguments $args, ConsoleIo $io) { 'driver' => ($outcfg['driver'] == 'Cake\Database\Driver\Postgres' ? "pdo_pgsql" : "pdo_mysql") ]; - $outconn = DriverManager::getConnection($cargs, $outconfig); + $this->outconn = DriverManager::getConnection($cargs, $outconfig); // We accept a list of table names, mostly for testing purposes $atables = $args->getArguments(); @@ -259,13 +296,13 @@ public function execute(Arguments $args, ConsoleIo $io) { try { // Do this before fixBooleans since we'll insert some - $this->fixChangelog($t, $row); + $this->fixChangelog($t, $row, isset($this->tables[$t]['addChangelog']) && $this->tables[$t]['addChangelog']); $this->fixBooleans($t, $row); $this->mapFields($t, $row); - $outconn->insert($t, $row); + $this->outconn->insert($t, $row); $this->cacheResults($t, $row); } @@ -297,7 +334,15 @@ public function execute(Arguments $args, ConsoleIo $io) { // data here, and also we're executing a maintenance operation (so query // optimization is less important) $outsql = "ALTER SEQUENCE " . $t . "_id_seq RESTART WITH " . $max; - $outconn->query($outsql); + $this->outconn->query($outsql); + + // Run any post processing functions for the table. + + if(!empty($this->tables[$t]['post'])) { + $p = $this->tables[$t]['post']; + + $this->$p(); + } } } @@ -365,24 +410,53 @@ protected function fixBooleans(string $table, array &$row) { * @since COmanage Registry v5.0.0 * @param string $table Table Name * @param array $row Row of attributes, fixed in place + * @param bool $force If true, always create keys */ - protected function fixChangelog(string $table, array &$row) { - if(array_key_exists('deleted', $row) && is_null($row['deleted'])) { + protected function fixChangelog(string $table, array &$row, bool $force=false) { + if($force || (array_key_exists('deleted', $row) && is_null($row['deleted']))) { $row['deleted'] = false; } - if(array_key_exists('revision', $row) && is_null($row['revision'])) { + if($force || (array_key_exists('revision', $row) && is_null($row['revision']))) { $row['revision'] = 0; } - if(array_key_exists('actor_identifier', $row) && is_null($row['actor_identifier'])) { + if($force || (array_key_exists('actor_identifier', $row) && is_null($row['actor_identifier']))) { $row['actor_identifier'] = 'Transmogrification'; } // The parent FK should remain NULL since this is the original record. } + /** + * Insert default CO Settings. + * + * @since COmanage Registry v5.0.0 + */ + protected function insertDefaultSettings() { + // Create a CoSetting for any CO that didn't previously have one. + + $createdSettings = []; + $createdCos = array_keys($this->cache['cos']['id']); + + foreach($this->cache['co_settings']['id'] as $co_setting_id => $cached) { + $createdSettings[] = $cached['co_id']; + } + + $emptySettings = array_values(array_diff($createdCos, $createdSettings)); + + if(!empty($emptySettings)) { + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + foreach($emptySettings as $coId) { + // Insert a default row into CoSettings for this CO ID + + $CoSettings->addDefaults($coId); + } + } + } + /** * Map fields that have been renamed from Registry Classic to Registry PE. * @@ -435,12 +509,15 @@ protected function mapFields(string $table, array &$row) { protected function map_extended_type(array $row) { switch($row['attribute']) { case 'CoDepartment.type': - return 'Department.type'; + return 'Departments.type'; case 'CoPersonRole.affiliation': - return 'PersonRole.affiliation'; + return 'PersonRoles.affiliation'; } - return $row['attribute']; + // For everything else, we need to pluralize the model name + $bits = explode('.', $row['attribute'], 2); + + return \Cake\Utility\Inflector::pluralize($bits[0]) . "." . $bits[1]; } /** @@ -452,7 +529,7 @@ protected function map_extended_type(array $row) { */ protected function map_identifier_type(array $row) { - return $this->map_type($row, 'Identifier.type', $this->findCoId($row)); + return $this->map_type($row, 'Identifiers.type', $this->findCoId($row)); } /** @@ -464,7 +541,7 @@ protected function map_identifier_type(array $row) { */ protected function map_name_type(array $row) { - return $this->map_type($row, 'Name.type', $this->findCoId($row)); + return $this->map_type($row, 'Names.type', $this->findCoId($row)); } /** diff --git a/app/src/Controller/ApiUsersController.php b/app/src/Controller/ApiUsersController.php index b56797408..afbae8342 100644 --- a/app/src/Controller/ApiUsersController.php +++ b/app/src/Controller/ApiUsersController.php @@ -30,21 +30,6 @@ namespace App\Controller; class ApiUsersController extends StandardController { - protected $permissions = [ - // Actions that operate over an entity (ie: require an $id) - 'entity' => [ - 'delete' => ['platformAdmin', 'coAdmin'], - 'edit' => ['platformAdmin', 'coAdmin'], - 'generate' => ['platformAdmin', 'coAdmin'], - 'view' => ['platformAdmin', 'coAdmin'] - ], - // Actions that operate over a table (ie: do not require an $id) - 'table' => [ - 'add' => ['platformAdmin', 'coAdmin'], - 'index' => ['platformAdmin', 'coAdmin'] - ] - ]; - public $pagination = [ 'order' => [ 'ApiUsers.username' => 'asc' diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index da8b0ea6f..6eeb0c553 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -31,8 +31,10 @@ use \App\Lib\Enum\TemplateableStatusEnum; use App\Lib\Events\ChangelogEventListener; +use App\Lib\Events\CoIdEventListener; use App\Lib\Events\RuleBuilderEventListener; use Cake\Controller\Controller; +use Cake\Core\Configure; use Cake\Datasource\Exception; use Cake\Datasource\Exception\RecordNotFoundException; use Cake\Http\Exception\UnauthorizedException; @@ -80,6 +82,9 @@ public function initialize(): void { $ChangelogEventListener = new ChangelogEventListener($this->RegistryAuth); EventManager::instance()->on($ChangelogEventListener); + $RuleBuilderEventListener = new RuleBuilderEventListener(); + EventManager::instance()->on($RuleBuilderEventListener); + // We use Paginator in the REST API as well $this->loadComponent('Paginator'); @@ -154,6 +159,98 @@ public function beforeRender(\Cake\Event\EventInterface $event) { return parent::beforeRender($event); } + /** + * Default implementation for calculating permissions for standard controllers, + * intended to be overridden by controllers with more speciific requirements. + * + * @since COmanage Registry v5.0.0 + * @param int $id Record ID if relevant, or null + * @return array Array of permissions + */ + + public function calculatePermissions(?int $id): array { + $ret = []; + + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + // Do we have an authenticated user? + $authenticatedUser = (bool)$this->RegistryAuth->getAuthenticatedUser(); + + // Is this user a Platform Administrator? + $platformAdmin = $this->RegistryAuth->isPlatformAdmin(); + + // Is this user a CO Administrator? + $coAdmin = $this->RegistryAuth->isCoAdmin($this->getCOID()); + + // Is this record read only? + $readOnly = false; + + // Pull the table permissions + $permissions = $table->getPermissions(); + + if($id) { + $readOnlyActions = ['view']; + + // Does this table have an isReadOnly call? + + if(method_exists($table, "isReadOnly")) { + // Pull the record so we can interrogate it + + $obj = $table->get($id); + + $readOnly = $table->isReadOnly($obj); + + if(!empty($permissions['readOnly'])) { + // Merge in controller specific actions permitted on read only entities + $readOnlyActions = array_merge($readOnlyActions, $permissions['readOnly']); + } + } + + // Permissions for actions that operate over individual entities + + foreach($permissions['entity'] as $action => $roles) { + $ok = false; + + if(!$readOnly || in_array($action, $readOnlyActions)) { + if(is_array($roles)) { + foreach($roles as $role) { + // eg: $role = "platformAdmin", which corresponds to the variables set, above + if($$role) { + $ok = true; + break; + } + } + } + } + + $ret[$action] = $ok; + } + } else { + // Permissions for actions that operate over tables + + foreach($permissions['table'] as $action => $roles) { + $ok = false; + + if(is_array($roles)) { + foreach($roles as $role) { + // eg: $role = "platformAdmin", which corresponds to the variables set, above + if($$role) { + $ok = true; + break; + } + } + } + + $ret[$action] = $ok; + } + } + + return $ret; + } + /** * Get the current CO. * @@ -226,7 +323,7 @@ protected function getPrimaryLink(bool $lookup=false) { $param = (int)$this->request->getParam('pass.0'); if(!empty($param)) { - $this->cur_pl->value = $this->$modelsName->calculatePrimaryLinkId($param); + $this->cur_pl->value = $this->$modelsName->findPrimaryLinkId($param); } } } elseif($this->request->is('post') && $this->request->getParam('action') != 'delete') { @@ -265,7 +362,7 @@ protected function getPrimaryLink(bool $lookup=false) { $param = (int)$this->request->getParam('pass.0'); if(!empty($param)) { - $this->cur_pl->value = $this->$modelsName->calculatePrimaryLinkId($param); + $this->cur_pl->value = $this->$modelsName->findPrimaryLinkId($param); } } } elseif($this->request->is('put') || $this->request->getParam('action') == 'delete') { @@ -275,7 +372,7 @@ protected function getPrimaryLink(bool $lookup=false) { $param = (int)$this->request->getParam('pass.0'); if(!empty($param)) { - $this->cur_pl->value = $this->$modelsName->calculatePrimaryLinkId($param); + $this->cur_pl->value = $this->$modelsName->findPrimaryLinkId($param); } } } @@ -294,7 +391,14 @@ protected function getPrimaryLink(bool $lookup=false) { $this->set('vv_primary_link_model', $linkModelName); try { - $this->set('vv_primary_link_obj', $linkModel->findById($this->cur_pl->value)->firstOrFail()); + $plObj = $linkModel->findById($this->cur_pl->value)->firstOrFail(); + + $this->set('vv_primary_link_obj', $plObj); + + // While we're here, note the CO since we'll probably need it soon + if(!empty($plObj->co_id)) { + $this->cur_pl->co_id = $plObj->co_id; + } } catch(RecordNotFoundException $e) { $this->llog('error', "Could not find value '" . $this->cur_pl->value . "' for primary link object " . $linkModelName); @@ -307,6 +411,25 @@ protected function getPrimaryLink(bool $lookup=false) { return $this->cur_pl; } + /** + * Get the redirect goal for this table. + * + * @since COmanage Registry v5.0.0 + * @return string Redirect goal + */ + + protected function getRedirectGoal(): string { + // $this->name = Models + $modelsName = $this->name; + + // PrimaryLinkTrait + if(method_exists($this->$modelsName, "getRedirectGoal")) { + return $this->$modelsName->getRedirectGoal(); + } + + return 'index'; + } + /** * Determine the (requested) current CO and make it available to the * rest of the application. @@ -316,8 +439,6 @@ protected function getPrimaryLink(bool $lookup=false) { * @throws \InvalidArgumentException */ -// XXX rewrite this and getPrimaryLink based on Match AppController when we -// have an indirect model (eg: co_person_role) that has a parent other than CO protected function setCO() { if($this->cur_co) { // Nothing to do... @@ -351,11 +472,13 @@ protected function setCO() { // Try to find the requested CO $coid = null; - // If the parent model is CO, then getPrimaryLink has already done our work + // getPrimaryLink has already done our work if($link->attr == 'co_id') { $coid = $link->value; } else { - // XXX map (see Match) + if(!empty($link->co_id)) { + $coid = $link->co_id; + } } if(!$coid @@ -378,6 +501,40 @@ protected function setCO() { if($this->cur_co->status == TemplateableStatusEnum::Active) { $this->set('vv_cur_co', $this->cur_co); } + + // We store the CO ID in Configuration to facilitate its access from + // model contexts such as validation where passing the value via the + // Controller is not particularly feasible. + + // This only works for the current model, not related models. If/when we + // need to support relatedmodels, we could have setCurCoId() cascade the + // CO to any of its related models that require it, or use the event + // listener approach commented out below. + if(method_exists($this->$modelsName, "acceptsCoId") + && $this->$modelsName->acceptsCoId()) { + $this->$modelsName->setCurCoId((int)$coid); + + /* This doesn't work for the current model since it has already been + initialized, but it could be an option for related models later... + (eg when we try to save a name via EIS or EF). But see also the new + approach below. + $CoIdEventListener = new CoIdEventListener($coid); + EventManager::instance()->on($CoIdEventListener);*/ + } + + // Walk through the first level associations and pass the CO ID to them, + // as well. We could ultimately cascade this via the table once we have + // a use case to do so, though note it's possible a child associations + // wants the CO ID even though the parent doesn't. + + foreach($this->$modelsName->associations()->getIterator() as $a) { + $aTable = $a->getTarget(); + + if(method_exists($aTable, "acceptsCoId") + && $aTable->acceptsCoId()) { + $aTable->setCurCoId((int)$coid); + } + } } } diff --git a/app/src/Controller/CoSettingsController.php b/app/src/Controller/CoSettingsController.php new file mode 100644 index 000000000..9fa1c6b95 --- /dev/null +++ b/app/src/Controller/CoSettingsController.php @@ -0,0 +1,52 @@ +CoSettings->find('all', ['conditions' => ['CoSettings.co_id' => $this->getCOID()]])->first(); + + return $this->redirect(['action' => 'edit', $settings->id]); + } +} \ No newline at end of file diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index 1e93536c4..0d873194c 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -53,6 +53,7 @@ use \Cake\Core\Configure; use \Cake\Datasource\Exception\RecordNotFoundException; use \Cake\Event\EventInterface; +use \Cake\Http\Exception\ForbiddenException; use \Cake\Http\Exception\UnauthorizedException; use \Cake\ORM\ResultSet; use \Cake\ORM\TableRegistry; @@ -62,10 +63,10 @@ class RegistryAuthComponent extends Component use \App\Lib\Traits\LabeledLogTrait; // The successfully authenticated user - protected $authenticatedUser = false; + protected ?string $authenticatedUser = null; // Was this an API user? - protected $authenticatedApiUser = false; + protected bool $authenticatedApiUser = false; /** * Authenticate an API User. @@ -101,49 +102,6 @@ protected function authenticateApiUser(): bool { return false; } - /** - * Authorize an API User. - * - * @since COmanage Registry v5.0.0 - * @return bool True if authorization was successful. - * @throws InvalidArgumentException - */ - - protected function authorizeApiUser(EventInterface $event) { - $controller = $event->getSubject(); - - // API authorization works a bit different from UI authorization, in that - // access is generally not Controller specific. - - $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers'); - - try { - // The CO might be NULL if there is no CO ID in the current context - // (eg: /index/cos). In that case, we use CO ID 1 (COmanage CO), which is - // the proxy for "root" access. - - $CO = $controller->getCO(); - - $priv = $ApiUsers->getUserPrivilege($this->authenticatedUser, ($CO ? $CO->id : 1)); - } - catch(\InvalidArgumentException $e) { - // User unknown or similar, probably should have been caught in authenticateApiUser - $this->llog('debug', "User authorization failed: " . $e->getMessage()); - throw $e; - } - - if(!$priv) { - // XXX to deal with unprivileged API users we'll need some mechanism to call - // into the controller (or plugin controller) to allow it to determine if - // we're authorized - - $this->llog('error', "Unprivileged User NOT IMPLEMENTED"); - throw new \InvalidArgumentException("NOT IMPLEMENTED"); - } - - return true; - } - /** * Callback run prior to the request action. * @@ -156,22 +114,37 @@ public function beforeFilter(EventInterface $event) { $request = $controller->getRequest(); $session = $request->getSession(); + $id = null; + $passed = $request->getParam('pass'); + + if(!empty($passed[0])) { + $id = (int)$passed[0]; + } + + // Perform authorization check + if($this->getConfig('apiUser')) { // There are no unauthenticated API calls, so always require a valid user try { if($this->authenticateApiUser()) { - $this->authorizeApiUser($event); + if($this->calculatePermission($request->getParam('action'), $id)) { + // Authorization successful + return true; + } } + + // Permission denied + throw new ForbiddenException(__d('error', 'perm')); } catch(RecordNotFoundException $e) { - // Requested record does not exist. For privileged API users, we can return + // Requested record does not exist. For platform API users, we can return // a RecordNotFoundException, otherwise we recast to generate permission denied. $this->llog('debug', "User authorization failed: " . $e->getMessage()); $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers'); - if($ApiUsers->getUserPrivilege($this->authenticatedUser, 1)) { + if($ApiUsers->getUserPrivilege($this->authenticatedUser) === true) { throw $e; } else { throw new UnauthorizedException(__d('error', 'auth.api.failed')); @@ -205,13 +178,6 @@ public function beforeFilter(EventInterface $event) { $controller->set('vv_user', ['username' => $auth['external']['user']]); $this->authenticatedUser = $auth['external']['user']; - $id = null; - $passed = $request->getParam('pass'); - - if(!empty($passed[0])) { - $id = (int)$passed[0]; - } - if($this->calculatePermission($request->getParam('action'), $id)) { // Authorization successful return true; @@ -270,6 +236,8 @@ public function calculatePermissionsForResultSet(ResultSet $rs): array { // We return an array since this is intended to be passed to a view $ret = []; + // Note these are Cake ORM functions (rewind, current, etc), and not array + // functions that PHP deprecated in 8.1.0. $rs->rewind(); while($rs->valid()) { @@ -305,7 +273,7 @@ public function calculatePermissionsForView(string $action, ?int $id=null): arra * @return string The authenticated user identifier or false if no authenticated user */ - public function getAuthenticatedUser() { + public function getAuthenticatedUser(): string { return $this->authenticatedUser; } @@ -339,7 +307,7 @@ public function getMenuPermissions() { * @return bool True if the current user is an API user */ - public function isApiUser() { + public function isApiUser(): bool { return $this->authenticatedApiUser; } @@ -350,10 +318,18 @@ public function isApiUser() { * @return bool True if the current user is a CO Administrator */ - public function isCoAdmin(?int $coId) { + public function isCoAdmin(?int $coId): bool { + if($this->authenticatedApiUser) { + $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers'); + + $priv = $ApiUsers->getUserPrivilege($this->authenticatedUser); + + return ($priv === true || $priv === $coId); + } else { // XXX hardcoded for now until we've bootstrapped the COmanage CO // XXX we should cache the lookup when we actually do a db query - return ($this->authenticatedUser == 'admin'); + return ($this->authenticatedUser == 'admin'); + } } /** @@ -363,9 +339,15 @@ public function isCoAdmin(?int $coId) { * @return bool True if the current user is a Platform Administrator */ - public function isPlatformAdmin() { + public function isPlatformAdmin(): bool { + if($this->authenticatedApiUser) { + $ApiUsers = TableRegistry::getTableLocator()->get('ApiUsers'); + + return ($ApiUsers->getUserPrivilege($this->authenticatedUser) === true); + } else { // XXX hardcoded for now until we've bootstrapped the COmanage CO // XXX we should cache the lookup when we actually do a db query - return ($this->authenticatedUser == 'admin'); + return ($this->authenticatedUser == 'admin'); + } } } \ No newline at end of file diff --git a/app/src/Controller/CosController.php b/app/src/Controller/CosController.php index f6ee02231..6b52e61f0 100644 --- a/app/src/Controller/CosController.php +++ b/app/src/Controller/CosController.php @@ -36,24 +36,6 @@ use Cake\ORM\TableRegistry; class CosController extends StandardController { - protected $permissions = [ - // Actions that operate over an entity (ie: require an $id) - 'entity' => [ - 'delete' => ['platformAdmin'], - 'duplicate' => ['platformAdmin'], - 'edit' => ['platformAdmin'], - 'view' => ['platformAdmin'] - ], - // Actions that are permitted on readonly entities (besides view) - 'readOnly' => ['duplicate'], - // Actions that operate over a table (ie: do not require an $id) - 'table' => [ - 'add' => ['platformAdmin'], - 'index' => ['platformAdmin'], - 'select' => ['authenticatedUser'] - ] - ]; - public $pagination = [ 'order' => [ 'Cos.name' => 'asc' diff --git a/app/src/Controller/CousController.php b/app/src/Controller/CousController.php index c90fdeba7..f1c954e63 100644 --- a/app/src/Controller/CousController.php +++ b/app/src/Controller/CousController.php @@ -34,20 +34,6 @@ //use \App\Lib\Enum\PermissionEnum; class CousController extends StandardController { - protected $permissions = [ - // Actions that operate over an entity (ie: require an $id) - 'entity' => [ - 'delete' => ['platformAdmin', 'coAdmin'], - 'edit' => ['platformAdmin', 'coAdmin'], - 'view' => ['platformAdmin', 'coAdmin'] - ], - // Actions that operate over a table (ie: do not require an $id) - 'table' => [ - 'add' => ['platformAdmin', 'coAdmin'], - 'index' => ['platformAdmin', 'coAdmin'] - ] - ]; - public $pagination = [ 'order' => [ 'Cous.name' => 'asc' diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index 071e6feb9..074c20275 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -34,24 +34,6 @@ //use \App\Lib\Enum\PermissionEnum; class DashboardsController extends StandardController { - protected $permissions = [ - // Actions that operate over an entity (ie: require an $id) - 'entity' => [ -/* - 'delete' => ['platformAdmin', 'coAdmin'], - 'edit' => ['platformAdmin', 'coAdmin'], - 'view' => ['platformAdmin', 'coAdmin']*/ - ], - // Actions that operate over a table (ie: do not require an $id) - 'table' => [ - 'configuration' => ['platformAdmin', 'coAdmin'], - 'dashboard' => ['platformAdmin', 'coAdmin'] // XXX this is not the correct long term permission -/* 'add' => ['platformAdmin', 'coAdmin'], - 'index' => ['platformAdmin', 'coAdmin'] - */ - ] - ]; - /** * Render the CO Configuration Dashboard. * @@ -96,7 +78,7 @@ public function configuration() { __d('controller', 'CoSettings', [99]) => [ 'icon' => 'settings', 'controller' => 'co_settings', - 'action' => 'add' + 'action' => 'manage' ]], $configMenuItems ); diff --git a/app/src/Controller/MVEAController.php b/app/src/Controller/MVEAController.php new file mode 100644 index 000000000..0801431d4 --- /dev/null +++ b/app/src/Controller/MVEAController.php @@ -0,0 +1,69 @@ +name = Models + $modelsName = $this->name; + + if(!$this->request->is('restful')) { + // Use the PrimaryLink to set information for breadcrumbs + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_primary_link_id', $link->value); + + switch($link->attr) { + case 'person_id': + $Names = TableRegistry::get('Names'); + $this->set('vv_person_name', $Names->primaryName((int)$link->value)); + break; + default; + break; + } + } + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/NamesController.php b/app/src/Controller/NamesController.php new file mode 100644 index 000000000..fb3401b1e --- /dev/null +++ b/app/src/Controller/NamesController.php @@ -0,0 +1,98 @@ + [ + 'Names.family' => 'asc', + 'Names.given' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Get the set of permitted name fields to pass to the view. + // (We don't need required name fields since FormHelper will handle that.) + +// XXX maybe $CoSettings should be available via AppController, like $this->getCOID()? + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + + $this->set('vv_permitted_fields', $settings->name_permitted_fields_array()); + $this->set('vv_default_type', $settings->name_default_type_id); + } + + return parent::beforeRender($event); + } + + /** + * Set a Name as primary. + * + * @since COmanage Registry v5.0.0 + * @param string $id Name ID + * @return \Cake\Http\Response HTTP Response + */ + + public function primary(string $id) { + // All we need to do is set this name to be primary, the model code will + // handle the various Application Rules. + + try { + $query = $this->Names->findById($id); + + // Pull the current record + $obj = $query->firstOrFail(); + + $obj->primary_name = true; + $this->Names->save($obj); + + $this->Flash->success(__d('result', 'Names.primary_name')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect((int)$id); + } +} \ No newline at end of file diff --git a/app/src/Controller/PeopleController.php b/app/src/Controller/PeopleController.php index 7b5687003..540178da5 100644 --- a/app/src/Controller/PeopleController.php +++ b/app/src/Controller/PeopleController.php @@ -31,40 +31,9 @@ // XXX not doing anything with Log yet use Cake\Log\Log; -//use \App\Lib\Enum\PermissionEnum; +use Cake\ORM\TableRegistry; class PeopleController extends StandardController { -// XXX need to update for couadmin - protected $permissions = [ - // Actions that operate over an entity (ie: require an $id) - 'entity' => [ -/* -We should add a more configurable permissions setting that controls CO Person -visibility, probably via CO Settings. eg: - CO Admin - Only CO Admins can see CO Person records - COU Admin - COU Admins can see CO Person records, plus CO Person Role records they manage - Any Admin - Any CO or COU Admin can see any CO Person and CO Person Role record - CO Group - (intended for helpdesk, maybe create a special helpdesk group instead?) Any Admin + members of the Group - - We might also want to introduce a new "Permission" object to abstract this out here - and in other places (like Enrollment Flow Authz). Though Permissions would still be - managed in the relevant UI (eg: CO Settings), the model abstraction would handle - rendering a View Element and processing the Permission at run time - - See also: CO-931, CO-1156, CO-1524 - */ - 'canvas' => ['platformAdmin', 'coAdmin'], - 'delete' => ['platformAdmin', 'coAdmin'], - 'edit' => ['platformAdmin', 'coAdmin'], - 'view' => ['platformAdmin', 'coAdmin'] - ], - // Actions that operate over a table (ie: do not require an $id) - 'table' => [ - 'add' => ['platformAdmin', 'coAdmin'], - 'index' => ['platformAdmin', 'coAdmin'] - ] - ]; - public $pagination = [ 'order' => [ // XXX this will sort by family name, but it this universally correct? @@ -83,14 +52,56 @@ class PeopleController extends StandardController { ]; /** - * Handle a canvas request. + * Callback run prior to the request render. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful') && $this->request->getParam('action') == 'add') { + // Get the set of permitted and required name fields to pass to the view. + + // We need to pull a few settings for default enrollment. +// XXX maybe $CoSettings should be available via AppController, like $this->getCOID()? + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + + $this->set('vv_permitted_name_fields', $settings->name_permitted_fields_array()); + $this->set('vv_required_name_fields', $settings->name_required_fields_array()); + $this->set('vv_default_name_type', $settings->name_default_type_id); + } + + return parent::beforeRender($event); + } + + /** + * Render the Person Canvas. + * + * @since COmanage Registry v5.0.0 + * @param string $id CO Person ID + */ + + public function canvas(string $id) { + // use StandardController::edit to render (and not conflict with edit(), below) + + parent::edit($id); + } + + /** + * Stub function to redirect to canvas. * * @since COmanage Registry v5.0.0 - * @param integer $id CO Person ID + * @param string $id CO Person ID */ - // XXX docblock - public function canvas($id) { - $this->edit($id); + public function edit(string $id) { + // Redirect to /canvas + + return $this->redirect([ + 'action' => 'canvas', + $id + ]); } } \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index f07dc2e33..42c934078 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -31,6 +31,7 @@ use InvalidArgumentException; use \Cake\Http\Exception\BadRequestException; +use \App\Lib\Enum\SuspendableStatusEnum; class StandardController extends AppController { // Pagination defaults should be set in each controller @@ -51,25 +52,31 @@ public function add() { $tableName = $table->getTable(); if($this->request->is('post')) { - // Try to save - $obj = $table->newEntity($this->request->getData()); - - // This throws \Cake\ORM\Exception\RolledbackTransactionException if aborted - // in afterSave - if($table->save($obj)) { - $this->Flash->success(__d('result', 'saved')); + try { + // Try to save + $obj = $table->newEntity($this->request->getData()); + + if($table->save($obj)) { + $this->Flash->success(__d('result', 'saved')); + + return $this->generateRedirect($obj->id); + } - return $this->generateRedirect(null); + $errors = $obj->getErrors(); + + if(!empty($errors)) { + $this->Flash->error(__d('error', 'fields', [ implode(',', + array_map(function($v) { return __d('field', $v); }, + array_keys($errors))) ])); + } else { + $this->Flash->error(__d('error', 'save', [$modelsName])); + } } - - $errors = $obj->getErrors(); - - if(!empty($errors)) { - $this->Flash->error(__d('error', 'fields', [ implode(',', - array_map(function($v) { return __d('field', $v); }, - array_keys($errors))) ])); - } else { - $this->Flash->error(__d('error', 'save', [$modelsName])); + catch(\Exception $e) { + // This throws \Cake\ORM\Exception\RolledbackTransactionException if + // aborted in afterSave + + $this->Flash->error($e->getMessage()); } // Pass $obj as context so the view can render validation errors @@ -80,10 +87,10 @@ public function add() { $this->set('vv_obj', $table->newEmptyEntity()); } - // PrimaryLinkTrait + // PrimaryLinkTrait, via AppController $this->getPrimaryLink(); - // AutoViewVarsTrait + // AutoViewVarsTrait, via AppController $this->populateAutoViewVars(); // Default title is add new object @@ -136,91 +143,6 @@ public function beforeRender(\Cake\Event\EventInterface $event) { return parent::beforeRender($event); } - /** - * Default implementation for calculating permissions for standard controllers, - * intended to be overridden by controllers with more speciific requirements. - * - * @since COmanage Registry v5.0.0 - * @param int $id Record ID if relevant, or null - * @return array Array of permissions - */ - - public function calculatePermissions(?int $id): array { - $ret = []; - - // $this->name = Models (ie: from ModelsTable) - $modelsName = $this->name; - // $table = the actual table object - $table = $this->$modelsName; - - // Do we have an authenticated user? - $authenticatedUser = (bool)$this->RegistryAuth->getAuthenticatedUser(); - - // Is this user a Platform Administrator? - $platformAdmin = $this->RegistryAuth->isPlatformAdmin(); - - // Is this user a CO Administrator? - $coAdmin = $this->RegistryAuth->isCoAdmin($this->getCOID()); - - // Is this record read only? - $readOnly = false; - - if($id) { - $readOnlyActions = ['view']; - - // Does this table have an isReadOnly call? - - if(method_exists($table, "isReadOnly")) { - // Pull the record so we can interrogate it - - $obj = $table->get($id); - - $readOnly = $table->isReadOnly($obj); - - if(!empty($this->permissions['readOnly'])) { - // Merge in controller specific actions permitted on read only entities - $readOnlyActions = array_merge($readOnlyActions, $this->permissions['readOnly']); - } - } - - // Permissions for actions that operate over individual entities - - foreach($this->permissions['entity'] as $action => $roles) { - $ok = false; - - if(!$readOnly || in_array($action, $readOnlyActions)) { - foreach($roles as $role) { - // eg: $role = "platformAdmin", which corresponds to the variables set, above - if($$role) { - $ok = true; - break; - } - } - } - - $ret[$action] = $ok; - } - } else { - // Permissions for actions that operate over tables - - foreach($this->permissions['table'] as $action => $roles) { - $ok = false; - - foreach($roles as $role) { - // eg: $role = "platformAdmin", which corresponds to the variables set, above - if($$role) { - $ok = true; - break; - } - } - - $ret[$action] = $ok; - } - } - - return $ret; - } - /** * Handle a delete action for a Standard object. * @@ -260,7 +182,17 @@ public function delete($id) { } catch(\Cake\ORM\Exception\PersistenceFailedException $e) { // deleteOrFail throws Cake\ORM\Exception\PersistenceFailedException - $this->Flash->error($e->getMessage()); + + // Application Rules that apply to the entity as a whole (or more than + // one field) can use "id" as their errorField, and we'll catch that here. + + $errors = $obj->getErrors(); + + if(!empty($errors['id'])) { + $this->Flash->error(implode(',', array_values($errors['id']))); + } else { + $this->Flash->error($e->getMessage()); + } } catch(\Exception $e) { // findById throws Cake\Datasource\Exception\RecordNotFoundException @@ -290,10 +222,10 @@ public function delete($id) { * Handle an edit action for a Standard object. * * @since COmanage Registry v5.0.0 - * @param Integer $id Object ID + * @param string $id Object ID */ - public function edit($id) { + public function edit(string $id) { // $this->name = Models (ie: from ModelsTable) $modelsName = $this->name; // $table = the actual table object @@ -344,7 +276,7 @@ public function edit($id) { if($table->save($obj)) { $this->Flash->success(__d('result', 'saved')); - return $this->generateRedirect($obj->id); + return $this->generateRedirect((int)$id); } $errors = $obj->getErrors(); @@ -362,7 +294,7 @@ public function edit($id) { // findById throws Cake\Datasource\Exception\RecordNotFoundException $this->Flash->error($e->getMessage()); - return $this->generateRedirect(null); + return $this->generateRedirect((int)$id); } $this->set('vv_obj', $obj); @@ -375,13 +307,19 @@ public function edit($id) { // AutoViewVarsTrait $this->populateAutoViewVars($obj); - // Default view title is edit object display field - $field = $table->getDisplayField(); - - if(!empty($obj->$field)) { - $this->set('vv_title', __d('operation', 'edit.a', $obj->$field)); + if(method_exists($table, 'generateDisplayField')) { + // We don't use a trait for this since each table will implement different logic + + $this->set('vv_title', __d('operation', 'edit.ai', $table->generateDisplayField($obj), $id)); } else { - $this->set('vv_title', __d('operation', 'edit.a', __d('controller', $modelsName, [1]))); + // Default view title is edit object display field + $field = $table->getDisplayField(); + + if(!empty($obj->$field)) { + $this->set('vv_title', __d('operation', 'edit.ai', $obj->$field, $id)); + } else { + $this->set('vv_title', __d('operation', 'edit.ai', __d('controller', $modelsName, [1]), $id)); + } } // Let the view render @@ -399,7 +337,12 @@ public function edit($id) { public function generateRedirect(?int $id) { $redirect = []; - if(in_array($this->request->getParam('action'), ['add', 'edit']) && $id) { + // By default we return to the index, but we'll also accept "self" or "primaryLink". + $redirectGoal = $this->getRedirectGoal(); + + if($redirectGoal == 'self' + && $id + && in_array($this->request->getParam('action'), ['add', 'edit'])) { // Redirect to the edit view of the record just added // (if the user has add permission, they probably have edit permission) @@ -407,6 +350,9 @@ public function generateRedirect(?int $id) { 'action' => 'edit', $id ]; + } elseif($redirectGoal == 'primaryLink') { + // XXX implement me + throw new \RuntimeException('generateRedirect NOT IMPLEMENTED'); } else { // Default is to redirect to the index view $redirect = ['action' => 'index']; @@ -534,10 +480,14 @@ protected function populateAutoViewVars(object $obj=null) { // returns the full object and the latter just returns a hash suitable // for a select. "type" is a shorthand for "select" for type_id. case 'type': - // Inject configuration + // Inject configuration. Since we're only ever looking at the types + // table, inject the current CO along with the requested attribute $avv['model'] = 'Types'; - // We assume the model using type_id has a primary link of co_id - $avv['find'] = 'filterPrimaryLink'; + $avv['where'] = [ + 'co_id' => $this->getCOID(), + 'attribute' => $avv['attribute'], + 'status' => SuspendableStatusEnum::Active + ]; case 'auxiliary': // XXX add list as in match? case 'select': @@ -601,7 +551,7 @@ protected function populateAutoViewVars(object $obj=null) { /** * Handle a view action for a Standard object. * - * @since COmanage Registry v6.0.0 + * @since COmanage Registry v5.0.0 * @param Integer $id Object ID */ @@ -647,9 +597,9 @@ public function view($id = null) { $field = $table->getDisplayField(); if(!empty($obj->$field)) { - $this->set('vv_title', __d('operation', 'view.a', $obj->$field)); + $this->set('vv_title', __d('operation', 'view.ai', $obj->$field, $id)); } else { - $this->set('vv_title', __d('operation', 'view.a', __d('controller', $modelsName, [1]))); + $this->set('vv_title', __d('operation', 'view.ai', __d('controller', $modelsName, [1]), $id)); } // Let the view render diff --git a/app/src/Controller/TypesController.php b/app/src/Controller/TypesController.php index e19e17bec..21f28c3c4 100644 --- a/app/src/Controller/TypesController.php +++ b/app/src/Controller/TypesController.php @@ -33,21 +33,6 @@ use Cake\Log\Log; class TypesController extends StandardController { - protected $permissions = [ - // Actions that operate over an entity (ie: require an $id) - 'entity' => [ - 'delete' => ['platformAdmin', 'coAdmin'], - 'edit' => ['platformAdmin', 'coAdmin'], - 'view' => ['platformAdmin', 'coAdmin'] - ], - // Actions that operate over a table (ie: do not require an $id) - 'table' => [ - 'add' => ['platformAdmin', 'coAdmin'], - 'index' => ['platformAdmin', 'coAdmin'], - 'restore' => ['platformAdmin', 'coAdmin'] - ] - ]; - public $pagination = [ 'order' => [ 'Types.attribute' => 'asc', diff --git a/app/src/Lib/Enum/LanguageEnum.php b/app/src/Lib/Enum/LanguageEnum.php new file mode 100644 index 000000000..d282a1bfb --- /dev/null +++ b/app/src/Lib/Enum/LanguageEnum.php @@ -0,0 +1,79 @@ +getSubject()->getSchema(); + + // We need to skip some metadata fields, including changelog and EIS fks + // changelog + $cl = Inflector::singularize($event->getSubject()->getTable()) . "_id"; + // external identity source + $eis = "source_" . $cl; + + foreach($schema->columns() as $col) { + if(in_array($col, [$cl, $eis])) { + // Skip the changelog key since it will only every have pointed to a + // record within the CO, and the self-dependency confuses the association + // calculation, below + continue; + } + + if($col == 'co_id') { + $rules->addUpdate( + [$this, 'ruleFreezeCO'], + 'freezeCO', + ['errorField' => $col] + ); + } elseif(preg_match('/^.*_id$/', $col)) { +// XXX still need to handle whatever "unfreeze" is going to become + $rules->add( + [$this, 'ruleValidateCO'], + 'validateCO', + ['errorField' => $col] + ); + } + } + + // The documentation is ambiguous as to whether or not we need to return $rules. + // The API docs say yes but the example doesn't have it. + return $rules; + } + + /** + * Define the list of implemented events. + * + * @since COmanage Registry v5.0.0 + * @return array Array of implemented events and associated configuration. + */ + + public function implementedEvents(): array { + return [ + 'Model.buildRules' => [ + 'callable' => 'buildRules', + // We don't currently need to set the priority +// 'priority' => -100 + ] + ]; + } + + /** + * Application Rule to prevent the CO ID of an object from being changed once + * attached. This is more of a Security Rule than an Application Rule, but for + * now we don't distinguish between the two types. + * + * This function arguably belongs in a trait or something, but then any table + * we apply it to needs to add that trait. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleFreezeCO(EntityInterface $entity, array $options) { + // GMR-1 Once an entity is created within a CO, it cannot be moved to + // another co. Note this check is only for the direct foreign key 'co_id', + // all other primary links are checked using ruleValidateCO. + + $want = $entity->get('co_id'); + $have = $entity->getOriginal('co_id'); + + if($want != $have) { + return __d('error', 'coid.frozen'); + } + + return true; + } + + /** + * Application Rule to require foreign keys to be within the same CO as the. + * entity being saved. This is more of a Security Rule than an Application + * Rule, but for now we don't distinguish between the two types. + * + * This function arguably belongs in a trait or something, but then any table + * we apply it to needs to add that trait. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleValidateCO(EntityInterface $entity, array $options) { + // The field to check is (confusingly) $options['errorField']. + // We don't need to check "unfreeze" here since it should be checked in + // buildRules(). + + if(empty($options['errorField'])) { + return __d('error', 'rule.ValidateCo.errorField'); + } + + // The foreign key we are validating + $targetField = $options['errorField']; + // The property name for the fk, which following cake's internal convention + // simply drops the _id. Note this property must also be set in the Table's + // belongsTo association definition. + $targetProperty = substr($targetField, 0, strlen($targetField)-3); + // The table we are validating, eg Name + $table = $options['repository']; + + // Use the table associations to find the correct target table name + $assn = $table->associations()->getByProperty($targetProperty); + + if(empty($assn)) { + // If you're debugging this, you most likely didn't set up your + // associations correctly. + throw new \LogicException("Missing association for $targetProperty in ruleValidateCO"); + } + + // The table holding the foreign key we are validating, eg Type + $targetTable = $assn->getTarget(); + + if(empty($entity->$targetField)) { + // If the foreign key field is blank there's nothing to check + return true; + } + + // First we need to determine the CO of this record. + + $have = $table->calculateCoForRecord($entity); + $want = $targetTable->findCoForRecord($entity->$targetField); + + if($want != $have) { + return __d('error', 'rule.ValidateCo.mismatch', $want, $have); + } + + return true; + } +} diff --git a/app/src/Lib/Traits/MVETrait.php b/app/src/Lib/Traits/MVETrait.php new file mode 100644 index 000000000..79aa9edc1 --- /dev/null +++ b/app/src/Lib/Traits/MVETrait.php @@ -0,0 +1,49 @@ +person_id)) { + return ['person_id' => $this->person_id]; + } elseif(!empty($this->external_identity_id)) { + return ['external_identity_id' => $entity->external_identity_id]; + } else { + throw new \InvalidArgumentException(__d('error', 'notfound.person')); + } + } +} diff --git a/app/src/Lib/Traits/PermissionsTrait.php b/app/src/Lib/Traits/PermissionsTrait.php new file mode 100644 index 000000000..d0238c462 --- /dev/null +++ b/app/src/Lib/Traits/PermissionsTrait.php @@ -0,0 +1,57 @@ +permissions; + } + + /** + * Set the permissions for this model. + * + * @since COmanage Registry v5.0.0 + * @param array $vars Array of permissions + */ + + public function setPermissions(array $perms) { + $this->permissions = $perms; + } +} diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php index caeaca77e..fc6a9d2b2 100644 --- a/app/src/Lib/Traits/PrimaryLinkTrait.php +++ b/app/src/Lib/Traits/PrimaryLinkTrait.php @@ -29,6 +29,9 @@ namespace App\Lib\Traits; +use \Cake\Datasource\EntityInterface; +use \Cake\ORM\TableRegistry; + trait PrimaryLinkTrait { // Primary Link field (eg: model:co_id) private $primaryLink = null; @@ -45,6 +48,24 @@ trait PrimaryLinkTrait { // Actions where the primary link can be obtained by looking up the record ID private $lookupActions = ['delete', 'edit', 'view']; + // Where to redirect on add or edit, can be 'self', 'index', or 'primaryLink' + private $redirectGoal = ['index']; + + // Accept the current CO ID? + private $acceptCoId = false; + protected $curCoId = null; + + /** + * Determine if this table accepts the CO ID via AppController. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this table accepts the CO ID, false otherwise + */ + + public function acceptsCoId(): bool { + return $this->acceptCoId; + } + /** * Whether the primary link is permitted to be empty. * @@ -83,18 +104,42 @@ public function allowUnkeyedPrimaryLink(string $action) { } /** - * Calculate the Primary Link ID associated with the requested object ID. + * Determine the CO for an entity. * * @since COmanage Registry v5.0.0 - * @param int $id Object ID - * @return int Primary Link ID - * @throws Cake\Datasource\Exception\RecordNotFoundException + * @param EntityInterface $entity Entity + * @return int|null CO ID or null if not found */ - public function calculatePrimaryLinkId(int $id) { - $obj = $this->findById($id)->firstOrFail(); + public function calculateCoForRecord(EntityInterface $entity): ?int { + if($this->primaryLink == 'co_id') { + if(!empty($entity->co_id)) { + return $entity->co_id; + } + + return null; + } else { + // Recursively ask the primaryLink until we get an answer + $LinkTable = $this->getPrimaryLinkTable(); + + return $LinkTable->findCoForRecord($entity->{$this->primaryLink}); + } + } + + /** + * Determine the CO for a record based on its ID. + * + * #since COmanage Registry v5.0.0 + * @param int $id Record ID + * @return int|null CO ID or null if not found + */ + + public function findCoForRecord(int $id): ?int { + // Pull tho object to examine the primary links + $query = $this->findById($id); - return $obj->{$this->primaryLink}; + // This will throw an error on failure + return $this->calculateCoForRecord($query->firstOrFail()); } /** @@ -111,7 +156,22 @@ public function findFilterPrimaryLink(\Cake\ORM\Query $query, array $options) { } /** - * Obtain the primary link. + * Calculate the Primary Link ID associated with the requested object ID. + * + * @since COmanage Registry v5.0.0 + * @param int $id Object ID + * @return int Primary Link ID + * @throws Cake\Datasource\Exception\RecordNotFoundException + */ + + public function findPrimaryLinkId(int $id) { + $obj = $this->findById($id)->firstOrFail(); + + return $obj->{$this->primaryLink}; + } + + /** + * Obtain the primary link field. * * @since COmanage Registry v5.0.0 * @return string Primary link attribute @@ -121,6 +181,17 @@ public function getPrimaryLink() { return $this->primaryLink; } + /** + * Obtain the primary link's table. + * + * @since COmanage Registry v5.0.0 + * @return Table Cake Table object + */ + + public function getPrimaryLinkTable() { + return TableRegistry::getTableLocator()->get($this->primaryLinkTable); + } + /** * Obtain the primary link's table name. * @@ -128,10 +199,37 @@ public function getPrimaryLink() { * @return string Primary link table name */ - public function getPrimaryLinkTableName() { + public function getPrimaryLinkTableName(): string { return $this->primaryLinkTable; } + /** + * Obtain this table's redirect goal. + * + * @since COmanage Registry v5.0.0 + * @return string Redirect goal + */ + + public function getRedirectGoal(): string { + return $this->redirectGoal; + } + + /** + * Set whether this table accepts a CO ID, set by AppController. In general, + * tables should NOT use this unless there is no other way to get the CO ID. + * In general, it is preferable to accept the CO ID as a function argument, + * or by calling findCoForRecord or calculateCoForRecord. This functionality + * is for contexts like setting validation rules, where passing in the CO ID + * normally is not possible. + * + * @since COmanage Registry v5.0.0 + * @param bool $accepts true if this table accepts the CO ID, false otherwise + */ + + public function setAcceptsCoId(bool $accepts) { + $this->acceptCoId = $accepts; + } + /** * Set whether the primary link is permitted to be empty. * @@ -154,6 +252,18 @@ public function setAllowLookupPrimaryLink(array $actions) { $this->lookupActions = array_merge($this->lookupActions, $actions); } + /** + * Set the current CO ID. Intended for use with AppController. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + */ + + + public function setCurCoId(int $coId) { + $this->curCoId = $coId; + } + /** * Set the primary link attribute. * @@ -171,13 +281,30 @@ public function setPrimaryLink($field) { } /** - * Set whether the primary link can be asserted directly. + * Set which actions permit a primary link to be passed as a request parameter. + * Defaults to [add, index]. * * @since COmanage Registry v5.0.0 - * @param boolean $allowEmpty true if the primary link can be asserted directly + * @param array $actions Array of actions that permit unkeyed primary links. */ public function setAllowUnkeyedPrimaryLink(array $actions) { $this->unkeyedActions = array_merge($this->unkeyedActions, $actions); } + + /** + * Set the redirect goal for this table. + * + * @since COmanage Registry v5.0.0 + * @param string $goal Redirect goal ('index', 'primaryLink', 'self') + * @throws InvalidArgumentException + */ + + public function setRedirectGoal(string $goal) { + if(!in_array($goal, ['index', 'primaryLink', 'self'])) { + throw new \InvalidArgumentException(__d('error', 'invalid', [$goal])); + } + + $this->redirectGoal = $goal; + } } diff --git a/app/src/Lib/Traits/RulesTrait.php b/app/src/Lib/Traits/RulesTrait.php deleted file mode 100644 index 5a82b5bda..000000000 --- a/app/src/Lib/Traits/RulesTrait.php +++ /dev/null @@ -1,100 +0,0 @@ -addUpdate( - [$this, 'ruleFreezePrimaryLink'], - 'freezePrimaryLink', - ['errorField' => $this->getPrimaryLink()] - ); - } - - // Add table specific rules - - if(method_exists($this, "buildTableRules")) { - $this->buildTableRules($rules); - } - - return $rules; - } - - // Only Application Rules that apply to multiple Tables should be defined - // here, so as not to create noise in this file or add unnecessary functions. - - /** - * Application Rule to reject changes to the primary link. This is more of a - * Security Rule than an Application Rule, but for now we don't distinguish - * between the two types. - * - * @since COmanage Registry v5.0.0 - * @param Entity $entity Entity to be validated - * @param array $options Application rule options - * @return boolean true if the Rule check passes, false otherwise - */ - - public function ruleFreezePrimaryLink($entity, $options) { - $want = $entity->get($this->getPrimaryLink()); - $have = $entity->getOriginal($this->getPrimaryLink()); - - // If the two values differ throw an error. Note this should only be called - // on update(), so we shouldn't need to check the original for null (as it - // might be on add). - - if($want !== $have) { - return __d('error', 'fields.primary_link', [$this->getPrimaryLink()]); - } - - return true; - } -} \ No newline at end of file diff --git a/app/src/Lib/Traits/ValidationTrait.php b/app/src/Lib/Traits/ValidationTrait.php index c53862d7b..c6246e88f 100644 --- a/app/src/Lib/Traits/ValidationTrait.php +++ b/app/src/Lib/Traits/ValidationTrait.php @@ -25,15 +25,83 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -/** - * THIS FILE IS MASTERED IN THE COMMON REPOSITORY. - */ - declare(strict_types = 1); namespace App\Lib\Traits; +use Cake\Core\Configure; +use Cake\ORM\TableRegistry; + trait ValidationTrait { + /** + * Verify that $value is a valid + * + * @since COmanage Registry v5.0.0 + * @param string $value Value to validate + * @param array $context Validation context + * @return mixed True if $value validates, or an error string otherwise + */ + + public function validateCO($value, array $context) { + // Verify that $value is a valid record in the current CO + + // We read the CO ID as set in Configure via AppController. Alternately, we + // could create an event listener (like ChangelogEventListener) that listens + // for buildValidator and injects the CO ID into the Validator object, but + // then every Table's validationDefault() would need to parse the CO ID and + // inject it into the validation configuration, which seems like a lot of + // extra work. + + // The CO of the record we are validating + $thisCoId = null; + + // The CO of the record we are pointing to, via the field being validated + $targetCoId = null; + + if(!empty($context['data']['co_id'])) { + // Accept the co_id in the request data + $thisCoId = $context['data']['co_id']; + } elseif(!empty($context['data']['id'])) { + // We can use findCoForRecord to get the CO in context + $thisCoId = $this->findCoForRecord($context['data']['id']); + } elseif(method_exists($this, 'getPrimaryLink') + && $this->getPrimaryLink() != null + && !empty($context['data'][$this->getPrimaryLink()])) { + // This is probably a new record being added (which we could verify via + // $context['newRecord']). We can't directly use findCoForRecord, but + // we can use the primary link to get the CO. + + $LinkTable = $this->getPrimaryLinkTable(); + + $thisCoId = $LinkTable->findCoForRecord((int)$context['data'][$this->getPrimaryLink()]); + } else { + return __d('error', 'coid'); + } + + if(!$thisCoId) { + return __d('error', 'coid'); + } + + // Calculate the table name for the requested field + if(preg_match('/^(.*?)_id$/', $context['field'], $f)) { + $tableName = \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[1])); + + $Table = TableRegistry::getTableLocator()->get($tableName); + + $targetCoId = $Table->findCoForRecord((int)$value); + } + + if(!$targetCoId) { + return __d('error', 'coid'); + } + + if($thisCoId != $targetCoId) { + return __d('error', 'coid.mismatch', [$this->name, $value]); + } + + return true; + } + /** * Perform a conditional validation check, where if a select value matches an * array of values, then another field must not be empty. In theory we should be @@ -51,7 +119,7 @@ trait ValidationTrait { * which will result in the validation error being unintuitively placed on the * "wrong" attribute. * - * @since COmanage Common v1.0.0 + * @since COmanage Registry v5.0.0 * @param string $value Value to validate * @param array $context Validation context * @return mixed True if $value validates, or an error string otherwise @@ -73,7 +141,7 @@ public function validateConditionalRequire($value, array $context) { /** * Determine if a string submitted from a form is valid input. * - * @since COmanage Common v1.0.0 + * @since COmanage Registry v5.0.0 * @param string $value Value to validate * @param array $context Validation context * @return mixed True if $value validates, or an error string otherwise @@ -107,30 +175,10 @@ public function validateInput($value, array $context) { return true; } - /** - * Determine if a string submitted from a form is a valid language. - * - * @since COmanage Common v1.0.0 - * @param string $value Value to validate - * @param array $context Validation context - * @return mixed True if $value validates, or an error string otherwise - */ - - public function validateLanguage($value, array $context) { -// XXX this was previously done by examining $cm_texts[$cm_lang]['en.language'] -// we need a new way to enumerate permitted language codes - /* - if(!in_array($value, array_values(timezone_identifiers_list()))) { - return __($COmponent.'.er.input.invalid'); - }*/ - - return true; - } - /** * Determine if a string submitted from a form is valid SQL identifier. * - * @since COmanage Common v1.0.0 + * @since COmanage Registry v5.0.0 * @param string $value Value to validate * @param array $context Validation context * @return mixed True if $value validates, or an error string otherwise @@ -155,7 +203,7 @@ public function validateSqlIdentifier($value, array $context) { /** * Determine if a string submitted from a form is a valid timezone. * - * @since COmanage Common v1.0.0 + * @since COmanage Registry v5.0.0 * @param string $value Value to validate * @param array $context Validation context * @return mixed True if $value validates, or an error string otherwise diff --git a/app/src/Model/Entity/Co.php b/app/src/Model/Entity/Co.php index 6db629df6..e94862e2d 100644 --- a/app/src/Model/Entity/Co.php +++ b/app/src/Model/Entity/Co.php @@ -37,4 +37,15 @@ class Co extends Entity { 'id' => false, 'slug' => false, ]; + + /** + * Determine if this entity is the COmanage CO. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this entity is the COmanage CO, false otherwise + */ + + public function isCOmanageCO(): bool { + return (strtolower($this->name) == 'comanage'); + } } \ No newline at end of file diff --git a/app/src/Model/Entity/CoSetting.php b/app/src/Model/Entity/CoSetting.php new file mode 100644 index 000000000..ad97f58f9 --- /dev/null +++ b/app/src/Model/Entity/CoSetting.php @@ -0,0 +1,65 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Obtain the set of fields permitted for names, as an array. + * + * @since COmanage Registry v5.0.0 + * @return array Arroy of permitted name fields + */ + + public function name_permitted_fields_array(): array { + return explode(",", $this->name_permitted_fields); + } + + /** + * Obtain the set of fields required for names, as an array. + * + * @since COmanage Registry v5.0.0 + * @return array Arroy of required name fields + */ + + public function name_required_fields_array(): array { + return explode(",", $this->name_required_fields); + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Name.php b/app/src/Model/Entity/Name.php index 81845bb1a..8058995ca 100644 --- a/app/src/Model/Entity/Name.php +++ b/app/src/Model/Entity/Name.php @@ -32,6 +32,8 @@ use Cake\ORM\Entity; class Name extends Entity { + use \App\Lib\Traits\MVETrait; + protected $_accessible = [ '*' => true, 'id' => false, @@ -39,18 +41,23 @@ class Name extends Entity { ]; /** - * Generate a common (full) name. + * Generate a full (common) name. * * @since COmanage Registry v5.0.0 * @param bool $showHonorific If true, return honorific as part of name * @return string Formatted name */ - protected function _getCommonName($showHonorific = false) { - // Name order is a bit tricky. We'll use the language encoding as our hint, - // although it isn't perfect. This could be replaced with a more sophisticated - // test as requirements evolve. - + protected function _getFullName($showHonorific = false) { + // AR-Name-2 If there is a display name set, use it as the full name. + if(!empty($this->display_name)) { + return $this->display_name; + } + + // AR-Name-3 Name order is a bit tricky. We'll use the language encoding as + // our hint, although it isn't perfect. This could be replaced with a more + // sophisticatedtest as requirements evolve. + $cn = ""; if(empty($this->language) @@ -90,4 +97,26 @@ protected function _getCommonName($showHonorific = false) { return $cn; } + + /** + * Determine if this is not a Primary Name. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is not a Primary Name, false otherwise. + */ + + public function notPrimary(): bool { + return !$this->primary_name; + } + + /** + * Generate a suitable label for rendering if this is a Primary Name. + * + * @since COmanage Registry v5.0.0 + * @return string Display label + */ + + public function primaryLabel(): string { + return ($this->primary_name ? __d('field', 'primary_name') : ""); + } } \ No newline at end of file diff --git a/app/src/Model/Table/ApiUsersTable.php b/app/src/Model/Table/ApiUsersTable.php index 05103f742..ffb4bf2dd 100644 --- a/app/src/Model/Table/ApiUsersTable.php +++ b/app/src/Model/Table/ApiUsersTable.php @@ -42,8 +42,8 @@ class ApiUsersTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; - use \App\Lib\Traits\RulesTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -78,6 +78,21 @@ public function initialize(array $config): void { 'class' => 'SuspendableStatusEnum' ] ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'generate' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); } /** @@ -139,19 +154,26 @@ public function generateKey(int $id) { * * @since COmanage Registry v5.0.0 * @param string $username API Username - * @param int $coId CO ID - * @return boolean True if $username is a privileged API user, false otherwise + * @return mixed true if $username is a platform API user, an integer (the CO ID) if the user is a privileged API user within that CO, or false otherwise * @throws InvalidArgumentException */ - public function getUserPrivilege(string $username, int $coId) { - $apiUser = $this->find()->where(['username' => $username])->first(); +// public function getUserPrivilege(string $username): mixed { +// mixed requires PHP 8 + public function getUserPrivilege(string $username) { + $apiUser = $this->find()->where(['username' => $username])->contain('Cos')->first(); if(empty($apiUser)) { throw new \InvalidArgumentException(__d('error', 'auth.api.unknown', [$username])); } - return $apiUser->privileged; + if($apiUser->co->isCOmanageCO()) { + return true; + } elseif($apiUser->privileged) { + return $apiUser->co_id; + } + + return false; } /** @@ -310,10 +332,7 @@ public function validationDefault(Validator $validator): Validator { $validator->add( 'status', 'content', - [ 'rule' => [ 'inList', [ - SuspendableStatusEnum::Active, - SuspendableStatusEnum::Suspended - ] ] ] + [ 'rule' => [ 'inList', SuspendableStatusEnum::getConstValues() ] ] ); $validator->notEmpty('status'); diff --git a/app/src/Model/Table/CoSettingsTable.php b/app/src/Model/Table/CoSettingsTable.php new file mode 100644 index 000000000..3036769ae --- /dev/null +++ b/app/src/Model/Table/CoSettingsTable.php @@ -0,0 +1,246 @@ +addBehavior('Changelog'); + $this->addBehavior('Timestamp'); + + // CO Settings are (a special type of) configuration + $this->setIsConfigurationTable(true); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('Types') + ->setForeignKey('name_default_type_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('name_default_type'); + + $this->setDisplayField('co_id'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + $this->setAllowUnkeyedPrimaryCO(['manage']); + $this->setRedirectGoal('self'); + + $this->setAutoViewVars([ + 'addressRequiredFields' => [ + 'type' => 'enum', + 'class' => 'RequiredAddressFieldsEnum' + ], + 'nameDefaultTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ], + 'namePermittedFields' => [ + 'type' => 'enum', + 'class' => 'PermittedNameFieldsEnum' + ], + 'nameRequiredFields' => [ + 'type' => 'enum', + 'class' => 'RequiredNameFieldsEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id). Since each CO's + // CoSetting is created during CO Setup, admins can only edit. + 'entity' => [ + 'delete' => false, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] // Required for REST API + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'], // Required for REST API + 'manage' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Add default settings to a CO. Intended for use at CO Setup. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @return int CoSettings ID + */ + + public function addDefaults(int $coId): int { + // Default values for each setting + + $defaultSettings = array( + 'co_id' => $coId, + 'address_required_fields' => RequiredAddressFieldsEnum::Street, + 'name_default_type_id' => null, + 'name_permitted_fields' => PermittedNameFieldsEnum::HGMFS, + 'name_required_fields' => RequiredNameFieldsEnum::Given +// XXX to add new settings, set a default here, then add a validation rule below +// also update data model documentation + // 'disable_expiration' => false, + // 'disable_ois_sync' => false, + // 'enable_normalization' => true, + // 'enable_nsf_demo' => false, + // 'group_validity_sync_window' => DEF_GROUP_SYNC_WINDOW, + // 'invitation_validity' => DEF_INV_VALIDITY, + // 'garbage_collection_interval' => DEF_GARBAGE_COLLECT_INTERVAL, + // 'permitted_fields_name' => PermittedNameFieldsEnum::HGMFS, + // 'required_fields_addr' => RequiredAddressFieldsEnum::Street, + // 'required_fields_name' => RequiredNameFieldsEnum::Given, + // 'sponsor_co_group_id' => null, + // 'sponsor_eligibility' => SponsorEligibilityEnum::CoOrCouAdmin, + // 't_and_c_login_mode' => TAndCLoginModeEnum::NotEnforced, + // 'enable_empty_cou' => false, + // 'theme_stacking' => SuspendableStatusEnum::Suspended, + // 'co_theme_id' => null, + // 'global_search_limit' => DEF_GLOBAL_SEARCH_LIMIT + ); + + $obj = $this->newEntity($defaultSettings); + + $this->save($obj); + + return $obj->id; + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param CoSetting $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\CoSetting $entity): string { + return __d('controller', 'CoSettings', [99]); + } + + /** + * Determine if a requested Type is in use as a default via CoSettings. + * + * @since COmanage Registry v5.0.0 + * @param int $id Type ID + * @return bool true if the type is in use as a default, false otherwise + */ + + public function typeIsDefault(int $id): bool { + // We actually don't need to care which type we're being asked about, since + // $id can only resolve to a single type (as the primary key for the types + // table). We simply see if $id is in any _default_type_id field. + + $orclause = []; + + foreach($this->getSchema()->columns() as $col) { + if(preg_match('/_default_type_id$/', $col)) { + $orclause[] = [$col => $id]; + } + } + + $count = $this->find('all')->where(['OR' => $orclause])->count(); + + return (bool)$count; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $validator->add( + 'address_required_fields', + 'content', + [ 'rule' => [ 'inList', RequiredAddressFieldsEnum::getConstValues() ] ] + ); + $validator->notEmptyString('address_required_fields'); + + $validator->add( + 'name_default_type_id', + 'content', + [ 'rule' => 'isInteger' ] + ); + $validator->allowEmptyString('name_default_type_id'); + + $validator->add( + 'name_permitted_fields', + 'content', + [ 'rule' => [ 'inList', PermittedNameFieldsEnum::getConstValues() ] ] + ); + $validator->notEmptyString('name_permitted_fields'); + + $validator->add( + 'name_required_fields', + 'content', + [ 'rule' => [ 'inList', RequiredNameFieldsEnum::getConstValues() ] ] + ); + $validator->notEmptyString('name_required_fields'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/CosTable.php b/app/src/Model/Table/CosTable.php index e5e0b3216..673452a9a 100644 --- a/app/src/Model/Table/CosTable.php +++ b/app/src/Model/Table/CosTable.php @@ -39,6 +39,7 @@ class CosTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -50,9 +51,9 @@ class CosTable extends Table { */ public function initialize(array $config): void { - // Timestamp behavior handles created/modified updates $this->addBehavior('Changelog'); $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates $this->addBehavior('Timestamp'); // COs are configuration @@ -62,16 +63,19 @@ public function initialize(array $config): void { $this->hasMany('ApiUsers') ->setDependent(true); - $this->hasMany('CoPeople') - ->setDependent(true) - ->setCascadeCallbacks(true); $this->hasMany('Cous') ->setDependent(true); $this->hasMany('Dashboards') ->setDependent(true); + $this->hasMany('People') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Types') ->setDependent(true); + $this->hasOne('CoSettings') + ->setDependent(true); + $this->setDisplayField('name'); $this->setAutoViewVars([ @@ -80,6 +84,24 @@ public function initialize(array $config): void { 'class' => 'TemplateableStatusEnum' ] ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin'], + 'duplicate' => ['platformAdmin'], + 'edit' => ['platformAdmin'], + 'view' => ['platformAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['duplicate'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin'], + 'index' => ['platformAdmin'], + 'select' => ['authenticatedUser'] + ] + ]); } /** @@ -153,13 +175,13 @@ public function duplicate($id) { public function isReadOnly($entity) { // The COmanage CO is read only - return ($entity->name == 'COmanage'); + return $entity->isCOmanageCO(); } /** * Application Rule to determine if the current entity is the COmanage CO. * - * @since COmanage Registyr v5.0.0 + * @since COmanage Registry v5.0.0 * @param Entity $entity Entity to be validated * @param array $options Application rule options * @return boolean true if the Rule check passes, false otherwise @@ -167,7 +189,7 @@ public function isReadOnly($entity) { public function ruleIsCOmanageCO($entity, $options) { // We want negative logic since we want to fail if we're editing the COmanage CO - if($entity->name == 'COmanage') { + if($entity->isCOmanageCO()) { return __d('error', 'edit.comanage'); } @@ -195,20 +217,25 @@ public function ruleIsActive($entity, $options) { /** * Perform initial setup for a CO. * - * @since COmanage Registry v0.9.2 + * @since COmanage Registry v5.0.0 * @param int $id CO ID * @return bool True on success */ public function setup(int $id) { - $Type = TableRegistry::getTableLocator()->get('Types'); + $Types = TableRegistry::getTableLocator()->get('Types'); // AR-Type-1 Set up the default values for extended types - $Type->addDefaults($id); + $Types->addDefaults($id); // Create the default groups // $this->CoGroup->addDefaults($coId); + // Set up the default settings + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $CoSettings->addDefaults($id); + return true; } @@ -232,7 +259,7 @@ public function validationDefault(Validator $validator): Validator { [ 'rule' => [ 'validateInput' ], 'provider' => 'table' ] ); - $validator->notEmpty('name'); + $validator->notEmptyString('name'); $validator->add( 'description', @@ -245,18 +272,14 @@ public function validationDefault(Validator $validator): Validator { [ 'rule' => [ 'validateInput' ], 'provider' => 'table' ] ); - $validator->allowEmpty('description'); + $validator->allowEmptyString('description'); $validator->add( 'status', 'content', - [ 'rule' => [ 'inList', [ - TemplateableStatusEnum::Active, - TemplateableStatusEnum::Suspended, - TemplateableStatusEnum::Template - ] ] ] + [ 'rule' => [ 'inList', TemplateableStatusEnum::getConstValues() ] ] ); - $validator->notEmpty('status'); + $validator->notEmptyString('status'); return $validator; } diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index ed2c6a9b5..77a33fb6d 100644 --- a/app/src/Model/Table/CousTable.php +++ b/app/src/Model/Table/CousTable.php @@ -38,8 +38,8 @@ class CousTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; - use \App\Lib\Traits\RulesTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -62,11 +62,30 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); + $this->belongsTo('Cous') + ->setForeignKey('parent_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('parent'); $this->setDisplayField('name'); $this->setPrimaryLink('co_id'); $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); } /** @@ -160,7 +179,7 @@ public function validationDefault(Validator $validator): Validator { 'content', [ 'rule' => 'isInteger' ] ); - $validator->notEmpty('co_id'); + $validator->notEmptyString('co_id'); $validator->add( 'name', @@ -173,7 +192,7 @@ public function validationDefault(Validator $validator): Validator { [ 'rule' => [ 'validateInput' ], 'provider' => 'table' ] ); - $validator->notEmpty('name'); + $validator->notEmptyString('name'); $validator->add( 'description', @@ -186,28 +205,28 @@ public function validationDefault(Validator $validator): Validator { [ 'rule' => [ 'validateInput' ], 'provider' => 'table' ] ); - $validator->allowEmpty('description'); + $validator->allowEmptyString('description'); $validator->add( 'parent_id', 'content', [ 'rule' => 'isInteger' ] ); - $validator->allowEmpty('parent_id'); + $validator->allowEmptyString('parent_id'); $validator->add( 'lft', 'content', [ 'rule' => 'isInteger' ] ); - $validator->allowEmpty('lft'); + $validator->allowEmptyString('lft'); $validator->add( 'rght', 'content', [ 'rule' => 'isInteger' ] ); - $validator->allowEmpty('rght'); + $validator->allowEmptyString('rght'); return $validator; } diff --git a/app/src/Model/Table/DashboardsTable.php b/app/src/Model/Table/DashboardsTable.php index bb122a092..55b45fed8 100644 --- a/app/src/Model/Table/DashboardsTable.php +++ b/app/src/Model/Table/DashboardsTable.php @@ -33,8 +33,8 @@ class DashboardsTable extends Table { use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; - use \App\Lib\Traits\RulesTrait; use \App\Lib\Traits\TableMetaTrait; /** @@ -61,5 +61,23 @@ public function initialize(array $config): void { $this->setPrimaryLink('co_id'); $this->setRequiresCO(true); $this->setAllowUnkeyedPrimaryCO(['configuration', 'dashboard']); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + /* + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin']*/ + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'configuration' => ['platformAdmin', 'coAdmin'], + 'dashboard' => ['platformAdmin', 'coAdmin'] // XXX this is not the correct long term permission + /* 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + */ + ] + ]); } } \ No newline at end of file diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index b327f26cc..43a8fa0e9 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -32,13 +32,15 @@ use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; +use Cake\ORM\TableRegistry; use Cake\Validation\Validator; +use \App\Lib\Enum\LanguageEnum; class NamesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; - use \App\Lib\Traits\RulesTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; @@ -72,14 +74,161 @@ public function initialize(array $config): void { $this->setIsConfigurationTable(false); // Define associations - $this->belongsTo('CoPeople'); - $this->belongsTo('OrgIdentity'); + $this->belongsTo('People'); + $this->belongsTo('ExternalIdentity'); + $this->belongsTo('Types'); -// XXX can we make this a function (generateCn)? - $this->setDisplayField('given'); + $this->setDisplayField('full_name'); - $this->setPrimaryLink('co_person_id'); +// XXX note primary link is external_identity_id when set... + $this->setPrimaryLink('person_id'); + $this->setAllowLookupPrimaryLink(['primary']); $this->setRequiresCO(true); + $this->setAcceptsCoId(true); + + $this->setAutoViewVars([ + 'languages' => [ + 'type' => 'enum', + 'class' => 'LanguageEnum' + ], + 'types' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'primary' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Callback after model save. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + */ + + public function afterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + // AR-Name-1 A Person must have exactly one Primary Name at all times. + // To enforce this, if the current $entity is flagged Primary Name, AND + // the current entity is new or was not previously the Primary Name, we look + // for any other names on the same Person or External Identity that are + // flagged Primary and unset them. + + if($entity->primary_name + // If we have a parent, we're creating a changelog archive, which we don't want to modify + && !$entity->name_id) { + if($entity->isNew() || !$entity->getOriginal('primary_name')) { + // We either have a brand new name flagged as primary, or a previously + // existing name that has been updated to be primary. Unset any other primary_name. + + $where = array_merge( + $entity->whereClause(), + [ + 'id IS NOT' => $entity->id, + 'primary_name' => true, + ] + ); + + // We can use the ORM here since we are unsetting primary_name, which + // means we won't get into an infinite callback loop. (Meanwhile, we do + // want other callbacks, in particular ChangelogBehavior, to run.) + + $query = $this->find('all')->where($where); + + foreach($query->all() as $obj) { + $obj->primary_name = false; + $this->save($obj); + } + } + } + + return true; + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-Name-4 Each Person or ExternalIdentity must have at least one name at + // all times. + $rules->addDelete([$this, 'ruleMinimumOneName'], + 'minimumOneName', + // This rule is really an entity rule, not a field rule, + // but cake won't pass the error without a specific field + ['errorField' => 'id']); + + return $rules; + } + + /** + * Determine if this is a Read Only record. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ +// XXX this should move to the entity directly + + public function isReadOnly($entity) { + // Names pipelined from an EIS are read only + + return !empty($entity->source_name_id); + } + + /** + * Obtain the primary name entity for a person. + * + * @since COmanage Registry v5.0.0 + * @param int $personId Person ID + * @return Name Name Entity + */ + + public function primaryName(int $personId) { + return $this->find() + ->where(['person_id' => $personId, + 'primary_name' => true]) + ->firstOrFail(); + } + + /** + * Application Rule to determine if there is at least one Name associated + * with the Person. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleMinimumOneName($entity, $options) { + $count = $this->find()->where($entity->whereClause())->count(); + + if($count == 1) { + return __d('error', 'Names.minimum'); + } + + return true; } /** @@ -88,18 +237,33 @@ public function initialize(array $config): void { * @since COmanage Registry v5.0.0 * @param Validator $validator Validator * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException */ public function validationDefault(Validator $validator): Validator { + // We need the current CO ID to dynamically set validation rules according + // to CoSettings. + + if(!$this->curCoId) { + throw new \InvalidArgumentException(__d('error', 'coid')); + } + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->curCoId])->firstOrFail(); + + $permittedFields = $settings->name_permitted_fields_array(); + $requiredFields = $settings->name_required_fields_array(); + // One of CO Person ID or Org Identity ID is required -// XXX Test this via the API? XXX we don't want to allow these to be reassigned $validator->add( 'person_id', 'content', [ 'rule' => 'isInteger' ] ); - $validator->notEmpty('co_person_id', null, function($context) { - return empty($context['data']['org_identity_id']); + $validator->notEmptyString('person_id', null, function($context) { + return empty($context['data']['external_identity_id']); }); $validator->add( @@ -107,110 +271,133 @@ public function validationDefault(Validator $validator): Validator { 'content', [ 'rule' => 'isInteger' ] ); - $validator->notEmpty('org_identity_id', null, function($context) { - return empty($context['data']['co_person_id']); + $validator->notEmptyString('external_identity_id', null, function($context) { + return empty($context['data']['person_id']); }); - $validator->add( - 'honorific', - 'length', - [ 'rule' => [ 'maxLength', 32 ] ] - ); - $validator->add( - 'honorific', - 'content', - [ 'rule' => [ 'validateInput' ], - 'provider' => 'table' ] - ); - $validator->allowEmpty('honorific'); + if(in_array('honorific', $permittedFields)) { + $validator->add( + 'honorific', + 'length', + [ 'rule' => [ 'maxLength', 32 ] ] + ); + $validator->add( + 'honorific', + 'content', + [ 'rule' => [ 'validateInput' ], + 'provider' => 'table' ] + ); + $validator->allowEmptyString('honorific'); + } - $validator->add( - 'given', - 'length', - [ 'rule' => [ 'maxLength', 128 ] ] - ); - $validator->add( - 'given', - 'content', - [ 'rule' => [ 'validateInput' ], - 'provider' => 'table' ] - ); - $validator->notEmpty('given'); + if(in_array('given', $permittedFields)) { + $validator->add( + 'given', + 'length', + [ 'rule' => [ 'maxLength', 128 ] ] + ); + $validator->add( + 'given', + 'content', + [ 'rule' => [ 'validateInput' ], + 'provider' => 'table' ] + ); + if(in_array('given', $requiredFields)) { + $validator->notEmptyString('given'); + } else { + $validator->allowEmptyString('given'); + } + } - $validator->add( - 'middle', - 'length', - [ 'rule' => [ 'maxLength', 128 ] ] - ); - $validator->add( - 'middle', - 'content', - [ 'rule' => [ 'validateInput' ], - 'provider' => 'table' ] - ); - $validator->allowEmpty('middle'); + if(in_array('middle', $permittedFields)) { + $validator->add( + 'middle', + 'length', + [ 'rule' => [ 'maxLength', 128 ] ] + ); + $validator->add( + 'middle', + 'content', + [ 'rule' => [ 'validateInput' ], + 'provider' => 'table' ] + ); + $validator->allowEmptyString('middle'); + } - $validator->add( - 'family', - 'length', - [ 'rule' => [ 'maxLength', 128 ] ] - ); - $validator->add( - 'family', - 'content', - [ 'rule' => [ 'validateInput' ], - 'provider' => 'table' ] - ); - $validator->allowEmpty('family'); + if(in_array('family', $permittedFields)) { + $validator->add( + 'family', + 'length', + [ 'rule' => [ 'maxLength', 128 ] ] + ); + $validator->add( + 'family', + 'content', + [ 'rule' => [ 'validateInput' ], + 'provider' => 'table' ] + ); + if(in_array('family', $requiredFields)) { + $validator->notEmptyString('family'); + } else { + $validator->allowEmptyString('family'); + } + } - $validator->add( - 'suffix', - 'length', - [ 'rule' => [ 'maxLength', 32 ] ] - ); - $validator->add( - 'suffix', - 'content', - [ 'rule' => [ 'validateInput' ], - 'provider' => 'table' ] - ); - $validator->allowEmpty('suffix'); + if(in_array('suffix', $permittedFields)) { + $validator->add( + 'suffix', + 'length', + [ 'rule' => [ 'maxLength', 32 ] ] + ); + $validator->add( + 'suffix', + 'content', + [ 'rule' => [ 'validateInput' ], + 'provider' => 'table' ] + ); + $validator->allowEmptyString('suffix'); + } $validator->add( 'type_id', 'content', [ 'rule' => 'isInteger' ] ); - $validator->add( - 'type_id', - 'content', -// XXX maybe this should be more generic? validateCO? - [ 'rule' => [ 'validateType' ], - 'provider' => 'table' ] - ); - $validator->notEmpty('type_id'); + $validator->notEmptyString('type_id'); $validator->add( 'language', 'content', - [ 'rule' => [ 'validateLanguage' ], - 'provider' => 'table' ] + [ 'rule' => [ 'inList', LanguageEnum::getConstValues() ] ] ); - $validator->allowEmpty('language'); + $validator->allowEmptyString('language'); $validator->add( 'primary_name', 'content', [ 'rule' => [ 'boolean' ] ] ); - $validator->allowEmpty('primary_name'); + $validator->allowEmptyString('primary_name'); + + $validator->add( + 'display_name', + 'length', + [ 'rule' => [ 'maxLength', 256 ] ] + ); + $validator->add( + 'display_name', + 'content', + [ 'rule' => [ 'validateInput' ], + 'provider' => 'table' ] + ); + $validator->allowEmptyString('display_name'); $validator->add( 'source_name_id', 'content', [ 'rule' => 'isInteger' ] ); - $validator->allowEmpty('source_name_id'); + $validator->allowEmptyString('source_name_id'); return $validator; } diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 2af4b48b7..504f6b752 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -38,9 +38,9 @@ class PeopleTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\QueryModificationTrait; - use \App\Lib\Traits\RulesTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -76,6 +76,7 @@ public function initialize(array $config): void { $this->setPrimaryLink('co_id'); $this->setRequiresCO(true); $this->setAllowLookupPrimaryLink(['canvas']); + $this->setRedirectGoal('self'); // XXX does some of this stuff really belong in the controller? $this->setEditContains(['PrimaryName']); @@ -88,9 +89,41 @@ public function initialize(array $config): void { ], 'types' => [ 'type' => 'type', - 'where' => ['attribute' => 'Name.type'] + 'attribute' => 'Names.type' ] ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) +// See also CFM-126 + 'entity' => [ + 'canvas' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param Person $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\Person $entity): string { + if(empty($entity->primary_name)) { + throw new \InvalidArgumentException(__d('error', 'Names.primary_name')); + } + + return $entity->primary_name->full_name; } /** @@ -107,19 +140,14 @@ public function validationDefault(Validator $validator): Validator { 'content', [ 'rule' => 'isInteger' ] ); - $validator->notEmpty('co_id'); + $validator->notEmptyString('co_id'); $validator->add( 'status', 'content', [ 'rule' => [ 'inList', StatusEnum::getConstValues() ]] -/* [ 'rule' => [ 'inList', [ - TemplateableStatusEnum::Active, - TemplateableStatusEnum::Suspended, - TemplateableStatusEnum::Template - ] ] ]*/ ); - $validator->notEmpty('status'); + $validator->notEmptyString('status'); $validator->add( 'timezone', @@ -127,14 +155,14 @@ public function validationDefault(Validator $validator): Validator { [ 'rule' => [ 'validateTimeZone' ], 'provider' => 'table' ] ); - $validator->allowEmpty('timezone'); + $validator->allowEmptyString('timezone'); $validator->add( 'date_of_birth', 'content', [ 'rule' => 'date' ] ); - $validator->allowEmpty('date_of_birth'); + $validator->allowEmptyString('date_of_birth'); return $validator; } diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php index da730126c..83f027f91 100644 --- a/app/src/Model/Table/TypesTable.php +++ b/app/src/Model/Table/TypesTable.php @@ -40,15 +40,16 @@ class TypesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; - // use \App\Lib\Traits\RulesTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; // XXX note not all models are implemented yet... -// add note to JIRA for commented out models // - uncomment attribute here +// - add relation to initialize() // - implement Table::defaultTypes +// - update CFM-56 (Types) protected $testVar = "123"; protected $supportedAttributes = [ @@ -81,7 +82,9 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); - $this->hasMany('identifiers'); + $this->hasMany('CoSettings') + ->setForeignKey('name_default_type_id'); + $this->hasMany('Identifiers'); $this->hasMany('Names'); // XXX add other MVEA models @@ -105,6 +108,21 @@ public function initialize(array $config): void { 'class' => 'SuspendableStatusEnum' ] ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + 'restore' => ['platformAdmin', 'coAdmin'] + ] + ]); } /** @@ -228,6 +246,22 @@ public function typeInUse($entity) { return $count != 0; } + /** + * Determine if the provided Type is in use as a default. + * + * @since COmanage Registry v5.0.0 + * @param Type $entity Type + * @return bool true if the Type is in use as a default, false otherwise + */ + + public function typeIsDefault(\App\Model\Entity\Type $entity): bool { + $attr = explode('.', $entity->attribute, 2); + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + return $CoSettings->typeIsDefault($entity->id); + } + /** * Application Rule to determine if the requested type is in use. * @@ -238,10 +272,16 @@ public function typeInUse($entity) { */ public function ruleTypeInUse($entity, $options) { + // First check that there are no operational references to the type if($this->typeInUse($entity)) { return __d('error', 'Types.inuse', [$entity->value]); } + // Also check that the type is not a default type in the CO Setting. + if($this->typeIsDefault($entity)) { + return __d('error', 'Types.isdefault', [$entity->value]); + } + return true; } @@ -259,14 +299,14 @@ public function validationDefault(Validator $validator): Validator { 'content', [ 'rule' => 'isInteger' ] ); - $validator->notEmpty('co_id'); + $validator->notEmptyString('co_id'); $validator->add( 'attribute', 'content', [ 'rule' => [ 'inList', $this->supportedAttributes ] ] ); - $validator->notEmpty('attribute'); + $validator->notEmptyString('attribute'); $validator->add( 'value', @@ -278,7 +318,7 @@ public function validationDefault(Validator $validator): Validator { 'content', [ 'rule' => [ 'custom', '/^[a-zA-Z0-9\-\.]+$/' ] ] ); - $validator->notEmpty('value'); + $validator->notEmptyString('value'); $validator->add( 'display_name', @@ -291,33 +331,21 @@ public function validationDefault(Validator $validator): Validator { [ 'rule' => [ 'validateInput' ], 'provider' => 'table' ] ); - $validator->notEmpty('display_name'); + $validator->notEmptyString('display_name'); $validator->add( 'edupersonaffiliation', 'content', - [ 'rule' => [ 'inList', [ - EduPersonAffiliationEnum::Affiliate, - EduPersonAffiliationEnum::Alum, - EduPersonAffiliationEnum::Employee, - EduPersonAffiliationEnum::Faculty, - EduPersonAffiliationEnum::LibraryWalkIn, - EduPersonAffiliationEnum::Member, - EduPersonAffiliationEnum::Staff, - EduPersonAffiliationEnum::Student - ] ] ] + [ 'rule' => [ 'inList', EduPersonAffiliationEnum::getConstValues() ] ] ); - $validator->allowEmpty('edupersonaffiliation'); + $validator->allowEmptyString('edupersonaffiliation'); $validator->add( 'status', 'content', - [ 'rule' => [ 'inList', [ - SuspendableStatusEnum::Active, - SuspendableStatusEnum::Suspended - ] ] ] + [ 'rule' => [ 'inList', SuspendableStatusEnum::getConstValues() ] ] ); - $validator->notEmpty('status'); + $validator->notEmptyString('status'); return $validator; } diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 583d1a8ed..781a27d03 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -103,16 +103,31 @@ public function control(string $fieldName, $controlCode = $this->Form->text($fieldName, $coptions); $liClass = " modelbox-data"; } else { - if($fieldName != 'status' && !isset($options['empty'])) { + if($fieldName != 'status' + && !isset($options['empty']) + && (!isset($options['suppressBlank']) || !$options['suppressBlank'])) { // Cause any select (except status) to render with a blank option, even // if the field is required. This makes it clear when a value need to be set. - // Note this will be ignore for non-select controls. + // Note this will be ignored for non-select controls. $coptions['empty'] = true; } $controlCode = $this->Form->control($fieldName, $coptions); } + // Required fields are usually determined by the model validator, but for + // related models the view (currently) has to pass the field as required in + // $options. For fields of the form model.0.field, if $options['required'] + // is true we'll update the set of required fields so the * renders correctly. + + if(isset($options['required']) + && $options['required'] + && preg_match('/(\w+).(\d+).(\w+)/', $fieldName, $matches)) { + if(!in_array($matches[3], $this->reqFields)) { + $this->reqFields[] = $matches[3]; + } + } + return $this->startLine($liClass) . $this->formNameDiv($fieldName, $labelText) . $this->formInfoDiv($controlCode) @@ -201,7 +216,7 @@ protected function formNameDiv(string $fieldName, string $labelText=null) { $f = null; if(preg_match('/^(.*?)_id$/', $fn, $f)) { - // Map foriegn keys (foo_id) to the controller label + // Map foreign keys (foo_id) to the controller label $label = __d('controller', Inflector::camelize(Inflector::pluralize($f[1])), [1]); } else { // Just look up the key @@ -220,7 +235,7 @@ protected function formNameDiv(string $fieldName, string $labelText=null) { } // If the description is the literal key we just generated, there is no description - if($desc == $mn.".".$fn.".desc") { + if($desc == $fn.".desc") { $desc = null; } diff --git a/app/templates/CoSettings/fields.inc b/app/templates/CoSettings/fields.inc new file mode 100644 index 000000000..97a970d71 --- /dev/null +++ b/app/templates/CoSettings/fields.inc @@ -0,0 +1,38 @@ + +Field->control('address_required_fields', ['suppressBlank' => true]); + + print $this->Field->control('name_default_type_id'); + + print $this->Field->control('name_permitted_fields', ['suppressBlank' => true]); + + print $this->Field->control('name_required_fields', ['suppressBlank' => true]); +} diff --git a/app/templates/Names/columns.inc b/app/templates/Names/columns.inc new file mode 100644 index 000000000..ece4d9a95 --- /dev/null +++ b/app/templates/Names/columns.inc @@ -0,0 +1,48 @@ + [ + 'type' => 'link' + ], + 'type_id' => [ + 'type' => 'fk', + 'append' => 'primaryLabel' + ], + 'language' => [ + 'type' => 'enum', + 'class' => 'LanguageEnum' + ] +]; + +$indexActions = [ + [ + 'action' => 'primary', + 'class' => 'linkbutton', + 'if' => 'notPrimary' + ] +]; \ No newline at end of file diff --git a/app/templates/Names/fields.inc b/app/templates/Names/fields.inc new file mode 100644 index 000000000..d36a5d2f0 --- /dev/null +++ b/app/templates/Names/fields.inc @@ -0,0 +1,49 @@ +Field->control($f); + } + } + + print $this->Field->control('type_id', ['default' => $vv_default_type]); + + print $this->Field->control('language'); + + print $this->Field->control('display_name'); + + // We don't allow unsetting of primary_name here because we need to know what + // the new primary_name is, but we do allow this name to become primary + // because afterSave will unset the old one. + print $this->Field->control('primary_name', ['readonly' => $vv_obj->primary_name]); +} diff --git a/app/templates/People/columns.inc b/app/templates/People/columns.inc index 318c35aeb..e521be396 100644 --- a/app/templates/People/columns.inc +++ b/app/templates/People/columns.inc @@ -32,7 +32,7 @@ $indexColumns = [ 'name' => [ 'type' => 'link', 'model' => 'primary_name', - 'field' => 'common_name', + 'field' => 'full_name', // XXX see comments in the controller about sorting on given vs family 'sortable' => 'PrimaryName.family' ], diff --git a/app/templates/People/fields.inc b/app/templates/People/fields.inc index bd6916b9a..ca4415911 100644 --- a/app/templates/People/fields.inc +++ b/app/templates/People/fields.inc @@ -31,22 +31,20 @@ if($vv_action == 'add') { // Primary Name (which we'll collect here) along with possibly collecting // other CO Person attributes. -// XXX this should be subject to the CO Setting for name fields -// XXX how do we flag the name fields as required? need to update auto-introspection -// in whatever calls startControlSet() - print $this->Field->control('names.0.given'); - - print $this->Field->control('names.0.family'); - -// XXX should cluster name fields together so it's obvious type relates to name -// XXX how do we set a default value here? ['default' => X], but what is X? it's -// an integer that varies according to the CO, we'd need a find where co=x and attribute=Name.type and name=official - print $this->Field->control('names.0.type_id', ['empty' => false]); + foreach(['honorific', 'given', 'middle', 'family', 'suffix'] as $f) { + if(in_array($f, $vv_permitted_name_fields)) { + print $this->Field->control('names.0.'.$f, ['required' => in_array($f, $vv_required_name_fields)]); + } + } + + print $this->Field->control('names.0.type_id', ['empty' => false, 'default' => $vv_default_name_type]); print $this->Field->control('status', ['empty' => false]); print $this->Field->control('date_of_birth'); + // AR-Name-1 Since this is the first name for this Person, it must be + // designated primary $hidden = [ // The initial name must be primary 'names.0.primary_name' => true @@ -55,5 +53,16 @@ if($vv_action == 'add') { // XXX This is a placeholder for canvas... maybe it should become a separate page // rather than overload fields.inc? - print $vv_obj->primary_name->common_name; + print $this->Html->link( + __d('controller', 'Names', [99]), + [ 'controller' => 'names', + 'action' => 'index', + '?' => [ + 'person_id' => $vv_obj->id + ] + ], + ['class' => 'linkbutton'] + ); + + // XXX add other MVEAs here } \ No newline at end of file diff --git a/app/templates/Standard/add-edit-view.php b/app/templates/Standard/add-edit-view.php index 3477f5e65..f16ce1ef4 100644 --- a/app/templates/Standard/add-edit-view.php +++ b/app/templates/Standard/add-edit-view.php @@ -34,6 +34,8 @@ // XXX backport to match? $tableName = \Cake\Utility\Inflector::tableize(\Cake\Utility\Inflector::singularize($this->name)); +// If you're looking to set a custom $vv_title, you might be able to use +// generateDisplayField() on the Table instead ?>
@@ -58,7 +60,7 @@ // XXX move delete to some form of buttons.inc? // XXX duplicates index.ctp though strangely this is working whereas index delete throws csrf error // This is a bit overlap with Elements/pageTitleAndButtons -if(!empty($vv_obj->id) && $vv_permissions['delete']) { +if($vv_action != 'add' && !empty($vv_obj->id) && $vv_permissions['delete']) { print '