From a2ae94d36002b65d68d096bf202ea91c0cd0d04f Mon Sep 17 00:00:00 2001
From: David Stinemetze <davidstinemetze@gmail.com>
Date: Sun, 24 Mar 2019 23:06:33 -0500
Subject: [PATCH] Issue #3041509 by WidgetsBurritos: Job Summary Report

---
 accessibility_scanner.install                      | 149 +++++++++++++++++++++
 accessibility_scanner.module                       |  34 +++++
 accessibility_scanner.services.yml                 |  11 ++
 css/achecker.css                                   |  17 +++
 .../WebPageArchiveEventSubscriber.php              | 105 +++++++++++++++
 src/Plugin/views/area/AcheckerResult.php           | 141 +++++++++++++++++++
 src/Sql/AcheckerSummaryStorage.php                 |  82 ++++++++++++
 src/Sql/AcheckerSummaryStorageInterface.php        |   9 ++
 templates/wpa-achecker-preview.html.twig           |   7 -
 templates/wpa-achecker-summary.html.twig           |  31 +++++
 10 files changed, 579 insertions(+), 7 deletions(-)
 create mode 100644 accessibility_scanner.install
 create mode 100644 accessibility_scanner.services.yml
 create mode 100644 src/EventSubscriber/WebPageArchiveEventSubscriber.php
 create mode 100644 src/Plugin/views/area/AcheckerResult.php
 create mode 100644 src/Sql/AcheckerSummaryStorage.php
 create mode 100644 src/Sql/AcheckerSummaryStorageInterface.php
 create mode 100644 templates/wpa-achecker-summary.html.twig

diff --git a/accessibility_scanner.install b/accessibility_scanner.install
new file mode 100644
index 0000000..2e72943
--- /dev/null
+++ b/accessibility_scanner.install
@@ -0,0 +1,149 @@
+<?php
+
+/**
+ * @file
+ * Install commands for accessibility_scanner.
+ */
+
+use Drupal\Core\Database\Database;
+
+/**
+ * Implements hook_schema().
+ */
+function accessibility_scanner_schema() {
+  $schema['wpa_achecker_summary'] = [
+    'description' => 'Summary of capture jobs.',
+    'fields' => [
+      'vid' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Maps to {web_page_archive_run_revision}.vid.',
+      ],
+      'total' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total URLs captured.',
+      ],
+      'pass' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total passing URLs captured.',
+      ],
+      'fail' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total failing URLs captured.',
+      ],
+      'num_of_errors' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of errors detected.',
+      ],
+      'num_of_likely_problems' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of likely problems detected.',
+      ],
+      'num_of_potential_problems' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of potential problems detected.',
+      ],
+      'guidelines' => [
+        'type' => 'varchar_ascii',
+        'length' => 1023,
+        'not null' => TRUE,
+        'default' => '',
+        'description' => 'Serializes list of applied guidelines',
+      ],
+    ],
+    'primary key' => ['vid'],
+  ];
+
+  return $schema;
+}
+
+/**
+ * Issue #3041509: Creates job summary table.
+ */
+function accessibility_scanner_update_8001() {
+  $spec = [
+    'description' => 'Summary of capture jobs.',
+    'fields' => [
+      'vid' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Maps to {web_page_archive_run_revision}.vid.',
+      ],
+      'total' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total URLs captured.',
+      ],
+      'pass' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total passing URLs captured.',
+      ],
+      'fail' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total failing URLs captured.',
+      ],
+      'num_of_errors' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of errors detected.',
+      ],
+      'num_of_likely_problems' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of likely problems detected.',
+      ],
+      'num_of_potential_problems' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of potential problems detected.',
+      ],
+      'guidelines' => [
+        'type' => 'varchar_ascii',
+        'length' => 1023,
+        'not null' => TRUE,
+        'default' => '',
+        'description' => 'Serializes list of applied guidelines',
+      ],
+    ],
+    'primary key' => ['vid'],
+  ];
+
+  $schema = Database::getConnection()->schema();
+  $schema->createTable('wpa_achecker_summary', $spec);
+}
diff --git a/accessibility_scanner.module b/accessibility_scanner.module
index 570dd77..4957258 100644
--- a/accessibility_scanner.module
+++ b/accessibility_scanner.module
@@ -11,5 +11,39 @@ function accessibility_scanner_theme($existing, $type, $theme, $path) {
     'wpa-achecker-preview' => [
       'variables' => ['summary' => NULL, 'url' => NULL, 'view_button' => NULL],
     ],
+    'wpa-achecker-summary' => [
+      'variables' => ['result' => NULL],
+    ],
+  ];
+}
+
+/**
+ * Implements hook_views_pre_view().
+ */
+function accessibility_scanner_views_pre_view($view, $display_id, array &$args) {
+  $item = array(
+    'id' => 'achecker_result',
+    'table' => 'views',
+    'field' => 'achecker_result',
+    'relationship' => 'none',
+    'group_type' => 'none',
+    'admin_label' => '',
+    'plugin_id' => 'achecker_result',
+  );
+
+  $view->setHandler('default', 'header', 'achecker_result', $item);
+}
+
+/**
+ * Implements hook_views_data().
+ */
+function accessibility_scanner_views_data() {
+  $data['views']['achecker_result'] = [
+    'title' => t('AChecker Summary Results'),
+    'help' => t('Shows achecker result summary'),
+    'area' => [
+      'id' => 'achecker_result',
+    ],
   ];
+  return $data;
 }
diff --git a/accessibility_scanner.services.yml b/accessibility_scanner.services.yml
new file mode 100644
index 0000000..b4d77c9
--- /dev/null
+++ b/accessibility_scanner.services.yml
@@ -0,0 +1,11 @@
+services:
+  accessibility_scanner.event_subscriber:
+    arguments: ['@accessibility_scanner.achecker_summary_storage']
+    class: Drupal\accessibility_scanner\EventSubscriber\WebPageArchiveEventSubscriber
+    tags:
+      - { name: event_subscriber }
+  accessibility_scanner.achecker_summary_storage:
+    arguments: ['@database']
+    class: Drupal\accessibility_scanner\Sql\AcheckerSummaryStorage
+    tags:
+      - { name: storage }
diff --git a/css/achecker.css b/css/achecker.css
index 7309ad0..349c6b1 100644
--- a/css/achecker.css
+++ b/css/achecker.css
@@ -101,3 +101,20 @@
 .achecker-clear {
   clear: both;
 }
+
+.achecker-runSummary {
+  background-color: #f8f8f8;
+  margin: 5px 0;
+  padding: 1px 5px 5px;
+}
+
+.achecker-runSummaryColumn {
+  float: left;
+  max-width: 33%;
+  padding: 0 15px;
+}
+
+.achecker-runSummaryList {
+  margin: 0;
+  padding: 0 20px;
+}
diff --git a/src/EventSubscriber/WebPageArchiveEventSubscriber.php b/src/EventSubscriber/WebPageArchiveEventSubscriber.php
new file mode 100644
index 0000000..8165522
--- /dev/null
+++ b/src/EventSubscriber/WebPageArchiveEventSubscriber.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Drupal\accessibility_scanner\EventSubscriber;
+
+use Drupal\accessibility_scanner\Sql\AcheckerSummaryStorage;
+use Drupal\web_page_archive\Entity\WebPageArchiveRunInterface;
+use Drupal\web_page_archive\Event\CaptureJobCompleteEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Subscribes to the web page archive events.
+ */
+class WebPageArchiveEventSubscriber implements EventSubscriberInterface {
+
+  /**
+   * @var TODO
+   */
+  protected $acheckerStorage;
+
+  public function __construct(AcheckerSummaryStorage $achecker_storage) {
+    $this->acheckerStorage = $achecker_storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @return array
+   *   The event names to listen for, and the methods that should be executed.
+   */
+  public static function getSubscribedEvents() {
+    return [
+      CaptureJobCompleteEvent::EVENT_NAME => 'captureComplete',
+    ];
+  }
+
+  /**
+   * Checks if a run comparison entity has the achecker capture utility.
+   *
+   * @param \Drupal\web_page_archive\Entity\WebPageArchiveRunInterface $web_page_archive_run
+   *   The web page archive run entity to check for achecker.
+   */
+  private function hasAcheckerCaptureUtility(WebPageArchiveRunInterface $web_page_archive_run) {
+    foreach ($web_page_archive_run->getCaptureUtilities() as $capture_utility) {
+      $capture_utility_value = $capture_utility->getValue();
+      if (isset($capture_utility_value['wpa_achecker_capture'])) {
+        return TRUE;
+      }
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * React to a capture job completing.
+   *
+   * @param \Drupal\web_page_archive\Event\CaptureJobCompleteEvent $event
+   *   Capture job completion event.
+   */
+  public function captureComplete(CaptureJobCompleteEvent $event) {
+    if (!$this->hasAcheckerCaptureUtility($event->runEntity)) {
+      return;
+    }
+
+    $result = [
+      'vid' => $event->runEntity->getRevisionId(),
+      'total' => 0,
+      'pass' => 0,
+      'fail' => 0,
+      'num_of_errors' => 0,
+      'num_of_likely_problems' => 0,
+      'num_of_potential_problems' => 0,
+      'guidelines' => [],
+    ];
+    $captured = $event->runEntity->getCapturedArray();
+    foreach ($captured as $captured_item) {
+      $value = $captured_item->getValue();
+      $capture_item_result = !empty($value['value']) ? unserialize($value['value']) : '';
+      if ($capture_item_result['capture_response']->getId() != 'wpa_achecker_capture_response') {
+        continue;
+      }
+      $summary = $capture_item_result['capture_response']->retrieveFileContents()->summary;
+      $result['total']++;
+      if ($summary->status == 'PASS') {
+        $result['pass']++;
+      }
+      else {
+        $result['fail']++;
+      }
+      $result['num_of_errors'] += intval($summary->NumOfErrors);
+      $result['num_of_likely_problems'] += intval($summary->NumOfLikelyProblems);
+      $result['num_of_potential_problems'] += intval($summary->NumOfPotentialProblems);
+      foreach ($summary->guidelines as $guideline) {
+        $guideline_str = (string) $guideline->guideline;
+        if (!isset($result['guidelines'][$guideline_str])) {
+          $result['guidelines'][$guideline_str] = $guideline_str;
+        }
+      }
+    }
+
+    $this->acheckerStorage->addResult($result);
+
+    \Drupal::logger('accessibility_scanner')->notice(json_encode($result));
+  }
+
+}
diff --git a/src/Plugin/views/area/AcheckerResult.php b/src/Plugin/views/area/AcheckerResult.php
new file mode 100644
index 0000000..eefe15e
--- /dev/null
+++ b/src/Plugin/views/area/AcheckerResult.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Drupal\accessibility_scanner\Plugin\views\area;
+
+use Drupal\Component\Utility\Html;
+use Drupal\Component\Utility\Xss;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\views\Plugin\views\area\AreaPluginBase;
+use Drupal\views\Plugin\views\style\DefaultSummary;
+
+/**
+ * Views area handler to display some configurable result summary.
+ *
+ * @ingroup views_area_handlers
+ *
+ * @ViewsArea("achecker_result")
+ */
+class AcheckerResult extends AreaPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function defineOptions() {
+    $options = parent::defineOptions();
+
+    $options['content'] = [
+      'default' => $this->t('Displaying @start - @end of @total'),
+    ];
+
+    return $options;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildOptionsForm(&$form, FormStateInterface $form_state) {
+    parent::buildOptionsForm($form, $form_state);
+    $item_list = [
+      '#theme' => 'item_list',
+      '#items' => [
+        '@start -- the initial record number in the set',
+        '@end -- the last record number in the set',
+        '@total -- the total records in the set',
+        '@label -- the human-readable name of the view',
+        '@per_page -- the number of items per page',
+        '@current_page -- the current page number',
+        '@current_record_count -- the current page record count',
+        '@page_count -- the total page count',
+      ],
+    ];
+    $list = \Drupal::service('renderer')->render($item_list);
+    $form['content'] = [
+      '#title' => $this->t('Display'),
+      '#type' => 'textarea',
+      '#rows' => 3,
+      '#default_value' => $this->options['content'],
+      '#description' => $this->t('You may use HTML code in this field. The following tokens are supported:') . $list,
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function query() {
+    if (strpos($this->options['content'], '@total') !== FALSE) {
+      $this->view->get_total_rows = TRUE;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render($empty = FALSE) {
+
+    if (isset($this->view->argument['vid_1'])) {
+      $run_id = intval($this->view->argument['vid_1']->getValue());
+      // TODO: Inject this?
+      $storage = \Drupal::service('accessibility_scanner.achecker_summary_storage');
+      if ($result = $storage->getResult($run_id)) {
+        return [
+          '#theme' => 'wpa-achecker-summary',
+          '#result' => $result,
+        ];
+      }
+    }
+    return ['#markup' => 'todo'];
+    // Must have options and does not work on summaries.
+    if (!isset($this->options['content']) || $this->view->style_plugin instanceof DefaultSummary) {
+      return [];
+    }
+    $output = '';
+    $format = $this->options['content'];
+    // Calculate the page totals.
+    $current_page = (int) $this->view->getCurrentPage() + 1;
+    $per_page = (int) $this->view->getItemsPerPage();
+    // @TODO: Maybe use a possible is views empty functionality.
+    // Not every view has total_rows set, use view->result instead.
+    $total = isset($this->view->total_rows) ? $this->view->total_rows : count($this->view->result);
+    $label = Html::escape($this->view->storage->label());
+    // If there is no result the "start" and "current_record_count" should be
+    // equal to 0. To have the same calculation logic, we use a "start offset"
+    // to handle all the cases.
+    $start_offset = empty($total) ? 0 : 1;
+    if ($per_page === 0) {
+      $page_count = 1;
+      $start = $start_offset;
+      $end = $total;
+    }
+    else {
+      $page_count = (int) ceil($total / $per_page);
+      $total_count = $current_page * $per_page;
+      if ($total_count > $total) {
+        $total_count = $total;
+      }
+      $start = ($current_page - 1) * $per_page + $start_offset;
+      $end = $total_count;
+    }
+    $current_record_count = ($end - $start) + $start_offset;
+    // Get the search information.
+    $replacements = [];
+    $replacements['@start'] = $start;
+    $replacements['@end'] = $end;
+    $replacements['@total'] = $total;
+    $replacements['@label'] = $label;
+    $replacements['@per_page'] = $per_page;
+    $replacements['@current_page'] = $current_page;
+    $replacements['@current_record_count'] = $current_record_count;
+    $replacements['@page_count'] = $page_count;
+    // Send the output.
+    if (!empty($total) || !empty($this->options['empty'])) {
+      $output .= Xss::filterAdmin(str_replace(array_keys($replacements), array_values($replacements), $format));
+      // Return as render array.
+      return [
+        '#markup' => $output,
+      ];
+    }
+
+    return [];
+  }
+
+}
diff --git a/src/Sql/AcheckerSummaryStorage.php b/src/Sql/AcheckerSummaryStorage.php
new file mode 100644
index 0000000..d0f9d74
--- /dev/null
+++ b/src/Sql/AcheckerSummaryStorage.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\accessibility_scanner\Sql;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Handles storage concerns for achecker summary.
+ */
+class AcheckerSummaryStorage {
+
+  use StringTranslationTrait;
+
+  /**
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * Constructs a new AcheckerSummaryStorage object.
+   *
+   * @param \Drupal\Core\Database\Connection $database
+   *   Database connection service.
+   */
+  public function __construct(Connection $database) {
+    $this->database = $database;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addResult(array $result) {
+    $required_keys = [
+      'vid',
+      'total',
+      'pass',
+      'fail',
+      'num_of_errors',
+      'num_of_likely_problems',
+      'num_of_potential_problems',
+      'guidelines',
+    ];
+    foreach ($required_keys as $required_key) {
+      if (!isset($result[$required_key])) {
+        throw new \Exception($this->t('@key is required', ['@key' => $required_key]));
+      }
+    }
+
+    $values = [
+      'vid' => (int) $result['vid'],
+      'total' => (int) $result['total'],
+      'pass' => (int) $result['pass'],
+      'fail' => (int) $result['fail'],
+      'num_of_errors' => (int) $result['num_of_errors'],
+      'num_of_likely_problems' => (int) $result['num_of_likely_problems'],
+      'num_of_potential_problems' => (int) $result['num_of_potential_problems'],
+      'guidelines' => serialize($result['guidelines']),
+    ];
+
+    $this->database
+      ->upsert('wpa_achecker_summary')
+      ->key('vid')
+      ->fields(array_keys($values))
+      ->values(array_values($values))
+      ->execute();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getResult($vid) {
+    $result = $this->database
+      ->select('wpa_achecker_summary', 's')
+      ->fields('s')
+      ->condition('vid', (int) $vid)
+      ->execute()->fetchAssoc();
+    $result['guidelines'] = unserialize($result['guidelines']);
+    return $result;
+  }
+
+}
diff --git a/src/Sql/AcheckerSummaryStorageInterface.php b/src/Sql/AcheckerSummaryStorageInterface.php
new file mode 100644
index 0000000..602bf1c
--- /dev/null
+++ b/src/Sql/AcheckerSummaryStorageInterface.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\accessibility_scanner\Sql;
+
+interface AcheckerSummaryStorageInterface {
+
+  public function addResult(array $row);
+
+}
diff --git a/templates/wpa-achecker-preview.html.twig b/templates/wpa-achecker-preview.html.twig
index 58e6959..9bcc589 100644
--- a/templates/wpa-achecker-preview.html.twig
+++ b/templates/wpa-achecker-preview.html.twig
@@ -14,13 +14,6 @@
 <div class="achecker-result achecker-result-{{ summary.status|lower }}">
   <div class="achecker-result-status">{{ summary.status }}</div>
   <div class="achecker-result-url">{{ url }}</div>
-  {% if summary.guidelines is not empty %}
-  <ul class="achecker-result-guidelines">
-    {% for guideline in summary.guidelines %}
-    <li>{{ guideline }}</li>
-    {% endfor %}
-  </ul>
-  {% endif %}
 
   <ul class="achecker-result-errors">
     {% if summary.num_of_errors > 0 %}
diff --git a/templates/wpa-achecker-summary.html.twig b/templates/wpa-achecker-summary.html.twig
new file mode 100644
index 0000000..e431d54
--- /dev/null
+++ b/templates/wpa-achecker-summary.html.twig
@@ -0,0 +1,31 @@
+
+<div{{ attributes.addClass('achecker-runSummary') }}>
+
+  <div class="achecker-runSummary">
+    <h4>{{ 'Cumulative Results:'|trans }}</h4>
+    <div class="achecker-runSummaryColumn">
+      <ul class="achecker-runSummaryList">
+        <li><strong>{{ 'Total:'|trans }}</strong> {{ result.total }}</li>
+        <li><strong>{{ 'Pass:'|trans }}</strong> {{ result.pass }}</li>
+        <li><strong>{{ 'Fail:'|trans }}</strong> {{ result.fail }}</li>
+      </ul>
+    </div>
+    <div class="achecker-runSummaryColumn">
+      <ul class="achecker-runSummaryList">
+        <li><strong>{{ 'Errors:'|trans }}</strong> {{ result.num_of_errors }}</li>
+        <li><strong>{{ 'Likely Problems:'|trans }}</strong> {{ result.num_of_likely_problems }}</li>
+        <li><strong>{{ 'Potential Problems:'|trans }}</strong> {{ result.num_of_potential_problems }}</li>
+      </ul>
+    </div>
+    <div class="achecker-runSummaryColumn">
+      <strong>{{ 'Guidelines:'|trans }}</strong>
+      <ul class="achecker-runSummaryList">
+        {% for guideline in result.guidelines %}
+          <li>{{ guideline }}</li>
+        {% endfor %}
+      </ul>
+    </div>
+    <div class="achecker-clear"></div>
+  </div>
+
+</div>
-- 
2.15.1 (Apple Git-101)

