Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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        = moodle_array_keys_filter($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
    /** @var bool scheduled allocation status */
735
    public $enablescheduled;
736
 
737
    /**
738
     * Use the factory method {@link self::instance_from_object()}
739
     */
740
    protected function __construct() {
741
    }
742
 
743
    /**
744
     * Factory method making the instance from data in the passed object
745
     *
746
     * @param stdClass $data an object holding the values for our public properties
747
     * @return workshop_random_allocator_setting
748
     */
749
    public static function instance_from_object(stdClass $data) {
750
        $i = new self();
751
 
752
        if (!isset($data->numofreviews)) {
753
            throw new coding_exception('Missing value of the numofreviews property');
754
        } else {
755
            $i->numofreviews = (int)$data->numofreviews;
756
        }
757
 
758
        if (!isset($data->numper)) {
759
            throw new coding_exception('Missing value of the numper property');
760
        } else {
761
            $i->numper = (int)$data->numper;
762
            if ($i->numper !== self::NUMPER_SUBMISSION and $i->numper !== self::NUMPER_REVIEWER) {
763
                throw new coding_exception('Invalid value of the numper property');
764
            }
765
        }
766
 
767
        foreach (array('excludesamegroup', 'removecurrent', 'assesswosubmission', 'addselfassessment') as $k) {
768
            if (isset($data->$k)) {
769
                $i->$k = (bool)$data->$k;
770
            } else {
771
                $i->$k = false;
772
            }
773
        }
774
 
775
        return $i;
776
    }
777
 
778
    /**
779
     * Factory method making the instance from data in the passed text
780
     *
781
     * @param string $text as returned by {@link self::export_text()}
782
     * @return workshop_random_allocator_setting
783
     */
784
    public static function instance_from_text($text) {
785
        return self::instance_from_object(json_decode($text));
786
    }
787
 
788
    /**
789
     * Exports the instance data as a text for persistant storage
790
     *
791
     * The returned data can be later used by {@self::instance_from_text()} factory method
792
     * to restore the instance data. The current implementation uses JSON export format.
793
     *
794
     * @return string JSON representation of our public properties
795
     */
796
    public function export_text() {
797
        $getvars = function($obj) { return get_object_vars($obj); };
798
        return json_encode($getvars($this));
799
    }
800
}