competition-8.x-1.x-dev/src/Entity/CompetitionEntryJudgingTrait.php
src/Entity/CompetitionEntryJudgingTrait.php
<?php
namespace Drupal\competition\Entity;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Component\Render\FormattableMarkup;
/**
* Provides a trait for competition judging.
*/
trait CompetitionEntryJudgingTrait {
use StringTranslationTrait;
/**
* Judging - assign the given judges to this entry for this round.
*
* @param int $round_id
* Round ID.
* @param array $judge_uids
* ALL judge user IDs that should be assigned to this entry in this round.
* @param bool $replace_existing
* If TRUE, all existing judge assignments/scores in this round will be
* removed - replaced by assignments to given judges and empty scores.
* If FALSE, then any judges in $judge_uids already assigned will remain
* assigned and their scores intact. Judges not in $judge_uids will be
* removed; judges in $judge_uids but not assigned yet will be assigned.
* Any "unassignments" will be logged only if not replacing all existing.
*/
public function assignJudges($round_id, array $judge_uids, $replace_existing = FALSE) {
// Get the criteria configured for this round.
// Cache because it's expected to call this method on a bunch of entries
// at once.
static $num_criteria = NULL;
if (!isset($num_criteria)) {
$competition = $this->getCompetition();
$judging = $competition->getJudging();
$num_criteria = count($judging->rounds[$round_id]['weighted_criteria']);
}
$to_assign = [];
$to_unassign = [];
if ($replace_existing) {
// All existing assignments are removed, and all provided judges are
// to be assigned.
$to_assign = $judge_uids;
}
else {
// Check current assignments to determine which judges to unassign and
// newly assign.
$current = [];
$current_indexes = [];
$data = $this->getData();
if (!empty($data['judging']['rounds'][$round_id]['scores'])) {
foreach ($data['judging']['rounds'][$round_id]['scores'] as $i => $score) {
$current[] = (int) $score->uid;
$current_indexes[(int) $score->uid] = $i;
}
}
// array_diff(): "...returns the values in array1 that are not present in
// any of the other arrays.".
$to_unassign = array_diff($current, $judge_uids);
$to_assign = array_diff($judge_uids, $current);
}
$round_data = [
'computed' => NULL,
'scores' => [],
];
$logs_assign = [];
// Hey entity caching, you're awesome.
/** @var \Drupal\user\Entity\User[] $judges */
$judges = \Drupal::entityTypeManager()->getStorage('user')->loadMultiple(array_merge($to_assign, $to_unassign));
// Collect assignments.
$scores_add = [];
if (!empty($to_assign)) {
$criteria_empty = [];
for ($i = 0; $i < $num_criteria; $i++) {
$criteria_empty['c' . $i] = NULL;
}
foreach ($to_assign as $uid) {
$scores_add[] = (object) [
'uid' => $uid,
'finalized' => FALSE,
'criteria' => $criteria_empty,
];
// Using $replace_existing to assume system vs. user action is meh.
$logs_assign[] = [
'uid' => ($replace_existing ? NULL : \Drupal::currentUser()->id()),
'round_id' => $round_id,
'message' => "Entry @ceid assigned to @name for scoring in Round @round_id.",
'message_args' => [
'@ceid' => $this->id(),
'@name' => $judges[$uid]->getUsername(),
'@round_id' => $round_id,
],
];
}
}
// If replacing all existing assignments/scores, just overwrite with the
// assignments to add.
// Since none have scores yet, the computed average remains empty.
if ($replace_existing) {
$round_data['scores'] = $scores_add;
}
// If not replacing all existing assignments/scores...
$logs_unassign = [];
if (!$replace_existing) {
// Start with any existing data.
$data = $this->getData();
if (!empty($data['judging']['rounds'][$round_id])) {
$round_data = $data['judging']['rounds'][$round_id];
// Do unassignments.
if (!empty($to_unassign)) {
foreach ($to_unassign as $uid) {
$had_score = FALSE;
$score = $round_data['scores'][$current_indexes[$uid]];
foreach ($score->criteria as $val) {
if ($val !== NULL) {
$had_score = TRUE;
break;
}
}
unset($round_data['scores'][$current_indexes[$uid]]);
// Log manual unassignments.
$logs_unassign[] = [
'uid' => \Drupal::currentUser()->id(),
'round_id' => $round_id,
'message' => "Entry @ceid no longer assigned to @name for scoring in Round @round_id. " . ($had_score ? "(Judge had scored entry.)" : "(Judge had not scored entry yet.)"),
'message_args' => [
'@ceid' => $this->id(),
'@name' => $judges[$uid]->getUsername(),
'@round_id' => $round_id,
],
];
}
}
}
// Add new assignments.
if (!empty($scores_add)) {
$round_data['scores'] = array_merge($round_data['scores'], $scores_add);
}
}
// Save assignments.
$data = $this->getData();
$data['judging']['rounds'][$round_id] = $round_data;
$this->setData($data);
// Post-save hook writes to assignment index table.
// @see ::postSave()
$this->save();
// If we didn't just set all new assignments with empty scores, we need to
// update score average.
// (Call this after save() since it pulls $entry->data again.)
if (!$replace_existing) {
$this->updateAverageScore($round_id);
}
// Save logs.
if (!empty($logs_assign) || !empty($logs_unassign)) {
// Log unassignments before assignments; feels more logical.
$this->addJudgingLogMultiple(array_merge($logs_unassign, $logs_assign));
}
}
/**
* Retrieve all judges assigned to an entry in a given round, if any.
*
* @param int $round_id
* Round ID.
* @param bool $load_entities
* Should entities be loaded.
*
* @return array|\Drupal\user\UserInterface[]
* If $load_entities, array of User entities; else array of user entity IDs
*/
public function getAssignedJudges($round_id, $load_entities = FALSE) {
$uids = [];
$data = $this->getData();
if (!empty($data['judging']['rounds'][$round_id]['scores'])) {
foreach ($data['judging']['rounds'][$round_id]['scores'] as $score) {
$uids[] = $score->uid;
}
}
return !empty($uids) && $load_entities ? \Drupal::entityTypeManager()->getStorage('user')->loadMultiple($uids) : $uids;
}
/**
* Check if a judge is assigned to this entry in given round.
*
* @param int $judge_uid
* Judge user ID.
* @param int $round_id
* Round ID.
*
* @return bool
* Is judge assigned.
*/
public function isJudgeAssigned($judge_uid, $round_id) {
return in_array($judge_uid, $this->getAssignedJudges($round_id));
}
/**
* Judging - save a judge's scores for a given round.
*
* This also recalculates the averaged score across all judges, weighted
* according to criteria settings, and saves it in 'computed' property.
*
* @param int $round_id
* Round ID.
* @param int $judge_uid
* Judge user ID.
* @param array $score_input
* Array of score values - keys are criteria (i.e. 'c0', 'c1', etc.) and
* values are this judge's submitted points value (out of the number of
* possible points - 'criterion_options' value in judging settings).
* If judge didn't submit a score for a criterion, its value should be NULL.
* @param bool $finalized
* Whether judge has marked these score values as finalized.
*
* @throws \InvalidArgumentException
* The $scores array must contain a value for each criterion defined for
* the round.
*
* @return bool
* Whether the input score was saved successfully. (This should only be
* FALSE if attempting to save over a score that was previously stored as
* finalized.)
*/
public function setJudgeScore($round_id, $judge_uid, array $score_input, $finalized) {
$data = $this->getData();
$round_data = [
'computed' => NULL,
'scores' => [],
];
if (!empty($data['judging']['rounds'][$round_id])) {
$round_data = $data['judging']['rounds'][$round_id];
}
// Check for existing score by this judge.
$existing_score = NULL;
$score_index = NULL;
if (!empty($round_data['scores'])) {
foreach ($round_data['scores'] as $i => $score) {
if ($score->uid == $judge_uid) {
$existing_score = $score;
$score_index = $i;
break;
}
}
}
// If existing score was marked 'finalized', score may not be updated -
// early return FALSE.
if (!empty($existing_score) && $existing_score->finalized) {
drupal_set_message(t("This score cannot be updated because it has already been finalized."), 'error');
return FALSE;
}
// Get criteria config.
$judging = $this->getCompetition()->getJudging();
$round_type = $judging->rounds[$round_id]['round_type'];
$criteria = $judging->rounds[$round_id]['weighted_criteria'];
$points_max = (int) $judging->rounds[$round_id]['criterion_options'];
// The scores (for multiple criteria) are expected to be submitted all
// together via a single form. Therefore, $score_input should be passed
// containing values (even if NULL) for all criteria - replacing all
// existing score values saved for this judge.
for ($i = 0; $i < count($criteria); $i++) {
if (!array_key_exists('c' . $i, $score_input)) {
throw new \InvalidArgumentException(sprintf('Argument $scores_input is missing one or more score criterion values - Round %d has %d criteria. Existing criteria: %s', $round_id, count($criteria)));
}
}
// Since we have values for all criteria, we can fully overwrite any
// existing score.
$score = (object) [
'uid' => $judge_uid,
'finalized' => $finalized,
'criteria' => [],
];
// Convert submitted point values into weighted points per criterion.
// Note: Pass/Fail rounds are scored as single criterion, either 1/1 (pass)
// or 0/1 (fail). Thus the weighted score becomes 100 or 0.
/*
* 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
*/
// Each criterion has a weight of 1 - 100
// $criteria is $label => $weight - re-key for ease of accessing weights.
$criteria_weights = [];
foreach (array_values($criteria) as $i => $weight) {
$criteria_weights['c' . $i] = (int) $weight;
}
$pass = NULL;
foreach ($score_input as $key => $points) {
// Note that 0 is a valid score. If anything other than a numeric value
// was submitted, set to NULL to indicate no score submitted for this
// criterion.
if (is_numeric($points)) {
$percent = ((int) $points) / $points_max;
$weighted_points = $percent * $criteria_weights[$key];
$score->criteria[$key] = floatval($weighted_points);
if ($round_type == 'pass_fail' && $key == 'c0') {
// Pass/Fail: 1 => Pass, 0 => Fail.
$pass = ($points > 0);
}
}
else {
$score->criteria[$key] = NULL;
}
}
if (isset($score_index)) {
// Replace this judge's existing score.
$round_data['scores'][$score_index] = $score;
}
else {
// Add this judge's score.
$round_data['scores'][] = $score;
}
unset($score, $weighted_points);
// Save new score data. We need to call save() here,
// since average func will pull $entry->data again.
$data = $this->getData();
$data['judging']['rounds'][$round_id] = $round_data;
$this->setData($data);
$this->save();
// Recalculate average score across all judges.
$this->updateAverageScore($round_id);
// Log scoring and finalization actions.
$logs = [];
$message = NULL;
switch ($round_type) {
case 'pass_fail':
if ($pass !== NULL) {
$message = ($pass ? "Passed" : "Failed") . " entry @ceid in Round @round_id.";
}
break;
case 'criteria':
$message = "Scored entry @ceid in Round @round_id.";
break;
}
if (!empty($message)) {
$logs[] = [
'uid' => $judge_uid,
'round_id' => $round_id,
'message' => $message,
'message_args' => [
'@ceid' => $this->id(),
'@round_id' => $round_id,
],
];
if ($finalized) {
$logs[] = [
'uid' => $judge_uid,
'round_id' => $round_id,
'message' => "Finalized score for entry @ceid in Round @round_id.",
'message_args' => [
'@ceid' => $this->id(),
'@round_id' => $round_id,
],
];
}
$this->addJudgingLogMultiple($logs);
}
return TRUE;
}
/**
* Mark a judge's score as finalized (or not).
*
* @param int $round_id
* Round ID.
* @param int $judge_uid
* Judge user ID.
* @param bool $finalized
* Whether to set this score to finalized or unfinalized. If setting to
* finalized, this will ensure that the score is complete (values submitted
* for all criteria) first.
*
* @throws \InvalidArgumentException
* If the entry is not in given round, or judge is not assigned.
*
* @return bool
* Whether the score finalized status was successfully updated. If
* $finalized was TRUE and the score was not complete, this will return
* FALSE.
*/
public function setJudgeScoreFinalized($round_id, $judge_uid, $finalized = TRUE) {
$round_id = (int) $round_id;
$judge_uid = (int) $judge_uid;
$data = $this->getData();
if (empty($data['judging']['rounds'][$round_id])) {
throw new \InvalidArgumentException("This competition entry is not in the given round: " . $round_id);
}
$round_data = $data['judging']['rounds'][$round_id];
// Find this judge's score.
$score = NULL;
$score_index = NULL;
if (!empty($round_data['scores'])) {
foreach ($round_data['scores'] as $i => $s) {
if ($s->uid == $judge_uid) {
$score = $s;
$score_index = $i;
break;
}
}
}
if (empty($score)) {
throw new \InvalidArgumentException("This competition entry is not assigned in Round " . $round_id . " to the given judge user: " . $judge_uid);
}
// If attempting to set finalized but score is not complete, return FALSE.
if ($finalized && !$score->finalized) {
$complete = TRUE;
foreach ($score->criteria as $val) {
if ($val === NULL) {
$complete = FALSE;
}
}
if (!$complete) {
return FALSE;
}
}
$score->finalized = $finalized;
// Save new score data.
$data['judging']['rounds'][$round_id][$score_index] = $score;
$this->setData($data);
$this->save();
// Log the action.
$uid = \Drupal::currentUser()->id();
if ($uid == $judge_uid) {
$this->addJudgingLog(
$uid,
$round_id,
($finalized ? "Finalized" : "Un-finalized") . " score for entry @ceid in Round @round_id.",
[
'@ceid' => $this->id(),
'@round_id' => $round_id,
]
);
}
else {
$judge = \Drupal::entityTypeManager()->getStorage('user')->load($judge_uid);
$this->addJudgingLog(
$uid,
$round_id,
($finalized ? "Finalized" : "Un-finalized") . " score by @name for entry @ceid in Round @round_id.",
[
'@name' => $judge->getAccountName(),
'@ceid' => $this->id(),
'@round_id' => $round_id,
]
);
}
return TRUE;
}
/**
* Recalculate and store the average of this entry's scores in a round.
*
* @param int $round_id
* Round ID.
*/
public function updateAverageScore($round_id) {
$data = $this->getData();
if (!empty($data['judging']['rounds'][$round_id]['scores'])) {
$round_data = $data['judging']['rounds'][$round_id];
// Collect weighted score (total points / 100) for each judge...
$overall_scores = [];
foreach ($round_data['scores'] as $score) {
// If any criterion score is missing, the overall score is incomplete.
$complete = TRUE;
foreach ($score->criteria as $weighted_points) {
if ($weighted_points === NULL) {
$complete = FALSE;
break;
}
}
// Incomplete scores cannot be accurately included in the average.
if ($complete) {
$overall_scores[$score->uid] = array_sum($score->criteria);
}
}
// Store average of all complete scores submitted so far (rounded to
// 2 decimal places).
if (!empty($overall_scores)) {
$round_data['computed'] = round(array_sum($overall_scores) / count($overall_scores), 2);
}
else {
$round_data['computed'] = NULL;
}
$data['judging']['rounds'][$round_id] = $round_data;
$this->setData($data);
$this->save();
}
}
/**
* Recalculate the average of this entry's scores in a round.
*
* @param int $round_id
* Round ID.
*
* @return float
* Calculated average score.
*/
public function getAverageScore($round_id) {
$data = $this->getData();
if (isset($data['judging']['rounds'][$round_id]['computed'])) {
return $data['judging']['rounds'][$round_id]['computed'];
}
return 0;
}
/**
* Retrieve a judge's score for the given round on this entry, if any.
*
* @param int $round_id
* Round ID.
* @param int $judge_uid
* Judge user ID.
*
* @return object|null
* If this judge is assigned to this entry in this round, the $score object
* as stored in the entry data, containing:
* $score->uid - int - this judge's uid
* $score->finalized - bool - whether judge marked this score final
* $score->criteria - array - weighted scores for each criterion; keys are
* 'c0', 'c1', etc., values are floats of weighted points or NULL if
* judge hasn't submitted a value yet)
* Extra properties, added here:
* $score->display - array - "original" scores for each criterion; keys
* are 'c0', 'c1', etc., values are the score number as displayed in the
* dropdown in the judging form (or empty string if judge hasn't submitted
* a value yet)
*
* @see ::setJudgeScores()
*/
public function getJudgeScore($round_id, $judge_uid) {
$score = NULL;
$data = $this->getData();
if (!empty($data['judging']['rounds'][$round_id]['scores'])) {
foreach ($data['judging']['rounds'][$round_id]['scores'] as $s) {
if ($s->uid == $judge_uid) {
$score = $s;
break;
}
}
if (!empty($score)) {
// Get criteria config.
$judging = $this->getCompetition()->getJudging();
$points_max = (int) $judging->rounds[$round_id]['criterion_options'];
// Each criterion has a weight of 1 - 100
// Criteria is stored is $label => $weight - re-key for ease of
// accessing weights.
$criteria_weights = [];
foreach (array_values($judging->rounds[$round_id]['weighted_criteria']) as $i => $weight) {
$criteria_weights['c' . $i] = (int) $weight;
}
// Compute original selected point values, for $score->display.
// Note: Pass/Fail rounds are scored as single criterion, either
// 1/1 (pass) or 0/1 (fail). Thus the weighted score becomes 100 or 0.
/*
* 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
*/
$score->display = [];
foreach ($score->criteria as $key => $weighted_points) {
if ($weighted_points === NULL) {
$score->display[$key] = '';
}
else {
$percent = $weighted_points / $criteria_weights[$key];
$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);
$score->display[$key] = $points;
}
}
}
}
return $score;
}
/**
* Check if this entry has a complete score by this judge.
*
* Score must be "complete", i.e. judge has submitted a value for each
* criterion, to return TRUE. Whether the score is marked "finalized" is
* irrelevant.
*
* @param int $round_id
* Round ID.
* @param int $judge_uid
* Judge user ID.
*
* @return bool
* TRUE if entry has a complete score by this judge, FALSE otherwise.
*/
public function hasJudgeScore($round_id, $judge_uid) {
$round_id = (int) $round_id;
$judge_uid = (int) $judge_uid;
$judge_score = NULL;
$complete = FALSE;
$data = $this->getData();
if (!empty($data['judging']['rounds'][$round_id]['scores'])) {
foreach ($data['judging']['rounds'][$round_id]['scores'] as $score) {
if ($score->uid == $judge_uid) {
$judge_score = $score;
break;
}
}
}
if (!empty($judge_score)) {
$complete = TRUE;
foreach ($judge_score->criteria as $val) {
if ($val === NULL) {
$complete = FALSE;
}
}
}
return $complete;
}
/**
* Check if this entry has complete scores by all assigned judges in a round.
*
* Each score must be "complete", i.e. judge has submitted a value for each
* criterion, to return TRUE. Whether the score is marked "finalized" is
* irrelevant.
*
* @param int $round_id
* Round ID.
*
* @return bool
* TRUE if entry has complete scores by all judge, FALSE otherwise.
*/
public function hasAllJudgeScores($round_id) {
$round_id = (int) $round_id;
$complete = TRUE;
$data = $this->getData();
if (!empty($data['judging']['rounds'][$round_id]['scores'])) {
foreach ($data['judging']['rounds'][$round_id]['scores'] as $score) {
foreach ($score->criteria as $val) {
if ($val === NULL) {
$complete = FALSE;
break 2;
}
}
}
}
return $complete;
}
/**
* Check if this entry has a complete, finalized score by this judge.
*
* Score must be "complete", i.e. judge has submitted a value for each
* criterion, and score must be marked finalized, to return TRUE.
*
* @param int $round_id
* Round ID.
* @param int $judge_uid
* Judge user ID.
*
* @return bool
* TRUE if entry has a complete, finalized score by this judge, FALSE
* otherwise.
*/
public function hasJudgeScoreFinalized($round_id, $judge_uid) {
$round_id = (int) $round_id;
$judge_uid = (int) $judge_uid;
$complete_final = FALSE;
if ($this->hasJudgeScore($round_id, $judge_uid)) {
$judge_score = NULL;
$data = $this->getData();
foreach ($data['judging']['rounds'][$round_id]['scores'] as $score) {
if ($score->uid == $judge_uid) {
$judge_score = $score;
break;
}
}
// We've already checked that score is complete; now check finalized.
$complete_final = $judge_score->finalized;
}
return $complete_final;
}
/**
* Render score details.
*
* Generate a table render array showing score details across all judges
* assigned to this entry in a given round.
*
* @param int $round_id
* Round ID.
* @param array $options
* Options for rendering:
* 'close' - boolean - whether to include link to remove score table from
* DOM. Defaults to FALSE if not provided.
*
* @return array
* Render array of type 'table'
*/
public function renderScoreDetailsTable($round_id, array $options = []) {
$build = [
'#type' => 'table',
'#attributes' => [
'class' => ['judging-score-details'],
],
'#header' => [
$this->t('Judge'),
$this->t('Criteria'),
[
'data' => $this->t('Score'),
'class' => ['score'],
],
[
'data' => $this->t('Display'),
'class' => 'score',
],
$this->t('Finalized'),
],
'#empty' => $this->t('No judges have been assigned to this entry yet.'),
'#rows' => [],
];
// TODO: use theme hook + template for this?
$build['#prefix'] = '<div class="judging-score-details-wrap">';
if (isset($options['close']) && $options['close'] === TRUE) {
$build['#prefix'] .= $this->t('<span class="close">Close</span>');
}
$build['#prefix'] .= '<p class="header">' . $this->t('Round @round_id score details for entry @entry_id', [
'@round_id' => $round_id,
'@entry_id' => $this->id(),
]) . '</p>';
$build['#suffix'] = '</div>';
// Get criteria config.
$judging = $this->getCompetition()->getJudging();
// pass_fail or criteria.
$round_type = $judging->rounds[$round_id]['round_type'];
$criteria = $judging->rounds[$round_id]['weighted_criteria'];
$criteria_meta = [];
$ci = 0;
foreach ($criteria as $label => $weight) {
$criteria_meta['c' . $ci] = [
'weight' => $weight,
'label_output' => $label . ' (' . $weight . '%)',
];
$ci++;
}
$points_max = (int) $judging->rounds[$round_id]['criterion_options'];
$data = $this->getData();
if (!empty($data['judging']['rounds'][$round_id]['scores'])) {
// TODO: is there a way to inject this?
$storage_user = \Drupal::entityTypeManager()->getStorage('user');
// Collect weighted score (total points / 100) for each judge.
$overall_scores = [];
foreach ($data['judging']['rounds'][$round_id]['scores'] as $score) {
$overall_scores[$score->uid] = 0;
$start_judge = TRUE;
foreach ($criteria_meta as $key => $meta) {
$row = [
'no_striping' => TRUE,
'data' => [],
];
if ($start_judge) {
$row['class'] = ['judge-div'];
$start_judge = FALSE;
}
// "Judge" column.
$row['data']['name'] = $storage_user->load($score->uid)->getUsername();
// "Criteria" column.
$row['data']['criterion'] = $meta['label_output'];
// "Score" and "Display" columns.
$row['data']['score'] = [
'data' => '',
'class' => ['score'],
];
$row['data']['display'] = [
'data' => '',
'class' => ['score'],
];
$weighted_points = $score->criteria[$key];
if ($weighted_points === NULL) {
// Judge has not yet submitted a value for this criterion.
$row['data']['score']['data'] = '-';
$row['data']['display']['data'] = $this->t('- None -');
// If any criterion value is missing, this judge's overall score
// is incomplete.
$overall_scores[$score->uid] = NULL;
}
else {
// "Score" column
// ex: "15 / 20 = 75.00%".
$weight = $meta['weight'];
$percent = $weighted_points / $weight;
$row['data']['score']['data'] = $weighted_points . " / " . $weight;
$row['data']['score']['data'] .= " = " . round(($percent * 100), 2) . "%";
// "Display" column
// ex: "3 / 4" -or- "Pass" or "Fail".
$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);
if ($round_type == 'criteria') {
$row['data']['display']['data'] = $points . " / " . $points_max;
}
elseif ($round_type == 'pass_fail') {
$row['data']['display']['data'] = ($points == 0 ? $this->t("Fail") : $this->t("Pass"));
}
// Add criterion value to this judge's overall score.
if ($overall_scores[$score->uid] !== NULL) {
$overall_scores[$score->uid] += $weighted_points;
}
}
// "Finalized" column.
$row['data']['finalized'] = [
'data' => ($score->finalized ? $this->t('Yes') : $this->t('No')),
];
$build['#rows'][] = $row;
}
// Remove incomplete scores so they are excluded from average.
if ($overall_scores[$score->uid] === NULL) {
unset($overall_scores[$score->uid]);
}
}
// Overall average score row.
$row = [
'no_striping' => TRUE,
'data' => [],
'class' => ['average'],
];
$row['data']['average'] = [
'data' => '',
'class' => ['average'],
'colspan' => 3,
];
if (empty($overall_scores)) {
$row['data']['average']['data'] = $this->t('Average: -');
}
else {
$average = round(array_sum($overall_scores) / count($overall_scores), 2);
$row['data']['average']['data'] = $this->t('Average: (@scores) / @overall_scores = @average%', [
'@scores' => implode(' + ', $overall_scores),
'@overall_scores' => count($overall_scores),
'@average' => $average,
]);
}
$row['data']['display'] = '';
$row['data']['finalized'] = '';
$build['#rows'][] = $row;
}
return $build;
}
/**
* Add single message to entry's judging log.
*
* @param int|null $uid
* User ID that performed the entry log action.
* NULL if performed by the system.
* @param int|null $round_id
* The judging round to which this action is associated, or during which
* it occurred. If NULL, the competition's active round will be used.
* Note: this is not currently displayed anywhere; it's used as metadata
* when deleting log messages along with round assignments/scores.
* @param string $message
* The untranslated log message. May contain placeholders as supported by
* translation functions (t(), etc.).
* @param array $message_args
* (Optional) Values to be replaced in the message, when output by a
* translation function (t(), etc.).
*/
public function addJudgingLog($uid, $round_id, $message, array $message_args = []) {
$log = [
'uid' => $uid,
'round_id' => $round_id,
'message' => $message,
'message_args' => $message_args,
];
$this->addJudgingLogMultiple([$log]);
}
/**
* Add multiple messages to entry's judging log.
*
* For efficiency, this should be used instead of addJudgingLog() whenever
* multiple messages need to be logged at a single point in the code - as this
* calls entry save() method only once.
*
* @param array $logs
* Array of associative arrays, each one defining a single log message,
* with keys pointing to corresponding addJudgingLog() method params:
* 'uid'
* 'round_id'
* 'message'
* 'message_args' (optional)
*
* @throws \InvalidArgumentException
* Thrown if $logs does not contain properly structured arrays to define
* messages.
*/
public function addJudgingLogMultiple(array $logs) {
// Validate $logs.
foreach ($logs as $log) {
foreach (['uid', 'round_id', 'message'] as $key) {
if (!array_key_exists($key, $log)) {
throw new \InvalidArgumentException('Each array in $logs parameter must include at least the keys "uid" and "message".');
}
}
}
// Fill in timestamp, message_args, default round_id.
$judging = $this->getCompetition()->getJudging();
foreach ($logs as &$log) {
$log['timestamp'] = REQUEST_TIME;
if (empty($log['message_args'])) {
$log['message_args'] = [];
}
// If round ID not given, use the active round, as that's when the action
// occurred.
// While this codebase does not currently do so, it is possible to log a
// message while no round is active - in which case there's no choice but
// to store NULL. Such messages would not be deleted when deleting all
// data for a round (via judging workflow form).
if ($log['round_id'] === NULL) {
$log['round_id'] = (!empty($judging->active_round) ? $judging->active_round : NULL);
}
if (is_numeric($log['round_id'])) {
$log['round_id'] = (int) $log['round_id'];
}
}
$data = $this->getData();
if (!isset($data['judging']['log'])) {
$data['judging']['log'] = [];
}
$data['judging']['log'] = array_merge($data['judging']['log'], $logs);
$this->setData($data);
$this->save();
}
/**
* Get judging log items.
*
* @return array|null
* Judging log notes for this entry
*/
public function getJudgingLog() {
$data = $this->getData();
if (!empty($data['judging']['log'])) {
return $data['judging']['log'];
}
return NULL;
}
/**
* Render judging log.
*
* @return array|null
* Drupal table render array
*/
public function renderJudgingLog() {
$logs = $this->getJudgingLog();
if ($logs) {
// Log messages are stored in chronological order; reverse it so that
// actions are displayed in most-recent to least-recent order.
$logs = array_reverse($logs);
$rows = [];
foreach ($logs as $log) {
$name = '';
if (!empty($log['uid'])) {
// Note: `Entity` class provides entityTypeManager() method, but it's
// protected, so we can't access it in this trait.
$user = \Drupal::entityTypeManager()->getStorage('user')->load($log['uid']);
$name = $user->getUsername();
}
else {
// Automated/system actions.
$name = $this->t('System');
}
$rows[] = [
[
'data' => $name,
'class' => ['nowrap'],
],
[
'data' => date('m/d/Y g:i a', $log['timestamp']),
'class' => ['nowrap'],
],
!empty($log['message_args']) ? FormattableMarkup::placeholderFormat($log['message'], $log['message_args']) : $log['message'],
];
}
return [
'#type' => 'table',
'#header' => [
$this->t('Judge'),
$this->t('Timestamp'),
$this->t('Note'),
],
'#empty' => $this->t('There are no log messages for this entry yet.'),
'#rows' => $rows,
];
}
return NULL;
}
/**
* Does this entry exist in a queue.
*
* @param string $queue
* Queue name.
*
* @return bool
* TRUE if entry exists in queue, FALSE if not.
*/
public function existsInQueue($queue) {
$data = $this->getData();
return in_array($queue, $data['judging']['queues']);
}
/**
* Move entry to a judging queue.
*
* @param string $queue
* Queue name.
*
* @return bool
* TRUE if entry added to queue; FALSE if entry was already in queue.
*
* @throws \InvalidArgumentException
* If $queue is not the name of a queue used in this competition.
*/
public function queueAdd($queue) {
$competition = $this->getCompetition();
$judging = $competition->getJudging();
if (!array_key_exists($queue, $judging->queues)) {
throw new \InvalidArgumentException('$queue must be the name of a queue used in this competition.');
}
$data = $this->getData();
if (!isset($data['judging']['queues'])) {
$data['judging']['queues'] = [];
}
if (in_array($queue, $data['judging']['queues'])) {
// Entry is already in this queue.
return FALSE;
}
// Overwrite the queues array to remove all existing queue assignments.
$data['judging']['queues'] = [$queue];
$this->setData($data);
$this->save();
// Pass NULL to log this action within the active round, since that's when
// it occurred.
$queues = \Drupal::config('competition.settings')->get('queues');
$this->addJudgingLog(\Drupal::currentUser()->id(), NULL, 'Moved entry @ceid to %queue_label list.', [
'@ceid' => $this->id(),
'%queue_label' => $queues[$queue],
]);
return TRUE;
}
/**
* Remove entry from a judging queue.
*
* @param string $queue
* Queue name.
*
* @return bool
* TRUE if entry removed from queue; FALSE if entry was not in queue to
* begin with.
*
* @throws \InvalidArgumentException
* If $queue is not the name of a queue used in this competition.
*/
public function queueRemove($queue) {
$competition = $this->getCompetition();
$judging = $competition->getJudging();
if (!array_key_exists($queue, $judging->queues)) {
throw new \InvalidArgumentException('$queue must be the name of a queue used in this competition.');
}
$data = $this->getData();
if (!in_array($queue, $data['judging']['queues'])) {
// Entry was not in this queue - cannot remove it.
return FALSE;
}
foreach ($data['judging']['queues'] as $index => $key) {
if ($key == $queue) {
unset($data['judging']['queues'][$index]);
$this->setData($data);
$this->save();
// Pass NULL to log this action within the active round, since that's
// when it occurred.
$queues = \Drupal::config('competition.settings')->get('queues');
$this->addJudgingLog(\Drupal::currentUser()->id(), NULL, 'Removed entry @ceid from %queue_label list.', [
'@ceid' => $this->id(),
'%queue_label' => $queues[$queue],
]);
return TRUE;
}
}
}
/**
* Alter judging.
*
* @param array $data
* Judging data.
*/
protected function dataReportingAlterJudging(array &$data) {
$judging = $this->getCompetition()->getJudging();
foreach ($data['judging']['rounds'] as $round_id => &$round) {
// 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'])) {
$round_config = $judging->rounds[$round_id];
$criteria_weights = [];
$i = 0;
foreach ($round_config['weighted_criteria'] as $weight) {
$criteria_weights['c' . $i] = (int) $weight;
$i++;
}
$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) {
$overall_scores[$score->uid] = 0;
if (!empty($score->criteria)) {
foreach ($score->criteria as $key => $weighted_points) {
if ($weighted_points === NULL) {
$score->criteria[$key] = [
'weighted_points' => NULL,
'display_weighted_points' => '-',
'display_percent' => '-',
'display_points' => '-',
];
$overall_scores[$score->uid] = NULL;
}
else {
$percent = $weighted_points / $criteria_weights[$key];
$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);
$score->criteria[$key] = [
// criterion.weighted_points
// 22.5.
'weighted_points' => $weighted_points,
// criterion.display_weighted_points
// "22.5 / 30".
'display_weighted_points' => $weighted_points . ' / ' . $criteria_weights[$key],
// 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) {
$score->weighted_points = $overall_scores[$score->uid];
}
else {
$score->weighted_points = NULL;
// Remove incomplete scores from counting towards average.
unset($overall_scores[$score->uid]);
}
}
if (count($overall_scores) > 0) {
$round['display_total_weighted_points'] = array_sum($overall_scores) . ' / ' . (count($overall_scores) * 100);
}
else {
$round['display_total_weighted_points'] = '-';
}
}
// We store computed overall average already; add display version.
if ($round['computed'] !== NULL) {
$round['display_average'] = number_format(round($round['computed'], 2), 2) . '%';
}
else {
$round['display_average'] = '-';
}
}
}
}
