diff --git a/src/Model/ModelData.php b/src/Model/ModelData.php index 454d00f8792..6ab8e1d0d8a 100644 --- a/src/Model/ModelData.php +++ b/src/Model/ModelData.php @@ -74,6 +74,8 @@ class ModelData private array $objCache = []; + private $_cache_statusFlags = null; + public function __construct() { // no-op @@ -487,6 +489,31 @@ public function hasValue(string $field, array $arguments = [], bool $cache = tru // UTILITY METHODS ------------------------------------------------------------------------------------------------- + /** + * 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; + } + /** * Find appropriate templates for SSViewer to use to render this object */ @@ -545,6 +572,17 @@ public function Debug(): ModelData|string return ModelDataDebugger::create($this); } + /** + * Clears record-specific cached data. + */ + public function flushCache(): 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 bea3921807b..ab7659cd8c9 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -116,17 +116,22 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro { /** * Human-readable singular name. - * @var string - * @config */ - private static $singular_name = null; + private static ?string $singular_name = null; /** * Human-readable plural name - * @var string - * @config */ - private static $plural_name = null; + private static ?string $plural_name = null; + + /** + * Description of the class. + * Unlike most configuration, this is usually used uninherited, meaning it should be defined + * on each subclass. + * + * Used in some areas of the CMS, e.g. when selecting what type of record to create. + */ + private static ?string $class_description = null; /** * @config @@ -141,7 +146,6 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro * @var string */ private static $default_classname = null; - /** * Whether this DataObject class must only use the primary database and not a read-only replica * Note that this will be only be enforced when using DataQuery::execute() or @@ -946,6 +950,27 @@ public function i18n_plural_name() return _t(static::class . '.PLURALNAME', $this->plural_name()); } + /** + * Get description for this class + */ + public function classDescription(): ?string + { + return static::config()->get('class_description', Config::UNINHERITED); + } + + /** + * Get localised description for this class + */ + public function i18n_classDescription(): ?string + { + $placeholder = 'PLACEHOLDER_DESCRIPTION'; + $description = _t(static::class.'.CLASS_DESCRIPTION', $this->classDescription() ?? $placeholder); + if ($description === $placeholder) { + return null; + } + return $description; + } + /** * Standard implementation of a title/label for a specific * record. Tries to find properties 'Title' or 'Name', @@ -3515,14 +3540,14 @@ 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 + * + * Also flush the cached results for all relations (has_one, has_many, many_many) * - * @param boolean $persistent When true will also clear persistent data stored in the Cache system. + * @param bool $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 */ - public function flushCache($persistent = true) + public function flushCache(bool $persistent = true): static { if (static::class == DataObject::class) { DataObject::$_cache_get_one = []; @@ -3536,11 +3561,9 @@ public function flushCache($persistent = true) } } - $this->extend('onFlushCache'); - $this->components = []; $this->eagerLoadedData = []; - return $this; + return parent::flushCache(); } /** @@ -3567,7 +3590,7 @@ public static function flush_and_destroy_cache() */ public static function reset() { - DBEnum::flushCache(); + DBEnum::reset(); 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 56409d8fb33..9fb6fc1f7ec 100644 --- a/src/ORM/FieldType/DBEnum.php +++ b/src/ORM/FieldType/DBEnum.php @@ -8,6 +8,7 @@ use SilverStripe\Forms\FormField; use SilverStripe\Forms\SelectField; use SilverStripe\Core\ArrayLib; +use SilverStripe\Core\Resettable; use SilverStripe\ORM\Connect\MySQLDatabase; use SilverStripe\ORM\DB; use SilverStripe\Model\ModelData; @@ -17,7 +18,7 @@ * * See {@link DropdownField} for a {@link FormField} to select enum values. */ -class DBEnum extends DBString +class DBEnum extends DBString implements Resettable { private static array $field_validators = [ OptionFieldValidator::class => ['getEnum'], @@ -44,7 +45,7 @@ class DBEnum extends DBString /** * Clear all cached enum values. */ - public static function flushCache(): void + public static function reset(): void { DBEnum::$enum_cache = []; } @@ -182,7 +183,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::reset();` */ public function getEnumObsolete(): array { diff --git a/src/ORM/Hierarchy/Hierarchy.php b/src/ORM/Hierarchy/Hierarchy.php index 929476fc778..ad3e86477b2 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 subclass types + */ + protected static array $cache_allowedChildren = []; public static function get_extra_config($class, $extension, $args) { @@ -151,6 +198,41 @@ protected function updateValidate(ValidationResult $validationResult) } $node = $node->Parent(); } + + // "Can be root" validation + if (!$owner::config()->get('can_be_root') && !$owner->ParentID) { + $validationResult->addError( + _t( + __CLASS__ . '.TypeOnRootNotAllowed', + 'Model type "{type}" is not allowed on the root level', + ['type' => $owner->i18n_singular_name()] + ), + ValidationResult::TYPE_ERROR, + 'CAN_BE_ROOT' + ); + } + + // Allowed children validation + $parent = $owner->getParent(); + if ($parent && $parent->exists()) { + // No need to check for subclasses or instanceof, as allowedChildren() already + // deconstructs any inheritance trees already. + $allowed = $parent->allowedChildren(); + $subject = $owner->hasMethod('getRecordForAllowedChildrenValidation') + ? $owner->getRecordForAllowedChildrenValidation() + : $owner; + if (!in_array($subject->ClassName, $allowed ?? [])) { + $validationResult->addError( + _t( + __CLASS__ . '.ChildTypeNotAllowed', + 'Model type "{type}" not allowed as child of this parent record', + ['type' => $subject->i18n_singular_name()] + ), + ValidationResult::TYPE_ERROR, + 'ALLOWED_CHILDREN' + ); + } + } } @@ -186,6 +268,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 +500,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 +612,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 +796,54 @@ public function getBreadcrumbs($separator = ' » ') return implode($separator ?? '', $crumbs); } + /** + * 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 +854,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/tests/php/ORM/DBEnumTest.php b/tests/php/ORM/DBEnumTest.php index 9190617e400..36de009239c 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::reset(); $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::reset(); $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::reset(); $this->assertEquals( ['Blue', 'Green'], $colourField->getEnumObsolete() diff --git a/tests/php/ORM/DataObjectSchemaGenerationTest.php b/tests/php/ORM/DataObjectSchemaGenerationTest.php index e5c04ce185e..05909e6259b 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::reset(); $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::reset(); $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::reset(); $this->assertEquals( [ TestObject::class, @@ -243,7 +243,7 @@ public function testClassNameSpecGeneration() $item1->write(); $item2 = new TestObject(); $item2->write(); - DBEnum::flushCache(); + DBEnum::reset(); $this->assertEquals( [ TestObject::class, diff --git a/tests/php/Security/SecurityTest.php b/tests/php/Security/SecurityTest.php index 9f73a925df3..cec3ccf9555 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::reset(); // Assumption: The database has been built correctly by the test runner, // and has all columns present in the ORM