competition-8.x-1.x-dev/src/CompetitionReporter.php
src/CompetitionReporter.php
<?php
namespace Drupal\competition;
use Drupal\Core\Database\Database;
use Drupal\Core\Database\Connection;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Url;
use Drupal\Core\State\StateInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\EntityFieldManager;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\competition\Entity\CompetitionEntry;
use Psr\Log\LoggerInterface;
/**
* Competition entry/user data reporting.
*/
class CompetitionReporter {
use StringTranslationTrait;
const REPORT_TABLE = 'competition_entry_report_data';
const REPORT_JUDGING_TABLE = 'competition_entry_report_judging_data';
/**
* The database service.
*
* @var \Drupal\Core\Database\Connection
*/
private $dbConnection;
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
private $entityTypeManager;
/**
* The entity field service.
*
* @var \Drupal\Core\Entity\EntityFieldManager
*/
private $entityFieldManager;
/**
* The module handler service.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
private $moduleHandler;
/**
* The entity storage service.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
private $entityFormDisplayStorage;
/**
* The date formatter service.
*
* @var \Drupal\Core\Datetime\DateFormatterInterface
*/
private $dateFormatter;
/**
* The logger service.
*
* @var \Psr\Log\LoggerInterface
*/
private $logger;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
private $state;
/**
* The competition judging service.
*
* @var \Drupal\competition\CompetitionJudgingSetup
*/
private $competitionJudgingSetup;
/**
* Config stored in 'competition.settings'.
*
* Edited at admin/structure/competition/settings.
*
* @var \Drupal\Core\Config\ImmutableConfig
*/
private $competitionSettings;
/**
* Entry status labels.
*
* @var array
*/
private $entryStatusLabels;
/**
* Local cache of field definitions on competition_entry types.
*
* Each key is an entry type (bundle); each value is an array of
* \Drupal\Core\Field\FieldDefinitionInterface.
*
* @var array
*/
private $fieldDefinitionsEntry;
/**
* Local cache of field definitions on user entity type.
*
* @var \Drupal\Core\Field\FieldDefinitionInterface
*/
private $fieldDefinitionsUser;
/**
* Names of competition_entry fields to exclude from report.
*
* Note: this is not currently per bundle.
*
* @var array
*/
private $fieldsExcludeEntry;
/**
* Names of user entity fields to exclude from report.
*
* @var array
*/
private $fieldsExcludeUser;
/**
* Local cache of field metadata and labels.
*
* Keyed by competition_entry type for entries, or by 'user' for user entity.
*
* @var array
*
* @see getEntityReportFieldsMeta()
*/
private $fieldsMetaCache;
/**
* Constructor.
*
* @param \Drupal\Core\Database\Connection $db_connection
* The database service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\Core\Entity\EntityFieldManager $entity_field_manager
* The entity field manager service.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* The module handler service.
* @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter
* The date formatter service.
* @param \Psr\Log\LoggerInterface $logger
* The logger service.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config service.
* @param \Drupal\competition\CompetitionJudgingSetup $competition_judging_setup
* The competition judging service.
*/
public function __construct(Connection $db_connection, EntityTypeManagerInterface $entity_type_manager, EntityFieldManager $entity_field_manager, ModuleHandlerInterface $moduleHandler, DateFormatterInterface $date_formatter, LoggerInterface $logger, StateInterface $state, ConfigFactoryInterface $config_factory, CompetitionJudgingSetup $competition_judging_setup) {
$this->dbConnection = $db_connection;
$this->entityTypeManager = $entity_type_manager;
$this->entityFieldManager = $entity_field_manager;
$this->moduleHandler = $moduleHandler;
$this->entityFormDisplayStorage = $entity_type_manager->getStorage('entity_form_display');
$this->dateFormatter = $date_formatter;
$this->logger = $logger;
$this->state = $state;
$this->competitionJudgingSetup = $competition_judging_setup;
$this->competitionSettings = $config_factory->get('competition.settings');
// Set default fields to exclude.
// Note: Do not exclude `data` serialized blob here - we do need it to be
// included in the flattened report data table. It will be handled
// separately for export to report CSV.
$this->fieldsExcludeEntry = [
'uuid',
// Weight is for sorting archived entries; not really relevant to
// larger-context reports.
'weight',
];
$this->fieldsExcludeUser = [
// User ID is already included via the entry owner.
'uuid',
'langcode',
'preferred_langcode',
'preferred_admin_langcode',
'pass',
'timezone',
'status',
'init',
'roles',
'default_langcode',
];
}
/**
* Get report data last updated.
*
* Get timestamp at which report data was last updated. This matches the most
* recent timestamp in `exported` column in report data table.
*
* @return int|null
* The UNIX timestamp value, or NULL if no recorded timestamp in the current
* environment
*
* @see self::updateAllReportData()
*/
public function getReportDataLastUpdated() {
// If no value stored for this key, get() returns FALSE.
return $this->state->get('competition.report_data_last_updated') ?: NULL;
}
/**
* Set report data last updated.
*
* Set timestamp at which report data was last updated. This should always
* be set to timestamp stored in `exported` column of the report table, when
* data is updated there.
*
* @param int $timestamp
* The UNIX timestamp value to store.
*
* @see self::updateAllReportData()
*/
protected function setReportDataLastUpdated($timestamp) {
$this->state->set('competition.report_data_last_updated', $timestamp);
}
/**
* Get report table columns.
*
* Get array of the column names in the custom database table in which we
* store report data.
*/
private function getReportTableColumns() {
return [
'type',
'cycle',
'ceid',
'uid',
'status',
'exported',
'data',
];
}
/**
* Retrieve human labels for competition_entry.status field values.
*
* This is a local cache around CompetitionEntry::getStatusLabels().
*
* @return array
* integer value => human label
*/
private function getEntryStatusLabels() {
if (empty($this->entryStatusLabels)) {
$this->entryStatusLabels = CompetitionEntry::getStatusLabels();
}
return $this->entryStatusLabels;
}
/**
* Retrieve array of all field definitions for given competition_entry type.
*
* @param string $bundle
* Competition entry bundle/type.
*
* @return \Drupal\Core\Field\FieldDefinitionInterface[]
* Field definitions.
*/
public function getFieldDefinitionsEntry($bundle) {
if (!isset($this->fieldDefinitionsEntry[$bundle])) {
$this->fieldDefinitionsEntry[$bundle] = $this->entityFieldManager->getFieldDefinitions('competition_entry', $bundle);
}
return $this->fieldDefinitionsEntry[$bundle];
}
/**
* Retrieve array of all field definitions for user entity type.
*
* @return \Drupal\Core\Field\FieldDefinitionInterface[]
* Field definitions.
*/
public function getFieldDefinitionsUser() {
if (empty($this->fieldDefinitionsUser)) {
$this->fieldDefinitionsUser = $this->entityFieldManager->getFieldDefinitions('user', 'user');
}
return $this->fieldDefinitionsUser;
}
/**
* Retrieve array of competition_entry field names to be excluded from report.
*
* Note: this is not currently per bundle.
*
* @return array
* Fields.
*/
public function getFieldsExcludeEntry() {
return $this->fieldsExcludeEntry;
}
/**
* Set the competition_entry field names to be excluded from report.
*
* This fully overrides the defaults.
*
* @param array $field_names
* Field names.
*/
public function setFieldsExcludeEntry(array $field_names) {
$this->fieldsExcludeEntry = $field_names;
}
/**
* Add a competition_entry field name to be excluded from report.
*
* This adds to the defaults.
*
* @param string $field_name
* Field name.
*/
public function addFieldExcludeEntry($field_name) {
if (!in_array($field_name, $this->fieldsExcludeEntry)) {
$this->fieldsExcludeEntry[] = $field_name;
}
}
/**
* Retrieve array of user entity field names to be excluded from report.
*
* @return array
* Non user fields.
*/
public function getFieldsExcludeUser() {
return $this->fieldsExcludeUser;
}
/**
* Set the user entity field names to be excluded from report.
*
* This fully overrides the defaults.
*
* @param array $field_names
* Field names.
*/
public function setFieldsExcludeUser(array $field_names) {
$this->fieldsExcludeUser = $field_names;
}
/**
* Add a user entity field name to be excluded from report.
*
* This adds to the defaults.
*
* @param string $field_name
* Field name.
*/
public function addFieldExcludeUser($field_name) {
if (!in_array($field_name, $this->fieldsExcludeUser)) {
$this->fieldsExcludeUser[] = $field_name;
}
}
/**
* Get fields sorted by form display.
*
* Retrieve field names on an entity, sorted by their weighted order in a
* form display config.
*
* Note! This will NOT include fields set to 'hidden' on that mode.
*
* @param string $form_display_id
* The ID of the entity form display - "$entity_type.$bundle.$display_mode".
*
* @return array|null
* Indexed array of field names, or NULL if form display config entity
* could not be loaded for given ID.
*
* TODO: this method does not fully work if there are field_groups
* configured, as weights only order *within* each group. This can be fixed
* manually by editing weight values directly rather than via drag-and-drop.
* (This is all moot if we provide a way to config field choice + order.)
*/
public function getFieldsSortedByFormDisplay($form_display_id) {
$display = $this->entityFormDisplayStorage->load($form_display_id);
if (empty($display)) {
return NULL;
}
$ids = explode('.', $form_display_id);
$entity_type = $ids[0];
$bundle = $ids[1];
if ($entity_type == 'competition_entry') {
$field_definitions = $this->getFieldDefinitionsEntry($bundle);
}
elseif ($entity_type == 'user') {
$field_definitions = $this->getFieldDefinitionsUser();
}
$fields_sorted = [];
foreach ($display->getComponents() as $field_name => $meta) {
$weight = (int) $meta['weight'];
// 'account' widget includes username, email, and password fields.
if ($entity_type == 'user' && $field_name == 'account') {
// Drag-and-drop ordering on admin UI always results in integer weight
// values. Use floats to split up this multi-field widget.
$fields_sorted[$weight] = 'name';
$fields_sorted[$weight + 0.5] = 'mail';
continue;
}
// Check for definition because the *display* can include "extra" fields
// that aren't base or configured fields. ('account' is one, but that's
// handled specially.)
if (empty($field_definitions[$field_name])) {
continue;
}
$fields_sorted[$weight] = $field_name;
}
// Sort by the keys.
ksort($fields_sorted);
// We only need the values.
$fields_sorted = array_values($fields_sorted);
return $fields_sorted;
}
/**
* Collect field metadata and labels for all fields to be included in report.
*
* @param string $entity_type
* Entity type - 'competition_entry' or 'user' - for which to retrieve
* field metadata.
* @param string|null $bundle
* Type (bundle) name of competition_entry for which to retrieve field
* metadata. Leave NULL for user entity type.
*
* @return array
* Contains two arrays of field data. Both are ordered for output to CSV.
* 'fields_meta' - field key => metadata
* field key is:
* {field_name}, for entry fields
* user.{field_name}, for user fields
* 'field_labels_all' - key => finalized CSV column label
* key is:
* {field_name} OR {field_name}.{property_name}, for entry fields
* user.{field_name} OR user.{field_name}.{property_name},
* for user fields.
*/
public function getEntityReportFieldsMeta($entity_type, $bundle = NULL) {
// Check local cache and return if already loaded for this type.
$cache_key = (!empty($bundle) ? $entity_type . '.' . $bundle : $entity_type);
if (!empty($this->fieldsMetaCache[$cache_key])) {
return $this->fieldsMetaCache[$cache_key];
}
// -------------------------------------------------------------.
// Collect all field keys in sensible order for output.
$keys_ordered = [];
// --- 1. Entry fields - primary identifiers.
if ($entity_type == 'competition_entry') {
// Put these fields first, as categories/identification/meta
// for the entry.
// These are base fields on the entity type, so can be hardcoded.
$fields_entry_primary = [
'type',
'cycle',
'ceid',
'status',
'created',
'changed',
'uid',
];
// TODO: include ceid_referrer here, if referrals are enabled (not
// implemented yet?)
// $competition = $this->entityTypeManager
// ->getStorage('competition')->load($bundle);
$keys_ordered = array_merge($keys_ordered, $fields_entry_primary);
}
// --- 2. User fields
// Show user fields next, as registration form is the first step and
// generally contains personal info.
$fields_user_all = [];
/* @var \Drupal\Core\Field\FieldDefinitionInterface[] $field_definitions_user */
$field_definitions_user = $this->getFieldDefinitionsUser();
// Primary identifying fields (hardcoded as they are base fields)
$fields_user_primary = [
'uid',
'name',
'mail',
];
$fields_user_all = array_merge($fields_user_all, $fields_user_primary);
// Use form display to set a sensible field order.
// Try 'register' display mode first; fallback to default.
$fields_user_sorted = $this->getFieldsSortedByFormDisplay('user.user.register');
if ($fields_user_sorted === NULL) {
$fields_user_sorted = $this->getFieldsSortedByFormDisplay('user.user.default');
}
$fields_user_sorted = array_diff($fields_user_sorted, $fields_user_primary);
$fields_user_all = array_merge($fields_user_all, $fields_user_sorted);
// Add remaining fields: not primary, not displayed in the form.
$fields_user_all = array_merge(
$fields_user_all,
array_diff(array_keys($field_definitions_user), $fields_user_all)
);
// Exclude certain fields.
$fields_exclude_user = $this->getFieldsExcludeUser();
// TODO: entry and user both have uid. Entry's label is prob preferable,
// but if exporting a user account only, entry.uid won't exist...
// custom combine labels maybe? "Entry Owner (User: UID)"
// $fields_exclude_user[] = 'uid';.
$fields_user_all = array_diff($fields_user_all, $fields_exclude_user);
// Add to master list, prepending 'user.' to key to denote as fields on
// user entity.
foreach ($fields_user_all as $name) {
$keys_ordered[] = 'user.' . $name;
}
// --- 3. Entry fields - non-primary.
if ($entity_type == 'competition_entry') {
$fields_entry_all = [];
/* @var \Drupal\Core\Field\FieldDefinitionInterface[] $field_definitions_entry */
$field_definitions_entry = $this->getFieldDefinitionsEntry($bundle);
// Use entry form display to set a sensible field order.
$fields_entry_sorted = $this->getFieldsSortedByFormDisplay('competition_entry.' . $bundle . '.default');
$fields_entry_all = array_merge($fields_entry_all, $fields_entry_sorted);
// Add remaining fields not displayed in form.
$fields_entry_all = array_merge(
$fields_entry_all,
array_diff(array_keys($field_definitions_entry), $fields_entry_all)
);
// Remove primary fields that were added before user fields.
$fields_entry_all = array_diff($fields_entry_all, $fields_entry_primary);
// Exclude certain fields.
$fields_exclude_entry = $this->getFieldsExcludeEntry();
$fields_entry_all = array_diff($fields_entry_all, $fields_exclude_entry);
// Add to master list.
$keys_ordered = array_merge($keys_ordered, $fields_entry_all);
}
// --- 4. Collect metadata for all fields.
$fields_meta = [];
$labels_seen = [];
$labels_same = [];
foreach ($keys_ordered as $key) {
$on_user = (strpos($key, 'user.') === 0);
$name = ($on_user ? substr($key, 5) : $key);
$definition = $on_user ? $field_definitions_user[$name] : $field_definitions_entry[$name];
$field_type = $definition->getType();
// TODO (minor): this doesn't really fit here, as it's one more kind of
// filtering, but is easy to do here.
if ($field_type == 'markup') {
continue;
}
// Get field label; denote whether it's same as any others.
$label = (string) $definition->getLabel();
if ($on_user) {
$label = $this->t('User: @label', [
'@label' => $label,
]);
}
if (in_array($label, $labels_seen) && !in_array($label, $labels_same)) {
$labels_same[] = $label;
}
$labels_seen[] = $label;
// Get field properties - store if more than one, to be split out later.
$property_names = $definition->getFieldStorageDefinition()->getPropertyNames();
// Tweak properties per field type.
switch ($field_type) {
case 'entity_reference':
// Note that entry 'type' field is actually an entity_reference to
// competition entity.
// TODO: this might be worth handling specially, to get some more
// identifying value off the ref'd entity.
$property_names = ['target_id'];
break;
case 'file':
// FileItem is an extension of EntityReferenceItem field type and
// has several properties. We won't pull field values via 'target_id'
// property name directly, but having one property name is consistent
// and accurate.
$property_names = ['target_id'];
break;
case 'datetime':
// There are both 'date' (a DrupalDateTime object) and 'value' (date
// string) properties; displaying the string suffices. Also, the
// 'date' property is not loaded in getValue().
$property_names = ['value'];
break;
case 'link':
// Remove 'options' - those have to do with external URL settings,
// HTML attributes, etc.
$property_names = array_diff($property_names, ['options']);
// TODO: use this when 'properties' is assoc
// unset($meta['properties']['options']);
// If field is config'd so user cannot enter a title, there's no
// property value to export.
// @see \Drupal\link\Plugin\Field\FieldType\LinkItem::fieldSettingsForm()
if ($definition->getSetting('title') === DRUPAL_DISABLED) {
$property_names = array_diff($property_names, ['title']);
}
break;
default:
break;
}
$fields_meta[$key] = [
'field_type' => $field_type,
'label' => $label,
'properties' => $property_names,
];
}
// --- 5. Collect labels.
// Build array: key => final label.
// - For multiple fields with same label, append field machine name for
// clarity
// - Expand fields with multiple properties to separate columns
// per property.
$field_labels_all = [];
foreach ($fields_meta as $key => &$meta) {
$on_user = (strpos($key, 'user.') === 0);
$name = ($on_user ? substr($key, 5) : $key);
$label = $meta['label'];
if (in_array($label, $labels_same)) {
$label .= (' (' . $name . ')');
}
if (count($meta['properties']) == 1) {
// If only one property, use single column with just field label.
$field_labels_all[$key] = $label;
}
else {
// Otherwise, split each property value into its own column.
// TODO: get property labels into here.
foreach ($meta['properties'] as $property_name) {
$field_labels_all[$key . '.' . $property_name] = $label . ' - ' . $property_name;
}
}
}
// --- 6. Allow altering fields meta and labels.
// Modules, be careful!!!
// TODO: document this hook.
$alter_data = [
'fields_meta' => &$fields_meta,
'field_labels_all' => &$field_labels_all,
];
// Unalterable stuff.
$alter_context = [
'entity_type' => $entity_type,
'bundle' => $bundle,
];
$this->moduleHandler->alter('competition_entry_report_fields_meta', $alter_data, $alter_context);
// Store final data sets in local cache var.
$this->fieldsMetaCache[$cache_key] = [
'fields_meta' => $fields_meta,
'field_labels_all' => $field_labels_all,
];
return $this->fieldsMetaCache[$cache_key];
}
/**
* Get list of columns in judging reporting data table.
*
* @return array
* Table column names, as defined in schema.
*/
public function getReportJudgingTableColumns() {
return [
'type',
'cycle',
'ceid',
'user_name',
'round_id',
'judge_name',
'score',
'score_finalized',
'votes',
'log',
'exported',
];
}
/**
* Get list of keys and labels of all available judging reports.
*
* TODO: consider YAML for this?
*
* @return array
* Array of available judging reports; keys are custom keys used to identify
* each report; values are human labels.
*/
public function getAllJudgingReports() {
$reports = [
// Everything from the master report (entry/user entity fields), plus
// all overall round scores (up to the given round)
'master' => $this->t('Master with Round Scores'),
// All score values - per judge, per entry.
'scores' => $this->t('Score Details'),
// Overall score per entry, by judge.
'judge_scores' => $this->t('Scores by Judge'),
// Number of completed scores, number of entries assigned - per judge.
'scores_completed' => $this->t('Scores Completed per Judge'),
// Number of votes in this round, per entry.
'votes' => $this->t('Votes per Entry'),
// Notes (log messages) - per entry.
'log' => $this->t('Notes'),
];
return $reports;
}
/**
* Get list of keys of all available judging reports.
*
* @return array
* Array of the custom keys used to identify available judging reports.
*/
public function getAllJudgingReportKeys() {
return array_keys($this->getAllJudgingReports());
}
/**
* Get list of keys and labels of judging reports that apply to a round.
*
* TODO: consider YAML for this - applicability per round type.
*
* @param string $competition_id
* The ID of competition with the given judging round.
* @param int $round_id
* The judging round number.
*
* @return array
* Array of applicable judging reports, according to round type. (Keys are
* the custom keys that identify reports; values are human labels.)
*
* @see CompetitionReporter::getAllJudgingReports()
*
* @throws \InvalidArgumentException
* If there is no competition entity with the given ID, or if there is no
* judging round with the given number in that competition's configuration.
*/
public function getJudgingReportsByRound($competition_id, $round_id) {
/* @var \Drupal\competition\CompetitionInterface $competition */
$competition = $this->entityTypeManager->getStorage('competition')->load($competition_id);
if (empty($competition)) {
throw new \InvalidArgumentException('Argument $competition_id must be the ID of a competition entity that exists.');
}
// Non-numeric strings cast to 0 (which cannot be a round ID).
$round_id = (int) $round_id;
$judging = $competition->getJudging();
if (empty($judging->rounds) || empty($judging->rounds[$round_id])) {
throw new \InvalidArgumentException('Argument $round_id must be the number (integer) of a configured judging round on the "' . $competition_id . '" competition.');
}
// Filter available reports by round type.
$round_type = $judging->rounds[$round_id]['round_type'];
$reports_all = $this->getAllJudgingReports();
$reports = [];
if ($round_type == 'voting') {
$reports = array_intersect_key($reports_all, array_flip(['votes', 'log']));
}
else {
$reports = array_diff_key($reports_all, array_flip(['votes']));
}
return $reports;
}
/**
* Check if a given judging report applies to a round (based on round type).
*
* @param string $competition_id
* The ID of competition with the given judging round.
* @param int $round_id
* The judging round number.
* @param string $report
* Custom key denoting a particular judging report.
*
* @return bool
* TRUE if the report applies to this round, FALSE if not.
*
* @throws \InvalidArgumentException
* If $report is not the key of a defined judging report.
*
* @see CompetitionReporter::getJudgingReportsByRound()
*/
public function judgingReportAppliesToRound($competition_id, $round_id, $report) {
if (!is_string($report) || !in_array($report, $this->getAllJudgingReportKeys())) {
throw new \InvalidArgumentException('Argument $report value "' . $report . '" is not the key of a defined judging report.');
}
// This method validates its arguments.
$reports_applicable = $this->getJudgingReportsByRound($competition_id, $round_id);
return (!empty($reports_applicable[$report]));
}
/**
* Get column keys and labels for a given judging report.
*
* TODO: consider YAML for this?
*
* @param string $report
* Custom key denoting a particular judging report.
* @param int $round_id
* The round ID for which to pull judging data for this report - for use
* in any column labels.
*
* @return array
* Array of columns for given report; keys are custom keys defining
* particular judging-related data; values are columns labels.
*
* @throws \InvalidArgumentException
* If $report is not one of the available judging reports.
*/
public function getJudgingReportColumns($report, $round_id) {
if (!in_array($report, $this->getAllJudgingReportKeys())) {
throw new \InvalidArgumentException('Argument $report value "' . $report . '" is not a valid judging report.');
}
$cols = [];
switch ($report) {
case 'master':
$cols = [
// This is a placeholder, to be expanded to all user/entry fields.
'entities' => $this->t('[All user and entry fields]'),
];
// Add score columns for all rounds up to and including the given one.
for ($r = 1; $r <= $round_id; $r++) {
$cols['round.' . $r . '.display_average'] = $this->t('Round @round_id Score', [
'@round_id' => $r,
]);
}
break;
case 'scores':
$cols = [
'ceid' => $this->t('Entry ID'),
// 'user.name' => $this->t('Username'),.
'user_name' => $this->t('Username'),
'round.display_total_weighted_points' => $this->t('Round @round_id Total', [
'@round_id' => $round_id,
]),
'round.display_average' => $this->t('Round @round_id Average', [
'@round_id' => $round_id,
]),
'judge_name' => $this->t('Judge'),
'round.criterion_label' => $this->t('Criteria'),
// $this->t('Score - Weighted Points'),.
'criterion.display_weighted_points' => $this->t('Criteria Total'),
// $this->t('Score - Percent'),.
'criterion.display_percent' => $this->t('Criteria Percent'),
// $this->t('Score - Displayed'),.
'criterion.display_points' => $this->t('Criteria Displayed'),
// 'score.display_finalized' => $this->t('Finalized'),.
'score_finalized' => $this->t('Finalized'),
];
break;
case 'judge_scores':
$cols = [
'judge_name' => $this->t('Judge'),
'ceid' => $this->t('Entry ID'),
'score.weighted_points' => $this->t('Round @round_id Overall Score', [
'@round_id' => $round_id,
]),
];
break;
case 'scores_completed':
$cols = [
'judge_name' => $this->t('Judge'),
'count_finalized' => $this->t('Num entries scored and finalized'),
'count_assigned' => $this->t('Num entries assigned'),
];
break;
case 'votes':
$cols = [
'ceid' => $this->t('Entry ID'),
'user_name' => $this->t('Username'),
'votes' => $this->t('Round @round_id Votes', [
'@round_id' => $round_id,
]),
];
break;
case 'log':
$cols = [
'ceid' => $this->t('Entry ID'),
// 'user.name' => $this->t('Username'),.
'user_name' => $this->t('Username'),
// 'log.judge_name' => $this->t('Judge'),.
'log.user_name' => $this->t('Judge'),
'log.timestamp' => $this->t('Timestamp'),
'log.message' => $this->t('Action'),
];
break;
}
return $cols;
}
/**
* Get a select query for a given judging report.
*
* @param string $report
* Custom key denoting a particular judging report.
* @param array $filters
* Filter values by which to limit the report - generally for basic
* properties of entries:
* 'type' - competition entry type
* 'cycle' - cycle of competition
* 'ceid' - entry ID, to get data on a single entry (only applicable to
* some report types)
* 'round_id' - judging round number.
*
* @return \Drupal\Core\Database\Query\SelectInterface
* The query to pull data for the report - not yet executed.
*
* @throws \InvalidArgumentException
* If $report is not one of the available judging reports.
*/
public function getJudgingReportQuery($report, array $filters = []) {
if (!in_array($report, $this->getAllJudgingReportKeys())) {
throw new \InvalidArgumentException('Argument $report value "' . $report . '" is not a valid judging report.');
}
/* @var \Drupal\Core\Database\Query\SelectInterface $select */
$select = $this->dbConnection->select(static::REPORT_JUDGING_TABLE, 'rd');
// Add filters that apply to all reports.
foreach (['type', 'cycle'] as $key) {
if (!empty($filters[$key])) {
$select->condition('rd.' . $key, $filters[$key]);
}
}
// Ensure integers - sometimes these end up as numerical strings.
foreach (['round_id', 'ceid'] as $key) {
if (!empty($filters[$key])) {
$filters[$key] = (int) $filters[$key];
}
}
switch ($report) {
case 'master':
$select->addField('rd', 'ceid');
// Add 'score' field for this entry for each round up to the given.
// Note: because there's a score row per judge assigned for a single
// entry, these joins will create multiple rows per entry. Only one row
// per entry is needed, as every score row also contains overall-round
// data. This is handled while processing the result rows:
// @see generateReportRowsJudging()
for ($r = 1; $r <= $filters['round_id']; $r++) {
$alias = 'rd';
// Initial selected table 'rd' => round 1.
// Join same table once for each additional round.
if ($r > 1) {
$alias = 'rd' . $r;
$select->innerJoin(static::REPORT_JUDGING_TABLE, $alias, $alias . '.ceid = rd.ceid');
}
// Limit this joined table to the round.
$select->condition($alias . '.round_id', $r);
// Add score column.
$select->addField($alias, 'score', 'round_' . $r . '_score');
}
$select->orderBy('rd.ceid', 'asc');
break;
case 'scores':
$select->isNotNull('rd.score');
if (!empty($filters['round_id'])) {
$select->condition('rd.round_id', $filters['round_id']);
}
if (!empty($filters['ceid'])) {
$select->condition('rd.ceid', $filters['ceid']);
}
$select->fields('rd', [
'ceid',
'user_name',
'judge_name',
'score',
'score_finalized',
]);
$select->orderBy('rd.ceid', 'asc');
break;
case 'judge_scores':
$select->isNotNull('rd.score');
if (!empty($filters['round_id'])) {
$select->condition('rd.round_id', $filters['round_id']);
}
if (!empty($filters['ceid'])) {
$select->condition('rd.ceid', $filters['ceid']);
}
$select->fields('rd', [
'judge_name',
'ceid',
'score',
]);
$select->orderBy('rd.judge_name', 'asc');
$select->orderBy('rd.ceid', 'asc');
break;
case 'scores_completed':
$select->isNotNull('rd.score');
if (!empty($filters['round_id'])) {
$select->condition('rd.round_id', $filters['round_id']);
}
$select->fields('rd', [
'judge_name',
]);
// TODO? these expressions for count_assigned, count_finalized are
// MySQL-specific...
$select->addExpression('COUNT(rd.score)', 'count_assigned');
$select->addExpression('SUM( IF(rd.score_finalized = 1, 1, 0) )', 'count_finalized');
$select->groupBy('rd.judge_name');
$select->orderBy('rd.judge_name', 'asc');
break;
case 'votes':
$select->isNotNull('rd.votes');
if (!empty($filters['round_id'])) {
$select->condition('rd.round_id', $filters['round_id']);
}
if (!empty($filters['ceid'])) {
$select->condition('rd.ceid', $filters['ceid']);
}
$select->fields('rd', [
'ceid',
'user_name',
'votes',
]);
$select->orderBy('rd.ceid', 'asc');
break;
case 'log':
$select->isNotNull('rd.log');
if (!empty($filters['round_id'])) {
// Cannot filter directly on round_id because log rows are per entry,
// not per entry per round.
// An inner join to same table with condition on round may result in
// duplicate records, because there is one score record per judge
// per round, which may be multiple per entry.
// Use a subquery, with 'distinct', that applies the same filters plus
// the round filter, to get all entry IDs in this round.
$select_sub = $this->dbConnection->select(static::REPORT_JUDGING_TABLE, 'rd2')
->fields('rd2', ['ceid'])
->distinct();
foreach (['type', 'cycle', 'ceid'] as $key) {
if (!empty($filters[$key])) {
$select_sub->condition('rd2.' . $key, $filters[$key]);
}
}
$select_sub->condition('rd2.round_id', $filters['round_id']);
// Then add subquery as a condition on main query.
// Note: subqueries only work with 'IN' operator, up til 8.3.x.
// @see https://www.drupal.org/node/2770421 (change record)
$select->condition('rd.ceid', $select_sub, 'IN');
}
if (!empty($filters['ceid'])) {
$select->condition('rd.ceid', $filters['ceid']);
}
$select->fields('rd', [
'ceid',
'user_name',
'log',
]);
$select->orderBy('rd.ceid', 'asc');
break;
}
return $select;
}
/**
* Flatten entities.
*
* Retrieve all field values from this set of entities; values are formatted
* for final report output.
*
* Field metadata is retrieved as needed for any types of competition entries
* included in the set, or for users.
*
* Note: this method does not chunk/batch the load and loop of entities.
* Calling code must be responsible if that could be an issue.
*
* @param string $entity_type
* Possible values: 'competition_entry' or 'user'.
* @param array $ids
* The IDs of the entities to process.
*
* @return array
* Entities.
*/
public function flattenEntities($entity_type, array $ids) {
$report_table_cols = $this->getReportTableColumns();
$status_labels = $this->getEntryStatusLabels();
// Each entity is flattened into one row.
$rows = [];
$entities = $this->entityTypeManager->getStorage($entity_type)->loadMultiple($ids);
foreach ($entities as $id => $entity) {
// Retrieve field metadata for this competition entry type, or for user.
// (These are cached locally per type.)
$bundle = ($entity_type == 'competition_entry' ? $entity->getType() : NULL);
$fields_meta = $this->getEntityReportFieldsMeta($entity_type, $bundle)['fields_meta'];
$row = [];
$entry_temp_data = NULL;
$owner = NULL;
if ($entity_type == 'competition_entry') {
// If save-for-later feature is enabled on this competition, entries may
// have partial data stored that is not saved to entry entity yet.
// Check for these values to pull into export.
if (method_exists($entity, 'getTempData')) {
$entry_temp_data = $entity->getTempData();
}
if (empty($entry_temp_data)) {
$entry_temp_data = [];
}
// Load the entry owner's user entity.
$owner = $entity->getOwner();
}
// Allow alteration of all loaded data for this entry just before looping
// fields to output.
// Note that we pass the entry and user objects, which allows direct
// modification, but these variables are not used anywhere else but this
// export, from here on.
// TODO: document this hook.
if ($entity_type == 'competition_entry') {
$alter_data = [
'entry' => &$entity,
'entry_temp_data' => &$entry_temp_data,
'owner' => &$owner,
];
}
else {
$alter_data = [
'account' => &$entity,
];
}
// This argument is passed by reference as well. Do not allow these to be
// altered.
$alter_context = [
'fields_meta' => $fields_meta,
'entity_type' => $entity_type,
];
$this->moduleHandler->alter('competition_entry_report_entry_data', $alter_data, $alter_context);
// Pull value for each field into single csv row.
// Key is one of:
// - {field_name} - for entry fields
// - user.{field_name} - for user fields.
foreach ($fields_meta as $field_key => $meta) {
$on_user = strpos($field_key, 'user.') === 0;
$field_name = ($on_user ? substr($field_key, 5) : $field_key);
// Get array of all field values.
if ($entity_type == 'competition_entry') {
$values = ($on_user ?
$owner->get($field_name)->getValue()
: $entity->get($field_name)->getValue());
}
else {
if ($on_user) {
$values = $entity->get($field_name)->getValue();
}
else {
// Fields that would be on entry are not in users report.
$values = NULL;
}
}
// If no value on entity for this field, check for value in temp data.
// (This only applies to fields on the entry, not the user.)
$values_temp = NULL;
if ($entity_type == 'competition_entry' && !$on_user && empty($values) && !empty($entry_temp_data[$field_name])) {
$value_temp = $entry_temp_data[$field_name]['input'];
// IMPORTANT: This structure is a bit different - it does not
// handle multiple values per field, due to how the temp data
// save method loops through the form value structure.
// @see CompetitionEntry::setTempData()
// So, we expect the temp value to be a string if only one property,
// or an array if multiple properties. Restructure to align with
// saved field values' structure, for sake of processing below.
if (is_string($value_temp) || is_int($value_temp)) {
if (!empty($value_temp)) {
// We don't have the property's name here; use a placeholder.
$values_temp = [
[
'_temp_value' => $value_temp,
],
];
}
}
elseif (is_array($value_temp)) {
$adjusted = FALSE;
if ($meta['field_type'] == 'file') {
// BANDAID:
// File fields have a 'display' property which is currently
// getting saved into the temp data, even if no file is uploaded.
// Filter that out.
if (isset($value_temp['display']) && empty($value_temp['fids'])) {
$values_temp = [];
$adjusted = TRUE;
}
// Single file ID is stored in 'fids', which is not one of the
// property names.
elseif (!empty($value_temp['fids'])) {
$values_temp = [
[
'fids' => $value_temp['fids'],
],
];
$adjusted = TRUE;
}
}
// List fields:
// For checkboxes (and maybe for multi-value select lists?), the
// form widget stores multiple values as a single flat array.
// (This would be interpreted as property => value in our code
// here.) Restructure as multiple field values.
if (in_array($meta['field_type'], [
'list_string',
'list_integer',
'list_float',
])) {
$values_temp = [];
foreach ($value_temp as $v) {
$values_temp[] = [
'_temp_value' => $v,
];
}
$adjusted = TRUE;
}
// Generically, pare down to expected property keys.
if (!$adjusted) {
if (!empty($meta['properties'])) {
$value_temp = array_intersect_key($value_temp, array_flip($meta['properties']));
}
}
// If not handled specifically above, and nonempty after properties
// filter, set final structured value.
if (!$adjusted && !empty($value_temp)) {
$values_temp = [
$value_temp,
];
}
}
}
// Initialize the output for this field (or this field split into
// properties) in the row.
if (count($meta['properties']) == 1) {
$row[$field_key] = '';
}
else {
foreach ($meta['properties'] as $property_name) {
$row[$field_key . '.' . $property_name] = '';
}
}
if (!empty($values) || !empty($values_temp)) {
if ($entity_type == 'competition_entry' && $field_key == 'data') {
// Entry `data` field stores a serialized blob (including all
// judging data). Pull this specially, so it can be unserialized and
// altered specifically for the reporting context.
// Note: this remains a structured array - it will not be flattened
// for storage into the reporting table. Extracting judging data
// values into separate columns (and often separate rows) is handled
// at the point of generating the CSV.
// $row[$field_key] = $entity->getDataReportingAltered();
$data = $entity->getData();
// Judging data is handled in separate reporting table altogether,
// so don't store it here.
if (!empty($data['judging'])) {
unset($data['judging']);
}
$row[$field_key] = $data;
}
elseif ($meta['field_type'] == 'file') {
// For files, the field value will only get us the file's entity
// ID - which isn't too useful. Load the file entities and pull
// the filenames too.
if (!empty($values)) {
// Getting one referenced entity out of the field value requires
// quite a few chained calls, but there is a convenience method
// to load all referenced entities.
// Thx: http://drupal.stackexchange.com/questions/186315/how-to-get-instance-of-referenced-entity
// @var \Drupal\file\Entity\File[] $files
if ($entity_type == 'competition_entry') {
$files = ($on_user ?
$owner->get($field_name)->referencedEntities()
: $entity->get($field_name)->referencedEntities());
}
else {
$files = $entity->get($field_name)->referencedEntities();
}
}
elseif (!empty($values_temp)) {
$fids = [];
foreach ($values_temp as $value) {
// Single file ID is stored in 'fids'.
$fids[] = $value['fids'];
}
$files = $this->entityTypeManager->getStorage('file')->loadMultiple($fids);
}
$multi_value = count($files) > 1;
foreach (array_values($files) as $delta => $file) {
// TODO:
// For now, denote multiple values by labeling with delta.
// This is somewhat awkward to read in a spreadsheet but more
// noticeable than line breaks, which entirely obscure
// additional values.
if ($multi_value) {
$row[$field_key] .= (' {' . $delta . '} ');
}
$row[$field_key] .= ($file->id() . ' - ' . $file->get('filename')->getValue()[0]['value']);
}
}
elseif ($field_key == 'status') {
// Output entry status label, rather than integer key.
// (Entry status will never be in temp data)
$row[$field_key] .= $status_labels[(int) $values[0]['value']];
}
elseif (in_array($meta['field_type'], [
'created',
'changed',
'timestamp',
])) {
// TODO: this field_type check does not handle properties.
// Timestamp values:
// Convert to human-readable date/time string.
// (For now, all these fields are auto-generated, not user-input -
// note we don't include 'datetime' type - so they'll never be
// stored in entry temp data.)
$timestamp = (int) $values[0]['value'];
// TODO: perhaps make a custom format that only controls this.
$row[$field_key] .= $this->dateFormatter->format($timestamp, 'short');
}
else {
// Generic processing (almost).
$values_process = (!empty($values) ? $values : $values_temp);
$multi_value = count($values_process) > 1;
foreach ($values_process as $delta => $value) {
if (count($meta['properties']) == 1) {
// Simplest case: one property; output to single column.
// TODO:
// For now, denote multiple values by labeling with delta.
// This is somewhat awkward to read in a spreadsheet but more
// noticeable than line breaks, which entirely obscure
// additional values.
if ($multi_value) {
$row[$field_key] .= (' {' . $delta . '} ');
}
$property_name = reset($meta['properties']);
$value_out = '';
if (isset($value[$property_name])) {
$value_out = $value[$property_name];
}
elseif (isset($value['_temp_value'])) {
// Special key used in temp saved-for-later data - see
// restructuring above.
$value_out = $value['_temp_value'];
}
if ($meta['field_type'] == 'boolean') {
// Boolean fields:
// Should use the configured 'On' label and 'Off' label.
// However, currently, the 'On' label is sometimes configured
// as the full text desired to show by the checkbox, which is
// too long for export.
if ($value_out === 1 || $value_out === '1') {
// $value_out = '[ X ]';.
$value_out = 'true';
}
elseif ($value_out === 0 || $value_out === '0') {
// $value_out = '[ ]';.
$value_out = 'false';
}
}
$row[$field_key] .= $value_out;
}
else {
// Multiple properties: output to separate column per property
// (as was done for the column labels).
foreach ($meta['properties'] as $property_name) {
$property_key = $field_key . '.' . $property_name;
// TODO:
// For now, denote multiple values by prefix with the delta.
// This is extra awkward for multiple values in a multi-
// property field, as those values should be grouped by delta,
// but we split into a column per property for readability...
if ($multi_value) {
$row[$property_key] .= (' {' . $delta . '} ');
}
if (isset($value[$property_name])) {
$row[$property_key] .= $value[$property_name];
}
}
}
}
}
}
// Trim because of multiple-value delineators.
if (isset($row[$field_key])) {
if (is_string($row[$field_key])) {
$row[$field_key] = trim($row[$field_key]);
}
}
elseif (!empty($meta['properties'])) {
foreach ($meta['properties'] as $property_name) {
$property_key = $field_key . '.' . $property_name;
if (isset($row[$property_key])) {
if (is_string($row[$property_key])) {
$row[$property_key] = trim($row[$property_key]);
}
}
}
}
}
// Post-process: restructure to match report data table columns.
$report_row = [];
// `data` column in report table holds JSON blob of all values not stored
// in other columns.
$json_data = $row;
// If processing user entities, copy the user ID value so it'll be pulled
// by 'uid' col name in the loop below.
if ($entity_type == 'user') {
$row['uid'] = $row['user.uid'];
}
// A few columns map directly to field values - retrieve these first.
foreach ($report_table_cols as $col) {
// JSON data blob is populated after.
if ($col == 'data') {
continue;
}
// Set default.
// Note: entry-specific col values will remain NULL for
// users-without-entries rows.
$report_row[$col] = NULL;
// `ceid` is part of primary key, so we use 0 instead of NULL.
// @see competition_schema()
if ($col == 'ceid') {
$report_row['ceid'] = 0;
}
// Set value in report row; remove it from JSON data.
if (array_key_exists($col, $row)) {
$report_row[$col] = $row[$col];
unset($json_data[$col]);
}
}
// Remaining fields go into the JSON blob column.
$json = Json::encode($json_data);
// This wraps PHP's json_encode(), which returns FALSE on failure.
if ($json === FALSE) {
$this->logger->error('%entity_type %id - error occurred attempting to convert field values to JSON.<br/><br/>Error message:<br/>@message<br/><br/>Field values:<br/>@values', [
'%entity_type' => $entity_type,
'%id' => $entity->id(),
'@message' => json_last_error_msg(),
'@values' => var_export($json_data, TRUE),
]);
$report_row['data'] = NULL;
}
else {
$report_row['data'] = $json;
}
$rows[$entity->id()] = $report_row;
}
return $rows;
}
/**
* Get all judging reporting data rows for this set of competition entries.
*
* Judging data is extracted from $entry->data['judging'] and mostly
* flattened.
*
* If an entry is in any rounds, its judging data will be stored in multiple
* rows of different types:
* - score - contains an assigned judge's score data, for a single round
* - log - contains all log messages, across all rounds this entry is in
* - votes - contains total number of votes for this entry, for a single
* voting round (if entry is in any)
*
* @param array $ids
* Competition entry IDs for which to generate report rows.
*
* @return array
* Array of arrays; each sub-array is a row to be inserted in the judging
* reporting table.
*/
public function flattenEntitiesJudging(array $ids) {
$rows = [];
// Lazy-load judging stuff by competition, since we may be processing
// entries in more than one competition.
$judging_cache = [];
$storage_user = $this->entityTypeManager->getStorage('user');
$cols = $this->getReportJudgingTableColumns();
/* @var \Drupal\competition\CompetitionEntryInterface[] $entries */
$entries = $this->entityTypeManager->getStorage('competition_entry')->loadMultiple($ids);
foreach ($entries as $entry) {
$data = $entry->getData();
if (!empty($data['judging'])) {
$competition = $entry->getCompetition();
if (empty($judging_cache[$competition->id()])) {
$judging = $competition->getJudging();
if (!empty($round_config['weighted_criteria'])) {
foreach ($judging->rounds as $round_id => &$round_config) {
$i = 0;
foreach ($round_config['weighted_criteria'] as $label => $weight) {
$round_config['criteria_weights']['c' . $i] = (int) $weight;
$i++;
}
}
}
$judging_cache[$competition->id()] = $judging;
}
$judging = $judging_cache[$competition->id()];
// "Display name" method will retrieve the anonymous user label (no
// translation there apparently), while "account name" method returns
// the user.name property.
$owner = $entry->getOwner();
$user_name = $owner->id() > 0 ? $owner->getAccountName() : ('(' . $owner->getDisplayName() . ')');
$row_base = [
'type' => $entry->getType(),
'cycle' => $entry->getCycle(),
'ceid' => $entry->id(),
'user_name' => $user_name,
];
// Score and votes rows.
foreach ($data['judging']['rounds'] as $round_id => $round) {
$round_config = $judging->rounds[$round_id];
if ($round_config['round_type'] == 'criteria' || $round_config['round_type'] == 'pass_fail') {
// Add different displays of each judge's criteria scores.
/*
* Score structure example (1 judge, 3 criteria):
*
* Points | Percent | Weight | Weighted Points
* ------ ------- ------ ---------------
* c0 | 3/4 = 75% 30% .75 * 30 = 22.5
* c1 | 1/4 = 25% 20% .25 * 20 = 5
* c2 | 2/4 = 50% 50% .50 * 50 = 25
* ---------------
* Overall weighted score = 52.5 / 100
*/
if (!empty($round['scores'])) {
// Collect all score rows in intermediary array.
$score_rows = [];
$points_max = (int) $round_config['criterion_options'];
// Collect weighted score (total points / 100) for each judge, to
// determine total weighted points.
// A judge's score must be complete (values submitted for all
// criteria) to factor into the total.
$overall_scores = [];
foreach ($round['scores'] as &$score) {
// Initialize row for this judge's score.
$row = $row_base;
$row['round_id'] = (int) $round_id;
$account = $storage_user->load($score->uid);
$row['judge_name'] = $account ? $account->getAccountName() : NULL;
$row['score'] = [];
// In case the user entity didn't load (deleted user), include
// the uid for potential use in output.
$row['score']['uid'] = $score->uid;
$overall_scores[$score->uid] = 0;
if (!empty($score->criteria)) {
foreach ($score->criteria as $key => $weighted_points) {
if ($weighted_points === NULL) {
$row['score']['criteria'][$key] = [];
$overall_scores[$score->uid] = NULL;
}
else {
$weight = $round_config['criteria_weights'][$key];
$percent = $weighted_points / $weight;
$points = $percent * $points_max;
// This should convert back to very close to the original
// integer value... but because floats aren't 'precise',
// it may not be. Round before casting.
$points = (int) round($points);
$row['score']['criteria'][$key] = [
// criterion.weighted_points
// 22.5.
'weighted_points' => $weighted_points,
// criterion.display_weighted_points
// '22.5 / 30'.
'display_weighted_points' => $weighted_points . ' / ' . $weight,
// criterion.display_percent
// '75%'
// TODO: rounding/decimal points?
'display_percent' => ($percent * 100) . '%',
// criterion.display_points
// '3 / 4'.
'display_points' => $points . ' / ' . $points_max,
];
if ($overall_scores[$score->uid] !== NULL) {
$overall_scores[$score->uid] += $weighted_points;
}
}
}
}
// score.weighted_points.
if ($overall_scores[$score->uid] !== NULL) {
$row['score']['weighted_points'] = $overall_scores[$score->uid];
// Add boolean for pass/fail rounds.
// These have only one criterion, so pass/fail status is
// equivalent for the one criterion and overall score.
if ($round_config['round_type'] == 'pass_fail') {
$pass = $score->criteria['c0'] == 100;
$row['score']['criteria']['c0']['pass'] = $pass;
$row['score']['pass'] = $pass;
}
}
else {
// Remove incomplete scores from counting towards average.
unset($overall_scores[$score->uid]);
}
// score_finalized.
$row['score_finalized'] = $score->finalized ? 1 : 0;
// Add row to intermediary array.
$score_rows[] = $row;
}
// These properties apply to the entry per round, not per judge.
// Because we are flattening, we don't have a place to store
// per-round values - so store them in 'score' in each row.
$round_values = [];
// round.display_average.
if ($round['computed'] !== NULL) {
$round_values['display_average'] = number_format(round($round['computed'], 2), 2) . '%';
// Add boolean for pass/fail rounds.
// Currently, overall pass is presumed to require an average
// of 100%, i.e. all assigned judges scored as pass.
if ($round_config['round_type'] == 'pass_fail') {
$round_values['pass'] = ($round['computed'] == 100);
}
}
// round.display_total_weighted_points.
if (count($overall_scores) > 0) {
$round_values['display_total_weighted_points'] = array_sum($overall_scores) . ' / ' . (count($overall_scores) * 100);
}
if (!empty($round_values)) {
foreach ($score_rows as &$row) {
$row['score']['round'] = $round_values;
}
}
}
// Finally, add all score rows.
if (!empty($score_rows)) {
foreach ($score_rows as &$row) {
$row['score'] = Json::encode($row['score']);
// Error encoding JSON.
if ($row['score'] === NULL) {
// TODO: logger
// TODO: if admin, set message.
}
// Fill in cols not applicable to this row type with NULL.
foreach (array_diff($cols, array_keys($row)) as $col) {
$row[$col] = NULL;
}
$rows[] = $row;
}
}
}
elseif ($round_config['round_type'] == 'voting') {
$row = $row_base;
$row['round_id'] = (int) $round_id;
// We store only the current total number of votes for this entry.
$row['votes'] = isset($round['votes']) ? $round['votes'] : NULL;
// Fill in cols not applicable to this row type with NULL.
foreach (array_diff($cols, array_keys($row)) as $col) {
$row[$col] = NULL;
}
$rows[] = $row;
}
}
// Log row.
$logs = $entry->getJudgingLog();
if (!empty($logs)) {
$row = $row_base;
// Logs are stored in chronological order; output in reverse for
// reports, matching admin UI notes display.
$logs = array_reverse($logs);
// Plug in user names.
foreach ($logs as &$log) {
if (!empty($log['uid'])) {
$account = $storage_user->load($log['uid']);
if (!empty($account)) {
$log['user_name'] = $account->getAccountName();
}
}
}
$logs_json = Json::encode($logs);
// Error encoding JSON.
if ($logs_json === NULL) {
// TODO: logger
// TODO: if admin, set message.
}
$row['log'] = $logs_json;
// Fill in cols not applicable to this row type with NULL.
foreach (array_diff($cols, array_keys($row)) as $col) {
$row[$col] = NULL;
}
$rows[] = $row;
}
}
}
return $rows;
}
/**
* Get count of records of given entity type in report data table.
*
* @param string $entity_type
* Entity type with possible values 'competition_entry' or 'user'
* user records are those users without any competition entry.
* @param array $params
* Additional conditions by which to filter the count. Only params valid
* for the given $entity_type will be applied.
* For competition_entry:
* - type
* - cycle
* - status.
*
* @return int|null
* The count of records in `competition_entry_report_data`
*
* @throws \InvalidArgumentException
* If $entity_type is not one of 'competition_entry' or 'user'.
*/
public function getCountReportRecords($entity_type, array $params = []) {
if (!in_array($entity_type, ['competition_entry', 'user'])) {
throw new \InvalidArgumentException('Argument $entity_type must be either "competition_entry" or "user".');
}
$count = NULL;
$query = $this->dbConnection->select($this::REPORT_TABLE, 'rd');
if ($entity_type == 'competition_entry') {
$query->fields('rd', ['ceid'])
// ->isNotNull('ceid')
->condition('ceid', 0, '<>');
// Note: currently there's no validation of param values - if any are
// unexpected values, they should simply result in the query returning
// no results.
if (!empty($params)) {
// Convert status key to its label, because reporting table holds
// ready-for-output values.
// TODO: this is not great
// (Use isset() to include status '0' for 'Created')
if (isset($params['status'])) {
$statuses = $this->competitionSettings->get('statuses');
if (!empty($statuses[$params['status']])) {
$params['status'] = $statuses[$params['status']];
}
}
foreach ($params as $k => $v) {
if (in_array($k, ['type', 'cycle', 'status'])) {
$query->condition($k, $v);
}
}
}
}
elseif ($entity_type == 'user') {
$query->fields('rd', ['uid'])
// ->isNull('ceid')
->condition('ceid', 0, '=');
}
$count = (int) $query->countQuery()
->execute()
->fetchField();
return $count;
}
/**
* Get a count of entries represented in judging report data table.
*
* This method does not check for data integrity of records - i.e. that all
* score rows and log row exist for the given entries. If 'round_id' filter
* exists, result is based on score rows (because only score rows have a
* round_id column value); otherwise it's based on any records that exist.
*
* @param array $params
* Possible keys:
* 'type' - competition entry type/bundle
* 'cycle' - competition cycle
* 'round_id' - judging round.
*
* @return int
* The number of entities represented in data table with given filters.
*/
public function getCountReportJudgingEntities(array $params = []) {
// Limit to supported cols.
$params = array_intersect_key($params, array_flip([
'type',
'cycle',
'round_id',
]));
// Ensure integers for relevant params.
foreach (['round_id'] as $col) {
if (!empty($params[$col])) {
$params[$col] = (int) $params[$col];
}
}
/* @var \Drupal\Core\Database\Query\SelectInterface $query */
$query = $this->dbConnection->select(static::REPORT_JUDGING_TABLE, 'rd')
->fields('rd', ['ceid'])
->distinct();
foreach ($params as $col => $value) {
$query->condition('rd.' . $col, $value);
}
// Convert to count query after everything else, because Drupal wraps the
// original query as a subquery.
$query = $query->countQuery();
return $query->execute()->fetchField();
}
/**
* Update values in reporting table for all current entries and users.
*
* - Query for all entries and users-without-entries that need to be added or
* updated in report data table
* - Flatten them (get all field values into a single array, corresponding to
* report table columns)
* - Delete records that need to be updated, then insert all new + updated
* entity records.
*
* This is invoked by Drush command: 'competition-update-report-data'
*
* @param bool $use_batch
* Whether to invoke a batch process to run this. This should be TRUE
* when calling from a webpage context (e.g. admin UI button), where PHP
* timeout could happen.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse|array
* If $batch is TRUE, the redirect response from batch_process().
* If $batch is FALSE, array of counts of data before/during/after this run,
* formatted with '@' for easily passing as $this->t() args. All keys:
* - @count_init_competition_entry
* - @count_init_user
* - @count_init_judging_ids
* - @count_deleted_competition_entry
* - @count_deleted_user
* - @count_deleted_judging_ids
* - @count_deleted_judging_records
* - @count_add_user
* - @count_update_user
* - @count_add_competition_entry
* - @count_deleted_user_now_entry
* - @count_update_competition_entry
* - @count_add_judging_ids
* - @count_update_judging_ids
* - @count_removed_judging_ids
* - @count_removed_judging_records
* - @count_total_competition_entry
* - @count_this_run_competition_entry
* - @count_total_user
* - @count_this_run_user
* - @count_total_judging_ids
* - @count_this_run_judging_ids
*
* TODO: currently setting `ceid` to `0` for users-without-entries records.
*
* @see competition_schema()
*/
public function updateAllReportData($use_batch) {
$log_args = [];
// --- Log starting counts of records.
$log_args['@count_init_competition_entry'] = $this->getCountReportRecords('competition_entry');
$log_args['@count_init_user'] = $this->getCountReportRecords('user');
$log_args['@count_init_judging_ids'] = $this->getCountReportJudgingEntities();
// --- Delete report records for entities that have been deleted.
// Drupal's DeleteQuery does not support joins - so select first, then
// delete that set of entity IDs.
// Entries
/*
DELETE rd
FROM competition_entry_report_data rd
LEFT JOIN competition_entry ce
ON ce.ceid = rd.ceid
WHERE rd.ceid <> 0
AND ce.ceid IS NULL
*/
$select_entries_delete = $this->dbConnection->select($this::REPORT_TABLE, 'rd')
->fields('rd', ['ceid'])
// ->isNotNull('rd.ceid')
->condition('rd.ceid', 0, '<>');
$select_entries_delete->leftJoin('competition_entry', 'ce', 'ce.ceid = rd.ceid');
$select_entries_delete->isNull('ce.ceid');
$entries_delete_ids = $select_entries_delete->execute()->fetchCol();
$log_args['@count_deleted_competition_entry'] = 0;
if (!empty($entries_delete_ids)) {
$log_args['@count_deleted_competition_entry'] = $this->dbConnection->delete($this::REPORT_TABLE)
->condition('ceid', $entries_delete_ids, 'IN')
->execute();
}
// Users
/*
DELETE rd
FROM competition_entry_report_data rd
LEFT JOIN users u
ON u.uid = rd.uid
WHERE rd.ceid = 0
AND u.uid IS NULL
*/
$select_users_delete = $this->dbConnection->select($this::REPORT_TABLE, 'rd')
->fields('rd', ['uid'])
// ->isNull('rd.ceid')
->condition('rd.ceid', 0);
$select_users_delete->leftJoin('users', 'u', 'u.uid = rd.uid');
$select_users_delete->isNull('u.uid');
$users_delete_ids = $select_users_delete->execute()->fetchCol();
$log_args['@count_deleted_user'] = 0;
if (!empty($users_delete_ids)) {
$log_args['@count_deleted_user'] = $this->dbConnection->delete($this::REPORT_TABLE)
// ->isNull('ceid')
->condition('ceid', 0)
->condition('uid', $users_delete_ids, 'IN')
->execute();
}
// 3) Entries - judging table
/*
DELETE rd
FROM competition_entry_report_judging_data rd
LEFT JOIN competition_entry ce
ON ce.ceid = rd.ceid
WHERE ce.ceid IS NULL
*/
$select_judging_deleted = $this->dbConnection->select($this::REPORT_JUDGING_TABLE, 'rd')
->fields('rd', ['ceid'])
->distinct();
$select_judging_deleted->leftJoin('competition_entry', 'ce', 'ce.ceid = rd.ceid');
$select_judging_deleted->isNull('ce.ceid');
$judging_deleted_ids = $select_judging_deleted->execute()->fetchCol();
$log_args['@count_deleted_judging_ids'] = count($judging_deleted_ids);
$log_args['@count_deleted_judging_records'] = 0;
if (!empty($judging_deleted_ids)) {
$log_args['@count_deleted_judging_records'] = $this->dbConnection->delete($this::REPORT_JUDGING_TABLE)
->condition('ceid', $judging_deleted_ids, 'IN')
->execute();
}
// --- Collect IDs of all entities to be added/updated in report tables.
// -- Entity report data table --.
// 1) User accounts - add:
// - no entry
// - not in report table
/*
SELECT u.uid
FROM users u
LEFT JOIN competition_entry ce
ON ce.uid = u.uid
LEFT JOIN $this::REPORT_TABLE rd
ON rd.uid = u.uid
WHERE u.uid > 0
AND ce.ceid IS NULL
AND rd.uid IS NULL
*/
$select_users_add = $this->dbConnection->select('users', 'u')
->fields('u', ['uid']);
$select_users_add->condition('u.uid', 0, '>');
// No entry.
$select_users_add->leftJoin('competition_entry', 'ce', 'ce.uid = u.uid');
$select_users_add->isNull('ce.ceid');
// Not in report table.
$select_users_add->leftJoin($this::REPORT_TABLE, 'rd', 'rd.uid = u.uid');
$select_users_add->isNull('rd.uid');
$users_add_ids = $select_users_add->execute()->fetchCol();
$log_args['@count_add_user'] = count($users_add_ids);
// 2) User accounts - update:
// - in report table
// - no entry
// - user entity changed after report row exported
/*
SELECT rd.uid
FROM $this::REPORT_TABLE rd
LEFT JOIN competition_entry ce
ON ce.uid = rd.uid
INNER JOIN users_field_data ud
ON ud.uid = rd.uid
WHERE ce.ceid IS NULL
AND ud.changed >= rd.exported
*/
$select_users_update = $this->dbConnection->select($this::REPORT_TABLE, 'rd')
->fields('rd', ['uid']);
// No entry.
$select_users_update->leftJoin('competition_entry', 'ce', 'ce.uid = rd.uid');
$select_users_update->isNull('ce.ceid');
// User entity changed after report row exported.
$select_users_update->innerJoin('users_field_data', 'ud', 'ud.uid = rd.uid');
$select_users_update->where('ud.changed >= rd.exported');
$users_update_ids = $select_users_update->execute()->fetchCol();
$log_args['@count_update_user'] = count($users_update_ids);
// 3) Entries - add:
// - not in report table
/*
SELECT ce.ceid, ce.uid
FROM competition_entry ce
LEFT JOIN $this::REPORT_TABLE rd
ON rd.ceid = ce.ceid
WHERE rd.ceid IS NULL
*/
$select_entries_add = $this->dbConnection->select('competition_entry', 'ce')
->fields('ce', ['ceid', 'uid']);
// Not in report table.
$select_entries_add->leftJoin($this::REPORT_TABLE, 'rd', 'rd.ceid = ce.ceid');
$select_entries_add->isNull('rd.ceid');
// Retrieve result as ceid => uid.
$entries_add_result = $select_entries_add->execute()->fetchAllKeyed();
$entries_add_ids = array_keys($entries_add_result);
$log_args['@count_add_competition_entry'] = count($entries_add_ids);
$entries_add_uids = array_unique(array_values($entries_add_result));
// 3b) User accounts - delete:
// (For entries newly added to the report table, remove user-only rows
// for those uids - as they are no longer users without entries.)
// - previously did not have an entry (in report table)
// - now have an entry
/*
DELETE
FROM $this::REPORT_TABLE
WHERE ceid IS NULL
AND uid IN (:entries_add_uids)
*/
$num_user_rows_deleted = 0;
if (!empty($entries_add_uids)) {
$num_user_rows_deleted = $this->dbConnection->delete($this::REPORT_TABLE)
// ->isNull('ceid')
->condition('ceid', 0, '=')
->condition('uid', $entries_add_uids, 'IN')
->execute();
}
$log_args['@count_deleted_user_now_entry'] = $num_user_rows_deleted;
// 4) Entries - update:
// - in report table
// - entry OR user entity changed after report row exported
/*
SELECT rd.ceid
FROM $this::REPORT_TABLE rd
INNER JOIN competition_entry ce
ON ce.ceid = rd.ceid
WHERE ce.changed >= rd.exported
*/
$select_entries_update = $this->dbConnection->select($this::REPORT_TABLE, 'rd')
->fields('rd', ['ceid']);
// Entry or user changed after report row exported.
$select_entries_update->innerJoin('competition_entry', 'ce', 'ce.ceid = rd.ceid');
$select_entries_update->innerJoin('users_field_data', 'ud', 'ud.uid = rd.uid');
$select_entries_update->where('ce.changed >= rd.exported OR ud.changed >= rd.exported');
$entries_update_ids = $select_entries_update->execute()->fetchCol();
$log_args['@count_update_competition_entry'] = count($entries_update_ids);
// -- Judging reporting table --.
// Retrieve all entries that are all involved in judging - any competition,
// any cycle, round or queue.
$judging_ids = [];
$judging_avail_stmt = $this->dbConnection
->select('competition_entry', 'ce')
->fields('ce', [
'ceid',
'data',
])
->condition('ce.status', CompetitionEntryInterface::STATUS_FINALIZED)
->execute();
while ($row = $judging_avail_stmt->fetchObject()) {
$data = unserialize($row->data);
if (!empty($data['judging']['rounds']) || !empty($data['judging']['queues'])) {
$judging_ids[] = $row->ceid;
}
}
$select_report_judging = $this->dbConnection->select($this::REPORT_JUDGING_TABLE, 'rd')
->fields('rd', ['ceid'])
->distinct();
$report_judging_ids = $select_report_judging->execute()->fetchCol();
// If any entries are no longer in a judging round/queue, remove from
// judging report table.
// This needs to run first so these records don't end up in the set of
// records to be updated.
$judging_remove_ids = array_diff($report_judging_ids, $judging_ids);
$log_args['@count_removed_judging_ids'] = count($judging_remove_ids);
$log_args['@count_removed_judging_records'] = 0;
if (!empty($judging_remove_ids)) {
$log_args['@count_removed_judging_records'] = $this->dbConnection->delete($this::REPORT_JUDGING_TABLE)
->condition('ceid', $judging_remove_ids, 'IN')
->execute();
}
// 5) Entries - judging - add:
$judging_add_ids = array_diff($judging_ids, $report_judging_ids);
$log_args['@count_add_judging_ids'] = count($judging_add_ids);
// 6) Entries - judging - update:
// There are multiple rows per entry. For simplicity, if ANY row for an
// entry is out of date, we will update all its rows.
$select_judging_update = $this->dbConnection->select($this::REPORT_JUDGING_TABLE, 'rd')
->fields('rd', ['ceid'])
->distinct();
// Entry changed after report row exported
// (Note: unfortunately no easy way to distinguish whether entry.changed
// was due to judging data change or any other edit to field values.)
$select_judging_update->innerJoin('competition_entry', 'ce', 'ce.ceid = rd.ceid');
$select_judging_update->where('ce.changed >= rd.exported');
$judging_update_ids = $select_judging_update->execute()->fetchCol();
$log_args['@count_update_judging_ids'] = count($judging_update_ids);
// --- Set up for adds/updates.
// Mark all records with the same timestamp.
$export_timestamp = REQUEST_TIME;
// Record timestamp. (This is displayed on admin reports page.)
$this->setReportDataLastUpdated($export_timestamp);
// Update rows - delete + insert instead.
// (Updating different values for a number of rows is tedious - must be done
// as a separate update query per row or as a complicated CASE statement.
// Instead, delete all rows we want to update and re-insert them, along with
// the new inserts.)
if (!empty($users_update_ids)) {
$this->dbConnection->delete($this::REPORT_TABLE)
->condition('uid', $users_update_ids, 'IN')
// ->isNull('ceid')
->condition('ceid', 0, '=')
->execute();
}
if (!empty($entries_update_ids)) {
$this->dbConnection->delete($this::REPORT_TABLE)
->condition('ceid', $entries_update_ids, 'IN')
->execute();
}
if (!empty($judging_update_ids)) {
$this->dbConnection->delete($this::REPORT_JUDGING_TABLE)
->condition('ceid', $judging_update_ids, 'IN')
->execute();
}
if (empty($entries_add_ids) && empty($entries_update_ids)
&& empty($users_add_ids) && empty($users_update_ids)
&& empty($judging_add_ids) && empty($judging_update_ids)) {
// Nothing to add/update.
// We can therefore bypass the batch entirely...
if ($use_batch) {
// ...but if any records were deleted, we still want to display that
// summary as a Drupal message for the user,
// since the batch-finished function will not be called to do so.
// Full log message is still handled later in this function.
if ($log_args['@count_deleted_user'] > 0
|| $log_args['@count_deleted_competition_entry'] > 0
|| $log_args['@count_deleted_judging_ids'] > 0
|| $log_args['@count_removed_judging_ids'] > 0) {
$this->updateAllReportDataLog($log_args, [
'drupal_message' => [
'details' => FALSE,
],
]);
}
else {
// TODO: fold this case into log method, so above if() is unneeded.
drupal_set_message($this->t('There is no new reporting data to update since the last run.'), 'warning');
}
}
}
else {
// -----------------------------------------------------------------------
// --- BATCH VERSION: split off now to process the collected IDs.
if ($use_batch) {
$batch = [
'title' => $this->t('Updating report data...'),
'operations' => [
// Operation 1: users.
[
// Callback.
[static::class, 'updateAllReportDataBatchProcess'],
// Arguments to pass to callback.
[
'user',
$users_add_ids,
$users_update_ids,
$export_timestamp,
$log_args,
],
],
// Operation 2: entries.
[
// Callback.
[static::class, 'updateAllReportDataBatchProcess'],
// Arguments to pass to callback.
[
'competition_entry',
$entries_add_ids,
$entries_update_ids,
$export_timestamp,
$log_args,
],
],
// Operation 3: judging.
[
// Callback.
[static::class, 'updateAllReportDataBatchProcess'],
// Arguments to pass to callback.
[
'judging',
$judging_add_ids,
$judging_update_ids,
$export_timestamp,
$log_args,
],
],
],
'finished' => [static::class, 'updateAllReportDataBatchFinished'],
// 'file' =>.
];
batch_set($batch);
// Pass the destination to which to redirect after completion.
return batch_process(Url::fromRoute('entity.competition_entry.reports'));
}
// --- If using batch, processing ends here ------------------------------
// -----------------------------------------------------------------------
// --- NON-BATCH VERSION -------------------------------------------------
// --- Flatten entities and insert/update data in report table.
// Users:
if (!empty($users_add_ids) || !empty($users_update_ids)) {
$user_rows_all = $this->flattenEntities('user', array_merge($users_add_ids, $users_update_ids));
// Now insert all user rows.
$insert_users = $this->dbConnection->insert($this::REPORT_TABLE);
$insert_users->fields($this->getReportTableColumns());
foreach ($user_rows_all as $row) {
$row['exported'] = $export_timestamp;
$insert_users->values($row);
}
// 'If the query was given multiple sets of values to insert,
// the return value is undefined.'.
$insert_users->execute();
}
// Entries:
if (!empty($entries_add_ids) || !empty($entries_update_ids)) {
$entry_rows_all = $this->flattenEntities('competition_entry', array_merge($entries_add_ids, $entries_update_ids));
// Now insert all entry rows.
$insert_entries = $this->dbConnection->insert($this::REPORT_TABLE);
$insert_entries->fields($this->getReportTableColumns());
foreach ($entry_rows_all as $row) {
$row['exported'] = $export_timestamp;
$insert_entries->values($row);
}
$insert_entries->execute();
}
// Judging:
if (!empty($judging_add_ids) || !empty($judging_update_ids)) {
$judging_rows_all = $this->flattenEntitiesJudging(array_merge($judging_add_ids, $judging_update_ids));
// Now insert all judging rows.
$insert_judging = $this->dbConnection->insert($this::REPORT_JUDGING_TABLE);
$insert_judging->fields($this->getReportJudgingTableColumns());
foreach ($judging_rows_all as $row) {
$row['exported'] = $export_timestamp;
$insert_judging->values($row);
}
$insert_judging->execute();
}
}
// Finally, log actual numbers.
// Entries:
$query_count_entries = $this->dbConnection->select($this::REPORT_TABLE, 'rd')
->fields('rd', ['ceid'])
// ->isNotNull('rd.ceid')
->condition('rd.ceid', 0, '<>');
$query_count_entries_this = clone $query_count_entries;
$query_count_entries_this->condition('rd.exported', $export_timestamp);
$log_args['@count_total_competition_entry'] = $query_count_entries
->countQuery()->execute()->fetchField();
$log_args['@count_this_run_competition_entry'] = $query_count_entries_this
->countQuery()->execute()->fetchField();
// Users:
$query_count_users = $this->dbConnection->select($this::REPORT_TABLE, 'rd')
->fields('rd', ['uid'])
// ->isNull('rd.ceid')
->condition('rd.ceid', 0, '=');
$query_count_users_this = clone $query_count_users;
$query_count_users_this->condition('rd.exported', $export_timestamp);
$log_args['@count_total_user'] = $query_count_users
->countQuery()->execute()->fetchField();
$log_args['@count_this_run_user'] = $query_count_users_this
->countQuery()->execute()->fetchField();
// Judging:
// Note: DISTINCT gives us the number of entries for which there are
// records, not the total number of records.
$query_count_judging = $this->dbConnection->select($this::REPORT_JUDGING_TABLE, 'rd')
->fields('rd', ['ceid'])
->distinct();
$query_count_judging_this = clone $query_count_judging;
$query_count_judging_this->condition('rd.exported', $export_timestamp);
$log_args['@count_total_judging_ids'] = $query_count_judging
->countQuery()->execute()->fetchField();
$log_args['@count_this_run_judging_ids'] = $query_count_judging_this
->countQuery()->execute()->fetchField();
// Log - full details to logger only, since we expect the non-batch version
// to run only from Drush cli call.
$this->updateAllReportDataLog($log_args, [
'log' => [
'details' => TRUE,
'always' => FALSE,
],
]);
return $log_args;
}
/**
* Update all report data.
*
* Batch operation function - flatten entities and add/update records to/in
* `competition_entry_report_data` table.
*
* @param string $report_type
* Report type with possible values 'user', 'competition_entry', 'judging'.
* @param array $add_ids
* IDs of entities to be added to the report table.
* @param array $update_ids
* IDs of entities to be updated in the report table.
* @param int $export_timestamp
* The timestamp at which to mark all records as exported.
* @param array $log_args
* Any values to be used in the final log message passed on from preparation
* before the batch began.
* @param array $context
* Batch context with possible keys:
* 'sandbox' - contains values that persist through all calls to this op
* 'results' - contains values to pass to the batch-finished function.
*/
public static function updateAllReportDataBatchProcess($report_type, array $add_ids, array $update_ids, $export_timestamp, array $log_args, array &$context) {
// We're in a static function, so grab the service.
/* @var \Drupal\competition\CompetitionReporter $reporter */
$reporter = \Drupal::service('competition.reporter');
if (empty($context['sandbox'])) {
// Set up log args - taking care not to overwrite them if already in
// collection from a previous op.
if (!isset($context['results']['log_args'])) {
$context['results']['log_args'] = $log_args;
}
// Pass along for use in post-run queries.
$context['results']['export_timestamp'] = $export_timestamp;
// Total count to be processed.
$context['sandbox']['total'] = count($add_ids) + count($update_ids);
// If no IDs - we're done.
if ($context['sandbox']['total'] == 0) {
$context['finished'] = 1;
return;
}
// -----------------------------------------------------------------------.
// Progress counter (not used in results)
$context['sandbox']['count'] = 0;
// Number to process per batch.
$context['sandbox']['per'] = \Drupal::config('competition.settings')->get('batch_size');
// All IDs to be processed.
$context['sandbox']['ids_all'] = array_merge($add_ids, $update_ids);
// Stash report table name and columns for the insert queries.
if ($report_type == 'judging') {
$context['sandbox']['report_table'] = static::REPORT_JUDGING_TABLE;
$context['sandbox']['report_table_cols'] = $reporter->getReportJudgingTableColumns();
}
else {
$context['sandbox']['report_table'] = static::REPORT_TABLE;
$context['sandbox']['report_table_cols'] = $reporter->getReportTableColumns();
}
}
$ids = array_slice($context['sandbox']['ids_all'], $context['sandbox']['count'], $context['sandbox']['per']);
// The real work!
if ($report_type == 'judging') {
$rows = $reporter->flattenEntitiesJudging($ids);
}
else {
$rows = $reporter->flattenEntities($report_type, $ids);
}
// Now insert to the report data table.
// Note: rows for IDs to be updated have already been deleted, so we can
// use one insert query for all, regardless.
$insert = $reporter->dbConnection->insert($context['sandbox']['report_table']);
$insert->fields($context['sandbox']['report_table_cols']);
foreach ($rows as $row) {
$row['exported'] = $export_timestamp;
$insert->values($row);
}
// "If the query was given multiple sets of values to insert,
// the return value is undefined.".
$insert->execute();
// Increment count by number of IDs processed, not number of rows -
// for judging data, there are likely multiple rows per entry ID.
$context['sandbox']['count'] += count($ids);
// Update progress.
$context['finished'] = $context['sandbox']['count'] / $context['sandbox']['total'];
}
/**
* Batch completion handler for adding/updating report data records.
*
* @param bool $success
* TRUE if no PHP fatals.
* @param array $results
* The $context['results'] array built during operation callbacks.
* @param array $operations
* Batch operations.
*/
public static function updateAllReportDataBatchFinished($success, array $results, array $operations) {
if ($success) {
$log_args = $results['log_args'];
/* @var \Drupal\competition\CompetitionReporter $reporter */
$reporter = \Drupal::service('competition.reporter');
// Collect post-run numbers.
// Entries:
$query_count_entries = $reporter->dbConnection->select($reporter::REPORT_TABLE, 'rd')
->fields('rd', ['ceid'])
// ->isNotNull('rd.ceid')
->condition('rd.ceid', 0, '<>');
$query_count_entries_this = clone $query_count_entries;
$query_count_entries_this->condition('rd.exported', $results['export_timestamp']);
$log_args['@count_total_competition_entry'] = $query_count_entries
->countQuery()->execute()->fetchField();
$log_args['@count_this_run_competition_entry'] = $query_count_entries_this
->countQuery()->execute()->fetchField();
// Users:
$query_count_users = $reporter->dbConnection->select($reporter::REPORT_TABLE, 'rd')
->fields('rd', ['uid'])
// ->isNull('rd.ceid')
->condition('rd.ceid', 0, '=');
$query_count_users_this = clone $query_count_users;
$query_count_users_this->condition('rd.exported', $results['export_timestamp']);
$log_args['@count_total_user'] = $query_count_users
->countQuery()->execute()->fetchField();
$log_args['@count_this_run_user'] = $query_count_users_this
->countQuery()->execute()->fetchField();
// Judging:
// Note: DISTINCT gives us the number of entries for which there are
// records, not the total number of records.
$query_count_judging = $reporter->dbConnection->select($reporter::REPORT_JUDGING_TABLE, 'rd')
->fields('rd', ['ceid'])
->distinct();
$query_count_judging_this = clone $query_count_judging;
$query_count_judging_this->condition('rd.exported', $results['export_timestamp']);
$log_args['@count_total_judging_ids'] = $query_count_judging
->countQuery()->execute()->fetchField();
$log_args['@count_this_run_judging_ids'] = $query_count_judging_this
->countQuery()->execute()->fetchField();
// Log - full details to logger, summary as Drupal message.
$reporter->updateAllReportDataLog($log_args, [
'log' => [
'details' => TRUE,
'always' => FALSE,
],
'drupal_message' => [
'details' => FALSE,
'always' => TRUE,
],
]);
}
else {
drupal_set_message($this->t('Error updating report data.'), 'error');
}
}
/**
* Update all report data.
*
* Helper to log and/or output messages for the results of updating all
* report data.
*
* @param array $log_args
* Array of counts of records/entities affected by the update, formatted
* with '@'-prefixed keys for usage as $this->t() args.
* @param array $instances
* Array defining where and how to log.
* Top-level keys define where (each is optional):
* 'log' - dblog logger
* 'drupal_message' - Drupal status message
* 'return' - return the untranslated message(s) generated in this method
* Each top-level key points to a sub-array with options:
* 'details' - boolean, TRUE for full detailed message, FALSE for summary
* 'html' - boolean, TRUE to include HTML in message string, FALSE for
* plain text. Defaults TRUE.
* 'always' - boolean, TRUE to log regardless of whether all counts of
* affected report data are zero; FALSE to log only of some count is
* more than zero. Defaults FALSE.
*
* @return array|null
* The message(s), if requested by $instances['return'], otherwise nothing.
*
* @see CompetitionReporter::updateAllReportData()
*/
public function updateAllReportDataLog(array $log_args, array $instances = []) {
$detailed = NULL;
$summary = NULL;
// If only deletions were performed (via direct queries), we may have
// bypassed batch add/update. In that case, a Drupal message is requested
// but these add/update totals have not been set.
$log_args = $log_args + [
'@count_total_competition_entry' => 0,
'@count_this_run_competition_entry' => 0,
'@count_total_user' => 0,
'@count_this_run_user' => 0,
'@count_total_judging_ids' => 0,
'@count_this_run_judging_ids' => 0,
];
$affected = (
$log_args['@count_this_run_competition_entry'] > 0
|| $log_args['@count_this_run_user'] > 0
|| $log_args['@count_this_run_judging_ids'] > 0
|| $log_args['@count_deleted_competition_entry'] > 0
|| $log_args['@count_deleted_user'] > 0
|| $log_args['@count_deleted_judging_ids'] > 0
|| $log_args['@count_removed_judging_ids'] > 0
);
$return = NULL;
foreach ($instances as $where => $options) {
if (!isset($options['details'])) {
continue;
}
if (!isset($options['html'])) {
$options['html'] = TRUE;
}
if (!isset($options['always'])) {
$options['always'] = FALSE;
}
// Bypass if nothing was affected and we're not forcing always logging.
if (!$options['always'] && !$affected) {
continue;
}
$messages = NULL;
if ($options['details']) {
if (empty($detailed)) {
$detailed = [];
$detailed[] =
'<strong>Updated all competition reporting data.</strong><br/><br/>
--- Entities in report data, before this run: ---<br/>
Registered users (without entries yet): <strong>@count_init_user</strong><br/>
Entries: <strong>@count_init_competition_entry</strong><br/>
Entries - judging data: <strong>@count_init_judging_ids</strong><br/><br/>
--- Entities deleted from report data, because the entities have been deleted: ---<br/>
Registered users (without entries yet): <strong>@count_deleted_user</strong><br/>
Entries: <strong>@count_deleted_competition_entry</strong><br/>
Entries - judging data: <strong>@count_deleted_judging_ids</strong><br/><br/>
--- To be added/updated: ---<br/>
Registered users (without entries yet) to add: <strong>@count_add_user</strong><br/>
Registered users (without entries yet) to update: <strong>@count_update_user</strong><br/>
Entries to add: <strong>@count_add_competition_entry</strong><br/>
Entries to update: <strong>@count_update_competition_entry</strong><br/>
Entries to add - judging data: <strong>@count_add_judging_ids</strong><br/>
Entries to update - judging data: <strong>@count_update_judging_ids</strong><br/><br/>
--- Entities successfully added/updated/deleted: ---<br/>
Registered users (without entries yet) - records added/updated: <strong>@count_this_run_user</strong><br/>
Entries - records added/updated: <strong>@count_this_run_competition_entry</strong><br/>
Entries - judging data added/updated: <strong>@count_this_run_judging_ids</strong><br/><br/>
Registered users who previously did not have an entry, now have entry - user-only records deleted: <strong>@count_deleted_user_now_entry</strong><br/><br/>
--- Entities in report data, after this run: ---<br/>
Registered users (without entries yet): <strong>@count_total_user</strong><br/>
Entries: <strong>@count_total_competition_entry</strong><br/>
Entries - judging data: <strong>@count_total_judging_ids</strong>';
}
$messages = $detailed;
}
else {
if (empty($summary)) {
// To allow effective translation, each message is kept separate
// rather than concatenating all into one long string which could be
// any of multitudinous variants, depending on which counts were
// nonzero.
$summary = [];
$summary[] = '<strong>Updated all competition reporting data:</strong>';
if ($log_args['@count_this_run_user'] > 0) {
$summary[] = 'Registered users (without entries yet) - added/updated: <strong>@count_this_run_user</strong>';
}
if ($log_args['@count_this_run_competition_entry'] > 0) {
$summary[] = 'Entries - added/updated: <strong>@count_this_run_competition_entry</strong>';
}
if ($log_args['@count_this_run_judging_ids'] > 0) {
$summary[] = 'Entries - judging data - added/updated: <strong>@count_this_run_judging_ids</strong>';
}
if ($log_args['@count_removed_judging_ids'] > 0) {
$summary[] = 'Entries - judging data - removed entries that are no longer in any judging round: <strong>@count_removed_judging_ids</strong>';
}
if ($log_args['@count_deleted_user_now_entry'] > 0) {
$summary[] = 'Registered users who previously did not have an entry, and now have entry - user-only records deleted: <strong>@count_deleted_user</strong>';
}
if ($log_args['@count_deleted_user'] > 0) {
$summary[] = 'Registered users (without entries yet) - records deleted because entities have been deleted: <strong>@count_deleted_user</strong>';
}
if ($log_args['@count_deleted_competition_entry'] > 0) {
$summary[] = 'Entries - records deleted because entities have been deleted: <strong>@count_deleted_competition_entry</strong>';
}
if ($log_args['@count_deleted_judging_ids'] > 0) {
$summary[] = 'Entries - judging data deleted because entities have been deleted: <strong>@count_deleted_judging_ids</strong>';
}
}
$messages = $summary;
}
// Convert to plain text if requested.
if (!$options['html']) {
foreach ($messages as &$message) {
// Remove tags we use.
// Note that all <br/> tags are followed by plaintext line breaks
// already, so we can remove them without replacing.
$message = str_replace(['<strong>', '</strong>', '<br/>'], '', $message);
}
}
switch ($where) {
case 'log':
// For the sake of translation (see above), we may create several
// messages in the log.
foreach ($messages as $message) {
$this->logger->notice($message, $log_args);
}
break;
case 'drupal_message':
foreach ($messages as $message) {
drupal_set_message(FormattableMarkup::placeholderFormat($message, $log_args));
}
break;
case 'return':
$return = $messages;
break;
}
}
return $return;
}
/**
* Generate report set batch.
*
* Sets up batch process to pull records from `competition_entry_report_data`
* database table and output to CSV report file.
*
* @param array $params
* Various parameters by which to filter the report data.
* 'entity' (optional) - if provided and value is 'user',
* retrieve all users-without-entries;
* otherwise assumes competition entries.
* 'type' (required) - competition_entry type/bundle - report must include
* only one type, because each type likely has different fields, and
* fields are the CSV columns.
* 'cycle' (optional) - competition_entry.cycle
* 'status' (optional) - competition_entry.status
* 'ceid' (optional) - a single competition_entry ID.
*
* @throws \InvalidArgumentException
* Thrown if any required parameters are missing or given parameter values
* are invalid.
*
* @throws \Exception
* Thrown if some error occurs preparing the file directory in which the
* CSV file would be saved.
*
* @see CompetitionEntryController::generateReport()
*/
public function generateReportSetBatch(array $params) {
$report_type = (!empty($params['report_type']) ? $params['report_type'] : 'competition_entry');
$entity_type = ($report_type == 'user' ? 'user' : 'competition_entry');
if (!in_array($report_type, [
'competition_entry',
'user',
'judging',
'combined',
])) {
drupal_set_message($this->t('Cannot generate report - report type must be competition entries, registered users who don\'t yet have entries, judging data, or a combination of competition entries and their judging data.'), 'error');
throw new \InvalidArgumentException('Query parameter "report_type" was given as "' . $report_type . '"; must be one of "competition_entry", "user", "judging", or "combined".');
}
// Validate other parameters according to report type.
$required_params = [];
$valid_params = [];
if ($entity_type == 'competition_entry') {
$required_params = [
'type',
];
$valid_params = array_merge($required_params, [
'cycle',
'status',
'ceid',
]);
// Judging reports are also pulling (only) entries, so all entry filters
// apply. Add in judging-specific filters.
if ($report_type == 'judging' || $report_type == 'combined') {
// Note: most likely it makes sense to filter judging data to a single
// cycle... but it's not technically required.
$required_params = array_merge($required_params, [
'judging_report',
'round_id',
]);
$valid_params = array_merge($valid_params, $required_params, []);
}
}
elseif ($entity_type == 'user') {
// Currently we don't support any filters on users-without-entries report.
}
// Filter params to valid keys.
$params = array_intersect_key($params, array_flip($valid_params));
// Check that all required params are present.
$missing = array_diff_key(array_flip($required_params), $params);
if (!empty($missing)) {
drupal_set_message($this->t('Cannot generate report - one or more required filter value(s) were not provided: %missing', [
'%missing' => implode(', ', array_keys($missing)),
]), 'error');
throw new \InvalidArgumentException('Missing required filter parameters: ' . implode(', ', $missing));
}
// Validate (and tweak) param values
// TODO? drupal_set_message() calls might belong better in controller
// catch blocks - but we don't have distinguishing exceptions currently.
foreach ($params as $k => $v) {
if ($entity_type == 'competition_entry') {
switch ($k) {
// Basic entry filters.
case 'type':
// Type should be the id of a competition entity.
$competition = $this->entityTypeManager->getStorage('competition')->load($v);
if (empty($competition)) {
drupal_set_message($this->t('Cannot generate report - filter value %v is not a configured competition.', [
'%v' => $v,
]), 'error');
throw new \InvalidArgumentException('The given value for "type" parameter, "' . $v . '", is not a competition entry type (competition ID).');
}
break;
case 'cycle':
$cycle_keys = array_keys($this->competitionSettings->get('cycles'));
if (!in_array($v, $cycle_keys)) {
drupal_set_message($this->t('Cannot generate report - filter value %v is not one of the configured competition cycles.', [
'%v' => $v,
]), 'error');
throw new \InvalidArgumentException('The given value for "cycle" parameter, "' . $v . '", is not one of the defined competition cycles.');
}
break;
case 'status':
// Status, as stored on entry, is an integer, but has corresponding
// human-readable string label. Allow the param to be either an
// integer key or string label.
// Note that the report query condition will be the string label, as
// that's what is stored in the report data table.
$statuses = $this->competitionSettings->get('statuses');
// Cast numeric string to int, in case it's meant to be status key.
if (is_string($v) && is_numeric($v)) {
$v = (int) $v;
}
// Convert a valid integer status key to corresponding label.
if (!empty($statuses[$v])) {
$v = $statuses[$v];
}
// Now check if it's one of the labels. Note strict type check.
if (!in_array($v, $statuses, TRUE)) {
drupal_set_message($this->t('Cannot generate report - filter value %v is not one of the configured competition entry statuses.', [
'%v' => $v,
]), 'error');
throw new \InvalidArgumentException('The given value for "status" parameter, "' . $v . '", is not one of the defined competition entry statuses.');
}
// If valid, store back into $params.
$params['status'] = $v;
break;
case 'ceid':
// Validate just for a positive integer which could be a legitimate
// entry ID. If it's not, the query will simply come back with no
// results and early-return.
// Cast to int - non-numeric strings cast to 0.
$params['ceid'] = (int) $params['ceid'];
if ($params['ceid'] < 1) {
drupal_set_message($this->t("Cannot generate report - %v is not a valid competition entry ID by which to filter.", [
'%v' => $v,
]), 'error');
throw new \InvalidArgumentException("The given value for 'ceid' parameter, '" . $v . "', cannot be a valid competition entry ID.");
}
break;
// Judging filters.
case 'judging_report':
if (!in_array($v, $this->getAllJudgingReportKeys())) {
drupal_set_message($this->t("Cannot generate report - %v is not an available judging report.", [
'%v' => $v,
]), 'error');
throw new \InvalidArgumentException("The given value for 'judging_report' parameter, " . $v . "', is not a valid judging report key.");
}
break;
case 'round_id':
// All round IDs are positive integers.
// Cast to int - non-numeric strings cast to 0.
$params['round_id'] = (int) $params['round_id'];
$competition = (!empty($params['type']) ? $this->entityTypeManager->getStorage('competition')->load($params['type']) : NULL);
// Check that configured round exists.
$round_exists = NULL;
if (!empty($competition)) {
$round_exists = FALSE;
$judging = $competition->getJudging();
foreach (array_keys($judging->rounds) as $round_id) {
if ((int) $round_id === $params['round_id']) {
$round_exists = TRUE;
break;
}
}
}
if ($params['round_id'] < 1 || $round_exists === FALSE) {
drupal_set_message($this->t("Cannot generate report - there is no configured judging round %v in this competition.", [
'%v' => $v,
]), 'error');
throw new \InvalidArgumentException("The given value for 'round_id' parameter, '" . $v . "', is not a valid round ID in this competition.");
}
// Check that report is applicable to the round.
if (!empty($competition)) {
if (!$this->judgingReportAppliesToRound($competition->id(), $params['round_id'], $params['judging_report'])) {
drupal_set_message($this->t("Cannot generate report - %report does not apply to Round @round_id, because of its round type.", [
'%report' => $this->getAllJudgingReports()[$params['judging_report']],
'@round_id' => $params['round_id'],
]), 'error');
throw new \InvalidArgumentException("The given values for parameters are incompatible: 'judging_report' value '" . $params['judging_report'] . "' and 'round_id' value '" . $params['round_id'] . "'. This report does not apply to the round due to round type.");
}
}
break;
}
}
elseif ($entity_type == 'user') {
}
}
// Prepare the output directory and file.
$dirpath = 'private://competition/reports';
if (!file_prepare_directory($dirpath, FILE_CREATE_DIRECTORY)) {
drupal_set_message($this->t("Error preparing file directory %dirpath to store CSV report file. This is likely a permissions issue.", [
'%dirpath' => $dirpath,
]), 'error');
// TODO: custom exception class? Not sure what might be appropriate.
throw new \Exception("Error creating or setting permissions on file directory in which to save report file - " . $dirpath);
}
$report_name = '';
switch ($report_type) {
case 'user':
$report_name = 'users-without-entries';
break;
case 'competition_entry':
$report_name = 'entries';
break;
case 'judging':
case 'combined':
$report_name .= 'judging-' . str_replace('_', '-', $params['judging_report']);
break;
}
$filename = 'report-' . $report_name . '-' . REQUEST_TIME . '.csv';
$filepath = $dirpath . '/' . $filename;
// Reorganize params.
$params_judging = [
'judging_report',
'round_id',
];
$params_batch = [
'filters' => array_diff_key($params, array_flip($params_judging)),
];
if ($report_type == 'judging' || $report_type == 'combined') {
$params_batch['judging'] = array_intersect_key($params, array_flip($params_judging));
$params_batch['judging']['report'] = $params_batch['judging']['judging_report'];
unset($params_batch['judging']['judging_report']);
}
// Define the batch.
$batch = [
'title' => $this->t("Generating report..."),
'operations' => [
[
// Callback.
[static::class, 'generateReportBatchWriteCsv'],
// Arguments to pass to callback.
[
$report_type,
$params_batch,
$filepath,
],
],
],
'finished' => [static::class, 'generateReportBatchFinished'],
// 'file' =>.
];
batch_set($batch);
// Pass the destination to which to redirect after completion.
// This does not accept a route name, apparently. :(.
return batch_process(Url::fromRoute('entity.competition_entry.reports'));
}
/**
* Batch operation function.
*
* Filtering by given params, pull records from
* `competition_entry_report_data` and write to CSV file.
*
* @param string $report_type
* Specifies base kind of report; one of:
* 'competition_entry' - general report of competition_entry entities
* 'user' - general report of user accounts without associated entries
* 'judging' - report of some specific judging data for competition entries
* 'combined' - full competition entry report plus some judging data. This
* is mainly defined/handled by judging report methods here.
* @param array $params
* Various parameters to define the desired report. All should be validated
* by the calling code which sets up the batch.
* $params['filters']:
* Validated parameters by which to filter records of that entity type:
* 'type' (present if $entity_type == competition_entry) - type/bundle
* of competition_entry
* 'cycle' (optional, if $entity_type == competition_entry)
* 'status' (optional, if $entity_type == competition_entry)
* 'ceid' (optional, if $entity_type == competition_entry) - a single
* entry ID
* $params['judging']:
* If $report_type == 'judging', some metadata to define the particular
* judging report.
* 'report' - custom key to designate desired judging report
* 'round_id' - round for which to pull judging data.
* @param string $filepath
* Path to CSV file to which to write.
* @param array $context
* Batch context with possible keys:
* 'sandbox' - contains values that persist through all calls to this op
* 'results' - contains values to pass to the batch-finished function.
*/
public static function generateReportBatchWriteCsv($report_type, array $params, $filepath, array &$context) {
// Set entity type based on report type.
$entity_type = ($report_type == 'user' ? 'user' : 'competition_entry');
// We're in a static method - get the container's instance of
// CompetitionReporter.
/* @var \Drupal\competition\CompetitionReporter $reporter */
$reporter = \Drupal::service('competition.reporter');
$init = empty($context['sandbox']);
$select = NULL;
if ($report_type == 'judging' || $report_type == 'combined') {
// Get query on judging report table via helper method.
$judging_filters = $params['filters'];
if (isset($judging_filters['status'])) {
unset($judging_filters['status']);
}
$judging_filters['round_id'] = $params['judging']['round_id'];
$select = $reporter->getJudgingReportQuery($params['judging']['report'], $judging_filters);
// If doing a combined report, join the non-judging reporting data table
// and add all its fields.
if ($report_type == 'combined') {
$select->innerJoin(static::REPORT_TABLE, 'erd', 'erd.ceid = rd.ceid');
$select->fields('erd');
}
}
else {
// Set up query on report data table.
$select = Database::getConnection()->select(static::REPORT_TABLE, 'rd')
->fields('rd');
// Apply entity type condition and sort by ceid or uid, accordingly.
if ($entity_type == 'competition_entry') {
if (!empty($params['filters']['ceid'])) {
$select->condition('rd.ceid', $params['filters']['ceid']);
unset($params['filters']['ceid']);
}
else {
// $select->isNotNull('rd.ceid');.
$select->condition('rd.ceid', 0, '<>');
}
$select->orderBy('rd.ceid', 'ASC');
}
elseif ($entity_type == 'user') {
// $select->isNull('rd.ceid');.
$select->condition('rd.ceid', 0, '=');
$select->orderBy('rd.uid', 'ASC');
}
// Apply all other filter conditions.
if (!empty($params['filters'])) {
foreach ($params['filters'] as $k => $v) {
$select->condition('rd.' . $k, $v);
}
}
}
// Initialization - end batch / early return if no records found.
if ($init) {
$context['results']['report_type'] = $report_type;
if ($report_type == 'judging' || $report_type == 'combined') {
$context['results']['judging']['report'] = $params['judging']['report'];
$context['results']['judging']['report_label'] = (string) $reporter->getAllJudgingReports()[$params['judging']['report']];
}
$context['results']['entity_type'] = $entity_type;
$context['results']['filepath'] = $filepath;
// Track number of records processed.
$context['results']['count'] = 0;
// Track entries/judges processed.
if ($report_type == 'judging' || $report_type == 'combined') {
$context['results']['judging']['entries_seen'] = [];
$context['results']['judging']['judges_seen'] = [];
}
// Get total number of records to process.
$select_count = clone $select;
$select_count = $select_count->countQuery();
$context['sandbox']['total'] = (int) $select_count->execute()->fetchField();
// If no records found with the given filters, end the batch now.
if ($context['sandbox']['total'] === 0) {
$context['finished'] = 1;
return;
}
// -----------------------------------------------------------------------.
// Retrieve columns/labels for this report.
$cols_judging = NULL;
$cols_entity = NULL;
if ($report_type == 'judging' || $report_type == 'combined') {
$cols_judging = $reporter->getJudgingReportColumns($params['judging']['report'], $params['judging']['round_id']);
}
if ($report_type == 'competition_entry' || $report_type == 'user' || $report_type == 'combined') {
$bundle = ($entity_type == 'competition_entry' ? $params['filters']['type'] : NULL);
$cols_entity = $reporter->getEntityReportFieldsMeta($entity_type, $bundle)['field_labels_all'];
// Don't include `data` serialized blob as its own column.
if ($entity_type == 'competition_entry' && isset($cols_entity['data'])) {
unset($cols_entity['data']);
}
}
switch ($report_type) {
case 'competition_entry':
case 'user':
$context['sandbox']['columns'] = $cols_entity;
break;
case 'judging':
$context['sandbox']['columns'] = $cols_judging;
break;
case 'combined':
// This should be in all 'combined' reports; kind of what designates
// them as combined...
if (isset($cols_judging['entities'])) {
unset($cols_judging['entities']);
}
$context['sandbox']['columns'] = array_merge($cols_entity, $cols_judging);
break;
}
// Get number of records to process per run.
$context['sandbox']['per'] = \Drupal::config('competition.settings')->get('batch_size');
// Load stuff needed by judging reports.
if ($report_type == 'judging' || $report_type == 'combined') {
$context['sandbox']['judging'] = [];
$competition = $reporter->entityTypeManager->getStorage('competition')->load($params['filters']['type']);
$rounds_config = $competition->getJudging()->rounds;
$round_config = $rounds_config[$params['judging']['round_id']];
// Get round type.
$context['sandbox']['judging']['round_type'] = $round_config['round_type'];
// Get/format criteria labels for this round.
$context['sandbox']['judging']['criteria_labels'] = [];
$i = 0;
foreach ($round_config['weighted_criteria'] as $label => $weight) {
$context['sandbox']['judging']['criteria_labels']['c' . $i] = $label . ' (' . $weight . '%)';
$i++;
}
// Stash all round config, for any reports with multiple rounds' data.
$context['sandbox']['judging']['rounds_config'] = $rounds_config;
}
}
// Open file handle.
$handle = fopen($filepath, 'a+');
// Attempt to get exclusive file-writing lock - early-return on failure.
if (!flock($handle, LOCK_EX)) {
fclose($handle);
// (No DI...unfortunately we have to get an instance of this class.)
/* @var \Drupal\competition\CompetitionReporter $competitionReporter */
$competitionReporter = \Drupal::service('competition.reporter');
$competitionReporter->logger->error("Error generating report - unable to lock file %filepath for exclusive writing.", [
'%filepath' => $filepath,
]);
drupal_set_message($this->t("Error generating report - unable to write to CSV file at %filepath.", [
'%filepath' => $filepath,
]), 'error');
$context['finished'] = 1;
return;
}
// ---------------------------------------------------------------.
// On first run, output column labels as first row.
if ($init) {
fputcsv($handle, array_values($context['sandbox']['columns']));
}
// Retrieve and process next batch of records.
$select->range($context['results']['count'], $context['sandbox']['per']);
$result = $select->execute();
while (($row = $result->fetchAssoc()) !== FALSE) {
if ($report_type != 'judging' && isset($row['data'])) {
// Unpack `data` column JSON blob; merge into row so we have one array
// of all entity field values.
// NOTE: this restores $row['data'] as unserialized $entry->data.
$json = $row['data'];
$json_data = Json::decode($json);
if ($json_data !== NULL) {
$row = array_merge($row, $json_data);
}
else {
// Json::decode() just wraps PHP's json_decode() - returns NULL if the
// string cannot be decoded.
$reporter->logger->error("Error retrieving report data for %entity_type %entity_id - could not decode JSON string containing field values.<br/><br/>JSON error message:<br/>%json_error<br/><br/>JSON string:<br/><pre>%json</pre>", [
'%entity_type' => str_replace("_", " ", $entity_type),
'%entity_id' => ($entity_type == 'competition_entry' ? $row['ceid'] : $row['uid']),
'%json_error' => json_last_error_msg(),
'%json' => $json,
]);
}
}
$csv_rows = [];
$entity_row = NULL;
$judging_rows = NULL;
// Get judging row(s).
if ($report_type == 'judging' || $report_type == 'combined') {
$judging_rows = static::generateReportRowsJudging($params['judging'], $row, $context);
}
// Entity field values row.
if ($report_type == 'competition_entry' || $report_type == 'user' || $report_type == 'combined') {
// Initialize row by setting column keys and defaulting all values to
// empty strings.
$entity_row = array_combine(
array_keys($context['sandbox']['columns']),
array_fill(0, count($context['sandbox']['columns']), '')
);
// Populate csv row with all values that exist.
foreach ($entity_row as $field_key => $value_output) {
if (isset($row[$field_key])) {
$entity_row[$field_key] = $row[$field_key];
}
}
}
switch ($report_type) {
case 'competition_entry':
case 'user':
$csv_rows[] = $entity_row;
break;
case 'judging':
$csv_rows = $judging_rows;
break;
case 'combined':
// For combined report - only output the row if a single, non-empty
// judging row was generated.
// (The 'master' report uses joins that generate multiple rows per
// entry; we only want one row, to merge with the row of entity field
// values. Judging row generation handles filtering out extraneous
// rows.)
// @see generateReportRowsJudging()
if (count($judging_rows) == 1) {
$csv_rows[] = array_merge($entity_row, $judging_rows[0]);
}
break;
}
// Write row(s) to file.
if (!empty($csv_rows)) {
foreach ($csv_rows as $csv_row) {
fputcsv($handle, array_values($csv_row));
}
}
$context['results']['count']++;
}
flock($handle, LOCK_UN);
fclose($handle);
// Update progress.
$context['finished'] = $context['results']['count'] / $context['sandbox']['total'];
}
/**
* Generate report rows.
*
* Given a row from the report table, generate rows for a particular judging
* report to be output to CSV.
*
* @param array $params
* Possible keys:
* 'report' - custom key to designate desired judging report
* 'round_id' - round for which to pull judging data.
* @param array $data_row
* A single record from the judging report data table.
* @param array $context
* The context array from the batch process, by reference.
* $context['sandbox']['judging'] contains:
* 'round_type' - 'pass_fail', 'criteria'
* 'criteria_labels' - array of criteria key (e.g. 'c0') => label for
* output
* $context['results']['judging'] contains:
* 'count_entries' - track number of entries processed for this report,
* if relevant
* 'count_judges' - track number of judges processed for this report,
* if relevant.
*
* @return array
* An array of arrays, each to be output as a row to the CSV.
*/
protected static function generateReportRowsJudging(array $params, array $data_row, array &$context) {
/* @var \Drupal\competition\CompetitionReporter $reporter */
$reporter = \Drupal::service('competition.reporter');
$report = $params['report'];
$round_id = $params['round_id'];
$criteria_labels = $context['sandbox']['judging']['criteria_labels'];
$round_type = $context['sandbox']['judging']['round_type'];
$rounds_config = $context['sandbox']['judging']['rounds_config'];
// Decode col values stored as JSON.
$json_cols = [
'score',
'log',
];
if (in_array($report, ['master'])) {
for ($r = 0; $r <= $round_id; $r++) {
$json_cols[] = 'round_' . $r . '_score';
}
}
foreach ($json_cols as $col) {
if (!empty($data_row[$col])) {
$data_row[$col] = Json::decode($data_row[$col]);
// Error decoding JSON.
if ($data_row[$col] === NULL) {
// TODO: logger.
// TODO: adjust message by report....ugh.
drupal_set_message($this->t("Entry @ceid: unable to parse %col value in judging report data table. All data values therein have been left blank in the report.", [
'@ceid' => $data_row['ceid'],
'%col' => $col,
]));
}
}
}
// Get the column keys for this report.
$cols = array_keys($reporter->getJudgingReportColumns($report, $round_id));
// Template to indicate missing data.
$empty_row = array_combine(
$cols,
array_fill(0, count($cols), '')
);
// Template to indicate that some data is not yet present, but that that is
// accurate, not an error - e.g. judge has not submitted a score yet.
// ("na" = not applicable)
$value_na = "-";
$na_row = array_combine(
$cols,
array_fill(0, count($cols), $value_na)
);
// Generate one or more rows for this entry.
$rows = [];
// Populate "master" data - these values are the same throughout all rows
// for this entry.
$cols_master = [
'ceid',
'user_name',
'judge_name',
'score.weighted_points',
'round.display_average',
'round.display_total_weighted_points',
'score_finalized',
'votes',
];
$row_master = [];
foreach (array_intersect($cols, $cols_master) as $key) {
$row_master[$key] = '';
if (array_key_exists($key, $data_row)) {
switch ($key) {
case 'score_finalized':
// Translate here during report generation, to potentially output
// in current user's language.
$row_master['score_finalized'] = $data_row['score_finalized'] ? $this->t("Yes") : $this->t("No");
break;
case 'judge_name':
if (!empty($data_row['judge_name'])) {
$row_master[$key] = $data_row['judge_name'];
}
elseif (!empty($data_row['score']['uid'])) {
// If uid is non-zero but user was not loaded, output note that
// (we assume) user was deleted.
$row_master[$key] = $this->t("Deleted user (user ID: @uid)", ['@uid' => $data_row['score']['uid']]);
}
break;
default:
$row_master[$key] = $data_row[$key];
break;
}
}
elseif (strpos($key, 'score.') === 0) {
// Per-score values.
if (!empty($data_row['score'])) {
$subkey = substr($key, 6);
if (isset($data_row['score'][$subkey])) {
switch ($subkey) {
case 'weighted_points':
// TODO: use a different key for pass/fail round?
if ($round_type == 'pass_fail') {
// Translate here during report generation, to potentially
// output in current user's language.
$row_master[$key] = ($data_row['score']['pass'] ? $this->t("Pass") : $this->t("Fail"));
}
else {
$row_master[$key] = $data_row['score'][$subkey];
}
break;
default:
$row_master[$key] = $data_row['score'][$subkey];
break;
}
}
else {
$row_master[$key] = $value_na;
}
}
}
elseif (strpos($key, 'round.') === 0) {
// Per-round values
// These are stored in each row's 'score' col too, because flattened
// rows don't provide a place for per-entry per-round (but not
// per-score) values.
if (!empty($data_row['score'])) {
$subkey = substr($key, 6);
if (!empty($data_row['score']['round'][$subkey])) {
$row_master[$key] = $data_row['score']['round'][$subkey];
}
else {
$row_master[$key] = $value_na;
}
}
}
}
// Generate row(s), populating remaining columns according to the specific
// report.
switch ($report) {
case 'master':
// The query joins the judging report table to itself, to accumulate
// a 'score' column for each round. Since there are multiple score
// rows per entry, the joins generate multiple result rows per entry.
// Each result row is passed to this function, but one row for an entry
// contains all the data we need. Thus, if we've already seen a row
// for this entry, SKIP additional rows.
if (!in_array($data_row['ceid'], $context['results']['judging']['entries_seen'])) {
// Compile one row for this entry.
$row = [];
foreach ($cols as $key) {
$row[$key] = '';
if (isset($row_master[$key])) {
$row[$key] = $row_master[$key];
}
else {
// Extract overall average score for the round.
// Key contains the round id, e.g. 'round.1.display_average'.
if (strpos($key, '.') !== FALSE) {
$subkeys = explode('.', $key);
if (count($subkeys) == 3 && $subkeys[0] == 'round' && $subkeys[2] == 'display_average') {
$r = $subkeys[1];
$r_type = $rounds_config[$r]['round_type'];
// Alias of the column as selected in the query.
// @see getJudgingReportQuery()
$query_col = 'round_' . $r . '_score';
if (isset($data_row[$query_col]['round']['display_average'])) {
// (The 'pass' value should be within 'round', but to handle
// report table data before this code change, check for it.
if ($r_type == 'pass_fail' && isset($data_row[$query_col]['round']['pass'])) {
// Translate here during report generation, to potentially
// output in current user's language.
$row[$key] = ($data_row[$query_col]['round']['pass'] ? $this->t("Pass") : $this->t("Fail"));
}
else {
$row[$key] = $data_row[$query_col]['round']['display_average'];
}
}
}
}
}
}
$rows[] = $row;
}
break;
case 'scores':
// Judging report table row == single judge's score.
// Output row per criterion.
if (!empty($data_row['score'])) {
foreach ($data_row['score']['criteria'] as $ckey => $cvalues) {
$row = [];
foreach ($cols as $key) {
$row[$key] = '';
if (isset($row_master[$key])) {
$row[$key] = $row_master[$key];
}
else {
switch ($key) {
case 'round.criterion_label':
$row[$key] = $criteria_labels[$ckey];
break;
// Per-criterion values.
case 'criterion.display_points':
$subkey = substr($key, 10);
if (isset($cvalues[$subkey])) {
// TODO: use a different key for pass/fail round?
if ($round_type == 'pass_fail') {
// Translate here during report generation, to
// potentially output in current user's language.
$row[$key] = $cvalues['pass'] ? $this->t("Pass") : $this->t("Fail");
}
else {
$row[$key] = $cvalues[$subkey];
}
}
else {
$row[$key] = $value_na;
}
break;
case 'criterion.display_weighted_points':
case 'criterion.display_percent':
$subkey = substr($key, 10);
if (isset($cvalues[$subkey])) {
$row[$key] = $cvalues[$subkey];
}
else {
$row[$key] = $value_na;
}
break;
}
}
}
$rows[] = $row;
}
}
else {
// If 'score' is empty, there was a JSON decode error. Output one row,
// populated with only what data we do have for this entry.
// (The '+' operator maintains the order of keys in the first array,
// which uses the column order.)
$rows[] = $empty_row + $row_master;
}
break;
case 'judge_scores':
// Report has one row per judge, per entry - ordered primarily by judge,
// not entry. The query handles that ordering.
// All columns are handled by master row; just copy over.
$row = [];
foreach ($cols as $key) {
// Note that master row contains something for all keys; it defaults
// to empty string if a value could not be retrieved due to JSON
// decode error.
$row[$key] = $row_master[$key];
}
$rows[] = $row;
break;
case 'scores_completed':
$row = [];
foreach ($cols as $key) {
if (array_key_exists($key, $row_master)) {
$row[$key] = $row_master[$key];
}
elseif (array_key_exists($key, $data_row)) {
// Pull in aggregated fields which are added specifically in this
// report's query.
// - count_assigned
// - count_finalized.
$row[$key] = $data_row[$key];
}
}
$rows[] = $row;
break;
case 'votes':
// One row per entry.
// All columns are handled by master row; just copy over.
$row = [];
foreach ($cols as $key) {
// Note that master row contains something for all keys; it defaults
// to empty string if a value could not be retrieved due to JSON
// decode error.
$row[$key] = $row_master[$key];
}
$rows[] = $row;
break;
case 'log':
if (!empty($data_row['log'])) {
$date_formatter = \Drupal::service('date.formatter');
// Output one row per log message.
// (Order has already been reversed, to most recent first, when
// storing into judging report table.)
// @see flattenEntitiesJudging()
foreach ($data_row['log'] as $log) {
$row = [];
foreach ($cols as $key) {
if (isset($row_master[$key])) {
$row[$key] = $row_master[$key];
}
else {
switch ($key) {
case 'log.user_name':
$name = '';
if (!empty($log['user_name'])) {
$name = $log['user_name'];
}
elseif (!empty($log['uid'])) {
// If user could not be loaded, output note that (we
// assume) user was deleted.
$name = $this->t("Unknown user (user ID: @uid)", ['@uid' => $log['uid']]);
}
else {
$name = $this->t("System");
}
$row[$key] = $name;
break;
case 'log.timestamp':
// Convert timestamp to date string here during report
// generation, to potentially output in current user's
// time zone.
// TODO: define a date format for this case, so it may
// be customized via admin UI.
$row[$key] = $date_formatter->format($log['timestamp'], 'custom', 'm/d/Y H:i');
break;
case 'log.message':
// Translate here during report generation, to potentially
// output in current user's language.
$row[$key] = (!empty($log['message_args']) ? FormattableMarkup::placeholderFormat($log['message'], $log['message_args']) : $log['message']);
break;
}
}
}
$rows[] = $row;
}
}
else {
// This case should be impossible - if entry is in a round, then we
// expect it to have log messages at least of movement into that
// round. However, for future-proofing in case of disabling or
// altering of logging, just add a mostly empty row to at least
// acknowledge that this entry is present in the round.
$rows[] = $na_row + $row_master;
}
break;
}
// Update lists of entries/judges processed.
// Batch-finished handler uses these as applicable per report.
// (They're also used above to deal with excess rows from joins in
// 'master' report.)
if (!empty($data_row['ceid'])) {
$context['results']['judging']['entries_seen'][] = $data_row['ceid'];
}
if (!empty($data_row['judge_name'])) {
$context['results']['judging']['judges_seen'][] = $data_row['judge_name'];
}
return $rows;
}
/**
* Report export batch completion handler.
*
* @param bool $success
* TRUE if no PHP fatals.
* @param array $results
* The $context['results'] array built during operation callbacks.
* @param array $operations
* Batch operations.
*/
public static function generateReportBatchFinished($success, array $results, array $operations) {
if ($success) {
if (isset($results['count'])) {
if ($results['count'] == 0) {
switch ($results['report_type']) {
case 'competition_entry':
drupal_set_message($this->t("No competition entries were found with the selected filters."));
break;
case 'user':
drupal_set_message($this->t("No registered users without a corresponding competition entry were found."));
break;
case 'judging':
drupal_set_message($this->t("No competition entries with judging data were found with the selected filters."));
break;
}
}
else {
$filename = str_replace('private://competition/reports/', '', $results['filepath']);
$t_args = [
'@count' => $results['count'],
':file_url' => Url::fromRoute('entity.competition_entry.report_download', [
'filename' => $filename,
])->toString(),
'@filename' => $filename,
];
$download_text = "Your report can be downloaded here: <a href=\":file_url\">@filename</a>";
switch ($results['report_type']) {
case 'competition_entry':
drupal_set_message(\Drupal::translation()->formatPlural(
$results['count'],
"Exported <strong>1</strong> competition entry. " . $download_text,
"Exported <strong>@count</strong> competition entries. " . $download_text,
$t_args
));
break;
case 'user':
drupal_set_message(\Drupal::translation()->formatPlural(
$results['count'],
"Exported <strong>1</strong> user account. " . $download_text,
"Exported <strong>@count</strong> user accounts. " . $download_text,
$t_args
));
break;
case 'judging':
case 'combined':
$t_args['@report_label'] = $results['judging']['report_label'];
switch ($results['judging']['report']) {
case 'scores':
case 'log':
case 'votes':
case 'master':
// Entry count message.
$count_entries = count(array_unique($results['judging']['entries_seen']));
drupal_set_message(\Drupal::translation()->formatPlural(
$count_entries,
"Exported <strong>@report_label</strong> for <strong>1</strong> competition entry. " . $download_text,
"Exported <strong>@report_label</strong> for <strong>@count</strong> competition entries. " . $download_text,
$t_args
));
break;
case 'judge_scores':
case 'scores_completed':
// Judge count message.
$count_judges = count(array_unique($results['judging']['judges_seen']));
drupal_set_message(\Drupal::translation()->formatPlural(
$count_judges,
"Exported <strong>@report_label</strong> for <strong>1</strong> judge. " . $download_text,
"Exported <strong>@report_label</strong> for <strong>@count</strong> judges. " . $download_text,
$t_args
));
break;
}
break;
}
}
}
}
else {
drupal_set_message($this->t('Error generating report.'), 'error');
}
}
}
