diff --git a/src/Model/ModelData.php b/src/Model/ModelData.php index 454d00f8792..2cdf2534cfc 100644 --- a/src/Model/ModelData.php +++ b/src/Model/ModelData.php @@ -74,11 +74,38 @@ class ModelData private array $objCache = []; + private $_cache_statusFlags = null; + public function __construct() { // no-op } + /** + * Flags provides the user with additional data about the current page status. + * + * Mostly this is used for versioning, but can be used for other purposes (e.g. localisation). + * Each page can have more than one status flag. + * + * Returns an associative array of a unique key to a (localized) title for the flag. + * The unique key can be reused as a CSS class. + * + * Example (simple): + * "deletedonlive" => "Deleted" + * + * Example (with optional title attribute): + * "deletedonlive" => ['text' => "Deleted", 'title' => 'This page has been deleted'] + */ + public function getStatusFlags(bool $cached = true): array + { + if (!$this->_cache_statusFlags || !$cached) { + $flags = []; + $this->extend('updateStatusFlags', $flags); + $this->_cache_statusFlags = $flags; + } + return $this->_cache_statusFlags; + } + // ----------------------------------------------------------------------------------------------------------------- // FIELD GETTERS & SETTERS ----------------------------------------------------------------------------------------- @@ -545,6 +572,20 @@ public function Debug(): ModelData|string return ModelDataDebugger::create($this); } + /** + * Clears record-specific cached data. + * + * @param boolean $persistent When true will also clear persistent data stored in the Cache system. + * When false will just clear session-local cached data + */ + public function flushCache(bool $persistent = true): static + { + $this->objCacheClear(); + $this->_cache_statusFlags = null; + $this->extend('onFlushCache'); + return $this; + } + /** * Generate the cache name for a field */ diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index ffd8f7afc37..1f27b7bcc32 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -3509,14 +3509,11 @@ public static function get_one($callerClass = null, $filter = "", $cache = true, } /** - * Flush the cached results for all relations (has_one, has_many, many_many) - * Also clears any cached aggregate data. + * @inheritDoc * - * @param boolean $persistent When true will also clear persistent data stored in the Cache system. - * When false will just clear session-local cached data - * @return static $this + * Also flush the cached results for all relations (has_one, has_many, many_many) */ - public function flushCache($persistent = true) + public function flushCache(bool $persistent = true): static { if (static::class == DataObject::class) { DataObject::$_cache_get_one = []; @@ -3530,11 +3527,9 @@ public function flushCache($persistent = true) } } - $this->extend('onFlushCache'); - $this->components = []; $this->eagerLoadedData = []; - return $this; + return parent::flushCache($persistent); } /** @@ -3561,7 +3556,7 @@ public static function flush_and_destroy_cache() */ public static function reset() { - DBEnum::flushCache(); + DBEnum::clearStaticCache(); ClassInfo::reset_db_cache(); static::getSchema()->reset(); DataObject::$_cache_get_one = []; diff --git a/src/ORM/FieldType/DBEnum.php b/src/ORM/FieldType/DBEnum.php index e71e6d17d9c..a666fc90034 100644 --- a/src/ORM/FieldType/DBEnum.php +++ b/src/ORM/FieldType/DBEnum.php @@ -38,7 +38,7 @@ class DBEnum extends DBString /** * Clear all cached enum values. */ - public static function flushCache(): void + public static function clearStaticCache(): void { DBEnum::$enum_cache = []; } @@ -176,7 +176,7 @@ public function getEnum(): array * If table or name are not set, or if it is not a valid field on the given table, * then only known enum values are returned. * - * Values cached in this method can be cleared via `DBEnum::flushCache();` + * Values cached in this method can be cleared via `DBEnum::clearStaticCache();` */ public function getEnumObsolete(): array { diff --git a/src/ORM/Hierarchy/Hierarchy.php b/src/ORM/Hierarchy/Hierarchy.php index 929476fc778..73f3082dc41 100644 --- a/src/ORM/Hierarchy/Hierarchy.php +++ b/src/ORM/Hierarchy/Hierarchy.php @@ -17,6 +17,10 @@ use SilverStripe\Core\Convert; use Exception; use SilverStripe\Model\ModelData; +use SilverStripe\ORM\HiddenClass; +use SilverStripe\Security\Member; +use SilverStripe\Security\Permission; +use SilverStripe\Security\Security; /** * DataObjects that use the Hierarchy extension can be be organised as a hierarchy, with children and parents. The most @@ -28,6 +32,45 @@ */ class Hierarchy extends Extension { + /** + * The name of the dedicated sort field, if there is one. + */ + private static ?string $sort_field = null; + + /** + * The default child class for this model. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + private static ?string $default_child = null; + + /** + * The default parent class for this model. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + private static ?string $default_parent = null; + + /** + * Indicates what kind of children this model can have. + * This can be an array of allowed child classes, or the string "none" - + * indicating that this model can't have children. + * If a classname is prefixed by "*", such as "*App\Model\MyModel", then only that + * class is allowed - no subclasses. Otherwise, the class and all its + * subclasses are allowed. + * To control allowed children on root level (no parent), use {@link $can_be_root}. + * + * Leaving this array empty means this model can have children of any class that is a subclass + * of the first class in its class hierarchy to have the Hierarchy extension, including records of the same class. + * + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + private static array $allowed_children = []; + + /** + * Controls whether a record can be in the root of the hierarchy. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + private static bool $can_be_root = true; + /** * The lower bounds for the amount of nodes to mark. If set, the logic will expand nodes until it reaches at least * this number, and then stops. Root nodes will always show regardless of this setting. Further nodes can be @@ -99,10 +142,14 @@ class Hierarchy extends Extension * A cache used by numChildren(). * Clear through {@link flushCache()}. * version (int)0 means not on this stage. - * - * @var array */ - protected static $cache_numChildren = []; + protected static array $cache_numChildren = []; + + /** + * Used as a cache for allowedChildren() + * Drastically reduces admin page load when there are a lot of page types + */ + protected static array $cache_allowedChildren = []; public static function get_extra_config($class, $extension, $args) { @@ -186,6 +233,32 @@ protected function loadDescendantIDListInto(&$idList, $node = null) } } + /** + * Duplicates each child of this record recursively and returns the top-level duplicate record. + * If there is a sort field, new sort values are set for the duplicates to retain their sort order. + */ + public function duplicateWithChildren(): static + { + $owner = $this->getOwner(); + $clone = $owner->duplicate(); + $children = $owner->AllChildren(); + $sortField = $owner->getSortField(); + + $sort = 1; + foreach ($children as $child) { + $childClone = $child->duplicateWithChildren(); + $childClone->ParentID = $clone->ID; + if ($sortField) { + //retain sort order by manually setting sort values + $childClone->$sortField = $sort; + $sort++; + } + $childClone->write(); + } + + return $clone; + } + /** * Get the children for this DataObject filtered by canView() * @@ -392,6 +465,103 @@ public static function prepopulate_numchildren_cache($baseClass, $idList = null) } } + /** + * Returns the class name of the default class for children of this page. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + public function defaultChild(): ?string + { + $owner = $this->getOwner(); + $default = $owner::config()->get('default_child'); + $allowed = $this->allowedChildren(); + if ($allowed) { + if (!$default || !in_array($default, $allowed)) { + $default = reset($allowed); + } + return $default; + } + return null; + } + + /** + * Returns the class name of the default class for the parent of this page. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + public function defaultParent(): ?string + { + return $this->getOwner()::config()->get('default_parent'); + } + + /** + * Returns an array of the class names of classes that are allowed to be children of this class. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + * + * @return string[] + */ + public function allowedChildren(): array + { + $owner = $this->getOwner(); + if (isset(static::$cache_allowedChildren[$owner->ClassName])) { + $allowedChildren = static::$cache_allowedChildren[$owner->ClassName]; + } else { + // Get config from the highest class in the hierarchy to define it. + // This avoids merged config, meaning each class that defines the allowed children defines it from scratch. + $baseClass = $this->getHierarchyBaseClass(); + $class = get_class($owner); + $candidates = null; + while ($class) { + if (Config::inst()->exists($class, 'allowed_children', Config::UNINHERITED)) { + $candidates = Config::inst()->get($class, 'allowed_children', Config::UNINHERITED); + break; + } + // Stop checking if we've hit the first class in the class hierarchy which has this extension + if ($class === $baseClass) { + break; + } + $class = get_parent_class($class); + } + if ($candidates === 'none') { + return []; + } + + // If we're using a superclass, check if we've already processed its allowed children list + if ($class !== $owner->ClassName && isset(static::$cache_allowedChildren[$class])) { + $allowedChildren = static::$cache_allowedChildren[$class]; + static::$cache_allowedChildren[$owner->ClassName] = $allowedChildren; + return $allowedChildren; + } + + // Set the highest available class (and implicitly its subclasses) as being allowed. + if (!$candidates) { + $candidates = [$baseClass]; + } + + // Parse candidate list + $allowedChildren = []; + foreach ((array)$candidates as $candidate) { + // If a classname is prefixed by "*", such as "*App\Model\MyModel", then only that class is allowed - no subclasses. + // Otherwise, the class and all its subclasses are allowed. + if (substr($candidate, 0, 1) == '*') { + $allowedChildren[] = substr($candidate, 1); + } elseif ($subclasses = ClassInfo::subclassesFor($candidate)) { + foreach ($subclasses as $subclass) { + if (!is_a($subclass, HiddenClass::class, true)) { + $allowedChildren[] = $subclass; + } + } + } + } + static::$cache_allowedChildren[$owner->ClassName] = $allowedChildren; + // Make sure we don't have to re-process if this is the allowed children set of a superclass + if ($class !== $owner->ClassName) { + static::$cache_allowedChildren[$class] = $allowedChildren; + } + } + $owner->extend('updateAllowedChildren', $allowedChildren); + + return $allowedChildren; + } + /** * Checks if we're on a controller where we should filter. ie. Are we loading the SiteTree? * @@ -407,6 +577,30 @@ public function showingCMSTree() && in_array($controller->getAction(), ["treeview", "listview", "getsubtree"]); } + /** + * Return the CSS classes to apply to this node in the CMS tree. + */ + public function CMSTreeClasses(): string + { + $owner = $this->getOwner(); + $classes = sprintf('class-%s', Convert::raw2htmlid(get_class($owner))); + + if (!$owner->canAddChildren()) { + $classes .= " nochildren"; + } + + if (!$owner->canEdit() && !$owner->canAddChildren()) { + if (!$owner->canView()) { + $classes .= " disabled"; + } else { + $classes .= " edit-disabled"; + } + } + + $owner->invokeWithExtensions('updateCMSTreeClasses', $classes); + return $classes; + } + /** * Find the first class in the inheritance chain that has Hierarchy extension applied * @@ -567,6 +761,65 @@ public function getBreadcrumbs($separator = ' » ') return implode($separator ?? '', $crumbs); } + /** + * Get the title that will be used in TreeDropdownField and other tree structures. + */ + public function getTreeTitle(): string + { + $owner = $this->getOwner(); + $title = $owner->MenuTitle ?: $owner->Title; + $owner->extend('updateTreeTitle', $title); + return $title ?? ''; // @TODO see if we need to escape this (it was escaped in Group and is in File too) + } + + /** + * Get the name of the dedicated sort field, if there is one. + */ + public function getSortField(): ?string + { + return $this->getOwner()::config()->get('sort_field'); + } + + /** + * Returns true if the current user can add children to this page. + * + * Denies permission if any of the following conditions is true: + * - canAddChildren() on a extension returns false + * - canEdit() is not granted + * - allowed_children is not set to "none" + */ + public function canAddChildren(?Member $member = null): bool + { + $owner = $this->getOwner(); + // Disable adding children to archived records + if ($owner->hasExtension(Versioned::class) && $owner->isArchived()) { + return false; + } + + if (!$member) { + $member = Security::getCurrentUser(); + } + + // Standard mechanism for accepting permission changes from extensions + $extended = $owner->extendedCan('canAddChildren', $member); + if ($extended !== null) { + return $extended; + } + + // Default permissions + if ($member && Permission::checkMember($member, 'ADMIN')) { + return true; + } + + return $owner->canEdit($member) && $owner::config()->get('allowed_children') !== 'none'; + } + + protected function extendCanAddChildren() + { + // Prevent canAddChildren from extending itself + return null; + } + /** * Flush all Hierarchy caches: * - Children (instance) @@ -577,4 +830,23 @@ protected function onFlushCache() $this->owner->_cache_children = null; Hierarchy::$cache_numChildren = []; } + + /** + * Block creating children not allowed for the parent type + */ + protected function canCreate(Member $member, array $context): ?bool + { + // Parent is added to context through CMSMain + // Note that not having a parent doesn't necessarily mean this record is being + // created at the root, so we can't check against can_be_root here. + $parent = isset($context['Parent']) ? $context['Parent'] : null; + $parentInHierarchy = ($parent && is_a($parent, $this->getHierarchyBaseClass())); + if ($parentInHierarchy && !in_array(get_class($this->getOwner()), $parent->allowedChildren())) { + return false; + } + if ($parent?->exists() && $parentInHierarchy && !$parent->canAddChildren($member)) { + return false; + } + return null; + } } diff --git a/src/Security/Group.php b/src/Security/Group.php index 6c10d32c073..3cc484dead4 100755 --- a/src/Security/Group.php +++ b/src/Security/Group.php @@ -494,16 +494,6 @@ public function stageChildren() ->sort('"Sort"'); } - /** - * @return string - */ - public function getTreeTitle() - { - $title = htmlspecialchars($this->Title ?? '', ENT_QUOTES); - $this->extend('updateTreeTitle', $title); - return $title; - } - /** * Overloaded to ensure the code is always descent. * diff --git a/tests/php/ORM/DBEnumTest.php b/tests/php/ORM/DBEnumTest.php index a44fbc57f51..1ae128b527f 100644 --- a/tests/php/ORM/DBEnumTest.php +++ b/tests/php/ORM/DBEnumTest.php @@ -95,7 +95,7 @@ public function testObsoleteValues() // Test values with a record $obj->Colour = 'Red'; $obj->write(); - DBEnum::flushCache(); + DBEnum::clearStaticCache(); $this->assertEquals( ['Red', 'Blue', 'Green'], @@ -104,7 +104,7 @@ public function testObsoleteValues() // If the value is removed from the enum, obsolete content is still retained $colourField->setEnum(['Blue', 'Green', 'Purple']); - DBEnum::flushCache(); + DBEnum::clearStaticCache(); $this->assertEquals( ['Blue', 'Green', 'Purple', 'Red'], // Red on the end now, because it's obsolete @@ -135,7 +135,7 @@ public function testObsoleteValues() // If obsolete records are deleted, the extra values go away $obj->delete(); $obj2->delete(); - DBEnum::flushCache(); + DBEnum::clearStaticCache(); $this->assertEquals( ['Blue', 'Green'], $colourField->getEnumObsolete() diff --git a/tests/php/ORM/DataObjectSchemaGenerationTest.php b/tests/php/ORM/DataObjectSchemaGenerationTest.php index e5c04ce185e..b89e9a8c4a4 100644 --- a/tests/php/ORM/DataObjectSchemaGenerationTest.php +++ b/tests/php/ORM/DataObjectSchemaGenerationTest.php @@ -197,7 +197,7 @@ public function testClassNameSpecGeneration() $schema = DataObject::getSchema(); // Test with blank entries - DBEnum::flushCache(); + DBEnum::clearStaticCache(); $do1 = new TestObject(); $fields = $schema->databaseFields(TestObject::class, false); // May be overridden from DBClassName to DBClassNameVarchar by config @@ -215,7 +215,7 @@ public function testClassNameSpecGeneration() // Test with instance of subclass $item1 = new TestIndexObject(); $item1->write(); - DBEnum::flushCache(); + DBEnum::clearStaticCache(); $this->assertEquals( [ TestObject::class, @@ -228,7 +228,7 @@ public function testClassNameSpecGeneration() // Test with instance of main class $item2 = new TestObject(); $item2->write(); - DBEnum::flushCache(); + DBEnum::clearStaticCache(); $this->assertEquals( [ TestObject::class, @@ -243,7 +243,7 @@ public function testClassNameSpecGeneration() $item1->write(); $item2 = new TestObject(); $item2->write(); - DBEnum::flushCache(); + DBEnum::clearStaticCache(); $this->assertEquals( [ TestObject::class, diff --git a/tests/php/Security/SecurityTest.php b/tests/php/Security/SecurityTest.php index 9f73a925df3..2f4102c4186 100644 --- a/tests/php/Security/SecurityTest.php +++ b/tests/php/Security/SecurityTest.php @@ -681,7 +681,7 @@ public function testSuccessfulLoginAttempts() public function testDatabaseIsReadyWithInsufficientMemberColumns() { Security::clear_database_is_ready(); - DBEnum::flushCache(); + DBEnum::clearStaticCache(); // Assumption: The database has been built correctly by the test runner, // and has all columns present in the ORM