diff --git a/admin/code/CampaignAdmin.php b/admin/code/CampaignAdmin.php
index 2e6d50e3c..997e74395 100644
--- a/admin/code/CampaignAdmin.php
+++ b/admin/code/CampaignAdmin.php
@@ -18,6 +18,7 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider {
'readCampaign',
'updateCampaign',
'deleteCampaign',
+ 'publishCampaign',
];
private static $menu_priority = 11;
@@ -28,6 +29,7 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider {
'GET sets' => 'readCampaigns',
'POST set/$ID' => 'createCampaign',
'GET set/$ID/$Name' => 'readCampaign',
+ 'PUT set/$ID/publish' => 'publishCampaign',
'PUT set/$ID' => 'updateCampaign',
'DELETE set/$ID' => 'deleteCampaign',
];
@@ -62,6 +64,10 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider {
],
'campaignViewRoute' => $urlSegment . '/:type?/:id?/:view?',
'itemListViewEndpoint' => $this->Link('set/:id/show'),
+ 'publishEndpoint' => [
+ 'url' => $this->Link('set/:id/publish'),
+ 'method' => 'put'
+ ]
]);
}
@@ -258,6 +264,8 @@ JSON;
'Created' => $changeSet->Created,
'LastEdited' => $changeSet->LastEdited,
'State' => $changeSet->State,
+ 'canEdit' => $changeSet->canEdit(),
+ 'canPublish' => $changeSet->canPublish(),
'_embedded' => ['ChangeSetItems' => []]
];
foreach($changeSet->Changes() as $changeSetItem) {
@@ -347,7 +355,7 @@ JSON;
$response = new SS_HTTPResponse();
if ($request->getHeader('Accept') == 'text/json') {
- $response->addHeader('Content-Type', 'application/json');
+ $response->addHeader('Content-Type', 'application/json');
$changeSet = ChangeSet::get()->byId($request->param('ID'));
switch ($request->param('Name')) {
@@ -361,7 +369,7 @@ JSON;
$response->setBody('{"message":"404"}');
}
- return $response;
+ return $response;
} else {
return $this->index($request);
@@ -395,19 +403,19 @@ JSON;
public function deleteCampaign(SS_HTTPRequest $request) {
$id = $request->param('ID');
if (!$id || !is_numeric($id)) {
- return (new SS_HTTPResponse(json_encode(['status' => 'error']), 400))
- ->addHeader('Content-Type', 'application/json');
- }
+ return (new SS_HTTPResponse(json_encode(['status' => 'error']), 400))
+ ->addHeader('Content-Type', 'application/json');
+ }
$record = ChangeSet::get()->byID($id);
if(!$record) {
return (new SS_HTTPResponse(json_encode(['status' => 'error']), 404))
- ->addHeader('Content-Type', 'application/json');
+ ->addHeader('Content-Type', 'application/json');
}
if(!$record->canDelete()) {
return (new SS_HTTPResponse(json_encode(['status' => 'error']), 401))
- ->addHeader('Content-Type', 'application/json');
+ ->addHeader('Content-Type', 'application/json');
}
$record->delete();
@@ -415,6 +423,44 @@ JSON;
return (new SS_HTTPResponse('', 204));
}
+ /**
+ * REST endpoint to publish a {@link ChangeSet} and all of its items.
+ *
+ * @param SS_HTTPRequest $request
+ *
+ * @return SS_HTTPResponse
+ */
+ public function publishCampaign(SS_HTTPRequest $request) {
+ $id = $request->param('ID');
+ if(!$id || !is_numeric($id)) {
+ return (new SS_HTTPResponse(json_encode(['status' => 'error']), 400))
+ ->addHeader('Content-Type', 'application/json');
+ }
+
+ $record = ChangeSet::get()->byID($id);
+ if(!$record) {
+ return (new SS_HTTPResponse(json_encode(['status' => 'error']), 404))
+ ->addHeader('Content-Type', 'application/json');
+ }
+
+ if(!$record->canPublish()) {
+ return (new SS_HTTPResponse(json_encode(['status' => 'error']), 401))
+ ->addHeader('Content-Type', 'application/json');
+ }
+
+ try {
+ $record->publish();
+ } catch(LogicException $e) {
+ return (new SS_HTTPResponse(json_encode(['status' => 'error', 'message' => $e->getMessage()]), 401))
+ ->addHeader('Content-Type', 'application/json');
+ }
+
+ return (new SS_HTTPResponse(
+ Convert::raw2json($this->getChangeSetResource($record)),
+ 200
+ ))->addHeader('Content-Type', 'application/json');
+ }
+
/**
* @todo Use GridFieldDetailForm once it can handle structured data and form schemas
*
diff --git a/admin/javascript/lang/en.js b/admin/javascript/lang/en.js
index e8405915b..1e7fd3ed9 100644
--- a/admin/javascript/lang/en.js
+++ b/admin/javascript/lang/en.js
@@ -19,6 +19,9 @@ if(typeof(ss) == 'undefined' || typeof(ss.i18n) == 'undefined') {
"ModelAdmin.DELETED": "Deleted",
"ModelAdmin.VALIDATIONERROR": "Validation Error",
"LeftAndMain.PAGEWASDELETED": "This page was deleted. To edit a page, select it from the left.",
- "Campaigns.ADDCAMPAIGN": "Add campaign"
+ "Campaigns.ADDCAMPAIGN": "Add campaign",
+ "Campaigns.PUBLISHCAMPAIGN": "Publish campaign",
+ "Campaigns.ITEM_SUMMARY_SINGULAR": "%s item",
+ "Campaigns.ITEM_SUMMARY_PLURAL": "%s items",
});
-}
\ No newline at end of file
+}
diff --git a/admin/javascript/lang/src/en.js b/admin/javascript/lang/src/en.js
index 85289aa4c..f37d7306d 100644
--- a/admin/javascript/lang/src/en.js
+++ b/admin/javascript/lang/src/en.js
@@ -14,5 +14,6 @@
"ModelAdmin.DELETED": "Deleted",
"ModelAdmin.VALIDATIONERROR": "Validation Error",
"LeftAndMain.PAGEWASDELETED": "This page was deleted. To edit a page, select it from the left.",
- "Campaigns.ADDCAMPAIGN": "Add campaign"
-}
\ No newline at end of file
+ "Campaigns.ADDCAMPAIGN": "Add campaign",
+ "Campaigns.PUBLISHCAMPAIGN": "Publish campaign",
+}
diff --git a/admin/javascript/src/components/label/README.md b/admin/javascript/src/components/label/README.md
new file mode 100644
index 000000000..62558c6fb
--- /dev/null
+++ b/admin/javascript/src/components/label/README.md
@@ -0,0 +1,15 @@
+# Label
+
+Small and adaptive tag for adding context to just about any content.
+Extends the [Label component](http://v4-alpha.getbootstrap.com/components/label/)
+in Bootstrap.
+
+## Variations
+
+Empty label - a round indicator demonstrating a status.
+Use together with a "contextual variation" like `label-warning`
+to get colour value.
+
+```html
+
+```
diff --git a/admin/javascript/src/components/label/styles.scss b/admin/javascript/src/components/label/styles.scss
new file mode 100644
index 000000000..5d91c513d
--- /dev/null
+++ b/admin/javascript/src/components/label/styles.scss
@@ -0,0 +1,6 @@
+.label--empty {
+ border-radius: 50%;
+ height: 10px;
+ width: 10px;
+ top: 1px;
+}
diff --git a/admin/javascript/src/sections/campaign-admin/controller.js b/admin/javascript/src/sections/campaign-admin/controller.js
index 7b087b1bc..37af80cf2 100644
--- a/admin/javascript/src/sections/campaign-admin/controller.js
+++ b/admin/javascript/src/sections/campaign-admin/controller.js
@@ -1,6 +1,7 @@
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
+import backend from 'silverstripe-backend';
import * as actions from 'state/campaign/actions';
import SilverStripeComponent from 'silverstripe-component';
import FormAction from 'components/form-action/index';
@@ -16,6 +17,13 @@ class CampaignAdminContainer extends SilverStripeComponent {
this.addCampaign = this.addCampaign.bind(this);
this.createFn = this.createFn.bind(this);
+ this.publishApi = backend.createEndpointFetcher({
+ url: this.props.config.publishEndpoint.url,
+ method: this.props.config.publishEndpoint.method,
+ payloadSchema: {
+ id: { urlReplacement: ':id', remove: true },
+ },
+ });
}
componentDidMount() {
@@ -73,6 +81,7 @@ class CampaignAdminContainer extends SilverStripeComponent {
const props = {
campaignId: this.props.campaignId,
itemListViewEndpoint: this.props.config.itemListViewEndpoint,
+ publishApi: this.publishApi,
};
return (
diff --git a/admin/javascript/src/sections/campaign-admin/item.js b/admin/javascript/src/sections/campaign-admin/item.js
index a9167f992..640ab2a8f 100644
--- a/admin/javascript/src/sections/campaign-admin/item.js
+++ b/admin/javascript/src/sections/campaign-admin/item.js
@@ -7,7 +7,7 @@ import i18n from 'i18n';
*/
class CampaignItem extends SilverStripeComponent {
render() {
- let thumbnail = '';
+ let thumbnail = null;
const badge = {};
const item = this.props.item;
diff --git a/admin/javascript/src/sections/campaign-admin/list.js b/admin/javascript/src/sections/campaign-admin/list.js
index 5c86d20be..fb10362e9 100644
--- a/admin/javascript/src/sections/campaign-admin/list.js
+++ b/admin/javascript/src/sections/campaign-admin/list.js
@@ -1,24 +1,33 @@
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
-import * as actions from 'state/records/actions';
+import * as recordActions from 'state/records/actions';
+import * as campaignActions from 'state/campaign/actions';
import SilverStripeComponent from 'silverstripe-component';
import Accordion from 'components/accordion/index';
import AccordionGroup from 'components/accordion/group';
import AccordionItem from 'components/accordion/item';
import NorthHeader from 'components/north-header/index';
+import FormAction from 'components/form-action/index';
import CampaignItem from './item';
import CampaignPreview from './preview';
+import i18n from 'i18n';
/**
* Represents a campaign list view
*/
class CampaignListContainer extends SilverStripeComponent {
+ constructor(props) {
+ super(props);
+
+ this.handlePublish = this.handlePublish.bind(this);
+ }
+
componentDidMount() {
const fetchURL = this.props.itemListViewEndpoint.replace(/:id/, this.props.campaignId);
super.componentDidMount();
- this.props.actions.fetchRecord('ChangeSet', 'get', fetchURL);
+ this.props.recordActions.fetchRecord('ChangeSet', 'get', fetchURL);
}
/**
@@ -79,12 +88,60 @@ class CampaignListContainer extends SilverStripeComponent {
{accordionGroups}
+
+ {this.renderButtonToolbar()}
+
{ previewUrl && }
);
}
+ renderButtonToolbar() {
+ const items = this.getItems(this.props.campaignId);
+
+ let itemSummaryLabel;
+ if (items) {
+ itemSummaryLabel = i18n.sprintf(
+ (items.length === 1) ?
+ i18n._t('Campaigns.ITEM_SUMMARY_SINGULAR')
+ : i18n._t('Campaigns.ITEM_SUMMARY_PLURAL'),
+ items.length
+ );
+
+ let button;
+ if (this.props.record.State === 'open') {
+ button = (
+
+ );
+ } else if (this.props.record.State === 'published') {
+ // TODO Implement "revert" feature
+ button = (
+
+ );
+ }
+
+ return (
+
+ {button}
+
+
+ {itemSummaryLabel}
+
+
+ );
+ }
+
+ return ;
+ }
/**
* Gets preview URL for itemid
@@ -100,6 +157,17 @@ class CampaignListContainer extends SilverStripeComponent {
return document.getElementsByTagName('base')[0].href;
}
+ /**
+ * @return {Array}
+ */
+ getItems() {
+ if (this.props.record && this.props.record._embedded) {
+ return this.props.record._embedded.ChangeSetItems;
+ }
+
+ return null;
+ }
+
/**
* Group items for changeset display
*
@@ -107,10 +175,10 @@ class CampaignListContainer extends SilverStripeComponent {
*/
groupItemsForSet() {
const groups = {};
- if (!this.props.record || !this.props.record._embedded) {
+ const items = this.getItems();
+ if (!items) {
return groups;
}
- const items = this.props.record._embedded.ChangeSetItems;
// group by whatever
items.forEach(item => {
@@ -132,8 +200,20 @@ class CampaignListContainer extends SilverStripeComponent {
return groups;
}
+ handlePublish(e) {
+ e.preventDefault();
+ this.props.campaignActions.publishCampaign(
+ this.props.publishApi,
+ this.props.campaignId
+ );
+ }
+
}
+CampaignListContainer.propTypes = {
+ publishApi: React.PropTypes.func.isRequired,
+};
+
function mapStateToProps(state, ownProps) {
// Find record specific to this item
let record = null;
@@ -149,7 +229,8 @@ function mapStateToProps(state, ownProps) {
function mapDispatchToProps(dispatch) {
return {
- actions: bindActionCreators(actions, dispatch),
+ recordActions: bindActionCreators(recordActions, dispatch),
+ campaignActions: bindActionCreators(campaignActions, dispatch),
};
}
diff --git a/admin/javascript/src/state/campaign/action-types.js b/admin/javascript/src/state/campaign/action-types.js
index 2aa52e7dc..bf1711356 100644
--- a/admin/javascript/src/state/campaign/action-types.js
+++ b/admin/javascript/src/state/campaign/action-types.js
@@ -1,3 +1,6 @@
export default {
SET_CAMPAIGN_ACTIVE_CHANGESET: 'SET_CAMPAIGN_ACTIVE_CHANGESET',
+ PUBLISH_CAMPAIGN_REQUEST: 'PUBLISH_CAMPAIGN_REQUEST',
+ PUBLISH_CAMPAIGN_SUCCESS: 'PUBLISH_CAMPAIGN_SUCCESS',
+ PUBLISH_CAMPAIGN_FAILURE: 'PUBLISH_CAMPAIGN_FAILURE',
};
diff --git a/admin/javascript/src/state/campaign/actions.js b/admin/javascript/src/state/campaign/actions.js
index 5d2700895..8a06520fc 100644
--- a/admin/javascript/src/state/campaign/actions.js
+++ b/admin/javascript/src/state/campaign/actions.js
@@ -1,4 +1,5 @@
import ACTION_TYPES from './action-types';
+import RECORD_ACTION_TYPES from 'state/records/action-types';
/**
* Show specified campaign set
@@ -13,3 +14,36 @@ export function showCampaignView(campaignId, view) {
};
}
+/**
+ * Publish a campaign and all its items
+ *
+ * @param {Function} publishApi See silverstripe-backend.js
+ * @param {number} campaignId
+ * @return {Object}
+ */
+export function publishCampaign(publishApi, campaignId) {
+ return (dispatch) => {
+ dispatch({
+ type: ACTION_TYPES.PUBLISH_CAMPAIGN_REQUEST,
+ payload: { campaignId },
+ });
+
+ publishApi({ id: campaignId })
+ .then((data) => {
+ dispatch({
+ type: ACTION_TYPES.PUBLISH_CAMPAIGN_SUCCESS,
+ payload: { campaignId },
+ });
+ dispatch({
+ type: RECORD_ACTION_TYPES.FETCH_RECORD_SUCCESS,
+ payload: { recordType: 'ChangeSet', data },
+ });
+ })
+ .catch((error) => {
+ dispatch({
+ type: ACTION_TYPES.PUBLISH_CAMPAIGN_FAILURE,
+ payload: { error },
+ });
+ });
+ };
+}
diff --git a/admin/javascript/src/state/records/actions.js b/admin/javascript/src/state/records/actions.js
index 933a96e83..fb4975b29 100644
--- a/admin/javascript/src/state/records/actions.js
+++ b/admin/javascript/src/state/records/actions.js
@@ -27,29 +27,32 @@ function populate(str, params) {
export function fetchRecords(recordType, method, url) {
const payload = { recordType };
const headers = { Accept: 'text/json' };
+ const methodToLowerCase = method.toLowerCase();
+
return (dispatch) => {
dispatch({
type: ACTION_TYPES.FETCH_RECORDS_REQUEST,
payload,
});
- const args = method.toLowerCase() === 'get'
+
+ const args = methodToLowerCase === 'get'
? [populate(url, payload), headers]
: [populate(url, payload), {}, headers];
- return backend[method.toLowerCase()](...args)
- .then(response => response.json())
- .then(json => {
- dispatch({
- type: ACTION_TYPES.FETCH_RECORDS_SUCCESS,
- payload: { recordType, data: json },
+ return backend[methodToLowerCase](...args)
+ .then(response => response.json())
+ .then(json => {
+ dispatch({
+ type: ACTION_TYPES.FETCH_RECORDS_SUCCESS,
+ payload: { recordType, data: json },
+ });
+ })
+ .catch((err) => {
+ dispatch({
+ type: ACTION_TYPES.FETCH_RECORDS_FAILURE,
+ payload: { error: err, recordType },
+ });
});
- })
- .catch((err) => {
- dispatch({
- type: ACTION_TYPES.FETCH_RECORDS_FAILURE,
- payload: { error: err, recordType },
- });
- });
};
}
@@ -64,29 +67,32 @@ export function fetchRecords(recordType, method, url) {
export function fetchRecord(recordType, method, url) {
const payload = { recordType };
const headers = { Accept: 'text/json' };
+ const methodToLowerCase = method.toLowerCase();
+
return (dispatch) => {
dispatch({
type: ACTION_TYPES.FETCH_RECORD_REQUEST,
payload,
});
- const args = method.toLowerCase() === 'get'
+
+ const args = methodToLowerCase === 'get'
? [populate(url, payload), headers]
: [populate(url, payload), {}, headers];
- return backend[method.toLowerCase()](...args)
- .then(response => response.json())
- .then(json => {
- dispatch({
- type: ACTION_TYPES.FETCH_RECORD_SUCCESS,
- payload: { recordType, data: json },
+ return backend[methodToLowerCase](...args)
+ .then(response => response.json())
+ .then(json => {
+ dispatch({
+ type: ACTION_TYPES.FETCH_RECORD_SUCCESS,
+ payload: { recordType, data: json },
+ });
+ })
+ .catch((err) => {
+ dispatch({
+ type: ACTION_TYPES.FETCH_RECORD_FAILURE,
+ payload: { error: err, recordType },
+ });
});
- })
- .catch((err) => {
- dispatch({
- type: ACTION_TYPES.FETCH_RECORD_FAILURE,
- payload: { error: err, recordType },
- });
- });
};
}
diff --git a/admin/javascript/src/styles/_layout.scss b/admin/javascript/src/styles/_layout.scss
new file mode 100644
index 000000000..d458f22ec
--- /dev/null
+++ b/admin/javascript/src/styles/_layout.scss
@@ -0,0 +1,9 @@
+.cms-south-actions {
+ height: 53px;
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ border-top: 1px solid #ddd;
+ background-color: #f6f7f8;
+ padding: 8px 15px;
+}
diff --git a/admin/javascript/src/styles/main.scss b/admin/javascript/src/styles/main.scss
index 5f9ec8e8c..83b64cfce 100644
--- a/admin/javascript/src/styles/main.scss
+++ b/admin/javascript/src/styles/main.scss
@@ -1,13 +1,13 @@
-/** -----------------------------
- * Sections
- * ------------------------------ */
-@import "../sections/campaign-admin/styles";
-
-/** -----------------------------
- * components
- * ------------------------------ */
+// Components
@import "../components/grid-field/styles";
@import "../components/north-header/styles";
@import "../components/north-header-breadcrumbs/styles";
@import "../components/form-action/styles";
@import "../components/hidden-field/styles";
+@import "../components/label/styles";
+
+// Structural
+@import "layout";
+
+// Sections
+@import "../sections/campaign-admin/styles";
diff --git a/admin/javascript/src/tests/silverstripe-backend-test.js b/admin/javascript/src/tests/silverstripe-backend-test.js
index 918a7b1ee..a5322f919 100644
--- a/admin/javascript/src/tests/silverstripe-backend-test.js
+++ b/admin/javascript/src/tests/silverstripe-backend-test.js
@@ -32,11 +32,15 @@ describe('SilverStripeBackend', () => {
it('should send a GET request to an endpoint', () => {
backend.get('http://example.com');
- expect(backend.fetch).toBeCalled();
- expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com');
- expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({
- method: 'get'
- }));
+
+ expect(backend.fetch).toBeCalledWith(
+ 'http://example.com',
+ {
+ method: 'get',
+ credentials: 'same-origin',
+ headers: {},
+ }
+ );
});
});
@@ -51,12 +55,17 @@ describe('SilverStripeBackend', () => {
backend.post('http://example.com', postData);
- expect(backend.fetch).toBeCalled();
- expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com');
- expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({
- method: 'post',
- body: postData
- }));
+ expect(backend.fetch).toBeCalledWith(
+ 'http://example.com',
+ {
+ method: 'post',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: postData,
+ }
+ );
});
});
@@ -71,12 +80,15 @@ describe('SilverStripeBackend', () => {
backend.put('http://example.com', putData);
- expect(backend.fetch).toBeCalled();
- expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com');
- expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({
- method: 'put',
- body: putData
- }));
+ expect(backend.fetch).toBeCalledWith(
+ 'http://example.com',
+ {
+ method: 'put',
+ credentials: 'same-origin',
+ headers: {},
+ body: putData,
+ }
+ );
});
});
@@ -91,12 +103,15 @@ describe('SilverStripeBackend', () => {
backend.delete('http://example.com', deleteData);
- expect(backend.fetch).toBeCalled();
- expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com');
- expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({
- method: 'delete',
- body: deleteData
- }));
+ expect(backend.fetch).toBeCalledWith(
+ 'http://example.com',
+ {
+ method: 'delete',
+ credentials: 'same-origin',
+ headers: {},
+ body: deleteData,
+ }
+ );
});
});
@@ -134,7 +149,7 @@ describe('SilverStripeBackend', () => {
});
});
- pit('should pass a JSON payload', () => {
+ it('should pass a JSON payload', () => {
const mock = getBackendMock({
text: () => Promise.resolve('{"status":"ok","message":"happy"}'),
headers: new Headers({
@@ -146,7 +161,7 @@ describe('SilverStripeBackend', () => {
method: 'post',
payloadFormat: 'json',
responseFormat: 'json',
- });
+ });
const promise = endpoint({ id: 1, values: { a: 'aye', b: 'bee' } });
expect(mock.post.mock.calls[0][0]).toEqual('http://example.org');
diff --git a/gulpfile.js b/gulpfile.js
index b065cfd21..6d1a0a76e 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -459,13 +459,12 @@ gulp.task('css', ['compile:css'], () => {
* Watches for changes if --development flag is given
*/
gulp.task('compile:css', () => {
- const outputStyle = isDev ? 'expanded' : 'compressed';
const tasks = rootCompileFolders.map((folder) => { // eslint-disable-line
return gulp.src(`${folder}/scss/**/*.scss`)
.pipe(sourcemaps.init())
.pipe(
sass({
- outputStyle,
+ outputStyle: 'compressed',
importer: (url, prev, done) => {
if (url.match(/^compass\//)) {
done({ file: 'scss/_compasscompat.scss' });
diff --git a/model/versioning/ChangeSet.php b/model/versioning/ChangeSet.php
index 953561e29..a62e01203 100644
--- a/model/versioning/ChangeSet.php
+++ b/model/versioning/ChangeSet.php
@@ -90,7 +90,7 @@ class ChangeSet extends DataObject {
);
}
if(!$this->canPublish()) {
- throw new Exception("The current member does not have permission to publish this ChangeSet.");
+ throw new LogicException("The current member does not have permission to publish this ChangeSet.");
}
DB::get_conn()->withTransaction(function(){