From b57b8e2bc2527841c40267a57c94c6d9e433ed71 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Tue, 22 Oct 2024 15:16:13 +1300 Subject: [PATCH] API Move logic from silverstripe/cms into central place This logic is used by CMSMain but needs to be callable on any hierarchical model. In some cases this logic is generic enough it could be on any model or any DataObject --- src/Model/ModelData.php | 38 +++ src/ORM/DataObject.php | 55 +++- src/ORM/FieldType/DBEnum.php | 7 +- src/ORM/Hierarchy/Hierarchy.php | 302 +++++++++++++++++- tests/php/ORM/DBEnumTest.php | 6 +- .../ORM/DataObjectSchemaGenerationTest.php | 8 +- tests/php/Security/SecurityTest.php | 2 +- 7 files changed, 388 insertions(+), 30 deletions(-) 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