You are here

SchedulerManager.php in Scheduler 8

Same filename and directory in other branches
  1. 2.x src/SchedulerManager.php

Namespace

Drupal\scheduler

File

src/SchedulerManager.php
View source
<?php

namespace Drupal\scheduler;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher;
use Drupal\Component\EventDispatcher\Event;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Link;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\node\NodeInterface;
use Drupal\scheduler\Exception\SchedulerMissingDateException;
use Drupal\scheduler\Exception\SchedulerNodeTypeNotEnabledException;
use Psr\Log\LoggerInterface;

/**
 * Defines a scheduler manager.
 */
class SchedulerManager {
  use StringTranslationTrait;

  /**
   * Date formatter service object.
   *
   * @var \Drupal\Core\Datetime\DateFormatterInterface
   */
  protected $dateFormatter;

  /**
   * Scheduler Logger service object.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * Module handler service object.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * Entity Type Manager service object.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Config Factory service object.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * The event dispatcher.
   *
   * @var \Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher
   */
  protected $eventDispatcher;

  /**
   * The time service.
   *
   * @var \Drupal\Component\Datetime\TimeInterface
   */
  protected $time;

  /**
   * Constructs a SchedulerManager object.
   */
  public function __construct(DateFormatterInterface $dateFormatter, LoggerInterface $logger, ModuleHandlerInterface $moduleHandler, EntityTypeManagerInterface $entityTypeManager, ConfigFactoryInterface $configFactory, ContainerAwareEventDispatcher $eventDispatcher, TimeInterface $time) {
    $this->dateFormatter = $dateFormatter;
    $this->logger = $logger;
    $this->moduleHandler = $moduleHandler;
    $this->entityTypeManager = $entityTypeManager;
    $this->configFactory = $configFactory;
    $this->eventDispatcher = $eventDispatcher;
    $this->time = $time;
  }

  /**
   * Dispatch a Scheduler event.
   *
   * All Scheduler events should be dispatched through this common function.
   *
   * Drupal 8.8 and 8.9 use Symfony 3.4 and from Drupal 9.0 the Symfony version
   * is 4.4. Starting with Symfony 4.3 the signature of the event dispatcher
   * function has the parameters swapped round, the event object is first,
   * followed by the event name string. At 9.0 the existing signature has to be
   * used but from 9.1 the parameters must be switched.
   *
   * @param \Drupal\Component\EventDispatcher\Event $event
   *   The event object.
   * @param string $event_name
   *   The text name for the event.
   *
   * @see https://www.drupal.org/project/scheduler/issues/3166688
   */
  public function dispatch(Event $event, string $event_name) {

    // \Symfony\Component\HttpKernel\Kernel::VERSION will give the symfony
    // version. However, testing this does not give the required outcome, we
    // need to test the Drupal core version.
    // @todo Remove the check when Core 9.1 is the lowest supported version.
    if (version_compare(\Drupal::VERSION, '9.1', '>=')) {

      // The new way, with $event first.
      $this->eventDispatcher
        ->dispatch($event, $event_name);
    }
    else {

      // Replicate the existing dispatch signature.
      $this->eventDispatcher
        ->dispatch($event_name, $event);
    }
  }

  /**
   * Publish scheduled nodes.
   *
   * @return bool
   *   TRUE if any node has been published, FALSE otherwise.
   *
   * @throws \Drupal\scheduler\Exception\SchedulerMissingDateException
   * @throws \Drupal\scheduler\Exception\SchedulerNodeTypeNotEnabledException
   */
  public function publish() {
    $result = FALSE;
    $action = 'publish';

    // Select all nodes of the types that are enabled for scheduled publishing
    // and where publish_on is less than or equal to the current time.
    $nids = [];
    $scheduler_enabled_types = array_keys(_scheduler_get_scheduler_enabled_node_types($action));
    if (!empty($scheduler_enabled_types)) {
      $query = $this->entityTypeManager
        ->getStorage('node')
        ->getQuery()
        ->exists('publish_on')
        ->condition('publish_on', $this->time
        ->getRequestTime(), '<=')
        ->condition('type', $scheduler_enabled_types, 'IN')
        ->latestRevision()
        ->sort('publish_on')
        ->sort('nid');

      // Disable access checks for this query.
      // @see https://www.drupal.org/node/2700209
      $query
        ->accessCheck(FALSE);
      $nids = $query
        ->execute();
    }

    // Allow other modules to add to the list of nodes to be published.
    $nids = array_unique(array_merge($nids, $this
      ->nidList($action)));

    // Allow other modules to alter the list of nodes to be published.
    $this->moduleHandler
      ->alter('scheduler_nid_list', $nids, $action);

    // In 8.x the entity translations are all associated with one node id
    // unlike 7.x where each translation was a separate node. This means that
    // the list of node ids returned above may have some translations that need
    // processing now and others that do not.

    /** @var \Drupal\node\NodeInterface[] $nodes */
    $nodes = $this
      ->loadNodes($nids);
    foreach ($nodes as $node_multilingual) {

      // The API calls could return nodes of types which are not enabled for
      // scheduled publishing, so do not process these. This check can be done
      // once, here, as the setting will be the same for all translations.
      if (!$node_multilingual->type->entity
        ->getThirdPartySetting('scheduler', 'publish_enable', $this
        ->setting('default_publish_enable'))) {
        throw new SchedulerNodeTypeNotEnabledException(sprintf("Node %d '%s' will not be published because node type '%s' is not enabled for scheduled publishing", $node_multilingual
          ->id(), $node_multilingual
          ->getTitle(), node_get_type_label($node_multilingual)));
      }
      $languages = $node_multilingual
        ->getTranslationLanguages();
      foreach ($languages as $language) {

        // The object returned by getTranslation() behaves the same as a $node.
        $node = $node_multilingual
          ->getTranslation($language
          ->getId());

        // If the current translation does not have a publish on value, or it is
        // later than the date we are processing then move on to the next.
        $publish_on = $node->publish_on->value;
        if (empty($publish_on) || $publish_on > $this->time
          ->getRequestTime()) {
          continue;
        }

        // Check that other modules allow the action on this node.
        if (!$this
          ->isAllowed($node, $action)) {
          continue;
        }

        // $node->setChangedTime($publish_on) will fail badly if an API call has
        // removed the date. Trap this as an exception here and give a
        // meaningful message.
        // @todo This will now never be thrown due to the empty(publish_on)
        // check above to cater for translations. Remove this exception?
        if (empty($node->publish_on->value)) {
          $field_definitions = $this->entityTypeManager
            ->getFieldDefinitions('node', $node
            ->getType());
          $field = (string) $field_definitions['publish_on']
            ->getLabel();
          throw new SchedulerMissingDateException(sprintf("Node %d '%s' will not be published because field '%s' has no value", $node
            ->id(), $node
            ->getTitle(), $field));
        }

        // Trigger the PRE_PUBLISH event so that modules can react before the
        // node is published.
        $event = new SchedulerEvent($node);
        $this
          ->dispatch($event, SchedulerEvents::PRE_PUBLISH);
        $node = $event
          ->getNode();

        // Update 'changed' timestamp.
        $node
          ->setChangedTime($publish_on);
        $old_creation_date = $node
          ->getCreatedTime();
        $msg_extra = '';

        // If required, set the created date to match published date.
        if ($node->type->entity
          ->getThirdPartySetting('scheduler', 'publish_touch', $this
          ->setting('default_publish_touch')) || $node
          ->getCreatedTime() > $publish_on && $node->type->entity
          ->getThirdPartySetting('scheduler', 'publish_past_date_created', $this
          ->setting('default_publish_past_date_created'))) {
          $node
            ->setCreatedTime($publish_on);
          $msg_extra = $this
            ->t('The previous creation date was @old_creation_date, now updated to match the publishing date.', [
            '@old_creation_date' => $this->dateFormatter
              ->format($old_creation_date, 'short'),
          ]);
        }
        $create_publishing_revision = $node->type->entity
          ->getThirdPartySetting('scheduler', 'publish_revision', $this
          ->setting('default_publish_revision'));
        if ($create_publishing_revision) {
          $node
            ->setNewRevision();

          // Use a core date format to guarantee a time is included.
          $revision_log_message = rtrim($this
            ->t('Published by Scheduler. The scheduled publishing date was @publish_on.', [
            '@publish_on' => $this->dateFormatter
              ->format($publish_on, 'short'),
          ]) . ' ' . $msg_extra);
          $node
            ->setRevisionLogMessage($revision_log_message)
            ->setRevisionCreationTime($this->time
            ->getRequestTime());
        }

        // Unset publish_on so the node will not get rescheduled by subsequent
        // calls to $node->save().
        $node->publish_on->value = NULL;

        // Invoke all implementations of hook_scheduler_publish_action() to
        // allow other modules to do the "publishing" process instead of
        // Scheduler.
        $hook = 'scheduler_publish_action';
        $processed = FALSE;
        $failed = FALSE;
        foreach ($this->moduleHandler
          ->getImplementations($hook) as $module) {
          $function = $module . '_' . $hook;
          $return = $function($node);
          $processed = $processed || $return === 1;
          $failed = $failed || $return === -1;
        }

        // Log the fact that a scheduled publication is about to take place.
        $view_link = $node
          ->toLink($this
          ->t('View node'));
        $node_type = $this->entityTypeManager
          ->getStorage('node_type')
          ->load($node
          ->bundle());
        $node_type_link = $node_type
          ->toLink($this
          ->t('@label settings', [
          '@label' => $node_type
            ->label(),
        ]), 'edit-form');
        $logger_variables = [
          '@type' => $node_type
            ->label(),
          '%title' => $node
            ->getTitle(),
          'link' => $node_type_link
            ->toString() . ' ' . $view_link
            ->toString(),
          '@hook' => 'hook_' . $hook,
        ];
        if ($failed) {

          // At least one hook function returned a failure or exception, so stop
          // processing this node and move on to the next one.
          $this->logger
            ->warning('Publishing failed for %title. Calls to @hook returned a failure code.', $logger_variables);
          continue;
        }
        elseif ($processed) {

          // The node had 'publishing' processed by a module implementing the
          // hook, so no need to do anything more, apart from log this result.
          $this->logger
            ->notice('@type: scheduled processing of %title completed by calls to @hook.', $logger_variables);
        }
        else {

          // None of the above hook calls processed the node and there were no
          // errors detected so set the node to published.
          $this->logger
            ->notice('@type: scheduled publishing of %title.', $logger_variables);
          $node
            ->setPublished();
        }

        // Invoke the event to tell Rules that Scheduler has published the node.
        if ($this->moduleHandler
          ->moduleExists('scheduler_rules_integration')) {
          _scheduler_rules_integration_dispatch_cron_event($node, 'publish');
        }

        // Trigger the PUBLISH event so that modules can react after the node is
        // published.
        $event = new SchedulerEvent($node);
        $this
          ->dispatch($event, SchedulerEvents::PUBLISH);

        // Use the standard actions system to publish and save the node.
        $node = $event
          ->getNode();
        $action_id = 'node_publish_action';
        if ($this->moduleHandler
          ->moduleExists('workbench_moderation_actions')) {

          // workbench_moderation_actions module uses a custom action instead.
          $action_id = 'state_change__node__published';
        }
        $this->entityTypeManager
          ->getStorage('action')
          ->load($action_id)
          ->getPlugin()
          ->execute($node);
        $result = TRUE;
      }
    }
    return $result;
  }

  /**
   * Unpublish scheduled nodes.
   *
   * @return bool
   *   TRUE if any node has been unpublished, FALSE otherwise.
   *
   * @throws \Drupal\scheduler\Exception\SchedulerMissingDateException
   * @throws \Drupal\scheduler\Exception\SchedulerNodeTypeNotEnabledException
   */
  public function unpublish() {
    $result = FALSE;
    $action = 'unpublish';

    // Select all nodes of the types that are enabled for scheduled unpublishing
    // and where unpublish_on is less than or equal to the current time.
    $nids = [];
    $scheduler_enabled_types = array_keys(_scheduler_get_scheduler_enabled_node_types($action));
    if (!empty($scheduler_enabled_types)) {
      $query = $this->entityTypeManager
        ->getStorage('node')
        ->getQuery()
        ->exists('unpublish_on')
        ->condition('unpublish_on', $this->time
        ->getRequestTime(), '<=')
        ->condition('type', $scheduler_enabled_types, 'IN')
        ->latestRevision()
        ->sort('unpublish_on')
        ->sort('nid');

      // Disable access checks for this query.
      // @see https://www.drupal.org/node/2700209
      $query
        ->accessCheck(FALSE);
      $nids = $query
        ->execute();
    }

    // Allow other modules to add to the list of nodes to be unpublished.
    $nids = array_unique(array_merge($nids, $this
      ->nidList($action)));

    // Allow other modules to alter the list of nodes to be unpublished.
    $this->moduleHandler
      ->alter('scheduler_nid_list', $nids, $action);

    /** @var \Drupal\node\NodeInterface[] $nodes */
    $nodes = $this
      ->loadNodes($nids);
    foreach ($nodes as $node_multilingual) {

      // The API calls could return nodes of types which are not enabled for
      // scheduled unpublishing. Do not process these.
      if (!$node_multilingual->type->entity
        ->getThirdPartySetting('scheduler', 'unpublish_enable', $this
        ->setting('default_unpublish_enable'))) {
        throw new SchedulerNodeTypeNotEnabledException(sprintf("Node %d '%s' will not be unpublished because node type '%s' is not enabled for scheduled unpublishing", $node_multilingual
          ->id(), $node_multilingual
          ->getTitle(), node_get_type_label($node_multilingual)));
      }
      $languages = $node_multilingual
        ->getTranslationLanguages();
      foreach ($languages as $language) {

        // The object returned by getTranslation() behaves the same as a $node.
        $node = $node_multilingual
          ->getTranslation($language
          ->getId());

        // If the current translation does not have an unpublish on value, or it
        // is later than the date we are processing then move on to the next.
        $unpublish_on = $node->unpublish_on->value;
        if (empty($unpublish_on) || $unpublish_on > $this->time
          ->getRequestTime()) {
          continue;
        }

        // Do not process the node if it still has a publish_on time which is in
        // the past, as this implies that scheduled publishing has been blocked
        // by one of the hook functions we provide, and is still being blocked
        // now that the unpublishing time has been reached.
        $publish_on = $node->publish_on->value;
        if (!empty($publish_on) && $publish_on <= $this->time
          ->getRequestTime()) {
          continue;
        }

        // Check that other modules allow the action on this node.
        if (!$this
          ->isAllowed($node, $action)) {
          continue;
        }

        // $node->setChangedTime($unpublish_on) will fail badly if an API call
        // has removed the date. Trap this as an exception here and give a
        // meaningful message.
        // @todo This will now never be thrown due to the empty(unpublish_on)
        // check above to cater for translations. Remove this exception?
        if (empty($unpublish_on)) {
          $field_definitions = $this->entityTypeManager
            ->getFieldDefinitions('node', $node
            ->getType());
          $field = (string) $field_definitions['unpublish_on']
            ->getLabel();
          throw new SchedulerMissingDateException(sprintf("Node %d '%s' will not be unpublished because field '%s' has no value", $node
            ->id(), $node
            ->getTitle(), $field));
        }

        // Trigger the PRE_UNPUBLISH event so that modules can react before the
        // node is unpublished.
        $event = new SchedulerEvent($node);
        $this
          ->dispatch($event, SchedulerEvents::PRE_UNPUBLISH);
        $node = $event
          ->getNode();

        // Update 'changed' timestamp.
        $node
          ->setChangedTime($unpublish_on);
        $create_unpublishing_revision = $node->type->entity
          ->getThirdPartySetting('scheduler', 'unpublish_revision', $this
          ->setting('default_unpublish_revision'));
        if ($create_unpublishing_revision) {
          $node
            ->setNewRevision();

          // Use a core date format to guarantee a time is included.
          $revision_log_message = $this
            ->t('Unpublished by Scheduler. The scheduled unpublishing date was @unpublish_on.', [
            '@unpublish_on' => $this->dateFormatter
              ->format($unpublish_on, 'short'),
          ]);

          // Create the new revision, setting message and revision timestamp.
          $node
            ->setRevisionLogMessage($revision_log_message)
            ->setRevisionCreationTime($this->time
            ->getRequestTime());
        }

        // Unset unpublish_on so the node will not get rescheduled by subsequent
        // calls to $node->save().
        $node->unpublish_on->value = NULL;

        // Invoke all implementations of hook_scheduler_unpublish_action() to
        // allow other modules to do the "unpublishing" process instead of
        // Scheduler.
        $hook = 'scheduler_unpublish_action';
        $processed = FALSE;
        $failed = FALSE;
        foreach ($this->moduleHandler
          ->getImplementations($hook) as $module) {
          $function = $module . '_' . $hook;
          $return = $function($node);
          $processed = $processed || $return === 1;
          $failed = $failed || $return === -1;
        }

        // Set up the log variables.
        $view_link = $node
          ->toLink($this
          ->t('View node'));
        $node_type = $this->entityTypeManager
          ->getStorage('node_type')
          ->load($node
          ->bundle());
        $node_type_link = $node_type
          ->toLink($this
          ->t('@label settings', [
          '@label' => $node_type
            ->label(),
        ]), 'edit-form');
        $logger_variables = [
          '@type' => $node_type
            ->label(),
          '%title' => $node
            ->getTitle(),
          'link' => $node_type_link
            ->toString() . ' ' . $view_link
            ->toString(),
          '@hook' => 'hook_' . $hook,
        ];
        if ($failed) {

          // At least one hook function returned a failure or exception, so stop
          // processing this node and move on to the next one.
          $this->logger
            ->warning('Unpublishing failed for %title. Calls to @hook returned a failure code.', $logger_variables);
          continue;
        }
        elseif ($processed) {

          // The node has 'unpublishing' processed by a module implementing the
          // hook, so no need to do anything more, apart from log this result.
          $this->logger
            ->notice('@type: scheduled processing of %title completed by calls to @hook.', $logger_variables);
        }
        else {

          // None of the above hook calls processed the node and there were no
          // errors detected so set the node to unpublished.
          $this->logger
            ->notice('@type: scheduled unpublishing of %title.', $logger_variables);
          $node
            ->setUnpublished();
        }

        // Invoke event to tell Rules that Scheduler has unpublished this node.
        if ($this->moduleHandler
          ->moduleExists('scheduler_rules_integration')) {
          _scheduler_rules_integration_dispatch_cron_event($node, 'unpublish');
        }

        // Trigger the UNPUBLISH event so that modules can react after the node
        // is unpublished.
        $event = new SchedulerEvent($node);
        $this
          ->dispatch($event, SchedulerEvents::UNPUBLISH);

        // Use the standard actions system to unpublish and save the node.
        $node = $event
          ->getNode();
        $action_id = 'node_unpublish_action';
        if ($this->moduleHandler
          ->moduleExists('workbench_moderation_actions')) {

          // workbench_moderation_actions module uses a custom action instead.
          $action_id = 'state_change__node__archived';
        }
        $this->entityTypeManager
          ->getStorage('action')
          ->load($action_id)
          ->getPlugin()
          ->execute($node);
        $result = TRUE;
      }
    }
    return $result;
  }

  /**
   * Checks whether a scheduled action on a node is allowed.
   *
   * This provides a way for other modules to prevent scheduled publishing or
   * unpublishing, by implementing hook_scheduler_allow_publishing() or
   * hook_scheduler_allow_unpublishing().
   *
   * @param \Drupal\node\NodeInterface $node
   *   The node on which the action is to be performed.
   * @param string $action
   *   The action that needs to be checked. Can be 'publish' or 'unpublish'.
   *
   * @return bool
   *   TRUE if the action is allowed, FALSE if not.
   *
   * @see hook_scheduler_allow_publishing()
   * @see hook_scheduler_allow_unpublishing()
   */
  public function isAllowed(NodeInterface $node, $action) {

    // Default to TRUE.
    $result = TRUE;

    // Check that other modules allow the action.
    $hook = 'scheduler_allow_' . $action . 'ing';
    foreach ($this->moduleHandler
      ->getImplementations($hook) as $module) {
      $function = $module . '_' . $hook;
      $result &= $function($node);
    }
    return $result;
  }

  /**
   * Gather node IDs for all nodes that need to be $action'ed.
   *
   * Modules can implement hook_scheduler_nid_list($action) and return an array
   * of node ids which will be added to the existing list.
   *
   * @param string $action
   *   The action being performed, either "publish" or "unpublish".
   *
   * @return array
   *   An array of node ids.
   */
  public function nidList($action) {
    $nids = [];
    foreach ($this->moduleHandler
      ->getImplementations('scheduler_nid_list') as $module) {
      $function = $module . '_scheduler_nid_list';
      $nids = array_merge($nids, $function($action));
    }
    return $nids;
  }

  /**
   * Run the lightweight cron.
   *
   * The Scheduler part of the processing performed here is the same as in the
   * normal Drupal cron run. The difference is that only scheduler_cron() is
   * executed, no other modules hook_cron() functions are called.
   *
   * This function is called from the external crontab job via url
   * /scheduler/cron/{access key} or it can be run interactively from the
   * Scheduler configuration page at /admin/config/content/scheduler/cron.
   * It is also executed when running Scheduler Cron via drush.
   *
   * @param array $options
   *   Options passed from drush command or admin form.
   */
  public function runLightweightCron(array $options = []) {

    // When calling via drush the log messages can be avoided by using --nolog.
    $log = $this
      ->setting('log') && empty($options['nolog']);
    if ($log) {
      if (array_key_exists('nolog', $options)) {
        $trigger = 'drush command';
      }
      elseif (array_key_exists('admin_form', $options)) {
        $trigger = 'admin user form';
      }
      else {
        $trigger = 'url';
      }
      $this->logger
        ->notice('Lightweight cron run activated by @trigger.', [
        '@trigger' => $trigger,
      ]);
    }
    scheduler_cron();
    if (ob_get_level() > 0) {
      $handlers = ob_list_handlers();
      if (isset($handlers[0]) && $handlers[0] == 'default output handler') {
        ob_clean();
      }
    }
    if ($log) {
      $link = Link::fromTextAndUrl($this
        ->t('settings'), Url::fromRoute('scheduler.cron_form'));
      $this->logger
        ->notice('Lightweight cron run completed.', [
        'link' => $link
          ->toString(),
      ]);
    }
  }

  /**
   * Helper method to access the settings of this module.
   *
   * @param string $key
   *   The key of the configuration.
   *
   * @return \Drupal\Core\Config\ImmutableConfig
   *   The value of the configuration item requested.
   */
  protected function setting($key) {
    return $this->configFactory
      ->get('scheduler.settings')
      ->get($key);
  }

  /**
   * Helper method to load latest revision of each node.
   *
   * @param array $nids
   *   Array of node ids.
   *
   * @return array
   *   Array of loaded nodes.
   *
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   */
  protected function loadNodes(array $nids) {
    $node_storage = $this->entityTypeManager
      ->getStorage('node');
    $nodes = [];

    // Load the latest revision for each node.
    foreach ($nids as $nid) {
      $node = $node_storage
        ->load($nid);
      $revision_ids = $node_storage
        ->revisionIds($node);
      $vid = end($revision_ids);
      $nodes[] = $node_storage
        ->loadRevision($vid);
    }
    return $nodes;
  }

}

Classes

Namesort descending Description
SchedulerManager Defines a scheduler manager.