competition-8.x-1.x-dev/src/CompetitionJudgingSetup.php
src/CompetitionJudgingSetup.php
<?php
namespace Drupal\competition;
use Drupal\Core\Config\ConfigFactory;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Session\AccountProxy;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationManager;
use Drupal\Core\Url;
/**
* Service to warehouse utilities for configuring competition judging.
*/
class CompetitionJudgingSetup {
use StringTranslationTrait;
const INDEX_TABLE = 'competition_entry_index';
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactory
*/
protected $configFactory;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $dbConnection;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountProxy
*/
protected $currentUser;
/**
* The CompetitionManager service.
*
* @var \Drupal\competition\CompetitionManager
*/
protected $competitionManager;
/**
* The user storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $storageUser;
/**
* The competition storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $storageCompetition;
/**
* The competition entry storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $storageCompetitionEntry;
/**
* Constructor.
*
* @param \Drupal\Core\Config\ConfigFactory $config_factory
* The config factory.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Database\Connection $db_connection
* The database connection.
* @param \Drupal\Core\StringTranslation\TranslationManager $string_translation
* The string translation service.
* @param \Drupal\Core\Session\AccountProxy $current_user
* The logged-in user account.
* @param \Drupal\competition\CompetitionManager $competition_manager
* The competition manager service.
*/
public function __construct(ConfigFactory $config_factory, EntityTypeManagerInterface $entity_type_manager, Connection $db_connection, TranslationManager $string_translation, AccountProxy $current_user, CompetitionManager $competition_manager) {
$this->configFactory = $config_factory;
$this->entityTypeManager = $entity_type_manager;
$this->dbConnection = $db_connection;
// Defined in StringTranslationTrait.
$this->stringTranslation = $string_translation;
$this->currentUser = $current_user;
$this->competitionManager = $competition_manager;
$this->storageUser = $this->entityTypeManager->getStorage('user');
$this->storageCompetition = $this->entityTypeManager->getStorage('competition');
$this->storageCompetitionEntry = $this->entityTypeManager->getStorage('competition_entry');
}
/**
* Helper to load competition entity.
*
* @param string $competition_id
* Competition ID.
*
* @return \Drupal\competition\CompetitionInterface
* The competition entity.
*
* @throws \InvalidArgumentException
* If there is no competition entity with the given ID.
*/
protected function loadCompetition($competition_id) {
// Note: EntityStorageBase::loadMultiple() handles caching already.
$competition = $this->storageCompetition->load($competition_id);
if ($competition === NULL) {
throw new \InvalidArgumentException("Argument \$competition_id must be the ID of a competition entity that exists.");
}
return $competition;
}
/**
* Get judge users (IDs or entities).
*
* Defined as having the permission 'judge competition entries'.
*
* In module's default configuration, this is all users having role
* 'competition_judge' or 'competition_judge_leader'.
*
* @param string|null $role
* A user role name by which to limit returned users.
* @param bool $load_entities
* Whether to load entities.
*
* @return array|\Drupal\user\UserInterface[]
* Array of user IDs or user entities
*/
public function getJudgeUsers($role = NULL, $load_entities = FALSE) {
$permission = 'judge competition entries';
$roles = [];
// Find all roles with this permission.
$role_storage = $this->entityTypeManager->getStorage('user_role');
/** @var \Drupal\user\RoleInterface $role */
foreach ($role_storage->loadMultiple() as $role_id => $role) {
// Bypass roles with 'is_admin' = TRUE, because they automatically have
// all permissions. This is unintuitive; it's not likely that judge roles
// are also site admin roles. Therefore, don't use hasPermissions() -
// which returns TRUE for any role with is_admin = TRUE.
// @see Role::hasPermission()
// TODO: confirm we do want to bypass admin roles because they
// automatically receive all permissions.
if (in_array($permission, $role->getPermissions())) {
$roles[] = $role_id;
}
}
$uids = [];
// No need to query for users if there are no roles.
if (!empty($roles)) {
/** @var \Drupal\Core\Entity\Query\QueryInterface $query */
$query = $this->storageUser->getQuery();
// Limit to active (not blocked)?
// $query->condition('status', 1);.
$or_roles = $query->orConditionGroup();
foreach ($roles as $role_id) {
$or_roles->condition('roles', $role_id);
}
$query->condition($or_roles);
$uids = $query->execute();
}
if ($load_entities && !empty($uids)) {
return $this->storageUser->loadMultiple($uids);
}
else {
return $uids;
}
}
/**
* Helper to return judges assigned to a given round.
*
* @param string $competition_id
* The competition ID.
* @param int $round_id
* The round ID.
* @param bool $load_entities
* Whether to load entities.
*
* @return array|\Drupal\user\UserInterface[]
* Array of user IDs or user entities.
*
* @see \Drupal\competition\Form\CompetitionJudgesRoundsSetupForm
*/
public function getJudgesForRound($competition_id, $round_id, $load_entities = FALSE) {
$competition = $this->loadCompetition($competition_id);
$judging = $competition->getJudging();
$round_id = (int) $round_id;
$uids = [];
foreach ($judging->judges_rounds as $uid => $rounds) {
if (in_array($round_id, $rounds, TRUE)) {
$uids[] = (int) $uid;
}
}
// Load entities to ensure returning only users that still exist (in case of
// deleted users leading to stale assignment data).
// loadMultiple() simply leaves out any ids that did not load an entity.
$users = $this->storageUser->loadMultiple($uids);
$uids_valid = array_keys($users);
if (count($uids_valid) != count($uids)) {
$uids_invalid = array_diff($uids, $uids_valid);
drupal_set_message($this->formatPlural(
count($uids_invalid),
"User ID %uids was assigned to judge Round @round, but no user account for this ID was found. Most likely the account was deleted. This user ID has been bypassed.",
"User IDs %uids were assigned to judge Round @round, but no user accounts for these IDs were found. Most likely the accounts were deleted. These user IDs have been bypassed.",
[
'%uids' => implode(", ", $uids_invalid),
'@round' => $round_id,
]
), 'warning');
}
return ($load_entities ? $users : $uids_valid);
}
/**
* Get the rounds to which a judge user is assigned, in a given competition.
*
* Note: this does not validate that $judge_uid is a user with appropriate
* judging permissions - only whether that user ID is present in the
* current judge/round assignment settings.
*
* @param string $competition_id
* The competition entity ID.
* @param int $judge_uid
* The judge user ID.
*
* @return array
* Array of integer IDs of rounds. Empty array if this user ID is not
* assigned to any.
*
* @see \Drupal\competition\Form\CompetitionJudgesRoundsSetupForm
*/
public function getJudgeAssignedRounds($competition_id, $judge_uid) {
$competition = $this->loadCompetition($competition_id);
$judging = $competition->getJudging();
$judge_uid = (int) $judge_uid;
$rounds = [];
if (!empty($judging->judges_rounds) && !empty($judging->judges_rounds[$judge_uid])) {
$rounds = $judging->judges_rounds[$judge_uid];
}
return $rounds;
}
/**
* Check if a judge is assigned to any judging round(s) in this competition.
*
* Note: this does not validate that $judge_uid is a user with appropriate
* judging permissions - only whether that user ID is present in the
* current judge/round assignment settings.
*
* @param string $competition_id
* The competition ID.
* @param int $judge_uid
* The user ID of a judge user.
*
* @return bool
* TRUE if judge is assigned to one or more rounds; else FALSE.
*
* @see \Drupal\competition\Form\CompetitionJudgesRoundsSetupForm
*/
public function isJudgeAssignedCompetition($competition_id, $judge_uid) {
$competition = $this->loadCompetition($competition_id);
$judging = $competition->getJudging();
$judge_uid = (int) $judge_uid;
return (!empty($judging->judges_rounds) && !empty($judging->judges_rounds[$judge_uid]));
}
/**
* Get all competitions in which a judge is assigned to any judging round(s).
*
* Note: this does not validate that $judge_uid is a user with appropriate
* judging permissions - only whether that user ID is present in the
* current judge/round assignment settings.
*
* @param int $judge_uid
* The user ID of a judge user.
*
* @return \Drupal\competition\CompetitionInterface[]
* Array of all competitions in which this judge is assigned to one or more
* rounds; empty array if none.
*
* @see \Drupal\competition\Form\CompetitionJudgesRoundsSetupForm
*/
public function getJudgeAssignedCompetitions($judge_uid) {
$judge_uid = (int) $judge_uid;
$competitions = [];
$competitions_all = $this->competitionManager->getCompetitions(NULL, TRUE);
foreach ($competitions_all as $competition) {
$judging = $competition->getJudging();
if (!empty($judging->judges_rounds) && !empty($judging->judges_rounds[$judge_uid])) {
$competitions[] = $competition;
}
}
return $competitions;
}
/**
* Assign given entries to judges in a round.
*
* Assignments are split as evenly as possible between all judges, according
* to how many judges are required to judge each entry in this round.
*
* This kicks off a batch process (unless there is an issue with judging
* setup such that assignment cannot proceed).
*
* @param int $competition_id
* The competition ID.
* @param int $round_id
* The round ID.
* @param array $entry_ids
* Entry IDs.
*
* @return bool|null|\Symfony\Component\HttpFoundation\RedirectResponse
* The result of batch_process() call - a redirect response or NULL - or
* FALSE if a setup issue prevented running batch.
*/
public function assignEntries($competition_id, $round_id, array $entry_ids) {
/** @var \Drupal\competition\CompetitionInterface $competition */
$competition = $this->loadCompetition($competition_id);
$judging = $competition->getJudging();
// TODO: determine if this would ever happen; check for now as backup.
// TODO: how to handle error situation? throw exception?
if (empty($entry_ids)) {
drupal_set_message($this->t("There are no entries to be assigned in this round."), 'warning');
return FALSE;
}
// Get number of judges required per entry for this round.
// If this is a voting round then limit to just 1 judge.
$num_judges_per = ($judging->rounds[$round_id]['round_type'] == 'voting') ? 1 : (int) $judging->rounds[$round_id]['required_scores'];
// Get the judges for this round.
$judge_uids = $this->getJudgesForRound($competition_id, $round_id);
// Are there enough judge accounts to assign required number of judges per
// entry?
// TODO: how to handle error situation? throw exception?
if (count($judge_uids) < $num_judges_per) {
drupal_set_message($this->t("Judging assignment could not be completed:<br/><br/>Round @round requires @judges_per judging score(s) per entry, but there are only @num_judges judge account(s) assigned to this round. Add more accounts with the 'Judge competition entries' permission and/or assign judge accounts to this round to continue.", [
'@round' => $round_id,
'@judges_per' => $num_judges_per,
'@num_judges' => count($judge_uids),
]), 'error');
return FALSE;
}
$batch = [
'title' => $this->t("Assigning entries to judges..."),
'operations' => [
[
// Callback.
[static::class, 'assignEntriesBatchProcess'],
// Arguments to pass to callback.
[
$round_id,
$judge_uids,
$entry_ids,
$num_judges_per,
],
],
],
'finished' => [static::class, 'assignEntriesBatchFinished'],
];
batch_set($batch);
}
/**
* Entry/judge assignment batch processor.
*
* @param int $round_id
* The integer ID of the round.
* @param array $judge_uids
* User IDs of all judges configured to judge in this round.
* @param array $entry_ids_all
* Array of IDs of all competition entries in this round, to be assigned.
* @param int $num_judges_per
* Batch size.
* @param array $context
* Key 'sandbox' contains values that persist through all calls to this op.
* Key 'results' contains values to pass to the batch-finished function.
*/
public static function assignEntriesBatchProcess($round_id, array $judge_uids, array $entry_ids_all, $num_judges_per, array &$context) {
if (empty($context['sandbox'])) {
$context['results']['round_id'] = $round_id;
// Counter of entries processed currently.
$context['results']['count_entries'] = 0;
// Total number of entries to process.
$context['sandbox']['total'] = count($entry_ids_all);
// Assignments made, keyed by judge uid.
$context['results']['assigned_by_judge'] = [];
// Assignments made, keyed by entry id.
$context['results']['assigned_by_entry'] = [];
// Current index within array of available judges.
$context['sandbox']['judge_index'] = 0;
shuffle($judge_uids);
}
$count_judges = count($judge_uids);
// Convenience var.
$j = &$context['sandbox']['judge_index'];
$per = \Drupal::config('competition.settings')->get('batch_size');
$entry_ids = array_slice($entry_ids_all, $context['results']['count_entries'], $per);
$entries = \Drupal::entityTypeManager()->getStorage('competition_entry')->loadMultiple($entry_ids);
/** @var \Drupal\competition\CompetitionEntryInterface $entry */
foreach ($entries as $entry_id => $entry) {
/*
* Evenly-divided assignment logic:
*
* Loop through the available judges, repeatedly. Assign the next N judges
* to the current entry. This ensures:
* a) unique set of judges for each entry (no repeats)
* b) even distribution of entries across all judges
*
* Example: 3 judges required per entry; 4 judges available
* entry 1: j1, j2, j3
* entry 2: j4, j1, j2
* entry 3: j3, j4, j1
* entry 4: j2, j3, j4
* entry 5: j1, j2, j3
*
* 15 assignments; j1 => 4, j2 => 4, j3 => 4, j4 => 3
*/
// Collect judges for this entry.
$assign_uids = [];
for ($i = 0; $i < $num_judges_per; $i++) {
$assign_uids[] = $judge_uids[$j];
// Update repeated-looping judge index.
$j++;
if ($j == $count_judges) {
$j = 0;
}
}
// Save assignments.
$entry->assignJudges($round_id, $assign_uids);
// Log the assignment.
$context['results']['assigned_by_entry'][$entry_id] = $assign_uids;
foreach ($assign_uids as $uid) {
$context['results']['assigned_by_judge'][$uid][] = $entry_id;
}
$context['results']['count_entries']++;
}
// Update progress.
$context['finished'] = $context['results']['count_entries'] / $context['sandbox']['total'];
}
/**
* Entry/judge assignment 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 API operations.
*/
public static function assignEntriesBatchFinished($success, array $results, array $operations) {
if ($success) {
$output = [];
$output['msg'] = [
'#markup' => \Drupal::translation()->formatPlural(
$results['count_entries'],
"Assigned 1 entry to judges in Round @round.",
"Assigned @count entries to judges in Round @round.",
[
'@round' => $results['round_id'],
]
),
];
drupal_set_message(\Drupal::service('renderer')->render($output));
}
else {
drupal_set_message(t('An error occurred while assigning judges.'), 'error');
}
}
/**
* Remove all entries from given round - delete judge assignments and scores.
*
* This also currently handles removing entries from voting round and
* deleting votes.
* TODO: voting round isolation into voting submodule.
*
* @param int $competition_id
* The competition ID.
* @param int $round_id
* The round ID.
*/
public function unassignAllEntriesRound($competition_id, $round_id) {
$entry_ids = $this->filterJudgingEntries($competition_id, [
'round_id' => $round_id,
]);
$batch = [
'title' => $this->t('Deleting entry/judge assignments...'),
'operations' => [
[
// Callback.
[static::class, 'unassignAllEntriesRoundBatchProcess'],
// Arguments to pass to callback.
[
$competition_id,
$entry_ids,
$round_id,
],
],
],
'finished' => [static::class, 'unassignAllEntriesRoundBatchFinished'],
];
batch_set($batch);
}
/**
* Remove entries from round - batch processor.
*
* @param string $competition_id
* The competition entity ID.
* @param array $entry_ids_all
* The entry IDs.
* @param int $round_id
* The round ID.
* @param array $context
* The batch API context.
*/
public static function unassignAllEntriesRoundBatchProcess($competition_id, array $entry_ids_all, $round_id, array &$context) {
/** @var \Drupal\competition\CompetitionJudgingSetup $judging_setup */
$judging_setup = \Drupal::service('competition.judging_setup');
if (empty($context['sandbox'])) {
$competition = $judging_setup->loadCompetition($competition_id);
$context['results']['round_id'] = $round_id;
$judging = $competition->getJudging();
$context['results']['round_type'] = $judging->rounds[$round_id]['round_type'];
// Counter of entries processed currently.
$context['results']['count_entries'] = 0;
// Total number of entries to process.
$context['sandbox']['total'] = count($entry_ids_all);
}
$per = \Drupal::config('competition.settings')->get('batch_size');
$entry_ids = array_slice($entry_ids_all, $context['results']['count_entries'], $per);
$entries = $judging_setup->storageCompetitionEntry->loadMultiple($entry_ids);
/** @var \Drupal\competition\CompetitionEntryInterface $entry */
foreach ($entries as $entry) {
$data = $entry->getData();
// Remove judge assignments/scores.
if (array_key_exists($round_id, $data['judging']['rounds'])) {
unset($data['judging']['rounds'][$round_id]);
}
// Remove log messages for all actions in this round.
if (!empty($data['judging']['log'])) {
foreach ($data['judging']['log'] as $i => $log) {
if ($log['round_id'] == $round_id) {
unset($data['judging']['log'][$i]);
}
}
}
$entry->setData($data);
$entry->save();
$context['results']['count_entries']++;
}
// Update progress.
$context['finished'] = $context['results']['count_entries'] / $context['sandbox']['total'];
}
/**
* Remove entries from round - 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
* The batch API operations.
*/
public static function unassignAllEntriesRoundBatchFinished($success, array $results, array $operations) {
if ($success) {
// Delete index records / other data that can be handled in one query.
if (in_array($results['round_type'], ['pass_fail', 'criteria'])) {
\Drupal::database()
->delete(CompetitionJudgingSetup::INDEX_TABLE)
->condition('scores_round', $results['round_id'], '=')
->execute();
}
elseif ($results['round_type'] == 'voting') {
// TODO: voting round isolation into voting submodule.
// Clear votes for all entries in the round.
/** @var \Drupal\competition_voting\CompetitionVoting $voting */
$voting = \Drupal::service('competition.voting');
$count_votes_deleted = $voting->deleteVotes([
'round_id' => $results['round_id'],
]);
}
if ($results['round_type'] == 'voting') {
drupal_set_message(\Drupal::translation()->formatPlural(
$results['count_entries'],
'Removed <strong>1</strong> entry from Round @round_id and deleted <strong>@count_votes</strong> associated votes.',
'Removed <strong>@count</strong> entries from Round @round_id and deleted <strong>@count_votes</strong> associated votes.',
[
'@round_id' => $results['round_id'],
'@count_votes' => $count_votes_deleted,
]
));
}
else {
drupal_set_message(\Drupal::translation()->formatPlural(
$results['count_entries'],
'Removed <strong>1</strong> entry, its scores and judge assignments from Round @round_id.',
'Removed <strong>@count</strong> entries, their scores and judge assignments from Round @round_id.',
[
'@round_id' => $results['round_id'],
]
));
}
}
else {
drupal_set_message(t('An error occurred while deleting judge assignments and scores.'), 'error');
}
}
/**
* Generate test scores for all judges on all entries in a given round.
*
* This will overwrite *any* existing score values for this round!
*
* @param string $competition_id
* The competition ID.
* @param int $round_id
* The round ID.
*
* @return bool|null
* Returns FALSE on error situation.
*/
public function generateTestScores($competition_id, $round_id) {
$entry_ids = $this->filterJudgingEntries($competition_id, [
'round_id' => $round_id,
]);
// This shouldn't happen if called from judging round workflow form.
// TODO: how to handle error situation? throw exception?
if (empty($entry_ids)) {
drupal_set_message($this->t("There are no entries in the given round."), 'warning');
return FALSE;
}
$batch = [
'title' => $this->t("Generating test scores..."),
'operations' => [
[
// Callback.
[static::class, 'generateTestScoresBatchProcess'],
// Arguments to pass to callback.
[
$competition_id,
$entry_ids,
$round_id,
],
],
],
'finished' => [static::class, 'generateTestScoresBatchFinished'],
];
batch_set($batch);
}
/**
* Generate test scores for a round - batch processor.
*
* @param string $competition_id
* The competition ID.
* @param array $entry_ids_all
* The entry IDs.
* @param int $round_id
* The round IDs.
* @param array $context
* The batch API context.
*/
public static function generateTestScoresBatchProcess($competition_id, array $entry_ids_all, $round_id, array &$context) {
if (empty($context['sandbox'])) {
$context['results']['round_id'] = $round_id;
// Counter of entries processed currently.
$context['results']['count_entries'] = 0;
// Counter of entries that were found to have no judges assigned.
$context['results']['count_entries_no_judges'] = 0;
// Total number of entries to process.
$context['sandbox']['total'] = count($entry_ids_all);
// Criteria config for this round.
$competition = \Drupal::entityTypeManager()->getStorage('competition')->load($competition_id);
$round = $competition->getJudging()->rounds[$round_id];
$context['sandbox']['criteria_count'] = count($round['weighted_criteria']);
if ($round['round_type'] == 'pass_fail') {
/* Pass/Fail rounds have two score value options:
* 1 => Pass
* 0 => Fail.
* @see CompetitionForm::save()
*/
$context['sandbox']['point_options'] = [1, 0];
}
else {
// TODO: for contrib, consider allowing 0 as a criterion score option.
$context['sandbox']['point_options'] = range(1, (int) $round['criterion_options'], 1);
}
}
$per = \Drupal::config('competition.settings')->get('batch_size');
$entry_ids = array_slice($entry_ids_all, $context['results']['count_entries'], $per);
$entries = \Drupal::entityTypeManager()->getStorage('competition_entry')->loadMultiple($entry_ids);
/** @var \Drupal\competition\CompetitionEntryInterface $entry */
foreach ($entries as $entry) {
// (Judge assignment should be run before this...)
$data = $entry->getData();
if (!empty($data['judging']['rounds'][$round_id]['scores'])) {
$point_options = $context['sandbox']['point_options'];
foreach ($data['judging']['rounds'][$round_id]['scores'] as $score) {
// Set a random point value for each criterion.
$input = [];
for ($i = 0; $i < $context['sandbox']['criteria_count']; $i++) {
$input['c' . $i] = $point_options[array_rand($point_options)];
}
// Save score (with $finalized = TRUE).
$entry->setJudgeScore($round_id, $score->uid, $input, TRUE);
}
}
else {
$context['results']['count_entries_no_judges']++;
}
$context['results']['count_entries']++;
}
// Update progress.
$context['finished'] = $context['results']['count_entries'] / $context['sandbox']['total'];
}
/**
* Generate test scores for a round - 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 API operations.
*/
public static function generateTestScoresBatchFinished($success, array $results, array $operations) {
if ($success) {
drupal_set_message(\Drupal::translation()->formatPlural(
$results['count_entries'] - $results['count_entries_no_judges'],
"Generated test scores for 1 entry.",
"Generated test scores for @count entries."
));
if (!empty($results['count_entries_no_judges'])) {
// Not exactly an error, but shouldn't happen.
drupal_set_message(\Drupal::translation()->formatPlural(
$results['count_entries_no_judges'],
'Notice: Could not generate test scores for 1 entry because it has no judges assigned. (Judge assignment for the round should be run before generating test scores.)',
'Notice: Could not generate test scores for @count entries because they have no judges assigned. (Judge assignment for the round should be run before generating test scores.)'
), 'warning');
}
}
else {
drupal_set_message(t('An error occurred while generating test scores.'), 'error');
}
}
/**
* Judging entries base query.
*
* Get a select query for all entries available for judging in competition's
* active cycle (NOT filtered by round, judge assignments, etc).
*
* Query has only these base conditions applied:
* 'type' - entries in the given competition
* 'cycle' - current cycle of the competition
* 'status' - finalized
*
* No sort is applied.
*
* Query is tagged with 'competition_entry_judging'.
*
* @param string $competition_id
* Competition ID.
*
* @return \Drupal\Core\Database\Query\SelectInterface
* The query - NOT executed.
*/
public function getJudgingEntriesBaseQuery($competition_id) {
/** @var \Drupal\competition\CompetitionInterface $competition */
$competition = $this->loadCompetition($competition_id);
// $options = $this->dbConnection->getConnectionOptions();
// $options['pdo'][\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY] = FALSE;.
/** @var \Drupal\Core\Database\Query\SelectInterface $query */
$query = $this->dbConnection
// , $options.
->select('competition_entry', 'ce')
->fields('ce', [
'ceid',
'data',
])
->condition('ce.type', $competition->id())
->condition('ce.cycle', $competition->getCycle())
->condition('ce.status', CompetitionEntryInterface::STATUS_FINALIZED);
$query->leftJoin(CompetitionJudgingSetup::INDEX_TABLE, 'ast', 'ast.ceid = ce.ceid');
$query->addTag('competition_entry_judging');
return $query;
}
/**
* Get competition entries filtered by certain judging-related criteria.
*
* Conditions always applied:
* 'type' - entries in the given competition.
* 'cycle' - current cycle of the competition.
* 'status' - finalized.
*
* @param string $competition_id
* Competition ID.
* @param array $filters
* Parameters by which to further filter the initial set - which is entries
* in the current cycle of the competition. All are optional.
*
* 'ceid' - array
* Entry IDs - limit to within this set of IDs
* 'queue' - string
* Limit to entries in this queue. As queues are exclusive, no other
* filters are applicable if this is provided (except 'ceid').
* 'round_id' - int
* Limit to entries in this judging round. (This will not include entries
* in any queue, by definition.)
* If provided as NULL - filter to entries not in any round.
* 'judging' - boolean
* If TRUE - limit to entries that are in any round or queue
*
* These parameters will only apply if 'round_id' is given and non-NULL:
* 'judge_uid' - int
* Entries assigned to this judge, in the given round.
* 'score_complete' - boolean
* If TRUE - if 'judge_uid' is also provided, limit to entries which that
* judge has completed scoring; if 'judge_uid' not provided, limit to
* entries with complete scores for ALL assigned judges in the round.
* 'min_score' - float
* Entries with average score (thus far) in the given round of at least
* this value.
* Note: this does not ensure that all assigned judges' scores are
* populated or finalized!
*
* @return array
* Filtered entries.
*/
public function filterJudgingEntries($competition_id, array $filters) {
$filters = array_intersect_key($filters, array_flip([
'ceid',
'queue',
'round_id',
'judging',
'judge_uid',
'score_complete',
'min_score',
]));
$filtered = [];
$queued = [];
/** @var \Drupal\Core\Database\Query\SelectInterface $query */
$query = $this->getJudgingEntriesBaseQuery($competition_id);
if (!empty($filters['round_id']) && !empty($filters['judge_uid'])) {
$query->condition('ast.scores_round', $filters['round_id']);
}
// Filter to an initial set of entry IDs.
if (!empty($filters['ceid']) && is_array($filters['ceid'])) {
$query->condition('ce.ceid', $filters['ceid'], 'IN');
unset($filters['ceid']);
}
/** @var \Drupal\Core\Database\StatementInterface $iterator */
$iterator = $query->execute();
if (empty($filters)) {
// If no further filters, we don't need to iterate the rows.
// Get 'ceid' column of query result.
$filtered = $iterator->fetchCol(0);
}
else {
while ($row = $iterator->fetchObject()) {
$data = unserialize($row->data);
// Filter for entries in any round or queue.
if (!empty($filters['judging'])) {
if (!empty($data['judging']['rounds']) || !empty($data['judging']['queues'])) {
$filtered[] = $row->ceid;
continue;
}
}
// Filter for entry in queue.
if (!empty($filters['queue'])) {
if (!empty($data['judging']['queues']) && in_array($filters['queue'], $data['judging']['queues'])) {
$filtered[] = $row->ceid;
continue;
}
}
if (!empty($data['judging']['queues'])) {
$queued[] = $row->ceid;
}
// Filter for entry NOT in a queue.
if (!in_array($row->ceid, $queued) && array_key_exists('round_id', $filters)) {
if ($filters['round_id'] == NULL) {
// Filter for entry in no rounds.
if (empty($data['judging']['rounds'])) {
$filtered[] = $row->ceid;
}
}
elseif (!empty($data['judging']['rounds'][$filters['round_id']])) {
// Filter for entry in a round.
if (!empty($filters['judge_uid'])) {
// Filter for entry assigned to judge.
if (!empty($data['judging']['rounds'][$filters['round_id']]['scores'])) {
foreach ($data['judging']['rounds'][$filters['round_id']]['scores'] as $score) {
if ($filters['judge_uid'] == $score->uid) {
if (!empty($filters['score_complete'])) {
// Filter for judge's score complete.
$complete = TRUE;
foreach ($score->criteria as $val) {
if ($val === NULL) {
$complete = FALSE;
}
}
if ($complete) {
$filtered[] = $row->ceid;
}
}
else {
// All entries assigned to judge.
$filtered[] = $row->ceid;
}
break;
}
}
}
}
elseif (!empty($filters['score_complete'])) {
// Filter for entries with ALL scores complete in this round.
$complete = FALSE;
if (!empty($data['judging']['rounds'][$filters['round_id']]['scores'])) {
$complete = TRUE;
foreach ($data['judging']['rounds'][$filters['round_id']]['scores'] as $score) {
foreach ($score->criteria as $val) {
if ($val === NULL) {
$complete = FALSE;
break 2;
}
}
}
}
if ($complete) {
$filtered[] = $row->ceid;
}
}
elseif (!empty($filters['min_score'])) {
// Filter for entry having minimum score.
if (isset($data['judging']['rounds'][$filters['round_id']]['computed'])) {
if ($data['judging']['rounds'][$filters['round_id']]['computed'] >= $filters['min_score']) {
$filtered[] = $row->ceid;
}
}
}
else {
// All round entries.
$filtered[] = $row->ceid;
}
}
}
}
}
$filtered = array_values(array_unique($filtered));
return $filtered;
}
/**
* Get judging admin page links.
*
* Used in CompetitionEntryController() and CompetitionEntryJudgingForm().
*
* @param string|null $competition
* The competition entity ID.
* Note: This is not upcast/type-hinted as a CompetitionInterface entity, so
* that a single route can be used with or without {competition} route param
* and be a local task tab.
* @param string $callback
* Callback type: 'setup', 'round-*' label or queue label.
*
* @return array
* Array of links.
*/
public function getNavLinks($competition, $callback) {
$judging = $competition->getJudging();
$round_type = (!empty($judging->active_round) ? $judging->rounds[$judging->active_round]['round_type'] : NULL);
$links = [];
// Setup.
if ($this->currentUser->hasPermission('administer competition judging setup')) {
$url = Url::fromRoute('entity.competition_entry.judging', [
'competition' => $competition->id(),
'callback' => 'setup',
]);
$links['setup'] = [
'title' => $this->t('Setup'),
'url' => $url,
];
}
// Assignments (current user) - only applicable to scored round types.
if (!empty($judging->active_round) && in_array($round_type, ['pass_fail', 'criteria'])) {
$count = count($this->filterJudgingEntries($competition->id(), [
'round_id' => $judging->active_round,
'judge_uid' => $this->currentUser->id(),
]));
$url = Url::fromRoute('entity.competition_entry.judging', [
'competition' => $competition->id(),
'callback' => 'assignments',
]);
if ($count > 0) {
$links['assignments'] = [
'title' => $this->t('My Assignments <sup>[@count]</sup>', [
'@count' => $count,
]),
'url' => $url,
];
}
}
// Round N (all entries).
if ($this->currentUser->hasPermission('administer competition judging')) {
// Keeping this in the loop for now, possibly with an eye towards
// exposing ALL rounds at once to admin users.
foreach ($judging->rounds as $round => $meta) {
if ($round == $judging->active_round) {
$count = count($this->filterJudgingEntries($competition->id(), [
'round_id' => $round,
]));
$url = Url::fromRoute('entity.competition_entry.judging', [
'competition' => $competition->id(),
'callback' => 'round-' . $round,
]);
$links['round_' . $round] = [
'title' => $this->t('Round @round <sup>[@count]</sup>', [
'@round' => $round,
'@count' => $count,
]),
'url' => $url,
];
}
}
}
// Queues.
if ($this->currentUser->hasPermission('administer competition judging')) {
foreach ($judging->queues as $queue => $enabled) {
if (!$enabled) {
continue;
}
$count = count($this->filterJudgingEntries($competition->id(), [
'queue' => $queue,
]));
$url = Url::fromRoute('entity.competition_entry.judging', [
'competition' => $competition->id(),
'callback' => $queue,
]);
$links[$queue] = [
'title' => $this->t('@title <sup>[@count]</sup>', [
'@title' => $competition->getJudgingQueueLabel($queue),
'@count' => $count,
]),
'url' => $url,
];
}
}
foreach (array_keys($links) as $key) {
if ($callback == $key || str_replace('-', '_', $callback) == $key) {
$links[$key]['attributes']['class'] = ['is-active'];
}
}
return $links;
}
/**
* Finalize scores for all given entries.
*
* This kicks off a batch process.
*
* @param int $round_id
* The round ID.
* @param array $entry_ids
* Entry IDs to process. Calling code should ensure that relevant scores
* (either for this judge, or all, according to $judge_uid) are complete
* on each entry. However, the batch processor does check if the scores are
* complete, and will skip finalizing any that are incomplete (and list
* such in an error message).
* @param int|null $judge_uid
* The uid of judge for whom to finalize scores; NULL indicates an admin
* is running finalization and all scores on the given entries are to be
* finalized.
*/
public function finalizeScores($round_id, array $entry_ids, $judge_uid) {
$batch = [
'title' => $this->t("Finalizing scores..."),
'operations' => [
[
// Callback.
[static::class, 'finalizeScoresBatchProcess'],
// Arguments to pass to callback.
[
$round_id,
$entry_ids,
$judge_uid,
],
],
],
'finished' => [static::class, 'finalizeScoresBatchFinished'],
];
batch_set($batch);
// TODO: if called from form submit handler, form api calls batch_process().
// Can we call it conditionally?
//
// Redirect to Round N tab (admins) or Assignments tab (admins).
// return batch_process(Url::fromRoute(...));.
}
/**
* Finalize scores batch processor.
*
* @param int $round_id
* The round ID.
* @param array $entry_ids_all
* Entry IDs to process. Calling code should ensure that relevant scores
* (either for this judge, or all, according to $judge_uid) are complete
* on each entry. However, this method does check if the scores are
* complete, and will skip finalizing any that are incomplete (and list
* such in an error message).
* @param int|null $judge_uid
* The uid of judge for whom to finalize scores; NULL indicates an admin
* is running finalization and all scores on the given entries are to be
* finalized.
* @param array $context
* Key 'sandbox' contains values that persist through all calls to this op.
* Key 'results' contains values to pass to the batch-finished function.
*/
public static function finalizeScoresBatchProcess($round_id, array $entry_ids_all, $judge_uid, array &$context) {
// Set batch init values.
if (empty($context['sandbox'])) {
$context['results']['round_id'] = $round_id;
$context['sandbox']['current_uid'] = \Drupal::currentUser()->id();
if (empty($judge_uid)) {
$context['sandbox']['judge_names'] = [];
}
// Counter of entries processed currently.
$context['results']['progress'] = 0;
// Total number of entries to process.
$context['sandbox']['total'] = count($entry_ids_all);
// Count of entries for which scores are successfully finalized.
$context['results']['count_finalized'] = 0;
// Count of entries for which scores were already finalized.
$context['results']['count_already'] = 0;
// Log entries with incomplete scores (should be none; this is backup).
$context['results']['incomplete_entries'] = [];
}
$storage_user = \Drupal::entityTypeManager()->getStorage('user');
// Get the number of entities to load per batch cycle.
$per = \Drupal::config('competition.settings')->get('batch_size');
// Split the total entries list into a smaller entries list,
// starting at the current progress position.
$entry_ids = array_slice($entry_ids_all, $context['results']['progress'], $per);
$entries = \Drupal::entityTypeManager()->getStorage('competition_entry')->loadMultiple($entry_ids);
/** @var \Drupal\competition\CompetitionEntryInterface $entry */
foreach ($entries as $entry) {
// Score completion should be verified by calling code. However, to avoid
// creating bad data, do not finalize any incomplete score.
$complete = (!empty($judge_uid) ?
$entry->hasJudgeScore($round_id, $judge_uid) :
$entry->hasAllJudgeScores($round_id)
);
if (!$complete) {
$context['results']['incomplete_entries'][] = $entry->id();
}
else {
// If score(s) verified as complete, now mark finalized, if not already.
$data = $entry->getData();
$logs = [];
$updated = FALSE;
foreach ($data['judging']['rounds'][$round_id]['scores'] as &$score) {
if (!empty($judge_uid) && $score->uid != $judge_uid) {
continue;
}
if (!$score->finalized) {
$score->finalized = TRUE;
$updated = TRUE;
// Log the action.
if (!empty($judge_uid) && $context['sandbox']['current_uid'] == $judge_uid) {
$logs[] = [
'uid' => $context['sandbox']['current_uid'],
'round_id' => $round_id,
'message' => "Finalized score for entry @ceid in Round @round_id.",
'message_args' => [
'@ceid' => $entry->id(),
'@round_id' => $round_id,
],
];
}
else {
if (empty($context['sandbox']['judge_names'][$score->uid])) {
$context['sandbox']['judge_names'][$score->uid] = $storage_user->load($score->uid)->getAccountName();
}
$logs[] = [
'uid' => $context['sandbox']['current_uid'],
'round_id' => $round_id,
'message' => "Finalized score by @name for entry @ceid in Round @round_id.",
'message_args' => [
'@name' => $context['sandbox']['judge_names'][$score->uid],
'@ceid' => $entry->id(),
'@round_id' => $round_id,
],
];
}
}
}
if ($updated) {
// Save updates.
$entry->setData($data);
$entry->save();
$entry->addJudgingLogMultiple($logs);
$context['results']['count_finalized']++;
}
else {
// This judge's score, or all scores, were already finalized.
$context['results']['count_already']++;
}
}
$context['results']['progress']++;
}
// Update progress.
$context['finished'] = $context['results']['progress'] / $context['sandbox']['total'];
}
/**
* Finalize scoring 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 API operations.
*/
public static function finalizeScoresBatchFinished($success, array $results, array $operations) {
if ($success) {
$translation = \Drupal::translation();
drupal_set_message($translation->formatPlural(
$results['count_finalized'],
"Finalized scores for 1 entry in Round @round.",
"Finalized scores for @count entries in Round @round.", [
'@round' => $results['round_id'],
]
));
if ($results['count_already'] > 0) {
drupal_set_message($translation->formatPlural(
$results['count_already'],
"(Scores for 1 entry were already finalized.)",
"(Scores for @count entries were already finalized.)"
));
}
if (count($results['incomplete_entries']) > 0) {
drupal_set_message(t("Unexpected error: could not finalize scores for the following entries because they were incomplete:<br/>@ids", [
'@ids' => implode(", ", $results['incomplete_entries']),
]), 'error');
}
}
else {
drupal_set_message(t('An error occurred while finalizing scores.'), 'error');
}
}
}
