Merge branch 'MDL-26461_change_rating_scale' of git://github.com/andyjdavis/moodle
[moodle.git] / rating / lib.php
1 <?php
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/>.
18 /**
19  * A class representing a single rating and containing some static methods for manipulating ratings
20  *
21  * @package    core
22  * @subpackage rating
23  * @copyright  2010 Andrew Davis
24  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  */
27 define('RATING_UNSET_RATING', -999);
29 define ('RATING_AGGREGATE_NONE', 0); //no ratings
30 define ('RATING_AGGREGATE_AVERAGE', 1);
31 define ('RATING_AGGREGATE_COUNT', 2);
32 define ('RATING_AGGREGATE_MAXIMUM', 3);
33 define ('RATING_AGGREGATE_MINIMUM', 4);
34 define ('RATING_AGGREGATE_SUM', 5);
36 define ('RATING_DEFAULT_SCALE', 5);
38 /**
39  * The rating class represents a single rating by a single user
40  *
41  * @copyright 2010 Andrew Davis
42  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43  * @since     Moodle 2.0
44  */
45 class rating implements renderable {
47     /**
48      * The context in which this rating exists
49      * @var context
50      */
51     public $context;
53     /**
54      * The id of the item (forum post, glossary item etc) being rated
55      * @var int
56      */
57     public $itemid;
59     /**
60      * The id scale (1-5, 0-100) that was in use when the rating was submitted
61      * @var int
62      */
63     public $scaleid;
65     /**
66      * The id of the user who submitted the rating
67      * @var int
68      */
69     public $userid;
71     /**
72      * settings for this rating. Necessary to render the rating.
73      * @var stdclass
74      */
75     public $settings;
77     /**
78     * Constructor.
79     * @param object $options {
80     *            context => context context to use for the rating [required]
81     *            itemid  => int the id of the associated item (forum post, glossary item etc) [required]
82     *            scaleid => int The scale in use when the rating was submitted [required]
83     *            userid  => int The id of the user who submitted the rating [required]
84     * }
85     */
86     public function __construct($options) {
87         $this->context = $options->context;
88         $this->itemid = $options->itemid;
89         $this->scaleid = $options->scaleid;
90         $this->userid = $options->userid;
91     }
93     /**
94     * Update this rating in the database
95     * @param int $rating the integer value of this rating
96     * @return void
97     */
98     public function update_rating($rating) {
99         global $DB;
101         $data = new stdclass();
102         $table = 'rating';
104         $item = new stdclass();
105         $item->id = $this->itemid;
106         $items = array($item);
108         $ratingoptions = new stdclass();
109         $ratingoptions->context = $this->context;
110         $ratingoptions->items = $items;
111         $ratingoptions->aggregate = RATING_AGGREGATE_AVERAGE;//we dont actually care what aggregation method is applied
112         $ratingoptions->scaleid = $this->scaleid;
113         $ratingoptions->userid = $this->userid;
115         $rm = new rating_manager();
116         $items = $rm->get_ratings($ratingoptions);
117         if( empty($items) || empty($items[0]->rating) || empty($items[0]->rating->id) ) {
118             $data->contextid    = $this->context->id;
119             $data->rating       = $rating;
120             $data->scaleid      = $this->scaleid;
121             $data->userid       = $this->userid;
122             $data->itemid       = $this->itemid;
124             $time = time();
125             $data->timecreated = $time;
126             $data->timemodified = $time;
128             $DB->insert_record($table, $data);
129         }
130         else {
131             $data->id       = $items[0]->rating->id;
132             $data->rating       = $rating;
134             $time = time();
135             $data->timemodified = $time;
137             $DB->update_record($table, $data);
138         }
139     }
141     /**
142     * Retreive the integer value of this rating
143     * @return int the integer value of this rating object
144     */
145     public function get_rating() {
146         return $this->rating;
147     }
149     /**
150     * Remove this rating from the database
151     * @return void
152     */
153     //public function delete_rating() {
154         //todo implement this if its actually needed
155     //}
156 } //end rating class definition
158 /**
159  * The rating_manager class provides the ability to retrieve sets of ratings from the database
160  *
161  * @copyright 2010 Andrew Davis
162  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
163  * @since     Moodle 2.0
164  */
165 class rating_manager {
167     /**
168     * Delete one or more ratings. Specify either a rating id, an item id or just the context id.
169     * @param object $options {
170     *            contextid => int the context in which the ratings exist [required]
171     *            ratingid => int the id of an individual rating to delete [optional]
172     *            userid => int delete the ratings submitted by this user. May be used in conjuction with itemid [optional]
173     *            itemid => int delete all ratings attached to this item [optional]
174     * }
175     * @return void
176     */
177     public function delete_ratings($options) {
178         global $DB;
180         if( !empty($options->ratingid) ) {
181             //delete a single rating
182             $DB->delete_records('rating', array('contextid'=>$options->contextid, 'id'=>$options->ratingid) );
183         }
184         else if( !empty($options->itemid) && !empty($options->userid) ) {
185             //delete the rating for an item submitted by a particular user
186             $DB->delete_records('rating', array('contextid'=>$options->contextid, 'itemid'=>$options->itemid, 'userid'=>$options->userid) );
187         }
188         else if( !empty($options->itemid) ) {
189             //delete all ratings for an item
190             $DB->delete_records('rating', array('contextid'=>$options->contextid, 'itemid'=>$options->itemid) );
191         }
192         else if( !empty($options->userid) ) {
193             //delete all ratings submitted by a user
194             $DB->delete_records('rating', array('contextid'=>$options->contextid, 'userid'=>$options->userid) );
195         }
196         else {
197             //delete all ratings for this context
198             $DB->delete_records('rating', array('contextid'=>$options->contextid) );
199         }
200     }
202     /**
203     * Returns an array of ratings for a given item (forum post, glossary entry etc)
204     * This returns all users ratings for a single item
205     * @param object $options {
206     *            context => context the context in which the ratings exists [required]
207     *            itemid  =>  int the id of the associated item (forum post, glossary item etc) [required]
208     *            sort    => string SQL sort by clause [optional]
209     * }
210     * @return array an array of ratings
211     */
212     public function get_all_ratings_for_item($options) {
213         global $DB;
215         $sortclause = '';
216         if( !empty($options->sort) ) {
217             $sortclause = "ORDER BY $options->sort";
218         }
220         $userfields = user_picture::fields('u', null, 'userid');
221         $sql = "SELECT r.id, r.rating, r.itemid, r.userid, r.timemodified, $userfields
222                 FROM {rating} r
223                 LEFT JOIN {user} u ON r.userid = u.id
224                 WHERE r.contextid = :contextid AND
225                       r.itemid  = :itemid
226                 {$sortclause}";
228         $params['contextid'] = $options->context->id;
229         $params['itemid'] = $options->itemid;
231         return $DB->get_records_sql($sql, $params);
232     }
234     /**
235     * Adds rating objects to an array of items (forum posts, glossary entries etc)
236     * Rating objects are available at $item->rating
237     * @param object $options {
238     *            context => context the context in which the ratings exists [required]
239     *            items  => array an array of items such as forum posts or glossary items. They must have an 'id' member ie $items[0]->id[required]
240     *            aggregate    => int what aggregation method should be applied. RATING_AGGREGATE_AVERAGE, RATING_AGGREGATE_MAXIMUM etc [required]
241     *            scaleid => int the scale from which the user can select a rating [required]
242     *            userid => int the id of the current user [optional]
243     *            returnurl => string the url to return the user to after submitting a rating. Can be left null for ajax requests [optional]
244     *            assesstimestart => int only allow rating of items created after this timestamp [optional]
245     *            assesstimefinish => int only allow rating of items created before this timestamp [optional]
246     *            plugintype => string plugin type ie 'mod' Used to find the permissions callback [optional]
247     *            pluginname => string plugin name ie 'forum' Used to find the permissions callback [optional]
248     * @return array the array of items with their ratings attached at $items[0]->rating
249     */
250     public function get_ratings($options) {
251         global $DB, $USER, $PAGE, $CFG;
253         //are ratings enabled?
254         if ($options->aggregate==RATING_AGGREGATE_NONE) {
255             return $options->items;
256         }
257         $aggregatestr = $this->get_aggregation_method($options->aggregate);
259         if(empty($options->items)) {
260             return $options->items;
261         }
263         $userid = null;
264         if (empty($options->userid)) {
265             $userid = $USER->id;
266         } else {
267             $userid = $options->userid;
268         }
270         //create an array of item ids
271         $itemids = array();
272         foreach($options->items as $item) {
273             $itemids[] = $item->id;
274         }
276         //get the items from the database
277         list($itemidtest, $params) = $DB->get_in_or_equal(
278                 $itemids, SQL_PARAMS_NAMED, 'itemid0000');
280         //note: all the group bys arent really necessary but PostgreSQL complains
281         //about selecting a mixture of grouped and non-grouped columns
282         $sql = "SELECT r.itemid, ur.id, ur.userid, ur.scaleid,
283         $aggregatestr(r.rating) AS aggrrating,
284         COUNT(r.rating) AS numratings,
285         ur.rating AS usersrating
286     FROM {rating} r
287     LEFT JOIN {rating} ur ON ur.contextid = r.contextid AND
288             ur.itemid = r.itemid AND
289             ur.userid = :userid
290     WHERE
291         r.contextid = :contextid AND
292         r.itemid $itemidtest
293     GROUP BY r.itemid, ur.rating, ur.id, ur.userid, ur.scaleid
294     ORDER BY r.itemid";
296         $params['userid'] = $userid;
297         $params['contextid'] = $options->context->id;
299         $ratingsrecords = $DB->get_records_sql($sql, $params);
301         //now create the rating sub objects
302         $scaleobj = new stdClass();
303         $scalemax = null;
305         //we could look for a scale id on each item to allow each item to use a different scale
306         if($options->scaleid < 0 ) { //if its a scale (not numeric)
307             $scalerecord = $DB->get_record('scale', array('id' => -$options->scaleid));
308             if ($scalerecord) {
309                 $scalearray = explode(',', $scalerecord->scale);
311                 //is there a more efficient way to get the indexes to start at 1 instead of 0?
312                 //this will go away when scales are refactored
313                 $c = count($scalearray);
314                 $n = null;
315                 for($i=0; $i<$c; $i++) {
316                     $n = $i+1;
317                     $scaleobj->scaleitems["$n"] = $scalearray[$i];//treat index as a string to allow sorting without changing the value
318                 }
319                 krsort($scaleobj->scaleitems);//have the highest grade scale item appear first
321                 $scaleobj->id = $options->scaleid;//dont use the one from the record or we "forget" that its negative
322                 $scaleobj->name = $scalerecord->name;
323                 $scaleobj->courseid = $scalerecord->courseid;
325                 $scalemax = count($scaleobj->scaleitems);
326             }
327         }
328         else { //its numeric
329             $scaleobj->scaleitems = $options->scaleid;
330             $scaleobj->id = $options->scaleid;
331             $scaleobj->name = null;
333             $scalemax = $options->scaleid;
334         }
336         //should $settings and $settings->permissions be declared as proper classes?
337         $settings = new stdclass(); //settings that are common to all ratings objects in this context
338         $settings->scale = $scaleobj; //the scale to use now
339         $settings->aggregationmethod = $options->aggregate;
340         if( !empty($options->returnurl) ) {
341             $settings->returnurl = $options->returnurl;
342         }
344         $settings->assesstimestart = $settings->assesstimefinish = null;
345         if( !empty($options->assesstimestart) ) {
346             $settings->assesstimestart = $options->assesstimestart;
347         }
348         if( !empty($options->assesstimefinish) ) {
349             $settings->assesstimefinish = $options->assesstimefinish;
350         }
352         //check site capabilities
353         $settings->permissions = new stdclass();
354         $settings->permissions->view = has_capability('moodle/rating:view',$options->context);//can view the aggregate of ratings of their own items
355         $settings->permissions->viewany = has_capability('moodle/rating:viewany',$options->context);//can view the aggregate of ratings of other people's items
356         $settings->permissions->viewall = has_capability('moodle/rating:viewall',$options->context);//can view individual ratings
357         $settings->permissions->rate = has_capability('moodle/rating:rate',$options->context);//can submit ratings
359         //check module capabilities (mostly for backwards compatability with old modules that previously implemented their own ratings)
360         $plugintype = !empty($options->plugintype) ? $options->plugintype : null;
361         $pluginname = !empty($options->pluginname) ? $options->pluginname : null;
362         $pluginpermissionsarray = $this->get_plugin_permissions_array($options->context->id, $plugintype, $pluginname);
364         $settings->pluginpermissions = new stdclass();
365         $settings->pluginpermissions->view = $pluginpermissionsarray['view'];
366         $settings->pluginpermissions->viewany = $pluginpermissionsarray['viewany'];
367         $settings->pluginpermissions->viewall = $pluginpermissionsarray['viewall'];
368         $settings->pluginpermissions->rate = $pluginpermissionsarray['rate'];
370         $rating = null;
371         $ratingoptions = new stdclass();
372         $ratingoptions->context = $options->context;//context is common to all ratings in the set
373         foreach($options->items as $item) {
374             $rating = null;
375             //match the item with its corresponding rating
376             foreach($ratingsrecords as $rec) {
377                 if( $item->id==$rec->itemid ) {
378                     //Note: rec->scaleid = the id of scale at the time the rating was submitted
379                     //may be different from the current scale id
380                     $ratingoptions->itemid = $item->id;
381                     $ratingoptions->scaleid = $rec->scaleid;
382                     $ratingoptions->userid = $rec->userid;
384                     $rating = new rating($ratingoptions);
385                     $rating->id         = $rec->id;    //unset($rec->id);
386                     $rating->aggregate  = $rec->aggrrating; //unset($rec->aggrrating);
387                     $rating->count      = $rec->numratings; //unset($rec->numratings);
388                     $rating->rating     = $rec->usersrating; //unset($rec->usersrating);
389                     $rating->itemtimecreated = $this->get_item_time_created($item);
391                     break;
392                 }
393             }
394             //if there are no ratings for this item
395             if( !$rating ) {
396                 $ratingoptions->itemid = $item->id;
397                 $ratingoptions->scaleid = null;
398                 $ratingoptions->userid = null;
400                 $rating = new rating($ratingoptions);
401                 $rating->id         = null;
402                 $rating->aggregate  = null;
403                 $rating->count      = 0;
404                 $rating->rating     = null;
406                 $rating->itemid     = $item->id;
407                 $rating->userid     = null;
408                 $rating->scaleid     = null;
409                 $rating->itemtimecreated = $this->get_item_time_created($item);
410             }
412             if( !empty($item->userid) ) {
413                 $rating->itemuserid = $item->userid;
414             } else {
415                 $rating->itemuserid = null;
416             }
417             $rating->settings = $settings;
418             $item->rating = $rating;
420             //Below is a nasty hack presumably here to handle scales being changed (out of 10 to out of 5 for example)
421             //
422             // it could throw off the grading if count and sum returned a grade higher than scale
423             // so to prevent it we review the results and ensure that grade does not exceed the scale, if it does we set grade = scale (i.e. full credit)
424             if ($rating->rating > $scalemax) {
425                 $rating->rating = $scalemax;
426             }
427             if ($rating->aggregate > $scalemax) {
428                 $rating->aggregate = $scalemax;
429             }
430         }
432         return $options->items;
433     }
435     private function get_item_time_created($item) {
436         if( !empty($item->created) ) {
437             return $item->created;//the forum_posts table has created instead of timecreated
438         }
439         else if(!empty($item->timecreated)) {
440             return $item->timecreated;
441         }
442         else {
443             return null;
444         }
445     }
447     /**
448     * Returns an array of grades calculated by aggregating item ratings.
449     * @param object $options {
450     *            userid => int the id of the user whose items have been rated. NOT the user who submitted the ratings. 0 to update all. [required]
451     *            aggregationmethod => int the aggregation method to apply when calculating grades ie RATING_AGGREGATE_AVERAGE [required]
452     *            scaleid => int the scale from which the user can select a rating. Used for bounds checking. [required]
453     *            itemtable => int the table containing the items [required]
454     *            itemtableusercolum => int the column of the user table containing the item owner's user id [required]
455     *
456     *            contextid => int the context in which the rated items exist [optional]
457     *
458     *            modulename => string the name of the module [optional]
459     *            moduleid => int the id of the module instance [optional]
460     *
461     * @return array the array of the user's grades
462     */
463     public function get_user_grades($options) {
464         global $DB;
466         $contextid = null;
468         //if the calling code doesn't supply a context id we'll have to figure it out
469         if( !empty($options->contextid) ) {
470             $contextid = $options->contextid;
471         }
472         else if( !empty($options->cmid) ) {
473             //not implemented as not currently used although cmid is potentially available (the forum supplies it)
474             //Is there a convenient way to get a context id from a cm id?
475             //$cmidnumber = $options->cmidnumber;
476         }
477         else if ( !empty($options->modulename) && !empty($options->moduleid) ) {
478             $modulename = $options->modulename;
479             $moduleid   = intval($options->moduleid);
481             //going direct to the db for the context id seems wrong
482             list($ctxselect, $ctxjoin) = context_instance_preload_sql('cm.id', CONTEXT_MODULE, 'ctx');
483             $sql = "SELECT cm.* $ctxselect
484             FROM {course_modules} cm
485             LEFT JOIN {modules} mo ON mo.id = cm.module
486             LEFT JOIN {{$modulename}} m ON m.id = cm.instance $ctxjoin
487             WHERE mo.name=:modulename AND m.id=:moduleid";
488             $contextrecord = $DB->get_record_sql($sql, array('modulename'=>$modulename, 'moduleid'=>$moduleid), '*', MUST_EXIST);
489             $contextid = $contextrecord->ctxid;
490         }
492         $params = array();
493         $params['contextid']= $contextid;
494         $itemtable          = $options->itemtable;
495         $itemtableusercolumn= $options->itemtableusercolumn;
496         $scaleid            = $options->scaleid;
497         $aggregationstring = $this->get_aggregation_method($options->aggregationmethod);
499         //if userid is not 0 we only want the grade for a single user
500         $singleuserwhere = '';
501         if ($options->userid!=0) {
502             $params['userid1'] = intval($options->userid);
503             $singleuserwhere = "AND i.{$itemtableusercolumn} = :userid1";
504         }
506         //MDL-24648 The where line used to be "WHERE (r.contextid is null or r.contextid=:contextid)"
507         //r.contextid will be null for users who haven't been rated yet
508         //no longer including users who haven't been rated to reduce memory requirements
509         $sql = "SELECT u.id as id, u.id AS userid, $aggregationstring(r.rating) AS rawgrade
510                 FROM {user} u
511                 LEFT JOIN {{$itemtable}} i ON u.id=i.{$itemtableusercolumn}
512                 LEFT JOIN {rating} r ON r.itemid=i.id
513                 WHERE r.contextid=:contextid
514                 $singleuserwhere
515                 GROUP BY u.id";
517         $results = $DB->get_records_sql($sql, $params);
519         if ($results) {
521             $scale = null;
522             $max = 0;
523             if ($options->scaleid >= 0) {
524                 //numeric
525                 $max = $options->scaleid;
526             } else {
527                 //custom scales
528                 $scale = $DB->get_record('scale', array('id' => -$options->scaleid));
529                 if ($scale) {
530                     $scale = explode(',', $scale->scale);
531                     $max = count($scale);
532                 } else {
533                     debugging('rating_manager::get_user_grades() received a scale ID that doesnt exist');
534                 }
535             }
537             // it could throw off the grading if count and sum returned a rawgrade higher than scale
538             // so to prevent it we review the results and ensure that rawgrade does not exceed the scale, if it does we set rawgrade = scale (i.e. full credit)
539             foreach ($results as $rid=>$result) {
540                 if ($options->scaleid >= 0) {
541                     //numeric
542                     if ($result->rawgrade > $options->scaleid) {
543                         $results[$rid]->rawgrade = $options->scaleid;
544                     }
545                 } else {
546                     //scales
547                     if (!empty($scale) && $result->rawgrade > $max) {
548                         $results[$rid]->rawgrade = $max;
549                     }
550                 }
551             }
552         }
554         return $results;
555     }
557     /**
558      * Returns array of aggregate types. Used by ratings.
559      *
560      * @return array
561      */
562     public function get_aggregate_types() {
563         return array (RATING_AGGREGATE_NONE  => get_string('aggregatenone', 'rating'),
564                       RATING_AGGREGATE_AVERAGE   => get_string('aggregateavg', 'rating'),
565                       RATING_AGGREGATE_COUNT => get_string('aggregatecount', 'rating'),
566                       RATING_AGGREGATE_MAXIMUM   => get_string('aggregatemax', 'rating'),
567                       RATING_AGGREGATE_MINIMUM   => get_string('aggregatemin', 'rating'),
568                       RATING_AGGREGATE_SUM   => get_string('aggregatesum', 'rating'));
569     }
571     /**
572     * Converts an aggregation method constant into something that can be included in SQL
573     * @param int $aggregate An aggregation constant. For example, RATING_AGGREGATE_AVERAGE.
574     * @return string an SQL aggregation method
575     */
576     public function get_aggregation_method($aggregate) {
577         $aggregatestr = null;
578         switch($aggregate){
579             case RATING_AGGREGATE_AVERAGE:
580                 $aggregatestr = 'AVG';
581                 break;
582             case RATING_AGGREGATE_COUNT:
583                 $aggregatestr = 'COUNT';
584                 break;
585             case RATING_AGGREGATE_MAXIMUM:
586                 $aggregatestr = 'MAX';
587                 break;
588             case RATING_AGGREGATE_MINIMUM:
589                 $aggregatestr = 'MIN';
590                 break;
591             case RATING_AGGREGATE_SUM:
592                 $aggregatestr = 'SUM';
593                 break;
594             default:
595                 $aggregatestr = 'AVG'; // Default to this to avoid real breakage - MDL-22270
596                 debugging('Incorrect call to get_aggregation_method(), was called with incorrect aggregate method ' . $aggregate, DEBUG_DEVELOPER);
597         }
598         return $aggregatestr;
599     }
601     /**
602     * Looks for a callback and retrieves permissions from the plugin whose items are being rated
603     * @param int $contextid The current context id
604     * @param string plugintype the type of plugin ie 'mod'
605     * @param string pluginname the name of the plugin ie 'forum'
606     * @return array rating related permissions
607     */
608     public function get_plugin_permissions_array($contextid, $plugintype=null, $pluginname=null) {
609         $pluginpermissionsarray = null;
610         $defaultpluginpermissions = array('rate'=>true,'view'=>true,'viewany'=>true,'viewall'=>true);//all true == rely on system level permissions if no plugin callback is defined
611         if ($plugintype && $pluginname) {
612             $pluginpermissionsarray = plugin_callback($plugintype, $pluginname, 'rating', 'permissions', array($contextid), $defaultpluginpermissions);
613         } else {
614             $pluginpermissionsarray = $defaultpluginpermissions;
615         }
616         return $pluginpermissionsarray;
617     }
619     /**
620     * Checks if the item exists and is NOT owned by the current owner. Uses a callback to find out what table to look in.
621     * @param string plugintype the type of plugin ie 'mod'
622     * @param string pluginname the name of the plugin ie 'forum'
623     * @return boolean True if the callback doesn't exist. True if the item exists and doesn't belong to the current user. False otherwise.
624     */
625     public function check_item_and_owner($plugintype, $pluginname, $itemid) {
626         global $DB, $USER;
628         list($tablename,$itemidcol,$useridcol) = plugin_callback($plugintype, $pluginname, 'rating', 'item_check_info');
630         if (!empty($tablename)) {
631             $item = $DB->get_record($tablename, array($itemidcol=>$itemid), $useridcol);
632             if ($item) {
633                 if ($item->userid!=$USER->id) {
634                     return true;
635                 }
636             }
638             return false;//item doesn't exist or belongs to the current user
639         } else {
640             return true;//callback doesn't exist
641         }
642     }
643 }//end rating_manager class definition