first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
---
label: 'Tracking and displaying popular content'
related:
- core.tracking_content
- history.tracking_user_content
- block.place
---
{% set statistics_settings_link_text %}{% trans %}Statistics{% endtrans %}{% endset %}
{% set permissions_link_text %}{% trans %}Permissions{% endtrans %}{% endset %}
{% set statistics_settings_link = render_var(help_route_link(statistics_settings_link_text, 'statistics.settings')) %}
{% set permissions_link = render_var(help_route_link(permissions_link_text, 'user.admin_permissions')) %}
<h2>{% trans %}Goal{% endtrans %}</h2>
<p>{% trans %}Configure and display tracking of how many times content has been viewed on your site, assuming that the core Statistics module is currently installed.{% endtrans %}</p>
<h2>{% trans %}What are the options for displaying popularity tracking?{% endtrans %}</h2>
<p>{% trans %}You can display a <em>content hits</em> counter of how many times a content item has been viewed, at the bottom of content item pages. You can also place a <em>Popular content</em> block in a region of your theme, which shows a list of the most popular and most recently-viewed content.{% endtrans %}</p>
<h2>{% trans %}Steps{% endtrans %}</h2>
<ol>
<li>{% trans %}In the <em>Manage</em> administrative menu, navigate to <em>Configuration</em> &gt; <em>System</em> &gt; <em>{{ statistics_settings_link }}</em>.{% endtrans %}</li>
<li>{% trans %}Check <em>Count content views</em> and click <em>Save configuration</em>.{% endtrans %}</li>
<li>{% trans %}In the <em>Manage</em> administrative menu, navigate to <em>People</em> &gt; <em>{{ permissions_link }}</em>.{% endtrans %}</li>
<li>{% trans %}In the <em>Statistics</em> section, check or uncheck the <em>View content hits</em> permission for each role. Click <em>Save permissions</em>.{% endtrans %}</li>
<li>{% trans %}Optionally, in the <em>Manage</em> administrative menu, navigate to <em>Structure</em> &gt; <em>Block layout</em>. Place the <em>Popular content</em> block in a region in your theme (you will need to have the core Block module installed; see related topic for more details on block placement).{% endtrans %}</li>
</ol>

View File

@@ -0,0 +1,5 @@
finished:
6:
statistics: statistics
7:
statistics: statistics

View File

@@ -0,0 +1,33 @@
# cspell:ignore daycount totalcount
id: statistics_node_counter
label: Node counter
migration_tags:
- Drupal 6
- Drupal 7
- Content
source:
plugin: node_counter
process:
nid:
-
plugin: migration_lookup
migration:
- d6_node_complete
- d7_node_complete
- d6_node
- d7_node
source: nid
-
plugin: node_complete_node_lookup
-
plugin: skip_on_empty
method: row
totalcount: totalcount
daycount: daycount
timestamp: timestamp
destination:
plugin: node_counter
migration_dependencies:
optional:
- d6_node
- d7_node

View File

@@ -0,0 +1,37 @@
# cspell:ignore daycount totalcount
id: statistics_node_translation_counter
label: Node translation counter
migration_tags:
- Drupal 6
- Drupal 7
- Content
- Multilingual
source:
plugin: node_counter
process:
nid:
-
plugin: migration_lookup
migration:
- d6_node_translation
- d7_node_translation
source: nid
-
plugin: skip_on_empty
method: row
-
plugin: extract
index:
- 0
totalcount: totalcount
daycount: daycount
timestamp: timestamp
destination:
plugin: node_counter
migration_dependencies:
required:
- language
- statistics_node_counter
optional:
- d6_node_translation
- d7_node_translation

View File

@@ -0,0 +1,19 @@
# cspell:ignore accesslog
id: statistics_settings
label: Statistics configuration
migration_tags:
- Drupal 6
- Drupal 7
- Configuration
source:
plugin: variable
variables:
- statistics_enable_access_log
- statistics_flush_accesslog_timer
- statistics_count_content_views
source_module: statistics
process:
'count_content_views': statistics_count_content_views
destination:
plugin: config
config_name: statistics.settings

View File

@@ -0,0 +1,152 @@
<?php
namespace Drupal\statistics;
use Drupal\Core\Database\Connection;
use Drupal\Core\State\StateInterface;
use Symfony\Component\HttpFoundation\RequestStack;
// cspell:ignore daycount totalcount
/**
* Provides the default database storage backend for statistics.
*/
class NodeStatisticsDatabaseStorage implements StatisticsStorageInterface {
/**
* The database connection used.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* Constructs the statistics storage.
*
* @param \Drupal\Core\Database\Connection $connection
* The database connection for the node view storage.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
*/
public function __construct(Connection $connection, StateInterface $state, RequestStack $request_stack) {
$this->connection = $connection;
$this->state = $state;
$this->requestStack = $request_stack;
}
/**
* {@inheritdoc}
*/
public function recordView($id) {
return (bool) $this->connection
->merge('node_counter')
->key('nid', $id)
->fields([
'daycount' => 1,
'totalcount' => 1,
'timestamp' => $this->getRequestTime(),
])
->expression('daycount', '[daycount] + 1')
->expression('totalcount', '[totalcount] + 1')
->execute();
}
/**
* {@inheritdoc}
*/
public function fetchViews($ids) {
$views = $this->connection
->select('node_counter', 'nc')
->fields('nc', ['totalcount', 'daycount', 'timestamp'])
->condition('nid', $ids, 'IN')
->execute()
->fetchAll();
foreach ($views as $id => $view) {
$views[$id] = new StatisticsViewsResult($view->totalcount, $view->daycount, $view->timestamp);
}
return $views;
}
/**
* {@inheritdoc}
*/
public function fetchView($id) {
$views = $this->fetchViews([$id]);
return reset($views);
}
/**
* {@inheritdoc}
*/
public function fetchAll($order = 'totalcount', $limit = 5) {
assert(in_array($order, ['totalcount', 'daycount', 'timestamp']), "Invalid order argument.");
return $this->connection
->select('node_counter', 'nc')
->fields('nc', ['nid'])
->orderBy($order, 'DESC')
->range(0, $limit)
->execute()
->fetchCol();
}
/**
* {@inheritdoc}
*/
public function deleteViews($id) {
return (bool) $this->connection
->delete('node_counter')
->condition('nid', $id)
->execute();
}
/**
* {@inheritdoc}
*/
public function resetDayCount() {
$statistics_timestamp = $this->state->get('statistics.day_timestamp', 0);
if (($this->getRequestTime() - $statistics_timestamp) >= 86400) {
$this->state->set('statistics.day_timestamp', $this->getRequestTime());
$this->connection->update('node_counter')
->fields(['daycount' => 0])
->execute();
}
}
/**
* {@inheritdoc}
*/
public function maxTotalCount() {
$query = $this->connection->select('node_counter', 'nc');
$query->addExpression('MAX([totalcount])');
$max_total_count = (int) $query->execute()->fetchField();
return $max_total_count;
}
/**
* Get current request time.
*
* @return int
* Unix timestamp for current server request time.
*/
protected function getRequestTime() {
return $this->requestStack->getCurrentRequest()->server->get('REQUEST_TIME');
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace Drupal\statistics\Plugin\Block;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\statistics\StatisticsStorageInterface;
// cspell:ignore daycount totalcount
/**
* Provides a 'Popular content' block.
*/
#[Block(
id: "statistics_popular_block",
admin_label: new TranslatableMarkup("Popular content"),
)]
class StatisticsPopularBlock extends BlockBase implements ContainerFactoryPluginInterface {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The entity repository service.
*
* @var \Drupal\Core\Entity\EntityRepositoryInterface
*/
protected $entityRepository;
/**
* The storage for statistics.
*
* @var \Drupal\statistics\StatisticsStorageInterface
*/
protected $statisticsStorage;
/**
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs a StatisticsPopularBlock object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
* The entity repository service
* @param \Drupal\statistics\StatisticsStorageInterface $statistics_storage
* The storage for statistics.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer configuration array.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityRepositoryInterface $entity_repository, StatisticsStorageInterface $statistics_storage, RendererInterface $renderer) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityTypeManager = $entity_type_manager;
$this->entityRepository = $entity_repository;
$this->statisticsStorage = $statistics_storage;
$this->renderer = $renderer;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
$container->get('entity.repository'),
$container->get('statistics.storage.node'),
$container->get('renderer')
);
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'top_day_num' => 0,
'top_all_num' => 0,
'top_last_num' => 0,
];
}
/**
* {@inheritdoc}
*/
protected function blockAccess(AccountInterface $account) {
return AccessResult::allowedIfHasPermission($account, 'access content');
}
/**
* {@inheritdoc}
*/
public function blockForm($form, FormStateInterface $form_state) {
// Popular content block settings.
$numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30, 40];
$numbers = ['0' => $this->t('Disabled')] + array_combine($numbers, $numbers);
$form['statistics_block_top_day_num'] = [
'#type' => 'select',
'#title' => $this->t("Number of day's top views to display"),
'#default_value' => $this->configuration['top_day_num'],
'#options' => $numbers,
'#description' => $this->t('How many content items to display in "day" list.'),
];
$form['statistics_block_top_all_num'] = [
'#type' => 'select',
'#title' => $this->t('Number of all time views to display'),
'#default_value' => $this->configuration['top_all_num'],
'#options' => $numbers,
'#description' => $this->t('How many content items to display in "all time" list.'),
];
$form['statistics_block_top_last_num'] = [
'#type' => 'select',
'#title' => $this->t('Number of most recent views to display'),
'#default_value' => $this->configuration['top_last_num'],
'#options' => $numbers,
'#description' => $this->t('How many content items to display in "recently viewed" list.'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function blockSubmit($form, FormStateInterface $form_state) {
$this->configuration['top_day_num'] = $form_state->getValue('statistics_block_top_day_num');
$this->configuration['top_all_num'] = $form_state->getValue('statistics_block_top_all_num');
$this->configuration['top_last_num'] = $form_state->getValue('statistics_block_top_last_num');
}
/**
* {@inheritdoc}
*/
public function build() {
$content = [];
if ($this->configuration['top_day_num'] > 0) {
$nids = $this->statisticsStorage->fetchAll('daycount', $this->configuration['top_day_num']);
if ($nids) {
$content['top_day'] = $this->nodeTitleList($nids, $this->t("Today's:"));
$content['top_day']['#suffix'] = '<br />';
}
}
if ($this->configuration['top_all_num'] > 0) {
$nids = $this->statisticsStorage->fetchAll('totalcount', $this->configuration['top_all_num']);
if ($nids) {
$content['top_all'] = $this->nodeTitleList($nids, $this->t('All time:'));
$content['top_all']['#suffix'] = '<br />';
}
}
if ($this->configuration['top_last_num'] > 0) {
$nids = $this->statisticsStorage->fetchAll('timestamp', $this->configuration['top_last_num']);
$content['top_last'] = $this->nodeTitleList($nids, $this->t('Last viewed:'));
$content['top_last']['#suffix'] = '<br />';
}
return $content;
}
/**
* Generates the ordered array of node links for build().
*
* @param int[] $nids
* An ordered array of node ids.
* @param string $title
* The title for the list.
*
* @return array
* A render array for the list.
*/
protected function nodeTitleList(array $nids, $title) {
$nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids);
$items = [];
foreach ($nids as $nid) {
$node = $this->entityRepository->getTranslationFromContext($nodes[$nid]);
$item = $node->toLink()->toRenderable();
$this->renderer->addCacheableDependency($item, $node);
$items[] = $item;
}
return [
'#theme' => 'item_list__node',
'#items' => $items,
'#title' => $title,
'#cache' => [
'tags' => $this->entityTypeManager->getDefinition('node')->getListCacheTags(),
],
];
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Drupal\statistics\Plugin\migrate\destination;
use Drupal\Core\Database\Connection;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\Attribute\MigrateDestination;
use Drupal\migrate\Plugin\migrate\destination\DestinationBase;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
// cspell:ignore daycount totalcount
/**
* Destination for node counter.
*/
#[MigrateDestination(
id: 'node_counter',
destination_module: 'statistics'
)]
class NodeCounter extends DestinationBase implements ContainerFactoryPluginInterface {
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $connection;
/**
* Constructs a node counter plugin.
*
* @param array $configuration
* Plugin configuration.
* @param string $plugin_id
* The plugin ID.
* @param mixed $plugin_definition
* The plugin definition.
* @param \Drupal\migrate\Plugin\MigrationInterface $migration
* The current migration.
* @param \Drupal\Core\Database\Connection $connection
* The database connection.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, Connection $connection) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration);
$this->connection = $connection;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, ?MigrationInterface $migration = NULL) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$migration,
$container->get('database')
);
}
/**
* {@inheritdoc}
*/
public function getIds() {
return ['nid' => ['type' => 'integer']];
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'nid' => $this->t('The ID of the node to which these statistics apply.'),
'totalcount' => $this->t('The total number of times the node has been viewed.'),
'daycount' => $this->t('The total number of times the node has been viewed today.'),
'timestamp' => $this->t('The most recent time the node has been viewed.'),
];
}
/**
* {@inheritdoc}
*/
public function import(Row $row, array $old_destination_id_values = []) {
$nid = $row->getDestinationProperty('nid');
$daycount = $row->getDestinationProperty('daycount');
$totalcount = $row->getDestinationProperty('totalcount');
$timestamp = $row->getDestinationProperty('timestamp');
$this->connection
->merge('node_counter')
->key('nid', $nid)
->fields([
'daycount' => $daycount,
'totalcount' => $totalcount,
'timestamp' => $timestamp,
])
->expression('daycount', '[daycount] + :daycount', [':daycount' => $daycount])
->expression('totalcount', '[totalcount] + :totalcount', [':totalcount' => $totalcount])
// Per Drupal policy: "A query may have any number of placeholders, but
// all must have unique names even if they have the same value."
// https://www.drupal.org/docs/drupal-apis/database-api/static-queries#placeholders
->expression('timestamp', 'CASE WHEN [timestamp] > :timestamp1 THEN [timestamp] ELSE :timestamp2 END', [':timestamp1' => $timestamp, ':timestamp2' => $timestamp])
->execute();
return [$row->getDestinationProperty('nid')];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Drupal\statistics\Plugin\migrate\source;
use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
// cspell:ignore daycount totalcount
/**
* Drupal 6/7 node counter source from database.
*
* For available configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
*
* @MigrateSource(
* id = "node_counter",
* source_module = "statistics"
* )
*/
class NodeCounter extends DrupalSqlBase {
/**
* {@inheritdoc}
*/
public function query() {
return $this->select('node_counter', 'nc')->fields('nc');
}
/**
* {@inheritdoc}
*/
public function fields() {
return [
'nid' => $this->t('The node ID.'),
'totalcount' => $this->t('The total number of times the node has been viewed.'),
'daycount' => $this->t('The total number of times the node has been viewed today.'),
'timestamp' => $this->t('The most recent time the node has been viewed.'),
];
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['nid']['type'] = 'integer';
return $ids;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Drupal\statistics\Plugin\views\field;
use Drupal\views\Attribute\ViewsField;
use Drupal\views\Plugin\views\field\Date;
use Drupal\Core\Session\AccountInterface;
/**
* Field handler to display the most recent time the node has been viewed.
*
* @ingroup views_field_handlers
*/
#[ViewsField("node_counter_timestamp")]
class NodeCounterTimestamp extends Date {
/**
* {@inheritdoc}
*/
public function access(AccountInterface $account) {
return $account->hasPermission('view post access counter');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Drupal\statistics\Plugin\views\field;
use Drupal\views\Attribute\ViewsField;
use Drupal\views\Plugin\views\field\NumericField;
use Drupal\Core\Session\AccountInterface;
/**
* Field handler to display numeric values from the statistics module.
*
* @ingroup views_field_handlers
*/
#[ViewsField("statistics_numeric")]
class StatisticsNumeric extends NumericField {
/**
* {@inheritdoc}
*/
public function access(AccountInterface $account) {
return $account->hasPermission('view post access counter');
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Drupal\statistics;
use Drupal\Core\Config\TypedConfigManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Configure statistics settings for this site.
*
* @internal
*/
class StatisticsSettingsForm extends ConfigFormBase {
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* Constructs a \Drupal\statistics\StatisticsSettingsForm object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The factory for configuration objects.
* @param \Drupal\Core\Config\TypedConfigManagerInterface $typedConfigManager
* The typed config manager.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
*/
public function __construct(ConfigFactoryInterface $config_factory, TypedConfigManagerInterface $typedConfigManager, ModuleHandlerInterface $module_handler) {
parent::__construct($config_factory, $typedConfigManager);
$this->moduleHandler = $module_handler;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory'),
$container->get('config.typed'),
$container->get('module_handler')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'statistics_settings_form';
}
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return ['statistics.settings'];
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config('statistics.settings');
// Content counter settings.
$form['content'] = [
'#type' => 'details',
'#title' => $this->t('Content viewing counter settings'),
'#open' => TRUE,
];
$form['content']['statistics_count_content_views'] = [
'#type' => 'checkbox',
'#title' => $this->t('Count content views'),
'#default_value' => $config->get('count_content_views'),
'#description' => $this->t('Increment a counter each time content is viewed.'),
];
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->config('statistics.settings')
->set('count_content_views', $form_state->getValue('statistics_count_content_views'))
->save();
// The popular statistics block is dependent on these settings, so clear the
// block plugin definitions cache.
if ($this->moduleHandler->moduleExists('block')) {
\Drupal::service('plugin.manager.block')->clearCachedDefinitions();
}
parent::submitForm($form, $form_state);
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Drupal\statistics;
// cspell:ignore daycount totalcount
/**
* Provides an interface defining Statistics Storage.
*
* Stores the views per day, total views and timestamp of last view
* for entities.
*/
interface StatisticsStorageInterface {
/**
* Counts an entity view.
*
* @param int $id
* The ID of the entity to count.
*
* @return bool
* TRUE if the entity view has been counted.
*/
public function recordView($id);
/**
* Returns the number of times entities have been viewed.
*
* @param array $ids
* An array of IDs of entities to fetch the views for.
*
* @return \Drupal\statistics\StatisticsViewsResult[]
* An array of value objects representing the number of times each entity
* has been viewed. The array is keyed by entity ID. If an ID does not
* exist, it will not be present in the array.
*/
public function fetchViews($ids);
/**
* Returns the number of times a single entity has been viewed.
*
* @param int $id
* The ID of the entity to fetch the views for.
*
* @return \Drupal\statistics\StatisticsViewsResult|false
* If the entity exists, a value object representing the number of times if
* has been viewed. If it does not exist, FALSE is returned.
*/
public function fetchView($id);
/**
* Returns the number of times an entity has been viewed.
*
* @param string $order
* The counter name to order by:
* - 'totalcount' The total number of views.
* - 'daycount' The number of views today.
* - 'timestamp' The unix timestamp of the last view.
* @param int $limit
* The number of entity IDs to return.
*
* @return array
* An ordered array of entity IDs.
*/
public function fetchAll($order = 'totalcount', $limit = 5);
/**
* Delete counts for a specific entity.
*
* @param int $id
* The ID of the entity which views to delete.
*
* @return bool
* TRUE if the entity views have been deleted.
*/
public function deleteViews($id);
/**
* Reset the day counter for all entities once every day.
*/
public function resetDayCount();
/**
* Returns the highest 'totalcount' value.
*
* @return int
* The highest 'totalcount' value.
*/
public function maxTotalCount();
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Drupal\statistics;
/**
* Value object for passing statistic results.
*/
class StatisticsViewsResult {
/**
* @var int
*/
protected $totalCount;
/**
* @var int
*/
protected $dayCount;
/**
* @var int
*/
protected $timestamp;
public function __construct($total_count, $day_count, $timestamp) {
$this->totalCount = (int) $total_count;
$this->dayCount = (int) $day_count;
$this->timestamp = (int) $timestamp;
}
/**
* Total number of times the entity has been viewed.
*
* @return int
*/
public function getTotalCount() {
return $this->totalCount;
}
/**
* Total number of times the entity has been viewed "today".
*
* @return int
*/
public function getDayCount() {
return $this->dayCount;
}
/**
* Timestamp of when the entity was last viewed.
*
* @return int
*/
public function getTimestamp() {
return $this->timestamp;
}
}

View File

@@ -0,0 +1,15 @@
name: Statistics
type: module
description: 'Logs how many times content is viewed.'
package: Core
# version: VERSION
lifecycle: deprecated
lifecycle_link: https://www.drupal.org/node/3223395#s-statistics
configure: statistics.settings
dependencies:
- drupal:node
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,87 @@
<?php
/**
* @file
* Install and update functions for the Statistics module.
*/
// cspell:ignore daycount totalcount
/**
* Implements hook_uninstall().
*/
function statistics_uninstall() {
// Remove states.
\Drupal::state()->delete('statistics.node_counter_scale');
\Drupal::state()->delete('statistics.day_timestamp');
}
/**
* Implements hook_schema().
*/
function statistics_schema() {
$schema['node_counter'] = [
'description' => 'Access statistics for {node}s.',
'fields' => [
'nid' => [
'description' => 'The {node}.nid for these statistics.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
],
'totalcount' => [
'description' => 'The total number of times the {node} has been viewed.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
'size' => 'big',
],
'daycount' => [
'description' => 'The total number of times the {node} has been viewed today.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
'size' => 'medium',
],
'timestamp' => [
'description' => 'The most recent time the {node} has been viewed.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
'size' => 'big',
],
],
'primary key' => ['nid'],
];
return $schema;
}
/**
* Implements hook_update_last_removed().
*/
function statistics_update_last_removed() {
return 8300;
}
/**
* Remove the year 2038 date limitation.
*/
function statistics_update_10100(&$sandbox = NULL) {
$connection = \Drupal::database();
if ($connection->schema()->tableExists('node_counter') && $connection->databaseType() != 'sqlite') {
$new = [
'description' => 'The most recent time the {node} has been viewed.',
'type' => 'int',
'unsigned' => TRUE,
'not null' => TRUE,
'default' => 0,
'size' => 'big',
];
$connection->schema()->changeField('node_counter', 'timestamp', 'timestamp', $new);
}
}

View File

@@ -0,0 +1,15 @@
/**
* @file
* Statistics functionality.
*/
(function ($, drupalSettings) {
setTimeout(() => {
$.ajax({
type: 'POST',
cache: false,
url: drupalSettings.statistics.url,
data: drupalSettings.statistics.data,
});
});
})(jQuery, drupalSettings);

View File

@@ -0,0 +1,8 @@
drupal.statistics:
version: VERSION
js:
statistics.js: {}
dependencies:
- core/jquery
- core/drupal
- core/drupalSettings

View File

@@ -0,0 +1,6 @@
statistics.settings:
title: Statistics
description: 'Configure the logging of content statistics.'
route_name: statistics.settings
parent: system.admin_config_system
weight: -15

View File

@@ -0,0 +1,137 @@
<?php
/**
* @file
* Logs and displays content statistics for a site.
*/
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Drupal\node\NodeInterface;
// cspell:ignore totalcount
/**
* Implements hook_help().
*/
function statistics_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.statistics':
$output = '';
$output .= '<h2>' . t('About') . '</h2>';
$output .= '<p>' . t('The Statistics module shows you how often content is viewed. This is useful in determining which pages of your site are most popular. For more information, see the <a href=":statistics_do">online documentation for the Statistics module</a>.', [':statistics_do' => 'https://www.drupal.org/documentation/modules/statistics/']) . '</p>';
$output .= '<h2>' . t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . t('Displaying popular content') . '</dt>';
$output .= '<dd>' . t('The module includes a <em>Popular content</em> block that displays the most viewed pages today and for all time, and the last content viewed. To use the block, enable <em>Count content views</em> on the <a href=":statistics-settings">Statistics page</a>, and then you can enable and configure the block on the <a href=":blocks">Block layout page</a>.', [':statistics-settings' => Url::fromRoute('statistics.settings')->toString(), ':blocks' => (\Drupal::moduleHandler()->moduleExists('block')) ? Url::fromRoute('block.admin_display')->toString() : '#']) . '</dd>';
$output .= '<dt>' . t('Page view counter') . '</dt>';
$output .= '<dd>' . t('The Statistics module includes a counter for each page that increases whenever the page is viewed. To use the counter, enable <em>Count content views</em> on the <a href=":statistics-settings">Statistics page</a>, and set the necessary <a href=":permissions">permissions</a> (<em>View content hits</em>) so that the counter is visible to the users.', [':statistics-settings' => Url::fromRoute('statistics.settings')->toString(), ':permissions' => Url::fromRoute('user.admin_permissions.module', ['modules' => 'statistics'])->toString()]) . '</dd>';
$output .= '</dl>';
return $output;
case 'statistics.settings':
return '<p>' . t('Settings for the statistical information that Drupal will keep about the site.') . '</p>';
}
}
/**
* Implements hook_ENTITY_TYPE_view() for node entities.
*/
function statistics_node_view(array &$build, EntityInterface $node, EntityViewDisplayInterface $display, $view_mode) {
if (!$node->isNew() && $view_mode == 'full' && node_is_page($node) && empty($node->in_preview)) {
$build['#attached']['library'][] = 'statistics/drupal.statistics';
$settings = ['data' => ['nid' => $node->id()], 'url' => \Drupal::request()->getBasePath() . '/' . \Drupal::service('extension.list.module')->getPath('statistics') . '/statistics.php'];
$build['#attached']['drupalSettings']['statistics'] = $settings;
}
}
/**
* Implements hook_node_links_alter().
*/
function statistics_node_links_alter(array &$links, NodeInterface $entity, array &$context) {
if ($context['view_mode'] != 'rss') {
$links['#cache']['contexts'][] = 'user.permissions';
if (\Drupal::currentUser()->hasPermission('view post access counter')) {
$statistics = \Drupal::service('statistics.storage.node')->fetchView($entity->id());
if ($statistics) {
$statistics_links['statistics_counter']['title'] = \Drupal::translation()->formatPlural($statistics->getTotalCount(), '1 view', '@count views');
$links['statistics'] = [
'#theme' => 'links__node__statistics',
'#links' => $statistics_links,
'#attributes' => ['class' => ['links', 'inline']],
];
}
$links['#cache']['max-age'] = \Drupal::config('statistics.settings')->get('display_max_age');
}
}
}
/**
* Implements hook_cron().
*/
function statistics_cron() {
$storage = \Drupal::service('statistics.storage.node');
$storage->resetDayCount();
$max_total_count = $storage->maxTotalCount();
\Drupal::state()->set('statistics.node_counter_scale', 1.0 / max(1.0, $max_total_count));
}
/**
* Implements hook_ENTITY_TYPE_predelete() for node entities.
*/
function statistics_node_predelete(EntityInterface $node) {
// Clean up statistics table when node is deleted.
$id = $node->id();
return \Drupal::service('statistics.storage.node')->deleteViews($id);
}
/**
* Implements hook_ranking().
*/
function statistics_ranking() {
if (\Drupal::config('statistics.settings')->get('count_content_views')) {
return [
'views' => [
'title' => t('Number of views'),
'join' => [
'type' => 'LEFT',
'table' => 'node_counter',
'alias' => 'node_counter',
'on' => 'node_counter.nid = i.sid',
],
// Inverse law that maps the highest view count on the site to 1 and 0
// to 0. Note that the ROUND here is necessary for PostgreSQL and SQLite
// in order to ensure that the :statistics_scale argument is treated as
// a numeric type, because the PostgreSQL PDO driver sometimes puts
// values in as strings instead of numbers in complex expressions like
// this.
'score' => '2.0 - 2.0 / (1.0 + node_counter.totalcount * (ROUND(:statistics_scale, 4)))',
'arguments' => [':statistics_scale' => \Drupal::state()->get('statistics.node_counter_scale', 0)],
],
];
}
}
/**
* Implements hook_preprocess_HOOK() for block templates.
*/
function statistics_preprocess_block(&$variables) {
if ($variables['configuration']['provider'] == 'statistics') {
$variables['attributes']['role'] = 'navigation';
}
}
/**
* Implements hook_block_alter().
*
* Removes the "popular" block from display if the module is not configured
* to count content views.
*/
function statistics_block_alter(&$definitions) {
$statistics_count_content_views = \Drupal::config('statistics.settings')->get('count_content_views');
if (empty($statistics_count_content_views)) {
unset($definitions['statistics_popular_block']);
}
}

View File

@@ -0,0 +1,4 @@
administer statistics:
title: 'Administer statistics'
view post access counter:
title: 'View content hits'

View File

@@ -0,0 +1,30 @@
<?php
/**
* @file
* Handles counts of node views via AJAX with minimal bootstrap.
*/
use Drupal\Core\DrupalKernel;
use Symfony\Component\HttpFoundation\Request;
chdir('../../..');
$autoloader = require_once 'autoload.php';
$kernel = DrupalKernel::createFromRequest(Request::createFromGlobals(), $autoloader, 'prod');
$kernel->boot();
$container = $kernel->getContainer();
$views = $container
->get('config.factory')
->get('statistics.settings')
->get('count_content_views');
if ($views) {
$nid = filter_input(INPUT_POST, 'nid', FILTER_VALIDATE_INT);
if ($nid) {
$container->get('request_stack')->push(Request::createFromGlobals());
$container->get('statistics.storage.node')->recordView($nid);
}
}

View File

@@ -0,0 +1,7 @@
statistics.settings:
path: '/admin/config/system/statistics'
defaults:
_form: '\Drupal\statistics\StatisticsSettingsForm'
_title: 'Statistics'
requirements:
_permission: 'administer statistics'

View File

@@ -0,0 +1,7 @@
services:
statistics.storage.node:
class: Drupal\statistics\NodeStatisticsDatabaseStorage
arguments: ['@database', '@state', '@request_stack']
tags:
- { name: backend_overridable }
Drupal\statistics\StatisticsStorageInterface: '@statistics.storage.node'

View File

@@ -0,0 +1,70 @@
<?php
/**
* @file
* Builds placeholder replacement tokens for node visitor statistics.
*/
use Drupal\Core\Render\BubbleableMetadata;
/**
* Implements hook_token_info().
*/
function statistics_token_info() {
$node['total-count'] = [
'name' => t("Number of views"),
'description' => t("The number of visitors who have read the node."),
];
$node['day-count'] = [
'name' => t("Views today"),
'description' => t("The number of visitors who have read the node today."),
];
$node['last-view'] = [
'name' => t("Last view"),
'description' => t("The date on which a visitor last read the node."),
'type' => 'date',
];
return [
'tokens' => ['node' => $node],
];
}
/**
* Implements hook_tokens().
*/
function statistics_tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
$token_service = \Drupal::token();
$replacements = [];
if ($type == 'node' & !empty($data['node'])) {
$node = $data['node'];
/** @var \Drupal\statistics\StatisticsStorageInterface $stats_storage */
$stats_storage = \Drupal::service('statistics.storage.node');
$node_view = NULL;
foreach ($tokens as $name => $original) {
if ($name == 'total-count') {
$node_view = $node_view ?? $stats_storage->fetchView($node->id());
$replacements[$original] = $node_view ? $node_view->getTotalCount() : 0;
}
elseif ($name == 'day-count') {
$node_view = $node_view ?? $stats_storage->fetchView($node->id());
$replacements[$original] = $node_view ? $node_view->getDayCount() : 0;
}
elseif ($name == 'last-view') {
$node_view = $node_view ?? $stats_storage->fetchView($node->id());
$replacements[$original] = $node_view ? \Drupal::service('date.formatter')->format($node_view->getTimestamp()) : t('never');
}
}
if ($created_tokens = $token_service->findWithPrefix($tokens, 'last-view')) {
$node_view = $node_view ?? $stats_storage->fetchView($node->id());
$replacements += $token_service->generate('date', $created_tokens, ['date' => $node_view ? $node_view->getTimestamp() : 0], $options, $bubbleable_metadata);
}
}
return $replacements;
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* @file
* Provide views data for statistics.module.
*/
// cspell:ignore daycount totalcount
/**
* Implements hook_views_data().
*/
function statistics_views_data() {
$data['node_counter']['table']['group'] = t('Content statistics');
$data['node_counter']['table']['join'] = [
'node_field_data' => [
'left_field' => 'nid',
'field' => 'nid',
],
];
$data['node_counter']['totalcount'] = [
'title' => t('Total views'),
'help' => t('The total number of times the node has been viewed.'),
'field' => [
'id' => 'statistics_numeric',
'click sortable' => TRUE,
],
'filter' => [
'id' => 'numeric',
],
'argument' => [
'id' => 'numeric',
],
'sort' => [
'id' => 'standard',
],
];
$data['node_counter']['daycount'] = [
'title' => t('Views today'),
'help' => t('The total number of times the node has been viewed today.'),
'field' => [
'id' => 'statistics_numeric',
'click sortable' => TRUE,
],
'filter' => [
'id' => 'numeric',
],
'argument' => [
'id' => 'numeric',
],
'sort' => [
'id' => 'standard',
],
];
$data['node_counter']['timestamp'] = [
'title' => t('Most recent view'),
'help' => t('The most recent time the node has been viewed.'),
'field' => [
'id' => 'node_counter_timestamp',
'click sortable' => TRUE,
],
'filter' => [
'id' => 'date',
],
'argument' => [
'id' => 'date',
],
'sort' => [
'id' => 'standard',
],
];
return $data;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
name: 'Statistics test views'
type: module
description: 'Provides default views for views statistics tests.'
package: Testing
# version: VERSION
dependencies:
- drupal:statistics
- drupal:views
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,250 @@
# cspell:ignore daycount totalcount
langcode: en
status: true
dependencies:
module:
- node
- user
id: test_statistics_integration
label: 'Test statistics integration'
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
pager:
type: none
options:
offset: 0
style:
type: default
row:
type: fields
fields:
title:
id: title
table: node_field_data
field: title
label: ''
alter:
alter_text: false
make_link: false
absolute: false
trim: false
word_boundary: false
ellipsis: false
strip_tags: false
html: false
hide_empty: false
empty_zero: false
plugin_id: field
entity_type: node
entity_field: title
timestamp:
id: timestamp
table: node_counter
field: timestamp
relationship: none
group_type: group
admin_label: ''
label: 'Most recent view'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
date_format: html_year
custom_date_format: ''
timezone: ''
plugin_id: date
totalcount:
id: totalcount
table: node_counter
field: totalcount
relationship: none
group_type: group
admin_label: ''
label: 'Total views'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
set_precision: false
precision: 0
decimal: .
separator: ''
format_plural: false
format_plural_string: "1\x03@count"
prefix: ''
suffix: ''
plugin_id: numeric
daycount:
id: daycount
table: node_counter
field: daycount
relationship: none
group_type: group
admin_label: ''
label: 'Views today'
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
set_precision: false
precision: 0
decimal: .
separator: ''
format_plural: false
format_plural_string: "1\x03@count"
prefix: ''
suffix: ''
plugin_id: numeric
filters:
status:
value: '1'
table: node_field_data
field: status
id: status
expose:
operator: ''
group: 1
plugin_id: boolean
entity_type: node
entity_field: status
sorts:
created:
id: created
table: node_field_data
field: created
order: DESC
entity_type: node
entity_field: created
page_1:
display_plugin: page
id: page_1
display_title: Page
position: null
display_options:
path: test_statistics_integration

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for statistics.
*
* @group statistics
* @group legacy
*/
class GenericTest extends GenericModuleTestBase {}

View File

@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional;
use Drupal\Core\Database\Database;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\Traits\Core\CronRunTrait;
// cspell:ignore accesslog daycount
/**
* Tests the statistics admin.
*
* @group statistics
* @group legacy
*/
class StatisticsAdminTest extends BrowserTestBase {
use CronRunTrait;
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['node', 'statistics'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A user that has permission to administer statistics.
*
* @var \Drupal\user\UserInterface
*/
protected $privilegedUser;
/**
* A page node for which to check content statistics.
*
* @var \Drupal\node\NodeInterface
*/
protected $testNode;
/**
* The Guzzle HTTP client.
*
* @var \GuzzleHttp\Client
*/
protected $client;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Set the max age to 0 to simplify testing.
$this->config('statistics.settings')->set('display_max_age', 0)->save();
// Create Basic page node type.
if ($this->profile != 'standard') {
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
}
$this->privilegedUser = $this->drupalCreateUser([
'administer statistics',
'view post access counter',
'create page content',
]);
$this->drupalLogin($this->privilegedUser);
$this->testNode = $this->drupalCreateNode(['type' => 'page', 'uid' => $this->privilegedUser->id()]);
$this->client = \Drupal::httpClient();
}
/**
* Verifies that the statistics settings page works.
*/
public function testStatisticsSettings(): void {
$config = $this->config('statistics.settings');
$this->assertEmpty($config->get('count_content_views'), 'Count content view log is disabled by default.');
// Enable counter on content view.
$edit['statistics_count_content_views'] = 1;
$this->drupalGet('admin/config/system/statistics');
$this->submitForm($edit, 'Save configuration');
$config = $this->config('statistics.settings');
$this->assertNotEmpty($config->get('count_content_views'), 'Count content view log is enabled.');
// Hit the node.
$this->drupalGet('node/' . $this->testNode->id());
// Manually calling statistics.php, simulating ajax behavior.
$nid = $this->testNode->id();
$post = ['nid' => $nid];
global $base_url;
$stats_path = $base_url . '/' . $this->getModulePath('statistics') . '/statistics.php';
$this->client->post($stats_path, ['form_params' => $post]);
// Hit the node again (the counter is incremented after the hit, so
// "1 view" will actually be shown when the node is hit the second time).
$this->drupalGet('node/' . $this->testNode->id());
$this->client->post($stats_path, ['form_params' => $post]);
$this->assertSession()->pageTextContains('1 view');
$this->drupalGet('node/' . $this->testNode->id());
$this->client->post($stats_path, ['form_params' => $post]);
$this->assertSession()->pageTextContains('2 views');
// Increase the max age to test that nodes are no longer immediately
// updated, visit the node once more to populate the cache.
$this->config('statistics.settings')->set('display_max_age', 3600)->save();
$this->drupalGet('node/' . $this->testNode->id());
$this->assertSession()->pageTextContains('3 views');
$this->client->post($stats_path, ['form_params' => $post]);
$this->drupalGet('node/' . $this->testNode->id());
// Verify that views counter was not updated.
$this->assertSession()->pageTextContains('3 views');
}
/**
* Tests that when a node is deleted, the node counter is deleted too.
*/
public function testDeleteNode(): void {
$this->config('statistics.settings')->set('count_content_views', 1)->save();
$this->drupalGet('node/' . $this->testNode->id());
// Manually calling statistics.php, simulating ajax behavior.
$nid = $this->testNode->id();
$post = ['nid' => $nid];
global $base_url;
$stats_path = $base_url . '/' . $this->getModulePath('statistics') . '/statistics.php';
$this->client->post($stats_path, ['form_params' => $post]);
$connection = Database::getConnection();
$result = $connection->select('node_counter', 'n')
->fields('n', ['nid'])
->condition('n.nid', $this->testNode->id())
->execute()
->fetchAssoc();
$this->assertEquals($result['nid'], $this->testNode->id(), 'Verifying that the node counter is incremented.');
$this->testNode->delete();
$result = $connection->select('node_counter', 'n')
->fields('n', ['nid'])
->condition('n.nid', $this->testNode->id())
->execute()
->fetchAssoc();
$this->assertFalse($result, 'Verifying that the node counter is deleted.');
}
/**
* Tests that cron clears day counts and expired access logs.
*/
public function testExpiredLogs(): void {
$this->config('statistics.settings')
->set('count_content_views', 1)
->save();
\Drupal::state()->set('statistics.day_timestamp', 8640000);
$this->drupalGet('node/' . $this->testNode->id());
// Manually calling statistics.php, simulating ajax behavior.
$nid = $this->testNode->id();
$post = ['nid' => $nid];
global $base_url;
$stats_path = $base_url . '/' . $this->getModulePath('statistics') . '/statistics.php';
$this->client->post($stats_path, ['form_params' => $post]);
$this->drupalGet('node/' . $this->testNode->id());
$this->client->post($stats_path, ['form_params' => $post]);
$this->assertSession()->pageTextContains('1 view');
// statistics_cron() will subtract
// statistics.settings:accesslog.max_lifetime config from
// \Drupal::time()->getRequestTime() in the delete query, so wait two secs here to make
// sure the access log will be flushed for the node just hit.
sleep(2);
$this->cronRun();
// Verify that no hit URL is found.
$this->drupalGet('admin/reports/pages');
$this->assertSession()->pageTextNotContains('node/' . $this->testNode->id());
$result = Database::getConnection()->select('node_counter', 'nc')
->fields('nc', ['daycount'])
->condition('nid', $this->testNode->id(), '=')
->execute()
->fetchField();
$this->assertEmpty($result, 'Daycount is zero.');
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\node\Entity\Node;
/**
* Tests if statistics.js is loaded when content is not printed.
*
* @group statistics
* @group legacy
*/
class StatisticsAttachedTest extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['node', 'statistics'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page']);
// Install "statistics_test_attached" and set it as the default theme.
$theme = 'statistics_test_attached';
\Drupal::service('theme_installer')->install([$theme]);
$this->config('system.theme')
->set('default', $theme)
->save();
// Installing a theme will cause the kernel terminate event to rebuild the
// router. Simulate that here.
\Drupal::service('router.builder')->rebuildIfNeeded();
}
/**
* Tests if statistics.js is loaded when content is not printed.
*/
public function testAttached(): void {
$node = Node::create([
'type' => 'page',
'title' => 'Page node',
'body' => 'body text',
]);
$node->save();
$this->drupalGet('node/' . $node->id());
$this->assertSession()->responseContains('core/modules/statistics/statistics.js');
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\node\Entity\Node;
/**
* Tests request logging for cached and uncached pages.
*
* We subclass BrowserTestBase rather than StatisticsTestBase, because we
* want to test requests from an anonymous user.
*
* @group statistics
* @group legacy
*/
class StatisticsLoggingTest extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['node', 'statistics', 'block', 'locale'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* User with permissions to create and edit pages.
*
* @var \Drupal\user\UserInterface
*/
protected $authUser;
/**
* Associative array representing a hypothetical Drupal language.
*
* @var array
*/
protected $language;
/**
* The Guzzle HTTP client.
*
* @var \GuzzleHttp\Client
*/
protected $client;
/**
* A test node.
*
* @var \Drupal\node\Entity\Node
*/
protected Node $node;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create Basic page node type.
if ($this->profile != 'standard') {
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
}
$this->authUser = $this->drupalCreateUser([
// For node creation.
'access content',
'create page content',
'edit own page content',
// For language negotiation administration.
'administer languages',
'access administration pages',
]);
// Ensure we have a node page to access.
$this->node = $this->drupalCreateNode(['title' => $this->randomMachineName(255), 'uid' => $this->authUser->id()]);
// Add a custom language and enable path-based language negotiation.
$this->drupalLogin($this->authUser);
$this->language = [
'predefined_langcode' => 'custom',
'langcode' => 'xx',
'label' => $this->randomMachineName(16),
'direction' => 'ltr',
];
$this->drupalGet('admin/config/regional/language/add');
$this->submitForm($this->language, 'Add custom language');
$this->drupalGet('admin/config/regional/language/detection');
$this->submitForm(['language_interface[enabled][language-url]' => 1], 'Save settings');
$this->drupalLogout();
// Enable access logging.
$this->config('statistics.settings')
->set('count_content_views', 1)
->save();
$this->client = \Drupal::httpClient();
}
/**
* Verifies node hit counter logging and script placement.
*/
public function testLogging(): void {
$path = 'node/' . $this->node->id();
$module_path = $this->getModulePath('statistics');
$stats_path = base_path() . $module_path . '/statistics.php';
$lib_path = base_path() . $module_path . '/statistics.js';
$expected_library = '/<script src=".*?' . preg_quote($lib_path, '/.') . '.*?">/is';
// Verify that logging scripts are not found on a non-node page.
$this->drupalGet('node');
$settings = $this->getDrupalSettings();
// Statistics library JS should not be present.
$this->assertSession()->responseNotMatches($expected_library);
$this->assertFalse(isset($settings['statistics']), 'Statistics settings not found on node page.');
// Verify that logging scripts are not found on a non-existent node page.
$this->drupalGet('node/9999');
$settings = $this->getDrupalSettings();
// Statistics library JS should not be present.
$this->assertSession()->responseNotMatches($expected_library);
$this->assertFalse(isset($settings['statistics']), 'Statistics settings not found on node page.');
// Verify that logging scripts are found on a valid node page.
$this->drupalGet($path);
$settings = $this->getDrupalSettings();
$this->assertSession()->responseMatches($expected_library);
$this->assertSame($settings['statistics']['data']['nid'], $this->node->id(), 'Found statistics settings on node page.');
// Verify the same when loading the site in a non-default language.
$this->drupalGet($this->language['langcode'] . '/' . $path);
$settings = $this->getDrupalSettings();
$this->assertSession()->responseMatches($expected_library);
$this->assertSame($settings['statistics']['data']['nid'], $this->node->id(), 'Found statistics settings on valid node page in a non-default language.');
// Manually call statistics.php to simulate ajax data collection behavior.
global $base_root;
$post = ['nid' => $this->node->id()];
$this->client->post($base_root . $stats_path, ['form_params' => $post]);
$node_counter = \Drupal::service('statistics.storage.node')->fetchView($this->node->id());
$this->assertSame(1, $node_counter->getTotalCount());
// Try fetching statistics for an invalid node ID and verify it returns
// FALSE.
$node_id = 1000000;
$node = Node::load($node_id);
$this->assertNull($node);
// This is a test specifically for the deprecated statistics_get() function
// and so should remain unconverted until that function is removed.
$result = \Drupal::service('statistics.storage.node')->fetchView($node_id);
$this->assertFalse($result);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Link;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
/**
* Tests display of statistics report blocks.
*
* @group statistics
* @group legacy
*/
class StatisticsReportsTest extends StatisticsTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Tests the "popular content" block.
*/
public function testPopularContentBlock(): void {
// Clear the block cache to load the Statistics module's block definitions.
$this->container->get('plugin.manager.block')->clearCachedDefinitions();
// Visit a node to have something show up in the block.
$node = $this->drupalCreateNode(['type' => 'page', 'uid' => $this->blockingUser->id()]);
$this->drupalGet('node/' . $node->id());
// Manually calling statistics.php, simulating ajax behavior.
$nid = $node->id();
$post = http_build_query(['nid' => $nid]);
$headers = ['Content-Type' => 'application/x-www-form-urlencoded'];
global $base_url;
$stats_path = $base_url . '/' . $this->getModulePath('statistics') . '/statistics.php';
$client = \Drupal::httpClient();
$client->post($stats_path, ['headers' => $headers, 'body' => $post]);
// Configure and save the block.
$block = $this->drupalPlaceBlock('statistics_popular_block', [
'label' => 'Popular content',
'top_day_num' => 3,
'top_all_num' => 3,
'top_last_num' => 3,
]);
// Get some page and check if the block is displayed.
$this->drupalGet('user');
$this->assertSession()->pageTextContains('Popular content');
$this->assertSession()->pageTextContains("Today's");
$this->assertSession()->pageTextContains('All time');
$this->assertSession()->pageTextContains('Last viewed');
$tags = Cache::mergeTags($node->getCacheTags(), $block->getCacheTags());
$tags = Cache::mergeTags($tags, $this->blockingUser->getCacheTags());
$tags = Cache::mergeTags($tags, ['block_view', 'config:block_list', 'node_list', 'rendered', 'user_view']);
$this->assertCacheTags($tags);
$contexts = Cache::mergeContexts($node->getCacheContexts(), $block->getCacheContexts());
$contexts = Cache::mergeContexts($contexts, ['url.query_args:_wrapper_format', 'url.site']);
$this->assertCacheContexts($contexts);
// Check if the node link is displayed.
$this->assertSession()->responseContains(Link::fromTextAndUrl($node->label(), $node->toUrl('canonical'))->toString());
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional;
use Drupal\Tests\BrowserTestBase;
/**
* Defines a base class for testing the Statistics module.
*/
abstract class StatisticsTestBase extends BrowserTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['node', 'block', 'ban', 'statistics'];
/**
* User with permissions to ban IPs.
*
* @var \Drupal\user\UserInterface
*/
protected $blockingUser;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create Basic page node type.
if ($this->profile != 'standard') {
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
}
// Create user.
$this->blockingUser = $this->drupalCreateUser([
'access administration pages',
'access site reports',
'ban IP addresses',
'administer blocks',
'administer statistics',
'administer users',
]);
$this->drupalLogin($this->blockingUser);
// Enable logging.
$this->config('statistics.settings')
->set('count_content_views', 1)
->save();
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional;
/**
* Tests statistics token replacement.
*
* @group statistics
* @group legacy
*/
class StatisticsTokenReplaceTest extends StatisticsTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Creates a node, then tests the statistics tokens generated from it.
*/
public function testStatisticsTokenReplacement(): void {
$language_interface = \Drupal::languageManager()->getCurrentLanguage();
// Create user and node.
$user = $this->drupalCreateUser(['create page content']);
$this->drupalLogin($user);
$node = $this->drupalCreateNode(['type' => 'page', 'uid' => $user->id()]);
/** @var \Drupal\Core\Datetime\DateFormatterInterface $date_formatter */
$date_formatter = $this->container->get('date.formatter');
$request_time = \Drupal::time()->getRequestTime();
// Generate and test tokens.
$tests = [];
$tests['[node:total-count]'] = 0;
$tests['[node:day-count]'] = 0;
$tests['[node:last-view]'] = 'never';
$tests['[node:last-view:short]'] = $date_formatter->format($request_time, 'short');
foreach ($tests as $input => $expected) {
$output = \Drupal::token()->replace($input, ['node' => $node], ['langcode' => $language_interface->getId()]);
$this->assertEquals($expected, $output, "Statistics token $input replaced.");
}
// Hit the node.
$this->drupalGet('node/' . $node->id());
// Manually calling statistics.php, simulating ajax behavior.
$nid = $node->id();
$post = http_build_query(['nid' => $nid]);
$headers = ['Content-Type' => 'application/x-www-form-urlencoded'];
global $base_url;
$stats_path = $base_url . '/' . $this->getModulePath('statistics') . '/statistics.php';
$client = \Drupal::httpClient();
$client->post($stats_path, ['headers' => $headers, 'body' => $post]);
/** @var \Drupal\statistics\StatisticsViewsResult $statistics */
$statistics = \Drupal::service('statistics.storage.node')->fetchView($node->id());
// Generate and test tokens.
$tests = [];
$tests['[node:total-count]'] = 1;
$tests['[node:day-count]'] = 1;
$tests['[node:last-view]'] = $date_formatter->format($statistics->getTimestamp());
$tests['[node:last-view:short]'] = $date_formatter->format($statistics->getTimestamp(), 'short');
// Test to make sure that we generated something for each token.
$this->assertNotContains(0, array_map('strlen', $tests), 'No empty tokens generated.');
foreach ($tests as $input => $expected) {
$output = \Drupal::token()->replace($input, ['node' => $node], ['langcode' => $language_interface->getId()]);
$this->assertEquals($expected, $output, "Statistics token $input replaced.");
}
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional\Views;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\user\Entity\User;
/**
* Tests basic integration of views data from the statistics module.
*
* @group statistics
* @group legacy
* @see
*/
class IntegrationTest extends ViewTestBase {
/**
* Modules to enable.
*
* @var array
*/
protected static $modules = ['statistics', 'statistics_test_views', 'node'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Stores the user object that accesses the page.
*
* @var \Drupal\user\UserInterface
*/
protected $webUser;
/**
* A test user with node viewing access only.
*
* @var \Drupal\user\Entity\User
*/
protected User $deniedUser;
/**
* Stores the node object which is used by the test.
*
* @var \Drupal\node\Entity\Node
*/
protected $node;
/**
* Views used by this test.
*
* @var array
*/
public static $testViews = ['test_statistics_integration'];
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = ['statistics_test_views']): void {
parent::setUp($import_test_views, $modules);
// Create a new user for viewing nodes and statistics.
$this->webUser = $this->drupalCreateUser([
'access content',
'view post access counter',
]);
// Create a new user for viewing nodes only.
$this->deniedUser = $this->drupalCreateUser(['access content']);
$this->drupalCreateContentType(['type' => 'page']);
$this->node = $this->drupalCreateNode(['type' => 'page']);
// Enable counting of content views.
$this->config('statistics.settings')
->set('count_content_views', 1)
->save();
}
/**
* Tests the integration of the {node_counter} table in views.
*/
public function testNodeCounterIntegration(): void {
$this->drupalLogin($this->webUser);
$this->drupalGet('node/' . $this->node->id());
// Manually calling statistics.php, simulating ajax behavior.
// @see \Drupal\statistics\Tests\StatisticsLoggingTest::testLogging().
global $base_url;
$stats_path = $base_url . '/' . $this->getModulePath('statistics') . '/statistics.php';
$client = $this->getHttpClient();
$client->post($stats_path, ['form_params' => ['nid' => $this->node->id()]]);
$this->drupalGet('test_statistics_integration');
/** @var \Drupal\statistics\StatisticsViewsResult $statistics */
$statistics = \Drupal::service('statistics.storage.node')->fetchView($this->node->id());
$this->assertSession()->pageTextContains('Total views: 1');
$this->assertSession()->pageTextContains('Views today: 1');
$this->assertSession()->pageTextContains('Most recent view: ' . date('Y', $statistics->getTimestamp()));
$this->drupalLogout();
$this->drupalLogin($this->deniedUser);
$this->drupalGet('test_statistics_integration');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextNotContains('Total views:');
$this->assertSession()->pageTextNotContains('Views today:');
$this->assertSession()->pageTextNotContains('Most recent view:');
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional\migrate_drupal\d6;
use Drupal\Tests\migrate_drupal_ui\Functional\NoMultilingualReviewPageTestBase;
/**
* Tests migrate upgrade review page.
*
* @group statistics
* @group legacy
*/
class NoMultilingualReviewPageTest extends NoMultilingualReviewPageTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'statistics',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->loadFixture($this->getModulePath('statistics') . '/tests/fixtures/drupal6.php');
}
/**
* {@inheritdoc}
*/
protected function getSourceBasePath() {
return __DIR__;
}
/**
* Tests that Statistics is displayed in the will be upgraded list.
*/
public function testMigrateUpgradeReviewPage(): void {
$this->prepare();
// Start the upgrade process.
$this->submitCredentialForm();
$session = $this->assertSession();
$this->submitForm([], 'I acknowledge I may lose data. Continue anyway.');
$session->statusCodeEquals(200);
// Confirm that Statistics will be upgraded.
$session->elementExists('xpath', "//td[contains(@class, 'checked') and text() = 'Statistics']");
$session->elementNotExists('xpath', "//td[contains(@class, 'error') and text() = 'Statistics']");
}
/**
* {@inheritdoc}
*/
protected function getAvailablePaths() {
return [];
}
/**
* {@inheritdoc}
*/
protected function getIncompletePaths() {
return [];
}
/**
* {@inheritdoc}
*/
protected function getMissingPaths() {
return [];
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional\migrate_drupal\d6;
use Drupal\Tests\migrate_drupal_ui\Functional\MigrateUpgradeExecuteTestBase;
/**
* Tests Drupal 6 upgrade using the migrate UI.
*
* The test method is provided by the MigrateUpgradeTestBase class.
*
* @group statistics
* @group legacy
*/
class UpgradeTest extends MigrateUpgradeExecuteTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'config_translation',
'content_translation',
'language',
'migrate_drupal_ui',
'statistics',
];
/**
* The entity storage for node.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $nodeStorage;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->loadFixture($this->getModulePath('statistics') . '/tests/fixtures/drupal6.php');
}
/**
* {@inheritdoc}
*/
protected function getSourceBasePath() {
return __DIR__ . '/files';
}
/**
* {@inheritdoc}
*/
protected function getEntityCounts() {
return [
'action' => 24,
'base_field_override' => 18,
'block' => 33,
'block_content' => 1,
'block_content_type' => 1,
'comment' => 2,
'comment_type' => 7,
'configurable_language' => 5,
'contact_form' => 2,
'contact_message' => 0,
'date_format' => 12,
'editor' => 2,
'entity_form_display' => 16,
'entity_form_mode' => 1,
'entity_view_display' => 25,
'entity_view_mode' => 10,
'field_config' => 25,
'field_storage_config' => 14,
'file' => 1,
'filter_format' => 7,
'image_style' => 4,
'language_content_settings' => 9,
'menu' => 8,
'menu_link_content' => 1,
'node' => 11,
'node_type' => 7,
'path_alias' => 0,
'search_page' => 3,
'shortcut' => 2,
'shortcut_set' => 1,
'taxonomy_term' => 1,
'taxonomy_vocabulary' => 1,
'user' => 3,
'user_role' => 4,
'view' => 14,
];
}
/**
* {@inheritdoc}
*/
protected function getEntityCountsIncremental() {
return [];
}
/**
* {@inheritdoc}
*/
protected function getAvailablePaths() {
return [
'Block',
'Content translation',
'Content',
'Comment',
'Filter',
'Internationalization',
'Locale',
'Menu',
'Node',
'Path',
'Statistics',
'System',
'User',
'Variable admin',
];
}
/**
* {@inheritdoc}
*/
protected function getMissingPaths() {
return [];
}
/**
* Executes all steps of migrations upgrade.
*/
public function testUpgrade(): void {
// Start the upgrade process.
$this->submitCredentialForm();
$session = $this->assertSession();
$this->submitForm([], 'I acknowledge I may lose data. Continue anyway.');
$session->statusCodeEquals(200);
// Test the review form.
$this->assertReviewForm();
$this->submitForm([], 'Perform upgrade');
$this->assertUpgrade($this->getEntityCounts());
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional\migrate_drupal\d7;
use Drupal\Tests\migrate_drupal_ui\Functional\NoMultilingualReviewPageTestBase;
/**
* Tests Drupal 7 upgrade without translations.
*
* The test method is provided by the MigrateUpgradeTestBase class.
*
* @group statistics
* @group legacy
*/
class NoMultilingualReviewPageTest extends NoMultilingualReviewPageTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'statistics',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->loadFixture($this->getModulePath('statistics') . '/tests/fixtures/drupal7.php');
}
/**
* Tests that Statistics is displayed in the will be upgraded list.
*/
public function testMigrateUpgradeReviewPage(): void {
$this->prepare();
// Start the upgrade process.
$this->submitCredentialForm();
$session = $this->assertSession();
$this->submitForm([], 'I acknowledge I may lose data. Continue anyway.');
$session->statusCodeEquals(200);
// Confirm that Statistics will be upgraded.
$session->elementExists('xpath', "//td[contains(@class, 'checked') and text() = 'Statistics']");
$session->elementNotExists('xpath', "//td[contains(@class, 'error') and text() = 'Statistics']");
}
/**
* {@inheritdoc}
*/
protected function getSourceBasePath() {
return __DIR__ . '/files';
}
/**
* {@inheritdoc}
*/
protected function getAvailablePaths() {
return [];
}
/**
* {@inheritdoc}
*/
protected function getIncompletePaths() {
return [];
}
/**
* {@inheritdoc}
*/
protected function getMissingPaths() {
return [];
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional\migrate_drupal\d7;
use Drupal\Tests\migrate_drupal_ui\Functional\MigrateUpgradeExecuteTestBase;
/**
* Tests Drupal 7 upgrade using the migrate UI.
*
* The test method is provided by the MigrateUpgradeTestBase class.
*
* @group statistics
* @group legacy
*/
class UpgradeTest extends MigrateUpgradeExecuteTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'config_translation',
'content_translation',
'datetime_range',
'language',
'migrate_drupal_ui',
'statistics',
'telephone',
];
/**
* The entity storage for node.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $nodeStorage;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// @todo remove when https://www.drupal.org/project/drupal/issues/3266491 is
// fixed.
// Delete the existing content made to test the ID Conflict form. Migrations
// are to be done on a site without content. The test of the ID Conflict
// form is being moved to its own issue which will remove the deletion
// of the created nodes.
// See https://www.drupal.org/project/drupal/issues/3087061.
$this->nodeStorage = $this->container->get('entity_type.manager')
->getStorage('node');
$this->nodeStorage->delete($this->nodeStorage->loadMultiple());
$this->loadFixture($this->getModulePath('statistics') . '/tests/fixtures/drupal7.php');
}
/**
* {@inheritdoc}
*/
protected function getSourceBasePath() {
return __DIR__ . '/files';
}
/**
* {@inheritdoc}
*/
protected function getEntityCounts() {
return [
'action' => 24,
'base_field_override' => 2,
'block' => 26,
'block_content' => 1,
'block_content_type' => 1,
'comment' => 4,
'comment_type' => 9,
'configurable_language' => 5,
'contact_form' => 2,
'contact_message' => 0,
'date_format' => 12,
'editor' => 2,
'entity_form_display' => 19,
'entity_form_mode' => 1,
'entity_view_display' => 28,
'entity_view_mode' => 11,
'field_config' => 33,
'field_storage_config' => 19,
'file' => 1,
'filter_format' => 7,
'image_style' => 7,
'language_content_settings' => 16,
'menu' => 5,
'menu_link_content' => 2,
'node' => 7,
'node_type' => 8,
'path_alias' => 0,
'search_page' => 3,
'shortcut' => 2,
'shortcut_set' => 1,
'taxonomy_term' => 15,
'taxonomy_vocabulary' => 2,
'user' => 3,
'user_role' => 4,
'view' => 14,
];
}
/**
* {@inheritdoc}
*/
protected function getEntityCountsIncremental() {
return [];
}
/**
* {@inheritdoc}
*/
protected function getAvailablePaths() {
return [
'Block',
'Blog',
'Comment',
'Contact',
'Content translation',
'Entity Translation',
'Field',
'Field SQL storage',
'Field UI',
'File',
'Filter',
'Image',
'Internationalization',
'List',
'Locale',
'Menu',
'Node',
'Number',
'Options',
'Path',
'Statistics',
'System',
'Taxonomy',
'Text',
'User',
];
}
/**
* {@inheritdoc}
*/
protected function getMissingPaths() {
return [
'Forum',
'Variable',
];
}
/**
* Executes all steps of migrations upgrade.
*/
public function testUpgrade(): void {
// Start the upgrade process.
$this->submitCredentialForm();
$session = $this->assertSession();
$this->submitForm([], 'I acknowledge I may lose data. Continue anyway.');
$session->statusCodeEquals(200);
// Test the review form.
$this->assertReviewForm();
$this->submitForm([], 'Perform upgrade');
$this->assertUpgrade($this->getEntityCounts());
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Functional\search;
use Drupal\Core\Database\Database;
use Drupal\search\Entity\SearchPage;
use Drupal\Tests\BrowserTestBase;
use Drupal\Tests\Traits\Core\CronRunTrait;
// cspell:ignore daycount totalcount
/**
* Indexes content and tests ranking factors.
*
* @group statistics
* @group legacy
*/
class SearchRankingTest extends BrowserTestBase {
use CronRunTrait;
/**
* The node search page.
*
* @var \Drupal\search\SearchPageInterface
*/
protected $nodeSearch;
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'search', 'statistics'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
// Create a plugin instance.
$this->nodeSearch = SearchPage::load('node_search');
// Log in with sufficient privileges.
$this->drupalLogin($this->drupalCreateUser([
'create page content',
'administer search',
]));
}
/**
* Tests statistics ranking on search pages.
*/
public function testRankings(): void {
// Create nodes for testing.
$nodes = [];
$settings = [
'type' => 'page',
'title' => 'Drupal rocks',
'body' => [['value' => "Drupal's search rocks"]],
// Node is one day old.
'created' => \Drupal::time()->getRequestTime() - 24 * 3600,
'sticky' => 0,
'promote' => 0,
];
foreach ([0, 1] as $num) {
$nodes['views'][$num] = $this->drupalCreateNode($settings);
}
// Enable counting of statistics.
$this->config('statistics.settings')->set('count_content_views', 1)->save();
// Simulating content views is kind of difficult in the test. So instead go
// ahead and manually update the counter for this node.
$nid = $nodes['views'][1]->id();
Database::getConnection()->insert('node_counter')
->fields([
'totalcount' => 5,
'daycount' => 5,
'timestamp' => \Drupal::time()->getRequestTime(),
'nid' => $nid,
])
->execute();
// Run cron to update the search index and statistics totals.
$this->cronRun();
// Test that the settings form displays the content ranking section.
$this->drupalGet('admin/config/search/pages/manage/node_search');
$this->assertSession()->pageTextContains('Content ranking');
// Check that views ranking is visible and set to 0.
$this->assertSession()->optionExists('edit-rankings-views-value', '0');
// Test each of the possible rankings.
$edit = [];
// Enable views ranking.
$edit['rankings[views][value]'] = 10;
$this->drupalGet('admin/config/search/pages/manage/node_search');
$this->submitForm($edit, 'Save search page');
$this->drupalGet('admin/config/search/pages/manage/node_search');
$this->assertSession()->optionExists('edit-rankings-views-value', '10');
// Reload the plugin to get the up-to-date values.
$this->nodeSearch = SearchPage::load('node_search');
// Do the search and assert the results.
$this->nodeSearch->getPlugin()->setSearch('rocks', [], []);
$set = $this->nodeSearch->getPlugin()->execute();
$this->assertEquals($nodes['views'][1]->id(), $set[0]['node']->id());
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\FunctionalJavascript;
use Drupal\Core\Session\AccountInterface;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\user\Entity\Role;
/**
* Tests that statistics works.
*
* @group statistics
* @group legacy
*/
class StatisticsLoggingTest extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['node', 'statistics', 'language'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Node for tests.
*
* @var \Drupal\node\Entity\Node
*/
protected $node;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->config('statistics.settings')
->set('count_content_views', 1)
->save();
Role::load(AccountInterface::ANONYMOUS_ROLE)
->grantPermission('view post access counter')
->save();
// Add another language to enable multilingual path processor.
ConfigurableLanguage::create(['id' => 'xx', 'label' => 'Test language'])->save();
$this->config('language.negotiation')->set('url.prefixes.en', 'en')->save();
$this->drupalCreateContentType(['type' => 'page', 'name' => 'Basic page']);
$this->node = $this->drupalCreateNode();
}
/**
* Tests that statistics works with different addressing variants.
*/
public function testLoggingPage(): void {
// At the first request, the page does not contain statistics counter.
$this->assertNull($this->getStatisticsCounter('node/1'));
$this->assertSame(1, $this->getStatisticsCounter('node/1'));
$this->assertSame(2, $this->getStatisticsCounter('en/node/1'));
$this->assertSame(3, $this->getStatisticsCounter('en/node/1'));
$this->assertSame(4, $this->getStatisticsCounter('index.php/node/1'));
$this->assertSame(5, $this->getStatisticsCounter('index.php/node/1'));
$this->assertSame(6, $this->getStatisticsCounter('index.php/en/node/1'));
$this->assertSame(7, $this->getStatisticsCounter('index.php/en/node/1'));
}
/**
* Gets counter of views by path.
*
* @param string $path
* A path to node.
*
* @return int|null
* A counter of views. Returns NULL if the page does not contain statistics.
*/
protected function getStatisticsCounter($path) {
$this->drupalGet($path);
// Wait while statistics module send ajax request.
$this->assertSession()->assertWaitOnAjaxRequest();
// Resaving the node to call the hook_node_links_alter(), which is used to
// update information on the page. See statistics_node_links_alter().
$this->node->save();
$field_counter = $this->getSession()->getPage()->find('css', '.links li');
return $field_counter ? (int) explode(' ', $field_counter->getText())[0] : NULL;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Kernel\Migrate\d6;
use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase;
/**
* Tests the migration of node counter data to Drupal 8.
*
* @group statistics
* @group legacy
*/
class MigrateNodeCounterTest extends MigrateDrupal6TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'content_translation',
'language',
'menu_ui',
'node',
'statistics',
'text',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('node');
$this->installConfig('node');
$this->installSchema('node', ['node_access']);
$this->installSchema('statistics', ['node_counter']);
$this->executeMigrations([
'language',
'd6_filter_format',
'd6_user_role',
'd6_node_settings',
'd6_user',
'd6_node_type',
'd6_language_content_settings',
'd6_node',
'd6_node_translation',
'statistics_node_counter',
]);
}
/**
* Gets the path to the fixture file.
*/
protected function getFixtureFilePath() {
return __DIR__ . '/../../../../fixtures/drupal6.php';
}
/**
* Tests migration of node counter.
*/
public function testStatisticsSettings(): void {
$this->assertNodeCounter(1, 2, 0, 1421727536);
$this->assertNodeCounter(2, 1, 0, 1471428059);
$this->assertNodeCounter(3, 1, 0, 1471428153);
$this->assertNodeCounter(4, 1, 1, 1478755275);
$this->assertNodeCounter(5, 1, 1, 1478755314);
$this->assertNodeCounter(10, 5, 1, 1521137459);
$this->assertNodeCounter(12, 3, 0, 1521137469);
// Tests that translated node counts include all translation counts.
$this->executeMigration('statistics_node_translation_counter');
$this->assertNodeCounter(10, 8, 2, 1521137463);
$this->assertNodeCounter(12, 5, 1, 1521137470);
}
/**
* Asserts various aspects of a node counter.
*
* @param int $nid
* The node ID.
* @param int $total_count
* The expected total count.
* @param int $day_count
* The expected day count.
* @param int $timestamp
* The expected timestamp.
*
* @internal
*/
protected function assertNodeCounter(int $nid, int $total_count, int $day_count, int $timestamp): void {
/** @var \Drupal\statistics\StatisticsViewsResult $statistics */
$statistics = $this->container->get('statistics.storage.node')->fetchView($nid);
$this->assertSame($total_count, $statistics->getTotalCount());
$this->assertSame($day_count, $statistics->getDayCount());
$this->assertSame($timestamp, $statistics->getTimestamp());
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Kernel\Migrate\d6;
use Drupal\Tests\SchemaCheckTestTrait;
use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase;
/**
* Upgrade variables to statistics.settings.yml.
*
* @group statistics
* @group legacy
*/
class MigrateStatisticsConfigsTest extends MigrateDrupal6TestBase {
use SchemaCheckTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['statistics'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->executeMigration('statistics_settings');
}
/**
* Gets the path to the fixture file.
*/
protected function getFixtureFilePath() {
return __DIR__ . '/../../../../fixtures/drupal6.php';
}
/**
* Tests migration of statistics variables to statistics.settings.yml.
*/
public function testStatisticsSettings(): void {
$config = $this->config('statistics.settings');
$this->assertSame(1, $config->get('count_content_views'));
$this->assertConfigSchema(\Drupal::service('config.typed'), 'statistics.settings', $config->get());
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Kernel\Migrate\d7;
use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
/**
* Tests the migration of node counter data to Drupal 8.
*
* @group statistics
* @group legacy
*/
class MigrateNodeCounterTest extends MigrateDrupal7TestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'content_translation',
'language',
'menu_ui',
'node',
'statistics',
'text',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installSchema('node', ['node_access']);
$this->installSchema('statistics', ['node_counter']);
$this->migrateUsers(FALSE);
$this->migrateContentTypes();
$this->executeMigrations([
'language',
'd7_language_content_settings',
'd7_node',
'd7_node_translation',
'statistics_node_counter',
]);
}
/**
* Gets the path to the fixture file.
*/
protected function getFixtureFilePath() {
return __DIR__ . '/../../../../fixtures/drupal7.php';
}
/**
* Tests migration of node counter.
*/
public function testStatisticsSettings(): void {
$this->assertNodeCounter(1, 2, 0, 1421727536);
$this->assertNodeCounter(2, 1, 0, 1471428059);
$this->assertNodeCounter(4, 1, 0, 1478755275);
// Tests that translated node counts include all translation counts.
$this->executeMigration('statistics_node_translation_counter');
$this->assertNodeCounter(2, 2, 0, 1471428153);
$this->assertNodeCounter(4, 2, 0, 1478755314);
}
/**
* Asserts various aspects of a node counter.
*
* @param int $nid
* The node ID.
* @param int $total_count
* The expected total count.
* @param int $day_count
* The expected day count.
* @param int $timestamp
* The expected timestamp.
*
* @internal
*/
protected function assertNodeCounter(int $nid, int $total_count, int $day_count, int $timestamp): void {
/** @var \Drupal\statistics\StatisticsViewsResult $statistics */
$statistics = $this->container->get('statistics.storage.node')->fetchView($nid);
$this->assertSame($total_count, $statistics->getTotalCount());
$this->assertSame($day_count, $statistics->getDayCount());
$this->assertSame($timestamp, $statistics->getTimestamp());
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Kernel\Migrate\d7;
use Drupal\Tests\SchemaCheckTestTrait;
use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
/**
* Upgrade variables to statistics.settings.yml.
*
* @group statistics
* @group legacy
*/
class MigrateStatisticsConfigsTest extends MigrateDrupal7TestBase {
use SchemaCheckTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['statistics'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->executeMigration('statistics_settings');
}
/**
* Gets the path to the fixture file.
*/
protected function getFixtureFilePath() {
return __DIR__ . '/../../../../fixtures/drupal7.php';
}
/**
* Tests migration of statistics variables to statistics.settings.yml.
*/
public function testStatisticsSettings(): void {
$config = $this->config('statistics.settings');
$this->assertSame(1, $config->get('count_content_views'));
$this->assertConfigSchema(\Drupal::service('config.typed'), 'statistics.settings', $config->get());
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Kernel\Plugin\migrate\source;
use Drupal\Tests\migrate\Kernel\MigrateSqlSourceTestBase;
// cspell:ignore daycount totalcount
/**
* Tests the node_counter source plugin.
*
* @covers \Drupal\statistics\Plugin\migrate\source\NodeCounter
*
* @group statistics
* @group legacy
*/
class NodeCounterTest extends MigrateSqlSourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['migrate_drupal', 'statistics'];
/**
* {@inheritdoc}
*/
public static function providerSource() {
$tests = [];
// The source data.
$tests[0]['source_data']['node_counter'] = [
[
'nid' => 1,
'totalcount' => 2,
'daycount' => 0,
'timestamp' => 1421727536,
],
[
'nid' => 2,
'totalcount' => 1,
'daycount' => 0,
'timestamp' => 1471428059,
],
[
'nid' => 3,
'totalcount' => 1,
'daycount' => 0,
'timestamp' => 1471428153,
],
[
'nid' => 4,
'totalcount' => 1,
'daycount' => 1,
'timestamp' => 1478755275,
],
[
'nid' => 5,
'totalcount' => 1,
'daycount' => 1,
'timestamp' => 1478755314,
],
];
// The expected results.
$tests[0]['expected_data'] = $tests[0]['source_data']['node_counter'];
return $tests;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\statistics\Unit;
use Drupal\statistics\StatisticsViewsResult;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\statistics\StatisticsViewsResult
* @group statistics
* @group legacy
*/
class StatisticsViewsResultTest extends UnitTestCase {
/**
* Tests migration of node counter.
*
* @covers ::__construct
*
* @dataProvider providerTestStatisticsCount
*/
public function testStatisticsCount($total_count, $day_count, $timestamp): void {
$statistics = new StatisticsViewsResult($total_count, $day_count, $timestamp);
$this->assertSame((int) $total_count, $statistics->getTotalCount());
$this->assertSame((int) $day_count, $statistics->getDayCount());
$this->assertSame((int) $timestamp, $statistics->getTimestamp());
}
public static function providerTestStatisticsCount() {
return [
[2, 0, 1421727536],
[1, 0, 1471428059],
[1, 1, 1478755275],
['1', '1', '1478755275'],
];
}
}

View File

@@ -0,0 +1,24 @@
<article{{ attributes }}>
{{ title_prefix }}
{% if not page %}
<h2{{ title_attributes }}>
<a href="{{ url }}" rel="bookmark">{{ label }}</a>
</h2>
{% endif %}
{{ title_suffix }}
{% if display_submitted %}
<footer>
{{ author_picture }}
<div{{ author_attributes }}>
{% trans %}Submitted by {{ author_name }} on {{ date }}{% endtrans %}
{{ metadata }}
</div>
</footer>
{% endif %}
<div{{ content_attributes }}>
{{ content.body }}
</div>
</article>

View File

@@ -0,0 +1,10 @@
name: 'Statistics test attached theme'
type: theme
base theme: stable9
description: 'Theme for testing attached library'
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222