MDL-65992 travis: Migrate to Xenial distro and default MySQL service
[moodle.git] / rating / lib.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * A class representing a single rating and containing some static methods for manipulating ratings
19  *
20  * @package    core_rating
21  * @subpackage rating
22  * @copyright  2010 Andrew Davis
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 define('RATING_UNSET_RATING', -999);
28 define ('RATING_AGGREGATE_NONE', 0); // No ratings.
29 define ('RATING_AGGREGATE_AVERAGE', 1);
30 define ('RATING_AGGREGATE_COUNT', 2);
31 define ('RATING_AGGREGATE_MAXIMUM', 3);
32 define ('RATING_AGGREGATE_MINIMUM', 4);
33 define ('RATING_AGGREGATE_SUM', 5);
35 define ('RATING_DEFAULT_SCALE', 5);
37 /**
38  * The rating class represents a single rating by a single user
39  *
40  * @package   core_rating
41  * @category  rating
42  * @copyright 2010 Andrew Davis
43  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44  * @since     Moodle 2.0
45  */
46 class rating implements renderable {
48     /**
49      * @var stdClass The context in which this rating exists
50      */
51     public $context;
53     /**
54      * @var string The component using ratings. For example "mod_forum"
55      */
56     public $component;
58     /**
59      * @var string The rating area to associate this rating with
60      *             This allows a plugin to rate more than one thing by specifying different rating areas
61      */
62     public $ratingarea = null;
64     /**
65      * @var int The id of the item (forum post, glossary item etc) being rated
66      */
67     public $itemid;
69     /**
70      * @var int The id scale (1-5, 0-100) that was in use when the rating was submitted
71      */
72     public $scaleid;
74     /**
75      * @var int The id of the user who submitted the rating
76      */
77     public $userid;
79     /**
80      * @var stdclass settings for this rating. Necessary to render the rating.
81      */
82     public $settings;
84     /**
85      * @var int The Id of this rating within the rating table. This is only set if the rating already exists
86      */
87     public $id = null;
89     /**
90      * @var int The aggregate of the combined ratings for the associated item. This is only set if the rating already exists
91      */
92     public $aggregate = null;
94     /**
95      * @var int The total number of ratings for the associated item. This is only set if the rating already exists
96      */
97     public $count = 0;
99     /**
100      * @var int The rating the associated user gave the associated item. This is only set if the rating already exists
101      */
102     public $rating = null;
104     /**
105      * @var int The time the associated item was created
106      */
107     public $itemtimecreated = null;
109     /**
110      * @var int The id of the user who submitted the rating
111      */
112     public $itemuserid = null;
114     /**
115      * Constructor.
116      *
117      * @param stdClass $options {
118      *            context => context context to use for the rating [required]
119      *            component => component using ratings ie mod_forum [required]
120      *            ratingarea => ratingarea to associate this rating with [required]
121      *            itemid  => int the id of the associated item (forum post, glossary item etc) [required]
122      *            scaleid => int The scale in use when the rating was submitted [required]
123      *            userid  => int The id of the user who submitted the rating [required]
124      *            settings => Settings for the rating object [optional]
125      *            id => The id of this rating (if the rating is from the db) [optional]
126      *            aggregate => The aggregate for the rating [optional]
127      *            count => The number of ratings [optional]
128      *            rating => The rating given by the user [optional]
129      * }
130      */
131     public function __construct($options) {
132         $this->context = $options->context;
133         $this->component = $options->component;
134         $this->ratingarea = $options->ratingarea;
135         $this->itemid = $options->itemid;
136         $this->scaleid = $options->scaleid;
137         $this->userid = $options->userid;
139         if (isset($options->settings)) {
140             $this->settings = $options->settings;
141         }
142         if (isset($options->id)) {
143             $this->id = $options->id;
144         }
145         if (isset($options->aggregate)) {
146             $this->aggregate = $options->aggregate;
147         }
148         if (isset($options->count)) {
149             $this->count = $options->count;
150         }
151         if (isset($options->rating)) {
152             $this->rating = $options->rating;
153         }
154     }
156     /**
157      * Update this rating in the database
158      *
159      * @param int $rating the integer value of this rating
160      */
161     public function update_rating($rating) {
162         global $DB;
164         $time = time();
166         $data = new stdClass;
167         $data->rating       = $rating;
168         $data->timemodified = $time;
170         $item = new stdclass();
171         $item->id = $this->itemid;
172         $items = array($item);
174         $ratingoptions = new stdClass;
175         $ratingoptions->context = $this->context;
176         $ratingoptions->component = $this->component;
177         $ratingoptions->ratingarea = $this->ratingarea;
178         $ratingoptions->items = $items;
179         $ratingoptions->aggregate = RATING_AGGREGATE_AVERAGE; // We dont actually care what aggregation method is applied.
180         $ratingoptions->scaleid = $this->scaleid;
181         $ratingoptions->userid = $this->userid;
183         $rm = new rating_manager();
184         $items = $rm->get_ratings($ratingoptions);
185         $firstitem = $items[0]->rating;
187         if (empty($firstitem->id)) {
188             // Insert a new rating.
189             $data->contextid    = $this->context->id;
190             $data->component    = $this->component;
191             $data->ratingarea   = $this->ratingarea;
192             $data->rating       = $rating;
193             $data->scaleid      = $this->scaleid;
194             $data->userid       = $this->userid;
195             $data->itemid       = $this->itemid;
196             $data->timecreated  = $time;
197             $data->timemodified = $time;
198             $DB->insert_record('rating', $data);
199         } else {
200             // Update the rating.
201             $data->id           = $firstitem->id;
202             $DB->update_record('rating', $data);
203         }
204     }
206     /**
207      * Retreive the integer value of this rating
208      *
209      * @return int the integer value of this rating object
210      */
211     public function get_rating() {
212         return $this->rating;
213     }
215     /**
216      * Returns this ratings aggregate value as a string.
217      *
218      * @return string ratings aggregate value
219      */
220     public function get_aggregate_string() {
222         $aggregate = $this->aggregate;
223         $method = $this->settings->aggregationmethod;
225         // Only display aggregate if aggregation method isn't COUNT.
226         $aggregatestr = '';
227         if (is_numeric($aggregate) && $method != RATING_AGGREGATE_COUNT) {
228             if ($method != RATING_AGGREGATE_SUM && !$this->settings->scale->isnumeric) {
230                 // Round aggregate as we're using it as an index.
231                 $aggregatestr .= $this->settings->scale->scaleitems[round($aggregate)];
232             } else { // Aggregation is SUM or the scale is numeric.
233                 $aggregatestr .= round($aggregate, 1);
234             }
235         }
237         return $aggregatestr;
238     }
240     /**
241      * Returns true if the user is able to rate this rating object
242      *
243      * @param int $userid Current user assumed if left empty
244      * @return bool true if the user is able to rate this rating object
245      */
246     public function user_can_rate($userid = null) {
247         if (empty($userid)) {
248             global $USER;
249             $userid = $USER->id;
250         }
251         // You can't rate your item.
252         if ($this->itemuserid == $userid) {
253             return false;
254         }
255         // You can't rate if you don't have the system cap.
256         if (!$this->settings->permissions->rate) {
257             return false;
258         }
259         // You can't rate if you don't have the plugin cap.
260         if (!$this->settings->pluginpermissions->rate) {
261             return false;
262         }
264         // You can't rate if the item was outside of the assessment times.
265         $timestart = $this->settings->assesstimestart;
266         $timefinish = $this->settings->assesstimefinish;
267         $timecreated = $this->itemtimecreated;
268         if (!empty($timestart) && !empty($timefinish) && ($timecreated < $timestart || $timecreated > $timefinish)) {
269             return false;
270         }
271         return true;
272     }
274     /**
275      * Returns true if the user is able to view the aggregate for this rating object.
276      *
277      * @param int|null $userid If left empty the current user is assumed.
278      * @return bool true if the user is able to view the aggregate for this rating object
279      */
280     public function user_can_view_aggregate($userid = null) {
281         if (empty($userid)) {
282             global $USER;
283             $userid = $USER->id;
284         }
286         // If the item doesnt belong to anyone or its another user's items and they can see the aggregate on items they don't own.
287         // Note that viewany doesnt mean you can see the aggregate or ratings of your own items.
288         if ((empty($this->itemuserid) or $this->itemuserid != $userid)
289             && $this->settings->permissions->viewany
290             && $this->settings->pluginpermissions->viewany ) {
292             return true;
293         }
295         // If its the current user's item and they have permission to view the aggregate on their own items.
296         if ($this->itemuserid == $userid
297             && $this->settings->permissions->view
298             && $this->settings->pluginpermissions->view) {
300             return true;
301         }
303         return false;
304     }
306     /**
307      * Returns a URL to view all of the ratings for the item this rating is for.
308      *
309      * If this is a rating of a post then this URL will take the user to a page that shows all of the ratings for the post
310      * (this one included).
311      *
312      * @param bool $popup whether of not the URL should be loaded in a popup
313      * @return moodle_url URL to view all of the ratings for the item this rating is for.
314      */
315     public function get_view_ratings_url($popup = false) {
316         $attributes = array(
317             'contextid'  => $this->context->id,
318             'component'  => $this->component,
319             'ratingarea' => $this->ratingarea,
320             'itemid'     => $this->itemid,
321             'scaleid'    => $this->settings->scale->id
322         );
323         if ($popup) {
324             $attributes['popup'] = 1;
325         }
326         return new moodle_url('/rating/index.php', $attributes);
327     }
329     /**
330      * Returns a URL that can be used to rate the associated item.
331      *
332      * @param int|null          $rating    The rating to give the item, if null then no rating param is added.
333      * @param moodle_url|string $returnurl The URL to return to.
334      * @return moodle_url can be used to rate the associated item.
335      */
336     public function get_rate_url($rating = null, $returnurl = null) {
337         if (empty($returnurl)) {
338             if (!empty($this->settings->returnurl)) {
339                 $returnurl = $this->settings->returnurl;
340             } else {
341                 global $PAGE;
342                 $returnurl = $PAGE->url;
343             }
344         }
345         $args = array(
346             'contextid'   => $this->context->id,
347             'component'   => $this->component,
348             'ratingarea'  => $this->ratingarea,
349             'itemid'      => $this->itemid,
350             'scaleid'     => $this->settings->scale->id,
351             'returnurl'   => $returnurl,
352             'rateduserid' => $this->itemuserid,
353             'aggregation' => $this->settings->aggregationmethod,
354             'sesskey'     => sesskey()
355         );
356         if (!empty($rating)) {
357             $args['rating'] = $rating;
358         }
359         $url = new moodle_url('/rating/rate.php', $args);
360         return $url;
361     }
363 } // End rating class definition.
365 /**
366  * The rating_manager class provides the ability to retrieve sets of ratings from the database
367  *
368  * @package   core_rating
369  * @category  rating
370  * @copyright 2010 Andrew Davis
371  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
372  * @since     Moodle 2.0
373  */
374 class rating_manager {
376     /**
377      * @var array An array of calculated scale options to save us generating them for each request.
378      */
379     protected $scales = array();
381     /**
382      * Delete one or more ratings. Specify either a rating id, an item id or just the context id.
383      *
384      * @global moodle_database $DB
385      * @param stdClass $options {
386      *            contextid => int the context in which the ratings exist [required]
387      *            ratingid => int the id of an individual rating to delete [optional]
388      *            userid => int delete the ratings submitted by this user. May be used in conjuction with itemid [optional]
389      *            itemid => int delete all ratings attached to this item [optional]
390      *            component => string The component to delete ratings from [optional]
391      *            ratingarea => string The ratingarea to delete ratings from [optional]
392      * }
393      */
394     public function delete_ratings($options) {
395         global $DB;
397         if (empty($options->contextid)) {
398             throw new coding_exception('The context option is a required option when deleting ratings.');
399         }
401         $conditions = array('contextid' => $options->contextid);
402         $possibleconditions = array(
403             'ratingid'   => 'id',
404             'userid'     => 'userid',
405             'itemid'     => 'itemid',
406             'component'  => 'component',
407             'ratingarea' => 'ratingarea'
408         );
409         foreach ($possibleconditions as $option => $field) {
410             if (isset($options->{$option})) {
411                 $conditions[$field] = $options->{$option};
412             }
413         }
414         $DB->delete_records('rating', $conditions);
415     }
417     /**
418      * Returns an array of ratings for a given item (forum post, glossary entry etc).
419      *
420      * This returns all users ratings for a single item
421      *
422      * @param stdClass $options {
423      *            context => context the context in which the ratings exists [required]
424      *            component => component using ratings ie mod_forum [required]
425      *            ratingarea => ratingarea to associate this rating with [required]
426      *            itemid  =>  int the id of the associated item (forum post, glossary item etc) [required]
427      *            sort    => string SQL sort by clause [optional]
428      * }
429      * @return array an array of ratings
430      */
431     public function get_all_ratings_for_item($options) {
432         global $DB;
434         if (!isset($options->context)) {
435             throw new coding_exception('The context option is a required option when getting ratings for an item.');
436         }
437         if (!isset($options->itemid)) {
438             throw new coding_exception('The itemid option is a required option when getting ratings for an item.');
439         }
440         if (!isset($options->component)) {
441             throw new coding_exception('The component option is now a required option when getting ratings for an item.');
442         }
443         if (!isset($options->ratingarea)) {
444             throw new coding_exception('The ratingarea option is now a required option when getting ratings for an item.');
445         }
447         $sortclause = '';
448         if (!empty($options->sort)) {
449             $sortclause = "ORDER BY $options->sort";
450         }
452         $params = array(
453             'contextid'  => $options->context->id,
454             'itemid'     => $options->itemid,
455             'component'  => $options->component,
456             'ratingarea' => $options->ratingarea,
457         );
458         $userfields = user_picture::fields('u', null, 'userid');
459         $sql = "SELECT r.id, r.rating, r.itemid, r.userid, r.timemodified, r.component, r.ratingarea, $userfields
460                   FROM {rating} r
461              LEFT JOIN {user} u ON r.userid = u.id
462                  WHERE r.contextid = :contextid AND
463                        r.itemid  = :itemid AND
464                        r.component = :component AND
465                        r.ratingarea = :ratingarea
466                        {$sortclause}";
468         return $DB->get_records_sql($sql, $params);
469     }
471     /**
472      * Adds rating objects to an array of items (forum posts, glossary entries etc). Rating objects are available at $item->rating
473      *
474      * @param stdClass $options {
475      *      context          => context the context in which the ratings exists [required]
476      *      component        => the component name ie mod_forum [required]
477      *      ratingarea       => the ratingarea we are interested in [required]
478      *      items            => array items like forum posts or glossary items. Each item needs an 'id' ie $items[0]->id [required]
479      *      aggregate        => int aggregation method to apply. RATING_AGGREGATE_AVERAGE, RATING_AGGREGATE_MAXIMUM etc [required]
480      *      scaleid          => int the scale from which the user can select a rating [required]
481      *      userid           => int the id of the current user [optional]
482      *      returnurl        => string the url to return the user to after submitting a rating. Null for ajax requests [optional]
483      *      assesstimestart  => int only allow rating of items created after this timestamp [optional]
484      *      assesstimefinish => int only allow rating of items created before this timestamp [optional]
485      * @return array the array of items with their ratings attached at $items[0]->rating
486      */
487     public function get_ratings($options) {
488         global $DB, $USER;
490         if (!isset($options->context)) {
491             throw new coding_exception('The context option is a required option when getting ratings.');
492         }
494         if (!isset($options->component)) {
495             throw new coding_exception('The component option is a required option when getting ratings.');
496         }
498         if (!isset($options->ratingarea)) {
499             throw new coding_exception('The ratingarea option is a required option when getting ratings.');
500         }
502         if (!isset($options->scaleid)) {
503             throw new coding_exception('The scaleid option is a required option when getting ratings.');
504         }
506         if (!isset($options->items)) {
507             throw new coding_exception('The items option is a required option when getting ratings.');
508         } else if (empty($options->items)) {
509             return array();
510         }
512         if (!isset($options->aggregate)) {
513             throw new coding_exception('The aggregate option is a required option when getting ratings.');
514         } else if ($options->aggregate == RATING_AGGREGATE_NONE) {
515             // Ratings are not enabled.
516             return $options->items;
517         }
518         $aggregatestr = $this->get_aggregation_method($options->aggregate);
520         // Default the userid to the current user if it is not set.
521         if (empty($options->userid)) {
522             $userid = $USER->id;
523         } else {
524             $userid = $options->userid;
525         }
527         // Get the item table name, the item id field, and the item user field for the given rating item
528         // from the related component.
529         list($type, $name) = core_component::normalize_component($options->component);
530         $default = array(null, 'id', 'userid');
531         list($itemtablename, $itemidcol, $itemuseridcol) = plugin_callback($type,
532                                                                            $name,
533                                                                            'rating',
534                                                                            'get_item_fields',
535                                                                            array($options),
536                                                                            $default);
538         // Create an array of item IDs.
539         $itemids = array();
540         foreach ($options->items as $item) {
541             $itemids[] = $item->{$itemidcol};
542         }
544         // Get the items from the database.
545         list($itemidtest, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
546         $params['contextid'] = $options->context->id;
547         $params['userid']    = $userid;
548         $params['component']    = $options->component;
549         $params['ratingarea'] = $options->ratingarea;
551         $sql = "SELECT r.id, r.itemid, r.userid, r.scaleid, r.rating AS usersrating
552                   FROM {rating} r
553                  WHERE r.userid = :userid AND
554                        r.contextid = :contextid AND
555                        r.itemid {$itemidtest} AND
556                        r.component = :component AND
557                        r.ratingarea = :ratingarea
558               ORDER BY r.itemid";
559         $userratings = $DB->get_records_sql($sql, $params);
561         $sql = "SELECT r.itemid, $aggregatestr(r.rating) AS aggrrating, COUNT(r.rating) AS numratings
562                   FROM {rating} r
563                  WHERE r.contextid = :contextid AND
564                        r.itemid {$itemidtest} AND
565                        r.component = :component AND
566                        r.ratingarea = :ratingarea
567               GROUP BY r.itemid, r.component, r.ratingarea, r.contextid
568               ORDER BY r.itemid";
569         $aggregateratings = $DB->get_records_sql($sql, $params);
571         $ratingoptions = new stdClass;
572         $ratingoptions->context = $options->context;
573         $ratingoptions->component = $options->component;
574         $ratingoptions->ratingarea = $options->ratingarea;
575         $ratingoptions->settings = $this->generate_rating_settings_object($options);
576         foreach ($options->items as $item) {
577             $founduserrating = false;
578             foreach ($userratings as $userrating) {
579                 // Look for an existing rating from this user of this item.
580                 if ($item->{$itemidcol} == $userrating->itemid) {
581                     // Note: rec->scaleid = the id of scale at the time the rating was submitted.
582                     // It may be different from the current scale id.
583                     $ratingoptions->scaleid = $userrating->scaleid;
584                     $ratingoptions->userid = $userrating->userid;
585                     $ratingoptions->id = $userrating->id;
586                     $ratingoptions->rating = min($userrating->usersrating, $ratingoptions->settings->scale->max);
588                     $founduserrating = true;
589                     break;
590                 }
591             }
592             if (!$founduserrating) {
593                 $ratingoptions->scaleid = null;
594                 $ratingoptions->userid = null;
595                 $ratingoptions->id = null;
596                 $ratingoptions->rating = null;
597             }
599             if (array_key_exists($item->{$itemidcol}, $aggregateratings)) {
600                 $rec = $aggregateratings[$item->{$itemidcol}];
601                 $ratingoptions->itemid = $item->{$itemidcol};
602                 $ratingoptions->aggregate = min($rec->aggrrating, $ratingoptions->settings->scale->max);
603                 $ratingoptions->count = $rec->numratings;
604             } else {
605                 $ratingoptions->itemid = $item->{$itemidcol};
606                 $ratingoptions->aggregate = null;
607                 $ratingoptions->count = 0;
608             }
610             $rating = new rating($ratingoptions);
611             $rating->itemtimecreated = $this->get_item_time_created($item);
612             if (!empty($item->{$itemuseridcol})) {
613                 $rating->itemuserid = $item->{$itemuseridcol};
614             }
615             $item->rating = $rating;
616         }
618         return $options->items;
619     }
621     /**
622      * Generates a rating settings object based upon the options it is provided.
623      *
624      * @param stdClass $options {
625      *      context           => context the context in which the ratings exists [required]
626      *      component         => string The component the items belong to [required]
627      *      ratingarea        => string The ratingarea the items belong to [required]
628      *      aggregate         => int Aggregation method to apply. RATING_AGGREGATE_AVERAGE, RATING_AGGREGATE_MAXIMUM etc [required]
629      *      scaleid           => int the scale from which the user can select a rating [required]
630      *      returnurl         => string the url to return the user to after submitting a rating. Null for ajax requests [optional]
631      *      assesstimestart   => int only allow rating of items created after this timestamp [optional]
632      *      assesstimefinish  => int only allow rating of items created before this timestamp [optional]
633      *      plugintype        => string plugin type ie 'mod' Used to find the permissions callback [optional]
634      *      pluginname        => string plugin name ie 'forum' Used to find the permissions callback [optional]
635      * }
636      * @return stdClass rating settings object
637      */
638     protected function generate_rating_settings_object($options) {
640         if (!isset($options->context)) {
641             throw new coding_exception('The context option is a required option when generating a rating settings object.');
642         }
643         if (!isset($options->component)) {
644             throw new coding_exception('The component option is now a required option when generating a rating settings object.');
645         }
646         if (!isset($options->ratingarea)) {
647             throw new coding_exception('The ratingarea option is now a required option when generating a rating settings object.');
648         }
649         if (!isset($options->aggregate)) {
650             throw new coding_exception('The aggregate option is now a required option when generating a rating settings object.');
651         }
652         if (!isset($options->scaleid)) {
653             throw new coding_exception('The scaleid option is now a required option when generating a rating settings object.');
654         }
656         // Settings that are common to all ratings objects in this context.
657         $settings = new stdClass;
658         $settings->scale             = $this->generate_rating_scale_object($options->scaleid); // The scale to use now.
659         $settings->aggregationmethod = $options->aggregate;
660         $settings->assesstimestart   = null;
661         $settings->assesstimefinish  = null;
663         // Collect options into the settings object.
664         if (!empty($options->assesstimestart)) {
665             $settings->assesstimestart = $options->assesstimestart;
666         }
667         if (!empty($options->assesstimefinish)) {
668             $settings->assesstimefinish = $options->assesstimefinish;
669         }
670         if (!empty($options->returnurl)) {
671             $settings->returnurl = $options->returnurl;
672         }
674         // Check site capabilities.
675         $settings->permissions = new stdClass;
676         // Can view the aggregate of ratings of their own items.
677         $settings->permissions->view    = has_capability('moodle/rating:view', $options->context);
678         // Can view the aggregate of ratings of other people's items.
679         $settings->permissions->viewany = has_capability('moodle/rating:viewany', $options->context);
680         // Can view individual ratings.
681         $settings->permissions->viewall = has_capability('moodle/rating:viewall', $options->context);
682         // Can submit ratings.
683         $settings->permissions->rate    = has_capability('moodle/rating:rate', $options->context);
685         // Check module capabilities
686         // This is mostly for backwards compatability with old modules that previously implemented their own ratings.
687         $pluginpermissionsarray = $this->get_plugin_permissions_array($options->context->id,
688                                                                       $options->component,
689                                                                       $options->ratingarea);
690         $settings->pluginpermissions = new stdClass;
691         $settings->pluginpermissions->view    = $pluginpermissionsarray['view'];
692         $settings->pluginpermissions->viewany = $pluginpermissionsarray['viewany'];
693         $settings->pluginpermissions->viewall = $pluginpermissionsarray['viewall'];
694         $settings->pluginpermissions->rate    = $pluginpermissionsarray['rate'];
696         return $settings;
697     }
699     /**
700      * Generates a scale object that can be returned
701      *
702      * @global moodle_database $DB moodle database object
703      * @param int $scaleid scale-type identifier
704      * @return stdClass scale for ratings
705      */
706     protected function generate_rating_scale_object($scaleid) {
707         global $DB;
708         if (!array_key_exists('s'.$scaleid, $this->scales)) {
709             $scale = new stdClass;
710             $scale->id = $scaleid;
711             $scale->name = null;
712             $scale->courseid = null;
713             $scale->scaleitems = array();
714             $scale->isnumeric = true;
715             $scale->max = $scaleid;
717             if ($scaleid < 0) {
718                 // It is a proper scale (not numeric).
719                 $scalerecord = $DB->get_record('scale', array('id' => abs($scaleid)));
720                 if ($scalerecord) {
721                     // We need to generate an array with string keys starting at 1.
722                     $scalearray = explode(',', $scalerecord->scale);
723                     $c = count($scalearray);
724                     for ($i = 0; $i < $c; $i++) {
725                         // Treat index as a string to allow sorting without changing the value.
726                         $scale->scaleitems[(string)($i + 1)] = $scalearray[$i];
727                     }
728                     krsort($scale->scaleitems); // Have the highest grade scale item appear first.
729                     $scale->isnumeric = false;
730                     $scale->name = $scalerecord->name;
731                     $scale->courseid = $scalerecord->courseid;
732                     $scale->max = count($scale->scaleitems);
733                 }
734             } else {
735                 // Generate an array of values for numeric scales.
736                 for ($i = 0; $i <= (int)$scaleid; $i++) {
737                     $scale->scaleitems[(string)$i] = $i;
738                 }
739             }
740             $this->scales['s'.$scaleid] = $scale;
741         }
742         return $this->scales['s'.$scaleid];
743     }
745     /**
746      * Gets the time the given item was created
747      *
748      * TODO: MDL-31511 - Find a better solution for this, its not ideal to test for fields really we should be
749      * asking the component the item belongs to what field to look for or even the value we
750      * are looking for.
751      *
752      * @param stdClass $item
753      * @return int|null return null if the created time is unavailable, otherwise return a timestamp
754      */
755     protected function get_item_time_created($item) {
756         if (!empty($item->created)) {
757             return $item->created; // The forum_posts table has created instead of timecreated.
758         } else if (!empty($item->timecreated)) {
759             return $item->timecreated;
760         } else {
761             return null;
762         }
763     }
765     /**
766      * Returns an array of grades calculated by aggregating item ratings.
767      *
768      * @param stdClass $options {
769      *      userid => int the id of the user whose items were rated, NOT the user who submitted ratings. 0 to update all. [required]
770      *      aggregationmethod => int the aggregation method to apply when calculating grades ie RATING_AGGREGATE_AVERAGE [required]
771      *      scaleid => int the scale from which the user can select a rating. Used for bounds checking. [required]
772      *      itemtable => int the table containing the items [required]
773      *      itemtableusercolum => int the column of the user table containing the item owner's user id [required]
774      *      component => The component for the ratings [required]
775      *      ratingarea => The ratingarea for the ratings [required]
776      *      contextid => int the context in which the rated items exist [optional]
777      *      modulename => string the name of the module [optional]
778      *      moduleid => int the id of the module instance [optional]
779      * }
780      * @return array the array of the user's grades
781      */
782     public function get_user_grades($options) {
783         global $DB;
785         $contextid = null;
787         if (!isset($options->component)) {
788             throw new coding_exception('The component option is now a required option when getting user grades from ratings.');
789         }
790         if (!isset($options->ratingarea)) {
791             throw new coding_exception('The ratingarea option is now a required option when getting user grades from ratings.');
792         }
794         // If the calling code doesn't supply a context id we'll have to figure it out.
795         if (!empty($options->contextid)) {
796             $contextid = $options->contextid;
797         } else if (!empty($options->modulename) && !empty($options->moduleid)) {
798             $modulename = $options->modulename;
799             $moduleid   = intval($options->moduleid);
801             // Going direct to the db for the context id seems wrong.
802             $ctxselect = ', ' . context_helper::get_preload_record_columns_sql('ctx');
803             $ctxjoin = "LEFT JOIN {context} ctx ON (ctx.instanceid = cm.id AND ctx.contextlevel = :contextlevel)";
804             $sql = "SELECT cm.* $ctxselect
805                       FROM {course_modules} cm
806                  LEFT JOIN {modules} mo ON mo.id = cm.module
807                  LEFT JOIN {{$modulename}} m ON m.id = cm.instance $ctxjoin
808                      WHERE mo.name=:modulename AND
809                            m.id=:moduleid";
810             $params = array('modulename' => $modulename, 'moduleid' => $moduleid, 'contextlevel' => CONTEXT_MODULE);
811             $contextrecord = $DB->get_record_sql($sql, $params, '*', MUST_EXIST);
812             $contextid = $contextrecord->ctxid;
813         }
815         $params = array();
816         $params['contextid']  = $contextid;
817         $params['component']  = $options->component;
818         $params['ratingarea'] = $options->ratingarea;
819         $itemtable            = $options->itemtable;
820         $itemtableusercolumn  = $options->itemtableusercolumn;
821         $scaleid              = $options->scaleid;
822         $aggregationstring    = $this->get_aggregation_method($options->aggregationmethod);
824         // If userid is not 0 we only want the grade for a single user.
825         $singleuserwhere = '';
826         if ($options->userid != 0) {
827             $params['userid1'] = intval($options->userid);
828             $singleuserwhere = "AND i.{$itemtableusercolumn} = :userid1";
829         }
831         // MDL-24648 The where line used to be "WHERE (r.contextid is null or r.contextid=:contextid)".
832         // r.contextid will be null for users who haven't been rated yet.
833         // No longer including users who haven't been rated to reduce memory requirements.
834         $sql = "SELECT u.id as id, u.id AS userid, $aggregationstring(r.rating) AS rawgrade
835                   FROM {user} u
836              LEFT JOIN {{$itemtable}} i ON u.id=i.{$itemtableusercolumn}
837              LEFT JOIN {rating} r ON r.itemid=i.id
838                  WHERE r.contextid = :contextid AND
839                        r.component = :component AND
840                        r.ratingarea = :ratingarea
841                        $singleuserwhere
842               GROUP BY u.id";
843         $results = $DB->get_records_sql($sql, $params);
845         if ($results) {
847             $scale = null;
848             $max = 0;
849             if ($options->scaleid >= 0) {
850                 // Numeric.
851                 $max = $options->scaleid;
852             } else {
853                 // Custom scales.
854                 $scale = $DB->get_record('scale', array('id' => -$options->scaleid));
855                 if ($scale) {
856                     $scale = explode(',', $scale->scale);
857                     $max = count($scale);
858                 } else {
859                     debugging('rating_manager::get_user_grades() received a scale ID that doesnt exist');
860                 }
861             }
863             // It could throw off the grading if count and sum returned a rawgrade higher than scale
864             // so to prevent it we review the results and ensure that rawgrade does not exceed the scale.
865             // If it does we set rawgrade = scale (i.e. full credit).
866             foreach ($results as $rid => $result) {
867                 if ($options->scaleid >= 0) {
868                     // Numeric.
869                     if ($result->rawgrade > $options->scaleid) {
870                         $results[$rid]->rawgrade = $options->scaleid;
871                     }
872                 } else {
873                     // Scales.
874                     if (!empty($scale) && $result->rawgrade > $max) {
875                         $results[$rid]->rawgrade = $max;
876                     }
877                 }
878             }
879         }
881         return $results;
882     }
884     /**
885      * Returns array of aggregate types. Used by ratings.
886      *
887      * @return array aggregate types
888      */
889     public function get_aggregate_types() {
890         return array (RATING_AGGREGATE_NONE     => get_string('aggregatenone', 'rating'),
891                       RATING_AGGREGATE_AVERAGE  => get_string('aggregateavg', 'rating'),
892                       RATING_AGGREGATE_COUNT    => get_string('aggregatecount', 'rating'),
893                       RATING_AGGREGATE_MAXIMUM  => get_string('aggregatemax', 'rating'),
894                       RATING_AGGREGATE_MINIMUM  => get_string('aggregatemin', 'rating'),
895                       RATING_AGGREGATE_SUM      => get_string('aggregatesum', 'rating'));
896     }
898     /**
899      * Converts an aggregation method constant into something that can be included in SQL
900      *
901      * @param int $aggregate An aggregation constant. For example, RATING_AGGREGATE_AVERAGE.
902      * @return string an SQL aggregation method
903      */
904     public function get_aggregation_method($aggregate) {
905         $aggregatestr = null;
906         switch($aggregate){
907             case RATING_AGGREGATE_AVERAGE:
908                 $aggregatestr = 'AVG';
909                 break;
910             case RATING_AGGREGATE_COUNT:
911                 $aggregatestr = 'COUNT';
912                 break;
913             case RATING_AGGREGATE_MAXIMUM:
914                 $aggregatestr = 'MAX';
915                 break;
916             case RATING_AGGREGATE_MINIMUM:
917                 $aggregatestr = 'MIN';
918                 break;
919             case RATING_AGGREGATE_SUM:
920                 $aggregatestr = 'SUM';
921                 break;
922             default:
923                 $aggregatestr = 'AVG'; // Default to this to avoid real breakage - MDL-22270.
924                 debugging('Incorrect call to get_aggregation_method(), incorrect aggregate method ' . $aggregate, DEBUG_DEVELOPER);
925         }
926         return $aggregatestr;
927     }
929     /**
930      * Looks for a callback like forum_rating_permissions() to retrieve permissions from the plugin whose items are being rated
931      *
932      * @param int $contextid The current context id
933      * @param string $component the name of the component that is using ratings ie 'mod_forum'
934      * @param string $ratingarea The area the rating is associated with
935      * @return array rating related permissions
936      */
937     public function get_plugin_permissions_array($contextid, $component, $ratingarea) {
938         $pluginpermissionsarray = null;
939         // Deny by default.
940         $defaultpluginpermissions = array('rate' => false, 'view' => false, 'viewany' => false, 'viewall' => false);
941         if (!empty($component)) {
942             list($type, $name) = core_component::normalize_component($component);
943             $pluginpermissionsarray = plugin_callback($type,
944                                                       $name,
945                                                       'rating',
946                                                       'permissions',
947                                                       array($contextid, $component, $ratingarea),
948                                                       $defaultpluginpermissions);
949         } else {
950             $pluginpermissionsarray = $defaultpluginpermissions;
951         }
952         return $pluginpermissionsarray;
953     }
955     /**
956      * Validates a submitted rating
957      *
958      * @param array $params submitted data
959      *      context => object the context in which the rated items exists [required]
960      *      component => The component the rating belongs to [required]
961      *      ratingarea => The ratingarea the rating is associated with [required]
962      *      itemid => int the ID of the object being rated [required]
963      *      scaleid => int the scale from which the user can select a rating. Used for bounds checking. [required]
964      *      rating => int the submitted rating
965      *      rateduserid => int the id of the user whose items have been rated. 0 to update all. [required]
966      *      aggregation => int the aggregation method to apply when calculating grades ie RATING_AGGREGATE_AVERAGE [optional]
967      * @return boolean true if the rating is valid, false if callback not found, throws rating_exception if rating is invalid
968      */
969     public function check_rating_is_valid($params) {
971         if (!isset($params['context'])) {
972             throw new coding_exception('The context option is a required option when checking rating validity.');
973         }
974         if (!isset($params['component'])) {
975             throw new coding_exception('The component option is now a required option when checking rating validity');
976         }
977         if (!isset($params['ratingarea'])) {
978             throw new coding_exception('The ratingarea option is now a required option when checking rating validity');
979         }
980         if (!isset($params['itemid'])) {
981             throw new coding_exception('The itemid option is now a required option when checking rating validity');
982         }
983         if (!isset($params['scaleid'])) {
984             throw new coding_exception('The scaleid option is now a required option when checking rating validity');
985         }
986         if (!isset($params['rateduserid'])) {
987             throw new coding_exception('The rateduserid option is now a required option when checking rating validity');
988         }
990         list($plugintype, $pluginname) = core_component::normalize_component($params['component']);
992         // This looks for a function like forum_rating_validate() in mod_forum lib.php
993         // wrapping the params array in another array as call_user_func_array() expands arrays into multiple arguments.
994         $isvalid = plugin_callback($plugintype, $pluginname, 'rating', 'validate', array($params), null);
996         // If null then the callback does not exist.
997         if ($isvalid === null) {
998             $isvalid = false;
999             debugging('rating validation callback not found for component '.  clean_param($component, PARAM_ALPHANUMEXT));
1000         }
1001         return $isvalid;
1002     }
1004     /**
1005      * Initialises JavaScript to enable AJAX ratings on the provided page
1006      *
1007      * @param moodle_page $page
1008      * @return true always returns true
1009      */
1010     public function initialise_rating_javascript(moodle_page $page) {
1011         global $CFG;
1013         // Only needs to be initialized once.
1014         static $done = false;
1015         if ($done) {
1016             return true;
1017         }
1019         $page->requires->js_init_call('M.core_rating.init');
1020         $done = true;
1022         return true;
1023     }
1025     /**
1026      * Returns a string that describes the aggregation method that was provided.
1027      *
1028      * @param string $aggregationmethod
1029      * @return string describes the aggregation method that was provided
1030      */
1031     public function get_aggregate_label($aggregationmethod) {
1032         $aggregatelabel = '';
1033         switch ($aggregationmethod) {
1034             case RATING_AGGREGATE_AVERAGE :
1035                 $aggregatelabel .= get_string("aggregateavg", "rating");
1036                 break;
1037             case RATING_AGGREGATE_COUNT :
1038                 $aggregatelabel .= get_string("aggregatecount", "rating");
1039                 break;
1040             case RATING_AGGREGATE_MAXIMUM :
1041                 $aggregatelabel .= get_string("aggregatemax", "rating");
1042                 break;
1043             case RATING_AGGREGATE_MINIMUM :
1044                 $aggregatelabel .= get_string("aggregatemin", "rating");
1045                 break;
1046             case RATING_AGGREGATE_SUM :
1047                 $aggregatelabel .= get_string("aggregatesum", "rating");
1048                 break;
1049         }
1050         $aggregatelabel .= get_string('labelsep', 'langconfig');
1051         return $aggregatelabel;
1052     }
1054     /**
1055      * Adds a new rating
1056      *
1057      * @param stdClass $cm course module object
1058      * @param stdClass $context context object
1059      * @param string $component component name
1060      * @param string $ratingarea rating area
1061      * @param int $itemid the item id
1062      * @param int $scaleid the scale id
1063      * @param int $userrating the user rating
1064      * @param int $rateduserid the rated user id
1065      * @param int $aggregationmethod the aggregation method
1066      * @since Moodle 3.2
1067      */
1068     public function add_rating($cm, $context, $component, $ratingarea, $itemid, $scaleid, $userrating, $rateduserid,
1069                                 $aggregationmethod) {
1070         global $CFG, $DB, $USER;
1072         $result = new stdClass;
1073         // Check the module rating permissions.
1074         // Doing this check here rather than within rating_manager::get_ratings() so we can return a error response.
1075         $pluginpermissionsarray = $this->get_plugin_permissions_array($context->id, $component, $ratingarea);
1077         if (!$pluginpermissionsarray['rate']) {
1078             $result->error = 'ratepermissiondenied';
1079             return $result;
1080         } else {
1081             $params = array(
1082                 'context'     => $context,
1083                 'component'   => $component,
1084                 'ratingarea'  => $ratingarea,
1085                 'itemid'      => $itemid,
1086                 'scaleid'     => $scaleid,
1087                 'rating'      => $userrating,
1088                 'rateduserid' => $rateduserid,
1089                 'aggregation' => $aggregationmethod
1090             );
1091             if (!$this->check_rating_is_valid($params)) {
1092                 $result->error = 'ratinginvalid';
1093                 return $result;
1094             }
1095         }
1097         // Rating options used to update the rating then retrieve the aggregate.
1098         $ratingoptions = new stdClass;
1099         $ratingoptions->context = $context;
1100         $ratingoptions->ratingarea = $ratingarea;
1101         $ratingoptions->component = $component;
1102         $ratingoptions->itemid  = $itemid;
1103         $ratingoptions->scaleid = $scaleid;
1104         $ratingoptions->userid  = $USER->id;
1106         if ($userrating != RATING_UNSET_RATING) {
1107             $rating = new rating($ratingoptions);
1108             $rating->update_rating($userrating);
1109         } else { // Delete the rating if the user set to "Rate..."
1110             $options = new stdClass;
1111             $options->contextid = $context->id;
1112             $options->component = $component;
1113             $options->ratingarea = $ratingarea;
1114             $options->userid = $USER->id;
1115             $options->itemid = $itemid;
1117             $this->delete_ratings($options);
1118         }
1120         // Future possible enhancement: add a setting to turn grade updating off for those who don't want them in gradebook.
1121         // Note that this would need to be done in both rate.php and rate_ajax.php.
1122         if ($context->contextlevel == CONTEXT_MODULE) {
1123             // Tell the module that its grades have changed.
1124             $modinstance = $DB->get_record($cm->modname, array('id' => $cm->instance));
1125             if ($modinstance) {
1126                 $modinstance->cmidnumber = $cm->id; // MDL-12961.
1127                 $functionname = $cm->modname.'_update_grades';
1128                 require_once($CFG->dirroot."/mod/{$cm->modname}/lib.php");
1129                 if (function_exists($functionname)) {
1130                     $functionname($modinstance, $rateduserid);
1131                 }
1132             }
1133         }
1135         // Object to return to client as JSON.
1136         $result->success = true;
1138         // Need to retrieve the updated item to get its new aggregate value.
1139         $item = new stdClass;
1140         $item->id = $itemid;
1142         // Most of $ratingoptions variables were previously set.
1143         $ratingoptions->items = array($item);
1144         $ratingoptions->aggregate = $aggregationmethod;
1146         $items = $this->get_ratings($ratingoptions);
1147         $firstrating = $items[0]->rating;
1149         // See if the user has permission to see the rating aggregate.
1150         if ($firstrating->user_can_view_aggregate()) {
1152             // For custom scales return text not the value.
1153             // This scales weirdness will go away when scales are refactored.
1154             $scalearray = null;
1155             $aggregatetoreturn = round($firstrating->aggregate, 1);
1157             // Output a dash if aggregation method == COUNT as the count is output next to the aggregate anyway.
1158             if ($firstrating->settings->aggregationmethod == RATING_AGGREGATE_COUNT or $firstrating->count == 0) {
1159                 $aggregatetoreturn = ' - ';
1160             } else if ($firstrating->settings->scale->id < 0) { // If its non-numeric scale.
1161                 // Dont use the scale item if the aggregation method is sum as adding items from a custom scale makes no sense.
1162                 if ($firstrating->settings->aggregationmethod != RATING_AGGREGATE_SUM) {
1163                     $scalerecord = $DB->get_record('scale', array('id' => -$firstrating->settings->scale->id));
1164                     if ($scalerecord) {
1165                         $scalearray = explode(',', $scalerecord->scale);
1166                         $aggregatetoreturn = $scalearray[$aggregatetoreturn - 1];
1167                     }
1168                 }
1169             }
1171             $result->aggregate = $aggregatetoreturn;
1172             $result->count = $firstrating->count;
1173             $result->itemid = $itemid;
1174         }
1175         return $result;
1176     }
1178     /**
1179      * Get ratings created since a given time.
1180      *
1181      * @param  stdClass $context   context object
1182      * @param  string $component  component name
1183      * @param  int $since         the time to check
1184      * @return array list of ratings db records since the given timelimit
1185      * @since Moodle 3.2
1186      */
1187     public function get_component_ratings_since($context, $component, $since) {
1188         global $DB, $USER;
1190         $ratingssince = array();
1191         $where = 'contextid = ? AND component = ? AND (timecreated > ? OR timemodified > ?)';
1192         $ratings = $DB->get_records_select('rating', $where, array($context->id, $component, $since, $since));
1193         // Check area by area if we have permissions.
1194         $permissions = array();
1195         $rm = new rating_manager();
1197         foreach ($ratings as $rating) {
1198             // Check if the permission array for the area is cached.
1199             if (!isset($permissions[$rating->ratingarea])) {
1200                 $permissions[$rating->ratingarea] = $rm->get_plugin_permissions_array($context->id, $component,
1201                                                                                         $rating->ratingarea);
1202             }
1204             if (($permissions[$rating->ratingarea]['view'] and $rating->userid == $USER->id) or
1205                     ($permissions[$rating->ratingarea]['viewany'] or $permissions[$rating->ratingarea]['viewall'])) {
1206                 $ratingssince[$rating->id] = $rating;
1207             }
1208         }
1209         return $ratingssince;
1210     }
1211 } // End rating_manager class definition.
1213 /**
1214  * The rating_exception class for exceptions specific to the ratings system
1215  *
1216  * @package   core_rating
1217  * @category  rating
1218  * @copyright 2010 Andrew Davis
1219  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1220  * @since     Moodle 2.0
1221  */
1222 class rating_exception extends moodle_exception {
1223     /**
1224      * @var string The message to accompany the thrown exception
1225      */
1226     public $message;
1227     /**
1228      * Generate exceptions that can be easily identified as coming from the ratings system
1229      *
1230      * @param string $errorcode the error code to generate
1231      */
1232     public function __construct($errorcode) {
1233         $this->errorcode = $errorcode;
1234         $this->message = get_string($errorcode, 'error');
1235     }