content_moderation.module 15.9 KB
Newer Older
1 2 3 4 5 6 7 8 9 10
<?php

/**
 * @file
 * Contains content_moderation.module.
 */

use Drupal\content_moderation\EntityOperations;
use Drupal\content_moderation\EntityTypeInfo;
use Drupal\content_moderation\ContentPreprocess;
11 12
use Drupal\content_moderation\Plugin\Action\ModerationOptOutPublish;
use Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublish;
13
use Drupal\Core\Access\AccessResult;
14
use Drupal\Core\Entity\Display\EntityFormDisplayInterface;
15 16
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\EntityInterface;
17
use Drupal\Core\Entity\EntityPublishedInterface;
18
use Drupal\Core\Entity\EntityTypeInterface;
19 20
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
21 22 23
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
24
use Drupal\Core\Url;
25 26 27
use Drupal\views\Plugin\views\filter\Broken;
use Drupal\views\ViewExecutable;
use Drupal\views\Views;
28
use Drupal\workflows\WorkflowInterface;
29 30
use Drupal\Core\Action\Plugin\Action\PublishAction;
use Drupal\Core\Action\Plugin\Action\UnpublishAction;
31
use Drupal\workflows\Entity\Workflow;
32
use Drupal\views\Entity\View;
33 34 35 36 37 38 39 40 41 42

/**
 * Implements hook_help().
 */
function content_moderation_help($route_name, RouteMatchInterface $route_match) {
  switch ($route_name) {
    // Main module help for the content_moderation module.
    case 'help.page.content_moderation':
      $output = '';
      $output .= '<h3>' . t('About') . '</h3>';
43
      $output .= '<p>' . t('The Content Moderation module allows you to expand on Drupal\'s "unpublished" and "published" states for content. It allows you to have a published version that is live, but have a separate working copy that is undergoing review before it is published. This is achieved by using <a href=":workflows">Workflows</a> to apply different states and transitions to entities as needed. For more information, see the <a href=":content_moderation">online documentation for the Content Moderation module</a>.', [':content_moderation' => 'https://www.drupal.org/documentation/modules/content_moderation', ':workflows' => Url::fromRoute('help.page', ['name' => 'workflows'])->toString()]) . '</p>';
44 45
      $output .= '<h3>' . t('Uses') . '</h3>';
      $output .= '<dl>';
46 47 48 49 50 51 52 53 54
      $output .= '<dt>' . t('Applying workflows') . '</dt>';
      $output .= '<dd>' . t('Content Moderation allows you to apply <a href=":workflows">Workflows</a> to content, custom blocks, and other <a href=":field_help" title="Field module help, with background on content entities">content entities</a>, to provide more fine-grained publishing options. For example, a Basic page might have states such as Draft and Published, with allowed transitions such as Draft to Published (making the current revision "live"), and Published to Draft (making a new draft revision of published content).', [':workflows' => Url::fromRoute('help.page', ['name' => 'workflows'])->toString(), ':field_help' => Url::fromRoute('help.page', ['name' => 'field'])->toString()]) . '</dd>';
      if (\Drupal::moduleHandler()->moduleExists('views')) {
        $moderated_content_view = View::load('moderated_content');
        if (isset($moderated_content_view) && $moderated_content_view->status() === TRUE) {
          $output .= '<dt>' . t('Moderating content') . '</dt>';
          $output .= '<dd>' . t('You can view a list of content awaiting moderation on the <a href=":moderated">moderated content page</a>. This will show any content in an unpublished state, such as Draft or Archived, to help surface content that requires more work from content editors.', [':moderated' => Url::fromRoute('view.moderated_content.moderated_content')->toString()]) . '</dd>';
        }
      }
55
      $output .= '<dt>' . t('Configure Content Moderation permissions') . '</dt>';
56
      $output .= '<dd>' . t('Each transition is exposed as a permission. If a user has the permission for a transition, they can use the transition to change the state of the content item, from Draft to Published.') . '</dd>';
57
      $output .= '</dl>';
58 59 60 61 62 63 64 65
      return $output;
  }
}

/**
 * Implements hook_entity_base_field_info().
 */
function content_moderation_entity_base_field_info(EntityTypeInterface $entity_type) {
66 67 68
  return \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityTypeInfo::class)
    ->entityBaseFieldInfo($entity_type);
69 70
}

71 72 73 74 75 76 77 78 79 80
/**
 * Implements hook_entity_bundle_field_info().
 */
function content_moderation_entity_bundle_field_info(EntityTypeInterface $entity_type, $bundle, array $base_field_definitions) {
  if (isset($base_field_definitions['moderation_state'])) {
    // Add the target bundle to the moderation state field. Since each bundle
    // can be attached to a different moderation workflow, adding this
    // information to the field definition allows the associated workflow to be
    // derived where a field definition is present.
    $base_field_definitions['moderation_state']->setTargetBundle($bundle);
81 82 83
    return [
      'moderation_state' => $base_field_definitions['moderation_state'],
    ];
84 85 86
  }
}

87 88 89 90
/**
 * Implements hook_entity_type_alter().
 */
function content_moderation_entity_type_alter(array &$entity_types) {
91 92 93
  \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityTypeInfo::class)
    ->entityTypeAlter($entity_types);
94 95 96 97 98 99
}

/**
 * Implements hook_entity_presave().
 */
function content_moderation_entity_presave(EntityInterface $entity) {
100 101 102
  return \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityOperations::class)
    ->entityPresave($entity);
103 104 105 106 107 108
}

/**
 * Implements hook_entity_insert().
 */
function content_moderation_entity_insert(EntityInterface $entity) {
109 110 111
  return \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityOperations::class)
    ->entityInsert($entity);
112 113 114 115 116 117
}

/**
 * Implements hook_entity_update().
 */
function content_moderation_entity_update(EntityInterface $entity) {
118 119 120
  return \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityOperations::class)
    ->entityUpdate($entity);
121 122
}

123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
/**
 * Implements hook_entity_delete().
 */
function content_moderation_entity_delete(EntityInterface $entity) {
  return \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityOperations::class)
    ->entityDelete($entity);
}

/**
 * Implements hook_entity_revision_delete().
 */
function content_moderation_entity_revision_delete(EntityInterface $entity) {
  return \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityOperations::class)
    ->entityRevisionDelete($entity);
}

/**
 * Implements hook_entity_translation_delete().
 */
function content_moderation_entity_translation_delete(EntityInterface $translation) {
  return \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityOperations::class)
    ->entityTranslationDelete($translation);
}

150 151 152 153 154 155 156 157 158
/**
 * Implements hook_entity_prepare_form().
 */
function content_moderation_entity_prepare_form(EntityInterface $entity, $operation, FormStateInterface $form_state) {
  \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityTypeInfo::class)
    ->entityPrepareForm($entity, $operation, $form_state);
}

159 160 161 162
/**
 * Implements hook_form_alter().
 */
function content_moderation_form_alter(&$form, FormStateInterface $form_state, $form_id) {
163 164 165
  \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityTypeInfo::class)
    ->formAlter($form, $form_state, $form_id);
166 167 168 169 170 171
}

/**
 * Implements hook_preprocess_HOOK().
 */
function content_moderation_preprocess_node(&$variables) {
172 173 174
  \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(ContentPreprocess::class)
    ->preprocessNode($variables);
175 176 177 178 179 180
}

/**
 * Implements hook_entity_extra_field_info().
 */
function content_moderation_entity_extra_field_info() {
181 182 183
  return \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityTypeInfo::class)
    ->entityExtraFieldInfo();
184 185 186 187 188 189
}

/**
 * Implements hook_entity_view().
 */
function content_moderation_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
190 191 192
  \Drupal::service('class_resolver')
    ->getInstanceFromDefinition(EntityOperations::class)
    ->entityView($build, $entity, $display, $view_mode);
193 194
}

195 196 197 198 199 200 201 202 203 204 205
/**
 * Implements hook_layout_builder_overrides_entity_form_display_alter().
 */
function content_moderation_layout_builder_overrides_entity_form_display_alter(EntityFormDisplayInterface $display) {
  $display->setComponent('moderation_state', [
    'type' => 'moderation_state_default',
    'weight' => -900,
    'settings' => [],
  ]);
}

206
/**
207
 * Implements hook_entity_access().
208
 *
209 210 211
 * Entities should be viewable if unpublished and the user has the appropriate
 * permission. This permission is therefore effectively mandatory for any user
 * that wants to moderate things.
212
 */
213
function content_moderation_entity_access(EntityInterface $entity, $operation, AccountInterface $account) {
214 215 216 217 218
  /** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */
  $moderation_info = Drupal::service('content_moderation.moderation_information');

  $access_result = NULL;
  if ($operation === 'view') {
219
    $access_result = (($entity instanceof EntityPublishedInterface) && !$entity->isPublished())
220 221 222
      ? AccessResult::allowedIfHasPermission($account, 'view any unpublished content')
      : AccessResult::neutral();

223
    $access_result->addCacheableDependency($entity);
224
  }
225
  elseif ($operation === 'update' && $moderation_info->isModeratedEntity($entity) && $entity->moderation_state) {
226 227 228
    /** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */
    $transition_validation = \Drupal::service('content_moderation.state_transition_validation');

229
    $valid_transition_targets = $transition_validation->getValidTransitions($entity, $account);
230
    $access_result = $valid_transition_targets ? AccessResult::neutral() : AccessResult::forbidden('No valid transitions exist for given account.');
231

232
    $access_result->addCacheableDependency($entity);
233
    $access_result->addCacheableDependency($account);
234
    $workflow = $moderation_info->getWorkflowForEntity($entity);
235
    $access_result->addCacheableDependency($workflow);
236 237 238 239 240
    foreach ($valid_transition_targets as $valid_transition_target) {
      $access_result->addCacheableDependency($valid_transition_target);
    }
  }

241 242 243 244 245 246 247 248 249
  // Do not allow users to delete the state that is configured as the default
  // state for the workflow.
  if ($entity instanceof WorkflowInterface) {
    $configuration = $entity->getTypePlugin()->getConfiguration();
    if (!empty($configuration['default_moderation_state']) && $operation === sprintf('delete-state:%s', $configuration['default_moderation_state'])) {
      return AccessResult::forbidden()->addCacheableDependency($entity);
    }
  }

250 251 252
  return $access_result;
}

253 254 255 256 257 258 259 260 261 262 263 264 265 266
/**
 * Implements hook_entity_field_access().
 */
function content_moderation_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, FieldItemListInterface $items = NULL) {
  if ($items && $operation === 'edit') {
    /** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */
    $moderation_info = Drupal::service('content_moderation.moderation_information');

    $entity_type = \Drupal::entityTypeManager()->getDefinition($field_definition->getTargetEntityTypeId());

    $entity = $items->getEntity();

    // Deny edit access to the published field if the entity is being moderated.
    if ($entity_type->hasKey('published') && $moderation_info->isModeratedEntity($entity) && $entity->moderation_state && $field_definition->getName() == $entity_type->getKey('published')) {
267
      return AccessResult::forbidden('Cannot edit the published field of moderated entities.');
268 269 270 271 272 273
    }
  }

  return AccessResult::neutral();
}

274 275 276 277 278 279 280 281 282 283 284 285 286 287
/**
 * Implements hook_theme().
 */
function content_moderation_theme() {
  return ['entity_moderation_form' => ['render element' => 'form']];
}

/**
 * Implements hook_action_info_alter().
 */
function content_moderation_action_info_alter(&$definitions) {

  // The publish/unpublish actions are not valid on moderated entities. So swap
  // their implementations out for alternates that will become a no-op on a
288
  // moderated entity. If another module has already swapped out those classes,
289
  // though, we'll be polite and do nothing.
290 291 292 293 294 295 296
  foreach ($definitions as &$definition) {
    if ($definition['id'] === 'entity:publish_action' && $definition['class'] == PublishAction::class) {
      $definition['class'] = ModerationOptOutPublish::class;
    }
    if ($definition['id'] === 'entity:unpublish_action' && $definition['class'] == UnpublishAction::class) {
      $definition['class'] = ModerationOptOutUnpublish::class;
    }
297 298
  }
}
299 300 301 302 303

/**
 * Implements hook_entity_bundle_info_alter().
 */
function content_moderation_entity_bundle_info_alter(&$bundles) {
304
  $translatable = FALSE;
305 306 307 308 309 310 311 312
  /** @var \Drupal\workflows\WorkflowInterface $workflow */
  foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
    /** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $plugin */
    $plugin = $workflow->getTypePlugin();
    foreach ($plugin->getEntityTypes() as $entity_type_id) {
      foreach ($plugin->getBundlesForEntityType($entity_type_id) as $bundle_id) {
        if (isset($bundles[$entity_type_id][$bundle_id])) {
          $bundles[$entity_type_id][$bundle_id]['workflow'] = $workflow->id();
313 314 315 316 317 318 319
          // If we have even one moderation-enabled translatable bundle, we need
          // to make the moderation state bundle translatable as well, to enable
          // the revision translation merge logic also for content moderation
          // state revisions.
          if (!empty($bundles[$entity_type_id][$bundle_id]['translatable'])) {
            $translatable = TRUE;
          }
320 321 322 323
        }
      }
    }
  }
324
  $bundles['content_moderation_state']['content_moderation_state']['translatable'] = $translatable;
325 326
}

327 328 329 330 331 332 333 334 335 336 337 338 339 340
/**
 * Implements hook_entity_bundle_delete().
 */
function content_moderation_entity_bundle_delete($entity_type_id, $bundle_id) {
  // Remove non-configuration based bundles from content moderation based
  // workflows when they are removed.
  foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) {
    if ($workflow->getTypePlugin()->appliesToEntityTypeAndBundle($entity_type_id, $bundle_id)) {
      $workflow->getTypePlugin()->removeEntityTypeAndBundle($entity_type_id, $bundle_id);
      $workflow->save();
    }
  }
}

341 342 343 344 345 346 347 348 349
/**
 * Implements hook_ENTITY_TYPE_insert().
 */
function content_moderation_workflow_insert(WorkflowInterface $entity) {
  // Clear bundle cache so workflow gets added or removed from the bundle
  // information.
  \Drupal::service('entity_type.bundle.info')->clearCachedBundles();
  // Clear field cache so extra field is added or removed.
  \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
350 351 352 353
  // Clear the views data cache so the extra field is available in views.
  if (\Drupal::moduleHandler()->moduleExists('views')) {
    Views::viewsData()->clear();
  }
354 355 356 357 358 359
}

/**
 * Implements hook_ENTITY_TYPE_update().
 */
function content_moderation_workflow_update(WorkflowInterface $entity) {
360 361 362 363 364
  // Clear bundle cache so workflow gets added or removed from the bundle
  // information.
  \Drupal::service('entity_type.bundle.info')->clearCachedBundles();
  // Clear field cache so extra field is added or removed.
  \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382
  // Clear the views data cache so the extra field is available in views.
  if (\Drupal::moduleHandler()->moduleExists('views')) {
    Views::viewsData()->clear();
  }
}

/**
 * Implements hook_views_post_execute().
 */
function content_moderation_views_post_execute(ViewExecutable $view) {
  // @todo, remove this once broken handlers in views configuration result in
  // a view no longer returning results. https://www.drupal.org/node/2907954.
  foreach ($view->filter as $id => $filter) {
    if (strpos($id, 'moderation_state') === 0 && $filter instanceof Broken) {
      $view->result = [];
      break;
    }
  }
383
}