88 |
--------------------------------------------------------------------------------
/actions.php:
--------------------------------------------------------------------------------
1 | on('collections.find.before', function ($name, &$options) use ($app) {
11 |
12 | // Get the collection.
13 | $collection = $this->module('collections')->collection($name);
14 | // Exclude on unpublished state.
15 | foreach ($collection['fields'] as $field) {
16 | if ($field['type'] === 'moderation') {
17 | $field_name = $field_name_lang = $field['name'];
18 | if ($field['localize'] && $lang = $app->param("lang", false)) {
19 | $field_name_lang .= "_{$lang}";
20 | }
21 |
22 | $options['filter']['$and'][] = [$field_name_lang => ['$exists' => TRUE]];
23 | $options['filter']['$and'][] = [$field_name_lang => ['$ne' => 'Unpublished']];
24 | break;
25 | }
26 | }
27 |
28 | if (!isset($field_name)) {
29 | return;
30 | }
31 |
32 | // If we are using filter/fields we need to use original field name.
33 | if (!empty($options['fields'])) {
34 | $options['fields'][$field_name] = 1;
35 | }
36 |
37 | $app->trigger("moderation.find.before", [$name, &$options]);
38 | });
39 |
40 | /**
41 | * Iterate over the collection entries.
42 | *
43 | * For the draft ones check if we have a previous published revision.
44 | */
45 | $app->on('collections.find.after', function ($name, &$entries) use ($app) {
46 | $token = $app->param('previewToken', FALSE);
47 | // If we have a valid previewToken don't need to go further.
48 | if ($token && $app->module('moderation')->validateToken($token)) {
49 | return;
50 | }
51 |
52 | // Get the moderation field.
53 | $field = $app->module('moderation')->getModerationField($name);
54 |
55 | if (!$field) {
56 | return;
57 | }
58 |
59 | $lang = $app->param('lang', FALSE);
60 | $ignoreDefaultFallback = $app->param('ignoreDefaultFallback', FALSE);
61 | if ($ignoreDefaultFallback = $this->param('ignoreDefaultFallback', false)) {
62 | $ignoreDefaultFallback = \in_array($ignoreDefaultFallback, ['1', '0']) ? \boolval($ignoreDefaultFallback) : $ignoreDefaultFallback;
63 | }
64 | $moderation_field = $field['name'];
65 | $localize = $field['localize'] ?? FALSE;
66 | $populate = $app->param('populate', 1);
67 |
68 | foreach ($entries as $idx => $entry) {
69 | if (!isset($entry[$moderation_field])) {
70 | continue;
71 | }
72 |
73 | // If Draft ensure we retrieve the latest published revision.
74 | // Please note that the entry being checked has already been thru lang filtering.
75 | if ($entry[$moderation_field] == 'Draft') {
76 | $revisions = $app->helper('revisions')->getList($entry['_id']);
77 |
78 | if ($lang && $localize) {
79 | $moderation_field .= "_{$lang}";
80 | }
81 | // However, this has not been filtered:
82 | $published = $app->module('moderation')->getLastPublished($entry['_id'], $moderation_field, $revisions);
83 |
84 | if ($published) {
85 | $published = $app->module('moderation')->removeLangSuffix($name, $published, $lang, $ignoreDefaultFallback);
86 | $published = array_merge($entry, array_intersect_key($published, $entry));
87 | $published = [$published];
88 | $populated = cockpit_populate_collection($published, $populate);
89 | $published = current($populated);
90 | $entries[$idx] = $published;
91 | }
92 | else {
93 | unset($entries[$idx]);
94 | }
95 | }
96 | }
97 | // Rebuild array indices.
98 | $entries = array_values($entries);
99 | });
100 |
--------------------------------------------------------------------------------
/bootstrap.php:
--------------------------------------------------------------------------------
1 | module('moderation')->extend([
17 |
18 | 'getLastPublished' => function ($id, $moderation_field, array $revisions = []) {
19 | $revisons = array_reverse($revisions);
20 |
21 | foreach ($revisions as $revision) {
22 |
23 | // If we have an Unpublished version before draft entry is ignored.
24 | if ($revision['data'][$moderation_field] === 'Unpublished') {
25 | return FALSE;
26 | }
27 |
28 | // If we have at least one Published revision.
29 | // And we don't have an unpublished one before we return the entry.
30 | if ($revision['data'][$moderation_field] === 'Published') {
31 | return $revision['data'];
32 | }
33 | }
34 |
35 | // In all other cases (no revisions, only drafts we ignore the entry).
36 | return FALSE;
37 | },
38 |
39 | 'validateToken' => function($token) {
40 | $keys = $this->app->module('cockpit')->loadApiKeys();
41 | if (!empty($keys['moderation']) && $keys['moderation'] === $token) {
42 | return TRUE;
43 | }
44 | return FALSE;
45 | },
46 |
47 | 'getModerationField' => function($name) {
48 | $collection = $this->app->module('collections')->collection($name);
49 | if ($collection && !empty($collection['fields'])) {
50 | foreach ($collection['fields'] as $field) {
51 | if ($field['type'] === 'moderation') {
52 | return $field;
53 | }
54 | }
55 | }
56 | return FALSE;
57 | },
58 |
59 | 'saveSettings' => function($settings) {
60 | $keys = $this->app->module('cockpit')->loadApiKeys();
61 | $keys['moderation'] = $settings['key'];
62 |
63 | return ['success' => $this->app->module('cockpit')->saveApiKeys($keys)];
64 | },
65 |
66 | 'removeLangSuffix' => function($name, $entry, $lang, $ignoreDefaultFallback) {
67 | if ($lang) {
68 |
69 | $collection = $this->app->module('collections')->collection($name);
70 |
71 | foreach ($collection['fields'] as $field) {
72 |
73 | if ($field['localize']) {
74 | $fieldName = $field['name'];
75 | $suffixedFieldName = $fieldName."_$lang";
76 |
77 | if (
78 | isset($entry[$suffixedFieldName]) &&
79 | $entry[$suffixedFieldName] !== ''
80 | ) {
81 | $entry[$fieldName] = $entry[$suffixedFieldName];
82 |
83 | if (isset($entry["{$suffixedFieldName}_slug"]) && $entry["{$suffixedFieldName}_slug"] !== '') {
84 | $entry["{$fieldName}_slug"] = $entry["{$suffixedFieldName}_slug"];
85 | }
86 | } elseif (
87 | $ignoreDefaultFallback === true ||
88 | (
89 | is_array($ignoreDefaultFallback) &&
90 | in_array($fieldName, $ignoreDefaultFallback)
91 | )
92 | ) {
93 | $entry[$fieldName] = null;
94 | }
95 | }
96 | }
97 | }
98 | return $entry;
99 | },
100 |
101 | 'setSchedule' => function(array $data) {
102 | $id = $data['id'];
103 | $lang = $data['lang'] ?? "";
104 |
105 | $user = $this->app->module('cockpit')->getUser();
106 |
107 | $existing = $this->app->storage->findOne('moderation/schedule', ['_oid' => $id, 'lang' => $lang]);
108 |
109 | $entry = [
110 | '_oid' => trim($id),
111 | 'schedule' => $data['schedule'],
112 | '_field' => $data['field'],
113 | '_collection' => $data['collection'],
114 | '_lang' => trim($data['lang']),
115 | '_creator' => $user['_id'] ?? NULL,
116 | '_modified' => time()
117 | ];
118 |
119 | if ($existing) {
120 | $entry['_id'] = $existing['_id'];
121 | $this->app->storage->save('moderation/schedule', $entry);
122 | }
123 | else {
124 | $this->app->storage->insert('moderation/schedule', $entry);
125 | }
126 |
127 | return $entry;
128 | },
129 |
130 | 'getSchedule' => function(array $data) {
131 | $filter = ['_oid' => $data['id'], '_lang' => $data['lang']];
132 | return $this->app->storage->findOne('moderation/schedule', $filter);
133 | },
134 |
135 | 'removeSchedule' => function(array $data) {
136 | $id = $data['id'];
137 | $lang = $data['lang'];
138 | return $this->app->storage->remove('moderation/schedule', ['_oid' => $id, '_lang' => $lang]);
139 | },
140 |
141 | 'getLastPublishedStatus' => function (array $params) {
142 | $revisions = $this->app->helper('revisions')->getList($params['id']);
143 | if ($revisions) {
144 | $moderationField = $this->getModerationField($params['collection']);
145 | if ($moderationField) {
146 | $field = $moderationField['name'];
147 | if ($moderationField['localize'] && $params['lang']) {
148 | $field .= "_{$params['lang']}";
149 | }
150 | foreach ($revisions as $revision) {
151 | if ($revision['data'][$field] != "Draft") {
152 | return $revision['data'][$field];
153 | }
154 | }
155 | }
156 | }
157 | return "Unpublished";
158 | },
159 |
160 | ]);
161 |
162 | // Incldude admin.
163 | if (COCKPIT_ADMIN && !COCKPIT_API_REQUEST) {
164 | include_once __DIR__ . '/admin.php';
165 | }
166 |
167 | // Include actions.
168 | if (COCKPIT_API_REQUEST) {
169 | include_once __DIR__ . '/actions.php';
170 | include_once __DIR__ . '/cockpitql.php';
171 | $this->on('cockpit.rest.init', function ($routes) {
172 | $routes['schedule'] = 'Moderation\\Controller\\RestApi';
173 | });
174 | }
175 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Cockpit CMS Moderation Add-on
2 |
3 | This addon extends Cockpit CMS core functionality by introducing the possibility to have moderation of collections. It means that its possible to create collections with a status (Unpublished, Draft or Published) affecting the way that entries are retrieved:
4 |
5 | - **Unpublished** - Any collection entry in unpublished state will be filtered out.
6 | - **Draft** - Any collection entry in Draft that doesn't have a previous revision in published status will be also filtered out. If there is a previous revision with published status the revision will be returned instead. However on a scenario that we have a published > unpublished > draft sequence no entry will be returned.
7 | - **Published** - They are always returned.
8 |
9 | ## Installation
10 |
11 | ### Manual
12 |
13 | Download [latest release](https://github.com/pauloamgomes/CockpitCMS-Moderation) and extract to `COCKPIT_PATH/addons/Moderation` directory
14 |
15 | ### Git
16 |
17 | ```sh
18 | git clone https://github.com/pauloamgomes/CockpitCMS-Moderation.git ./addons/Moderation
19 | ```
20 |
21 | ### Cockpit CLI
22 |
23 | ```sh
24 | php ./cp install/addon --name Moderation --url https://github.com/pauloamgomes/CockpitCMS-Moderation.git
25 | ```
26 |
27 | ### Composer
28 |
29 | 1. Make sure path to cockpit addons is defined in your projects' _composer.json_ file:
30 |
31 | ```json
32 | {
33 | "name": "MY_PROJECT",
34 | "extra": {
35 | "installer-paths": {
36 | "cockpit/addons/{$name}": ["type:cockpit-module"]
37 | }
38 | }
39 | }
40 | ```
41 |
42 | 2. In your project root run:
43 |
44 | ```sh
45 | composer require pauloamgomes/cockpitcms-moderation
46 | ```
47 |
48 | ---
49 |
50 | ## Configuration
51 |
52 | To use the main functionality of the addon no extra configuration is required.
53 | To use the preview mode (Draft entries will be also returned) is required to configure an API key
54 | on the addon settings page. You can use the moderation api key in your requests like:
55 |
56 | ```
57 | http://your-cockpit-site/api/collections/get/?token=&previewToken=
58 | ```
59 |
60 | Additional addon settings are available at: http://your-cockpit-site/settings/moderation
61 |
62 | ### Permissions
63 |
64 | The following permissions (ACL's) are defined:
65 |
66 | * **manage** - access to all moderation states and addons settings page
67 | * **publish** - can change entries to Published state
68 | * **unpublish** - can change entries to Unpublished state
69 |
70 | Example of configuration for 3 groups of editors where `editor` can only create/update entries to `Draft` state, `approver` can create/update `Draft` and move to `Published` state, and finally `manager` can publish and unpublish entries.
71 |
72 | ```yaml
73 | groups:
74 | editor:
75 | approver:
76 | moderation:
77 | publish: true
78 | manager:
79 | moderation:
80 | publish: true
81 | unpublish: true
82 | ```
83 |
84 | By default admins have super access, any other groups that have not the permissions specificed in the configuration, can only create/edit
85 | entries only in Draft mode.
86 |
87 | ### Scheduling
88 |
89 | Scheduling is supported since version v1.3, to enable scheduling add a new entry in the config.yml like below:
90 |
91 | ```yaml
92 | moderation:
93 | schedule:
94 | - page
95 | - article
96 | ```
97 | above configuration enables scheduling on collections page and article, if you want to enable for all collections by default use:
98 |
99 | ```yaml
100 | moderation:
101 | schedule: *
102 | ```
103 |
104 | If using scheduling, its required to provide a `schedule` permission for non admin roles:
105 |
106 | ```yaml
107 | groups:
108 | editor:
109 | moderation:
110 | schedule: true
111 | ```
112 | The Scheduling just defines in Cockpit the operation and date/time for a specific collection entry, to run the scheduling is required
113 | to invoke a REST API endpoint, that endpoing can be invoked using cron or other mechanism. By default when executed, it will run against
114 | all scheduled entries between a range of time in the past (default of 10m, but can be changed in the request) and current date.
115 |
116 | The following example illustrates how that works:
117 |
118 | 
119 |
120 | In the above example, the schedule operation was executed at 22:55 and detected 2 operations to run at 22:52 (in the range of 10m) performing
121 | the defined moderation status change.
122 |
123 |
124 | ## Usage
125 |
126 | 1. Add a new field to your collection of type Moderation.
127 | You can name whatever you want for your moderation field, e.g. status, moderation, etc.. But you need to keep in mind
128 | if you change the field later you may need to manually update all existing collection entries.
129 | 2. When creating a new collection entry the moderation value will be `Draft`, can be changed to `Unpublished` or `Published`.
130 | 3. When editing an existing collection the moderation value will change automatically to `Draft`.
131 | 4. When retrieving a collection entry (or list of entries) only entries with moderation value of `Published` or `Draft` (if there is a `Published` revision before the `Draft` one) will be returned.
132 |
133 | ### Options
134 | The moderation field supports the following options:
135 |
136 | * `autodraft` (default: _true_) If set to _false_, entries that are being edited wont be set to `Draft`
137 |
138 | ### Localization
139 |
140 | The moderation fields supports localization as any other Cockpit field, just enable the localization option in the field configuration.
141 | When editing an entry, the moderation status will be saved according to the selected option on each language.
142 |
143 | If Scheduling is enabled and the moderation field is set to be localized, the scheduling will be defined for each language.
144 |
145 | ## Demo
146 |
147 | [](http://www.youtube.com/watch?v=TdhoThghRRY "Screencast")
148 |
149 | - [Demo v1.3](http://www.youtube.com/watch?v=TdhoThghRRY "Screencast v1.3")
150 | - [Demo v1.0](https://youtu.be/LywGxJqUJkg "Screencast v1.0")
151 |
152 |
153 | ## Copyright and license
154 |
155 | Copyright since 2018 pauloamgomes under the MIT license.
156 |
--------------------------------------------------------------------------------
/views/partials/entry-aside.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
@lang('Set Schedule')
4 |
5 |
6 | @lang('Configure the scheduling moderation status and the date/time.')
7 |