From 7861a8f7407491af024a272263dae146a9cd21e2 Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Thu, 30 Apr 2009 05:38:48 +0000 Subject: [PATCH] ENHANCEMENT: Added Versioned::get_including_deleted(), for querying deleted pages. ENHANCEMENT: Added childrenMethod argument to Hierarchy tree-generation functions. ENHANCEMENT: Added 'show deleted pages' function to CMS, with a restore page option. git-svn-id: svn://svn.silverstripe.com/silverstripe/open/modules/sapphire/branches/2.3@75736 467b73ca-7a2a-4603-9d3b-597d59a354a9 --- core/model/Hierarchy.php | 69 ++++++++++++++++++++++++----------- core/model/SiteTree.php | 66 ++++++++++++++++++++++++++------- core/model/Versioned.php | 38 +++++++++++++++++-- tests/SiteTreeActionsTest.php | 4 ++ tests/SiteTreeTest.php | 39 ++++++++++++++++++++ 5 files changed, 179 insertions(+), 37 deletions(-) diff --git a/core/model/Hierarchy.php b/core/model/Hierarchy.php index 88a51de6c..5ca46d200 100644 --- a/core/model/Hierarchy.php +++ b/core/model/Hierarchy.php @@ -25,15 +25,21 @@ class Hierarchy extends DataObjectDecorator { * @param string $titleEval PHP code to evaluate to start each child - this should include '
  • ' * @param string $extraArg Extra arguments that will be passed on to children, for if they overload this function. * @param boolean $limitToMarked Display only marked children. + * @param string $childrenMethod The name of the method used to get children from each object * @param boolean $rootCall Set to true for this first call, and then to false for calls inside the recursion. You should not change this. * @return string */ - public function getChildrenAsUL($attributes = "", $titleEval = '"
  • " . $child->Title', $extraArg = null, $limitToMarked = false, $rootCall = true) { + public function getChildrenAsUL($attributes = "", $titleEval = '"
  • " . $child->Title', $extraArg = null, $limitToMarked = false, $childrenMethod = "AllChildrenIncludingDeleted", $rootCall = true) { if($limitToMarked && $rootCall) { $this->markingFinished(); } - $children = $this->owner->AllChildrenIncludingDeleted($extraArg); + if($this->owner->hasMethod($childrenMethod)) { + $children = $this->owner->$childrenMethod($extraArg); + } else { + user_error(sprintf("Can't find the method '%s' on class '%s' for getting tree children", + $childrenMethod, get_class($this->owner)), E_USER_ERROR); + } if($children) { if($attributes) { @@ -47,7 +53,7 @@ class Hierarchy extends DataObjectDecorator { if(!$limitToMarked || $child->isMarked()) { $foundAChild = true; $output .= eval("return $titleEval;") . "\n" . - $child->getChildrenAsUL("", $titleEval, $extraArg, $limitToMarked, false) . "
  • \n"; + $child->getChildrenAsUL("", $titleEval, $extraArg, $limitToMarked, $childrenMethod, false) . "\n"; } } @@ -70,13 +76,13 @@ class Hierarchy extends DataObjectDecorator { * @param int $minCount The minimum amount of nodes to mark. * @return int The actual number of nodes marked. */ - public function markPartialTree($minCount = 30, $context = null) { + public function markPartialTree($minCount = 30, $context = null, $childrenMethod = "AllChildrenIncludingDeleted") { $this->markedNodes = array($this->owner->ID => $this->owner); $this->owner->markUnexpanded(); // foreach can't handle an ever-growing $nodes list while(list($id, $node) = each($this->markedNodes)) { - $this->markChildren($node, $context); + $this->markChildren($node, $context, $childrenMethod); if($minCount && sizeof($this->markedNodes) >= $minCount) { break; @@ -140,8 +146,14 @@ class Hierarchy extends DataObjectDecorator { * Mark all children of the given node that match the marking filter. * @param DataObject $node Parent node. */ - public function markChildren($node, $context = null) { - $children = $node->AllChildrenIncludingDeleted($context); + public function markChildren($node, $context = null, $childrenMethod = "AllChildrenIncludingDeleted") { + if($node->hasMethod($childrenMethod)) { + $children = $node->$childrenMethod($context); + } else { + user_error(sprintf("Can't find the method '%s' on class '%s' for getting tree children", + $childrenMethod, get_class($this->owner)), E_USER_ERROR); + } + $node->markExpanded(); if($children) { foreach($children as $child) { @@ -172,10 +184,10 @@ class Hierarchy extends DataObjectDecorator { */ public function markingClasses() { $classes = ''; - if(!$this->expanded) { + if(!$this->isExpanded()) { $classes .= " unexpanded"; } - if(!$this->treeOpened) { + if(!$this->isTreeOpened()) { $classes .= " closed"; } return $classes; @@ -230,42 +242,42 @@ class Hierarchy extends DataObjectDecorator { * True if this DataObject is marked. * @var boolean */ - protected $marked = false; + protected static $marked = array(); /** * True if this DataObject is expanded. * @var boolean */ - protected $expanded = false; + protected static $expanded = array(); /** * True if this DataObject is opened. * @var boolean */ - protected $treeOpened = false; + protected static $treeOpened = array(); /** * Mark this DataObject as expanded. */ public function markExpanded() { - $this->marked = true; - $this->expanded = true; + self::$marked[ClassInfo::baseDataClass($this->owner->class)][$this->owner->ID] = true; + self::$expanded[ClassInfo::baseDataClass($this->owner->class)][$this->owner->ID] = true; } /** * Mark this DataObject as unexpanded. */ public function markUnexpanded() { - $this->marked = true; - $this->expanded = false; + self::$marked[ClassInfo::baseDataClass($this->owner->class)][$this->owner->ID] = true; + self::$expanded[ClassInfo::baseDataClass($this->owner->class)][$this->owner->ID] = false; } /** * Mark this DataObject's tree as opened. */ public function markOpened() { - $this->marked = true; - $this->treeOpened = true; + self::$marked[ClassInfo::baseDataClass($this->owner->class)][$this->owner->ID] = true; + self::$treeOpened[ClassInfo::baseDataClass($this->owner->class)][$this->owner->ID] = true; } /** @@ -273,7 +285,9 @@ class Hierarchy extends DataObjectDecorator { * @return boolean */ public function isMarked() { - return $this->marked; + $baseClass = ClassInfo::baseDataClass($this->owner->class); + $id = $this->owner->ID; + return isset(self::$marked[$baseClass][$id]) ? self::$marked[$baseClass][$id] : false; } /** @@ -281,14 +295,18 @@ class Hierarchy extends DataObjectDecorator { * @return boolean */ public function isExpanded() { - return $this->expanded; + $baseClass = ClassInfo::baseDataClass($this->owner->class); + $id = $this->owner->ID; + return isset(self::$expanded[$baseClass][$id]) ? self::$expanded[$baseClass][$id] : false; } /** * Check if this DataObject's tree is opened. */ public function isTreeOpened() { - return $this->treeOpened; + $baseClass = ClassInfo::baseDataClass($this->owner->class); + $id = $this->owner->ID; + return isset(self::$treeOpened[$baseClass][$id]) ? self::$treeOpened[$baseClass][$id] : false; } /** @@ -481,6 +499,15 @@ class Hierarchy extends DataObjectDecorator { return $this->allChildrenIncludingDeleted; } + + /** + * Return all the children that this page had, including pages that were deleted + * from both stage & live. + */ + public function AllHistoricalChildren() { + return Versioned::get_including_deleted(ClassInfo::baseDataClass($this->owner->class), + "ParentID = " . (int)$this->owner->ID); + } /** * Return the number of children diff --git a/core/model/SiteTree.php b/core/model/SiteTree.php index 161eb9a1f..945e8e474 100644 --- a/core/model/SiteTree.php +++ b/core/model/SiteTree.php @@ -1362,11 +1362,15 @@ class SiteTree extends DataObject implements PermissionProvider,i18nEntityProvid if($this->IsDeletedFromStage) { if($this->can('CMSEdit')) { - // "restore" - $actions->push(new FormAction('revert',_t('CMSMain.RESTORE','Restore'))); - - // "delete from live" - $actions->push(new FormAction('deletefromlive',_t('CMSMain.DELETEFP','Delete from the published site'))); + if($this->ExistsOnLive) { + // "restore" + $actions->push(new FormAction('revert',_t('CMSMain.RESTORE','Restore'))); + // "delete from live" + $actions->push(new FormAction('deletefromlive',_t('CMSMain.DELETEFP','Delete from the published site'))); + } else { + // "restore" + $actions->push(new FormAction('restore',_t('CMSMain.RESTORE','Restore'))); + } } } else { if($this->canEdit()) { @@ -1461,6 +1465,29 @@ class SiteTree extends DataObject implements PermissionProvider,i18nEntityProvid $this->extend('onAfterRevertToLive'); } + + /** + * Restore the content in the active copy of this SiteTree page to the stage site. + * @return The SiteTree object. + */ + function doRestoreToStage() { + // if no record can be found on draft stage (meaning it has been "deleted from draft" before), + // create an empty record + if(!DB::query("SELECT ID FROM SiteTree WHERE ID = $this->ID")->value()) { + DB::query("INSERT INTO SiteTree SET ID = $this->ID"); + } + + $oldStage = Versioned::current_stage(); + Versioned::reading_stage('Stage'); + $this->forceChange(); + $this->writeWithoutVersion(); + + $result = DataObject::get_by_id($this->class, $this->ID); + + Versioned::reading_stage($oldStage); + + return $result; + } /** * Check if this page is new - that is, if it has yet to have been written @@ -1569,11 +1596,13 @@ class SiteTree extends DataObject implements PermissionProvider,i18nEntityProvid // sort alphabetically, and put current on top asort($result); - $currentPageTypeName = $result[$currentClass]; - unset($result[$currentClass]); - $result = array_reverse($result); - $result[$currentClass] = $currentPageTypeName; - $result = array_reverse($result); + if($currentClass) { + $currentPageTypeName = $result[$currentClass]; + unset($result[$currentClass]); + $result = array_reverse($result); + $result[$currentClass] = $currentPageTypeName; + $result = array_reverse($result); + } return $result; } @@ -1675,7 +1704,11 @@ class SiteTree extends DataObject implements PermissionProvider,i18nEntityProvid */ function TreeTitle() { if($this->IsDeletedFromStage) { - $tag ="del title=\"" . _t('SiteTree.REMOVEDFROMDRAFT', 'Removed from draft site') . "\""; + if($this->ExistsOnLive) { + $tag ="del title=\"" . _t('SiteTree.REMOVEDFROMDRAFT', 'Removed from draft site') . "\""; + } else { + $tag ="del class=\"deletedOnLive\" title=\"" . _t('SiteTree.DELETEDPAGE', 'Deleted page') . "\""; + } } elseif($this->IsAddedToStage) { $tag = "ins title=\"" . _t('SiteTree.ADDEDTODRAFT', 'Added to draft site') . "\""; } elseif($this->IsModifiedOnStage) { @@ -1754,9 +1787,16 @@ class SiteTree extends DataObject implements PermissionProvider,i18nEntityProvid if(!is_numeric($this->ID)) return false; $stageVersion = Versioned::get_versionnumber_by_stage('SiteTree', 'Stage', $this->ID); - $liveVersion = Versioned::get_versionnumber_by_stage('SiteTree', 'Live', $this->ID); - return (!$stageVersion && $liveVersion); + // Return true for both completely deleted pages and for pages just deleted from stage. + return !$stageVersion; + } + + /** + * Return true if this page exists on the live site + */ + function getExistsOnLive() { + return (bool)Versioned::get_versionnumber_by_stage('SiteTree', 'Live', $this->ID); } /** diff --git a/core/model/Versioned.php b/core/model/Versioned.php index db14da5a7..eb46628e6 100755 --- a/core/model/Versioned.php +++ b/core/model/Versioned.php @@ -106,9 +106,9 @@ class Versioned extends DataObjectDecorator { * Create a temporary table mapping each database record to its version on the given date. * This is used by the versioning system to return database content on that date. * @param string $baseTable The base table. - * @param string $date The date. + * @param string $date The date. If omitted, then the latest version of each page will be returned. */ - protected function requireArchiveTempTable($baseTable, $date) { + protected static function requireArchiveTempTable($baseTable, $date = null) { if(!isset(self::$createdArchiveTempTable[$baseTable])) { self::$createdArchiveTempTable[$baseTable] = true; @@ -116,12 +116,17 @@ class Versioned extends DataObjectDecorator { RecordID INT NOT NULL PRIMARY KEY, Version INT NOT NULL )"); + + if($date) $dateClause = "WHERE LastEdited <= '$date'"; + else $dateClause = ""; + DB::query("INSERT INTO _Archive$baseTable SELECT RecordID, max(Version) FROM {$baseTable}_versions - WHERE LastEdited <= '$date' + $dateClause GROUP BY RecordID"); } } + /** * An array of DataObject extensions that may require versioning for extra tables * The array value is a set of suffixes to form these table names, assuming a preceding '_'. @@ -719,6 +724,33 @@ class Versioned extends DataObjectDecorator { return new $className($record); } + + /** + * Return the equivalent of a DataObject::get() call, querying the latest + * version of each page stored in the (class)_versions tables. + * + * In particular, this will query deleted records as well as active ones. + */ + static function get_including_deleted($class, $filter = "", $sort = "") { + $oldStage = Versioned::$reading_stage; + Versioned::$reading_stage = null; + + $SNG = singleton($class); + + // Build query + $query = $SNG->buildVersionSQL($filter, $sort); + $baseTable = ClassInfo::baseDataClass($class); + self::requireArchiveTempTable($baseTable); + $query->from["_Archive$baseTable"] = "INNER JOIN `_Archive$baseTable` + ON `_Archive$baseTable`.RecordID = `{$baseTable}_versions`.RecordID + AND `_Archive$baseTable`.Version = `{$baseTable}_versions`.Version"; + + // Process into a DataObjectSet + $result = $SNG->buildDataObjectSet($query->execute()); + + Versioned::$reading_stage = $oldStage; + return $result; + } /** * @return DataObject diff --git a/tests/SiteTreeActionsTest.php b/tests/SiteTreeActionsTest.php index 2199e7bcd..38b5bbb51 100644 --- a/tests/SiteTreeActionsTest.php +++ b/tests/SiteTreeActionsTest.php @@ -66,9 +66,13 @@ if(class_exists('SiteTreeCMSWorkflow')) { function testActionsDeletedFromStageRecord() { $page = new Page(); $page->write(); + $pageID = $page->ID; $page->publish('Stage', 'Live'); $page->deleteFromStage('Stage'); + // Get the live version of the page + $page = Versioned::get_one_by_stage("SiteTree", "Live", "`SiteTree`.ID = $pageID"); + $author = $this->objFromFixture('Member', 'cmseditor'); $this->session()->inst_set('loggedInAs', $author->ID); diff --git a/tests/SiteTreeTest.php b/tests/SiteTreeTest.php index 699dba78f..2f1efe338 100644 --- a/tests/SiteTreeTest.php +++ b/tests/SiteTreeTest.php @@ -167,6 +167,45 @@ class SiteTreeTest extends SapphireTest { $this->assertFalse($modifiedOnDraftPage->IsAddedToStage); $this->assertTrue($modifiedOnDraftPage->IsModifiedOnStage); } + + /** + * Test that a page can be completely deleted and restored to the stage site + */ + function testRestoreToStage() { + $page = $this->objFromFixture('Page', 'about'); + $pageID = $page->ID; + $page->delete(); + $this->assertTrue(!DataObject::get_by_id("Page", $pageID)); + + $deletedPage = Versioned::get_latest_version('SiteTree', $pageID); + $resultPage = $deletedPage->doRestoreToStage(); + + $requeriedPage = DataObject::get_by_id("Page", $pageID); + + $this->assertEquals($pageID, $resultPage->ID); + $this->assertEquals($pageID, $requeriedPage->ID); + $this->assertEquals('About Us', $requeriedPage->Title); + $this->assertEquals('Page', $requeriedPage->class); + + + $page2 = $this->objFromFixture('Page', 'staff'); + $page2ID = $page2->ID; + $page2->doUnpublish(); + $page2->delete(); + + // Check that if we restore while on the live site that the content still gets pushed to + // stage + Versioned::reading_stage('Live'); + $deletedPage = Versioned::get_latest_version('SiteTree', $page2ID); + $deletedPage->doRestoreToStage(); + $this->assertTrue(!Versioned::get_one_by_stage("Page", "Live", "`SiteTree`.ID = " . $page2ID)); + + Versioned::reading_stage('Stage'); + $requeriedPage = DataObject::get_by_id("Page", $page2ID); + $this->assertEquals('Staff', $requeriedPage->Title); + $this->assertEquals('Page', $requeriedPage->class); + + } } // We make these extend page since that's what all page types are expected to do