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
27defined('MOODLE_INTERNAL') || die();
28
29global $CFG;    // access to global variables during unit test
30
31require_once(__DIR__ . '/../lib.php');            // interface definition
32require_once(__DIR__ . '/../../locallib.php');    // workshop internal API
33require_once(__DIR__ . '/settings_form.php');     // settings form
34
35/**
36 * Allocates the submissions randomly
37 */
38class 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 */
715class 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}
799