mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-09-09 03:01:18 +02:00
NEW: Add SilverStripe\ORM\UnexpectedDataException class. This change provides more graceful handling of the case where a ChangeSetItem is referencing a no-longer-existing class. The new exception, SilverStripe\ORM\UnexpectedDataException, is intended to be available for throwing whenever we have unexpected data in the database. It can be trapped by the relevant UIs and more graceful errors than HTTP 500s can be provided.
588 lines
14 KiB
PHP
588 lines
14 KiB
PHP
<?php
|
|
|
|
namespace SilverStripe\Admin;
|
|
|
|
use SilverStripe\Control\Controller;
|
|
use SilverStripe\Control\HTTPResponse;
|
|
use SilverStripe\Control\HTTPRequest;
|
|
use SilverStripe\Core\Convert;
|
|
use SilverStripe\Forms\HiddenField;
|
|
use SilverStripe\Forms\FormAction;
|
|
use SilverStripe\Forms\FieldList;
|
|
use SilverStripe\Forms\Form;
|
|
use SilverStripe\ORM\SS_List;
|
|
use SilverStripe\ORM\Versioning\ChangeSet;
|
|
use SilverStripe\ORM\Versioning\ChangeSetItem;
|
|
use SilverStripe\ORM\DataObject;
|
|
use SilverStripe\ORM\UnexpectedDataException;
|
|
use SilverStripe\Security\SecurityToken;
|
|
use SilverStripe\Security\PermissionProvider;
|
|
use LogicException;
|
|
|
|
/**
|
|
* Campaign section of the CMS
|
|
*/
|
|
class CampaignAdmin extends LeftAndMain implements PermissionProvider {
|
|
|
|
private static $allowed_actions = [
|
|
'set',
|
|
'sets',
|
|
'schema',
|
|
'DetailEditForm',
|
|
'readCampaigns',
|
|
'readCampaign',
|
|
'deleteCampaign',
|
|
'publishCampaign',
|
|
];
|
|
|
|
private static $menu_priority = 3;
|
|
|
|
private static $menu_title = 'Campaigns';
|
|
|
|
private static $tree_class = 'SilverStripe\\ORM\\Versioning\\ChangeSet';
|
|
|
|
private static $url_handlers = [
|
|
'GET sets' => 'readCampaigns',
|
|
'POST set/$ID/publish' => 'publishCampaign',
|
|
'GET set/$ID/$Name' => 'readCampaign',
|
|
'DELETE set/$ID' => 'deleteCampaign',
|
|
];
|
|
|
|
private static $url_segment = 'campaigns';
|
|
|
|
/**
|
|
* Size of thumbnail width
|
|
*
|
|
* @config
|
|
* @var int
|
|
*/
|
|
private static $thumbnail_width = 64;
|
|
|
|
/**
|
|
* Size of thumbnail height
|
|
*
|
|
* @config
|
|
* @var int
|
|
*/
|
|
private static $thumbnail_height = 64;
|
|
|
|
private static $required_permission_codes = 'CMS_ACCESS_CampaignAdmin';
|
|
|
|
public function getClientConfig() {
|
|
return array_merge(parent::getClientConfig(), [
|
|
'reactRouter' => true,
|
|
'form' => [
|
|
// TODO Use schemaUrl instead
|
|
'EditForm' => [
|
|
'schemaUrl' => $this->Link('schema/EditForm')
|
|
],
|
|
'DetailEditForm' => [
|
|
'schemaUrl' => $this->Link('schema/DetailEditForm')
|
|
],
|
|
],
|
|
'itemListViewEndpoint' => [
|
|
'url' => $this->Link() . 'set/:id/show',
|
|
'method' => 'get'
|
|
],
|
|
'publishEndpoint' => [
|
|
'url' => $this->Link() . 'set/:id/publish',
|
|
'method' => 'post'
|
|
],
|
|
'treeClass' => $this->config()->tree_class
|
|
]);
|
|
}
|
|
|
|
public function schema($request) {
|
|
// TODO Hardcoding schema until we can get GridField to generate a schema dynamically
|
|
$treeClassJS = Convert::raw2js($this->config()->tree_class);
|
|
$adminURL = Convert::raw2js(AdminRootController::admin_url());
|
|
$json = <<<JSON
|
|
{
|
|
"id": "Form_EditForm",
|
|
"schema": {
|
|
"name": "EditForm",
|
|
"id": "Form_EditForm",
|
|
"action": "schema",
|
|
"method": "GET",
|
|
"schema_url": "{$adminURL}campaigns\/schema\/EditForm",
|
|
"attributes": {
|
|
"id": "Form_EditForm",
|
|
"action": "{$adminURL}campaigns\/EditForm",
|
|
"method": "POST",
|
|
"enctype": "multipart\/form-data",
|
|
"target": null
|
|
},
|
|
"data": [],
|
|
"fields": [{
|
|
"name": "ID",
|
|
"id": "Form_EditForm_ID",
|
|
"type": "Hidden",
|
|
"component": null,
|
|
"holder_id": null,
|
|
"title": false,
|
|
"source": null,
|
|
"extraClass": "hidden form-group--no-label",
|
|
"description": null,
|
|
"rightTitle": null,
|
|
"leftTitle": null,
|
|
"readOnly": false,
|
|
"disabled": false,
|
|
"customValidationMessage": "",
|
|
"attributes": [],
|
|
"data": []
|
|
}, {
|
|
"name": "ChangeSets",
|
|
"id": "Form_EditForm_ChangeSets",
|
|
"type": "Custom",
|
|
"component": "GridField",
|
|
"holder_id": null,
|
|
"title": "Campaigns",
|
|
"source": null,
|
|
"extraClass": null,
|
|
"description": null,
|
|
"rightTitle": null,
|
|
"leftTitle": null,
|
|
"readOnly": false,
|
|
"disabled": false,
|
|
"customValidationMessage": "",
|
|
"attributes": [],
|
|
"data": {
|
|
"recordType": "{$treeClassJS}",
|
|
"collectionReadEndpoint": {
|
|
"url": "{$adminURL}campaigns\/sets",
|
|
"method": "GET"
|
|
},
|
|
"itemReadEndpoint": {
|
|
"url": "{$adminURL}campaigns\/set\/:id",
|
|
"method": "GET"
|
|
},
|
|
"itemUpdateEndpoint": {
|
|
"url": "{$adminURL}campaigns\/set\/:id",
|
|
"method": "PUT"
|
|
},
|
|
"itemCreateEndpoint": {
|
|
"url": "{$adminURL}campaigns\/set\/:id",
|
|
"method": "POST"
|
|
},
|
|
"itemDeleteEndpoint": {
|
|
"url": "{$adminURL}campaigns\/set\/:id",
|
|
"method": "DELETE"
|
|
},
|
|
"editFormSchemaEndpoint": "{$adminURL}campaigns\/schema\/DetailEditForm",
|
|
"columns": [
|
|
{"name": "Title", "field": "Name"},
|
|
{"name": "Changes", "field": "ChangesCount"},
|
|
{"name": "Description", "field": "Description"}
|
|
]
|
|
}
|
|
}, {
|
|
"name": "SecurityID",
|
|
"id": "Form_EditForm_SecurityID",
|
|
"type": "Hidden",
|
|
"component": null,
|
|
"holder_id": null,
|
|
"title": "Security ID",
|
|
"source": null,
|
|
"extraClass": "hidden",
|
|
"description": null,
|
|
"rightTitle": null,
|
|
"leftTitle": null,
|
|
"readOnly": false,
|
|
"disabled": false,
|
|
"customValidationMessage": "",
|
|
"attributes": [],
|
|
"data": []
|
|
}],
|
|
"actions": []
|
|
}
|
|
}
|
|
JSON;
|
|
|
|
$formName = $request->param('ID');
|
|
if($formName == 'EditForm') {
|
|
$response = $this->getResponse();
|
|
$response->addHeader('Content-Type', 'application/json');
|
|
$response->setBody($json);
|
|
return $response;
|
|
} else {
|
|
return parent::schema($request);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* REST endpoint to get a list of campaigns.
|
|
*
|
|
* @return HTTPResponse
|
|
*/
|
|
public function readCampaigns() {
|
|
$response = new HTTPResponse();
|
|
$response->addHeader('Content-Type', 'application/json');
|
|
$hal = $this->getListResource();
|
|
$response->setBody(Convert::array2json($hal));
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Get list contained as a hal wrapper
|
|
*
|
|
* @return array
|
|
*/
|
|
protected function getListResource() {
|
|
$items = $this->getListItems();
|
|
$count = $items->count();
|
|
/** @var string $treeClass */
|
|
$treeClass = $this->config()->tree_class;
|
|
$hal = [
|
|
'count' => $count,
|
|
'total' => $count,
|
|
'_links' => [
|
|
'self' => [
|
|
'href' => $this->Link('items')
|
|
]
|
|
],
|
|
'_embedded' => [$treeClass => []]
|
|
];
|
|
foreach($items as $item) {
|
|
/** @var ChangeSet $item */
|
|
$resource = $this->getChangeSetResource($item);
|
|
$hal['_embedded'][$treeClass][] = $resource;
|
|
}
|
|
return $hal;
|
|
}
|
|
|
|
/**
|
|
* Build item resource from a changeset
|
|
*
|
|
* @param ChangeSet $changeSet
|
|
* @return array
|
|
*/
|
|
protected function getChangeSetResource(ChangeSet $changeSet) {
|
|
$hal = [
|
|
'_links' => [
|
|
'self' => [
|
|
'href' => $this->SetLink($changeSet->ID)
|
|
]
|
|
],
|
|
'ID' => $changeSet->ID,
|
|
'Name' => $changeSet->Name,
|
|
'Created' => $changeSet->Created,
|
|
'LastEdited' => $changeSet->LastEdited,
|
|
'State' => $changeSet->State,
|
|
'canEdit' => $changeSet->canEdit(),
|
|
'canPublish' => false,
|
|
'_embedded' => ['items' => []]
|
|
];
|
|
|
|
// Before presenting the changeset to the client,
|
|
// synchronise it with new changes.
|
|
try {
|
|
$changeSet->sync();
|
|
$hal['Description'] = $changeSet->getDescription();
|
|
$hal['canPublish'] = $changeSet->canPublish();
|
|
|
|
foreach($changeSet->Changes() as $changeSetItem) {
|
|
if(!$changeSetItem) {
|
|
continue;
|
|
}
|
|
|
|
/** @var ChangesetItem $changeSetItem */
|
|
$resource = $this->getChangeSetItemResource($changeSetItem);
|
|
$hal['_embedded']['items'][] = $resource;
|
|
}
|
|
$hal['ChangesCount'] = count($hal['_embedded']['items']);
|
|
|
|
// An unexpected data exception means that the database is corrupt
|
|
} catch(UnexpectedDataException $e) {
|
|
$hal['Description'] = 'Corrupt database! ' . $e->getMessage();
|
|
$hal['ChangesCount'] = '-';
|
|
}
|
|
return $hal;
|
|
}
|
|
|
|
/**
|
|
* Build item resource from a changesetitem
|
|
*
|
|
* @param ChangeSetItem $changeSetItem
|
|
* @return array
|
|
*/
|
|
protected function getChangeSetItemResource(ChangeSetItem $changeSetItem) {
|
|
$baseClass = DataObject::getSchema()->baseDataClass($changeSetItem->ObjectClass);
|
|
$baseSingleton = DataObject::singleton($baseClass);
|
|
$thumbnailWidth = (int)$this->config()->thumbnail_width;
|
|
$thumbnailHeight = (int)$this->config()->thumbnail_height;
|
|
$hal = [
|
|
'_links' => [
|
|
'self' => [
|
|
'href' => $this->ItemLink($changeSetItem->ID)
|
|
]
|
|
],
|
|
'ID' => $changeSetItem->ID,
|
|
'Created' => $changeSetItem->Created,
|
|
'LastEdited' => $changeSetItem->LastEdited,
|
|
'Title' => $changeSetItem->getTitle(),
|
|
'ChangeType' => $changeSetItem->getChangeType(),
|
|
'Added' => $changeSetItem->Added,
|
|
'ObjectClass' => $changeSetItem->ObjectClass,
|
|
'ObjectID' => $changeSetItem->ObjectID,
|
|
'BaseClass' => $baseClass,
|
|
'Singular' => $baseSingleton->i18n_singular_name(),
|
|
'Plural' => $baseSingleton->i18n_plural_name(),
|
|
'Thumbnail' => $changeSetItem->ThumbnailURL($thumbnailWidth, $thumbnailHeight),
|
|
];
|
|
// Get preview urls
|
|
$previews = $changeSetItem->getPreviewLinks();
|
|
if($previews) {
|
|
$hal['_links']['preview'] = $previews;
|
|
}
|
|
|
|
// Get edit link
|
|
$editLink = $changeSetItem->CMSEditLink();
|
|
if($editLink) {
|
|
$hal['_links']['edit'] = [
|
|
'href' => $editLink,
|
|
];
|
|
}
|
|
|
|
// Depending on whether the object was added implicitly or explicitly, set
|
|
// other related objects.
|
|
if($changeSetItem->Added === ChangeSetItem::IMPLICITLY) {
|
|
$referencedItems = $changeSetItem->ReferencedBy();
|
|
$referencedBy = [];
|
|
foreach($referencedItems as $referencedItem) {
|
|
$referencedBy[] = [
|
|
'href' => $this->SetLink($referencedItem->ID)
|
|
];
|
|
}
|
|
if($referencedBy) {
|
|
$hal['_links']['referenced_by'] = $referencedBy;
|
|
}
|
|
}
|
|
|
|
return $hal;
|
|
}
|
|
|
|
/**
|
|
* Gets viewable list of campaigns
|
|
*
|
|
* @return SS_List
|
|
*/
|
|
protected function getListItems() {
|
|
return ChangeSet::get()
|
|
->filter('State', ChangeSet::STATE_OPEN)
|
|
->filterByCallback(function($item) {
|
|
/** @var ChangeSet $item */
|
|
return ($item->canView());
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* REST endpoint to get a campaign.
|
|
*
|
|
* @param HTTPRequest $request
|
|
*
|
|
* @return HTTPResponse
|
|
*/
|
|
public function readCampaign(HTTPRequest $request) {
|
|
$response = new HTTPResponse();
|
|
|
|
if ($request->getHeader('Accept') == 'text/json') {
|
|
$response->addHeader('Content-Type', 'application/json');
|
|
if (!$request->param('Name')) {
|
|
return (new HTTPResponse(null, 400));
|
|
}
|
|
|
|
/** @var ChangeSet $changeSet */
|
|
$changeSet = ChangeSet::get()->byID($request->param('ID'));
|
|
if(!$changeSet) {
|
|
return (new HTTPResponse(null, 404));
|
|
}
|
|
|
|
if(!$changeSet->canView()) {
|
|
return (new HTTPResponse(null, 403));
|
|
}
|
|
|
|
$body = Convert::raw2json($this->getChangeSetResource($changeSet));
|
|
return (new HTTPResponse($body, 200))
|
|
->addHeader('Content-Type', 'application/json');
|
|
} else {
|
|
return $this->index($request);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* REST endpoint to delete a campaign.
|
|
*
|
|
* @param HTTPRequest $request
|
|
*
|
|
* @return HTTPResponse
|
|
*/
|
|
public function deleteCampaign(HTTPRequest $request) {
|
|
// Check security ID
|
|
if (!SecurityToken::inst()->checkRequest($request)) {
|
|
return new HTTPResponse(null, 400);
|
|
}
|
|
|
|
$id = $request->param('ID');
|
|
if (!$id || !is_numeric($id)) {
|
|
return (new HTTPResponse(null, 400));
|
|
}
|
|
|
|
$record = ChangeSet::get()->byID($id);
|
|
if(!$record) {
|
|
return (new HTTPResponse(null, 404));
|
|
}
|
|
|
|
if(!$record->canDelete()) {
|
|
return (new HTTPResponse(null, 403));
|
|
}
|
|
|
|
$record->delete();
|
|
|
|
return (new HTTPResponse(null, 204));
|
|
}
|
|
|
|
/**
|
|
* REST endpoint to publish a {@link ChangeSet} and all of its items.
|
|
*
|
|
* @param HTTPRequest $request
|
|
*
|
|
* @return HTTPResponse
|
|
*/
|
|
public function publishCampaign(HTTPRequest $request) {
|
|
// Protect against CSRF on destructive action
|
|
if(!SecurityToken::inst()->checkRequest($request)) {
|
|
return (new HTTPResponse(null, 400));
|
|
}
|
|
|
|
$id = $request->param('ID');
|
|
if(!$id || !is_numeric($id)) {
|
|
return (new HTTPResponse(null, 400));
|
|
}
|
|
|
|
/** @var ChangeSet $record */
|
|
$record = ChangeSet::get()->byID($id);
|
|
if(!$record) {
|
|
return (new HTTPResponse(null, 404));
|
|
}
|
|
|
|
if(!$record->canPublish()) {
|
|
return (new HTTPResponse(null, 403));
|
|
}
|
|
|
|
try {
|
|
$record->publish();
|
|
} catch(LogicException $e) {
|
|
return (new HTTPResponse(json_encode(['status' => 'error', 'message' => $e->getMessage()]), 401))
|
|
->addHeader('Content-Type', 'application/json');
|
|
}
|
|
|
|
return (new HTTPResponse(
|
|
Convert::raw2json($this->getChangeSetResource($record)),
|
|
200
|
|
))->addHeader('Content-Type', 'application/json');
|
|
}
|
|
|
|
/**
|
|
* Url handler for edit form
|
|
*
|
|
* @param HTTPRequest $request
|
|
* @return Form
|
|
*/
|
|
public function DetailEditForm($request) {
|
|
// Get ID either from posted back value, or url parameter
|
|
$id = $request->param('ID') ?: $request->postVar('ID');
|
|
return $this->getDetailEditForm($id);
|
|
}
|
|
|
|
/**
|
|
* @todo Use GridFieldDetailForm once it can handle structured data and form schemas
|
|
*
|
|
* @param int $id
|
|
* @return Form
|
|
*/
|
|
public function getDetailEditForm($id) {
|
|
// Get record-specific fields
|
|
$record = null;
|
|
if($id) {
|
|
$record = ChangeSet::get()->byID($id);
|
|
if(!$record || !$record->canView()) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if(!$record) {
|
|
$record = ChangeSet::singleton();
|
|
}
|
|
|
|
$fields = $record->getCMSFields();
|
|
|
|
// Add standard fields
|
|
$fields->push(HiddenField::create('ID'));
|
|
$form = Form::create(
|
|
$this,
|
|
'DetailEditForm',
|
|
$fields,
|
|
FieldList::create(
|
|
FormAction::create('save', _t('CMSMain.SAVE', 'Save'))
|
|
->setIcon('save'),
|
|
FormAction::create('cancel', _t('LeftAndMain.CANCEL', 'Cancel'))
|
|
->setUseButtonTag(true)
|
|
)
|
|
);
|
|
|
|
// Load into form
|
|
if($id && $record) {
|
|
$form->loadDataFrom($record);
|
|
}
|
|
|
|
// Configure form to respond to validation errors with form schema
|
|
// if requested via react.
|
|
$form->setValidationResponseCallback(function() use ($form) {
|
|
return $this->getSchemaResponse($form);
|
|
});
|
|
|
|
return $form;
|
|
}
|
|
|
|
/**
|
|
* Gets user-visible url to edit a specific {@see ChangeSet}
|
|
*
|
|
* @param $itemID
|
|
* @return string
|
|
*/
|
|
public function SetLink($itemID) {
|
|
return Controller::join_links(
|
|
$this->Link('set'),
|
|
$itemID
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Gets user-visible url to edit a specific {@see ChangeSetItem}
|
|
*
|
|
* @param int $itemID
|
|
* @return string
|
|
*/
|
|
public function ItemLink($itemID) {
|
|
return Controller::join_links(
|
|
$this->Link('item'),
|
|
$itemID
|
|
);
|
|
}
|
|
|
|
public function providePermissions() {
|
|
return array(
|
|
"CMS_ACCESS_CampaignAdmin" => array(
|
|
'name' => _t('CMSMain.ACCESS', "Access to '{title}' section", array('title' => static::menu_title())),
|
|
'category' => _t('Permission.CMS_ACCESS_CATEGORY', 'CMS Access'),
|
|
'help' => _t(
|
|
'CampaignAdmin.ACCESS_HELP',
|
|
'Allow viewing of the campaign publishing section.'
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
}
|