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/>.
16
17/**
18 * Condition on grades of current user.
19 *
20 * @package availability_grade
21 * @copyright 2014 The Open University
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25namespace availability_grade;
26
27defined('MOODLE_INTERNAL') || die();
28
29/**
30 * Condition on grades of current user.
31 *
32 * @package availability_grade
33 * @copyright 2014 The Open University
34 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35 */
36class condition extends \core_availability\condition {
37    /** @var int Grade item id */
38    private $gradeitemid;
39
40    /** @var float|null Min grade (must be >= this) or null if none */
41    private $min;
42
43    /** @var float|null Max grade (must be < this) or null if none */
44    private $max;
45
46    /**
47     * Constructor.
48     *
49     * @param \stdClass $structure Data structure from JSON decode
50     * @throws \coding_exception If invalid data structure.
51     */
52    public function __construct($structure) {
53        // Get grade item id.
54        if (isset($structure->id) && is_int($structure->id)) {
55            $this->gradeitemid = $structure->id;
56        } else {
57            throw new \coding_exception('Missing or invalid ->id for grade condition');
58        }
59
60        // Get min and max.
61        if (!property_exists($structure, 'min')) {
62            $this->min = null;
63        } else if (is_float($structure->min) || is_int($structure->min)) {
64            $this->min = $structure->min;
65        } else {
66            throw new \coding_exception('Missing or invalid ->min for grade condition');
67        }
68        if (!property_exists($structure, 'max')) {
69            $this->max = null;
70        } else if (is_float($structure->max) || is_int($structure->max)) {
71            $this->max = $structure->max;
72        } else {
73            throw new \coding_exception('Missing or invalid ->max for grade condition');
74        }
75    }
76
77    public function save() {
78        $result = (object)array('type' => 'grade', 'id' => $this->gradeitemid);
79        if (!is_null($this->min)) {
80            $result->min = $this->min;
81        }
82        if (!is_null($this->max)) {
83            $result->max = $this->max;
84        }
85        return $result;
86    }
87
88    /**
89     * Returns a JSON object which corresponds to a condition of this type.
90     *
91     * Intended for unit testing, as normally the JSON values are constructed
92     * by JavaScript code.
93     *
94     * @param int $gradeitemid Grade item id
95     * @param number|null $min Min grade (or null if no min)
96     * @param number|null $max Max grade (or null if no max)
97     * @return stdClass Object representing condition
98     */
99    public static function get_json($gradeitemid, $min = null, $max = null) {
100        $result = (object)array('type' => 'grade', 'id' => (int)$gradeitemid);
101        if (!is_null($min)) {
102            $result->min = $min;
103        }
104        if (!is_null($max)) {
105            $result->max = $max;
106        }
107        return $result;
108    }
109
110    public function is_available($not, \core_availability\info $info, $grabthelot, $userid) {
111        $course = $info->get_course();
112        $score = $this->get_cached_grade_score($this->gradeitemid, $course->id, $grabthelot, $userid);
113        $allow = $score !== false &&
114                (is_null($this->min) || $score >= $this->min) &&
115                (is_null($this->max) || $score < $this->max);
116        if ($not) {
117            $allow = !$allow;
118        }
119
120        return $allow;
121    }
122
123    public function get_description($full, $not, \core_availability\info $info) {
124        $course = $info->get_course();
125        // String depends on type of requirement. We are coy about
126        // the actual numbers, in case grades aren't released to
127        // students.
128        if (is_null($this->min) && is_null($this->max)) {
129            $string = 'any';
130        } else if (is_null($this->max)) {
131            $string = 'min';
132        } else if (is_null($this->min)) {
133            $string = 'max';
134        } else {
135            $string = 'range';
136        }
137        if ($not) {
138            // The specific strings don't make as much sense with 'not'.
139            if ($string === 'any') {
140                $string = 'notany';
141            } else {
142                $string = 'notgeneral';
143            }
144        }
145        // We cannot get the name at this point because it requires format_string which is not
146        // allowed here. Instead, get it later with the callback function below.
147        $name = $this->description_callback([$this->gradeitemid]);
148        return get_string('requires_' . $string, 'availability_grade', $name);
149    }
150
151    /**
152     * Gets the grade name at display time.
153     *
154     * @param \course_modinfo $modinfo Modinfo
155     * @param \context $context Context
156     * @param string[] $params Parameters (just grade item id)
157     * @return string Text value
158     */
159    public static function get_description_callback_value(
160            \course_modinfo $modinfo, \context $context, array $params): string {
161        if (count($params) !== 1 || !is_number($params[0])) {
162            return '<!-- Invalid grade description callback -->';
163        }
164        $gradeitemid = (int)$params[0];
165        return self::get_cached_grade_name($modinfo->get_course_id(), $gradeitemid);
166    }
167
168    protected function get_debug_string() {
169        $out = '#' . $this->gradeitemid;
170        if (!is_null($this->min)) {
171            $out .= ' >= ' . sprintf('%.5f', $this->min);
172        }
173        if (!is_null($this->max)) {
174            if (!is_null($this->min)) {
175                $out .= ',';
176            }
177            $out .= ' < ' . sprintf('%.5f', $this->max);
178        }
179        return $out;
180    }
181
182    /**
183     * Obtains the name of a grade item, also checking that it exists. Uses a
184     * cache. The name returned is suitable for display.
185     *
186     * @param int $courseid Course id
187     * @param int $gradeitemid Grade item id
188     * @return string Grade name or empty string if no grade with that id
189     */
190    private static function get_cached_grade_name($courseid, $gradeitemid) {
191        global $DB, $CFG;
192        require_once($CFG->libdir . '/gradelib.php');
193
194        // Get all grade item names from cache, or using db query.
195        $cache = \cache::make('availability_grade', 'items');
196        if (($cacheditems = $cache->get($courseid)) === false) {
197            // We cache the whole items table not the name; the format_string
198            // call for the name might depend on current user (e.g. multilang)
199            // and this is a shared cache.
200            $cacheditems = $DB->get_records('grade_items', array('courseid' => $courseid));
201            $cache->set($courseid, $cacheditems);
202        }
203
204        // Return name from cached item or a lang string.
205        if (!array_key_exists($gradeitemid, $cacheditems)) {
206            return get_string('missing', 'availability_grade');
207        }
208        $gradeitemobj = $cacheditems[$gradeitemid];
209        $item = new \grade_item;
210        \grade_object::set_properties($item, $gradeitemobj);
211        return $item->get_name();
212    }
213
214    /**
215     * Obtains a grade score. Note that this score should not be displayed to
216     * the user, because gradebook rules might prohibit that. It may be a
217     * non-final score subject to adjustment later.
218     *
219     * @param int $gradeitemid Grade item ID we're interested in
220     * @param int $courseid Course id
221     * @param bool $grabthelot If true, grabs all scores for current user on
222     *   this course, so that later ones come from cache
223     * @param int $userid Set if requesting grade for a different user (does
224     *   not use cache)
225     * @return float Grade score as a percentage in range 0-100 (e.g. 100.0
226     *   or 37.21), or false if user does not have a grade yet
227     */
228    protected static function get_cached_grade_score($gradeitemid, $courseid,
229            $grabthelot=false, $userid=0) {
230        global $USER, $DB;
231        if (!$userid) {
232            $userid = $USER->id;
233        }
234        $cache = \cache::make('availability_grade', 'scores');
235        if (($cachedgrades = $cache->get($userid)) === false) {
236            $cachedgrades = array();
237        }
238        if (!array_key_exists($gradeitemid, $cachedgrades)) {
239            if ($grabthelot) {
240                // Get all grades for the current course.
241                $rs = $DB->get_recordset_sql('
242                        SELECT
243                            gi.id,gg.finalgrade,gg.rawgrademin,gg.rawgrademax
244                        FROM
245                            {grade_items} gi
246                            LEFT JOIN {grade_grades} gg ON gi.id=gg.itemid AND gg.userid=?
247                        WHERE
248                            gi.courseid = ?', array($userid, $courseid));
249                foreach ($rs as $record) {
250                    // This function produces division by zero error warnings when rawgrademax and rawgrademin
251                    // are equal. Below change does not affect function behavior, just avoids the warning.
252                    if (is_null($record->finalgrade) || $record->rawgrademax == $record->rawgrademin) {
253                        // No grade = false.
254                        $cachedgrades[$record->id] = false;
255                    } else {
256                        // Otherwise convert grade to percentage.
257                        $cachedgrades[$record->id] =
258                                (($record->finalgrade - $record->rawgrademin) * 100) /
259                                ($record->rawgrademax - $record->rawgrademin);
260                    }
261                }
262                $rs->close();
263                // And if it's still not set, well it doesn't exist (eg
264                // maybe the user set it as a condition, then deleted the
265                // grade item) so we call it false.
266                if (!array_key_exists($gradeitemid, $cachedgrades)) {
267                    $cachedgrades[$gradeitemid] = false;
268                }
269            } else {
270                // Just get current grade.
271                $record = $DB->get_record('grade_grades', array(
272                    'userid' => $userid, 'itemid' => $gradeitemid));
273                // This function produces division by zero error warnings when rawgrademax and rawgrademin
274                // are equal. Below change does not affect function behavior, just avoids the warning.
275                if ($record && !is_null($record->finalgrade) && $record->rawgrademax != $record->rawgrademin) {
276                    $score = (($record->finalgrade - $record->rawgrademin) * 100) /
277                        ($record->rawgrademax - $record->rawgrademin);
278                } else {
279                    // Treat the case where row exists but is null, same as
280                    // case where row doesn't exist.
281                    $score = false;
282                }
283                $cachedgrades[$gradeitemid] = $score;
284            }
285            $cache->set($userid, $cachedgrades);
286        }
287        return $cachedgrades[$gradeitemid];
288    }
289
290    public function update_after_restore($restoreid, $courseid, \base_logger $logger, $name) {
291        global $DB;
292        $rec = \restore_dbops::get_backup_ids_record($restoreid, 'grade_item', $this->gradeitemid);
293        if (!$rec || !$rec->newitemid) {
294            // If we are on the same course (e.g. duplicate) then we can just
295            // use the existing one.
296            if ($DB->record_exists('grade_items',
297                    array('id' => $this->gradeitemid, 'courseid' => $courseid))) {
298                return false;
299            }
300            // Otherwise it's a warning.
301            $this->gradeitemid = 0;
302            $logger->process('Restored item (' . $name .
303                    ') has availability condition on grade that was not restored',
304                    \backup::LOG_WARNING);
305        } else {
306            $this->gradeitemid = (int)$rec->newitemid;
307        }
308        return true;
309    }
310
311    public function update_dependency_id($table, $oldid, $newid) {
312        if ($table === 'grade_items' && (int)$this->gradeitemid === (int)$oldid) {
313            $this->gradeitemid = $newid;
314            return true;
315        } else {
316            return false;
317        }
318    }
319}
320