silverstripe-cms/code/model/VirtualPage.php
Stig Lindqvist 45046f08e8 Bug: CMS tree filters doesn't count the correct number of children for deleted pages
This is a bug that combines Hierarchy, Versioned and LeftAndMain admins and CMSSiteTreeFilters.

This bug can be reproduced by having a large site tree with enough deleted pages in it so it doesn't
pre load all the children pages when initially opening an admin. Filter by either 'All pages including deleted'
or 'Deleted pages'. For CMS users it will look like deleted pages are gone.

The solution involves a couple of smaller fixes in both CMS and framework modules.

1) Ensure that 'numHistoricalChildren' are used instead of 'numChildren' when dealing with deleted pages
2) LeftAndMain::currentPage() deletes all the 'marking' cache previously built up by Hierarchy::markPartialTree()
3) Use Versioned::get_included_deleted() instead of raw DB queries against the DataObject tables when calculating parents in CMSSiteTreeFilter
2014-07-25 16:18:54 +12:00

553 lines
17 KiB
PHP

<?php
/**
* Virtual Page creates an instance of a page, with the same fields that the original page had, but readonly.
* This allows you can have a page in mulitple places in the site structure, with different children without duplicating the content
* Note: This Only duplicates $db fields and not the $has_one etc..
* @package cms
*/
class VirtualPage extends Page {
private static $description = 'Displays the content of another page';
public static $virtualFields;
/**
* @var Array Define fields that are not virtual - the virtual page must define these fields themselves.
* Note that anything in {@link self::config()->initially_copied_fields} is implicitly included in this list.
*/
private static $non_virtual_fields = array(
"SecurityTypeID",
"OwnerID",
"URLSegment",
"Sort",
"Status",
'ShowInMenus',
// 'Locale'
'ShowInSearch',
'Version',
"Embargo",
"Expiry",
);
/**
* @var Array Define fields that are initially copied to virtual pages but left modifiable after that.
*/
private static $initially_copied_fields = array(
'ShowInMenus',
'ShowInSearch',
'URLSegment',
);
private static $has_one = array(
"CopyContentFrom" => "SiteTree",
);
private static $db = array(
"VersionID" => "Int",
);
/**
* Generates the array of fields required for the page type.
*/
public function getVirtualFields() {
$nonVirtualFields = array_merge(self::config()->non_virtual_fields, self::config()->initially_copied_fields);
$record = $this->CopyContentFrom();
$allFields = $record->db();
if($hasOne = $record->has_one()) foreach($hasOne as $link) $allFields[$link . 'ID'] = "Int";
$virtualFields = array();
foreach($allFields as $field => $type) {
if(!in_array($field, $nonVirtualFields)) $virtualFields[] = $field;
}
return $virtualFields;
}
/**
* @return SiteTree Returns the linked page, or failing that, a new object.
*/
public function CopyContentFrom() {
$copyContentFromID = $this->CopyContentFromID;
if(!$copyContentFromID) return new SiteTree();
if(!isset($this->components['CopyContentFrom'])) {
$this->components['CopyContentFrom'] = DataObject::get_by_id("SiteTree",
$copyContentFromID);
// Don't let VirtualPages point to other VirtualPages
if($this->components['CopyContentFrom'] instanceof VirtualPage) {
$this->components['CopyContentFrom'] = null;
}
// has_one component semantics incidate than an empty object should be returned
if(!$this->components['CopyContentFrom']) {
$this->components['CopyContentFrom'] = new SiteTree();
}
}
return $this->components['CopyContentFrom'] ? $this->components['CopyContentFrom'] : new SiteTree();
}
public function setCopyContentFromID($val) {
if($val && DataObject::get_by_id('SiteTree', $val) instanceof VirtualPage) $val = 0;
return $this->setField("CopyContentFromID", $val);
}
public function ContentSource() {
return $this->CopyContentFrom();
}
/**
* For VirtualPage, add a canonical link tag linking to the original page
* See TRAC #6828 & http://support.google.com/webmasters/bin/answer.py?hl=en&answer=139394
*
* @param boolean $includeTitle Show default <title>-tag, set to false for custom templating
* @return string The XHTML metatags
*/
public function MetaTags($includeTitle = true) {
$tags = parent::MetaTags($includeTitle);
if ($this->CopyContentFrom()->ID) {
$tags .= "<link rel=\"canonical\" href=\"{$this->CopyContentFrom()->Link()}\" />\n";
}
return $tags;
}
public function allowedChildren() {
if($this->CopyContentFrom()) {
return $this->CopyContentFrom()->allowedChildren();
}
}
public function syncLinkTracking() {
if($this->CopyContentFromID) {
$this->HasBrokenLink = !(bool) DataObject::get_by_id('SiteTree', $this->CopyContentFromID);
} else {
$this->HasBrokenLink = true;
}
}
/**
* We can only publish the page if there is a published source page
*/
public function canPublish($member = null) {
return $this->isPublishable() && parent::canPublish($member);
}
/**
* Return true if we can delete this page from the live site, which is different from can
* we publish it.
*/
public function canDeleteFromLive($member = null) {
return parent::canPublish($member);
}
/**
* Returns true if is page is publishable by anyone at all
* Return false if the source page isn't published yet.
*
* Note that isPublishable doesn't affect ete from live, only publish.
*/
public function isPublishable() {
// No source
if(!$this->CopyContentFrom() || !$this->CopyContentFrom()->ID) {
return false;
}
// Unpublished source
if(!Versioned::get_versionnumber_by_stage('SiteTree', 'Live', $this->CopyContentFromID)) {
return false;
}
// Default - publishable
return true;
}
/**
* Generate the CMS fields from the fields from the original page.
*/
public function getCMSFields() {
$fields = parent::getCMSFields();
// Setup the linking to the original page.
$copyContentFromField = new TreeDropdownField(
"CopyContentFromID",
_t('VirtualPage.CHOOSE', "Linked Page"),
"SiteTree"
);
// filter doesn't let you select children of virtual pages as as source page
//$copyContentFromField->setFilterFunction(create_function('$item', 'return !($item instanceof VirtualPage);'));
// Setup virtual fields
if($virtualFields = $this->getVirtualFields()) {
$roTransformation = new ReadonlyTransformation();
foreach($virtualFields as $virtualField) {
if($fields->dataFieldByName($virtualField))
$fields->replaceField($virtualField, $fields->dataFieldByName($virtualField)->transform($roTransformation));
}
}
$msgs = array();
$fields->addFieldToTab("Root.Main", $copyContentFromField, "Title");
// Create links back to the original object in the CMS
if($this->CopyContentFrom()->exists()) {
$link = "<a class=\"cmsEditlink\" href=\"admin/pages/edit/show/$this->CopyContentFromID\">"
. _t('VirtualPage.EditLink', 'edit')
. "</a>";
$msgs[] = _t(
'VirtualPage.HEADERWITHLINK',
"This is a virtual page copying content from \"{title}\" ({link})",
array(
'title' => $this->CopyContentFrom()->Title,
'link' => $link
)
);
} else {
$msgs[] = _t('VirtualPage.HEADER', "This is a virtual page");
$msgs[] = _t(
'SITETREE.VIRTUALPAGEWARNING',
'Please choose a linked page and save first in order to publish this page'
);
}
if(
$this->CopyContentFromID
&& !Versioned::get_versionnumber_by_stage('SiteTree', 'Live', $this->CopyContentFromID)
) {
$msgs[] = _t(
'SITETREE.VIRTUALPAGEDRAFTWARNING',
'Please publish the linked page in order to publish the virtual page'
);
}
$fields->addFieldToTab("Root.Main",
new LiteralField(
'VirtualPageMessage',
'<div class="message notice">' . implode('. ', $msgs) . '.</div>'
),
'CopyContentFromID'
);
return $fields;
}
public function getSettingsFields() {
$fields = parent::getSettingsFields();
if(!$this->CopyContentFrom()->exists()) {
$fields->addFieldToTab("Root.Settings",
new LiteralField(
'VirtualPageWarning',
'<div class="message notice">'
. _t(
'SITETREE.VIRTUALPAGEWARNINGSETTINGS',
'Please choose a linked page in the main content fields in order to publish'
)
. '</div>'
),
'ClassName'
);
}
return $fields;
}
/**
* We have to change it to copy all the content from the original page first.
*/
public function onBeforeWrite() {
$performCopyFrom = null;
// Determine if we need to copy values.
if(
$this->extension_instances['Versioned']->migratingVersion
&& Versioned::current_stage() == 'Live'
&& $this->CopyContentFromID
) {
// On publication to live, copy from published source.
$performCopyFrom = true;
$stageSourceVersion = DB::query("SELECT \"Version\" FROM \"SiteTree\" WHERE \"ID\" = $this->CopyContentFromID")->value();
$liveSourceVersion = DB::query("SELECT \"Version\" FROM \"SiteTree_Live\" WHERE \"ID\" = $this->CopyContentFromID")->value();
// We're going to create a new VP record in SiteTree_versions because the published
// version might not exist, unless we're publishing the latest version
if($stageSourceVersion != $liveSourceVersion) {
$this->extension_instances['Versioned']->migratingVersion = null;
}
} else {
// On regular write, copy from draft source. This is only executed when the source page changes.
$performCopyFrom = $this->isChanged('CopyContentFromID', 2) && $this->CopyContentFromID != 0;
}
if($performCopyFrom && $this instanceof VirtualPage) {
// This flush is needed because the get_one cache doesn't respect site version :-(
singleton('SiteTree')->flushCache();
$source = DataObject::get_one("SiteTree",sprintf('"SiteTree"."ID" = %d', $this->CopyContentFromID));
// Leave the updating of image tracking until after write, in case its a new record
$this->copyFrom($source, false);
}
parent::onBeforeWrite();
}
public function onAfterWrite() {
parent::onAfterWrite();
// Don't do this stuff when we're publishing
if(!$this->extension_instances['Versioned']->migratingVersion) {
if(
$this->isChanged('CopyContentFromID')
&& $this->CopyContentFromID != 0
&& $this instanceof VirtualPage
) {
$this->updateImageTracking();
}
}
// Check if page type has changed to a non-virtual page.
// Caution: Relies on the fact that the current instance is still of the old page type.
if($this->isChanged('ClassName', 2)) {
$changed = $this->getChangedFields();
$classBefore = $changed['ClassName']['before'];
$classAfter = $changed['ClassName']['after'];
if($classBefore != $classAfter) {
// Remove all database rows for the old page type to avoid inconsistent data retrieval.
// TODO This should apply to all page type changes, not only on VirtualPage - but needs
// more comprehensive testing as its a destructive operation
$removedTables = array_diff(ClassInfo::dataClassesFor($classBefore), ClassInfo::dataClassesFor($classAfter));
if($removedTables) foreach($removedTables as $removedTable) {
// Note: *_versions records are left intact
foreach(array('', 'Live') as $stage) {
if($stage) $removedTable = "{$removedTable}_{$stage}";
DB::query(sprintf('DELETE FROM "%s" WHERE "ID" = %d', $removedTable, $this->ID));
}
}
// Also publish the change immediately to avoid inconsistent behaviour between
// a non-virtual draft and a virtual live record (e.g. republishing the original record
// shouldn't republish the - now unrelated - changes on the ex-VirtualPage draft).
// Copies all stage fields to live as well.
$source = DataObject::get_one("SiteTree",sprintf('"SiteTree"."ID" = %d', $this->CopyContentFromID));
$this->copyFrom($source);
$this->publish('Stage', 'Live');
// Change reference on instance (as well as removing the underlying database tables)
$this->CopyContentFromID = 0;
}
}
}
public function validate() {
$result = parent::validate();
// "Can be root" validation
$orig = $this->CopyContentFrom();
if(!$orig->stat('can_be_root') && !$this->ParentID) {
$result->error(
_t(
'VirtualPage.PageTypNotAllowedOnRoot',
'Original page type "{type}" is not allowed on the root level for this virtual page',
array('type' => $orig->i18n_singular_name())
),
'CAN_BE_ROOT_VIRTUAL'
);
}
return $result;
}
/**
* Ensure we have an up-to-date version of everything.
*/
public function copyFrom($source, $updateImageTracking = true) {
if($source) {
foreach($this->getVirtualFields() as $virtualField) {
$this->$virtualField = $source->$virtualField;
}
// We also want to copy certain, but only if we're copying the source page for the first
// time. After this point, the user is free to customise these for the virtual page themselves.
if($this->isChanged('CopyContentFromID', 2) && $this->CopyContentFromID != 0) {
foreach(self::config()->initially_copied_fields as $fieldName) {
$this->$fieldName = $source->$fieldName;
}
}
if($updateImageTracking) $this->updateImageTracking();
}
}
public function updateImageTracking() {
// Doesn't work on unsaved records
if(!$this->ID) return;
// Remove CopyContentFrom() from the cache
unset($this->components['CopyContentFrom']);
// Update ImageTracking
$this->ImageTracking()->setByIdList($this->CopyContentFrom()->ImageTracking()->column('ID'));
}
/**
* @param string $numChildrenMethod
* @return string
*/
public function CMSTreeClasses($numChildrenMethod="numChildren") {
return parent::CMSTreeClasses($numChildrenMethod) . ' VirtualPage-' . $this->CopyContentFrom()->ClassName;
}
/**
* Allow attributes on the master page to pass
* through to the virtual page
*
* @param string $field
* @return mixed
*/
public function __get($field) {
if(parent::hasMethod($funcName = "get$field")) {
return $this->$funcName();
} else if(parent::hasField($field)) {
return $this->getField($field);
} else {
return $this->copyContentFrom()->$field;
}
}
/**
* Pass unrecognized method calls on to the original data object
*
* @param string $method
* @param string $args
* @return mixed
*/
public function __call($method, $args) {
if(parent::hasMethod($method)) {
return parent::__call($method, $args);
} else {
return call_user_func_array(array($this->copyContentFrom(), $method), $args);
}
}
/**
* @param string $field
* @return bool
*/
public function hasField($field) {
return (
array_key_exists($field, $this->record)
|| $this->hasDatabaseField($field)
|| array_key_exists($field, $this->db()) // Needed for composite fields
|| parent::hasMethod("get{$field}")
|| $this->CopyContentFrom()->hasField($field)
);
}
/**
* Overwrite to also check for method on the original data object
*
* @param string $method
* @return bool
*/
public function hasMethod($method) {
if(parent::hasMethod($method)) return true;
return $this->copyContentFrom()->hasMethod($method);
}
}
/**
* Controller for the virtual page.
* @package cms
*/
class VirtualPage_Controller extends Page_Controller {
private static $allowed_actions = array(
'loadcontentall' => 'ADMIN',
);
/**
* Reloads the content if the version is different ;-)
*/
public function reloadContent() {
$this->failover->copyFrom($this->failover->CopyContentFrom());
$this->failover->write();
return;
}
public function getViewer($action) {
$originalClass = get_class($this->CopyContentFrom());
if ($originalClass == 'SiteTree') $name = 'Page_Controller';
else $name = $originalClass."_Controller";
$controller = new $name();
return $controller->getViewer($action);
}
/**
* When the virtualpage is loaded, check to see if the versions are the same
* if not, reload the content.
* NOTE: Virtual page must have a container object of subclass of sitetree.
* We can't load the content without an ID or record to copy it from.
*/
public function init(){
if(isset($this->record) && $this->record->ID){
if($this->record->VersionID != $this->failover->CopyContentFrom()->Version){
$this->reloadContent();
$this->VersionID = $this->failover->CopyContentFrom()->VersionID;
}
}
parent::init();
}
public function loadcontentall() {
$pages = DataObject::get("VirtualPage");
foreach($pages as $page) {
$page->copyFrom($page->CopyContentFrom());
$page->write();
$page->publish("Stage", "Live");
echo "<li>Published $page->URLSegment";
}
}
/**
* Also check the original object's original controller for the method
*
* @param string $method
* @return bool
*/
public function hasMethod($method) {
$haveIt = parent::hasMethod($method);
if (!$haveIt) {
$originalClass = get_class($this->CopyContentFrom());
if ($originalClass == 'SiteTree') $name = 'ContentController';
else $name = $originalClass."_Controller";
$controller = new $name($this->dataRecord->copyContentFrom());
$haveIt = $controller->hasMethod($method);
}
return $haveIt;
}
/**
* Pass unrecognized method calls on to the original controller
*
* @param string $method
* @param string $args
* @return mixed
*
* @throws Exception Any error other than a 'no method' error.
*/
public function __call($method, $args) {
try {
return parent::__call($method, $args);
} catch (Exception $e) {
// Hack... detect exception type. We really should use exception subclasses.
// if the exception isn't a 'no method' error, rethrow it
if ($e->getCode() !== 2175) throw $e;
$original = $this->copyContentFrom();
$originalClass = get_class($original);
if ($originalClass == 'SiteTree') $name = 'ContentController';
else $name = $originalClass."_Controller";
$controller = new $name($this->dataRecord->copyContentFrom());
return call_user_func_array(array($controller, $method), $args);
}
}
}