diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 7c21a4186..b99afbe73 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -14,6 +14,7 @@ "description": { "type": "string", "size": 128 }, "external_identity_id": { "type": "integer", "foreignkey": { "table": "external_identities", "column": "id" } }, "external_identity_role_id": { "type": "integer", "foreignkey": { "table": "external_identity_roles", "column": "id" } }, + "group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, "id": { "type": "integer", "autoincrement": true, "primarykey": true }, "language": { "type": "string", "size": 16 }, "name": { "type": "string", "size": 128, "notnull": true }, @@ -215,6 +216,70 @@ } }, + "groups": { + "columns": { + "id": {}, + "co_id": {}, + "cou_id": {}, + "name": {}, + "description": { "size": 256 }, + "open": { "type": "boolean" }, + "status": {}, + "group_type": { "type": "string", "size": 2 }, + "nesting_mode_all": { "type": "boolean" } + }, + "indexes": { + "groups_i1": { "columns": [ "co_id" ] }, + "groups_i2": { "columns": [ "co_id", "name" ] }, + "groups_i3": { "columns": [ "co_id", "group_type" ] }, + "groups_i4": { "columns": [ "cou_id", "group_type" ] }, + "groups_i5": { "columns": [ "cou_id" ], "comment": "Not really needed but DBAL autogenerates"} + } + }, + + "group_nestings": { + "columns": { + "id": {}, + "group_id": { "comment": "Calling this 'source_group_id makes things complicated'" }, + "target_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, + "negate": { "type": "boolean" } + }, + "indexes": { + "group_nestings_i1": { "columns": [ "group_id" ] }, + "group_nestings_i2": { "columns": [ "target_group_id" ] } + } + }, + + "group_members": { + "columns": { + "id": {}, + "group_id": {}, + "person_id": {}, + "valid_from": {}, + "valid_through": {}, + "group_nesting_id": { "type": "integer", "foreignkey": { "table": "group_nestings", "column": "id" }} + }, + "indexes": { + "group_members_i1": { "columns": [ "group_id" ]}, + "group_members_i2": { "columns": [ "person_id" ]}, + "group_members_i3": { "columns": [ "group_id", "person_id" ]}, + "group_members_i4": { "columns": [ "group_nesting_id" ]} + } + }, + + "group_owners": { + "columns": { + "id": {}, + "group_id": {}, + "person_id": {} + }, + "indexes": { + "group_owners_i1": { "columns": [ "group_id" ]}, + "group_owners_i2": { "columns": [ "person_id" ]}, + "group_owners_i3": { "columns": [ "group_id", "person_id" ]} + } + }, + "names": { "columns": { "id": {}, @@ -300,7 +365,7 @@ "identifiers_i2": { "columns": [ "identifier", "type_id", "external_identity_id" ] }, "identifiers_i3": { "columns": [ "type_id" ] } }, - "mvea": [ "person", "external_identity" ], + "mvea": [ "person", "external_identity", "group" ], "sourced": true }, @@ -344,14 +409,18 @@ "person_role_id": {}, "external_identity_id": {}, "external_identity_role_id": {}, - "actor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } } + "group_id": {}, + "actor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "actor_api_user_id": { "type": "integer", "foreignkey": { "table": "api_users", "column": "id" } } }, "indexes": { "history_records_i1": { "columns": [ "person_id" ] }, "history_records_i2": { "columns": [ "external_identity_id" ] }, "history_records_i3": { "columns": [ "actor_person_id" ] }, "history_records_i4": { "columns": [ "person_role_id" ] }, - "history_records_i5": { "columns": [ "external_identity_role_id" ] } + "history_records_i5": { "columns": [ "external_identity_role_id" ] }, + "history_records_i6": { "columns": [ "group_id" ] }, + "history_records_i7": { "columns": [ "actor_api_user_id" ] } } } }, diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index 80b8d8029..d6b7ec4bd 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -54,6 +54,18 @@ msgstr "{0,plural,=1{External Identity} other{External Identities}}" msgid "ExternalIdentityRoles" msgstr "{0,plural,=1{External Identity Role} other{External Identity Roles}}" +msgid "GroupMembers" +msgstr "{0,plural,=1{Group Member} other{Group Members}}" + +msgid "GroupNestings" +msgstr "{0,plural,=1{Group Nesting} other{Group Nestings}}" + +msgid "GroupOwners" +msgstr "{0,plural,=1{Group Owner} other{Group Owners}}" + +msgid "Groups" +msgstr "{0,plural,=1{Group} other{Groups}}" + msgid "HistoryRecords" msgstr "{0,plural,=1{History Record} other{History Records}}" diff --git a/app/resources/locales/en_US/defaultType.po b/app/resources/locales/en_US/defaultType.po index d2eb532b8..eec6e4abf 100644 --- a/app/resources/locales/en_US/defaultType.po +++ b/app/resources/locales/en_US/defaultType.po @@ -126,6 +126,30 @@ msgstr "Official" msgid "Names.preferred" msgstr "Preferred" +msgid "PersonRoles.affiliate" +msgstr "Affiliate" + +msgid "PersonRoles.alum" +msgstr "Alum" + +msgid "PersonRoles.employee" +msgstr "Employee" + +msgid "PersonRoles.faculty" +msgstr "Faculty" + +msgid "PersonRoles.librarywalkin" +msgstr "Library Walk-In" + +msgid "PersonRoles.member" +msgstr "Member" + +msgid "PersonRoles.staff" +msgstr "Staff" + +msgid "PersonRoles.student" +msgstr "Student" + msgid "Urls.official" msgstr "Official" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index 425ded715..8bfbe5089 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -84,6 +84,9 @@ msgstr "This record is read only and cannot be edited" msgid "exists" msgstr "{0} already exists with this name" +msgid "exists.GroupMember" +msgstr "{0} is already a member of Group {1}" + msgid "fields" msgstr "Please recheck these fields: {0}" @@ -93,6 +96,28 @@ msgstr "The Primary Link {0} is frozen and cannot be changed" msgid "file" msgstr "Cannot read file {0}" +# Used to construct flash message for field errors +msgid "flash" +msgstr "{0}: {1}" + +msgid "GroupNestings.active" +msgstr "Group {0} is not active and so cannot be nested" + +msgid "GroupNestings.automatic" +msgstr "Group {0} is an automatic Group and so cannot be a nesting target" + +msgid "GroupNestings.exists" +msgstr "Group is already nested into this target" + +msgid "GroupNestings.loop" +msgstr "Target group is already nested into this Group" + +msgid "GroupNestings.same" +msgstr "Group cannot be nested into itself" + +msgid "Groups.nested" +msgstr "Group is nested or has nestings, and cannot be suspended or deleted" + msgid "input.blank" msgstr "Value cannot consist of only blank characters" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index f16b3b468..634a4e8a7 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -126,9 +126,51 @@ msgstr "Map the extended affiliation to this eduPersonAffiliation, see all Nested (Source) Groups to be a member of this Group (instead of any)" + +msgid "Groups.open" +msgstr "Open" + +msgid "Groups.open.desc" +msgstr "Open groups may be self-joined by any Person in the CO" + msgid "given" msgstr "Given Name" +msgid "group_membership" +msgstr "{0} Membership in {1}" + msgid "honorific" msgstr "Honorific" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 30b42f67a..249e6092d 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -105,6 +105,9 @@ msgstr "Make Primary" msgid "Types.restore" msgstr "Add/Restore Default Types" +msgid "reconcile" +msgstr "Reconcile" + msgid "remove" msgstr "Remove" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index 90ee8c3d8..a707e6f14 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -39,6 +39,39 @@ msgstr "{0} {1} Deleted: {2}" msgid "edited.mvea" msgstr "{0} {1} Edited: {2}" +msgid "Groups.added" +msgstr "Group {0} created" + +msgid "Groups.deleted" +msgstr "Group {0} deleted" + +msgid "Groups.edited" +msgstr "Group {0} edited: {1}" + +msgid "Groups.reconciled" +msgstr "Membership for group reconciled" + +msgid "GroupMembers.added" +msgstr "Added {0} to group {1}" + +msgid "GroupMembers.added.nesting" +msgstr "Added {0} to group {1} via nesting of group {2} ({3})" + +msgid "GroupMembers.deleted" +msgstr "Removed {0} from group {1}" + +msgid "GroupMembers.deleted.nesting" +msgstr "Removed {0} from group {1} via nesting of group {2} ({3})" + +msgid "GroupMembers.edited" +msgstr "Membership for {0} in group {1} edited: {2}" + +msgid "GroupOwners.added" +msgstr "Added {0} as an owner of group {1}" + +msgid "GroupOwners.deleted" +msgstr "Removed {0} as an owner of group {1}" + msgid "saved" msgstr "Saved" diff --git a/app/src/Command/TransmogrifyCommand.php b/app/src/Command/TransmogrifyCommand.php index bd07edc61..d954e26fe 100644 --- a/app/src/Command/TransmogrifyCommand.php +++ b/app/src/Command/TransmogrifyCommand.php @@ -163,6 +163,46 @@ class TransmogrifyCommand extends Command { 'postRow' => 'split_external_identity', 'cache' => [ 'person_id' ] ], + 'groups' => [ + 'source' => 'cm_co_groups', + 'displayField' => 'name', + 'cache' => [ 'co_id' ], + 'booleans' => [ 'nesting_mode_all', 'open' ], + 'fieldMap' => [ + // auto is implied by group_type + 'auto' => null, + // Rename the changelog key + 'co_group_id' => 'group_id' + ] + ], + 'group_nestings' => [ + 'source' => 'cm_co_group_nestings', + 'displayField' => 'id', + 'booleans' => [ 'negate' ], + 'fieldMap' => [ + 'co_group_id' => 'group_id', + 'target_co_group_id' => 'target_group_id', + // Rename the changelog key + 'co_group_nesting_id' => 'group_nesting_id' + ] + ], + 'group_members' => [ + 'source' => 'cm_co_group_members', + 'displayField' => 'id', + 'booleans' => [ 'member', 'owner' ], + 'fieldMap' => [ + 'co_group_id' => 'group_id', + 'co_person_id' => 'person_id', + 'member' => null, + 'owner' => null, + 'co_group_nesting_id' => 'group_nesting_id', + // Rename the changelog key + 'co_group_member_id' => 'group_member_id', + // Temporary until implemented + 'source_org_identity_id' => null + ], + 'preRow' => 'check_group_memberships' + ], 'names' => [ 'source' => 'cm_names', 'displayField' => 'id', @@ -219,13 +259,13 @@ class TransmogrifyCommand extends Command { 'displayField' => 'id', 'booleans' => [ 'login' ], 'fieldMap' => [ + 'co_group_id' => 'group_id', 'co_person_id' => 'person_id', 'org_identity_id' => 'external_identity_id', 'type_id' => '&map_identifier_type', 'type' => null, // XXX temporary until tables are migrated 'co_department_id' => null, - 'co_group_id' => null, 'co_provisioning_target_id' => null, 'organization_id' => null ] @@ -260,12 +300,12 @@ class TransmogrifyCommand extends Command { 'source' => 'cm_history_records', 'displayField' => 'id', 'fieldMap' => [ - 'co_person_id' => 'person_id', - 'org_identity_id' => 'external_identity_id', 'actor_co_person_id' => 'actor_person_id', + 'co_person_id' => 'person_id', 'co_person_role_id' => 'person_role_id', + 'co_group_id' => 'group_id', + 'org_identity_id' => 'external_identity_id', // XXX temporary until tables are migrated - 'co_group_id' => null, 'co_email_list_id' => null, 'co_service_id' => null ] @@ -328,6 +368,39 @@ protected function cacheResults(string $table, array $row) { } } + /** + * Check if a group membership is actually asserted. + * + * @since COmanage Registry v5.0.0 + * @param array $origRow Row of table data (original data) + * @param array $row Row of table data (post fixes) + * @throws InvalidArgumentException + */ + + protected function check_group_memberships(array $origRow, array $row) { + if($row['owner'] && !$row['deleted'] && !$row['co_group_member_id']) { + // Insert a GroupOwner row for this record. Note we ignore valid from and + // through for this. We also ignore non-current changelog records. + + $ownerRow = [ + 'group_id' => $origRow['co_group_id'], + 'person_id' => $origRow['co_person_id'], + 'created' => $origRow['created'], + 'modified' => $origRow['modified'], + 'group_owner_id' => null, + 'revision' => 0, + 'deleted' => 'f', + 'actor_identifier' => $origRow['actor_identifier'] + ]; + + $this->outconn->insert('group_owners', $ownerRow); + } + + if(!$row['member']) { + throw new \InvalidArgumentException('member not set on GroupMember'); + } + } + /** * Execute the Transmogrify Command. * @@ -417,6 +490,14 @@ public function execute(Arguments $args, ConsoleIo $io) { // Make a copy of the original data for any post processing followups $origRow = $row; + // Run any pre processing functions for the row. + + if(!empty($this->tables[$t]['preRow'])) { + $p = $this->tables[$t]['preRow']; + + $this->$p($origRow, $row); + } + // Do this before fixBooleans since we'll insert some $this->fixChangelog($t, $row, isset($this->tables[$t]['addChangelog']) && $this->tables[$t]['addChangelog']); @@ -505,6 +586,10 @@ protected function findCoId(array $row) { return $this->cache['people']['id'][ $personId ]['co_id']; } } + } elseif(!empty($row['group_id'])) { + if(isset($this->cache['groups']['id'][ $row['group_id'] ]['co_id'])) { + return $this->cache['groups']['id'][ $row['group_id'] ]['co_id']; + } } throw new \InvalidArgumentException('CO not found for record'); @@ -538,7 +623,7 @@ protected function fixBooleans(string $table, array &$row) { } /** - * Populate empty Changelog data from legacy records, and handle table renames. + * Populate empty Changelog data from legacy records * * @since COmanage Registry v5.0.0 * @param string $table Table Name @@ -581,6 +666,7 @@ protected function fixChangelog(string $table, array &$row, bool $force=false) { * * @since COmanage Registry v5.0.0 */ + protected function insertDefaultSettings() { // Create a CoSetting for any CO that didn't previously have one. @@ -958,8 +1044,8 @@ protected function split_external_identity(array $origRow, array $row) { $roleRow['affiliation_type_id'] = $this->map_affiliation_type($row); // Fix up changelog - $roleRow['external_identity_role_id'] = $origRow['org_identity_id']; - unset($roleRow['org_identity_id']); + // Since we're creating a new row, we have to manually fix up booleans + $roleRow['deleted'] = ($roleRow['deleted'] ? 't' : 'f'); $this->outconn->insert('external_identity_roles', $roleRow); } diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 94ef18a9b..1ca613c45 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -161,7 +161,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { /** * Default implementation for calculating permissions for standard controllers, - * intended to be overridden by controllers with more speciific requirements. + * intended to be overridden by controllers with more specific requirements. * * @since COmanage Registry v5.0.0 * @param int $id Record ID if relevant, or null @@ -235,6 +235,28 @@ public function calculatePermissions(?int $id): array { $ret[$action] = $ok; } + + if(!empty($permissions['related'])) { + foreach($permissions['related'] as $rtable) { + $rpermissions = $table->$rtable->getPermissions(); + + foreach($rpermissions['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[$rtable][$action] = $ok; + } + } + } } else { // Permissions for actions that operate over tables diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index 06b37a163..f2a164595 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -294,6 +294,9 @@ public function getMenuPermissions() { // Can access the Configuration Dashboard for the current CO $permissions['configuration'] = true; + // Can manage Groups in the current CO + $permissions['groups'] = true; + // Can manage People in the current CO $permissions['people'] = true; diff --git a/app/src/Controller/GroupMembersController.php b/app/src/Controller/GroupMembersController.php new file mode 100644 index 000000000..b7aaa866f --- /dev/null +++ b/app/src/Controller/GroupMembersController.php @@ -0,0 +1,61 @@ + [ + 'People.primary_name.name' => 'asc' + ] + ]; + + /** + * 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) { + // Pull the Group name for breadcrumb rendering + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->GroupMembers->Groups->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->GroupMembers->Groups->getDisplayField()); + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/GroupNestingsController.php b/app/src/Controller/GroupNestingsController.php new file mode 100644 index 000000000..67f973a1b --- /dev/null +++ b/app/src/Controller/GroupNestingsController.php @@ -0,0 +1,67 @@ + [ + 'Group.name' => 'asc' + ] + ]; + + /** + * 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) { + // Pull the Group name for breadcrumb rendering + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->GroupNestings->Groups->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->GroupNestings->Groups->getDisplayField()); + } + + // We need to calculate the available set of groups for nesting. We do this + // here rather than via autoViewVars because we need to know the current + // group (to exclude it). + + $this->set('targetGroups', $this->GroupNestings->availableGroups((int)$link->value)); + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/GroupOwnersController.php b/app/src/Controller/GroupOwnersController.php new file mode 100644 index 000000000..ac278486e --- /dev/null +++ b/app/src/Controller/GroupOwnersController.php @@ -0,0 +1,61 @@ + [ + 'People.primary_name.name' => 'asc' + ] + ]; + + /** + * 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) { + // Pull the Group name for breadcrumb rendering + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->GroupOwners->Groups->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->GroupOwners->Groups->getDisplayField()); + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/GroupsController.php b/app/src/Controller/GroupsController.php new file mode 100644 index 000000000..902f7696d --- /dev/null +++ b/app/src/Controller/GroupsController.php @@ -0,0 +1,60 @@ + [ + 'Groups.name' => 'asc' + ] + ]; + + /** + * Reconcile a Group's memberships. + * + * @since COmanage Registry v5.0.0 + * @param string $id Group ID + */ + + public function reconcile(string $id) { + try { + $this->Groups->reconcile((int)$id); + $this->Flash->success(__d('result', 'Groups.reconciled')); + } + 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/StandardController.php b/app/src/Controller/StandardController.php index da6309a85..92908606c 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -66,7 +66,9 @@ public function add() { if(!empty($errors)) { $this->Flash->error(__d('error', 'fields', [ implode(',', - array_map(function($v) { return __d('field', $v); }, + array_map(function($v) use ($errors) { + return __d('error', 'flash', [$v, implode(',', array_values($errors[$v]))]); + }, array_keys($errors))) ])); } else { $this->Flash->error(__d('error', 'save', [$modelsName])); @@ -288,7 +290,9 @@ public function edit(string $id) { if(!empty($errors)) { $this->Flash->error(__d('error', 'fields', [ implode(',', - array_map(function($v) { return __d('field', $v); }, + array_map(function($v) use ($errors) { + return __d('error', 'flash', [$v, implode(',', array_values($errors[$v]))]); + }, array_keys($errors))) ])); } else { $this->Flash->error(__d('error', 'save', [$modelsName])); diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php index 069cde29d..ec0c5d309 100644 --- a/app/src/Lib/Enum/ActionEnum.php +++ b/app/src/Lib/Enum/ActionEnum.php @@ -32,9 +32,17 @@ class ActionEnum extends StandardEnum { // Codes beginning with 'X' (eg: 'XABC') are reserved for local use // Codes beginning with a lowercase 'p' (eg: 'pABC') are reserved for plugin use - const CommentAdded = 'CMNT'; - const MVEAAdded = 'AMVE'; - const MVEADeleted = 'DMVE'; - const MVEAEdited = 'EMVE'; - const NamePrimary = 'PNAM'; + const CommentAdded = 'CMNT'; + const GroupAdded = 'ACGR'; + const GroupDeleted = 'DCGR'; + const GroupEdited = 'ECGR'; + const GroupMemberAdded = 'ACGM'; + const GroupMemberDeleted = 'DCGM'; + const GroupMemberEdited = 'ECGM'; + const GroupOwnerAdded = 'ACGO'; + const GroupOwnerDeleted = 'DCGO'; + const MVEAAdded = 'AMVE'; + const MVEADeleted = 'DMVE'; + const MVEAEdited = 'EMVE'; + const NamePrimary = 'PNAM'; } \ No newline at end of file diff --git a/app/src/Lib/Enum/GroupTypeEnum.php b/app/src/Lib/Enum/GroupTypeEnum.php new file mode 100644 index 000000000..fe12fdcc5 --- /dev/null +++ b/app/src/Lib/Enum/GroupTypeEnum.php @@ -0,0 +1,37 @@ +deleted) && $entity->deleted)) { + $table = $event->getSubject(); + + if(method_exists($table, "localAfterSave")) { + return $table->localAfterSave($event, $entity, $options); + } + } + + return true; + } +} diff --git a/app/src/Lib/Traits/HistoryTrait.php b/app/src/Lib/Traits/HistoryTrait.php index 063a68ded..33d9d9c1e 100644 --- a/app/src/Lib/Traits/HistoryTrait.php +++ b/app/src/Lib/Traits/HistoryTrait.php @@ -157,14 +157,24 @@ public function recordHistory($entity, ?string $action=null, ?string $comment=nu $personRoleId = $this->lookupPersonRoleId($entity); $externalIdentityId = $this->lookupExternalIdentityId($entity); $externalIdentityRoleId = $this->lookupExternalIdentityRoleId($entity); + $groupId = $this->lookupGroupId($entity); - return $HistoryRecords->recordForPerson( - $personId, - $laction, - $lcomment, - $personRoleId, - $externalIdentityId, - $externalIdentityRoleId - ); + if($groupId) { + return $HistoryRecords->recordForGroup( + $groupId, + $laction, + $lcomment, + $personId + ); + } else { + return $HistoryRecords->recordForPerson( + $personId, + $laction, + $lcomment, + $personRoleId, + $externalIdentityId, + $externalIdentityRoleId + ); + } } } diff --git a/app/src/Lib/Traits/LabeledLogTrait.php b/app/src/Lib/Traits/LabeledLogTrait.php index c36b7949d..e1fb64186 100644 --- a/app/src/Lib/Traits/LabeledLogTrait.php +++ b/app/src/Lib/Traits/LabeledLogTrait.php @@ -39,15 +39,11 @@ trait LabeledLogTrait { * * @since COmanage Registry v5.0.0 * @param string $level Log level - * @param array $msg Log message, constructed from an array converted to json + * @param array $msg Log message in the form of an array (that will be converted to JSON) */ public function alog(string $level, ?array $msg) { - $bt = debug_backtrace(0, 2); - - $m = getmypid() . " " . $bt[1]['class'] . "::" . $bt[1]['function'] . ": " . json_encode($msg, JSON_PRETTY_PRINT); - - Log::write($level, $m); + return $this->llog($level, json_encode($msg, JSON_PRETTY_PRINT)); } /** @@ -63,6 +59,14 @@ public function llog(string $level, string $msg) { $m = getmypid() . " " . $bt[1]['class'] . "::" . $bt[1]['function'] . ": " . $msg; - Log::write($level, $m); + // We overload $level here, which Cake defines roughly the same way as + // syslog (alert, info, debug, notice, etc). We add two more: trace and + // rule, which we transition to scopes (defined in app.php). + + if(in_array($level, ['rule', 'trace'])) { + Log::info($m, ['scope' => [$level]]); + } else { + Log::write($level, $m); + } } } diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php index 2d9dba9f8..64edd65ed 100644 --- a/app/src/Lib/Traits/PrimaryLinkTrait.php +++ b/app/src/Lib/Traits/PrimaryLinkTrait.php @@ -258,14 +258,14 @@ public function lookupExternalIdentityId($entity): ?int { $a = $entity->extract(['external_identity_id']); - if(array_key_exists('external_identity_id', $a)) { + if($entity->getSource() == 'ExternalIdentities') { + return $entity->id; + } elseif(array_key_exists('external_identity_id', $a)) { // We want to return here whether or not the key is set since if it's NULL // we're not directly pointing to an External Identity. We can't use // property_exists because Cake is dynamically getting. return $entity->external_identity_id; - } elseif($entity->getSource() == 'ExternalIdentities') { - return $entity->id; } else { $linkEntity = $this->findPrimaryLinkEntity($entity); @@ -278,7 +278,7 @@ public function lookupExternalIdentityId($entity): ?int { } /** - * Determine the Person Role ID associated with an entity. + * Determine the External Identity Role ID associated with an entity. * * @since COmanage Registry v5.0.0 * @param Entity $entity Entity @@ -288,10 +288,30 @@ public function lookupExternalIdentityId($entity): ?int { public function lookupExternalIdentityRoleId($entity): ?int { $a = $entity->extract(['external_identity_role_id']); - if(array_key_exists('external_identity_role_id', $a)) { + if($entity->getSource() == 'ExternalIdentityRoles') { + return $entity->id; + } elseif(array_key_exists('external_identity_role_id', $a)) { return $entity->external_identity_role_id; - } elseif($entity->getSource() == 'ExternalIdentityRoles') { + } + + return null; + } + + /** + * Determine the Group ID associated with an entity. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity + * @return int Group ID + */ + + public function lookupGroupId($entity): ?int { + $a = $entity->extract(['group_id']); + + if($entity->getSource() == 'Groups') { return $entity->id; + } elseif(array_key_exists('group_id', $a)) { + return $entity->group_id; } return null; @@ -308,10 +328,10 @@ public function lookupExternalIdentityRoleId($entity): ?int { public function lookupPersonId($entity): ?int { $a = $entity->extract(['person_id']); - if(array_key_exists('person_id', $a)) { - return $entity->person_id; - } elseif($entity->getSource() == 'People') { + if($entity->getSource() == 'People') { return $entity->id; + } elseif(array_key_exists('person_id', $a)) { + return $entity->person_id; } else { $linkEntity = $this->findPrimaryLinkEntity($entity); @@ -341,10 +361,10 @@ public function lookupPersonId($entity): ?int { public function lookupPersonRoleId($entity): ?int { $a = $entity->extract(['person_role_id']); - if(array_key_exists('person_role_id', $a)) { - return $entity->person_role_id; - } elseif($entity->getSource() == 'PersonRoles') { + if($entity->getSource() == 'PersonRoles') { return $entity->id; + } elseif(array_key_exists('person_role_id', $a)) { + return $entity->person_role_id; } return null; diff --git a/app/src/Lib/Util/PaginatedSqlIterator.php b/app/src/Lib/Util/PaginatedSqlIterator.php new file mode 100644 index 000000000..74d374d29 --- /dev/null +++ b/app/src/Lib/Util/PaginatedSqlIterator.php @@ -0,0 +1,202 @@ +table = $table; + $this->conditions = $conditions; + + $this->position = 0; + } + + /** + * Obtain the current element of the iteration. + * + * @since COmanage Registry v3.3.0 + * @return mixed Element at the current position + */ + + public function current() { + return $this->results[$this->position]; + } + + /** + * Obtain the current count of records. + * + * @since COmanage Registry v3.3.0 + * @param bool $refresh Refresh the count rather than returning the cached count + * @return int Record count + */ + + public function count(bool $refresh=false): int { + if($this->count === null || $refresh) { + $this->loadCount(); + } + + return $this->initialCount; + } + + /** + * Obtain the current position of the iteration. + * + * @since COmanage Registry v3.3.0 + * @return int The current position + */ + + public function key(): int { + return $this->position; + } + + /** + * Obtain the count of records. + * + * @since COmanage Registry v3.3.0 + */ + + protected function loadCount() { + $query = $this->table->find(); + + if($this->conditions) { + $query = $query->where($this->conditions); + } + + $this->initialCount = $query->count(); + } + + /** + * Obtain the next page of results (releasing the current page). + * + * @since COmanage Registry v3.3.0 + */ + + protected function loadPage() { + unset($this->results); + $this->results = null; + + $this->position = 0; + + $query = $this->table->find() + ->where([$this->keyField . ' >' => $this->maxid]); + + if($this->conditions) { + $query = $query->where($this->conditions); + } + + $query = $query->order([$this->keyField => 'ASC']) + ->limit($this->pageSize) + // We always request exactly one page, starting from $this->maxid. + // We don't use Cake's pagination because the resultset could + // change between calls, and the keyset technique ensures we + // always get the full set (since newer records will have + // higher IDs). + ->page(1); + + $resultSet = $query->all(); + + // Use the ResultSet to determine the maximum ID. Since we ordered by + // id we know the highest value is in the last result. + $max = $resultSet->last(); + + if($max) { + $this->maxid = $max->id; + } + // else no remaining rows. valid() will return false. + + // Convert the result set to an array for our own iterator use + $this->results = $resultSet->toArray(); + } + + /** + * Move to the next record in the iteration. + * + * @since COmanage Registry v3.3.0 + */ + + public function next() { + $this->position++; + + if($this->position >= count($this->results)) { + // We've reached the end of the current page, retrieve the next page of results + $this->loadPage(); + } + } + + /** + * Rewind to the first record in the iteration. (This is called by PHP on initialization.) + * + * @since COmanage Registry v3.3.0 + */ + + public function rewind() { + $this->maxid = 0; + + $this->loadPage(); + } + + /** + * Determine if the current position is valid. + * + * @since COmanage Registry v3.3.0 + * @return boolean True if the current position is valid, false otherwise + */ + + public function valid() { + return !empty($this->results[$this->position]); + } +} diff --git a/app/src/Model/Behavior/ChangelogBehavior.php b/app/src/Model/Behavior/ChangelogBehavior.php index 351f60346..612f089f7 100644 --- a/app/src/Model/Behavior/ChangelogBehavior.php +++ b/app/src/Model/Behavior/ChangelogBehavior.php @@ -137,7 +137,7 @@ public function beforeSave(\Cake\Event\Event $event, \Cake\Datasource\EntityInte $actor = substr($actor, 0, 256); } - if(empty($entity->id)) { + if($entity->isNew()) { // This is an add, just set default metadata LogBehavior::strace($alias, 'Changelog setting default changelog metadata on add'); diff --git a/app/src/Model/Behavior/LogBehavior.php b/app/src/Model/Behavior/LogBehavior.php index c4519a810..4e72825c6 100644 --- a/app/src/Model/Behavior/LogBehavior.php +++ b/app/src/Model/Behavior/LogBehavior.php @@ -51,12 +51,7 @@ public function beforeFind(\Cake\Event\Event $event, \Cake\ORM\Query $query, \Ar $label = getmypid() . "/" . $subject->getAlias() . ": "; // XXX can we inject IP address of requester (where available)? - Log::info($label . 'beforeFind', ['scope' => ['trace']]); - } - - // XXX docblock - - public function beforeSave(\Cake\Event\Event $event, $entity, \ArrayObject $options) { + Log::info($label . 'beforeFind: ' . $query->sql(), ['scope' => ['trace']]); } // XXX don't define log() since it will collide with LogTrait? diff --git a/app/src/Model/Behavior/TimezoneBehavior.php b/app/src/Model/Behavior/TimezoneBehavior.php index b3d6310d1..734e5ce54 100644 --- a/app/src/Model/Behavior/TimezoneBehavior.php +++ b/app/src/Model/Behavior/TimezoneBehavior.php @@ -44,6 +44,10 @@ class TimezoneBehavior extends Behavior /** * Convert timestamps to UTC for database saves prior to data marshaling. + * The expectation is this will only be functional for the UI, where $this->tz + * is set. (The API is expected to provide times in UTC.) For rendering, + * FieldHelper::control() and Standard index.php will adjust back to the local + * timezone. * * @since COmanage Registry v5.0.0 * @param Event $event beforeMarshal event diff --git a/app/src/Model/Entity/Group.php b/app/src/Model/Entity/Group.php new file mode 100644 index 000000000..b7e170923 --- /dev/null +++ b/app/src/Model/Entity/Group.php @@ -0,0 +1,99 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this is an automatic group. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is not an automatic group, false otherwise. + */ + + public function isAutomatic(): bool { + return in_array($this->group_type, [GroupTypeEnum::ActiveMembers, GroupTypeEnum::AllMembers]); + } + + /** + * Determine if this entity record can be deleted. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the record can be deleted, false otherwise + */ + + public function canDelete(): bool { + return !$this->isSystem(); + } + + /** + * Determine if this entity is a system group. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this entity is automatically managed, false otherwise + */ + + public function isSystem(): bool { + return in_array($this->group_type, [GroupTypeEnum::ActiveMembers, GroupTypeEnum::Admins, GroupTypeEnum::AllMembers]); + } + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // Automatic groups are read-only + + return $this->isAutomatic(); + } + + /** + * Determine if this is not an automatic group. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is not an automatic group, false otherwise. + */ + + public function notAutomatic(): bool { + return !$this->isAutomatic(); + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/GroupMember.php b/app/src/Model/Entity/GroupMember.php new file mode 100644 index 000000000..3b0dd4b24 --- /dev/null +++ b/app/src/Model/Entity/GroupMember.php @@ -0,0 +1,40 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/GroupNesting.php b/app/src/Model/Entity/GroupNesting.php new file mode 100644 index 000000000..5034d0c10 --- /dev/null +++ b/app/src/Model/Entity/GroupNesting.php @@ -0,0 +1,40 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/GroupOwner.php b/app/src/Model/Entity/GroupOwner.php new file mode 100644 index 000000000..9aae5bea7 --- /dev/null +++ b/app/src/Model/Entity/GroupOwner.php @@ -0,0 +1,40 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Person.php b/app/src/Model/Entity/Person.php index 3721f3f05..5e64536ef 100644 --- a/app/src/Model/Entity/Person.php +++ b/app/src/Model/Entity/Person.php @@ -30,6 +30,7 @@ namespace App\Model\Entity; use Cake\ORM\Entity; +use \App\Lib\Enum\StatusEnum; class Person extends Entity { use \App\Lib\Traits\ReadOnlyEntityTrait; @@ -39,4 +40,15 @@ class Person extends Entity { 'id' => false, 'slug' => false, ]; + + /** + * Determine if this Person is Active (includes GracePeriod). + * + * @since COmanage Registry v5.0.0 + * @return bool true if Person is Active or GracePeriod, false otherwise + */ + + public function isActive(): bool { + return in_array($this->status, [StatusEnum::Active, StatusEnum::GracePeriod]); + } } \ No newline at end of file diff --git a/app/src/Model/Entity/PersonRole.php b/app/src/Model/Entity/PersonRole.php index f45cab4e3..4254c8267 100644 --- a/app/src/Model/Entity/PersonRole.php +++ b/app/src/Model/Entity/PersonRole.php @@ -30,6 +30,7 @@ namespace App\Model\Entity; use Cake\ORM\Entity; +use \App\Lib\Enum\StatusEnum; class PersonRole extends Entity { use \App\Lib\Traits\ReadOnlyEntityTrait; @@ -39,4 +40,15 @@ class PersonRole extends Entity { 'id' => false, 'slug' => false, ]; + + /** + * Determine if this Person Role is Active (includes GracePeriod). + * + * @since COmanage Registry v5.0.0 + * @return bool true if the Person Role is Active or GracePeriod, false otherwise + */ + + public function isActive(): bool { + return in_array($this->status, [StatusEnum::Active, StatusEnum::GracePeriod]); + } } \ No newline at end of file diff --git a/app/src/Model/Table/AdHocAttributesTable.php b/app/src/Model/Table/AdHocAttributesTable.php index 0a3ef2dc6..7801945ea 100644 --- a/app/src/Model/Table/AdHocAttributesTable.php +++ b/app/src/Model/Table/AdHocAttributesTable.php @@ -33,6 +33,7 @@ use Cake\Validation\Validator; class AdHocAttributesTable extends Table { + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; @@ -93,7 +94,7 @@ public function initialize(array $config): void { * @return bool True on success */ - public function afterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { $this->recordHistory($entity); return true; diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index d39bff85b..8074b00aa 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -36,6 +36,7 @@ class AddressesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; @@ -121,7 +122,7 @@ public function initialize(array $config): void { * @return bool True on success */ - public function afterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { $this->recordHistory($entity); return true; diff --git a/app/src/Model/Table/ApiUsersTable.php b/app/src/Model/Table/ApiUsersTable.php index 6c364d3e8..9c6247aa7 100644 --- a/app/src/Model/Table/ApiUsersTable.php +++ b/app/src/Model/Table/ApiUsersTable.php @@ -96,14 +96,14 @@ public function initialize(array $config): void { } /** - * Define business rules to supplement the default trait implementation. + * Define business rules. * * @since COmanage Registry v5.0.0 * @param RulesChecker $rules RulesChecker object * @return RulesChecker */ - public function buildTableRules(RulesChecker $rules): RulesChecker { + public function buildRules(RulesChecker $rules): RulesChecker { // We don't want to perform the uniqueness check until after then namespacing // check in order to avoid information leakage. This requires more complicated // rule building. @@ -253,7 +253,7 @@ public function validateKey(string $username, string $apiKey, string $remoteIp) ]); if(!$Hasher->check($apiKey, $apiUser->api_key)) { - throw new \InvalidArgumentException('registry.er.auth.api.key', [$username]); + throw new \InvalidArgumentException(__d('error', 'auth.api.key', [$username])); } if($Hasher->needsRehash($apiUser->api_key)) { diff --git a/app/src/Model/Table/CosTable.php b/app/src/Model/Table/CosTable.php index 245024f22..57c400158 100644 --- a/app/src/Model/Table/CosTable.php +++ b/app/src/Model/Table/CosTable.php @@ -38,6 +38,7 @@ class CosTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\TableMetaTrait; @@ -67,6 +68,8 @@ public function initialize(array $config): void { ->setDependent(true); $this->hasMany('Dashboards') ->setDependent(true); + $this->hasMany('Groups') + ->setDependent(true); $this->hasMany('People') ->setDependent(true) ->setCascadeCallbacks(true); @@ -104,26 +107,6 @@ public function initialize(array $config): void { ]); } - /** - * 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) { - if($entity->isNew() && !empty($entity->id)) { - // Run setup for new CO - - $this->setup($entity->id); - } - - return true; - } - /** * Define business rules. * @@ -176,6 +159,26 @@ public function findCOmanageCO(Query $query): Query { return $query->where(['lower(name)' => 'comanage']); } + /** + * 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 localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options) { + if($entity->isNew() && !empty($entity->id)) { + // Run setup for new CO + + $this->setup($entity->id); + } + + return true; + } + /** * Application Rule to determine if the current entity is the COmanage CO. * @@ -221,18 +224,14 @@ public function ruleIsActive($entity, $options): bool { */ public function setup(int $id): bool { - $Types = TableRegistry::getTableLocator()->get('Types'); - // AR-Type-1 Set up the default values for extended types - $Types->addDefaults($id); + $this->Types->addDefaults($id); - // Create the default groups -// $this->CoGroup->addDefaults($coId); + // AR-CO-6 Create the default groups + $this->Groups->addDefaults($id); // Set up the default settings - $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); - - $CoSettings->addDefaults($id); + $this->CoSettings->addDefaults($id); return true; } diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index 055fbf5c9..b518a9498 100644 --- a/app/src/Model/Table/CousTable.php +++ b/app/src/Model/Table/CousTable.php @@ -32,10 +32,12 @@ use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; +use Cake\ORM\TableRegistry; use Cake\Validation\Validator; class CousTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; @@ -67,6 +69,9 @@ public function initialize(array $config): void { // _id suffix to match Cake's default pattern. ->setProperty('parent'); + $this->hasMany('Groups'); + $this->hasMany('PersonRoles'); + $this->setDisplayField('name'); $this->setPrimaryLink('co_id'); @@ -88,14 +93,14 @@ public function initialize(array $config): void { } /** - * Define business rules to supplement the default trait implementation. + * Define business rules. * * @since COmanage Registry v5.0.0 * @param RulesChecker $rules RulesChecker object * @return RulesChecker */ - public function buildTableRules(RulesChecker $rules): RulesChecker { + public function buildRules(RulesChecker $rules): RulesChecker { // AR-CO-3 Two COUs within the same CO cannot share the same name $rules->add($rules->isUnique(['name', 'co_id'], __d('error', 'exists', [__d('controller', 'Cous', [1])]))); @@ -108,6 +113,26 @@ public function buildTableRules(RulesChecker $rules): RulesChecker { return $rules; } + /** + * 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 localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options) { + if($entity->isNew() && !empty($entity->id)) { + // Run setup for new COU + + $this->setup($entity->id, $entity->co_id); + } + + return true; + } + /** * Assemble the set of potential parent COUs. * @@ -164,6 +189,22 @@ public function rulePotentialParent($entity, $options) { return true; } + /** + * Perform initial setup for a COU. + * + * @since COmanage Registry v5.0.0 + * @param int $id COU ID + * @param int $coId CO ID + * @return bool True on success + */ + + public function setup(int $id, int $coId): bool { + // AR-COU-4 Create the default groups + $this->Groups->addDefaults($coId, $id); + + return true; + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index f1cccae3a..4d4ec2869 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -34,6 +34,7 @@ class EmailAddressesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; @@ -116,7 +117,7 @@ public function initialize(array $config): void { * @return bool True on success */ - public function afterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { $this->recordHistory($entity); return true; diff --git a/app/src/Model/Table/GroupMembersTable.php b/app/src/Model/Table/GroupMembersTable.php new file mode 100644 index 000000000..5bb5db115 --- /dev/null +++ b/app/src/Model/Table/GroupMembersTable.php @@ -0,0 +1,500 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); + + // Group Members are not configuration + $this->setIsConfigurationTable(false); + + // Define associations + $this->belongsTo('GroupNestings'); + $this->belongsTo('Groups'); + $this->belongsTo('People'); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('group_id'); + $this->setRequiresCO(true); + + $this->setEditContains(['Groups', 'People.PrimaryName']); + + $this->setIndexContains([ + 'GroupNestings' => 'Groups', + 'Groups', + 'People.PrimaryName' + ]); + + $this->setPermissions([ +// XXX update for couAdmins, group owners, etc + // 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'] + ] + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-GroupMember-1 A Person cannot have two manually created GroupMember + // records for the same Group. + $rules->addCreate([$this, 'ruleIsGroupMember'], + 'isGroupMember', + ['errorField' => 'person_id']); + + return $rules; + } + + /** + * 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\GroupMember $entity): string { + // Pull the group and person information to build a more useful display string + + return __d('field', 'group_membership', [$entity->person->primary_name->full_name, $entity->group->name]); + } + + /** + * Determine if the specified Person is a member of the specified Group. + * + * @since COmanage Registry v5.0.0 + * @param int $groupId Group ID + * @param int $personId Person ID + * @param bool $direct If true, the Person must be a direct member of the Group + * @param bool $checkValidity If true, check valid_from and valid_through dates + * @return bool true if Person is a member of Group, false otherwise + */ + + public function isMember(int $groupId, + int $personId, + bool $direct=false, + bool $checkValidity=true): bool { + // This function is here (instead of GroupsTable) because we need it for + // rule validation on new GroupMember save. + + $conditions = [ + 'group_id' => $groupId, + 'person_id' => $personId + ]; + + if($checkValidity) { + // Only pull currently valid group memberships + + $conditions['AND'][] = [ + 'OR' => [ + 'valid_from IS NULL', + 'valid_from < ' => date('Y-m-d H:i:s', time()) + ] + ]; + $conditions['AND'][] = [ + 'OR' => [ + 'valid_through IS NULL', + 'valid_through > ' => date('Y-m-d H:i:s', time()) + ] + ]; + } + + if($direct) { +// XXX need to add pipelines here eventually + $conditions[] = 'group_nesting_id IS NULL'; + } + + $count = $this->find()->where($conditions)->count(); + + // When !$direct, we could get more than one row back + return ($count > 0); + } + + /** + * 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 localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + // Pull the related entities for HistoryRecord comment creation. + $person = $this->People->get($entity->person_id, ['contain' => ['PrimaryName']]); + $group = $this->Groups->get($entity->group_id); + + $action = null; + $langKey = ''; + $langKeySuffix = ''; + $commentParams = [ + (!empty($person->primary_name) ? $person->primary_name->full_name : "?"), + $group->name + ]; + + if(!empty($entity->group_nesting_id)) { + // We need to allow retrieval of archived records since we might be called + // after the GroupNesting was deleted + $nesting = $this->GroupNestings->get($entity->group_nesting_id, + ['contain' => ['Groups'], + 'archived' => true]); + + $langKeySuffix = '.nesting'; + $commentParams[] = $nesting->group->name; + $commentParams[] = $entity->group_nesting_id; + } + + if($entity->isNew()) { + $action = ActionEnum::GroupMemberAdded; + $langKey = 'GroupMembers.added'; + } elseif($entity->get('deleted')) { + $action = ActionEnum::GroupMemberDeleted; + $langKey = 'GroupMembers.deleted'; + } else { + $action = ActionEnum::GroupMemberEdited; + $langKey = 'GroupMembers.edited'; + $commentParams[] = $this->changesToString($entity); + } + + $comment = __d('result', $langKey . $langKeySuffix, $commentParams); + + $this->recordHistory($entity, $action, $comment); + + // On save, we pull any nestings where this Group is the source and sync + // memberships for the target. (Any membership changes should then recurse.) + + $groupNestings = $this->GroupNestings->find() + ->where(['GroupNestings.group_id' => $entity->group_id]) + ->contain(['TargetGroups']) + ->all(); + + foreach($groupNestings as $groupNesting) { + $this->syncNestedMembership($entity->person_id, $groupNesting->target_group); + } + + return true; + } + + /** + * Application Rule to determine if the Person is already a member of the Group. + * + * @since COmanage Registyr 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 ruleIsGroupMember($entity, $options) { + // We don't allow the same Person to be manually added to the same Group + // twice, though they could have a separate membership via Nestings or + // EIS Pipelines. + + if($this->isMember($entity->group_id, $entity->person_id, true, false)) { + // Pull the Person and Group name for the error message. + $person = $this->People->get($entity->person_id, ['contain' => ['PrimaryName']]); + $group = $this->Groups->get($entity->group_id); + + return __d('error', 'exists.GroupMember', [$person->primary_name->full_name, $group->name]); + } + + return true; + } + + /** + * Sync an automatic group membership. + * + * @since COmanage Registry v5.0.0 + * @param GroupTypeEnum $groupType Type of Group to sync membership + * @param int $couId COU ID, or null for CO level groups + * @param int $personId Person ID of member + * @param bool $eligible Whether the person should be in the group + * @param bool $provision Whether to run provisioners + * @throws InvalidArgumentException + */ + + public function syncAutomaticMembership(string $groupType, + ?int $couId, + int $personId, + bool $eligible, + bool $provision=true) { + // Find the CO from the Person + $coId = $this->People->findCoForRecord($personId); + + if(!$coId) { + throw new \InvalidArgumentException(__d('error', 'notfound', __d('controller', 'People'))); + } + + // Find the requested group + $targetGroup = $this->Groups->find() + ->where([ + 'co_id' => $coId, + 'group_type' => $groupType, + // $couId will be null for CO level groups + 'cou_id IS' => $couId + ]) + ->firstOrFail(); + + // Is $personId already a member? We don't use $this->isMember because we + // may delete this record, below. + + $memberEntity = $this->find()->where(['group_id' => $targetGroup->id, 'person_id' => $personId])->first(); + $isMember = !empty($memberEntity); + + $hAction = null; + + if($eligible && !$isMember) { + // Add a membership + + $membership = [ + 'group_id' => $targetGroup->id, + 'person_id' => $personId + ]; + + $entity = $this->newEntity($membership); + +// XXX need to make sure $provision is honored here + $this->saveOrFail($entity, ['provision' => $provision]); + $this->llog('rule', "Added automatic membership for Person ID $personId to Group ID " . $targetGroup->id); + } elseif(!$eligible && $isMember) { + // Remove the membership + + $this->delete($memberEntity); + $this->llog('rule', "Removed automatic membership for Person ID $personId from Group ID " . $targetGroup->id); + } + // else nothing to do + } + + public function syncNestedMembership(int $personId, + \Cake\Datasource\EntityInterface $targetGroup, + //\Cake\ORM\ResultSet $groupNestings, + //bool $eligible, // XXX still needed? + bool $provision=true) { + // The operation we perform (add or delete) may be inverted by the CoGroupNesting + // configuration. + + // Our pseudologic for what to do here is as follows: + // t = isMemberOf($targetGroup) + // t' = shouldBeMemberOf($targetGroup) + // + // if(t && !t') addTo($targetGroup) + // elseif(!t && t') removeFrom($targetGroup) + + // $coPersonId should be a member of $targetGroup if any of the following are true + // (1) Nesting/Negate = false + // AND TargetGroup/Mode = any + // AND $sourceMember + // AND not a member of any source group for target where Nesting/Negate = true + // (2) Nesting/Negate = false + // AND TargetGroup/Mode = all + // AND member of all source groups for target + // AND not a member of any source group for target where Nesting/Negate = true + // (3) Nesting/Negate = true + // AND TargetGroup/Mode = any + // AND !$sourceMember + // AND member of any non-negated source group for target + // AND not a member of any source group for target where Nesting/Negate = true + // (4) Nesting/Negate = true + // AND TargetGroup/Mode = all + // AND !$sourceMember + // AND member of all non-negated source groups for target + // AND not a member of any source group for target where Nesting/Negate = true + + // As of v5.0.0, we clarify that if a Person is a member of multiple source + // Groups that convey nested membership into the target Group, we will create + // one membership _for each nesting_, regardless of the nesting mode. ie: + // if nesting_mode_all is true, then once the Person is a member of all + // source groups, they will receive the same number of memberships. + + // Pull the set of nestings for the target group. + + $groupNestings = $this->GroupNestings->find() + ->where(['GroupNestings.target_group_id' => $targetGroup->id]) + ->all(); + + // We convert $groupNestings to an array to avoid any confusion with the + // nested foreach() loops + foreach($groupNestings->toArray() as $groupNesting) { + $shouldBe = false; // Should $person be a member of $targetGroup? + $negated = false; // $person is ineligible for $targetGroup due to any negative membership + $isAny = false; // $person is a member of any (positive) source group for $targetGroup + $isAll = false; // $person is a member of all (positive) source groups for $targetGroup + $isCurrent = false; // $person is a member of $targetGroup due to $groupNesting + + // Walk all nestings to determine negation and current memberships. To track + // $isAll, we need at least one positive membership. In other words, a Target + // Group with only one Nesting, and that one Nesting is negative, does not + // automatically make everybody else a member. + $pAvail = 0; // Available positive memberships + $pCount = 0; // Actual positive memberships + + // Don't conflict with the outer foreach... + foreach($groupNestings->toArray() as $n) { + if($n->negate) { + // If this is the current nesting we don't need to look anything up + //if((($n->id == $groupNesting->id) && $sourceMember) + // || + if($this->isMember($n->group_id, $personId)) { + $negated = true; + } + } else { + $pAvail++; + + if($this->isMember($n->group_id, $personId)) { + $isAny = true; + $pCount++; + } + } + } + + // We need at least one positive group to count as ALL + $isAll = ($pAvail > 0 && $pCount == $pAvail); + + if(!$negated && !$targetGroup->nesting_mode_all && $isAny) { + // Case (1) and (3) + $shouldBe = true; + } elseif(!$negated && $targetGroup->nesting_mode_all && $isAll) { + // Case (2) and (4) + $shouldBe = true; + } + + // Is $personId already a member via this Nesting? + $memberEntity = $this->find() + ->where([ + 'group_id' => $targetGroup->id, + 'person_id' => $personId, + 'group_nesting_id' => $groupNesting->id + ]) + ->first(); + $isCurrent = !empty($memberEntity); + + if(!$isCurrent && $shouldBe) { + // Add a GroupMember record associated with this Nesting + + $membership = [ + 'group_id' => $targetGroup->id, + 'person_id' => $personId, + 'group_nesting_id' => $groupNesting->id + ]; + + $entity = $this->newEntity($membership); + + // XXX need to make sure $provision is honored here + $this->saveOrFail($entity, ['provision' => $provision]); + $this->llog('rule', "Added nested membership for Person ID $personId to Group ID " . $targetGroup->id . " (Group Nesting ID " . $groupNesting->id . ")"); + } elseif($isCurrent && !$shouldBe) { + // Remove the GroupMember associated with this Nesting + + $this->delete($memberEntity); + $this->llog('rule', "Removed nested membership for Person ID $personId from Group ID " . $targetGroup->id . " (Group Nesting ID " . $groupNesting->id . ")"); + } + } + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('group_id'); + + $validator->add('person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('person_id'); + + $validator->add('valid_from', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('valid_from'); + + $validator->add('valid_through', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('valid_through'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/GroupNestingsTable.php b/app/src/Model/Table/GroupNestingsTable.php new file mode 100644 index 000000000..ebd30ab8e --- /dev/null +++ b/app/src/Model/Table/GroupNestingsTable.php @@ -0,0 +1,393 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + // Group Nestings are not configuration + $this->setIsConfigurationTable(false); + + // Define associations + $this->belongsTo('Groups'); + $this->belongsTo('TargetGroups') + ->setClassName('Groups') + ->setForeignKey('target_group_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('target_group'); + + $this->hasMany('GroupMembers'); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('group_id'); + $this->setRequiresCO(true); + + $this->setEditContains(['Groups', 'TargetGroups']); + + $this->setIndexContains(['Groups', 'TargetGroups']); + + $this->setPermissions([ +// XXX update for couAdmins, group owners, etc + // 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'] + ] + ]); + } + + /** + * Obtain the set of groups available as a nesting target group for the + * specified source. + * + * @since COmanage Registry v5.0.0 + * @param int $id Group ID (of source Group) + * @return array Array of available target Groups, as returned by find('list') + */ + + public function availableGroups(int $groupId): array { + // Find the CO for $id. This will throw an exception if not found. + $sourceGroup = $this->Groups->get($id); + + // We don't remove groups that are already nested from the list -- we'll + // catch those in rule validation. + + return $this->Groups->find('list') + ->where([ + 'Groups.co_id' => $sourceGroup->co_id, + // AR-Group-Nesting-1 Only Active groups may be nested + 'Groups.status' => SuspendableStatusEnum::Active, + // AR-Group-Nesting-2 A group may not nest into itself + 'Groups.id IS NOT' => $id, + // AR-Group-Nesting-3 Automatic groups cannot be targets + 'OR' => [ + 'Groups.group_type NOT IN' => [GroupTypeEnum::ActiveMembers, GroupTypeEnum::AllMembers], + // Unclear why null values don't qualify for NOT IN... + 'Groups.group_type IS' => null + ] + ]) + ->order(['Groups.name' => 'ASC']) + ->toArray(); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // Since the groups in the nesting can't be changed after creation, these + // rules only need apply on new entity creation. + + // AR-Group-Nesting-1 Only Active groups may be nested + + $rules->addCreate([$this, 'ruleIsActive'], + 'isActive', + ['errorField' => 'target_group_id']); + + // AR-Group-Nesting-2 A group may not nest into itself + + $rules->addCreate([$this, 'ruleIsNotSource'], + 'isNotSource', + ['errorField' => 'target_group_id']); + + // AR-Group-Nesting-3 A group may not nest into an Automatic group + + $rules->addCreate([$this, 'ruleIsNotAutomatic'], + 'isNotAutomatic', + ['errorField' => 'target_group_id']); + + // AR-Group-Nesting-4 A group may not nest into the same target group + // more than once. + + $rules->addCreate([$this, 'ruleAlreadyNested'], + 'alreadyNested', + ['errorField' => 'target_group_id']); + + // AR-Group-Nesting-5 Group Nestings may not loop + + $rules->addCreate([$this, 'ruleLoops'], + 'loops', + ['errorField' => 'target_group_id']); + + return $rules; + } + + /** + * Check for loops in Group Nestings. + * + * @since COmanage Registry v5.0.0 + * @param int $groupId Source Group ID + * @param int $targetId Target Group ID + * @return bool true if no loop is detected, false otherwise + */ + + protected function checkLoops(int $groupId, int $targetId): bool { + // Pull the set of nestings for which $targetId is the source. + + $nestings = $this->find('all') + ->where(['GroupNestings.group_id' => $targetId]) + ->all(); + + foreach($nestings as $n) { + // If _this_ target_group_id matches $groupId then we have a loop. + // We also fail if this target_group_id is nested into $groupId. +// XXX do we need to maxrecursion this? + + if($n->target_group_id == $groupId + || !$this->checkLoops($groupId, $n->target_group_id)) { + return false; + } + } + + return true; + } + + /** + * Determine if the requested group already nests into the specified target. + * + * @since COmanage Registry v5.0.0 + * @param int $groupId Source Group ID + * @param int $targetId Target Group ID + * @return bool True if $groupId does not already nest into $targetId, false otherwise + */ + + protected function checkParents(int $groupId, int $targetId): bool { + // Pull the set of nestings for which $groupId is the source. + + $nestings = $this->find('all') + ->where(['GroupNestings.group_id' => $groupId]) + ->all(); + + foreach($nestings as $n) { + // We fail if this nesting points to $targetId, or if any nestings + // for _this_ target_group_id point to $targetId +// XXX do we need to maxrecursion this? + if($n->target_group_id == $targetId + || !$this->checkParents($n->target_group_id, $targetId)) { + return false; + } + } + + return true; + } + + /** + * 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 localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + // XXX This is temporary until JobShell is available (CFM-169), at which + // point either all reconciliation moves to JobShell, or maybe only if + // the source group has a large number of records to process (though then + // we have to cascade, so maybe that's too hard to figure out). + $this->Groups->reconcile($entity->target_group_id); + + return true; + } + + /** + * Application Rule to determine if the source group is already nested in the target. + * + * @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 ruleAlreadyNested($entity, $options) { + // The first (easy) check is if there is a direct pair already recorded + + $count = $this->find('all') + ->where([ + 'GroupNestings.group_id' => $entity->group_id, + 'GroupNestings.target_group_id' => $entity->target_group_id + ]) + ->count(); + + if($count > 0 || !$this->checkParents($entity->group_id, $entity->target_group_id)) { + return __d('error', 'GroupNestings.exists'); + } + + return true; + } + + /** + * Application Rule to determine if the target group is Active. + * + * @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 ruleIsActive($entity, $options) { + if(!empty($entity->target_group_id)) { + // get() throws an Exception if not found + $group = $this->Groups->get($entity->target_group_id); + + if($group->status != SuspendableStatusEnum::Active) { + return __d('error', 'GroupNestings.active', [$group->name]); + } + } + + return true; + } + + /** + * Application Rule to determine if the target group is (not) the source group. + * + * @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 ruleIsNotAutomatic($entity, $options) { + if(!empty($entity->target_group_id)) { + // get() throws an Exception if not found + $group = $this->Groups->get($entity->target_group_id); + + if($group->isAutomatic()) { + return __d('error', 'GroupNestings.automatic', [$group->name]); + } + } + + return true; + } + + /** + * Application Rule to determine if the target group is (not) the source group. + * + * @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 ruleIsNotSource($entity, $options) { + if($entity->group_id == $entity->target_group_id) { + return __d('error', 'GroupNestings.same'); + } + + return true; + } + + /** + * Application Rule to determine if the target group is already nested into the + * source group. + * + * @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 ruleLoops($entity, $options) { + if(!$this->checkLoops($entity->group_id, $entity->target_group_id)) { + return __d('error', 'GroupNestings.loop'); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('group_id'); + + $validator->add('target_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('target_group_id'); + + $validator->add('negate', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('negate'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/GroupOwnersTable.php b/app/src/Model/Table/GroupOwnersTable.php new file mode 100644 index 000000000..fcd14362d --- /dev/null +++ b/app/src/Model/Table/GroupOwnersTable.php @@ -0,0 +1,167 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + // Group Owners are not configuration + $this->setIsConfigurationTable(false); + + // Define associations + $this->belongsTo('Groups'); + $this->belongsTo('People'); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('group_id'); + $this->setRequiresCO(true); + + $this->setEditContains(['Groups', 'People.PrimaryName']); + + $this->setIndexContains(['Groups', 'People.PrimaryName']); + + $this->setPermissions([ +// XXX update for couAdmins, group owners, etc + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { +// XXX This isn't explicitly an Application Rule, should it be? +// XXX This isn't a great error message, GroupMember has a better one but needs additional +// context to construct it + $rules->add($rules->isUnique(['person_id', 'group_id'], __d('error', 'exists', [__d('controller', 'GroupOwners', [1])]))); + + return $rules; + } + + /** + * 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 localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + // Pull the related entities for HistoryRecord comment creation. + $person = $this->People->get($entity->person_id, ['contain' => ['PrimaryName']]); + $group = $this->Groups->get($entity->group_id); + + if($entity->isNew()) { + $action = ActionEnum::GroupOwnerAdded; + $comment = __d('result', 'GroupOwners.added', [$person->primary_name->full_name, $group->name]); + } elseif($entity->get('deleted')) { + $action = ActionEnum::GroupOwnerDeleted; + $comment = __d('result', 'GroupOwners.deleted', [$person->primary_name->full_name, $group->name]); + } else { + // GroupOwners can't currently be edited +// $action = ActionEnum::GroupOwnerEdited; +// $comment = __d('result', 'GroupOwners.edited', [$person->primary_name->full_name, $group->name, $this->changesToString($entity)]); + } + + $this->recordHistory($entity, $action, $comment); + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('group_id'); + + $validator->add('person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('person_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php new file mode 100644 index 000000000..9567dca50 --- /dev/null +++ b/app/src/Model/Table/GroupsTable.php @@ -0,0 +1,573 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + // Groups are not configuration + $this->setIsConfigurationTable(false); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('Cous'); + + $this->hasMany('GroupMembers') + ->setDependent(true); + $this->hasMany('GroupNestings') + ->setDependent(true); + $this->hasMany('GroupOwners') + ->setDependent(true); + $this->hasMany('HistoryRecords') + ->setDependent(true); + $this->hasMany('Identifiers') + ->setDependent(true); + + $this->setDisplayField('name'); + + $this->setPrimaryLink('co_id'); + $this->setAllowLookupPrimaryLink(['reconcile']); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ +// XXX update for couAdmins, etc + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'reconcile' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['reconcile'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ], + // Related models whose permissions we'll need, typically for table views + 'related' => [ +// XXX As a first pass, this (combined with the implementation in AppController::calculatePermissions) +// will render a link to group-members?group_id=X for all groups in the index view +// groups?co_id=2. This may or may not be right in the long term, eg for private +// groups. Maybe it's OK for now, since all groups are visible to all members of the CO. + 'GroupMembers', + 'GroupNestings', + 'GroupOwners', + 'HistoryRecords', + 'Identifiers' + ] + ]); + } + + /** + * Add the system groups for a CO or COU. (AR-CO-6, AR-COU-4) + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @param int $couId COU ID + * @param bool $rename If true, rename any existing groups + * @return bool True on success + * @throws InvalidArgumentException + * @throws RuntimeException + * @throws PersistenceFailedException + */ + + public function addDefaults(int $coId, int $couId=null, bool $rename=false): bool { + // Pull the name of the CO/COU + + $Cos = TableRegistry::getTableLocator()->get('Cos'); + + try { + $co = $Cos->get($coId); + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + throw new \InvalidArgumentException(__d('error', __d('controller', 'Cos', [1]))); + } + + $couName = null; + + if($couId) { + $Cous = TableRegistry::getTableLocator()->get('Cous'); + + try { + $cou = $Cous->get($couId); + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + throw new \InvalidArgumentException(__d('error', 'notfound', [__d('controller', 'Cous', [1])])); + } + + $couName = $cou->name; + } + + // The names get prefixed "CO" or "CO:COU:", as appropriate + + $defaultGroups = [ + ':admins' => [ + 'group_type' => GroupTypeEnum::Admins, + 'auto' => false, + 'description' => __d('field', 'Groups.desc.admins', [$couName ?: $co->name]), + 'open' => false, + 'status' => SuspendableStatusEnum::Active, + 'cou_id' => ($couId ?: null) + ], + ':members:active' => [ + 'group_type' => GroupTypeEnum::ActiveMembers, + 'auto' => true, + 'description' => __d('field', 'Groups.desc.members.active', [$couName ?: $co->name]), + 'open' => false, + 'status' => SuspendableStatusEnum::Active, + 'cou_id' => ($couId ?: null) + ], + ':members:all' => [ + 'group_type' => GroupTypeEnum::AllMembers, + 'auto' => true, + 'description' => __d('field', 'Groups.desc.members', [$couName ?: $co->name]), + 'open' => false, + 'status' => SuspendableStatusEnum::Active, + 'cou_id' => ($couId ?: null) + ], + ]; + + foreach($defaultGroups as $suffix => $attrs) { + // Construct the full group name + $gname = "CO" . ($couName ? ":COU:".$couName : "") . $suffix; + + // See if there is already a group with this type for this CO + + $grp = $this->find() + ->where([ + 'Groups.co_id' => $coId, + 'Groups.group_type' => $attrs['group_type'], + 'Groups.cou_id IS' => $couId ?: null + ]) + ->first(); + + if(!$grp) { + // No existing group, create a new one + + $entity = $this->newEntity($attrs); + $entity->co_id = $coId; + $entity->name = $gname; + + if(!$this->save($entity)) { + throw new \RuntimeException(__d('error', 'save', ['GroupsTable::addDefaults'])); + } + } elseif($rename) { + // We already have an entity, so just update the fields we need to change + $grp->name = $gname; + $grp->description = $attrs['description']; + + if(!$this->save($grp)) { + throw new \RuntimeException(__d('error', 'save', ['GroupsTable::addDefaults'])); + } + } + } + + 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-Group-1 Two Groups within the same CO cannot share the same name + $rules->add($rules->isUnique(['name', 'co_id'], __d('error', 'exists', [__d('controller', 'Groups', [1])]))); + + // AR-Group-2 A Group cannot be set to Suspended if it is nested into a + // Target Group or is a Target Group for a nesting. This and AR-Group-3 + // are to avoid unexpected consequences from implicitly undoing anesting... + // the administrator must do that first. + $rules->addUpdate([$this, 'ruleIsNested'], + 'isNestedUpdate', + ['errorField' => 'status']); + + // AR-Group-3 A Group cannot be deleted if it is nested into a Target Group + // or is a Target Group for a nesting + $rules->addDelete([$this, 'ruleIsNested'], + 'isNestedDelete', + ['errorField' => 'status']); + + return $rules; + } + + /** + * Obtain an iterator for all members of the requested Group. + * + * @since COmanage Registry v5.0.0 + * @param int $id Group ID + * @param int $groupNestingId If provided, only members due to this Group Nesting ID + * @return PaginatedSqlIterator Iterator for GroupMembers + */ + + public function getMembers(int $id, int $groupNestingId=null): PaginatedSqlIterator { + $conditions = [ + 'group_id' => $id, +// XXX add check for valid_from/through and test +// 'valid_from' +// 'valid_through' + ]; + + if($groupNestingId) { + $conditions['group_nesting_id'] = $groupNestingId; + } + + return new PaginatedSqlIterator($this->GroupMembers->getTarget(), $conditions); + } + + /** + * Get Members of this Group who are Members due to the specified Group + * Nesting ID. + * + * @since COmanage Registry v5.0.0 + * @param int $id Group ID + * @param int $groupNestingId Group Nesting ID + * @return PaginatedSqlIterator Iterator for GroupMembers + */ + + public function getMembersViaNesting(int $id, int $groupNestingId): PaginatedSqlIterator { + return $this->getMembers($id, $groupNestingId); + } + + /** + * 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 localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + if($entity->isNew()) { + $action = ActionEnum::GroupAdded; + $comment = __d('result', 'Groups.added', [$entity->name]); + } elseif($entity->get('deleted')) { + $action = ActionEnum::GroupDeleted; + $comment = __d('result', 'Groups.deleted', [$entity->name]); + } else { + $action = ActionEnum::GroupEdited; + $comment = __d('result', 'Groups.edited', [$entity->name, $this->changesToString($entity)]); + } + + $this->recordHistory($entity, $action, $comment); + + return true; + } + + /** + * Reconcile the members of an automatic or nested Group. + * + * @since COmanage Registry v5.0.0 + * @param int $id Group ID + */ + + public function reconcile(int $id) { + $group = $this->get($id); + + if($group->isAutomatic()) { + $this->reconcileAutomaticGroup($group); + } else { + $this->reconcileNestedMemberships($group); + } + } + + /** + * Reconcile the members of an automatic Group. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity Group + */ + + protected function reconcileAutomaticGroup(\Cake\Datasource\EntityInterface $entity) { + // In order to handle very large groups, we can't pull the full set of + // members into memory. Instead, we use the paginated iterator. This + // involves two passes. + + // First, we pull the current members of the Group, and for each member + // make sure they are still eligible. + + $iterator = $this->getMembers($entity->id); + + foreach($iterator as $k => $groupMember) { + if(!empty($entity->cou_id)) { + if($entity->group_type == GroupTypeEnum::ActiveMembers) { + // If $groupMember is not an active member of cou_id, remove the membership + if(!$this->Cous->PersonRoles->hasActive($groupMember->person_id, $entity->cou_id)) { + $this->llog('rule', "AR-PersonRole-2 Reconciliation removing membership for Person ID " . $groupMember->person_id . " from Group ID " . $groupMember->group_id); + $this->GroupMembers->delete($groupMember); + } + } else { + // If $groupMember does not have any role in cou_id, remove the membership + if(!$this->Cous->PersonRoles->hasAny($groupMember->person_id, $entity->cou_id)) { + $this->llog('rule', "AR-PersonRole-1 Reconciliation removing membership for Person ID " . $groupMember->person_id . " from Group ID " . $groupMember->group_id); + $this->GroupMembers->delete($groupMember); + } + } + } else { + // Look at the Person record + $person = $this->GroupMembers->People->get($groupMember->person_id); + + if($entity->group_type == GroupTypeEnum::ActiveMembers) { + if(!$person || !$person->isActive()) { + $this->llog('rule', "AR-Person-2 Reconciliation removing membership for Person ID " . $groupMember->person_id . " from Group ID " . $groupMember->group_id); + $this->GroupMembers->delete($groupMember); + } + } else { + if(!$person || $person->status == StatusEnum::Deleted) { + $this->llog('rule', "AR-Person-1 Reconciliation removing membership for Person ID " . $groupMember->person_id . " from Group ID " . $groupMember->group_id); + $this->GroupMembers->delete($groupMember); + } + } + } + } + + // Second, we pull the members of the CO/COU and make sure they have the + // correlated membership. + + if(!empty($entity->cou_id)) { + // This won't return roles in Deleted status, but returns all others + $iterator = $this->Cous->PersonRoles->getMembers($entity->cou_id); + + foreach($iterator as $k => $personRole) { + if($entity->group_type == GroupTypeEnum::AllMembers + || $personRole->isActive()) { + // Check if the Person is already a member of the Group + if(!$this->GroupMembers->isMember($entity->id, $personRole->person_id)) { + // Add the membership + + $membership = [ + 'group_id' => $entity->id, + 'person_id' => $personRole->person_id + ]; + + $gmEntity = $this->GroupMembers->newEntity($membership); + + $this->GroupMembers->saveOrFail($gmEntity); + $this->llog('rule', ($entity->group_type == GroupTypeEnum::AllMembers ? "AR-PersonRole-2" : "AR-PersonRole-1") . " Reconciliation added automatic membership for Person ID " . $personRole->person_id . " to Group ID " . $entity->id); + } + } + } + } else { + $iterator = $this->People->getMembers($entity->co_id); + + foreach($iterator as $k => $person) { + if($entity->group_type == GroupTypeEnum::AllMembers + || $person->isActive()) { + // Add the membership + + $membership = [ + 'group_id' => $entity->id, + 'person_id' => $person->id + ]; + + $entity = $this->GroupMembers->newEntity($membership); + + $this->GroupMembers->saveOrFail($entity); + $this->llog('rule', ($entity->group_type == GroupTypeEnum::AllMembers ? "AR-Person-2" : "AR-Person-1") . " Reconciliation added automatic membership for Person ID " . $person->id . " to Group ID " . $entity->id); + } + } + } + + return; + } + + /** + * Reconcile the members of a nested Group. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity Group + */ + + protected function reconcileNestedMemberships(\Cake\Datasource\EntityInterface $entity) { + // When a new GroupNesting is saved, we're called on the _target_. + + // Start by pulling the Group Nestings for this Group. We'll only go one level deep. + + $groupNestings = $this->GroupNestings->find() + ->where(['GroupNestings.target_group_id' => $entity->id]) + ->all(); + + // First iterate through the current members of the target group (who are + // members due to one of the nestings) and recheck their eligibility. This + // will remove anyone who is no longer eligible. + + // We convert $groupNestings to an array for the outer loop to ensure we don't + // have conflicts with the next loop + foreach($groupNestings->toArray() as $groupNesting) { + $iterator = $this->getMembersViaNesting($groupNesting->target_group_id, $groupNesting->id); + + foreach($iterator as $k => $targetGroupMember) { + $this->GroupMembers->syncNestedMembership($targetGroupMember->person_id, + $entity); + } + } + + // Next, for each nesting iterate through the members of that nesting and + // recheck their eligibility. This will add in anyone who is now eligible. + // (We do this second since the first iteration might shrink the population + // to check here.) + + foreach($groupNestings->toArray() as $groupNesting) { + $iterator = $this->getMembers($groupNesting->group_id); + + foreach($iterator as $k => $sourceGroupMember) { + $this->GroupMembers->syncNestedMembership($sourceGroupMember->person_id, + $entity); + } + } + + return true; + } + + /** + * Application Rule to determine if the group is nested. + * + * @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 ruleIsNested($entity, $options) { + // We check that the subject group is either a source or a target, but + // only if the $entity status is Suspended. + + if($entity->status == SuspendableStatusEnum::Suspended) { + $count = $this->GroupNestings->find('all') + ->where([ + 'OR' => [ + 'GroupNestings.group_id' => $entity->id, + 'GroupNestings.target_group_id' => $entity->id + ] + ]) + ->count(); + + if($count > 0) { + return __d('error', 'Groups.nested'); + } + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $validator->add('cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('cou_id'); + + $this->registerStringValidation($validator, $schema, 'name', true); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('open', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('open'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('group_type', [ + 'content' => ['rule' => ['inList', GroupTypeEnum::getConstValues()]] + ]); + $validator->notEmptyString('group_type'); + + $validator->add('nesting_mode_all', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('nesting_mode_all'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/HistoryRecordsTable.php b/app/src/Model/Table/HistoryRecordsTable.php index fb08bef6c..0a87dc3bf 100644 --- a/app/src/Model/Table/HistoryRecordsTable.php +++ b/app/src/Model/Table/HistoryRecordsTable.php @@ -57,6 +57,9 @@ public function initialize(array $config): void { $this->setIsConfigurationTable(false); // Define associations + $this->belongsTo('ApiUser') + ->setForeignKey('actor_api_user_id') + ->setProperty('actor_api_user'); $this->belongsTo('ActorPeople') ->setClassName('People') ->setForeignKey('actor_person_id') @@ -67,12 +70,13 @@ public function initialize(array $config): void { $this->belongsTo('PersonRoles'); $this->belongsTo('ExternalIdentities'); $this->belongsTo('ExternalIdentityRoles'); + $this->belongsTo('Groups'); $this->setDisplayField('comment'); // XXX note primary link is external_identity_id when set... // or the other fields as we add them - $this->setPrimaryLink(['external_identity_id', 'person_id']); + $this->setPrimaryLink(['external_identity_id', 'group_id', 'person_id']); $this->setAllowLookupPrimaryLink(['primary']); $this->setRequiresCO(true); @@ -91,7 +95,8 @@ public function initialize(array $config): void { 'ActorPeople' => ['Names' => ['queryBuilder' => function ($q) { return $q->where(['primary_name' => true]); }]], - 'ExternalIdentities' => ['PrimaryName'] + 'ExternalIdentities' => ['PrimaryName'], + 'Groups' ]); $this->setPermissions([ @@ -124,6 +129,38 @@ public function generateDisplayField(\App\Model\Entity\HistoryRecord $entity): s return __d('controller', 'HistoryRecords', [1]); } + /** + * Record a History Record entry for a Group. + * + * @since COmanage Registry v5.0.0 + * @param int $groupId Group ID + * @param string $action Action + * @param string $comment Comment + * @param int $personId Person ID + * @return int History Record ID + */ + + public function recordForGroup(int $groupId, + string $action, + string $comment, + ?int $personId=null): int { + $record = [ + 'group_id' => $groupId, + 'action' => $action, + 'comment' => $comment + ]; + + if($personId) { + $record['person_id'] = $personId; + } + + $obj = $this->newEntity($record); + + $this->saveOrFail($obj); + + return $obj->id; + } + /** * Record a History Record entry for a Person. * diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 867202eb7..5135dc526 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -35,6 +35,7 @@ class IdentifiersTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; @@ -85,13 +86,14 @@ public function initialize(array $config): void { $this->setIsConfigurationTable(false); // Define associations - $this->belongsTo('People'); $this->belongsTo('ExternalIdentities'); + $this->belongsTo('Groups'); + $this->belongsTo('People'); $this->belongsTo('Types'); $this->setDisplayField('identifier'); - $this->setPrimaryLink(['external_identity_id', 'person_id']); + $this->setPrimaryLink(['external_identity_id', 'group_id', 'person_id']); $this->setAllowLookupPrimaryLink(['primary']); $this->setRequiresCO(true); @@ -132,7 +134,7 @@ public function initialize(array $config): void { * @return bool True on success */ - public function afterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { $this->recordHistory($entity); return true; diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index 2f36199fb..bf1b7fec6 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -39,6 +39,7 @@ class NamesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; @@ -114,6 +115,31 @@ public function initialize(array $config): void { ]); } + /** + * 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']); + + // AR-Name-1 The Primary Name cannot be deleted. + $rules->addDelete([$this, 'rulePrimaryNameDelete'], + 'primaryNameDelete', + ['errorField' => 'primary_name']); + + return $rules; + } + /** * Callback after model save. * @@ -124,12 +150,7 @@ public function initialize(array $config): void { * @return bool True on success */ - public function afterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { - // If we have a parent, we're creating a changelog archive, which we don't want to modify - if($entity->name_id) { - return true; - } - + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { $this->recordHistory($entity); // AR-Name-1 A Person must have exactly one Primary Name at all times. @@ -168,31 +189,6 @@ public function afterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\En 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']); - - // AR-Name-1 The Primary Name cannot be deleted. - $rules->addDelete([$this, 'rulePrimaryNameDelete'], - 'primaryNameDelete', - ['errorField' => 'primary_name']); - - return $rules; - } /** * Obtain the primary name entity for a person. diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 572384117..d1d961854 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -33,12 +33,16 @@ use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\Validation\Validator; +use \App\Lib\Enum\GroupTypeEnum; use \App\Lib\Enum\StatusEnum; +use \App\Lib\Util\PaginatedSqlIterator; class PeopleTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\QueryModificationTrait; @@ -75,6 +79,10 @@ public function initialize(array $config): void { ->setDependent(true); $this->hasMany('EmailAddresses') ->setDependent(true); + $this->hasMany('GroupMembers') + ->setDependent(true); + $this->hasMany('GroupOwners') + ->setDependent(true); $this->hasMany('HistoryRecords') ->setDependent(true); $this->hasMany('Identifiers') @@ -134,6 +142,27 @@ public function initialize(array $config): void { ]); } + /** + * 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 localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + // XXX implement this eventually? + //$provision = (isset($options['provision']) ? $options['provision'] : true); + + $this->reconcileCoMembersGroupMemberships($entity); + + return true; + } + /** * Table specific logic to generate a display field. * @@ -150,6 +179,72 @@ public function generateDisplayField(\App\Model\Entity\Person $entity): string { return $entity->primary_name->full_name; } + /** + * Obtain an iterator for the set of Members in the specified CO. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @return PaginatedSqlIterator Iterator for People + */ + + public function getMembers(int $coId): PaginatedSqlIterator { + $conditions = [ + 'co_id' => $coId, + 'status IS NOT' => StatusEnum::Deleted + ]; + + return new PaginatedSqlIterator($this, $conditions); + } + + /** + * Reconcile memberships in CO members groups based on the Person entity. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity Person Entity + * @param bool $provision Whether to run provisioners + * @throws InvalidArgumentException + * @throws RuntimeException + */ + + public function reconcileCoMembersGroupMemberships(\Cake\Datasource\EntityInterface $entity, bool $provision=true) { + // This is similar to PersonRole::reconcileCouMembersGroupMemberships. + + $activeEligible = $entity->isActive(); + $allEligible = $entity->status != StatusEnum::Deleted; + + // Update the automatic CO groups + $this->llog('rule', "AR-Person-1 Syncing membership in All Members Group for CO " . $entity->co_id . " for Person " . $entity->id . ", eligibility=" . $allEligible); + $this->GroupMembers->syncAutomaticMembership(GroupTypeEnum::AllMembers, null, $entity->id, $allEligible, $provision); + $this->llog('rule', "AR-Person-2 Syncing membership in Active Members Group for CO " . $entity->co_id . " for Person " . $entity->id . ", eligibility=" . $activeEligible); + $this->GroupMembers->syncAutomaticMembership(GroupTypeEnum::ActiveMembers, null, $entity->id, $activeEligible, $provision); + + // Pull the Person Roles for this Person. Note if COUs are not in use this + // will be a bit of extra work, but probably not worth worrying about. + + $personRoles = $this->PersonRoles->find('all') + ->where(['person_id' => $entity->id]) + ->all(); + + foreach($personRoles as $role) { + if(!empty($role->cou_id)) { + // If the Person is not $allEligible, then no COU groups are eligible either. + + if($allEligible) { + // If a Person has multiple roles in the same COU, we'll be doing a bit + // of extra work in calling reconcileCouMembersGroupMemberships multiple + // times, since it will correctly handle multiple roles in the same COU + // in a single call. + + $this->PersonRoles->reconcileCouMembersGroupMemberships($role, $provision, $activeEligible); + } else { + // Make sure there are no memberships for this COU + $this->GroupMembers->syncAutomaticMembership(GroupTypeEnum::AllMembers, $role->cou_id, $entity->id, false, $provision); + $this->GroupMembers->syncAutomaticMembership(GroupTypeEnum::ActiveMembers, $role->cou_id, $entity->id, false, $provision); + } + } + } + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php index 7519ec709..866234566 100644 --- a/app/src/Model/Table/PersonRolesTable.php +++ b/app/src/Model/Table/PersonRolesTable.php @@ -33,18 +33,38 @@ use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\Validation\Validator; +use \App\Lib\Enum\GroupTypeEnum; use \App\Lib\Enum\StatusEnum; +use \App\Lib\Util\PaginatedSqlIterator; class PersonRolesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; + use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; + // Default "out of the box" types for this model. Entries here should be + // given a default localization in app/resources/locales/*/defaultType.po + protected $defaultTypes = [ + 'affiliation' => [ + 'affiliate', + 'alum', + 'employee', + 'faculty', + 'librarywalkin', + 'member', + 'staff', + 'student' + ] + ]; + /** * Perform Cake Model initialization. * @@ -57,6 +77,7 @@ public function initialize(array $config): void { $this->addBehavior('Changelog'); $this->addBehavior('Log'); $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); // Person Roles are not configuration $this->setIsConfigurationTable(false); @@ -160,6 +181,204 @@ public function generateDisplayField(\App\Model\Entity\PersonRole $entity): stri return (string)$entity->id; } + /** + * Obtain an iterator for the set of Members in the specified COU. + * + * @since COmanage Registry v5.0.0 + * @param int $couId COU ID + * @return PaginatedSqlIterator Iterator for Person Roles + */ + + public function getMembers(int $couId): PaginatedSqlIterator { + // We don't explicitly look at valid from/through, instead we expect that + // Expiration Policies will correctly set status. + $conditions = [ + 'cou_id' => $couId, + 'status IS NOT' => StatusEnum::Deleted + ]; + + return new PaginatedSqlIterator($this, $conditions); + } + + /** + * Determine if the specified Person has a valid Person Role in the specified + * COU. Note this function only looks at status, not validity dates. + * + * @param int $personId Person ID + * @param int $couId COU ID + * @return bool True if the Person has at least one active Person Role, false otherwise + */ + + public function hasActive(int $personId, int $couId): bool { + // We return true if the Person has at least one active Role in the + // specified COU. We ignore validity dates, expecting instead that + // expiration policies will correctly update the Role status as needed. + + // We need to examine the status of all roles in the COU, not just the current + // one, to see if the person is eligible for the relevant members group. + + $roles = $this->find('all') + ->where(['person_id' => $personId, + 'cou_id' => $couId]) + ->all(); + + foreach($roles as $role) { + // Any one active role is sufficient + + if($role->isActive()) { + return true; + } + } + + return false; + } + + /** + * Determine if the specified Person has any Person Role in the specified COU. + * + * @param int $personId Person ID + * @param int $couId COU ID + * @return bool True if the Person has at least one Person Role, false otherwise + */ + + public function hasAny(int $personId, int $couId): bool { + // We return true if the Person has at least one Role in the specified COU, + // regardless of status. + + // We need to examine the status of any roles returned since a Deleted Role + // does not count as "Any" Role. + + $roles = $this->find('all') + ->where(['person_id' => $personId, + 'cou_id' => $couId]) + ->all(); + + if(empty($roles)) { + return false; + } + + foreach($roles as $role) { + // Any non-deleted role is sufficient + if($role->status != StatusEnum::Deleted) { + return true; + } + } + + return false; + } + + /** + * 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 localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + $this->recordHistory($entity); + + $this->reconcileCouMembersGroupMemberships($entity); + + return true; + } + + /** + * Reconcile memberships in COU members groups based on the + * PersonRole(s) for a Person and the COU(s) for those roles. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity PersonRole Entity + * @param bool $provision Whether to run provisioners + * @param bool $personActive If false, role is not eligible for Active Members Group + * @throws InvalidArgumentException + * @throws RuntimeException + */ + + public function reconcileCouMembersGroupMemberships(\Cake\Datasource\EntityInterface $entity, bool $provision=true, bool $personActive=true) { + // First see if there is a COU associated with this Role. + + if(!$entity->cou_id) { + if(!$entity->isNew()) { + // If we're going from a COU to no COU we need to remove the automatic + // group memberships (the inverse of below, where we go from no COU to + // having a COU) + + $oldCouId = $entity->getOriginal('cou_id'); + + if($oldCouId) { + $this->llog('rule', "AR-PersonRole-1 Removing PersonRole " . $entity->id . " (Person " . $entity->person_id . ") from All Members Group for COU " . $oldCouId . " due to removal of Person Role from COU"); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::AllMembers, $oldCouId, $entity->person_id, false, $provision); + $this->llog('rule', "AR-PersonRole-2 Removing PersonRole " . $entity->id . " (Person " . $entity->person_id . ") from Active Members Group for COU " . $oldCouId . " due to removal of Person Role from COU"); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::ActiveMembers, $oldCouId, $entity->person_id, false, $provision); + } + } + + // Since there is no COU associated with this Person Role, there is + // nothing else to do + + return; + } + + if(!$entity->person_id) { + // We're probably deleting the CO + return; + } + + // We need to examine the status of all roles in the COU, not just the current + // one, to see if the person is eligible for the relevant members group. + + $roles = $this->find('all') + ->where(['person_id' => $entity->person_id, + 'cou_id' => $entity->cou_id]) + ->all(); + + // For $activeEligible, we need at least one active role + $activeRole = false; + + // For $allEligible, we need at least one role not Deleted + $allEligible = false; + + foreach($roles as $role) { + if($role->isActive()) { + $activeRole = true; + } + + if($role->status != StatusEnum::Deleted) { + $allEligible = true; + } + } + + $activeEligible = $personActive && $activeRole; + + // Create or remove memberships for the Active and All groups for this COU. + + $this->llog('rule', "AR-PersonRole-1 Syncing membership in All Members Group for COU " . $entity->cou_id . " for PersonRole " . $entity->id . " (Person " . $entity->person_id . "), eligibility=" . $allEligible); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::AllMembers, $entity->cou_id, $entity->person_id, $allEligible, $provision); + $this->llog('rule', "AR-PersonRole-2 Syncing membership in Active Members Group for COU " . $entity->cou_id . " for PersonRole " . $entity->id . " (Person " . $entity->person_id . "), eligibility=" . $activeEligible); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::ActiveMembers, $entity->cou_id, $entity->person_id, $activeEligible, $provision); + + if(!$entity->isNew()) { + // Remove group memberships if the COU ID (PersonRole moved) or Person ID + // (PersonRole relinked) has changed. + + if($entity->get('cou_id') !== $entity->getOriginal('cou_id')) { + // We must have a COU ID, since we checked above for the case where we don't + $oldCouId = $entity->getOriginal('cou_id'); + + if($oldCouId) { + $this->llog('rule', "AR-PersonRole-1 Removing PersonRole " . $entity->id . " (Person " . $entity->person_id . ") from All Members Group for COU " . $oldCouId . " due to removal of Person Role from COU"); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::AllMembers, $oldCouId, $entity->person_id, false, $provision); + $this->llog('rule', "AR-PersonRole-1 Removing PersonRole " . $entity->id . " (Person " . $entity->person_id . ") from All Members Group for COU " . $oldCouId . " due to removal of Person Role from COU"); + $this->People->GroupMembers->syncAutomaticMembership(GroupTypeEnum::ActiveMembers, $oldCouId, $entity->person_id, false, $provision); + } + // else no prior COU ID, nothing to do + } + } + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index 400e46e14..2080e0f74 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -35,6 +35,7 @@ class TelephoneNumbersTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; @@ -117,7 +118,7 @@ public function initialize(array $config): void { * @return bool True on success */ - public function afterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { $this->recordHistory($entity); return true; diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php index 54b6952a0..bfb180ff2 100644 --- a/app/src/Model/Table/TypesTable.php +++ b/app/src/Model/Table/TypesTable.php @@ -328,7 +328,7 @@ public function validationDefault(Validator $validator): Validator { 'length' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn('value')]], 'provider' => 'table'], 'value' => ['rule' => ['custom', '/^[a-zA-Z0-9\-\.]+$/'], - 'message' => __d('error', 'input.invalid.url')] + 'message' => __d('error', 'input.invalid')] ]); $validator->notEmptyString('value'); diff --git a/app/src/Model/Table/UrlsTable.php b/app/src/Model/Table/UrlsTable.php index 94100fd5d..1f260b273 100644 --- a/app/src/Model/Table/UrlsTable.php +++ b/app/src/Model/Table/UrlsTable.php @@ -34,6 +34,7 @@ class UrlsTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; + use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\PermissionsTrait; @@ -110,7 +111,7 @@ public function initialize(array $config): void { * @return bool True on success */ - public function afterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { $this->recordHistory($entity); return true; diff --git a/app/templates/GroupMembers/columns.inc b/app/templates/GroupMembers/columns.inc new file mode 100644 index 000000000..e213ddf9c --- /dev/null +++ b/app/templates/GroupMembers/columns.inc @@ -0,0 +1,58 @@ + [ + 'type' => 'relatedLink', + 'model' => 'person', + 'submodel' => 'primary_name', + 'field' => 'full_name', +// XXX not clear how to sort on submodel, but maybe just leave it for UX revisions? +// 'sortable' => 'PrimaryName.family' + ], + 'source' => [ + 'type' => 'closure', + 'function' => function ($entity) { + if(!empty($entity->group_nesting_id)) { + if(!empty($entity->group_nesting->group->name)) { + return $this->Html->link($entity->group_nesting->group->name, + ['controller' => 'groups', 'action' => 'edit', $entity->group_nesting->group->id]); + } else { + return __d('controller', 'GroupNestings', [1]); + } + } else { + return __d('field', 'GroupMembers.source.direct'); + } + } + ], + 'valid_from' => [ + 'type' => 'datetime' + ], + 'valid_through' => [ + 'type' => 'datetime' + ] +]; \ No newline at end of file diff --git a/app/templates/GroupMembers/fields.inc b/app/templates/GroupMembers/fields.inc new file mode 100644 index 000000000..559a946a2 --- /dev/null +++ b/app/templates/GroupMembers/fields.inc @@ -0,0 +1,40 @@ +Field->control('person_id', ['type' => 'text']); +} else { + print $this->Form->hidden('person_id'); +} + +// XXX valid from should default to 00:00:00 time, valid through to 23:59:59 +print $this->Field->control('valid_from'); + +print $this->Field->control('valid_through'); + +// XXX RFE: Add links to EIS or Nesting info \ No newline at end of file diff --git a/app/templates/GroupNestings/columns.inc b/app/templates/GroupNestings/columns.inc new file mode 100644 index 000000000..e1d6e3698 --- /dev/null +++ b/app/templates/GroupNestings/columns.inc @@ -0,0 +1,35 @@ + [ + 'label' => __d('field', 'GroupNestings.target_group_id'), + 'type' => 'relatedLink', + 'model' => 'target_group', + 'field' => 'name' + ] +]; \ No newline at end of file diff --git a/app/templates/GroupNestings/fields.inc b/app/templates/GroupNestings/fields.inc new file mode 100644 index 000000000..f913a1f21 --- /dev/null +++ b/app/templates/GroupNestings/fields.inc @@ -0,0 +1,47 @@ +Field->control('target_group_id'); +} elseif($vv_action == 'edit') { + // The target group can't be changed after adding + print $this->Field->statusControl('target_group_id', + $vv_obj->target_group->name, + ['url' => ['controller' => 'groups', + 'action' => 'edit', + $vv_obj->target_group_id]]); +} + +if($vv_action == 'add' || $vv_action == 'edit') { + print $this->Field->statusControl('group_id', + $vv_bc_parent_obj->name, + ['url' => ['controller' => 'groups', + 'action' => 'edit', + $vv_bc_parent_obj->id]]); + + print $this->Field->control('negate'); +} \ No newline at end of file diff --git a/app/templates/GroupOwners/columns.inc b/app/templates/GroupOwners/columns.inc new file mode 100644 index 000000000..fcda854dd --- /dev/null +++ b/app/templates/GroupOwners/columns.inc @@ -0,0 +1,37 @@ + [ + 'type' => 'relatedLink', + 'model' => 'person', + 'submodel' => 'primary_name', + 'field' => 'full_name', +// XXX not clear how to sort on submodel, but maybe just leave it for UX revisions? +// 'sortable' => 'PrimaryName.family' + ] +]; \ No newline at end of file diff --git a/app/templates/GroupOwners/fields.inc b/app/templates/GroupOwners/fields.inc new file mode 100644 index 000000000..302546218 --- /dev/null +++ b/app/templates/GroupOwners/fields.inc @@ -0,0 +1,32 @@ +Field->control('person_id', ['type' => 'text']); +} \ No newline at end of file diff --git a/app/templates/Groups/columns.inc b/app/templates/Groups/columns.inc new file mode 100644 index 000000000..087c4106d --- /dev/null +++ b/app/templates/Groups/columns.inc @@ -0,0 +1,76 @@ + [ + 'type' => 'link' + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'StatusEnum', + 'sortable' => true + ], + 'description' => [ + 'type' => 'echo' + ] +]; + +// XXX This should show on fields.inc and more generally perhaps we should merge +// this with the set of buttons available on the add-edit-view page (maybe just +// have that page use $indexActions?) (CO-647) +$indexActions = [ + [ + 'controller' => 'group_members', + 'action' => 'index', + 'icon' => 'people', + ], + [ + 'controller' => 'group_owners', + 'action' => 'index', + 'icon' => 'settings' + ], + [ + 'controller' => 'group_nestings', + 'action' => 'index', + 'icon' => 'group_add' + ], + [ + 'controller' => 'history_records', + 'action' => 'index', + 'icon' => 'history' + ], + [ + 'controller' => 'identifiers', + 'action' => 'index', + 'icon' => 'label', + 'if' => 'notAutomatic' + ], + [ + 'action' => 'reconcile', + 'icon' => 'sync' + ] +]; \ No newline at end of file diff --git a/app/templates/Groups/fields.inc b/app/templates/Groups/fields.inc new file mode 100644 index 000000000..ce4e5833e --- /dev/null +++ b/app/templates/Groups/fields.inc @@ -0,0 +1,36 @@ +Field->control('name'); + +print $this->Field->control('description'); + +print $this->Field->control('status'); + +print $this->Field->control('open'); + +print $this->Field->control('nesting_mode_all'); diff --git a/app/templates/HistoryRecords/fields.inc b/app/templates/HistoryRecords/fields.inc index 639d2ab98..45c57a180 100644 --- a/app/templates/HistoryRecords/fields.inc +++ b/app/templates/HistoryRecords/fields.inc @@ -105,6 +105,22 @@ if($vv_action == 'add' || $vv_action == 'view') { ); } + if(!empty($vv_obj->group_id)) { + $viewLink = [ + 'url' => [ + 'controller' => 'groups', + 'action' => 'edit', + $vv_obj->group_id + ], + ]; + + print $this->Field->statusControl( + 'group_id', + $vv_obj->group->name, + $viewLink + ); + } + if(!empty($vv_obj->actor_person->names)) { $viewLink = [ 'url' => [ diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index b51b3b200..b3b437dec 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -40,6 +40,7 @@ // $tablename = models // XXX backport to match? $tableName = Inflector::tableize(Inflector::singularize($this->name)); +$tableFK = Inflector::singularize($tableName) . "_id"; // Do we have records for this index? This will be set to true during render if we do. // Otherwise, we'll print out a "no records" message. @@ -198,36 +199,6 @@ function _column_key($modelsName, $c, $tz=null) { print __d('enumeration', $cfg['class'].'.0') . $suffix; } break; - case 'datetime': - // XXX dates can be rendered as eg $entity->created->format(DATE_RFC850); - print $this->Time->nice($entity->$col, $vv_tz) . $suffix; - break; - case 'enum': - if($entity->$col) { - // XXX Need to add badging - see index.php in Match - print __d('enumeration', $cfg['class'].'.'.$entity->$col) . $suffix; - } - break; - case 'fk': - // Assuming $col is of the form foo_id, look to see if the corresponding - // AutoViewVar $foos is set, and if so render the lookup value instead - $f = null; - if(preg_match('/^(.*?)_id$/', $col, $f)) { - $avv = Inflector::variable(Inflector::pluralize($f[1])); - - if(!empty(${$avv}[$entity->$col])) { - // We found the viewvar (eg: $foos), and it has a corresponding value - // (eg: $foos[3]), so render it - print ${$avv}[$entity->$col]. $suffix; // XXX filter_var? - } else { - // No match, just render the value - print $entity->$col. $suffix; - } - } else { - // Just print the value - print $entity->$col. $suffix; - } - break; case 'button': if(!empty($entity->$col)) { $buttonAttrs = []; @@ -260,6 +231,42 @@ function _column_key($modelsName, $c, $tz=null) { print $this->Form->button($buttonText, $buttonAttrs); } break; + case 'closure': + $fn = $cfg['function']; + print $fn($entity); + break; + case 'datetime': + // XXX dates can be rendered as eg $entity->created->format(DATE_RFC850); + if(!empty($entity->$col)) { + print $this->Time->nice($entity->$col, $vv_tz) . $suffix; + } + break; + case 'enum': + if($entity->$col) { + // XXX Need to add badging - see index.php in Match + print __d('enumeration', $cfg['class'].'.'.$entity->$col) . $suffix; + } + break; + case 'fk': + // Assuming $col is of the form foo_id, look to see if the corresponding + // AutoViewVar $foos is set, and if so render the lookup value instead + $f = null; + if(preg_match('/^(.*?)_id$/', $col, $f)) { + $avv = Inflector::variable(Inflector::pluralize($f[1])); + + if(!empty(${$avv}[$entity->$col])) { + // We found the viewvar (eg: $foos), and it has a corresponding value + // (eg: $foos[3]), so render it + print ${$avv}[$entity->$col]. $suffix; // XXX filter_var? + } else { + // No match, just render the value + print $entity->$col. $suffix; + } + } else { + // Just print the value + print $entity->$col. $suffix; + } + break; case 'link': case 'relatedLink': case 'echo': @@ -363,16 +370,25 @@ function _column_key($modelsName, $c, $tz=null) { // if(isset($entity->status) && $entity->status == StatusEnum::Active) { $actionOrderDefault = $this->Menu->getMenuOrder('Default'); foreach($indexActions as $a) { - if($vv_permission_set[$entity->id][ $a['action'] ]) { - // If there's a conditional on the field, test the entity - if(!empty($a['if'])) { - $f = $a['if']; - - if(!$entity->$f()) { - continue; - } + $ok = false; + if(!empty($a['controller'])) { + $tableName = Inflector::camelize($a['controller']); + + if(isset($vv_permission_set[$entity->id][$tableName][ $a['action'] ])) { + $ok = $vv_permission_set[$entity->id][$tableName][ $a['action'] ]; } + } else { + $ok = $vv_permission_set[$entity->id][ $a['action'] ]; + } + + if($ok && !empty($a['if'])) { + // If there's a conditional on the field, test the entity + $f = $a['if']; + $ok = $entity->$f(); + } + + if($ok) { $actionOrder = !empty($a['order']) ? $a['order'] : $actionOrderDefault++; $actionIcon = !empty($a['icon']) ? $a['icon'] : $this->Menu->getMenuIcon('Default'); $actionClass = !empty($a['class']) ? $a['class'] : ''; @@ -409,14 +425,12 @@ function _column_key($modelsName, $c, $tz=null) { ); } elseif(!empty($a['controller'])) { // We're linking into a related controller - /* XXX Modify the following for links to related controllers set in $indexActions. - This is the example from Match: - $actionLabel = __('match.ct.' . Inflector::camelize(Inflector::pluralize($a['controller'])), [99]); + $actionLabel = __d('controller', Inflector::camelize(Inflector::pluralize($a['controller'])), [99]); $actionUrl = $this->Url->build( ['controller' => $a['controller'], - 'action' => $a['action'], - '?' => [ $tableFK => $entity->id] ] - ); */ + 'action' => $a['action'], + '?' => [ $tableFK => $entity->id] ] + ); } else { $actionLabel = __d('operation', $a['action']); $actionUrl = $this->Url->build(['action' => $a['action'], $entity->id]); diff --git a/app/templates/element/breadcrumbs.php b/app/templates/element/breadcrumbs.php index b937e24f8..5c15197a6 100644 --- a/app/templates/element/breadcrumbs.php +++ b/app/templates/element/breadcrumbs.php @@ -68,6 +68,27 @@ ); } + // If we have a parent object interrogate it to construct a link + if(!empty($vv_bc_parent_obj)) { + // eg: Groups + $parentTable = $vv_bc_parent_obj->getSource(); + // eg: groups + $parentController = \Cake\Utility\Inflector::dasherize($parentTable); + + $this->Breadcrumbs->add( + __d('controller', $parentTable, [99]), + ['controller' => $parentController, + '?' => ['co_id' => !empty($vv_cur_co) ? $vv_cur_co->id : 1]] + ); + + $this->Breadcrumbs->add( + $vv_bc_parent_obj->$vv_bc_parent_displayfield, + ['controller' => $parentController, + 'action' => 'edit', + $vv_bc_parent_obj->id] + ); + } + // If we're rendering an MVEA, insert a link to the parent entity if(!empty($vv_primary_link_id)) { if(!empty($vv_person_name)) { diff --git a/app/templates/element/menuMain.php b/app/templates/element/menuMain.php index 66f0af399..0ee9e64e1 100644 --- a/app/templates/element/menuMain.php +++ b/app/templates/element/menuMain.php @@ -33,6 +33,10 @@ // In Registry PE, there is no more Platform Administration menu, so there // is no menu context without a current CO. (The Platform Administration // menu is now part of the COmanage CO configuration.) + + // When adding new items here, 'permission' corresponds to + // RegistryAuthComponent::getMenuPermissions. 'icon' is from + // https://fonts.google.com/icons?selected=Material+Icons $menuItems = [ [ @@ -42,6 +46,13 @@ 'icon' => 'person', 'label' => __d('menu', 'co.people') ], + [ + 'permission' => 'groups', + 'controller' => 'groups', + 'action' => 'index', + 'icon' => 'group', + 'label' => __d('menu', 'co.groups') + ], [ 'permission' => 'configuration', 'controller' => 'dashboards',