From c3154516ff481169b986b1590803f1286dfb7654 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Sun, 22 Jan 2023 20:07:21 -0500 Subject: [PATCH] Initial implementation of plugin common infrastructure (CFM-42) --- app/config/app.php | 9 +- app/config/schema/schema.json | 15 + app/resources/locales/en_US/command.po | 6 + app/resources/locales/en_US/controller.po | 3 + app/resources/locales/en_US/defaultType.po | 103 ++-- app/resources/locales/en_US/enumeration.po | 2 +- app/resources/locales/en_US/error.po | 12 + app/resources/locales/en_US/field.po | 250 ++++----- app/resources/locales/en_US/information.po | 9 + app/resources/locales/en_US/operation.po | 15 + app/resources/locales/en_US/result.po | 9 + app/src/Application.php | 10 + app/src/Command/DatabaseCommand.php | 258 +-------- app/src/Command/TransmogrifyCommand.php | 4 +- app/src/Controller/AppController.php | 4 +- .../Component/RegistryAuthComponent.php | 7 +- app/src/Controller/DashboardsController.php | 14 +- app/src/Controller/PluginsController.php | 111 ++++ app/src/Controller/PronounsController.php | 2 +- app/src/Controller/StandardController.php | 68 ++- .../StandardPluggableController.php | 111 ++++ .../Controller/StandardPluginController.php | 63 +++ app/src/Lib/Enum/PluginLocationEnum.php | 36 ++ app/src/Lib/Enum/StatusEnum.php | 3 +- app/src/Lib/Traits/LabeledLogTrait.php | 8 +- app/src/Lib/Traits/PluggableModelTrait.php | 90 ++++ app/src/Lib/Traits/TypeTrait.php | 2 +- app/src/Lib/Util/PaginatedSqlIterator.php | 8 +- app/src/Lib/Util/SchemaManager.php | 339 ++++++++++++ app/src/Lib/Util/StringUtilities.php | 89 +++- app/src/Model/Behavior/ChangelogBehavior.php | 28 +- app/src/Model/Entity/Co.php | 13 + app/src/Model/Entity/Plugin.php | 80 +++ app/src/Model/Table/CosTable.php | 160 +++++- .../Model/Table/ExternalIdentitiesTable.php | 30 +- .../Table/ExternalIdentityRolesTable.php | 12 +- app/src/Model/Table/GroupsTable.php | 19 +- app/src/Model/Table/NamesTable.php | 11 + app/src/Model/Table/PeopleTable.php | 74 ++- app/src/Model/Table/PersonRolesTable.php | 28 +- app/src/Model/Table/PluginsTable.php | 498 ++++++++++++++++++ app/src/Model/Table/TypesTable.php | 6 +- app/src/View/Helper/FieldHelper.php | 27 +- app/templates/Cos/columns.inc | 8 + app/templates/Dashboards/configuration.php | 12 +- app/templates/Plugins/columns.inc | 59 +++ app/templates/Standard/add-edit-view.php | 11 +- app/templates/element/breadcrumbs.php | 82 ++- 48 files changed, 2301 insertions(+), 517 deletions(-) create mode 100644 app/src/Controller/PluginsController.php create mode 100644 app/src/Controller/StandardPluggableController.php create mode 100644 app/src/Controller/StandardPluginController.php create mode 100644 app/src/Lib/Enum/PluginLocationEnum.php create mode 100644 app/src/Lib/Traits/PluggableModelTrait.php create mode 100644 app/src/Lib/Util/SchemaManager.php create mode 100644 app/src/Model/Entity/Plugin.php create mode 100644 app/src/Model/Table/PluginsTable.php create mode 100644 app/templates/Plugins/columns.inc diff --git a/app/config/app.php b/app/config/app.php index 9ea7fcc5e..e9d05dcb7 100644 --- a/app/config/app.php +++ b/app/config/app.php @@ -80,7 +80,14 @@ 'cssBaseUrl' => 'css/', 'jsBaseUrl' => 'js/', 'paths' => [ - 'plugins' => [ROOT . DS . 'plugins' . DS], + 'plugins' => [ + // Core plugins, always active + ROOT . DS . 'plugins' . DS, + // Optional plugins, must be activated + ROOT . DS . 'availableplugins' . DS, + // Local plugins, must be activated + LOCAL . DS . 'plugins' . DS + ], 'templates' => [ROOT . DS . 'templates' . DS], 'locales' => [ROOT . DS . 'resources' . DS . 'locales' . DS], ], diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 59f572f25..a7144a176 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -10,6 +10,7 @@ "columns": { "co_id": { "type": "integer", "foreignkey": { "table": "cos", "column": "id" }, "notnull": true }, + "comment": { "type": "string", "size": 256 }, "cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, "description": { "type": "string", "size": 128 }, "external_identity_id": { "type": "integer", "foreignkey": { "table": "external_identities", "column": "id" } }, @@ -20,6 +21,7 @@ "name": { "type": "string", "size": 128, "notnull": true }, "person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, "person_role_id": { "type": "integer", "foreignkey": { "table": "person_roles", "column": "id" } }, + "plugin": { "type": "string", "size": 80 }, "status": { "type": "string", "size": 2 }, "type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": true }, "valid_from": { "type": "datetime" }, @@ -36,6 +38,19 @@ "timestamps": false }, + "plugins": { + "columns": { + "id": {}, + "plugin": {}, + "location": { "type": "string", "size": 32 }, + "status": {}, + "comment": {} + }, + "indexes": { + }, + "changelog": false + }, + "cos": { "columns": { "id": {}, diff --git a/app/resources/locales/en_US/command.po b/app/resources/locales/en_US/command.po index 65969df57..4abf825d2 100644 --- a/app/resources/locales/en_US/command.po +++ b/app/resources/locales/en_US/command.po @@ -33,6 +33,12 @@ msgstr "Database schema update successful" msgid "db.schema" msgstr "Loading database schema from {0}" +msgid "db.schema.plugin" +msgstr "Loading database schema from active plugin {0}" + +msgid "db.schema.plugin.none" +msgstr "No database schema found for active plugin {0}" + msgid "opt.admin-family-name" msgstr "Family Name of initial platform administrator" diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index e613cad43..67753bd02 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -87,6 +87,9 @@ msgstr "{0,plural,=1{Person Role} other{Person Roles}}" msgid "Pronouns" msgstr "{0,plural,=1{Pronoun Preference} other{Pronouns}}" +msgid "Plugins" +msgstr "{0,plural,=1{Plugin} other{Plugins}}" + msgid "TelephoneNumbers" msgstr "{0,plural,=1{Telephone Number} other{Telephone Numbers}}" diff --git a/app/resources/locales/en_US/defaultType.po b/app/resources/locales/en_US/defaultType.po index a2634dd47..93cceccdc 100644 --- a/app/resources/locales/en_US/defaultType.po +++ b/app/resources/locales/en_US/defaultType.po @@ -24,137 +24,152 @@ # Labels for default types when a new CO is created -msgid "Addresses.campus" +msgid "Addresses.type.campus" msgstr "Campus" -msgid "Addresses.home" +msgid "Addresses.type.home" msgstr "Home" -msgid "Addresses.office" +msgid "Addresses.type.office" msgstr "Office" -msgid "Addresses.postal" +msgid "Addresses.type.postal" msgstr "Postal" -msgid "EmailAddresses.delivery" +msgid "EmailAddresses.type.delivery" msgstr "Delivery" -msgid "EmailAddresses.forwarding" +msgid "EmailAddresses.type.forwarding" msgstr "Forwarding" -msgid "EmailAddresses.list" +msgid "EmailAddresses.type.list" msgstr "Mailing List" -msgid "EmailAddresses.official" +msgid "EmailAddresses.type.official" msgstr "Official" -msgid "EmailAddresses.personal" +msgid "EmailAddresses.type.personal" msgstr "Personal" -msgid "EmailAddresses.preferred" +msgid "EmailAddresses.type.preferred" msgstr "Preferred" -msgid "EmailAddresses.recovery" +msgid "EmailAddresses.type.recovery" msgstr "Recovery" -msgid "Identifiers.badge" +msgid "Identifiers.type.badge" msgstr "Badge" -msgid "Identifiers.enterprise" +msgid "Identifiers.type.enterprise" msgstr "Enterprise" -msgid "Identifiers.eppn" +msgid "Identifiers.type.eppn" msgstr "ePPN" -msgid "Identifiers.eptid" +msgid "Identifiers.type.eptid" msgstr "ePTID" -msgid "Identifiers.epuid" +msgid "Identifiers.type.epuid" msgstr "ePUID" -msgid "Identifiers.gid" +msgid "Identifiers.type.gid" msgstr "GID" -msgid "Identifiers.mail" +msgid "Identifiers.type.mail" msgstr "Mail" -msgid "Identifiers.national" +msgid "Identifiers.type.national" msgstr "National" -msgid "Identifiers.network" +msgid "Identifiers.type.network" msgstr "Network" -msgid "Identifiers.oidcsub" +msgid "Identifiers.type.oidcsub" msgstr "OIDC Sub" -msgid "Identifiers.openid" +msgid "Identifiers.type.openid" msgstr "OpenID" -msgid "Identifiers.orcid" +msgid "Identifiers.type.orcid" msgstr "ORCiD" -msgid "Identifiers.provisioningtarget" +msgid "Identifiers.type.provisioningtarget" msgstr "Provisioning Target" -msgid "Identifiers.reference" +msgid "Identifiers.type.reference" msgstr "Match Reference" -msgid "Identifiers.pairwiseid" +msgid "Identifiers.type.pairwiseid" msgstr "SAML Pairwise" -msgid "Identifiers.subjectid" +msgid "Identifiers.type.subjectid" msgstr "SAML Subject" -msgid "Identifiers.sorid" +msgid "Identifiers.type.sorid" msgstr "System of Record" -msgid "Identifiers.uid" +msgid "Identifiers.type.uid" msgstr "UID" -msgid "Names.alternate" +msgid "Names.type.alternate" msgstr "Alternate" -msgid "Names.author" +msgid "Names.type.author" msgstr "Author" -msgid "Names.fka" +msgid "Names.type.fka" msgstr "Formerly Known As" -msgid "Names.official" +msgid "Names.type.official" msgstr "Official" -msgid "Names.preferred" +msgid "Names.type.preferred" msgstr "Preferred" -msgid "PersonRoles.affiliate" +msgid "PersonRoles.affiliation_type.affiliate" msgstr "Affiliate" -msgid "PersonRoles.alum" +msgid "PersonRoles.affiliation_type.alum" msgstr "Alum" -msgid "PersonRoles.employee" +msgid "PersonRoles.affiliation_type.employee" msgstr "Employee" -msgid "PersonRoles.faculty" +msgid "PersonRoles.affiliation_type.faculty" msgstr "Faculty" -msgid "PersonRoles.librarywalkin" +msgid "PersonRoles.affiliation_type.librarywalkin" msgstr "Library Walk-In" -msgid "PersonRoles.member" +msgid "PersonRoles.affiliation_type.member" msgstr "Member" -msgid "PersonRoles.staff" +msgid "PersonRoles.affiliation_type.staff" msgstr "Staff" -msgid "PersonRoles.student" +msgid "PersonRoles.affiliation_type.student" msgstr "Student" msgid "Pronouns.default" msgstr "Default" -msgid "Urls.official" +msgid "TelephoneNumbers.type.campus" +msgstr "Campus" + +msgid "TelephoneNumbers.type.fax" +msgstr "Fax" + +msgid "TelephoneNumbers.type.home" +msgstr "Home" + +msgid "TelephoneNumbers.type.mobile" +msgstr "Mobile" + +msgid "TelephoneNumbers.type.office" +msgstr "Office" + +msgid "Urls.type.official" msgstr "Official" -msgid "Urls.personal" +msgid "Urls.type.personal" msgstr "Personal" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index e54973338..e4eb276eb 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -265,7 +265,7 @@ msgid "StatusEnum.C" msgstr "Confirmed" msgid "StatusEnum.D" -msgstr "Deleted" +msgstr "Archived" msgid "StatusEnum.D2" msgstr "Duplicate" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index af75b2577..6d49759e9 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -69,6 +69,9 @@ msgstr "Invalid database configuration \"{0}\"" msgid "default.conflict" msgstr "Default values already imported" +msgid "delete" +msgstr "Failed to delete {0} {1}" + msgid "delete.active" msgstr "This record is in Active status and cannot be deleted" @@ -118,6 +121,9 @@ msgstr "Group is nested or has nestings, and cannot be suspended or deleted" msgid "Identifiers.login" msgstr "Only Identifiers attached to a Person may be flagged for login" +msgid "inactive" +msgstr "{0} {1} is not Active" + msgid "input.blank" msgstr "Value cannot consist of only blank characters" @@ -172,6 +178,9 @@ msgstr "Page number must be an integer" msgid "perm" msgstr "Permission Denied" +msgid "Plugins.inuse" +msgstr "{0,plural,=1{Plugin in use for {1} \"{2}\" in CO {3}} other{Plugin in use for {1} \"{2}\" in CO {3} and others}}" + msgid "primary_link" msgstr "Could not find value for Primary Link" @@ -190,6 +199,9 @@ msgstr "Foreign key {0} CO ID {1} does not match primary object CO ID {2}" msgid "save" msgstr "Save Failed ({0})" +msgid "save.plugin" +msgstr "Failed to instantiate Plugin {0}" + msgid "schema.column" msgstr "No type defined for table \"{0}\" column \"{1}\"" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 7680d2404..58cb5352a 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -25,6 +25,9 @@ # Fields # Keys of the form MyModels.field_name[.desc] will apply only to MyModels.field_name # Keys of the form field_name[.desc] will apply if no model specific key is found +# +# When adding entries to this file, group non-model specific translations at the top, +# then model specific translations alphabetically by model. msgid "action" msgstr "Action" @@ -44,60 +47,9 @@ msgstr "Area Code" msgid "attribute" msgstr "Attribute" -msgid "AuthenticationEvents.authenticated_identifier" -msgstr "Authenticated Identifier" - -msgid "AuthenticationEvents.authentication_event" -msgstr "Authentication Event" - msgid "comment" msgstr "Comment" -msgid "Cos.member.not" -msgstr "{0} (Not a Member)" - -msgid "CoSettings.default_address_type_id" -msgstr "Default Address Type" - -msgid "CoSettings.default_email_address_type_id" -msgstr "Default Email Address Type" - -msgid "CoSettings.default_identifier_type_id" -msgstr "Default Identifier Type" - -msgid "CoSettings.default_name_type_id" -msgstr "Default Name Type" - -msgid "CoSettings.default_pronoun_type_id" -msgstr "Default Pronoun Type" - -msgid "CoSettings.default_telephone_number_type_id" -msgstr "Default Telephone Number Type" - -msgid "CoSettings.default_url_type_id" -msgstr "Default URL Type" - -msgid "CoSettings.permitted_fields_name" -msgstr "Name Permitted Fields" - -msgid "CoSettings.permitted_fields_telephone_number" -msgstr "Telephone Number Permitted Fields" - -msgid "CoSettings.required_fields_address" -msgstr "Address Required Fields" - -msgid "CoSettings.required_fields_name" -msgstr "Name Required Fields" - -msgid "CoSettings.search_global_limit" -msgstr "Global Search Limit" - -msgid "CoSettings.search_global_limited_models" -msgstr "Limit Global Search Scope" - -msgid "CoSettings.search_global_limited_models.desc" -msgstr "If true, Global Search will only search Names, Email Addresses, and Identifiers. This may result in faster searches for larger deployments." - msgid "country" msgstr "Country" @@ -149,52 +101,9 @@ msgstr "Extension" msgid "id" msgstr "ID" -msgid "Types.edupersonaffiliation.desc" -# XXX update link to PE wiki? -msgstr "Map the extended affiliation to this eduPersonAffiliation, see eduPersonAffiliation and Extended Affiliations" - msgid "family" msgstr "Family Name" -msgid "GroupMembers.source" -msgstr "Membership Source" - -msgid "GroupMembers.source.direct" -msgstr "Direct" - -msgid "GroupNestings.group_id" -msgstr "Nested Group" - -msgid "GroupNestings.negate" -msgstr "Negate Nesting" - -msgid "GroupNestings.negate.desc" -msgstr "If true, members of the Nested Group will not be eligible to be a member of the Target Group" - -msgid "GroupNestings.target_group_id" -msgstr "Target Group" - -msgid "Groups.desc.admins" -msgstr "{0} Administrators" - -msgid "Groups.desc.members" -msgstr "{0} Members" - -msgid "Groups.desc.members.active" -msgstr "{0} Active Members" - -msgid "Groups.nesting_mode_all" -msgstr "Require All for Nested Memberships" - -msgid "Groups.nesting_mode_all.desc" -msgstr "For membership in this Group via Nested Groups, require membership in 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" @@ -234,16 +143,6 @@ msgstr "Middle" msgid "name" msgstr "Name" -msgid "TelephoneNumbers.formatted_number" -msgstr "Telephone Number" - -msgid "TelephoneNumbers.number" -msgstr "Telephone Number" - -# This field is for rendering the telephone number into a string (eg: 555 1212 x279) -msgid "TelephoneNumbers.number.ext" -msgstr "x" - # This field is called "ordr" to avoid conflicts with MySQL msgid "ordr" msgstr "Order" @@ -254,6 +153,9 @@ msgstr "Organization" msgid "parent_id" msgstr "Parent" +msgid "plugin" +msgstr "Plugin" + msgid "postal_code" msgstr "Postal Code" @@ -266,15 +168,9 @@ msgstr "Privileged" msgid "pronouns" msgstr "Preferred Pronouns" -msgid "ApiUsers.privileged.desc" -msgstr "A privileged API user has full access to the CO. Unprivileged API users may be granted specific permissions where supported." - msgid "remote_ip" msgstr "IP Address" -msgid "ApiUsers.remote_ip.desc" -msgstr "If specified, a regular expression describing the IP address(es) from which this API User may connect. Be sure to escape dots (eg: "/10\\.0\\.1\\.150/")." - msgid "required" msgstr "Required" @@ -302,9 +198,6 @@ msgstr "State" msgid "status" msgstr "Status" -msgid "Type.status" -msgstr "Suspending a Type will prevent it from being assigned to new attributes, but will not remove it from existing attributes" - msgid "street" msgstr "Street" @@ -326,9 +219,6 @@ msgstr "URL" msgid "username" msgstr "Username" -msgid "ApiUsers.username.desc" -msgstr "The API User Name must be prefixed with the string \"co_#.\"" - msgid "valid_from" msgstr "Valid From" @@ -350,11 +240,133 @@ msgstr "Valid Through ({0})" msgid "value" msgstr "Value" +msgid "verified" +msgstr "Verified" + +msgid "ApiUsers.privileged.desc" +msgstr "A privileged API user has full access to the CO. Unprivileged API users may be granted specific permissions where supported." + +msgid "ApiUsers.remote_ip.desc" +msgstr "If specified, a regular expression describing the IP address(es) from which this API User may connect. Be sure to escape dots (eg: "/10\\.0\\.1\\.150/")." + +msgid "ApiUsers.username.desc" +msgstr "The API User Name must be prefixed with the string \"co_#.\"" + +msgid "AuthenticationEvents.authenticated_identifier" +msgstr "Authenticated Identifier" + +msgid "AuthenticationEvents.authentication_event" +msgstr "Authentication Event" + +msgid "Cos.member.not" +msgstr "{0} (Not a Member)" + +msgid "CoSettings.default_address_type_id" +msgstr "Default Address Type" + +msgid "CoSettings.default_email_address_type_id" +msgstr "Default Email Address Type" + +msgid "CoSettings.default_identifier_type_id" +msgstr "Default Identifier Type" + +msgid "CoSettings.default_name_type_id" +msgstr "Default Name Type" + +msgid "CoSettings.default_pronoun_type_id" +msgstr "Default Pronoun Type" + +msgid "CoSettings.default_telephone_number_type_id" +msgstr "Default Telephone Number Type" + +msgid "CoSettings.default_url_type_id" +msgstr "Default URL Type" + +msgid "CoSettings.permitted_fields_name" +msgstr "Name Permitted Fields" + +msgid "CoSettings.permitted_fields_telephone_number" +msgstr "Telephone Number Permitted Fields" + +msgid "CoSettings.required_fields_address" +msgstr "Address Required Fields" + +msgid "CoSettings.required_fields_name" +msgstr "Name Required Fields" + +msgid "CoSettings.search_global_limit" +msgstr "Global Search Limit" + +msgid "CoSettings.search_global_limited_models" +msgstr "Limit Global Search Scope" + +msgid "CoSettings.search_global_limited_models.desc" +msgstr "If true, Global Search will only search Names, Email Addresses, and Identifiers. This may result in faster searches for larger deployments." + +msgid "GroupMembers.source" +msgstr "Membership Source" + +msgid "GroupMembers.source.direct" +msgstr "Direct" + +msgid "GroupNestings.group_id" +msgstr "Nested Group" + +msgid "GroupNestings.negate" +msgstr "Negate Nesting" + +msgid "GroupNestings.negate.desc" +msgstr "If true, members of the Nested Group will not be eligible to be a member of the Target Group" + +msgid "GroupNestings.target_group_id" +msgstr "Target Group" + +msgid "Groups.desc.admins" +msgstr "{0} Administrators" + +msgid "Groups.desc.members" +msgstr "{0} Members" + +msgid "Groups.desc.members.active" +msgstr "{0} Active Members" + +msgid "Groups.nesting_mode_all" +msgstr "Require All for Nested Memberships" + +msgid "Groups.nesting_mode_all.desc" +msgstr "For membership in this Group via Nested Groups, require membership in 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 "Plugins.plugin" +msgstr "Plugin" + +msgid "Plugins.location" +msgstr "Location" + +msgid "TelephoneNumbers.formatted_number" +msgstr "Telephone Number" + +msgid "TelephoneNumbers.number" +msgstr "Telephone Number" + +# This field is for rendering the telephone number into a string (eg: 555 1212 x279) +msgid "TelephoneNumbers.number.ext" +msgstr "x" + +msgid "Types.edupersonaffiliation.desc" +# XXX update link to PE wiki? +msgstr "Map the extended affiliation to this eduPersonAffiliation, see eduPersonAffiliation and Extended Affiliations" + +msgid "Types.status.desc" +msgstr "Suspending a Type will prevent it from being assigned to new attributes, but will not remove it from existing attributes" + msgid "Types.value" msgstr "Database Value" msgid "Types.value.desc" msgstr "Database value for this type, characters must be alphanumeric, dot, or dash" - -msgid "verified" -msgstr "Verified" \ No newline at end of file diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index 9ae8226af..7e6551f1e 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -42,6 +42,15 @@ msgstr "ID: {0}" msgid "pagination.format" msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" +msgid "plugin.active" +msgstr "Active" + +msgid "plugin.active.only" +msgstr "Active, Cannot Be Disabled" + +msgid "plugin.inactive" +msgstr "Inactive" + msgid "global.records.none" msgstr "There are no records to display." diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 56341ea64..e337e64fa 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -24,6 +24,9 @@ # Operations (Commands) +msgid "activate" +msgstr "Activate" + msgid "add.a" msgstr "Add a New {0}" @@ -33,6 +36,9 @@ msgstr "Generate API Key" msgid "api.key.generate.confirm" msgstr "Are you sure you wish to generate a new API Key?" +msgid "applySchema" +msgstr "Apply Database Schema" + msgid "cancel" msgstr "Cancel" @@ -48,9 +54,15 @@ msgstr "Close" msgid "confirm" msgstr "Confirm" +msgid "configure.plugin" +msgstr "Configure Plugin" + msgid "dashboard.configuration" msgstr "Manage {0} Configuration" +msgid "deactivate" +msgstr "Deactivate" + msgid "delete" msgstr "Delete" @@ -123,6 +135,9 @@ msgstr "Global Search" msgid "skip_to_content" msgstr "Skip to main content" +msgid "Cos.switch" +msgstr "Switch To This CO" + msgid "view" msgstr "View" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index c928acd01..e241c06c9 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -24,9 +24,18 @@ # Results +msgid "activated" +msgstr "{0} Activated" + msgid "added.mvea" msgstr "{0} {1} Added: {2}" +msgid "applied.schema" +msgstr "Successfully applied database schema" + +msgid "deactivated" +msgstr "{0} Deactivated" + msgid "deleted" msgstr "Deleted" diff --git a/app/src/Application.php b/app/src/Application.php index ea6657cd8..6186b1d65 100644 --- a/app/src/Application.php +++ b/app/src/Application.php @@ -24,6 +24,8 @@ use Cake\Routing\Middleware\AssetMiddleware; use Cake\Routing\Middleware\RoutingMiddleware; +use Cake\ORM\TableRegistry; + /** * Application setup class. * @@ -55,6 +57,14 @@ public function bootstrap(): void } // Load more plugins here + + $Plugins = TableRegistry::getTableLocator()->get('Plugins'); + + $activePlugins = $Plugins->find('active')->all(); + + foreach($activePlugins as $p) { + $this->addPlugin($p->plugin); + } } /** diff --git a/app/src/Command/DatabaseCommand.php b/app/src/Command/DatabaseCommand.php index b2a8ea109..8f8578f17 100644 --- a/app/src/Command/DatabaseCommand.php +++ b/app/src/Command/DatabaseCommand.php @@ -33,13 +33,9 @@ use Cake\Console\Command; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; -use Cake\Datasource\ConnectionInterface; -use Cake\Datasource\ConnectionManager; +use Cake\ORM\TableRegistry; -use Doctrine\DBAL\DriverManager; -use Doctrine\DBAL\Schema\Comparator; -use Doctrine\DBAL\Schema\Schema; -use Doctrine\DBAL\Schema\SchemaDiff; +use App\Lib\Util\SchemaManager; class DatabaseCommand extends Command { /** @@ -70,241 +66,39 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption */ public function execute(Arguments $args, ConsoleIo $io) { - // Database schema management. We use Doctrine DBAL rather than Cake's migrations - // (phinx) because migrations make development annoying (want to add a field - // to a table after you've created it? that's a new migration!), and can't - // provide a single representation of a given table (since you're recording - // diffs, not desired end state). ADOdb (used in earlier versions) was hard to - // debug and poorly maintained. DBAL doesn't have a schema format (like axmls) - // but it does everything else, and specifying a schema format is easy. - - // First try to parse our schema file + $SchemaManager = new SchemaManager(io: $io); + // First apply the core schema $schemaFile = ROOT . DS . 'config' . DS . 'schema' . DS . 'schema.json'; - if(!is_readable($schemaFile)) { - throw new \RuntimeException(__d('error', 'file', [$schemaFile])); - } - $io->out(__d('command', 'db.schema', [$schemaFile])); - - $json = file_get_contents($schemaFile); - - $schemaConfig = json_decode($json); - - if(!$schemaConfig) { - // json_last_error[_msg]() are pretty useless. If you are debugging here, - // it's most likely because of one of the following: - // - An unmatched brace { } - // - A trailing comma (permitted in PHP but not JSON) - // - Single quotes instead of double quotes - throw new \RuntimeException(__d('error', 'schema.parse', [$schemaFile])); - } - - // Use the ConnectionManager to get the database config to pass to adodb. - $db = ConnectionManager::get('default'); - - // $db is a ConnectionInterface object - $cfg = $db->config(); - - $config = new \Doctrine\DBAL\Configuration(); - - $cfargs = [ - 'dbname' => $cfg['database'], - 'user' => $cfg['username'], - 'password' => $cfg['password'], - 'host' => $cfg['host'], - 'driver' => ($cfg['driver'] == 'Cake\Database\Driver\Postgres' ? "pdo_pgsql" : "mysqli") - ]; - - // For MySQL SSL - if(!empty($cfg['ssl_ca'])) { - $cfargs['ssl_ca'] = $cfg['ssl_ca']; - } - - $conn = DriverManager::getConnection($cfargs, $config); - - $schema = new Schema(); - - // Walk through $schemaConfig and build our schema in DBAL format. - - foreach($schemaConfig->tables as $tName => $tCfg) { - $table = $schema->createTable($tName); - - foreach($tCfg->columns as $cName => $cCfg) { - // We allow "inherited" definitions from the fieldLibrary, so merge together - // the configurations (if appropriate) - - $colCfg = (object)array_merge((isset($schemaConfig->columnLibrary->columns->$cName) - ? (array)$schemaConfig->columnLibrary->columns->$cName - : []), - (array)$cCfg); - - if(!isset($colCfg->type)) { - throw new \RuntimeException(__d('error', 'schema.column', [$tName, $cName])); - } - - // For type definitions see https://www.doctrine-project.org/projects/doctrine-dbal/en/2.12/reference/types.html#types - $options = []; - - if(isset($colCfg->autoincrement)) { - $options['autoincrement'] = $colCfg->autoincrement; - } - - if($colCfg->type == "string") { - $options['length'] = $colCfg->size; - } - - if(isset($colCfg->notnull)) { - $options['notnull'] = $colCfg->notnull; - } else { - $options['notnull'] = false; - } - - $table->addColumn($cName, $colCfg->type, $options); - - if(isset($colCfg->primarykey) && $colCfg->primarykey) { - $table->setPrimaryKey(["id"]); - } - - if(isset($colCfg->foreignkey)) { - $table->addForeignKeyConstraint($colCfg->foreignkey->table, - [$cName], - [$colCfg->foreignkey->column], - [], - // We name our foreign keys the same way they - // were previously named by adodb - $tName . "_" . $cName . "_fkey"); - } - } - - // (For Registry) If MVEA models are specified, emit the appropriate - // columns and indexes. MVEA attributes must be added before indexes, in - // case the table has composite indexes referencing MVEA columns. - - if(!empty($tCfg->mvea)) { - $i = 1; - - foreach($tCfg->mvea as $m) { - $mColumn = $m . "_id"; - $fkTable = \Cake\Utility\Inflector::tableize($m); - - // Insert a foreign key to this model and index it - $table->addColumn($mColumn, "integer", ['notnull' => false]); - $table->addForeignKeyConstraint($fkTable, [$mColumn], ['id'], [], $tName . "_" . $mColumn . "_fkey"); - $table->addIndex([$mColumn], $tName . "_im" . $i++); - } - } - - if(isset($tCfg->indexes)) { - // We don't autogenerate names for indexes so if the definition of an index - // changes DBAL can just rebuild that index instead of recreating every index - // on the table. (This should speed up schema updates vs ADOdb.) This does - // require each index to be named in the schema file, but we had to do that - // in axmls too, even though it rebuilt every index every time through. - - foreach($tCfg->indexes as $iName => $iCfg) { - // $flags and $options as passed to Index(), but otherwise undocumented - $flags = []; - $options = []; - - $table->addIndex($iCfg->columns, $iName, $flags, $options); - } - } - - // (For Registry) If an attribute is "sourced" it is a CO Person attribute - // that is copied via a Pipeline from an Org Identity that was created from - // an Org Identity Source, so we need a foreign key into ourself. - - if(isset($tCfg->sourced) && $tCfg->sourced) { - $sColumn = "source_" . \Cake\Utility\Inflector::singularize($tName) . "_id"; - - // Insert a foreign key to this model and index it - $table->addColumn($sColumn, "integer", ['notnull' => false]); - $table->addForeignKeyConstraint($table, [$sColumn], ['id'], [], $tName . "_" . $sColumn . "_fkey"); - $table->addIndex([$sColumn], $tName . "_im" . $i++); - } - - // Default is to insert timestamp and changelog fields, unless disabled - - if(!isset($tCfg->timestamps) || $tCfg->timestamps) { - // Insert Cake metadata fields - $table->addColumn("created", "datetime"); - $table->addColumn("modified", "datetime", ['notnull' => false]); - } - - if(!isset($tCfg->changelog) || $tCfg->changelog) { - // Insert ChangelogBehavior metadata fields - $clColumn = \Cake\Utility\Inflector::singularize($tName) . "_id"; - $table->addColumn($clColumn, "integer", ['notnull' => false]); - $table->addColumn("revision", "integer", ['notnull' => false]); - $table->addColumn("deleted", "boolean", ['notnull' => false]); - $table->addColumn("actor_identifier", "string", ['length' => 256, 'notnull' => false]); - - $table->addForeignKeyConstraint($table, [$clColumn], ['id'], [], $tName . "_" . $clColumn . "_fkey"); - $table->addIndex([$clColumn], $tName . "_icl", [], []); - } - } - - // This is the SQL that represents the desired state of the database - $toSql = $schema->toSql($conn->getDatabasePlatform()); - - // SchemaManager provides info about the database - $sm = $conn->createSchemaManager(); - - // The is the current database representation - $curSchema = $sm->createSchema(); - - $fromSql = $curSchema->toSql($conn->getDatabasePlatform()); - - // Really run the SQL? - $doSQL = !$args->getOption('not'); - try { - // We manually call compare so we can get the SchemaDiff object. We need - // this for toSaveSql(), which we use to avoid dropping undocumented tables - // (like the matchgrids, which are dynamically created and so won't be in the - // schema file). -// $diffSql = $curSchema->getMigrateToSql($schema, $conn->getDatabasePlatform()); - $comparator = new Comparator(); - $schemaDiff = $comparator->compareSchemas($curSchema, $schema); - - $diffSql = $schemaDiff->toSaveSql($conn->getDatabasePlatform()); - - // We don't start a transaction since in general we always want to move to - // the desired state, and if we fail in flight it's probably a bug that - // needs to be fixed. - - foreach($diffSql as $sql) { - // XXX At some point do this only if $verbose - $io->out($sql); - - if($cfg['driver'] == 'Cake\Database\Driver\Postgres' - && preg_match("/^DROP SEQUENCE [a-z]*_id_seq/", $sql)) { - // Remove the DROP SEQUENCE statements in $fromSql because they're Postgres automagic - // being misinterpreted. (Note toSaveSql might mask this now.) - // XXX Maybe debug and file a PR to not emit DROP SEQUENCE on PG for autoincrementesque fields? - $io->out("Skipping sequence drop"); + $SchemaManager->applySchemaFile(schemaFile: $schemaFile, + diffOnly: $args->getOption('not')); + + // Next see which plugins are active and have database configurations + $Plugins = TableRegistry::getTableLocator()->get('Plugins'); + + // AR-Plugin-6 Only apply schemas from active plugins + $activePlugins = $Plugins->find('active')->all(); + + if(!empty($activePlugins)) { + foreach($activePlugins as $p) { + $pSchemaConfig = $Plugins->getPluginSchema($p); + + if($pSchemaConfig) { + $io->out(__d('command', 'db.schema.plugin', [$p->plugin])); + $SchemaManager->applySchemaObject($pSchemaConfig); } else { - if($doSQL) { - $stmt = $conn->executeQuery($sql); - // $stmt just returns the query string so we don't bother examining it - } + $io->out(__d('command', 'db.schema.plugin.none', [$p->plugin])); } } - - if(!$doSQL) { - $io->out(__d('command', 'db.noop')); - } else { - $io->out(__d('command', 'db.ok')); - } } - catch(\Exception $e) { - $io->out($e->getMessage()); + + if($args->getOption('not')) { + $io->out(__d('command', 'db.noop')); + } else { + $io->out(__d('command', 'db.ok')); } - - // We might run bin/cake schema_cache clear or - // bin/cake schema_cache build --connection default - // but so far we don't have an example indicating it's needed. } } diff --git a/app/src/Command/TransmogrifyCommand.php b/app/src/Command/TransmogrifyCommand.php index 2995f0141..8597b9d37 100644 --- a/app/src/Command/TransmogrifyCommand.php +++ b/app/src/Command/TransmogrifyCommand.php @@ -872,7 +872,7 @@ protected function map_address_type(array $row) { */ protected function map_affiliation_type(array $row) { - return $this->map_type($row, 'PersonRoles.affiliation', $this->findCoId($row), 'affiliation'); + return $this->map_type($row, 'PersonRoles.affiliation_type', $this->findCoId($row), 'affiliation_type'); } /** @@ -900,7 +900,7 @@ protected function map_extended_type(array $row) { case 'CoDepartment.type': return 'Departments.type'; case 'CoPersonRole.affiliation': - return 'PersonRoles.affiliation'; + return 'PersonRoles.affiliation_type'; } // For everything else, we need to pluralize the model name diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index c67a31fcf..d6c023593 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -351,7 +351,7 @@ protected function getPrimaryLink(bool $lookup=false) { * @return string Redirect goal */ - protected function getRedirectGoal(): string { + protected function getRedirectGoal(): ?string { // $this->name = Models $modelsName = $this->name; @@ -497,6 +497,8 @@ protected function setCO() { if($this->cur_co->status === TemplateableStatusEnum::Active) { $this->set('vv_cur_co', $this->cur_co); + } else { + throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Cos', [1]), $coid])); } // We store the CO ID in Configuration to facilitate its access from diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index bdf87b51c..a6cc9e156 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -246,10 +246,13 @@ protected function calculatePermissions(?int $id=null): array { $ret = []; + // This will need to be prefixed to the model, if set + $pluginName = $controller->getPlugin(); + // $this->name = Models (ie: from ModelsTable) - $modelsName = $controller->getName(); + $modelsName = ($pluginName ? "$pluginName." : "") . $controller->getName(); // $table = the actual table object - $table = $controller->getTableLocator()->get($modelsName); + // Do we have an authenticated user? $authenticatedUser = (bool)$this->getAuthenticatedUser(); diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index 36c015613..970494723 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -66,6 +66,11 @@ public function configuration() { 'controller' => 'cous', 'action' => 'index' ], + __d('controller', 'Reports', [99]) => [ + 'icon' => 'summarize', + 'controller' => 'reports', + 'action' => 'index' + ], __d('controller', 'Types', [99]) => [ 'icon' => 'widgets', 'controller' => 'types', @@ -98,6 +103,11 @@ public function configuration() { 'icon' => 'home', 'controller' => 'cos', 'action' => 'index' + ], + __d('controller', 'Plugins', [99]) => [ + 'icon' => 'electrical_services', + 'controller' => 'plugins', + 'action' => 'index' ] ]; } @@ -106,7 +116,7 @@ public function configuration() { $this->set('vv_platform_menu_items', $platformMenuItems); } - + /** * Render a Dashboard. * @@ -195,7 +205,7 @@ public function search() { // XXX Still need to implement this (see also CFM-126) $roles = []; - + // Gather our search string. $q = ''; if(!empty($this->request->getData('global-search-q'))) { diff --git a/app/src/Controller/PluginsController.php b/app/src/Controller/PluginsController.php new file mode 100644 index 000000000..3d6d0ce7c --- /dev/null +++ b/app/src/Controller/PluginsController.php @@ -0,0 +1,111 @@ + [ + 'Plugins.plugin' => 'asc' + ] + ]; + + /** + * Apply the database schema for a Plugin. + * + * @since COmanage Registry v5.0.0 + * @param string $id Plugin ID + */ + + public function applySchema(string $id) { + try { + $this->Plugins->applySchema((int)$id); + $this->Flash->success(__d('result', 'applied.schema')); + } + catch(Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->redirect(['action' => 'index']); + } + + /** + * Activate a Plugin. + * + * @since COmanage Registry v5.0.0 + * @param string $id Plugin ID + */ + + public function activate(string $id) { + try { + $this->Plugins->activate((int)$id); + $this->Flash->success(__d('result', 'activated', __d('controller', 'Plugins', [1]))); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->redirect(['action' => 'index']); + } + + /** + * Deactivate a Plugin. + * + * @since COmanage Registry v5.0.0 + * @param string $id Plugin ID + */ + + public function deactivate(string $id) { + try { + $this->Plugins->deactivate((int)$id); + $this->Flash->success(__d('result', 'deactivated', __d('controller', 'Plugins', [1]))); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->redirect(['action' => 'index']); + } + + /** + * Generate an index for the global set of Plugins. + * + * @since COmanage Registry v5.0.0 + */ + + public function index() { + // Loading this page (Configuration > Plugins) triggers the Plugin Registry refresh (AR-Plugin-11). + $this->Plugins->syncPluginRegistry(); + + parent::index(); + } +} \ No newline at end of file diff --git a/app/src/Controller/PronounsController.php b/app/src/Controller/PronounsController.php index 0d516eabb..36f0c3606 100644 --- a/app/src/Controller/PronounsController.php +++ b/app/src/Controller/PronounsController.php @@ -34,7 +34,7 @@ use Cake\ORM\TableRegistry; class PronounsController extends MVEAController { - public $pagination = [ + public $paginate = [ 'order' => [ 'Pronouns.pronouns' => 'asc' ] diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index d5a994ce0..a62131ff8 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -32,11 +32,12 @@ use InvalidArgumentException; use \Cake\Http\Exception\BadRequestException; use \App\Lib\Enum\SuspendableStatusEnum; +use \App\Lib\Util\StringUtilities; class StandardController extends AppController { // Pagination defaults should be set in each controller public $pagination = []; - + /** * Handle an add action for a Standard object. * @@ -55,10 +56,17 @@ public function add() { try { // Try to save $obj = $table->newEntity($this->request->getData()); - + if($table->save($obj)) { $this->Flash->success(__d('result', 'saved')); + // If this is a Pluggable Model, instantiate the plugin and redirect + // into the Entry Point Model + if(!empty($obj->plugin) && method_exists($this, "instantiatePlugin")) { + // instantiatePlugin() is implemented in StandardPluggableController + return $this->instantiatePlugin($obj); + } + return $this->generateRedirect($obj->id); } @@ -97,7 +105,7 @@ public function add() { // Default title is add new object $this->set('vv_title', __d('operation', 'add.a', __d('controller', $modelsName, [1]))); - + // Supertitle is normally the display name of the parent object when subnavigation exists. // Set this here as the fallback default. This value is overriden in MVEAController to hold the // name of the parent object, not the model name of the current object. @@ -150,6 +158,15 @@ public function beforeRender(\Cake\Event\EventInterface $event) { $this->set('vv_permissions', $this->RegistryAuth->calculatePermissionsForView($this->request->getParam('action'), $id)); + // The template path may vary if we're in a plugin context + $vv_template_path = ROOT . DS . "templates" . DS . $modelsName; + + if(!empty($this->getPlugin())) { + $vv_template_path = $this->getPluginPath($this->getPlugin(), "templates") . DS . $modelsName; + } + + $this->set('vv_template_path', $vv_template_path); + return parent::beforeRender($event); } @@ -174,8 +191,13 @@ public function delete($id) { $obj = $table->findById($id)->firstOrFail(); // XXX throw 404 on RESTful not found? -// XXX document AR-CO-1 when we implement hard delete/changelog - $table->deleteOrFail($obj); + // By default, a delete is a soft delete. The exceptions are when + // deleting a CO (AR-CO-1) or when an expunge flag is passed and + // expunge is enabled within the CO (XXX not yet implemented). + + $useHardDelete = ($modelsName == "Cos"); + + $table->deleteOrFail($obj, ['useHardDelete' => $useHardDelete]); // Use the display field to generate the flash message @@ -360,6 +382,16 @@ public function generateRedirect(?int $id) { // By default we return to the index, but we'll also accept "self" or "primaryLink". $redirectGoal = $this->getRedirectGoal(); + if(!$redirectGoal) { + // Our default behavior is index unless we're in a plugin context + + if(!empty($this->getPlugin())) { + $redirectGoal = 'pluggableLink'; + } else { + $redirectGoal = 'index'; + } + } + if($redirectGoal == 'self' && $id && in_array($this->request->getParam('action'), ['add', 'edit'])) { @@ -370,9 +402,22 @@ public function generateRedirect(?int $id) { 'action' => 'edit', $id ]; - } elseif($redirectGoal == 'primaryLink') { - // XXX implement me - throw new \RuntimeException('generateRedirect NOT IMPLEMENTED'); + } elseif($redirectGoal == 'pluggableLink' || $redirectGoal == 'primaryLink') { + // pluggableLink and primaryLink do basically the same thing, except that + // pluggableLink moves from a plugin to core so we need to drop the plugin + $link = $this->getPrimaryLink(true); + + if(!empty($link->attr) && !empty($link->value)) { + $redirect = [ + 'controller' => StringUtilities::foreignKeyToClassName($link->attr), + 'action' => 'edit', + $link->value + ]; + + if($redirectGoal == 'pluggableLink') { + $redirect['plugin'] = null; + } + } } else { // Default is to redirect to the index view $redirect = ['action' => 'index']; @@ -519,7 +564,7 @@ public function index() { // Let the view render $this->render('/Standard/index'); } - + /** * Populate any auto view variables, as requested via AutoViewVarsTrait. * @@ -562,6 +607,7 @@ protected function populateAutoViewVars(object $obj=null) { 'attribute' => $avv['attribute'], 'status' => SuspendableStatusEnum::Active ]; + // fall through case 'auxiliary': // XXX add list as in match? case 'select': @@ -635,6 +681,10 @@ protected function populateAutoViewVars(object $obj=null) { // implement a potentialParents method $this->set($vvar, $table->potentialParents($this->getCOID())); break; + case 'plugin': + $PluginTable = $this->getTableLocator()->get('Plugins'); + $this->set($vvar, $PluginTable->getActivePluginModels($avv['pluginType'])); + break; default: // XXX I18n? and in match? throw new \LogicException('Unknonwn Auto View Var Type {0}', [$avv['type']]); diff --git a/app/src/Controller/StandardPluggableController.php b/app/src/Controller/StandardPluggableController.php new file mode 100644 index 000000000..0268a3126 --- /dev/null +++ b/app/src/Controller/StandardPluggableController.php @@ -0,0 +1,111 @@ +name = Models (ie: from ModelsTable) + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + + $parentId = $this->request->getParam('pass')[0]; + $parentObj = $table->findById($parentId) + ->firstOrFail(); + + $pluginTable = $this->getTableLocator()->get($parentObj->plugin); + $pluginObj = $pluginTable->find() + ->where(['report_id' => $parentId]) + ->firstOrFail(); + + return $this->redirect([ + 'plugin' => StringUtilities::pluginPlugin($parentObj->plugin), + 'controller' => StringUtilities::pluginModel($parentObj->plugin), + 'action' => 'edit', + $pluginObj->id + ]); + } + + /** + * Instantiate the plugin for this Pluggable model. Upon success, a redirect into + * the edit view for the instantiated object will be issued. + * + * @since COmanage Registry v5.0.0 + * @param object $obj Pluggable object + */ + + protected function instantiatePlugin(object $obj) { + // Create the row for the entry point model, then redirect into it + + // eg: report_id + $parentKey = StringUtilities::entityToForeignKey($obj); + + // For now, we just populate the foreign key from the instantiated plugin + // to its parent object, but we might want to allow the plugin model to + // set some default values. + $iValues = [ + $parentKey => $obj->id + ]; + + $pTable = $this->getTableLocator()->get($obj->plugin); + + $iObj = $pTable->newEntity($iValues); + + // We skip validation and rule checking because we're saving a skeletal record. (AR-Plugin-9) + + if($pTable->save($iObj, ['validate' => false, 'checkRules' => false])) { + // Redirect into plugin + + return $this->redirect([ + 'plugin' => StringUtilities::pluginPlugin($obj->plugin), + 'controller' => StringUtilities::pluginModel($obj->plugin), + 'action' => 'edit', + $iObj->id + ]); + } else { + $this->Flash->error(__d('error', 'save.plugin', [$obj->plugin])); + } + } +} \ No newline at end of file diff --git a/app/src/Controller/StandardPluginController.php b/app/src/Controller/StandardPluginController.php new file mode 100644 index 000000000..6f705c2d7 --- /dev/null +++ b/app/src/Controller/StandardPluginController.php @@ -0,0 +1,63 @@ +getTableLocator()->get('Plugins'); + + // Because plugins are uniquely named (AR-Plugin-1) we can do a find based + // on the name to get the object. + + $plugin = $PluginTable->find() + ->where([ + 'plugin' => $pluginName, + 'status' => SuspendableStatusEnum::Active + ]) + ->firstOrFail(); + + return $PluginTable->pluginPath($plugin, $file); + } +} \ No newline at end of file diff --git a/app/src/Lib/Enum/PluginLocationEnum.php b/app/src/Lib/Enum/PluginLocationEnum.php new file mode 100644 index 000000000..3e5f544ab --- /dev/null +++ b/app/src/Lib/Enum/PluginLocationEnum.php @@ -0,0 +1,36 @@ +find() + ->select('plugin') + ->distinct(['plugin']) + ->all(); + + foreach($models as $m) { + $this->hasMany($m->plugin) + ->setDependent(true) + ->setCascadeCallbacks(true); + } + + $this->setAllowLookupPrimaryLink(['configure']); + } + + /** + * Determine the plugin type used by this Pluggable Model. This is the lowercased + * singular prefix of the Pluggable Model Table name. eg: For "ReportsTable" the + * plugin type is "report". + * + * @since COmanage Registry v5.0.0 + * @return string Plugin model type + */ + + public function getPluggableModelType(): string { + return Inflector::underscore(StringUtilities::tableToEntityName($this)); + } + + /** + * Determine if a Registry Plugin is in use, specifically if an Entry Point Model + * from the requested Plugin is in a configuration object for this Pluggable Model. + * + * @since COmanage Registry v5.0.0 + * @param string $plugin Plugin to examine + * @return ResultSet Set of configuration objects in use + */ + + public function pluginInUse(string $plugin): ResultSet { + return $this->find() + ->where(['plugin LIKE' => $plugin . ".%"]) + ->all(); + } +} diff --git a/app/src/Lib/Traits/TypeTrait.php b/app/src/Lib/Traits/TypeTrait.php index 3b8717490..21906e9bc 100644 --- a/app/src/Lib/Traits/TypeTrait.php +++ b/app/src/Lib/Traits/TypeTrait.php @@ -78,7 +78,7 @@ public function defaultTypes(string $attribute) { // eg: "Name" foreach($this->defaultTypes[$attribute] as $t) { // Map to localized text string - $ret[$t] = __d('defaultType', $this->getAlias().'.'.$t); + $ret[$t] = __d('defaultType', $this->getAlias().'.'.$attribute.'.'.$t); } return $ret; diff --git a/app/src/Lib/Util/PaginatedSqlIterator.php b/app/src/Lib/Util/PaginatedSqlIterator.php index 74d374d29..2b2a4e3a8 100644 --- a/app/src/Lib/Util/PaginatedSqlIterator.php +++ b/app/src/Lib/Util/PaginatedSqlIterator.php @@ -58,9 +58,13 @@ class PaginatedSqlIterator implements \Iterator { // The highest ID we've seen so far private $maxid = 0; - public function __construct($table, $conditions=null) { //}, $fields=null, $contain=null) { + // Options for find() + private $options = []; + + public function __construct($table, $conditions=null, $options=[]) { $this->table = $table; $this->conditions = $conditions; + $this->options = $options; $this->position = 0; } @@ -131,7 +135,7 @@ protected function loadPage() { $this->position = 0; - $query = $this->table->find() + $query = $this->table->find('all', $this->options) ->where([$this->keyField . ' >' => $this->maxid]); if($this->conditions) { diff --git a/app/src/Lib/Util/SchemaManager.php b/app/src/Lib/Util/SchemaManager.php new file mode 100644 index 000000000..888ba8f07 --- /dev/null +++ b/app/src/Lib/Util/SchemaManager.php @@ -0,0 +1,339 @@ +io = $io; + + // Use the ConnectionManager to get the database config to pass to DBAL. + $db = ConnectionManager::get('default'); + + // $db is a ConnectionInterface object + $cfg = $db->config(); + + $config = new \Doctrine\DBAL\Configuration(); + + $cfargs = [ + 'dbname' => $cfg['database'], + 'user' => $cfg['username'], + 'password' => $cfg['password'], + 'host' => $cfg['host'], + 'driver' => ($cfg['driver'] == 'Cake\Database\Driver\Postgres' ? "pdo_pgsql" : "mysqli") + ]; + + // For MySQL SSL + if(!empty($cfg['ssl_ca'])) { + $cfargs['ssl_ca'] = $cfg['ssl_ca']; + } + + $this->conn = DriverManager::getConnection($cfargs, $config); + $this->driver = $cfg['driver']; + } + + /** + * Apply a schema file. + * + * @since COmanage Registry v5.0.0 + * @param string $schemaFile Schema file to apply + * @param bool $parseOnly If true, attempt to parse the file only, but perform no other actions + * @param bool $diffOnly If true, generate a diff against the current database state, but do not apply it + */ + + public function applySchemaFile(string $schemaFile, bool $parseOnly=false, bool $diffOnly=false) { + if(!is_readable($schemaFile)) { + throw new \RuntimeException(__d('error', 'file', [$schemaFile])); + } + + $this->llog('debug', __d('command', 'db.schema', [$schemaFile])); + + $json = file_get_contents($schemaFile); + + $schemaConfig = json_decode($json); + + if(!$schemaConfig) { + // json_last_error[_msg]() are pretty useless. If you are debugging here, + // it's most likely because of one of the following: + // - An unmatched brace { } + // - A trailing comma (permitted in PHP but not JSON) + // - Single quotes instead of double quotes + throw new \RuntimeException(__d('error', 'schema.parse', [$schemaFile])); + } + + // If there is a column library (which should only be in the main config), + // cache it since plugins may reference it + if(!empty($schemaConfig->columnLibrary)) { + $this->columnLibrary = $schemaConfig->columnLibrary; + } + + if(!$parseOnly) { + $this->processSchema(schemaConfig: $schemaConfig, diffOnly: $diffOnly); + } + } + + /** + * Apply an already parsed schema object. + * + * @since COmanage Registry v5.0.0 + * @param object $schemaObject Schema object + */ + + public function applySchemaObject(object $schemaObject) { + if(!$this->columnLibrary) { + // We need the column library from the core config + $this->applySchemaFile(schemaFile: ROOT . DS . 'config' . DS . 'schema' . DS . 'schema.json', + parseOnly: true); + } + + $this->processSchema(schemaConfig: $schemaObject); + } + + /** + * Process a schema object. + * + * @since COmanage Registry v5.0.0 + * @param object $schemaConfig Schema object + * @param bool $diffOnly If true, generate a diff against the current database state, but do not apply it + */ + + protected function processSchema(object $schemaConfig, bool $diffOnly=false) { + $schema = new Schema(); + + // Walk through $schemaConfig and build our schema in DBAL format. + + foreach($schemaConfig->tables as $tName => $tCfg) { + $table = $schema->createTable($tName); + + foreach($tCfg->columns as $cName => $cCfg) { + // We allow "inherited" definitions from the fieldLibrary, so merge together + // the configurations (if appropriate) + + $colCfg = (object)array_merge((isset($this->columnLibrary->columns->$cName) + ? (array)$this->columnLibrary->columns->$cName + : []), + (array)$cCfg); + + if(!isset($colCfg->type)) { + throw new \RuntimeException(__d('error', 'schema.column', [$tName, $cName])); + } + + // For type definitions see https://www.doctrine-project.org/projects/doctrine-dbal/en/2.12/reference/types.html#types + $options = []; + + if(isset($colCfg->autoincrement)) { + $options['autoincrement'] = $colCfg->autoincrement; + } + + if($colCfg->type == "string") { + $options['length'] = $colCfg->size; + } + + if(isset($colCfg->notnull)) { + $options['notnull'] = $colCfg->notnull; + } else { + $options['notnull'] = false; + } + + $table->addColumn($cName, $colCfg->type, $options); + + if(isset($colCfg->primarykey) && $colCfg->primarykey) { + $table->setPrimaryKey(["id"]); + } + + if(isset($colCfg->foreignkey)) { + $table->addForeignKeyConstraint($colCfg->foreignkey->table, + [$cName], + [$colCfg->foreignkey->column], + [], + // We name our foreign keys the same way they + // were previously named by adodb + $tName . "_" . $cName . "_fkey"); + } + } + + // (For Registry) If MVEA models are specified, emit the appropriate + // columns and indexes. MVEA attributes must be added before indexes, in + // case the table has composite indexes referencing MVEA columns. + + if(!empty($tCfg->mvea)) { + $i = 1; + + foreach($tCfg->mvea as $m) { + $mColumn = $m . "_id"; + $fkTable = \Cake\Utility\Inflector::tableize($m); + + // Insert a foreign key to this model and index it + $table->addColumn($mColumn, "integer", ['notnull' => false]); + $table->addForeignKeyConstraint($fkTable, [$mColumn], ['id'], [], $tName . "_" . $mColumn . "_fkey"); + $table->addIndex([$mColumn], $tName . "_im" . $i++); + } + } + + if(isset($tCfg->indexes)) { + // We don't autogenerate names for indexes so if the definition of an index + // changes DBAL can just rebuild that index instead of recreating every index + // on the table. (This should speed up schema updates vs ADOdb.) This does + // require each index to be named in the schema file, but we had to do that + // in axmls too, even though it rebuilt every index every time through. + + foreach($tCfg->indexes as $iName => $iCfg) { + // $flags and $options as passed to Index(), but otherwise undocumented + $flags = []; + $options = []; + + $table->addIndex($iCfg->columns, $iName, $flags, $options); + } + } + + // (For Registry) If an attribute is "sourced" it is a CO Person attribute + // that is copied via a Pipeline from an Org Identity that was created from + // an Org Identity Source, so we need a foreign key into ourself. + + if(isset($tCfg->sourced) && $tCfg->sourced) { + $sColumn = "source_" . \Cake\Utility\Inflector::singularize($tName) . "_id"; + + // Insert a foreign key to this model and index it + $table->addColumn($sColumn, "integer", ['notnull' => false]); + $table->addForeignKeyConstraint($table, [$sColumn], ['id'], [], $tName . "_" . $sColumn . "_fkey"); + $table->addIndex([$sColumn], $tName . "_im" . $i++); + } + + // Default is to insert timestamp and changelog fields, unless disabled + + if(!isset($tCfg->timestamps) || $tCfg->timestamps) { + // Insert Cake metadata fields + $table->addColumn("created", "datetime"); + $table->addColumn("modified", "datetime", ['notnull' => false]); + } + + if(!isset($tCfg->changelog) || $tCfg->changelog) { + // Insert ChangelogBehavior metadata fields + $clColumn = \Cake\Utility\Inflector::singularize($tName) . "_id"; + $table->addColumn($clColumn, "integer", ['notnull' => false]); + $table->addColumn("revision", "integer", ['notnull' => false]); + $table->addColumn("deleted", "boolean", ['notnull' => false]); + $table->addColumn("actor_identifier", "string", ['length' => 256, 'notnull' => false]); + + $table->addForeignKeyConstraint($table, [$clColumn], ['id'], [], $tName . "_" . $clColumn . "_fkey"); + $table->addIndex([$clColumn], $tName . "_icl", [], []); + } + } + + // This is the SQL that represents the desired state of the database + $toSql = $schema->toSql($this->conn->getDatabasePlatform()); + + // SchemaManager provides info about the database + $sm = $this->conn->createSchemaManager(); + + // The is the current database representation + $curSchema = $sm->createSchema(); + + $fromSql = $curSchema->toSql($this->conn->getDatabasePlatform()); + + try { + // We manually call compare so we can get the SchemaDiff object. We need + // this for toSaveSql(), which we use to avoid dropping undocumented tables + // (like the matchgrids, which are dynamically created and so won't be in the + // schema file). + $comparator = new Comparator(); + $schemaDiff = $comparator->compareSchemas($curSchema, $schema); + + $diffSql = $schemaDiff->toSaveSql($this->conn->getDatabasePlatform()); + + // We don't start a transaction since in general we always want to move to + // the desired state, and if we fail in flight it's probably a bug that + // needs to be fixed. + + foreach($diffSql as $sql) { + if($this->io) $this->io->out($sql); + + if($this->driver == 'Cake\Database\Driver\Postgres' + && preg_match("/^DROP SEQUENCE [a-z]*_id_seq/", $sql)) { + // Remove the DROP SEQUENCE statements in $fromSql because they're Postgres automagic + // being misinterpreted. (Note toSaveSql might mask this now.) + // XXX Maybe debug and file a PR to not emit DROP SEQUENCE on PG for autoincrementesque fields? + if($this->io) $io->out("Skipping sequence drop"); + } else { + if(!$diffOnly) { + $stmt = $this->conn->executeQuery($sql); + // $stmt just returns the query string so we don't bother examining it + } + } + } + + $this->alog('debug', $diffSql); + } + catch(\Exception $e) { + if($this->io) $this->io->out($e->getMessage()); + } + + // We might run bin/cake schema_cache clear or + // bin/cake schema_cache build --connection default + // but so far we don't have an example indicating it's needed. + } +} \ No newline at end of file diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index 8ee21e80b..51a8e9c96 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry - * @since COmanage Registry v3.3.0 + * @since COmanage Registry v5.0.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ @@ -46,7 +46,7 @@ class StringUtilities { public static function columnKey($modelsName, $c, $tz=null, $useCustomClMdlLabel=false): string { if(strpos($c, "_id", strlen($c)-3)) { // Key is of the form field_id, use .ct label instead - $k = Inflector::camelize(Inflector::pluralize(substr($c, 0, strlen($c)-3))); + $k = $this->foreignKeyToClassName($c); return __d('controller', $k, [1]); } @@ -76,6 +76,91 @@ public static function columnKey($modelsName, $c, $tz=null, $useCustomClMdlLabel return ($cfield !== $c) ? $cfield : \Cake\Utility\Inflector::humanize($c); } + /** + * Determine the class basename of a Cake Entity. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity + * @return string Entity Class Basename + * @todo Refactor existing code to use these calls (Standard/index.php, MVETrait, ReadOnlyTrait, TableMetaTrait, and ChangelogBehavior) + */ + + public static function entityToClassName($entity): string { + // $classPath will be something like App\Model\Entity\Name, but we want to return "Names" + $classPath = get_class($entity); + + return Inflector::pluralize(substr($classPath, strrpos($classPath, '\\')+1)); + } + + /** + * Determine the foreign key name to point to a Cake Entity (eg: foo_id for a Foo object). + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity + * @return string Foreign key name + */ + + public static function entityToForeignKey($entity): string { + // $classPath will be something like App\Model\Entity\Name, but we want to return "name_id" + $classPath = get_class($entity); + + return Inflector::underscore(Inflector::singularize(substr($classPath, strrpos($classPath, '\\')+1))) . "_id"; + } + + /** + * Determine the class name from a foreign key (eg: report_id -> Reports). + * + * @since COmanage Registry v5.0.0 + * @param string $s Foreign Key name + * @return string Class name + */ + + public static function foreignKeyToClassName(string $s): string { + return Inflector::camelize(Inflector::pluralize(substr($s, 0, strlen($s)-3))); + } + + /** + * Determine the model component of a Plugin path. + * + * @since COmanage Registry v5.0.0 + * @param string $s Plugin path, in Plugin.Model format. + * @return string Model name + */ + + public static function pluginModel(string $s): string { + $bits = explode('.', $s, 2); + + return $bits[1]; + } + + /** + * Determine the plugin component of a Plugin path. + * + * @since COmanage Registry v5.0.0 + * @param string $s Plugin path, in Plugin.Model format. + * @return string Plugin name + */ + + public static function pluginPlugin(string $s): string { + $bits = explode('.', $s, 2); + + return $bits[0]; + } + + /** + * Determine the Entity name from a Table object. + * + * @since COmanage Registry v5.0.0 + * @param Table $table Cake Table object + * @return string Entity name (eg: Report) + */ + + public static function tableToEntityName($table): string { + $classPath = $table->getEntityClass(); + + return substr($classPath, strrpos($classPath, '\\')+1); + } + // The following two utilities provide base64 encoding and decoding for // strings that might contain special characters that could interfere with // URLs. base64 can generate reserved characters, so we handle those specially diff --git a/app/src/Model/Behavior/ChangelogBehavior.php b/app/src/Model/Behavior/ChangelogBehavior.php index 612f089f7..b70729f0a 100644 --- a/app/src/Model/Behavior/ChangelogBehavior.php +++ b/app/src/Model/Behavior/ChangelogBehavior.php @@ -45,17 +45,32 @@ class ChangelogBehavior extends Behavior */ public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $options) { + if(isset($options['useHardDelete']) && $options['useHardDelete']) { + // Hard delete requested, so just return + return true; + } + $subject = $event->getSubject(); $alias = $subject->getAlias(); LogBehavior::strace($alias, 'Changelog converting delete to update'); + // Since we stop the delete event, we need to manually trigger cascades. + // Note Cake defaults to delete via deleteAll(), which skips callbacks, + // which means we wouldn't be called. Models need to declare "cascadeCallbacks" to + // true in association definitions. + // XXX though this will slow hard delete, which doesn't need it... + + $subject->associations()->cascadeDelete($entity, $options->getArrayCopy()); + + // Update this record as deleted + $entity->deleted = true; $subject->saveOrFail($entity, ['checkRules' => false, 'archive' => false]); // Stop the delete from actually happening $event->stopPropagation(); - + // But return success return true; } @@ -71,17 +86,16 @@ public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $op */ public function beforeFind(\Cake\Event\Event $event, \Cake\ORM\Query $query, \ArrayObject $options, bool $primary) { + if(isset($options['archived']) && $options['archived']) { + // Archived records requested (including possiblf expunge), so just return + return true; + } + $subject = $event->getSubject(); $table = $subject->getTable(); $alias = $subject->getAlias(); $parentfk = Inflector::singularize($table) . "_id"; - if(isset($options['archived']) && $options['archived']) { - // XXX need to the same check for expunge - - return true; - } - LogBehavior::strace($alias, 'Changelog altering find conditions'); // XXX add support for archived, revision, etc diff --git a/app/src/Model/Entity/Co.php b/app/src/Model/Entity/Co.php index d1e5fcede..20ffd8933 100644 --- a/app/src/Model/Entity/Co.php +++ b/app/src/Model/Entity/Co.php @@ -31,6 +31,8 @@ use Cake\ORM\Entity; +use \App\Lib\Enum\SuspendableStatusEnum; + class Co extends Entity { protected $_accessible = [ '*' => true, @@ -38,6 +40,17 @@ class Co extends Entity { 'slug' => false, ]; + /** + * Determine if this CO is Active. + * + * @since COmanage Registry v5.0.0 + * @return bool true if the CO is Active, false otherwise + */ + + public function isActive(): bool { + return $this->status == SuspendableStatusEnum::Active; + } + /** * Determine if this entity is the COmanage CO. * diff --git a/app/src/Model/Entity/Plugin.php b/app/src/Model/Entity/Plugin.php new file mode 100644 index 000000000..f91bc25e3 --- /dev/null +++ b/app/src/Model/Entity/Plugin.php @@ -0,0 +1,80 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this Plugin can be activated. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this plugin can be activated, false otherwise + */ + + public function canActivate(): bool { + // Any Suspended plugin can be activated + + return $this->status == SuspendableStatusEnum::Suspended; + } + + /** + * Determine if this Plugin can be deactivated. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this plugin can be deactivated, false otherwise + */ + + public function canDeactivate(): bool { + // Only non-core Active plugins can be deactivated + + return ($this->status == SuspendableStatusEnum::Active && !$this->isReadOnly()); + } + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // Local plugins are read only + + return $this->location == 'core'; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/CosTable.php b/app/src/Model/Table/CosTable.php index dba58be6d..8fad3b625 100644 --- a/app/src/Model/Table/CosTable.php +++ b/app/src/Model/Table/CosTable.php @@ -29,15 +29,19 @@ namespace App\Model\Table; -use App\Lib\Enum\StatusEnum; +use Cake\Datasource\EntityInterface; +use Cake\ORM\Exception\PersistenceFailedException; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\Utility\Hash; use Cake\Validation\Validator; + +use \App\Lib\Enum\StatusEnum; use \App\Lib\Enum\SuspendableStatusEnum; use \App\Lib\Enum\TemplateableStatusEnum; +use \App\Lib\Util\PaginatedSqlIterator; class CosTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; @@ -66,21 +70,30 @@ public function initialize(array $config): void { // Define associations $this->hasMany('ApiUsers') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Cous') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Dashboards') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Groups') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('People') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('Reports') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Types') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasOne('CoSettings') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->setDisplayField('name'); @@ -97,6 +110,7 @@ public function initialize(array $config): void { 'delete' => ['platformAdmin'], 'duplicate' => ['platformAdmin'], 'edit' => ['platformAdmin'], + 'switch' => ['platformAdmin'], 'view' => ['platformAdmin'] ], // Actions that are permitted on readonly entities (besides view) @@ -106,6 +120,10 @@ public function initialize(array $config): void { 'add' => ['platformAdmin'], 'index' => ['platformAdmin'], 'select' => ['authenticatedUser'] + ], + // Related models whose permissions we'll need, typically for table views + 'related' => [ + 'Dashboards' ] ]); } @@ -145,6 +163,105 @@ public function buildRules(RulesChecker $rules): RulesChecker { return $rules; } + /** + * Delete a CO. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity CO to be deleted + * @param array $options Delete options (as per Cake) + * @return boolean true on success + * @throws Cake\ORM\Exception\PersistenceFailedException + */ + + public function deleteOrFail(EntityInterface $entity, $options = []): bool { + // Completely wiping a CO requires special handling. Because of the complex + // dependency paths, we can't simply rely on Cake's dependency propagation + // on delete. + + // We ignore $options['useHardDelete'] because COs can _only_ be hard deleted. + + // We'll start by obtaining the set of models directly associated with the CO model. + $associations = $this->associations(); + + // We need to sort the associations into several buckets: + // (1) Pluggable Models, + // (2) Configuration Models that belong to a model other than Cos, + // (3) Primary Models, + // (4) Configuration Models that do not belong to another model + // We don't need to identify Secondary Models because Primary Model deletes + // will cascade to them, and generally Cake's cascade delete will be sufficient. + // See also: https://spaces.at.internet2.edu/display/COmanage/Registry+PE+Data+Model#RegistryPEDataModel-Tables + + // These will be keyed on the association target class name, with values being the Table objects + $pluggable = []; + $configFirst = []; + $primary = []; + $configLast = []; + + foreach($associations->getByType(['HasOne', 'HasMany']) as $a) { + $targetTable = $a->getTarget(); + + if(method_exists($targetTable, "getPluggableModelType")) { + $pluggable[ $a->getClassName() ] = $targetTable; + } elseif($targetTable->getIsConfigurationTable()) { + // eg: CoSettings + $targetAssociations = $a->associations(); + + // Did we find an association to something other than Cos? + $found = false; + + foreach($targetAssociations->getByType(['belongsTo', 'belongsToMany']) as $ta) { + // eg: Types (CoSettings belongsTo Types) + // We also skip associations into the same model (eg: Cous, for TreeBehavior) + + if($ta->getClassName() != 'Cos' + && $ta->getClassName() != $a->getClassName()) { + $found = true; + break; + } + } + + if($found) { + $configFirst[ $a->getClassName() ] = $targetTable; + } else { + $configLast[ $a->getClassName() ] = $targetTable; + } + } else { + // This is by definition a Primary Object since it belongsTo CO, eg: People + $primary[ $a->getClassName() ] = $targetTable; + } + } + + // First, delete plugin related models + // XXX unclear that we need to do anything here... PluggableModelTrait will + // automatically bind instantiated Entry Point Models when a Pluggable Table object + // is initialized, so plugin related models should be automatically deleted when + // the Pluggable Model is deleted. + + // Delete any Configuration Object that references a Primary Object or other + // Configuration Objects (such as Types) + + $this->paginatedDelete($entity->id, $configFirst); + + // Delete Primary Objects, which should cascade and take Secondary Objects with them. + + $this->paginatedDelete($entity->id, $primary); + + // Delete any remaining Configuration Objects, (including Types) after all + // models that might reference them + + $this->paginatedDelete($entity->id, $configLast); + + // Delete any Changelog records for this CO. We can use deleteAll because we + // don't need any callbacks to fire. + $this->deleteAll(['Cos.co_id' => $entity->id]); + + // Finally, delete the CO itself + parent::deleteOrFail($entity, ['useHardDelete' => true, 'checkRules' => false]); + + return true; + } + /* public function duplicate($id) { // XXX document AR-CO-4, use TableMetaTrait to determine which tables are configuration @@ -195,7 +312,7 @@ public function getCosForIdentifier(string $loginIdentifier): array { foreach($identifiers as $i) { // Both the Person and the CO must be active. Note that there may be an - // Active Identifier pointing to a Deleted Person (for certain edge cases), + // Active Identifier pointing to an Archived Person (for certain edge cases), // in which case $i->person is null even though person_id is not. if($i->person && $i->person->isActive() @@ -233,7 +350,32 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } - + + /** + * Perform a paginated delete over a large set of objects within a CO. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @param array $tableSet Set of tables to operate over. + * @todo This could probably be generalized, if it were useful somewhere else + */ + + protected function paginatedDelete(int $coId, array $tableSet) { + foreach($tableSet as $tableName => $table) { + $iterator = new PaginatedSqlIterator(table: $table, + conditions: ['co_id' => $coId], + options: ['archived' => true]); + + foreach($iterator as $k => $tentity) { + // We call delete on each entity individually so that callbacks fire, + // in particular the unsetting of foreign keys that might be set. + + // We disable checkRules since we're hard deleting all objects in the CO. + $table->deleteOrFail($tentity, ['useHardDelete' => true, 'checkRules' => false]); + } + } + } + /** * Application Rule to determine if the current entity is the COmanage CO. * diff --git a/app/src/Model/Table/ExternalIdentitiesTable.php b/app/src/Model/Table/ExternalIdentitiesTable.php index 455c19c76..d96180634 100644 --- a/app/src/Model/Table/ExternalIdentitiesTable.php +++ b/app/src/Model/Table/ExternalIdentitiesTable.php @@ -69,25 +69,35 @@ public function initialize(array $config): void { ->setClassName('Names') ->setConditions(['PrimaryName.primary_name' => true]); $this->hasMany('Names') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Addresses') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('AdHocAttributes') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('EmailAddresses') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('ExternalIdentityRoles') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('HistoryRecords') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Identifiers') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Pronouns') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('TelephoneNumbers') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Urls') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->setDisplayField('id'); diff --git a/app/src/Model/Table/ExternalIdentityRolesTable.php b/app/src/Model/Table/ExternalIdentityRolesTable.php index 987648b41..42b7d68d8 100644 --- a/app/src/Model/Table/ExternalIdentityRolesTable.php +++ b/app/src/Model/Table/ExternalIdentityRolesTable.php @@ -71,13 +71,17 @@ public function initialize(array $config): void { ->setProperty('affiliation_type'); $this->hasMany('Addresses') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('AdHocAttributes') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('TelephoneNumbers') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('HistoryRecords') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->setDisplayField('id'); diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index d0046913b..e4e7b0d27 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -73,15 +73,20 @@ public function initialize(array $config): void { $this->belongsTo('Cous'); $this->hasMany('GroupMembers') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('GroupNestings') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('GroupOwners') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('HistoryRecords') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Identifiers') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->setDisplayField('name'); @@ -423,7 +428,7 @@ protected function reconcileAutomaticGroup(\Cake\Datasource\EntityInterface $ent $this->GroupMembers->delete($groupMember); } } else { - if(!$person || $person->status == StatusEnum::Deleted) { + if(!$person || $person->status == StatusEnum::Archived) { $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); } @@ -435,7 +440,7 @@ protected function reconcileAutomaticGroup(\Cake\Datasource\EntityInterface $ent // correlated membership. if(!empty($entity->cou_id)) { - // This won't return roles in Deleted status, but returns all others + // This won't return roles in Archived status, but returns all others $iterator = $this->Cous->PersonRoles->getMembers($entity->cou_id); foreach($iterator as $k => $personRole) { diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index 764b3f5d2..1671ae9d6 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -42,6 +42,7 @@ class NamesTable extends Table { 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\TableMetaTrait; @@ -221,6 +222,11 @@ public function ruleMinimumOneName($entity, $options) { $count = $this->find()->where($entity->whereClause())->count(); if($count == 1) { + $this->llog( + level: 'error', + msg: "AR-Name-4 Each Person or ExternalIdentity must have at least one name at all times", + id: $entity->id + ); return __d('error', 'Names.minimum'); } @@ -238,6 +244,11 @@ public function ruleMinimumOneName($entity, $options) { public function rulePrimaryNameDelete($entity, $options) { if($entity->primary_name) { + $this->llog( + level: 'error', + msg: "AR-Name-1 The Primary Name cannot be deleted", + id: $entity->id + ); return __d('error', 'Names.primary_name.del'); } diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 07582a198..f495b9ce0 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -73,29 +73,44 @@ public function initialize(array $config): void { ->setClassName('Names') ->setConditions(['PrimaryName.primary_name' => true]); $this->hasMany('Names') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Addresses') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('AdHocAttributes') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('EmailAddresses') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('ExternalIdentities') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('GroupMembers') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('GroupOwners') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('HistoryRecords') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Identifiers') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('PersonRoles') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Pronouns') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('TelephoneNumbers') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('Urls') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); // XXX can we change this to Name? $this->setDisplayField('id'); @@ -158,6 +173,37 @@ public function initialize(array $config): void { ]); } + /** + * Callback before model delete. + * + * @since COmanage Registry v5.0.0 + * @param CakeEventEvent $event The beforeDelete event + * @param $entity Entity + * @param ArrayObject $options Options + * @return boolean True on success + */ + + public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $options) { + if(isset($options['useHardDelete']) + && $options['useHardDelete'] + && $entity->id > 0) { + // Hard delete, so clear out any foreign keys pointing to this Person. + // This will also clear foreign keys from archived changelog records. + + $this->PersonRoles->updateAll( + [ 'manager_person_id' => null ], + [ 'manager_person_id' => $entity->id ] + ); + + $this->PersonRoles->updateAll( + [ 'sponsor_person_id' => null ], + [ 'sponsor_person_id' => $entity->id ] + ); + } + + return true; + } + /** * Callback after model save. * @@ -206,7 +252,7 @@ public function generateDisplayField(\App\Model\Entity\Person $entity): string { public function getMembers(int $coId): PaginatedSqlIterator { $conditions = [ 'co_id' => $coId, - 'status IS NOT' => StatusEnum::Deleted + 'status IS NOT' => StatusEnum::Archived ]; return new PaginatedSqlIterator($this, $conditions); @@ -226,7 +272,7 @@ public function reconcileCoMembersGroupMemberships(\Cake\Datasource\EntityInterf // This is similar to PersonRole::reconcileCouMembersGroupMemberships. $activeEligible = $entity->isActive(); - $allEligible = $entity->status != StatusEnum::Deleted; + $allEligible = $entity->status != StatusEnum::Archived; // 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); diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php index 06054310c..d66192061 100644 --- a/app/src/Model/Table/PersonRolesTable.php +++ b/app/src/Model/Table/PersonRolesTable.php @@ -54,7 +54,7 @@ class PersonRolesTable extends Table { // 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' => [ + 'affiliation_type' => [ 'affiliate', 'alum', 'employee', @@ -101,13 +101,17 @@ public function initialize(array $config): void { ->setProperty('affiliation_type'); $this->hasMany('Addresses') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('AdHocAttributes') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('TelephoneNumbers') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('HistoryRecords') - ->setDependent(true); + ->setDependent(true) + ->setCascadeCallbacks(true); $this->setDisplayField('id'); @@ -137,7 +141,7 @@ public function initialize(array $config): void { ], 'affiliationTypes' => [ 'type' => 'type', - 'attribute' => 'PersonRoles.affiliation' + 'attribute' => 'PersonRoles.affiliation_type' ], 'cous' => [ 'type' => 'select', @@ -195,7 +199,7 @@ public function getMembers(int $couId): PaginatedSqlIterator { // Expiration Policies will correctly set status. $conditions = [ 'cou_id' => $couId, - 'status IS NOT' => StatusEnum::Deleted + 'status IS NOT' => StatusEnum::Archived ]; return new PaginatedSqlIterator($this, $conditions); @@ -246,7 +250,7 @@ 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 + // We need to examine the status of any roles returned since an Archived Role // does not count as "Any" Role. $roles = $this->find('all') @@ -259,8 +263,8 @@ public function hasAny(int $personId, int $couId): bool { } foreach($roles as $role) { - // Any non-deleted role is sufficient - if($role->status != StatusEnum::Deleted) { + // Any non-archived role is sufficient + if($role->status != StatusEnum::Archived) { return true; } } @@ -339,7 +343,7 @@ public function reconcileCouMembersGroupMemberships(\Cake\Datasource\EntityInter // For $activeEligible, we need at least one active role $activeRole = false; - // For $allEligible, we need at least one role not Deleted + // For $allEligible, we need at least one role not Archived $allEligible = false; foreach($roles as $role) { @@ -347,7 +351,7 @@ public function reconcileCouMembersGroupMemberships(\Cake\Datasource\EntityInter $activeRole = true; } - if($role->status != StatusEnum::Deleted) { + if($role->status != StatusEnum::Archived) { $allEligible = true; } } diff --git a/app/src/Model/Table/PluginsTable.php b/app/src/Model/Table/PluginsTable.php new file mode 100644 index 000000000..93bd55130 --- /dev/null +++ b/app/src/Model/Table/PluginsTable.php @@ -0,0 +1,498 @@ + [ + 'path' => ROOT . DS . 'plugins', + 'status' => SuspendableStatusEnum::Active + ], + PluginLocationEnum::Available => [ + 'path' => ROOT . DS . 'availableplugins', + 'status' => SuspendableStatusEnum::Suspended + ], + PluginLocationEnum::Local => [ + 'path' => LOCAL . DS . 'plugins', + 'status' => SuspendableStatusEnum::Suspended + ] + ]; + + /** + * Perform Cake Model initialization. + * + * @since COmanage Registry v5.0.0 + * @param array $config Configuration options passed to constructor + */ + + public function initialize(array $config): void { + // $this->addBehavior('Changelog'); + // $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + // Plugins are configuration + $this->setIsConfigurationTable(true); + + $this->setDisplayField('plugin'); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'activate' => ['platformAdmin'], + 'applySchema' => ['platformAdmin'], + 'deactivate' => ['platformAdmin'], + 'delete' => false, + 'edit' => false, + 'view' => false + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['applySchema'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + // Plugins are added automatically + 'add' => false, + 'index' => ['platformAdmin'] + ] + ]); + } + + /** + * Activate a plugin. + * + * @since COmanage Registry v5.0.0 + * @param int $id Plugin ID + * @return bool True on success + */ + + public function activate(int $id): bool { + $plugin = $this->get($id); + + if($plugin->canActivate()) { + $plugin->status = SuspendableStatusEnum::Active; + $plugin->comment = __d('information', 'plugin.active'); + $this->saveOrFail($plugin); + + // AR-Plugin-6 If a plugin is activated, apply its schema. Note it's possible + // the schema was previously applied, but that's OK, this will just bring it + // up to date (which might imply no changes). + + $pSchemaConfig = $this->getPluginSchema($plugin); + + if($pSchemaConfig) { + $SchemaManager = new SchemaManager(); + + $SchemaManager->applySchemaObject($pSchemaConfig); + } + } + + return true; + } + + /** + * Apply the database schema for a plugin. + * + * @since COmanage Registry v5.0.0 + * @param int $id Plugin ID + * @return bool True on success + */ + + public function applySchema(int $id): bool { + $plugin = $this->get($id); + + $pSchemaConfig = $this->getPluginSchema($plugin); + + if($pSchemaConfig) { + $SchemaManager = new SchemaManager(); + + $SchemaManager->applySchemaObject($pSchemaConfig); + } else { + $this->llog('debug', "Plugin $plugin->plugin does not define a database schema"); + } + + 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-Plugin-8 A Plugin cannot be suspended or deleted if it is in use (referenced in + // a configuration object). (Strictly speaking we don't need to do this for create/add + // since nothing should reference the pluing at that point.) + $rules->addUpdate([$this, 'ruleInUse'], + 'inUse', + ['errorField' => 'status']); + + $rules->addDelete([$this, 'ruleInUse'], + 'inUse', + ['errorField' => 'status']); + + return $rules; + } + + /** + * Deactivate a plugin. + * + * @since COmanage Registry v5.0.0 + * @param int $id Plugin ID + * @return bool True on success + */ + + public function deactivate(int $id): bool { + $plugin = $this->get($id); + + if($plugin->canDeactivate()) { + $plugin->status = SuspendableStatusEnum::Suspended; + $plugin->comment = __d('information', 'plugin.inactive'); + $this->saveOrFail($plugin); + + // AR-Plugin-7 If a plugin is suspended, its associated database schema is NOT removed + } + + return true; + } + + /** + * Find the set of active Plugins. + * + * @since COmanage Registry v5.0.0 + * @param Query $query Cake Query object + * @return Query $query Cake Query object + */ + + public function findActive(Query $query): Query { + return $query->where(['Plugins.status' => SuspendableStatusEnum::Active]); + } + + /** + * Obtain the set of active plugin models of a given type. + * + * @since COmanage Registry v5.0.0 + * @param string $type Plugin type + * @return array Array of plugin models implementing $type + */ + + public function getActivePluginModels(string $type): array { + // First get the list of enabled plugins + + $plugins = $this->find('active')->all(); + + $active = []; + + foreach($plugins as $p) { + // Interrogate each plugin for its models implementing $type + $active = array_merge($active, + array_map(function($v) use ($p) { + return $p->plugin . "." . $v; + }, $this->getPluginModelsByType($p, $type))); + } + + // For use in populating the select, we want the keys and values to be the same + return array_combine($active, $active); + } + + /** + * Obtain the Entry Point Models implemented by a plugin for a specific plugin type. + * + * @since COmanage Registry v5.0.0 + * @param Plugin $plugin Cake Plugin object + * @param string $type Plugin type + * @return array Array of Entry Point Models + */ + + public function getPluginModelsByType(\App\Model\Entity\Plugin $plugin, string $type): array { + // Plugins can implement multiple plugin types, and multiple "entry point" models + // into each type. The index for this configuration is maintained in the plugin's + // src/config/plugin.json file. + + $pConfig = $this->readPluginConfig(plugin: $plugin, key: "types"); + + if(!empty($pConfig)) { + if(isset($pConfig->$type) && is_array($pConfig->$type)) { + return $pConfig->$type; + } else { + $this->llog('debug', "Plugin $plugin->plugin does not have a valid types configuration"); + } + } else { + $this->llog('debug', "Plugin $plugin->plugin does not have a plugin.json file"); + } + + return []; + } + + /** + * Obtain the database schema defined for a plugin. + * + * @since COmanage Registry v5.0.0 + * @param Plugin $plugin Cake Plugin object + * @return object Database schema in object form + */ + + public function getPluginSchema(\App\Model\Entity\Plugin $plugin): ?object { + return $this->readPluginConfig($plugin, 'schema'); + } + + /** + * Determine the filesystem path to a file within a plugin. + * + * @since COmanage Registry v5.0.0 + * @param Plugin $plugin Plugin object + * @param string $file File name + * @return string Path to file + * @throws InvalidArgumentException + */ + + public function pluginPath(\App\Model\Entity\Plugin $plugin, string $file): string { + $fileName = $this->paths[$plugin->location]['path'] . DS . $plugin->plugin . DS . $file; + + if(is_readable($fileName)) { + // This is the plugin we're looking for + $this->llog('debug', "Found plugin $plugin->plugin in $plugin->location directory"); + + return $fileName; + } + + $this->llog('error', "Could not find $plugin"); + + throw new \InvalidArgumentException("Could not find $plugin"); + } + + /** + * Read the plugin configuration. + * + * @since COmanage Registry v5.0.0 + * @param Plugin $plugin Plugin object + * @param string $key Configuration key + * @return object Configuration, as a parsed json object + */ + + protected function readPluginConfig(\App\Model\Entity\Plugin $plugin, string $key): ?object { + $cfg = $this->pluginPath($plugin, 'src' . DS . 'config' . DS . 'plugin.json');; + + $json = file_get_contents($cfg); + + if(!empty($json)) { + $jcfg = json_decode($json); + + if(isset($jcfg->$key)) { + return $jcfg->$key; + } + } + + return null; + } + + /** + * Application Rule to determine if the current entity is in use (is referenced + * by a configuration object). + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.0.0 + */ + + public function ruleInUse($entity, array $options): string|bool { + // This rule only applies if the Plugin was suspended. + if(!$entity->isDirty('status') || $entity->status != SuspendableStatusEnum::Suspended) { + return true; + } + + // $entity->plugin is the physical plugin directory on the filesystem + // (the Registry Plugin), which can implement multiple Entry Points, + // each of which can be a different type. First, pull that configuration. + + $pConfig = $this->readPluginConfig(plugin: $entity, key: "types"); + + // We now need to query each Pluggable Model ("ReportsTable" for plugins + // of type "report") to see if this Plugin is in use for that Model. + // If any Pluggable Model returns true, we fail validation. + + foreach($pConfig as $type => $entryPoints) { + if(!empty($entryPoints)) { + // Convert $type to a table class (eg: "report" to "Reports") + $tableName = Inflector::pluralize(Inflector::classify($type)); + $table = TableRegistry::getTableLocator()->get($tableName); + + // We don't actually need $entryPoints here, we just wanted to make sure + // the plugin implements at least one Entry Point for this $type. + $r = $table->pluginInUse($entity->plugin); + + if(!empty($r) && count($r) > 0) { + // There could be other plugin types in use, but that's probably the + // exception and anyway just returning a single error will be sufficient + // for now + + return __d('error', 'Plugins.inuse', [count($r), $type, $r->first()->name, $r->first()->co_id]); + } + } + } + + return true; + } + + /** + * Examine the available set of plugins and update the global Plugins table appropriately. + * + * @since COmanage Registry v5.0.0 + */ + + public function syncPluginRegistry() { + // Determine which plugins are on the filesystem in the various supported locations + + // The plugins we've found + $plugins = [ + PluginLocationEnum::Core => [], + PluginLocationEnum::Available => [], + PluginLocationEnum::Local => [] + ]; + + $pluginIndex = []; + + // First pull the set of available plugins + + foreach($this->paths as $t => $cfg) { + if(!file_exists($cfg['path'])) { + continue; + } + + $dh = opendir($cfg['path']); + + while(($d = readdir($dh)) !== false) { + if($d == "." || $d == "..") { + continue; + } + + $plugins[$t][] = $d; + + if(empty($pluginIndex[$d])) { + // We want the first path we find for a given plugin so as not to violate AR-Plugin-5 + $pluginIndex[$d] = $t; + } + } + + closedir($dh); + } + + // Pull our current Plugin configuration + + $registeredIndex = []; + + $registered = $this->find()->all(); + + // Create an array of the already registered plugins + foreach($registered as $rp) { + $registeredIndex[$rp->plugin] = $rp->location; + } + + // Insert rows for any plugin not currently in the Registry. + // Core plugins are inserted as active, others as suspended. + + $newPlugins = []; + + foreach(array_keys($plugins) as $pluginType) { + foreach($plugins[$pluginType] as $p) { + if(!isset($registeredIndex[$p])) { + $obj = $this->newEntity([ + 'plugin' => $p, + 'location' => $pluginType, + 'status' => $this->paths[$pluginType]['status'], + 'comment' => __d('information', + $this->paths[$pluginType]['status'] == SuspendableStatusEnum::Active + ? 'plugin.active.only' + : 'plugin.inactive') + ]); + + $this->saveOrFail($obj); + } + } + } + + // Remove rows for any plugin that no longer exists on disk + foreach($registered as $rp) { + if(!isset($pluginIndex[$rp->plugin])) { + $this->deleteOrFail($rp); + } + } + } + + /** + * 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(); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + $validator->add('location', [ + 'content' => ['rule' => ['inList', PluginLocationEnum::getConstValues()]] + ]); + $validator->notEmptyString('location'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'comment', false); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php index 6ec5d8bcc..a0a3368c4 100644 --- a/app/src/Model/Table/TypesTable.php +++ b/app/src/Model/Table/TypesTable.php @@ -56,7 +56,7 @@ class TypesTable extends Table { protected $supportedAttributes = [ 'Addresses.type', // 'Departments.type', - 'PersonRoles.affiliation', + 'PersonRoles.affiliation_type', 'EmailAddresses.type', 'Identifiers.type', 'Names.type', @@ -90,6 +90,8 @@ public function initialize(array $config): void { $this->hasMany('EmailAddresses'); $this->hasMany('Identifiers'); $this->hasMany('Names'); + $this->hasMany('PersonRoles') + ->setForeignKey('affiliation_type_id'); $this->hasMany('Pronouns'); $this->hasMany('TelephoneNumbers'); $this->hasMany('Urls'); @@ -150,7 +152,7 @@ public function addDefault(int $coId, string $attribute) { throw new \InvalidArgumentException(__d('error', 'unknown', [$attribute])); } - // Split $attribute + // Split $attribute (eg: Names.type) $attr = explode('.', $attribute, 2); // We need the appropriate model for $attribute to manipulate the default types diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 2b09d23b8..b28d6f24f 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -34,6 +34,7 @@ use Cake\Utility\Inflector; use Cake\View\Helper; use App\Lib\Enum\DateTypeEnum; +use App\Lib\Util\StringUtilities; class FieldHelper extends Helper { public $helpers = ['Form', 'Html', 'Url', 'Alert']; @@ -50,6 +51,9 @@ class FieldHelper extends Helper { // The current entity, if edit or view protected $entity = null; + // The current action + protected $action = null; + /** * Emit an informational banner. * @@ -85,7 +89,11 @@ public function control(string $fieldName, string $cssClass=''): string { $coptions = $options; $coptions['label'] = false; - $coptions['readonly'] = !$this->editable || (isset($options['readonly']) && $options['readonly']); + $coptions['readonly'] = + !$this->editable + || (isset($options['readonly']) && $options['readonly']) + // Plugins can't be changed after the parent object is instantiated + || ($fieldName == 'plugin' && $this->action == 'edit'); // Selects, Checkboxes, and Radio Buttons use "disabled" $coptions['disabled'] = $coptions['readonly']; @@ -113,6 +121,22 @@ public function control(string $fieldName, // Generate the form control or pass along the markup generated in a wrapper function $controlCode = empty($ctrlCode) ? $this->Form->control($fieldName, $coptions) : $ctrlCode; + $vv_obj = $this->getView()->get('vv_obj'); + + if($fieldName == 'plugin') { + return $this->statusControl($fieldName, + $vv_obj->$fieldName, + [ + 'label' => __d('operation', 'configure.plugin'), + 'url' => [ + 'plugin' => null, + 'controller' => 'reports', + 'action' => 'configure', + $vv_obj->id + ] + ], + $labelText); + } // Required fields are usually determined by the model validator, but for // related models the view (currently) has to pass the field as required in @@ -426,6 +450,7 @@ public function startControlSet(string $modelName, $this->modelName = $modelName; $this->reqFields = $reqFields; $this->entity = $entity; + $this->action = $action; return '