Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 311 and 402] [Versions 311 and 403] [Versions 39 and 311]

   1  <?php
   2  
   3  // This file is part of Moodle - http://moodle.org/
   4  //
   5  // Moodle is free software: you can redistribute it and/or modify
   6  // it under the terms of the GNU General Public License as published by
   7  // the Free Software Foundation, either version 3 of the License, or
   8  // (at your option) any later version.
   9  //
  10  // Moodle is distributed in the hope that it will be useful,
  11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  13  // GNU General Public License for more details.
  14  //
  15  // You should have received a copy of the GNU General Public License
  16  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  17  
  18  /**
  19   * Allocates the submissions randomly
  20   *
  21   * @package    workshopallocation_random
  22   * @subpackage mod_workshop
  23   * @copyright  2009 David Mudrak <david.mudrak@gmail.com>
  24   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  25   */
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  global $CFG;    // access to global variables during unit test
  30  
  31  require_once (__DIR__ . '/../lib.php');            // interface definition
  32  require_once (__DIR__ . '/../../locallib.php');    // workshop internal API
  33  require_once (__DIR__ . '/settings_form.php');     // settings form
  34  
  35  /**
  36   * Allocates the submissions randomly
  37   */
  38  class workshop_random_allocator implements workshop_allocator {
  39  
  40      /** constants used to pass status messages between init() and ui() */
  41      const MSG_SUCCESS       = 1;
  42  
  43      /** workshop instance */
  44      protected $workshop;
  45  
  46      /** mform with settings */
  47      protected $mform;
  48  
  49      /**
  50       * @param workshop $workshop Workshop API object
  51       */
  52      public function __construct(workshop $workshop) {
  53          $this->workshop = $workshop;
  54      }
  55  
  56      /**
  57       * Allocate submissions as requested by user
  58       *
  59       * @return workshop_allocation_result
  60       */
  61      public function init() {
  62          global $PAGE;
  63  
  64          $result = new workshop_allocation_result($this);
  65          $customdata = array();
  66          $customdata['workshop'] = $this->workshop;
  67          $this->mform = new workshop_random_allocator_form($PAGE->url, $customdata);
  68          if ($this->mform->is_cancelled()) {
  69              redirect($this->workshop->view_url());
  70          } else if ($settings = $this->mform->get_data()) {
  71              $settings = workshop_random_allocator_setting::instance_from_object($settings);
  72              $this->execute($settings, $result);
  73              return $result;
  74          } else {
  75              // this branch is executed if the form is submitted but the data
  76              // doesn't validate and the form should be redisplayed
  77              // or on the first display of the form.
  78              $result->set_status(workshop_allocation_result::STATUS_VOID);
  79              return $result;
  80          }
  81      }
  82  
  83      /**
  84       * Executes the allocation based on the given settings
  85       *
  86       * @param workshop_random_allocator_setting $setting
  87       * @param workshop_allocation_result allocation result logger
  88       */
  89      public function execute(workshop_random_allocator_setting $settings, workshop_allocation_result $result) {
  90  
  91          $authors        = $this->workshop->get_potential_authors();
  92          $authors        = $this->workshop->get_grouped($authors);
  93          $reviewers      = $this->workshop->get_potential_reviewers(!$settings->assesswosubmission);
  94          $reviewers      = $this->workshop->get_grouped($reviewers);
  95          $assessments    = $this->workshop->get_all_assessments();
  96          $newallocations = array();      // array of array(reviewer => reviewee)
  97  
  98          if ($settings->numofreviews) {
  99              if ($settings->removecurrent) {
 100                  // behave as if there were no current assessments
 101                  $curassessments = array();
 102              } else {
 103                  $curassessments = $assessments;
 104              }
 105              $options                     = array();
 106              $options['numofreviews']     = $settings->numofreviews;
 107              $options['numper']           = $settings->numper;
 108              $options['excludesamegroup'] = $settings->excludesamegroup;
 109              $randomallocations  = $this->random_allocation($authors, $reviewers, $curassessments, $result, $options);
 110              $newallocations     = array_merge($newallocations, $randomallocations);
 111              $result->log(get_string('numofrandomlyallocatedsubmissions', 'workshopallocation_random', count($randomallocations)));
 112              unset($randomallocations);
 113          }
 114          if ($settings->addselfassessment) {
 115              $selfallocations    = $this->self_allocation($authors, $reviewers, $assessments);
 116              $newallocations     = array_merge($newallocations, $selfallocations);
 117              $result->log(get_string('numofselfallocatedsubmissions', 'workshopallocation_random', count($selfallocations)));
 118              unset($selfallocations);
 119          }
 120          if (empty($newallocations)) {
 121              $result->log(get_string('noallocationtoadd', 'workshopallocation_random'), 'info');
 122          } else {
 123              $newnonexistingallocations = $newallocations;
 124              $this->filter_current_assessments($newnonexistingallocations, $assessments);
 125              $this->add_new_allocations($newnonexistingallocations, $authors, $reviewers);
 126              $allreviewers = $reviewers[0];
 127              $allreviewersreloaded = false;
 128              foreach ($newallocations as $newallocation) {
 129                  $reviewerid = key($newallocation);
 130                  $authorid = current($newallocation);
 131                  $a = new stdClass();
 132                  if (isset($allreviewers[$reviewerid])) {
 133                      $a->reviewername = fullname($allreviewers[$reviewerid]);
 134                  } else {
 135                      // this may happen if $settings->assesswosubmission is false but the reviewer
 136                      // of the re-used assessment has not submitted anything. let us reload
 137                      // the list of reviewers name including those without their submission
 138                      if (!$allreviewersreloaded) {
 139                          $allreviewers = $this->workshop->get_potential_reviewers(false);
 140                          $allreviewersreloaded = true;
 141                      }
 142                      if (isset($allreviewers[$reviewerid])) {
 143                          $a->reviewername = fullname($allreviewers[$reviewerid]);
 144                      } else {
 145                          // this should not happen usually unless the list of participants was changed
 146                          // in between two cycles of allocations
 147                          $a->reviewername = '#'.$reviewerid;
 148                      }
 149                  }
 150                  if (isset($authors[0][$authorid])) {
 151                      $a->authorname = fullname($authors[0][$authorid]);
 152                  } else {
 153                      $a->authorname = '#'.$authorid;
 154                  }
 155                  if (in_array($newallocation, $newnonexistingallocations)) {
 156                      $result->log(get_string('allocationaddeddetail', 'workshopallocation_random', $a), 'ok', 1);
 157                  } else {
 158                      $result->log(get_string('allocationreuseddetail', 'workshopallocation_random', $a), 'ok', 1);
 159                  }
 160              }
 161          }
 162          if ($settings->removecurrent) {
 163              $delassessments = $this->get_unkept_assessments($assessments, $newallocations, $settings->addselfassessment);
 164              // random allocator should not be able to delete assessments that have already been graded
 165              // by reviewer
 166              $result->log(get_string('numofdeallocatedassessment', 'workshopallocation_random', count($delassessments)), 'info');
 167              foreach ($delassessments as $delassessmentkey => $delassessmentid) {
 168                  $author = (object) [];
 169                  $reviewer = (object) [];
 170                  username_load_fields_from_object($author, $assessments[$delassessmentid], 'author');
 171                  username_load_fields_from_object($reviewer, $assessments[$delassessmentid], 'reviewer');
 172                  $a = [
 173                      'authorname' => fullname($author),
 174                      'reviewername' => fullname($reviewer),
 175                  ];
 176                  if (!is_null($assessments[$delassessmentid]->grade)) {
 177                      $result->log(get_string('allocationdeallocategraded', 'workshopallocation_random', $a), 'error', 1);
 178                      unset($delassessments[$delassessmentkey]);
 179                  } else {
 180                      $result->log(get_string('assessmentdeleteddetail', 'workshopallocation_random', $a), 'info', 1);
 181                  }
 182              }
 183              $this->workshop->delete_assessment($delassessments);
 184          }
 185          $result->set_status(workshop_allocation_result::STATUS_EXECUTED);
 186      }
 187  
 188      /**
 189       * Returns the HTML code to print the user interface
 190       */
 191      public function ui() {
 192          global $PAGE;
 193  
 194          $output = $PAGE->get_renderer('mod_workshop');
 195  
 196          $m = optional_param('m', null, PARAM_INT);  // status message code
 197          $message = new workshop_message();
 198          if ($m == self::MSG_SUCCESS) {
 199              $message->set_text(get_string('randomallocationdone', 'workshopallocation_random'));
 200              $message->set_type(workshop_message::TYPE_OK);
 201          }
 202  
 203          $out  = $output->container_start('random-allocator');
 204          $out .= $output->render($message);
 205          // the nasty hack follows to bypass the sad fact that moodle quickforms do not allow to actually
 206          // return the HTML content, just to display it
 207          ob_start();
 208          $this->mform->display();
 209          $out .= ob_get_contents();
 210          ob_end_clean();
 211  
 212          // if there are some not-grouped participant in a group mode, warn the user
 213          $gmode = groups_get_activity_groupmode($this->workshop->cm, $this->workshop->course);
 214          if (VISIBLEGROUPS == $gmode or SEPARATEGROUPS == $gmode) {
 215              $users = $this->workshop->get_potential_authors() + $this->workshop->get_potential_reviewers();
 216              $users = $this->workshop->get_grouped($users);
 217              if (isset($users[0])) {
 218                  $nogroupusers = $users[0];
 219                  foreach ($users as $groupid => $groupusers) {
 220                      if ($groupid == 0) {
 221                          continue;
 222                      }
 223                      foreach ($groupusers as $groupuserid => $groupuser) {
 224                          unset($nogroupusers[$groupuserid]);
 225                      }
 226                  }
 227                  if (!empty($nogroupusers)) {
 228                      $list = array();
 229                      foreach ($nogroupusers as $nogroupuser) {
 230                          $list[] = fullname($nogroupuser);
 231                      }
 232                      $a = implode(', ', $list);
 233                      $out .= $output->box(get_string('nogroupusers', 'workshopallocation_random', $a), 'generalbox warning nogroupusers');
 234                  }
 235              }
 236          }
 237  
 238          // TODO $out .= $output->heading(get_string('stats', 'workshopallocation_random'));
 239  
 240          $out .= $output->container_end();
 241  
 242          return $out;
 243      }
 244  
 245      /**
 246       * Delete all data related to a given workshop module instance
 247       *
 248       * This plugin does not store any data.
 249       *
 250       * @see workshop_delete_instance()
 251       * @param int $workshopid id of the workshop module instance being deleted
 252       * @return void
 253       */
 254      public static function delete_instance($workshopid) {
 255          return;
 256      }
 257  
 258      /**
 259       * Return an array of possible numbers of reviews to be done
 260       *
 261       * Should contain numbers 1, 2, 3, ... 10 and possibly others up to a reasonable value
 262       *
 263       * @return array of integers
 264       */
 265      public static function available_numofreviews_list() {
 266  
 267          $options = [];
 268  
 269          for ($i = 100; $i > 20; $i = $i - 10) {
 270              $options[$i] = $i;
 271          }
 272  
 273          for ($i = 20; $i >= 0; $i--) {
 274              $options[$i] = $i;
 275          }
 276  
 277          return $options;
 278      }
 279  
 280      /**
 281       * Allocates submissions to their authors for review
 282       *
 283       * If the submission has already been allocated, it is skipped. If the author is not found among
 284       * reviewers, the submission is not assigned.
 285       *
 286       * @param array $authors grouped of {@see workshop::get_potential_authors()}
 287       * @param array $reviewers grouped by {@see workshop::get_potential_reviewers()}
 288       * @param array $assessments as returned by {@see workshop::get_all_assessments()}
 289       * @return array of new allocations to be created, array of array(reviewerid => authorid)
 290       */
 291      protected function self_allocation($authors=array(), $reviewers=array(), $assessments=array()) {
 292          if (!isset($authors[0]) || !isset($reviewers[0])) {
 293              // no authors or no reviewers
 294              return array();
 295          }
 296          $alreadyallocated = array();
 297          foreach ($assessments as $assessment) {
 298              if ($assessment->authorid == $assessment->reviewerid) {
 299                  $alreadyallocated[$assessment->authorid] = 1;
 300              }
 301          }
 302          $add = array(); // list of new allocations to be created
 303          foreach ($authors[0] as $authorid => $author) {
 304              // for all authors in all groups
 305              if (isset($reviewers[0][$authorid])) {
 306                  // if the author can be reviewer
 307                  if (!isset($alreadyallocated[$authorid])) {
 308                      // and the allocation does not exist yet, then
 309                      $add[] = array($authorid => $authorid);
 310                  }
 311              }
 312          }
 313          return $add;
 314      }
 315  
 316      /**
 317       * Creates new assessment records
 318       *
 319       * @param array $newallocations pairs 'reviewerid' => 'authorid'
 320       * @param array $dataauthors    authors by group, group [0] contains all authors
 321       * @param array $datareviewers  reviewers by group, group [0] contains all reviewers
 322       * @return bool
 323       */
 324      protected function add_new_allocations(array $newallocations, array $dataauthors, array $datareviewers) {
 325          global $DB;
 326  
 327          $newallocations = $this->get_unique_allocations($newallocations);
 328          $authorids      = $this->get_author_ids($newallocations);
 329          $submissions    = $this->workshop->get_submissions($authorids);
 330          $submissions    = $this->index_submissions_by_authors($submissions);
 331          foreach ($newallocations as $newallocation) {
 332              $reviewerid = key($newallocation);
 333              $authorid = current($newallocation);
 334              if (!isset($submissions[$authorid])) {
 335                  throw new moodle_exception('unabletoallocateauthorwithoutsubmission', 'workshop');
 336              }
 337              $submission = $submissions[$authorid];
 338              $status = $this->workshop->add_allocation($submission, $reviewerid, 1, true);   // todo configurable weight?
 339              if (workshop::ALLOCATION_EXISTS == $status) {
 340                  debugging('newallocations array contains existing allocation, this should not happen');
 341              }
 342          }
 343      }
 344  
 345      /**
 346       * Flips the structure of submission so it is indexed by authorid attribute
 347       *
 348       * It is the caller's responsibility to make sure the submissions are not teacher
 349       * examples so no user is the author of more submissions.
 350       *
 351       * @param string $submissions array indexed by submission id
 352       * @return array indexed by author id
 353       */
 354      protected function index_submissions_by_authors($submissions) {
 355          $byauthor = array();
 356          if (is_array($submissions)) {
 357              foreach ($submissions as $submissionid => $submission) {
 358                  if (isset($byauthor[$submission->authorid])) {
 359                      throw new moodle_exception('moresubmissionsbyauthor', 'workshop');
 360                  }
 361                  $byauthor[$submission->authorid] = $submission;
 362              }
 363          }
 364          return $byauthor;
 365      }
 366  
 367      /**
 368       * Extracts unique list of authors' IDs from the structure of new allocations
 369       *
 370       * @param array $newallocations of pairs 'reviewerid' => 'authorid'
 371       * @return array of authorids
 372       */
 373      protected function get_author_ids($newallocations) {
 374          $authors = array();
 375          foreach ($newallocations as $newallocation) {
 376              $authorid = reset($newallocation);
 377              if (!in_array($authorid, $authors)) {
 378                  $authors[] = $authorid;
 379              }
 380          }
 381          return $authors;
 382      }
 383  
 384      /**
 385       * Removes duplicate allocations
 386       *
 387       * @param mixed $newallocations array of 'reviewerid' => 'authorid' pairs
 388       * @return array
 389       */
 390      protected function get_unique_allocations($newallocations) {
 391          return array_merge(array_map('unserialize', array_unique(array_map('serialize', $newallocations))));
 392      }
 393  
 394      /**
 395       * Returns the list of assessments to remove
 396       *
 397       * If user selects "removecurrentallocations", we should remove all current assessment records
 398       * and insert new ones. But this would needlessly waste table ids. Instead, let us find only those
 399       * assessments that have not been re-allocated in this run of allocation. So, the once-allocated
 400       * submissions are kept with their original id.
 401       *
 402       * @param array $assessments         list of current assessments
 403       * @param mixed $newallocations      array of 'reviewerid' => 'authorid' pairs
 404       * @param bool  $keepselfassessments do not remove already allocated self assessments
 405       * @return array of assessments ids to be removed
 406       */
 407      protected function get_unkept_assessments($assessments, $newallocations, $keepselfassessments) {
 408          $keepids = array(); // keep these assessments
 409          foreach ($assessments as $assessmentid => $assessment) {
 410              $aaid = $assessment->authorid;
 411              $arid = $assessment->reviewerid;
 412              if (($keepselfassessments) && ($aaid == $arid)) {
 413                  $keepids[$assessmentid] = null;
 414                  continue;
 415              }
 416              foreach ($newallocations as $newallocation) {
 417                  $nrid = key($newallocation);
 418                  $naid = current($newallocation);
 419                  if (array($arid, $aaid) == array($nrid, $naid)) {
 420                      // re-allocation found - let us continue with the next assessment
 421                      $keepids[$assessmentid] = null;
 422                      continue 2;
 423                  }
 424              }
 425          }
 426          return array_keys(array_diff_key($assessments, $keepids));
 427      }
 428  
 429      /**
 430       * Allocates submission reviews randomly
 431       *
 432       * The algorithm of this function has been described at http://moodle.org/mod/forum/discuss.php?d=128473
 433       * Please see the PDF attached to the post before you study the implementation. The goal of the function
 434       * is to connect each "circle" (circles are representing either authors or reviewers) with a required
 435       * number of "squares" (the other type than circles are).
 436       *
 437       * The passed $options array must provide keys:
 438       *      (int)numofreviews - number of reviews to be allocated to each circle
 439       *      (int)numper - what user type the circles represent.
 440       *      (bool)excludesamegroup - whether to prevent peer submissions from the same group in visible group mode
 441       *
 442       * @param array    $authors      structure of grouped authors
 443       * @param array    $reviewers    structure of grouped reviewers
 444       * @param array    $assessments  currently assigned assessments to be kept
 445       * @param workshop_allocation_result $result allocation result logger
 446       * @param array    $options      allocation options
 447       * @return array                 array of (reviewerid => authorid) pairs
 448       */
 449      protected function random_allocation($authors, $reviewers, $assessments, $result, array $options) {
 450          if (empty($authors) || empty($reviewers)) {
 451              // nothing to be done
 452              return array();
 453          }
 454  
 455          $numofreviews = $options['numofreviews'];
 456          $numper       = $options['numper'];
 457  
 458          if (workshop_random_allocator_setting::NUMPER_SUBMISSION == $numper) {
 459              // circles are authors, squares are reviewers
 460              $result->log(get_string('resultnumperauthor', 'workshopallocation_random', $numofreviews), 'info');
 461              $allcircles = $authors;
 462              $allsquares = $reviewers;
 463              // get current workload
 464              list($circlelinks, $squarelinks) = $this->convert_assessments_to_links($assessments);
 465          } elseif (workshop_random_allocator_setting::NUMPER_REVIEWER == $numper) {
 466              // circles are reviewers, squares are authors
 467              $result->log(get_string('resultnumperreviewer', 'workshopallocation_random', $numofreviews), 'info');
 468              $allcircles = $reviewers;
 469              $allsquares = $authors;
 470              // get current workload
 471              list($squarelinks, $circlelinks) = $this->convert_assessments_to_links($assessments);
 472          } else {
 473              throw new moodle_exception('unknownusertypepassed', 'workshop');
 474          }
 475          // get the users that are not in any group. in visible groups mode, these users are exluded
 476          // from allocation by this method
 477          // $nogroupcircles is array (int)$userid => undefined
 478          if (isset($allcircles[0])) {
 479              $nogroupcircles = array_flip(array_keys($allcircles[0]));
 480          } else {
 481              $nogroupcircles = array();
 482          }
 483          foreach ($allcircles as $circlegroupid => $circles) {
 484              if ($circlegroupid == 0) {
 485                  continue;
 486              }
 487              foreach ($circles as $circleid => $circle) {
 488                  unset($nogroupcircles[$circleid]);
 489              }
 490          }
 491          // $result->log('circle links = ' . json_encode($circlelinks), 'debug');
 492          // $result->log('square links = ' . json_encode($squarelinks), 'debug');
 493          $squareworkload         = array();  // individual workload indexed by squareid
 494          $squaregroupsworkload   = array();    // group workload indexed by squaregroupid
 495          foreach ($allsquares as $squaregroupid => $squares) {
 496              $squaregroupsworkload[$squaregroupid] = 0;
 497              foreach ($squares as $squareid => $square) {
 498                  if (!isset($squarelinks[$squareid])) {
 499                      $squarelinks[$squareid] = array();
 500                  }
 501                  $squareworkload[$squareid] = count($squarelinks[$squareid]);
 502                  $squaregroupsworkload[$squaregroupid] += $squareworkload[$squareid];
 503              }
 504              $squaregroupsworkload[$squaregroupid] /= count($squares);
 505          }
 506          unset($squaregroupsworkload[0]);    // [0] is not real group, it contains all users
 507          // $result->log('square workload = ' . json_encode($squareworkload), 'debug');
 508          // $result->log('square group workload = ' . json_encode($squaregroupsworkload), 'debug');
 509          $gmode = groups_get_activity_groupmode($this->workshop->cm, $this->workshop->course);
 510          if (SEPARATEGROUPS == $gmode) {
 511              // shuffle all groups but [0] which means "all users"
 512              $circlegroups = array_keys(array_diff_key($allcircles, array(0 => null)));
 513              shuffle($circlegroups);
 514          } else {
 515              // all users will be processed at once
 516              $circlegroups = array(0);
 517          }
 518          // $result->log('circle groups = ' . json_encode($circlegroups), 'debug');
 519          foreach ($circlegroups as $circlegroupid) {
 520              $result->log('processing circle group id ' . $circlegroupid, 'debug');
 521              $circles = $allcircles[$circlegroupid];
 522              // iterate over all circles in the group until the requested number of links per circle exists
 523              // or it is not possible to fulfill that requirment
 524              // during the first iteration, we try to make sure that at least one circlelink exists. during the
 525              // second iteration, we try to allocate two, etc.
 526              for ($requiredreviews = 1; $requiredreviews <= $numofreviews; $requiredreviews++) {
 527                  $this->shuffle_assoc($circles);
 528                  $result->log('iteration ' . $requiredreviews, 'debug');
 529                  foreach ($circles as $circleid => $circle) {
 530                      if (VISIBLEGROUPS == $gmode and isset($nogroupcircles[$circleid])) {
 531                          $result->log('skipping circle id ' . $circleid, 'debug');
 532                          continue;
 533                      }
 534                      $result->log('processing circle id ' . $circleid, 'debug');
 535                      if (!isset($circlelinks[$circleid])) {
 536                          $circlelinks[$circleid] = array();
 537                      }
 538                      $keeptrying     = true;     // is there a chance to find a square for this circle?
 539                      $failedgroups   = array();  // array of groupids where the square should be chosen from (because
 540                                                  // of their group workload) but it was not possible (for example there
 541                                                  // was the only square and it had been already connected
 542                      while ($keeptrying && (count($circlelinks[$circleid]) < $requiredreviews)) {
 543                          // firstly, choose a group to pick the square from
 544                          if (NOGROUPS == $gmode) {
 545                              if (in_array(0, $failedgroups)) {
 546                                  $keeptrying = false;
 547                                  $result->log(get_string('resultnomorepeers', 'workshopallocation_random'), 'error', 1);
 548                                  break;
 549                              }
 550                              $targetgroup = 0;
 551                          } elseif (SEPARATEGROUPS == $gmode) {
 552                              if (in_array($circlegroupid, $failedgroups)) {
 553                                  $keeptrying = false;
 554                                  $result->log(get_string('resultnomorepeersingroup', 'workshopallocation_random'), 'error', 1);
 555                                  break;
 556                              }
 557                              $targetgroup = $circlegroupid;
 558                          } elseif (VISIBLEGROUPS == $gmode) {
 559                              $trygroups = array_diff_key($squaregroupsworkload, array(0 => null));   // all but [0]
 560                              $trygroups = array_diff_key($trygroups, array_flip($failedgroups));     // without previous failures
 561                              if ($options['excludesamegroup']) {
 562                                  // exclude groups the circle is member of
 563                                  $excludegroups = array();
 564                                  foreach (array_diff_key($allcircles, array(0 => null)) as $exgroupid => $exgroupmembers) {
 565                                      if (array_key_exists($circleid, $exgroupmembers)) {
 566                                          $excludegroups[$exgroupid] = null;
 567                                      }
 568                                  }
 569                                  $trygroups = array_diff_key($trygroups, $excludegroups);
 570                              }
 571                              $targetgroup = $this->get_element_with_lowest_workload($trygroups);
 572                          }
 573                          if ($targetgroup === false) {
 574                              $keeptrying = false;
 575                              $result->log(get_string('resultnotenoughpeers', 'workshopallocation_random'), 'error', 1);
 576                              break;
 577                          }
 578                          $result->log('next square should be from group id ' . $targetgroup, 'debug', 1);
 579                          // now, choose a square from the target group
 580                          $trysquares = array_intersect_key($squareworkload, $allsquares[$targetgroup]);
 581                          // $result->log('individual workloads in this group are ' . json_encode($trysquares), 'debug', 1);
 582                          unset($trysquares[$circleid]);  // can't allocate to self
 583                          $trysquares = array_diff_key($trysquares, array_flip($circlelinks[$circleid])); // can't re-allocate the same
 584                          $targetsquare = $this->get_element_with_lowest_workload($trysquares);
 585                          if (false === $targetsquare) {
 586                              $result->log('unable to find an available square. trying another group', 'debug', 1);
 587                              $failedgroups[] = $targetgroup;
 588                              continue;
 589                          }
 590                          $result->log('target square = ' . $targetsquare, 'debug', 1);
 591                          // ok - we have found the square
 592                          $circlelinks[$circleid][]       = $targetsquare;
 593                          $squarelinks[$targetsquare][]   = $circleid;
 594                          $squareworkload[$targetsquare]++;
 595                          $result->log('increasing square workload to ' . $squareworkload[$targetsquare], 'debug', 1);
 596                          if ($targetgroup) {
 597                              // recalculate the group workload
 598                              $squaregroupsworkload[$targetgroup] = 0;
 599                              foreach ($allsquares[$targetgroup] as $squareid => $square) {
 600                                  $squaregroupsworkload[$targetgroup] += $squareworkload[$squareid];
 601                              }
 602                              $squaregroupsworkload[$targetgroup] /= count($allsquares[$targetgroup]);
 603                              $result->log('increasing group workload to ' . $squaregroupsworkload[$targetgroup], 'debug', 1);
 604                          }
 605                      } // end of processing this circle
 606                  } // end of one iteration of processing circles in the group
 607              } // end of all iterations over circles in the group
 608          } // end of processing circle groups
 609          $returned = array();
 610          if (workshop_random_allocator_setting::NUMPER_SUBMISSION == $numper) {
 611              // circles are authors, squares are reviewers
 612              foreach ($circlelinks as $circleid => $squares) {
 613                  foreach ($squares as $squareid) {
 614                      $returned[] = array($squareid => $circleid);
 615                  }
 616              }
 617          }
 618          if (workshop_random_allocator_setting::NUMPER_REVIEWER == $numper) {
 619              // circles are reviewers, squares are authors
 620              foreach ($circlelinks as $circleid => $squares) {
 621                  foreach ($squares as $squareid) {
 622                      $returned[] = array($circleid => $squareid);
 623                  }
 624              }
 625          }
 626          return $returned;
 627      }
 628  
 629      /**
 630       * Extracts the information about reviews from the authors' and reviewers' perspectives
 631       *
 632       * @param array $assessments array of assessments as returned by {@link workshop::get_all_assessments()}
 633       * @return array of two arrays
 634       */
 635      protected function convert_assessments_to_links($assessments) {
 636          $authorlinks    = array(); // [authorid]    => array(reviewerid, reviewerid, ...)
 637          $reviewerlinks  = array(); // [reviewerid]  => array(authorid, authorid, ...)
 638          foreach ($assessments as $assessment) {
 639              if (!isset($authorlinks[$assessment->authorid])) {
 640                  $authorlinks[$assessment->authorid] = array();
 641              }
 642              if (!isset($reviewerlinks[$assessment->reviewerid])) {
 643                  $reviewerlinks[$assessment->reviewerid] = array();
 644              }
 645              $authorlinks[$assessment->authorid][]   = $assessment->reviewerid;
 646              $reviewerlinks[$assessment->reviewerid][] = $assessment->authorid;
 647              }
 648          return array($authorlinks, $reviewerlinks);
 649      }
 650  
 651      /**
 652       * Selects an element with the lowest workload
 653       *
 654       * If there are more elements with the same workload, choose one of them randomly. This may be
 655       * used to select a group or user.
 656       *
 657       * @param array $workload [groupid] => (int)workload
 658       * @return mixed int|bool id of the selected element or false if it is impossible to choose
 659       */
 660      protected function get_element_with_lowest_workload($workload) {
 661          $precision = 10;
 662  
 663          if (empty($workload)) {
 664              return false;
 665          }
 666          $minload = round(min($workload), $precision);
 667          $minkeys = array();
 668          foreach ($workload as $key => $val) {
 669              if (round($val, $precision) == $minload) {
 670                  $minkeys[$key] = $val;
 671              }
 672          }
 673          return array_rand($minkeys);
 674      }
 675  
 676      /**
 677       * Shuffle the order of array elements preserving the key=>values
 678       *
 679       * @param array $array to be shuffled
 680       * @return true
 681       */
 682      protected function shuffle_assoc(&$array) {
 683          if (count($array) > 1) {
 684              // $keys needs to be an array, no need to shuffle 1 item or empty arrays, anyway
 685              $keys = array_keys($array);
 686              shuffle($keys);
 687              foreach($keys as $key) {
 688                  $new[$key] = $array[$key];
 689              }
 690              $array = $new;
 691          }
 692          return true; // because this behaves like in-built shuffle(), which returns true
 693      }
 694  
 695      /**
 696       * Filter new allocations so that they do not contain an already existing assessment
 697       *
 698       * @param mixed $newallocations array of ('reviewerid' => 'authorid') tuples
 699       * @param array $assessments    array of assessment records
 700       * @return void
 701       */
 702      protected function filter_current_assessments(&$newallocations, $assessments) {
 703          foreach ($assessments as $assessment) {
 704              $allocation     = array($assessment->reviewerid => $assessment->authorid);
 705              $foundat        = array_keys($newallocations, $allocation);
 706              $newallocations = array_diff_key($newallocations, array_flip($foundat));
 707          }
 708      }
 709  }
 710  
 711  
 712  /**
 713   * Data object defining the settings structure for the random allocator
 714   */
 715  class workshop_random_allocator_setting {
 716  
 717      /** aim to a number of reviews per one submission {@see self::$numper} */
 718      const NUMPER_SUBMISSION = 1;
 719      /** aim to a number of reviews per one reviewer {@see self::$numper} */
 720      const NUMPER_REVIEWER   = 2;
 721  
 722      /** @var int number of reviews */
 723      public $numofreviews;
 724      /** @var int either {@link self::NUMPER_SUBMISSION} or {@link self::NUMPER_REVIEWER} */
 725      public $numper;
 726      /** @var bool prevent reviews by peers from the same group */
 727      public $excludesamegroup;
 728      /** @var bool remove current allocations */
 729      public $removecurrent;
 730      /** @var bool participants can assess without having submitted anything */
 731      public $assesswosubmission;
 732      /** @var bool add self-assessments */
 733      public $addselfassessment;
 734  
 735      /**
 736       * Use the factory method {@link self::instance_from_object()}
 737       */
 738      protected function __construct() {
 739      }
 740  
 741      /**
 742       * Factory method making the instance from data in the passed object
 743       *
 744       * @param stdClass $data an object holding the values for our public properties
 745       * @return workshop_random_allocator_setting
 746       */
 747      public static function instance_from_object(stdClass $data) {
 748          $i = new self();
 749  
 750          if (!isset($data->numofreviews)) {
 751              throw new coding_exception('Missing value of the numofreviews property');
 752          } else {
 753              $i->numofreviews = (int)$data->numofreviews;
 754          }
 755  
 756          if (!isset($data->numper)) {
 757              throw new coding_exception('Missing value of the numper property');
 758          } else {
 759              $i->numper = (int)$data->numper;
 760              if ($i->numper !== self::NUMPER_SUBMISSION and $i->numper !== self::NUMPER_REVIEWER) {
 761                  throw new coding_exception('Invalid value of the numper property');
 762              }
 763          }
 764  
 765          foreach (array('excludesamegroup', 'removecurrent', 'assesswosubmission', 'addselfassessment') as $k) {
 766              if (isset($data->$k)) {
 767                  $i->$k = (bool)$data->$k;
 768              } else {
 769                  $i->$k = false;
 770              }
 771          }
 772  
 773          return $i;
 774      }
 775  
 776      /**
 777       * Factory method making the instance from data in the passed text
 778       *
 779       * @param string $text as returned by {@link self::export_text()}
 780       * @return workshop_random_allocator_setting
 781       */
 782      public static function instance_from_text($text) {
 783          return self::instance_from_object(json_decode($text));
 784      }
 785  
 786      /**
 787       * Exports the instance data as a text for persistant storage
 788       *
 789       * The returned data can be later used by {@self::instance_from_text()} factory method
 790       * to restore the instance data. The current implementation uses JSON export format.
 791       *
 792       * @return string JSON representation of our public properties
 793       */
 794      public function export_text() {
 795          $getvars = function($obj) { return get_object_vars($obj); };
 796          return json_encode($getvars($this));
 797      }
 798  }