Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 39 and 310] [Versions 39 and 311] [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 and 403]

   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          $options = array();
 267          $options[30] = 30;
 268          $options[20] = 20;
 269          $options[15] = 15;
 270          for ($i = 10; $i >= 0; $i--) {
 271              $options[$i] = $i;
 272          }
 273          return $options;
 274      }
 275  
 276      /**
 277       * Allocates submissions to their authors for review
 278       *
 279       * If the submission has already been allocated, it is skipped. If the author is not found among
 280       * reviewers, the submission is not assigned.
 281       *
 282       * @param array $authors grouped of {@see workshop::get_potential_authors()}
 283       * @param array $reviewers grouped by {@see workshop::get_potential_reviewers()}
 284       * @param array $assessments as returned by {@see workshop::get_all_assessments()}
 285       * @return array of new allocations to be created, array of array(reviewerid => authorid)
 286       */
 287      protected function self_allocation($authors=array(), $reviewers=array(), $assessments=array()) {
 288          if (!isset($authors[0]) || !isset($reviewers[0])) {
 289              // no authors or no reviewers
 290              return array();
 291          }
 292          $alreadyallocated = array();
 293          foreach ($assessments as $assessment) {
 294              if ($assessment->authorid == $assessment->reviewerid) {
 295                  $alreadyallocated[$assessment->authorid] = 1;
 296              }
 297          }
 298          $add = array(); // list of new allocations to be created
 299          foreach ($authors[0] as $authorid => $author) {
 300              // for all authors in all groups
 301              if (isset($reviewers[0][$authorid])) {
 302                  // if the author can be reviewer
 303                  if (!isset($alreadyallocated[$authorid])) {
 304                      // and the allocation does not exist yet, then
 305                      $add[] = array($authorid => $authorid);
 306                  }
 307              }
 308          }
 309          return $add;
 310      }
 311  
 312      /**
 313       * Creates new assessment records
 314       *
 315       * @param array $newallocations pairs 'reviewerid' => 'authorid'
 316       * @param array $dataauthors    authors by group, group [0] contains all authors
 317       * @param array $datareviewers  reviewers by group, group [0] contains all reviewers
 318       * @return bool
 319       */
 320      protected function add_new_allocations(array $newallocations, array $dataauthors, array $datareviewers) {
 321          global $DB;
 322  
 323          $newallocations = $this->get_unique_allocations($newallocations);
 324          $authorids      = $this->get_author_ids($newallocations);
 325          $submissions    = $this->workshop->get_submissions($authorids);
 326          $submissions    = $this->index_submissions_by_authors($submissions);
 327          foreach ($newallocations as $newallocation) {
 328              $reviewerid = key($newallocation);
 329              $authorid = current($newallocation);
 330              if (!isset($submissions[$authorid])) {
 331                  throw new moodle_exception('unabletoallocateauthorwithoutsubmission', 'workshop');
 332              }
 333              $submission = $submissions[$authorid];
 334              $status = $this->workshop->add_allocation($submission, $reviewerid, 1, true);   // todo configurable weight?
 335              if (workshop::ALLOCATION_EXISTS == $status) {
 336                  debugging('newallocations array contains existing allocation, this should not happen');
 337              }
 338          }
 339      }
 340  
 341      /**
 342       * Flips the structure of submission so it is indexed by authorid attribute
 343       *
 344       * It is the caller's responsibility to make sure the submissions are not teacher
 345       * examples so no user is the author of more submissions.
 346       *
 347       * @param string $submissions array indexed by submission id
 348       * @return array indexed by author id
 349       */
 350      protected function index_submissions_by_authors($submissions) {
 351          $byauthor = array();
 352          if (is_array($submissions)) {
 353              foreach ($submissions as $submissionid => $submission) {
 354                  if (isset($byauthor[$submission->authorid])) {
 355                      throw new moodle_exception('moresubmissionsbyauthor', 'workshop');
 356                  }
 357                  $byauthor[$submission->authorid] = $submission;
 358              }
 359          }
 360          return $byauthor;
 361      }
 362  
 363      /**
 364       * Extracts unique list of authors' IDs from the structure of new allocations
 365       *
 366       * @param array $newallocations of pairs 'reviewerid' => 'authorid'
 367       * @return array of authorids
 368       */
 369      protected function get_author_ids($newallocations) {
 370          $authors = array();
 371          foreach ($newallocations as $newallocation) {
 372              $authorid = reset($newallocation);
 373              if (!in_array($authorid, $authors)) {
 374                  $authors[] = $authorid;
 375              }
 376          }
 377          return $authors;
 378      }
 379  
 380      /**
 381       * Removes duplicate allocations
 382       *
 383       * @param mixed $newallocations array of 'reviewerid' => 'authorid' pairs
 384       * @return array
 385       */
 386      protected function get_unique_allocations($newallocations) {
 387          return array_merge(array_map('unserialize', array_unique(array_map('serialize', $newallocations))));
 388      }
 389  
 390      /**
 391       * Returns the list of assessments to remove
 392       *
 393       * If user selects "removecurrentallocations", we should remove all current assessment records
 394       * and insert new ones. But this would needlessly waste table ids. Instead, let us find only those
 395       * assessments that have not been re-allocated in this run of allocation. So, the once-allocated
 396       * submissions are kept with their original id.
 397       *
 398       * @param array $assessments         list of current assessments
 399       * @param mixed $newallocations      array of 'reviewerid' => 'authorid' pairs
 400       * @param bool  $keepselfassessments do not remove already allocated self assessments
 401       * @return array of assessments ids to be removed
 402       */
 403      protected function get_unkept_assessments($assessments, $newallocations, $keepselfassessments) {
 404          $keepids = array(); // keep these assessments
 405          foreach ($assessments as $assessmentid => $assessment) {
 406              $aaid = $assessment->authorid;
 407              $arid = $assessment->reviewerid;
 408              if (($keepselfassessments) && ($aaid == $arid)) {
 409                  $keepids[$assessmentid] = null;
 410                  continue;
 411              }
 412              foreach ($newallocations as $newallocation) {
 413                  $nrid = key($newallocation);
 414                  $naid = current($newallocation);
 415                  if (array($arid, $aaid) == array($nrid, $naid)) {
 416                      // re-allocation found - let us continue with the next assessment
 417                      $keepids[$assessmentid] = null;
 418                      continue 2;
 419                  }
 420              }
 421          }
 422          return array_keys(array_diff_key($assessments, $keepids));
 423      }
 424  
 425      /**
 426       * Allocates submission reviews randomly
 427       *
 428       * The algorithm of this function has been described at http://moodle.org/mod/forum/discuss.php?d=128473
 429       * Please see the PDF attached to the post before you study the implementation. The goal of the function
 430       * is to connect each "circle" (circles are representing either authors or reviewers) with a required
 431       * number of "squares" (the other type than circles are).
 432       *
 433       * The passed $options array must provide keys:
 434       *      (int)numofreviews - number of reviews to be allocated to each circle
 435       *      (int)numper - what user type the circles represent.
 436       *      (bool)excludesamegroup - whether to prevent peer submissions from the same group in visible group mode
 437       *
 438       * @param array    $authors      structure of grouped authors
 439       * @param array    $reviewers    structure of grouped reviewers
 440       * @param array    $assessments  currently assigned assessments to be kept
 441       * @param workshop_allocation_result $result allocation result logger
 442       * @param array    $options      allocation options
 443       * @return array                 array of (reviewerid => authorid) pairs
 444       */
 445      protected function random_allocation($authors, $reviewers, $assessments, $result, array $options) {
 446          if (empty($authors) || empty($reviewers)) {
 447              // nothing to be done
 448              return array();
 449          }
 450  
 451          $numofreviews = $options['numofreviews'];
 452          $numper       = $options['numper'];
 453  
 454          if (workshop_random_allocator_setting::NUMPER_SUBMISSION == $numper) {
 455              // circles are authors, squares are reviewers
 456              $result->log(get_string('resultnumperauthor', 'workshopallocation_random', $numofreviews), 'info');
 457              $allcircles = $authors;
 458              $allsquares = $reviewers;
 459              // get current workload
 460              list($circlelinks, $squarelinks) = $this->convert_assessments_to_links($assessments);
 461          } elseif (workshop_random_allocator_setting::NUMPER_REVIEWER == $numper) {
 462              // circles are reviewers, squares are authors
 463              $result->log(get_string('resultnumperreviewer', 'workshopallocation_random', $numofreviews), 'info');
 464              $allcircles = $reviewers;
 465              $allsquares = $authors;
 466              // get current workload
 467              list($squarelinks, $circlelinks) = $this->convert_assessments_to_links($assessments);
 468          } else {
 469              throw new moodle_exception('unknownusertypepassed', 'workshop');
 470          }
 471          // get the users that are not in any group. in visible groups mode, these users are exluded
 472          // from allocation by this method
 473          // $nogroupcircles is array (int)$userid => undefined
 474          if (isset($allcircles[0])) {
 475              $nogroupcircles = array_flip(array_keys($allcircles[0]));
 476          } else {
 477              $nogroupcircles = array();
 478          }
 479          foreach ($allcircles as $circlegroupid => $circles) {
 480              if ($circlegroupid == 0) {
 481                  continue;
 482              }
 483              foreach ($circles as $circleid => $circle) {
 484                  unset($nogroupcircles[$circleid]);
 485              }
 486          }
 487          // $result->log('circle links = ' . json_encode($circlelinks), 'debug');
 488          // $result->log('square links = ' . json_encode($squarelinks), 'debug');
 489          $squareworkload         = array();  // individual workload indexed by squareid
 490          $squaregroupsworkload   = array();    // group workload indexed by squaregroupid
 491          foreach ($allsquares as $squaregroupid => $squares) {
 492              $squaregroupsworkload[$squaregroupid] = 0;
 493              foreach ($squares as $squareid => $square) {
 494                  if (!isset($squarelinks[$squareid])) {
 495                      $squarelinks[$squareid] = array();
 496                  }
 497                  $squareworkload[$squareid] = count($squarelinks[$squareid]);
 498                  $squaregroupsworkload[$squaregroupid] += $squareworkload[$squareid];
 499              }
 500              $squaregroupsworkload[$squaregroupid] /= count($squares);
 501          }
 502          unset($squaregroupsworkload[0]);    // [0] is not real group, it contains all users
 503          // $result->log('square workload = ' . json_encode($squareworkload), 'debug');
 504          // $result->log('square group workload = ' . json_encode($squaregroupsworkload), 'debug');
 505          $gmode = groups_get_activity_groupmode($this->workshop->cm, $this->workshop->course);
 506          if (SEPARATEGROUPS == $gmode) {
 507              // shuffle all groups but [0] which means "all users"
 508              $circlegroups = array_keys(array_diff_key($allcircles, array(0 => null)));
 509              shuffle($circlegroups);
 510          } else {
 511              // all users will be processed at once
 512              $circlegroups = array(0);
 513          }
 514          // $result->log('circle groups = ' . json_encode($circlegroups), 'debug');
 515          foreach ($circlegroups as $circlegroupid) {
 516              $result->log('processing circle group id ' . $circlegroupid, 'debug');
 517              $circles = $allcircles[$circlegroupid];
 518              // iterate over all circles in the group until the requested number of links per circle exists
 519              // or it is not possible to fulfill that requirment
 520              // during the first iteration, we try to make sure that at least one circlelink exists. during the
 521              // second iteration, we try to allocate two, etc.
 522              for ($requiredreviews = 1; $requiredreviews <= $numofreviews; $requiredreviews++) {
 523                  $this->shuffle_assoc($circles);
 524                  $result->log('iteration ' . $requiredreviews, 'debug');
 525                  foreach ($circles as $circleid => $circle) {
 526                      if (VISIBLEGROUPS == $gmode and isset($nogroupcircles[$circleid])) {
 527                          $result->log('skipping circle id ' . $circleid, 'debug');
 528                          continue;
 529                      }
 530                      $result->log('processing circle id ' . $circleid, 'debug');
 531                      if (!isset($circlelinks[$circleid])) {
 532                          $circlelinks[$circleid] = array();
 533                      }
 534                      $keeptrying     = true;     // is there a chance to find a square for this circle?
 535                      $failedgroups   = array();  // array of groupids where the square should be chosen from (because
 536                                                  // of their group workload) but it was not possible (for example there
 537                                                  // was the only square and it had been already connected
 538                      while ($keeptrying && (count($circlelinks[$circleid]) < $requiredreviews)) {
 539                          // firstly, choose a group to pick the square from
 540                          if (NOGROUPS == $gmode) {
 541                              if (in_array(0, $failedgroups)) {
 542                                  $keeptrying = false;
 543                                  $result->log(get_string('resultnomorepeers', 'workshopallocation_random'), 'error', 1);
 544                                  break;
 545                              }
 546                              $targetgroup = 0;
 547                          } elseif (SEPARATEGROUPS == $gmode) {
 548                              if (in_array($circlegroupid, $failedgroups)) {
 549                                  $keeptrying = false;
 550                                  $result->log(get_string('resultnomorepeersingroup', 'workshopallocation_random'), 'error', 1);
 551                                  break;
 552                              }
 553                              $targetgroup = $circlegroupid;
 554                          } elseif (VISIBLEGROUPS == $gmode) {
 555                              $trygroups = array_diff_key($squaregroupsworkload, array(0 => null));   // all but [0]
 556                              $trygroups = array_diff_key($trygroups, array_flip($failedgroups));     // without previous failures
 557                              if ($options['excludesamegroup']) {
 558                                  // exclude groups the circle is member of
 559                                  $excludegroups = array();
 560                                  foreach (array_diff_key($allcircles, array(0 => null)) as $exgroupid => $exgroupmembers) {
 561                                      if (array_key_exists($circleid, $exgroupmembers)) {
 562                                          $excludegroups[$exgroupid] = null;
 563                                      }
 564                                  }
 565                                  $trygroups = array_diff_key($trygroups, $excludegroups);
 566                              }
 567                              $targetgroup = $this->get_element_with_lowest_workload($trygroups);
 568                          }
 569                          if ($targetgroup === false) {
 570                              $keeptrying = false;
 571                              $result->log(get_string('resultnotenoughpeers', 'workshopallocation_random'), 'error', 1);
 572                              break;
 573                          }
 574                          $result->log('next square should be from group id ' . $targetgroup, 'debug', 1);
 575                          // now, choose a square from the target group
 576                          $trysquares = array_intersect_key($squareworkload, $allsquares[$targetgroup]);
 577                          // $result->log('individual workloads in this group are ' . json_encode($trysquares), 'debug', 1);
 578                          unset($trysquares[$circleid]);  // can't allocate to self
 579                          $trysquares = array_diff_key($trysquares, array_flip($circlelinks[$circleid])); // can't re-allocate the same
 580                          $targetsquare = $this->get_element_with_lowest_workload($trysquares);
 581                          if (false === $targetsquare) {
 582                              $result->log('unable to find an available square. trying another group', 'debug', 1);
 583                              $failedgroups[] = $targetgroup;
 584                              continue;
 585                          }
 586                          $result->log('target square = ' . $targetsquare, 'debug', 1);
 587                          // ok - we have found the square
 588                          $circlelinks[$circleid][]       = $targetsquare;
 589                          $squarelinks[$targetsquare][]   = $circleid;
 590                          $squareworkload[$targetsquare]++;
 591                          $result->log('increasing square workload to ' . $squareworkload[$targetsquare], 'debug', 1);
 592                          if ($targetgroup) {
 593                              // recalculate the group workload
 594                              $squaregroupsworkload[$targetgroup] = 0;
 595                              foreach ($allsquares[$targetgroup] as $squareid => $square) {
 596                                  $squaregroupsworkload[$targetgroup] += $squareworkload[$squareid];
 597                              }
 598                              $squaregroupsworkload[$targetgroup] /= count($allsquares[$targetgroup]);
 599                              $result->log('increasing group workload to ' . $squaregroupsworkload[$targetgroup], 'debug', 1);
 600                          }
 601                      } // end of processing this circle
 602                  } // end of one iteration of processing circles in the group
 603              } // end of all iterations over circles in the group
 604          } // end of processing circle groups
 605          $returned = array();
 606          if (workshop_random_allocator_setting::NUMPER_SUBMISSION == $numper) {
 607              // circles are authors, squares are reviewers
 608              foreach ($circlelinks as $circleid => $squares) {
 609                  foreach ($squares as $squareid) {
 610                      $returned[] = array($squareid => $circleid);
 611                  }
 612              }
 613          }
 614          if (workshop_random_allocator_setting::NUMPER_REVIEWER == $numper) {
 615              // circles are reviewers, squares are authors
 616              foreach ($circlelinks as $circleid => $squares) {
 617                  foreach ($squares as $squareid) {
 618                      $returned[] = array($circleid => $squareid);
 619                  }
 620              }
 621          }
 622          return $returned;
 623      }
 624  
 625      /**
 626       * Extracts the information about reviews from the authors' and reviewers' perspectives
 627       *
 628       * @param array $assessments array of assessments as returned by {@link workshop::get_all_assessments()}
 629       * @return array of two arrays
 630       */
 631      protected function convert_assessments_to_links($assessments) {
 632          $authorlinks    = array(); // [authorid]    => array(reviewerid, reviewerid, ...)
 633          $reviewerlinks  = array(); // [reviewerid]  => array(authorid, authorid, ...)
 634          foreach ($assessments as $assessment) {
 635              if (!isset($authorlinks[$assessment->authorid])) {
 636                  $authorlinks[$assessment->authorid] = array();
 637              }
 638              if (!isset($reviewerlinks[$assessment->reviewerid])) {
 639                  $reviewerlinks[$assessment->reviewerid] = array();
 640              }
 641              $authorlinks[$assessment->authorid][]   = $assessment->reviewerid;
 642              $reviewerlinks[$assessment->reviewerid][] = $assessment->authorid;
 643              }
 644          return array($authorlinks, $reviewerlinks);
 645      }
 646  
 647      /**
 648       * Selects an element with the lowest workload
 649       *
 650       * If there are more elements with the same workload, choose one of them randomly. This may be
 651       * used to select a group or user.
 652       *
 653       * @param array $workload [groupid] => (int)workload
 654       * @return mixed int|bool id of the selected element or false if it is impossible to choose
 655       */
 656      protected function get_element_with_lowest_workload($workload) {
 657          $precision = 10;
 658  
 659          if (empty($workload)) {
 660              return false;
 661          }
 662          $minload = round(min($workload), $precision);
 663          $minkeys = array();
 664          foreach ($workload as $key => $val) {
 665              if (round($val, $precision) == $minload) {
 666                  $minkeys[$key] = $val;
 667              }
 668          }
 669          return array_rand($minkeys);
 670      }
 671  
 672      /**
 673       * Shuffle the order of array elements preserving the key=>values
 674       *
 675       * @param array $array to be shuffled
 676       * @return true
 677       */
 678      protected function shuffle_assoc(&$array) {
 679          if (count($array) > 1) {
 680              // $keys needs to be an array, no need to shuffle 1 item or empty arrays, anyway
 681              $keys = array_keys($array);
 682              shuffle($keys);
 683              foreach($keys as $key) {
 684                  $new[$key] = $array[$key];
 685              }
 686              $array = $new;
 687          }
 688          return true; // because this behaves like in-built shuffle(), which returns true
 689      }
 690  
 691      /**
 692       * Filter new allocations so that they do not contain an already existing assessment
 693       *
 694       * @param mixed $newallocations array of ('reviewerid' => 'authorid') tuples
 695       * @param array $assessments    array of assessment records
 696       * @return void
 697       */
 698      protected function filter_current_assessments(&$newallocations, $assessments) {
 699          foreach ($assessments as $assessment) {
 700              $allocation     = array($assessment->reviewerid => $assessment->authorid);
 701              $foundat        = array_keys($newallocations, $allocation);
 702              $newallocations = array_diff_key($newallocations, array_flip($foundat));
 703          }
 704      }
 705  }
 706  
 707  
 708  /**
 709   * Data object defining the settings structure for the random allocator
 710   */
 711  class workshop_random_allocator_setting {
 712  
 713      /** aim to a number of reviews per one submission {@see self::$numper} */
 714      const NUMPER_SUBMISSION = 1;
 715      /** aim to a number of reviews per one reviewer {@see self::$numper} */
 716      const NUMPER_REVIEWER   = 2;
 717  
 718      /** @var int number of reviews */
 719      public $numofreviews;
 720      /** @var int either {@link self::NUMPER_SUBMISSION} or {@link self::NUMPER_REVIEWER} */
 721      public $numper;
 722      /** @var bool prevent reviews by peers from the same group */
 723      public $excludesamegroup;
 724      /** @var bool remove current allocations */
 725      public $removecurrent;
 726      /** @var bool participants can assess without having submitted anything */
 727      public $assesswosubmission;
 728      /** @var bool add self-assessments */
 729      public $addselfassessment;
 730  
 731      /**
 732       * Use the factory method {@link self::instance_from_object()}
 733       */
 734      protected function __construct() {
 735      }
 736  
 737      /**
 738       * Factory method making the instance from data in the passed object
 739       *
 740       * @param stdClass $data an object holding the values for our public properties
 741       * @return workshop_random_allocator_setting
 742       */
 743      public static function instance_from_object(stdClass $data) {
 744          $i = new self();
 745  
 746          if (!isset($data->numofreviews)) {
 747              throw new coding_exception('Missing value of the numofreviews property');
 748          } else {
 749              $i->numofreviews = (int)$data->numofreviews;
 750          }
 751  
 752          if (!isset($data->numper)) {
 753              throw new coding_exception('Missing value of the numper property');
 754          } else {
 755              $i->numper = (int)$data->numper;
 756              if ($i->numper !== self::NUMPER_SUBMISSION and $i->numper !== self::NUMPER_REVIEWER) {
 757                  throw new coding_exception('Invalid value of the numper property');
 758              }
 759          }
 760  
 761          foreach (array('excludesamegroup', 'removecurrent', 'assesswosubmission', 'addselfassessment') as $k) {
 762              if (isset($data->$k)) {
 763                  $i->$k = (bool)$data->$k;
 764              } else {
 765                  $i->$k = false;
 766              }
 767          }
 768  
 769          return $i;
 770      }
 771  
 772      /**
 773       * Factory method making the instance from data in the passed text
 774       *
 775       * @param string $text as returned by {@link self::export_text()}
 776       * @return workshop_random_allocator_setting
 777       */
 778      public static function instance_from_text($text) {
 779          return self::instance_from_object(json_decode($text));
 780      }
 781  
 782      /**
 783       * Exports the instance data as a text for persistant storage
 784       *
 785       * The returned data can be later used by {@self::instance_from_text()} factory method
 786       * to restore the instance data. The current implementation uses JSON export format.
 787       *
 788       * @return string JSON representation of our public properties
 789       */
 790      public function export_text() {
 791          $getvars = function($obj) { return get_object_vars($obj); };
 792          return json_encode($getvars($this));
 793      }
 794  }