Skip to content

Commit

Permalink
Initial commit for Group Hierarchy (CO-1223)
Browse files Browse the repository at this point in the history
  • Loading branch information
Benn Oshrin committed Oct 10, 2025
1 parent b7d403d commit e93fb79
Show file tree
Hide file tree
Showing 15 changed files with 410 additions and 180 deletions.
14 changes: 6 additions & 8 deletions app/config/schema/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,17 +203,14 @@
"id": {},
"co_id": {},
"name": {},
"description": {},
"parent_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } },
"lft": { "type": "integer" },
"rght": { "type": "integer" }
"description": {}
},
"indexes": {
"cous_i1": { "columns": [ "co_id" ] },
"cous_i2": { "columns": [ "name" ] },
"cous_i3": { "columns": [ "co_id", "name" ] },
"cous_i4": { "needed": false, "columns": [ "parent_id" ] }
}
"cous_i3": { "columns": [ "co_id", "name" ] }
},
"tree": true
},

"dashboards": {
Expand Down Expand Up @@ -325,7 +322,8 @@
"groups_i4": { "columns": [ "cou_id", "group_type" ] },
"groups_i5": { "needed": false, "columns": [ "cou_id" ]},
"groups_i6": { "needed": false, "columns": [ "owners_group_id" ]}
}
},
"tree": true
},

"group_nestings": {
Expand Down
28 changes: 26 additions & 2 deletions app/resources/locales/en_US/error.po
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,30 @@ msgstr "Target group is already nested into this Group"
msgid "GroupNestings.same"
msgstr "Group cannot be nested into itself"

msgid "Groups.name.prefix"
msgstr "Standard Groups may not have names starting with \"CO:\""
msgid "Groups.child.standard"
msgstr "Only Standard Groups may have parents"

msgid "Groups.children"
msgstr "Group has {0} child(ren) and cannot be deleted"

msgid "Groups.name.co"
msgstr "Standard Groups may not be named \"CO\""

msgid "Groups.name.colon"
msgstr "Standard Groups may not have names with a colon (\":\")"

msgid "Groups.name.inuse"
msgstr "A Group ({0}) with the same parent ({1}) is already using the requested name"

msgid "Groups.nested"
msgstr "Group is nested or has nestings, and cannot be suspended or deleted"

msgid "Groups.owners.update"
msgstr "Update called on Owners Group"

msgid "Groups.parent.standard"
msgstr "Only Standard Groups may be parents"

msgid "IdentifierAssignments.exists"
msgstr "The identifier \"{0}\" is already in use"

Expand Down Expand Up @@ -403,6 +418,12 @@ msgstr "Ooops... Something went wrong."
msgid "setup.co.comanage"
msgstr "Failed to setup COmanage CO"

msgid "tree.parent.invalid"
msgstr "The selected {0} is not a valid parent for the current record"

msgid "tree.parent.same"
msgstr "The current {0} cannot be its own parent"

msgid "Types.inuse"
msgstr "Type {0} is in use and cannot be deleted"

Expand All @@ -415,6 +436,9 @@ msgstr "Unknown value \"{0}\""
msgid "unknown.identifier"
msgstr "Unknown Identifier \"{0}\""

msgid "ug.task.checkGroupNames.invalid"
msgstr "Found {0} Group(s) with colons in their names or named 'CO', these Groups must be renamed before continuing"

msgid "ug.task.unknown"
msgstr "Task {0} is not defined"

Expand Down
6 changes: 6 additions & 0 deletions app/resources/locales/en_US/field.po
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ msgstr "Parent Record ID"
msgid "changelog.revision"
msgstr "Revision"

msgid "children"
msgstr "Children"

msgid "code"
msgstr "Code"

Expand Down Expand Up @@ -612,6 +615,9 @@ msgstr "Open groups may be self-joined by any Person in the CO"
msgid "Groups.owners.desc.affix"
msgstr "{0} Owners"

msgid "Groups.owners.for"
msgstr "Owners for Group"

msgid "Groups.owners_group_id"
msgstr "Owners Group"

Expand Down
6 changes: 6 additions & 0 deletions app/resources/locales/en_US/information.po
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@ msgstr "Current version: {0}"
msgid "ug.version.target"
msgstr "Target version: {0}"

msgid "ug.tasks.buildGroupTree"
msgstr "Recovering Group tree"

msgid "ug.tasks.checkGroupNames"
msgstr "Verifying Group names"

msgid "ug.tasks.createDefaultGroups.co"
msgstr "Creating new Default Groups for CO {0}"

Expand Down
60 changes: 59 additions & 1 deletion app/src/Command/UpgradeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use Cake\Console\ConsoleIo;
use Cake\Console\ConsoleOptionParser;
use Cake\Datasource\ConnectionManager;
use \App\Lib\Enum\GroupTypeEnum;

class UpgradeCommand extends BaseCommand
{
Expand Down Expand Up @@ -72,14 +73,23 @@ class UpgradeCommand extends BaseCommand
],
"5.2.0" => [
'block' => false,
'post' => ['createDefaultGroups', 'installMostlyStaticPages']
'pre' => [
'checkGroupNames'
],
'post' => [
'buildGroupTree',
'createDefaultGroups',
'installMostlyStaticPages'
]
]
];

// For descriptions of task parameters, see dispatch(). We store these separately
// to make them easier to use regardless of context (pre/post/manual).

protected $taskParams = [
'buildGroupTree' => ['global' => true],
'checkGroupNames' => ['global' => true],
'createDefaultGroups' => ['perCO' => true, 'perCOU' => true],
'installMostlyStaticPages' => ['perCO' => true]
];
Expand Down Expand Up @@ -326,6 +336,54 @@ protected function dispatch(string $task) {
}
}

/**
* Establish tree metadata for Groups.
*
* @since COmanage Registry v5.2.0
*/

protected function buildGroupTree() {
// Although we partition Groups by CO, tree metadata applies to the table
// as a whole, so we only need to run this once.

$GroupsTable = $this->getTableLocator()->get('Groups');
$GroupsTable->recover();
}

/**
* Check that no Standard Groups have colons in their names.
*
* @since COmanage Registry v5.2.0
*/

protected function checkGroupNames() {
// Because it's not clear how to resolve conflicting Group names, we simply throw
// an error and require the administrator to figure out what to do.

$GroupsTable = $this->getTableLocator()->get('Groups');

// AR-Group-9 Standard Group names may not use colons (:) and Standard Groups may not be named CO.
$problemGroups = $GroupsTable->find()
->where([
'OR' => [
'name LIKE' => '%:%',
'name' => 'CO'
],
'group_type' => GroupTypeEnum::Standard
])
->all();

if($problemGroups->count() > 0) {
$this->io->err(__d('error', 'ug.task.checkGroupNames.invalid', [$problemGroups->count()]));

foreach($problemGroups as $pg) {
$this->io->err($pg->id . " = " . $pg->name);
}

throw new \RuntimeException(__d('error', 'ug.task.checkGroupNames.invalid', [$problemGroups->count()]));
}
}

/**
* Create Approver and MFA Exemption Groups.
*
Expand Down
34 changes: 0 additions & 34 deletions app/src/Controller/CousController.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,45 +31,11 @@

// XXX not doing anything with Log yet
use Cake\Log\Log;
//use \App\Lib\Enum\PermissionEnum;

class CousController extends StandardController {


protected array $paginate = [
'order' => [
'Cous.name' => 'asc'
]
];

/**
* Callback run prior to the request render.
*
* @since COmanage Registry v5.0.0
* @param EventInterface $event Cake Event
*/

public function beforeRender(\Cake\Event\EventInterface $event) {
if(!$this->request->is('restful')) {
// Pull the set of potential Parent COUs

switch($this->request->getParam('action')) {
case 'add':
$this->set('parents', $this->Cous->potentialParents($this->getCOID(), null, true));
break;
case 'edit':
$p = $this->request->getParam('pass');
$couId = (int)$p[0];
$this->set('parents', $this->Cous->potentialParents($this->getCOID(), $couId, true));
break;
case 'index':
$this->set('parents', $this->Cous->potentialParents($this->getCOID()));
break;
default:
break;
}
}

return parent::beforeRender($event);
}
}
1 change: 1 addition & 0 deletions app/src/Controller/ErrorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public function initialize(): void
// Tell cake to automatically negotiate JSON, which will have the
// most visible side effect of causing stack traces to render as
// JSON instead of HTML
// https://discourse.cakephp.org/t/rest-api-exceptions-in-html-instead-of-json/11668
$this->addViewClasses([\Cake\View\JsonView::class]);
}

Expand Down
2 changes: 1 addition & 1 deletion app/src/Controller/StandardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,7 @@ protected function populateAutoViewVars(object $obj=null) {

// AutoViewVarsTrait
if(method_exists($table, 'getAutoViewVars') && $table->getAutoViewVars()) {
foreach ($table->calculateAutoViewVars($this->getCOID(), $obj) as $vvar => $value) {
foreach ($table->calculateAutoViewVars($this->getCOID(), $obj, $this->request->getParam('action')) as $vvar => $value) {
$this->set($vvar, $value);
}
}
Expand Down
2 changes: 1 addition & 1 deletion app/src/Lib/Events/RuleBuilderEventListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ public function ruleFreezePrimaryLink(EntityInterface $entity, array $options) {
$haveCO = $table->calculateCoForRecord($entity, true);

if($wantCO != $haveCO) {
$this->llog('error', "GMR-1 Attempt to move " . $table->getAlias() . " record " . $entity->id . " from CO " . $have . " to CO " . $want . " is not allowed");
$this->llog('error', "GMR-1 Attempt to move " . $table->getAlias() . " record " . $entity->id . " from CO " . $haveCO . " to CO " . $wantCO . " is not allowed");
return __d('error', 'coid.frozen');
}

Expand Down
17 changes: 14 additions & 3 deletions app/src/Lib/Traits/AutoViewVarsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,16 @@ public function setAutoViewVars($vars): void {
*
* @since COmanage Registry v5.1.0
* @param int|null $coId
* @param Object|null $obj Current object (eg: from edit), if set
* @param Object|null $obj Current object (eg: from edit), if set
* @param string $action Controller action
* @return \Generator
*/

public function calculateAutoViewVars(int|null $coId, Object $obj = null): \Generator {
public function calculateAutoViewVars(
int|null $coId,
Object $obj = null,
string $action = null
): \Generator {
/** var Cake\ORM\Table $table */
$table = $this;

Expand Down Expand Up @@ -198,7 +203,13 @@ public function calculateAutoViewVars(int|null $coId, Object $obj = null): \Gene
$generatedValue = $query->toArray();
break;
case 'parent':
$generatedValue = $table->getParents($coId);
// Supported models must use TreeTrait
$generatedValue = $table->potentialParents(
coId: $coId,
// We only want the hierarchical prefixes ("-") for add and edit
id: $obj ? $obj->id : null,
hierarchy: in_array($action, ['add', 'edit'])
);
break;
case 'plugin':
if(!empty($avv['pluginType'])) {
Expand Down
21 changes: 21 additions & 0 deletions app/src/Lib/Util/SchemaManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,27 @@ protected function processSchema(
$table->addIndex([$sColumn], $tablePrefix.$tName . "_im" . $i++);
}

// If this table uses TreeBehavior, emit the appropriate columnsand indexes.
if(isset($tCfg->tree) && $tCfg->tree) {
// Cake's TreeBehavior uses three columns: parent_id (which fks back to the
// same table, similar to source_foo), lft, and rght. The recommendation is
// to index parent_id (which we would do anyway since DBAL wants to put indexes
// on all fks) and lft.

$foreignTableName = $this->conn->qualifyTableName($tablePrefix.$tName);

// Insert a foreign key to this model and index it
$table->addColumn("parent_id", "integer", ['notnull' => false]);
$table->addForeignKeyConstraint($foreignTableName, ["parent_id"], ['id'], [], $tablePrefix.$tName . "_parent_id_fkey");
$table->addIndex(["parent_id"], $tablePrefix.$tName."_it1");

// Add the other columns
$table->addColumn("lft", "integer", ['notnull' => false]);
$table->addIndex(["lft"], $tablePrefix.$tName."_it2");

$table->addColumn("rght", "integer", ['notnull' => false]);
}

// Default is to insert timestamp and changelog fields, unless disabled

if(!isset($tCfg->timestamps) || $tCfg->timestamps) {
Expand Down
Loading

0 comments on commit e93fb79

Please sign in to comment.