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 * Grading method controller for the guide plugin
19 *
20 * @package    gradingform_guide
21 * @copyright  2012 Dan Marsden <dan@danmarsden.com>
22 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25defined('MOODLE_INTERNAL') || die();
26
27require_once($CFG->dirroot.'/grade/grading/form/lib.php');
28
29/** guide: Used to compare our gradeitem_type against. */
30const MARKING_GUIDE = 'guide';
31
32/**
33 * This controller encapsulates the guide grading logic
34 *
35 * @package    gradingform_guide
36 * @copyright  2012 Dan Marsden <dan@danmarsden.com>
37 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38 */
39class gradingform_guide_controller extends gradingform_controller {
40    // Modes of displaying the guide (used in gradingform_guide_renderer).
41    /** guide display mode: For editing (moderator or teacher creates a guide) */
42    const DISPLAY_EDIT_FULL     = 1;
43    /** guide display mode: Preview the guide design with hidden fields */
44    const DISPLAY_EDIT_FROZEN   = 2;
45    /** guide display mode: Preview the guide design (for person with manage permission) */
46    const DISPLAY_PREVIEW       = 3;
47    /** guide display mode: Preview the guide (for people being graded) */
48    const DISPLAY_PREVIEW_GRADED= 8;
49    /** guide display mode: For evaluation, enabled (teacher grades a student) */
50    const DISPLAY_EVAL          = 4;
51    /** guide display mode: For evaluation, with hidden fields */
52    const DISPLAY_EVAL_FROZEN   = 5;
53    /** guide display mode: Teacher reviews filled guide */
54    const DISPLAY_REVIEW        = 6;
55    /** guide display mode: Dispaly filled guide (i.e. students see their grades) */
56    const DISPLAY_VIEW          = 7;
57
58    /** @var stdClass|false the definition structure */
59    protected $moduleinstance = false;
60
61    /**
62     * Extends the module settings navigation with the guide grading settings
63     *
64     * This function is called when the context for the page is an activity module with the
65     * FEATURE_ADVANCED_GRADING, the user has the permission moodle/grade:managegradingforms
66     * and there is an area with the active grading method set to 'guide'.
67     *
68     * @param settings_navigation $settingsnav {@link settings_navigation}
69     * @param navigation_node $node {@link navigation_node}
70     */
71    public function extend_settings_navigation(settings_navigation $settingsnav, navigation_node $node=null) {
72        $node->add(get_string('definemarkingguide', 'gradingform_guide'),
73            $this->get_editor_url(), settings_navigation::TYPE_CUSTOM,
74            null, null, new pix_icon('icon', '', 'gradingform_guide'));
75    }
76
77    /**
78     * Extends the module navigation
79     *
80     * This function is called when the context for the page is an activity module with the
81     * FEATURE_ADVANCED_GRADING and there is an area with the active grading method set to the given plugin.
82     *
83     * @param global_navigation $navigation {@link global_navigation}
84     * @param navigation_node $node {@link navigation_node}
85     * @return void
86     */
87    public function extend_navigation(global_navigation $navigation, navigation_node $node=null) {
88        if (has_capability('moodle/grade:managegradingforms', $this->get_context())) {
89            // No need for preview if user can manage forms, he will have link to manage.php in settings instead.
90            return;
91        }
92        if ($this->is_form_defined() && ($options = $this->get_options()) && !empty($options['alwaysshowdefinition'])) {
93            $node->add(get_string('gradingof', 'gradingform_guide', get_grading_manager($this->get_areaid())->get_area_title()),
94                    new moodle_url('/grade/grading/form/'.$this->get_method_name().'/preview.php',
95                        array('areaid' => $this->get_areaid())), settings_navigation::TYPE_CUSTOM);
96        }
97    }
98
99    /**
100     * Saves the guide definition into the database
101     *
102     * @see parent::update_definition()
103     * @param stdClass $newdefinition guide definition data as coming from gradingform_guide_editguide::get_data()
104     * @param int $usermodified optional userid of the author of the definition, defaults to the current user
105     */
106    public function update_definition(stdClass $newdefinition, $usermodified = null) {
107        $this->update_or_check_guide($newdefinition, $usermodified, true);
108        if (isset($newdefinition->guide['regrade']) && $newdefinition->guide['regrade']) {
109            $this->mark_for_regrade();
110        }
111    }
112
113    /**
114     * Either saves the guide definition into the database or check if it has been changed.
115     *
116     * Returns the level of changes:
117     * 0 - no changes
118     * 1 - only texts or criteria sortorders are changed, students probably do not require re-grading
119     * 2 - added levels but maximum score on guide is the same, students still may not require re-grading
120     * 3 - removed criteria or changed number of points, students require re-grading but may be re-graded automatically
121     * 4 - removed levels - students require re-grading and not all students may be re-graded automatically
122     * 5 - added criteria - all students require manual re-grading
123     *
124     * @param stdClass $newdefinition guide definition data as coming from gradingform_guide_editguide::get_data()
125     * @param int|null $usermodified optional userid of the author of the definition, defaults to the current user
126     * @param bool $doupdate if true actually updates DB, otherwise performs a check
127     * @return int
128     */
129    public function update_or_check_guide(stdClass $newdefinition, $usermodified = null, $doupdate = false) {
130        global $DB;
131
132        // Firstly update the common definition data in the {grading_definition} table.
133        if ($this->definition === false) {
134            if (!$doupdate) {
135                // If we create the new definition there is no such thing as re-grading anyway.
136                return 5;
137            }
138            // If definition does not exist yet, create a blank one
139            // (we need id to save files embedded in description).
140            parent::update_definition(new stdClass(), $usermodified);
141            parent::load_definition();
142        }
143        if (!isset($newdefinition->guide['options'])) {
144            $newdefinition->guide['options'] = self::get_default_options();
145        }
146        $newdefinition->options = json_encode($newdefinition->guide['options']);
147        $editoroptions = self::description_form_field_options($this->get_context());
148        $newdefinition = file_postupdate_standard_editor($newdefinition, 'description', $editoroptions, $this->get_context(),
149            'grading', 'description', $this->definition->id);
150
151        // Reload the definition from the database.
152        $currentdefinition = $this->get_definition(true);
153
154        // Update guide data.
155        $haschanges = array();
156        if (empty($newdefinition->guide['criteria'])) {
157            $newcriteria = array();
158        } else {
159            $newcriteria = $newdefinition->guide['criteria']; // New ones to be saved.
160        }
161        $currentcriteria = $currentdefinition->guide_criteria;
162        $criteriafields = array('sortorder', 'description', 'descriptionformat', 'descriptionmarkers',
163            'descriptionmarkersformat', 'shortname', 'maxscore');
164        foreach ($newcriteria as $id => $criterion) {
165            if (preg_match('/^NEWID\d+$/', $id)) {
166                // Insert criterion into DB.
167                $data = array('definitionid' => $this->definition->id, 'descriptionformat' => FORMAT_MOODLE,
168                    'descriptionmarkersformat' => FORMAT_MOODLE); // TODO format is not supported yet.
169                foreach ($criteriafields as $key) {
170                    if (array_key_exists($key, $criterion)) {
171                        $data[$key] = $criterion[$key];
172                    }
173                }
174                if ($doupdate) {
175                    $id = $DB->insert_record('gradingform_guide_criteria', $data);
176                }
177                $haschanges[5] = true;
178            } else {
179                // Update criterion in DB.
180                $data = array();
181                foreach ($criteriafields as $key) {
182                    if (array_key_exists($key, $criterion) && $criterion[$key] != $currentcriteria[$id][$key]) {
183                        $data[$key] = $criterion[$key];
184                    }
185                }
186                if (!empty($data)) {
187                    // Update only if something is changed.
188                    $data['id'] = $id;
189                    if ($doupdate) {
190                        $DB->update_record('gradingform_guide_criteria', $data);
191                    }
192                    $haschanges[1] = true;
193                }
194            }
195        }
196        // Remove deleted criteria from DB.
197        foreach (array_keys($currentcriteria) as $id) {
198            if (!array_key_exists($id, $newcriteria)) {
199                if ($doupdate) {
200                    $DB->delete_records('gradingform_guide_criteria', array('id' => $id));
201                }
202                $haschanges[3] = true;
203            }
204        }
205        // Now handle comments.
206        if (empty($newdefinition->guide['comments'])) {
207            $newcomment = array();
208        } else {
209            $newcomment = $newdefinition->guide['comments']; // New ones to be saved.
210        }
211        $currentcomments = $currentdefinition->guide_comments;
212        $commentfields = array('sortorder', 'description');
213        foreach ($newcomment as $id => $comment) {
214            if (preg_match('/^NEWID\d+$/', $id)) {
215                // Insert criterion into DB.
216                $data = array('definitionid' => $this->definition->id, 'descriptionformat' => FORMAT_MOODLE);
217                foreach ($commentfields as $key) {
218                    if (array_key_exists($key, $comment)) {
219                        // Check if key is the comment's description.
220                        if ($key === 'description') {
221                            // Get a trimmed value for the comment description.
222                            $description = trim($comment[$key]);
223                            // Check if the comment description is empty.
224                            if (empty($description)) {
225                                // Continue to the next comment object if the description is empty.
226                                continue 2;
227                            }
228                        }
229                        $data[$key] = $comment[$key];
230                    }
231                }
232                if ($doupdate) {
233                    $id = $DB->insert_record('gradingform_guide_comments', $data);
234                }
235            } else {
236                // Update criterion in DB.
237                $data = array();
238                foreach ($commentfields as $key) {
239                    if (array_key_exists($key, $comment) && $comment[$key] != $currentcomments[$id][$key]) {
240                        $data[$key] = $comment[$key];
241                    }
242                }
243                if (!empty($data)) {
244                    // Update only if something is changed.
245                    $data['id'] = $id;
246                    if ($doupdate) {
247                        $DB->update_record('gradingform_guide_comments', $data);
248                    }
249                }
250            }
251        }
252        // Remove deleted criteria from DB.
253        foreach (array_keys($currentcomments) as $id) {
254            if (!array_key_exists($id, $newcomment)) {
255                if ($doupdate) {
256                    $DB->delete_records('gradingform_guide_comments', array('id' => $id));
257                }
258            }
259        }
260        // End comments handle.
261        foreach (array('status', 'description', 'descriptionformat', 'name', 'options') as $key) {
262            if (isset($newdefinition->$key) && $newdefinition->$key != $this->definition->$key) {
263                $haschanges[1] = true;
264            }
265        }
266        if ($usermodified && $usermodified != $this->definition->usermodified) {
267            $haschanges[1] = true;
268        }
269        if (!count($haschanges)) {
270            return 0;
271        }
272        if ($doupdate) {
273            parent::update_definition($newdefinition, $usermodified);
274            $this->load_definition();
275        }
276        // Return the maximum level of changes.
277        $changelevels = array_keys($haschanges);
278        sort($changelevels);
279        return array_pop($changelevels);
280    }
281
282    /**
283     * Marks all instances filled with this guide with the status INSTANCE_STATUS_NEEDUPDATE
284     */
285    public function mark_for_regrade() {
286        global $DB;
287        if ($this->has_active_instances()) {
288            $conditions = array('definitionid'  => $this->definition->id,
289                        'status'  => gradingform_instance::INSTANCE_STATUS_ACTIVE);
290            $DB->set_field('grading_instances', 'status', gradingform_instance::INSTANCE_STATUS_NEEDUPDATE, $conditions);
291        }
292    }
293
294    /**
295     * Loads the guide form definition if it exists
296     *
297     * There is a new array called 'guide_criteria' appended to the list of parent's definition properties.
298     */
299    protected function load_definition() {
300        global $DB;
301
302        // Check to see if the user prefs have changed - putting here as this function is called on post even when
303        // validation on the page fails. - hard to find a better place to locate this as it is specific to the guide.
304        $showdesc = optional_param('showmarkerdesc', null, PARAM_BOOL); // Check if we need to change pref.
305        $showdescstudent = optional_param('showstudentdesc', null, PARAM_BOOL); // Check if we need to change pref.
306        if ($showdesc !== null) {
307            set_user_preference('gradingform_guide-showmarkerdesc', $showdesc);
308        }
309        if ($showdescstudent !== null) {
310            set_user_preference('gradingform_guide-showstudentdesc', $showdescstudent);
311        }
312
313        // Get definition.
314        $definition = $DB->get_record('grading_definitions', array('areaid' => $this->areaid,
315            'method' => $this->get_method_name()), '*');
316        if (!$definition) {
317            // The definition doesn't have to exist. It may be that we are only now creating it.
318            $this->definition = false;
319            return false;
320        }
321
322        $this->definition = $definition;
323        // Now get criteria.
324        $this->definition->guide_criteria = array();
325        $this->definition->guide_comments = array();
326        $criteria = $DB->get_recordset('gradingform_guide_criteria', array('definitionid' => $this->definition->id), 'sortorder');
327        foreach ($criteria as $criterion) {
328            foreach (array('id', 'sortorder', 'description', 'descriptionformat',
329                           'maxscore', 'descriptionmarkers', 'descriptionmarkersformat', 'shortname') as $fieldname) {
330                if ($fieldname == 'maxscore') {  // Strip any trailing 0.
331                    $this->definition->guide_criteria[$criterion->id][$fieldname] = (float)$criterion->{$fieldname};
332                } else {
333                    $this->definition->guide_criteria[$criterion->id][$fieldname] = $criterion->{$fieldname};
334                }
335            }
336        }
337        $criteria->close();
338
339        // Now get comments.
340        $comments = $DB->get_recordset('gradingform_guide_comments', array('definitionid' => $this->definition->id), 'sortorder');
341        foreach ($comments as $comment) {
342            foreach (array('id', 'sortorder', 'description', 'descriptionformat') as $fieldname) {
343                $this->definition->guide_comments[$comment->id][$fieldname] = $comment->{$fieldname};
344            }
345        }
346        $comments->close();
347        if (empty($this->moduleinstance)) { // Only set if empty.
348            $modulename = $this->get_component();
349            $context = $this->get_context();
350            if (strpos($modulename, 'mod_') === 0) {
351                $dbman = $DB->get_manager();
352                $modulename = substr($modulename, 4);
353                if ($dbman->table_exists($modulename)) {
354                    $cm = get_coursemodule_from_id($modulename, $context->instanceid);
355                    if (!empty($cm)) { // This should only occur when the course is being deleted.
356                        $this->moduleinstance = $DB->get_record($modulename, array("id"=>$cm->instance));
357                    }
358                }
359            }
360        }
361    }
362
363    /**
364     * Returns the default options for the guide display
365     *
366     * @return array
367     */
368    public static function get_default_options() {
369        $options = array(
370            'alwaysshowdefinition' => 1,
371            'showmarkspercriterionstudents' => 1,
372        );
373        return $options;
374    }
375
376    /**
377     * Gets the options of this guide definition, fills the missing options with default values
378     *
379     * @return array
380     */
381    public function get_options() {
382        $options = self::get_default_options();
383        if (!empty($this->definition->options)) {
384            $thisoptions = json_decode($this->definition->options);
385            foreach ($thisoptions as $option => $value) {
386                $options[$option] = $value;
387            }
388        }
389        return $options;
390    }
391
392    /**
393     * Converts the current definition into an object suitable for the editor form's set_data()
394     *
395     * @param bool $addemptycriterion whether to add an empty criterion if the guide is completely empty (just being created)
396     * @return stdClass
397     */
398    public function get_definition_for_editing($addemptycriterion = false) {
399
400        $definition = $this->get_definition();
401        $properties = new stdClass();
402        $properties->areaid = $this->areaid;
403        if (isset($this->moduleinstance->grade)) {
404            $properties->modulegrade = $this->moduleinstance->grade;
405        }
406        if ($definition) {
407            foreach (array('id', 'name', 'description', 'descriptionformat', 'status') as $key) {
408                $properties->$key = $definition->$key;
409            }
410            $options = self::description_form_field_options($this->get_context());
411            $properties = file_prepare_standard_editor($properties, 'description', $options, $this->get_context(),
412                'grading', 'description', $definition->id);
413        }
414        $properties->guide = array('criteria' => array(), 'options' => $this->get_options(), 'comments' => array());
415        if (!empty($definition->guide_criteria)) {
416            $properties->guide['criteria'] = $definition->guide_criteria;
417        } else if (!$definition && $addemptycriterion) {
418            $properties->guide['criteria'] = array('addcriterion' => 1);
419        }
420        if (!empty($definition->guide_comments)) {
421            $properties->guide['comments'] = $definition->guide_comments;
422        } else if (!$definition && $addemptycriterion) {
423            $properties->guide['comments'] = array('addcomment' => 1);
424        }
425        return $properties;
426    }
427
428    /**
429     * Returns the form definition suitable for cloning into another area
430     *
431     * @see parent::get_definition_copy()
432     * @param gradingform_controller $target the controller of the new copy
433     * @return stdClass definition structure to pass to the target's {@link update_definition()}
434     */
435    public function get_definition_copy(gradingform_controller $target) {
436
437        $new = parent::get_definition_copy($target);
438        $old = $this->get_definition_for_editing();
439        $new->description_editor = $old->description_editor;
440        $new->guide = array('criteria' => array(), 'options' => $old->guide['options'], 'comments' => array());
441        $newcritid = 1;
442        foreach ($old->guide['criteria'] as $oldcritid => $oldcrit) {
443            unset($oldcrit['id']);
444            $new->guide['criteria']['NEWID'.$newcritid] = $oldcrit;
445            $newcritid++;
446        }
447        $newcomid = 1;
448        foreach ($old->guide['comments'] as $oldcritid => $oldcom) {
449            unset($oldcom['id']);
450            $new->guide['comments']['NEWID'.$newcomid] = $oldcom;
451            $newcomid++;
452        }
453        return $new;
454    }
455
456    /**
457     * Options for displaying the guide description field in the form
458     *
459     * @param context $context
460     * @return array options for the form description field
461     */
462    public static function description_form_field_options($context) {
463        global $CFG;
464        return array(
465            'maxfiles' => -1,
466            'maxbytes' => get_max_upload_file_size($CFG->maxbytes),
467            'context'  => $context,
468        );
469    }
470
471    /**
472     * Formats the definition description for display on page
473     *
474     * @return string
475     */
476    public function get_formatted_description() {
477        if (!isset($this->definition->description)) {
478            return '';
479        }
480        $context = $this->get_context();
481
482        $options = self::description_form_field_options($this->get_context());
483        $description = file_rewrite_pluginfile_urls($this->definition->description, 'pluginfile.php', $context->id,
484            'grading', 'description', $this->definition->id, $options);
485
486        $formatoptions = array(
487            'noclean' => false,
488            'trusted' => false,
489            'filter' => true,
490            'context' => $context
491        );
492        return format_text($description, $this->definition->descriptionformat, $formatoptions);
493    }
494
495    /**
496     * Returns the guide plugin renderer
497     *
498     * @param moodle_page $page the target page
499     * @return gradingform_guide_renderer
500     */
501    public function get_renderer(moodle_page $page) {
502        return $page->get_renderer('gradingform_'. $this->get_method_name());
503    }
504
505    /**
506     * Returns the HTML code displaying the preview of the grading form
507     *
508     * @param moodle_page $page the target page
509     * @return string
510     */
511    public function render_preview(moodle_page $page) {
512
513        if (!$this->is_form_defined()) {
514            throw new coding_exception('It is the caller\'s responsibility to make sure that the form is actually defined');
515        }
516
517        // Check if current user is able to see preview
518        $options = $this->get_options();
519        if (empty($options['alwaysshowdefinition']) && !has_capability('moodle/grade:managegradingforms', $page->context))  {
520            return '';
521        }
522
523        $criteria = $this->definition->guide_criteria;
524        $comments = $this->definition->guide_comments;
525        $output = $this->get_renderer($page);
526
527        $guide = '';
528        $guide .= $output->box($this->get_formatted_description(), 'gradingform_guide-description');
529        if (has_capability('moodle/grade:managegradingforms', $page->context)) {
530            $guide .= $output->display_guide_mapping_explained($this->get_min_max_score());
531            $guide .= $output->display_guide($criteria, $comments, $options, self::DISPLAY_PREVIEW, 'guide');
532        } else {
533            $guide .= $output->display_guide($criteria, $comments, $options, self::DISPLAY_PREVIEW_GRADED, 'guide');
534        }
535
536        return $guide;
537    }
538
539    /**
540     * Deletes the guide definition and all the associated information
541     */
542    protected function delete_plugin_definition() {
543        global $DB;
544
545        // Get the list of instances.
546        $instances = array_keys($DB->get_records('grading_instances', array('definitionid' => $this->definition->id), '', 'id'));
547        // Delete all fillings.
548        $DB->delete_records_list('gradingform_guide_fillings', 'instanceid', $instances);
549        // Delete instances.
550        $DB->delete_records_list('grading_instances', 'id', $instances);
551        // Get the list of criteria records.
552        $criteria = array_keys($DB->get_records('gradingform_guide_criteria',
553            array('definitionid' => $this->definition->id), '', 'id'));
554        // Delete critera.
555        $DB->delete_records_list('gradingform_guide_criteria', 'id', $criteria);
556        // Delete comments.
557        $DB->delete_records('gradingform_guide_comments', array('definitionid' => $this->definition->id));
558    }
559
560    /**
561     * If instanceid is specified and grading instance exists and it is created by this rater for
562     * this item, this instance is returned.
563     * If there exists a draft for this raterid+itemid, take this draft (this is the change from parent)
564     * Otherwise new instance is created for the specified rater and itemid
565     *
566     * @param int $instanceid
567     * @param int $raterid
568     * @param int $itemid
569     * @return gradingform_instance
570     */
571    public function get_or_create_instance($instanceid, $raterid, $itemid) {
572        global $DB;
573        if ($instanceid &&
574                $instance = $DB->get_record('grading_instances',
575                    array('id'  => $instanceid, 'raterid' => $raterid, 'itemid' => $itemid), '*', IGNORE_MISSING)) {
576            return $this->get_instance($instance);
577        }
578        if ($itemid && $raterid) {
579            $params = array('definitionid' => $this->definition->id, 'raterid' => $raterid, 'itemid' => $itemid);
580            if ($rs = $DB->get_records('grading_instances', $params, 'timemodified DESC', '*', 0, 1)) {
581                $record = reset($rs);
582                $currentinstance = $this->get_current_instance($raterid, $itemid);
583                if ($record->status == gradingform_guide_instance::INSTANCE_STATUS_INCOMPLETE &&
584                        (!$currentinstance || $record->timemodified > $currentinstance->get_data('timemodified'))) {
585                    $record->isrestored = true;
586                    return $this->get_instance($record);
587                }
588            }
589        }
590        return $this->create_instance($raterid, $itemid);
591    }
592
593    /**
594     * Returns html code to be included in student's feedback.
595     *
596     * @param moodle_page $page
597     * @param int $itemid
598     * @param array $gradinginfo result of function grade_get_grades
599     * @param string $defaultcontent default string to be returned if no active grading is found
600     * @param bool $cangrade whether current user has capability to grade in this context
601     * @return string
602     */
603    public function render_grade($page, $itemid, $gradinginfo, $defaultcontent, $cangrade) {
604        return $this->get_renderer($page)->display_instances($this->get_active_instances($itemid), $defaultcontent, $cangrade);
605    }
606
607    // Full-text search support.
608
609    /**
610     * Prepare the part of the search query to append to the FROM statement
611     *
612     * @param string $gdid the alias of grading_definitions.id column used by the caller
613     * @return string
614     */
615    public static function sql_search_from_tables($gdid) {
616        return " LEFT JOIN {gradingform_guide_criteria} gc ON (gc.definitionid = $gdid)";
617    }
618
619    /**
620     * Prepare the parts of the SQL WHERE statement to search for the given token
621     *
622     * The returned array cosists of the list of SQL comparions and the list of
623     * respective parameters for the comparisons. The returned chunks will be joined
624     * with other conditions using the OR operator.
625     *
626     * @param string $token token to search for
627     * @return array An array containing two more arrays
628     *     Array of search SQL fragments
629     *     Array of params for the search fragments
630     */
631    public static function sql_search_where($token) {
632        global $DB;
633
634        $subsql = array();
635        $params = array();
636
637        // Search in guide criteria description.
638        $subsql[] = $DB->sql_like('gc.description', '?', false, false);
639        $params[] = '%'.$DB->sql_like_escape($token).'%';
640
641        return array($subsql, $params);
642    }
643
644    /**
645     * Calculates and returns the possible minimum and maximum score (in points) for this guide
646     *
647     * @return array
648     */
649    public function get_min_max_score() {
650        if (!$this->is_form_available()) {
651            return null;
652        }
653        $returnvalue = array('minscore' => 0, 'maxscore' => 0);
654        $maxscore = 0;
655        foreach ($this->get_definition()->guide_criteria as $id => $criterion) {
656            $maxscore += $criterion['maxscore'];
657        }
658        $returnvalue['maxscore'] = $maxscore;
659        $returnvalue['minscore'] = 0;
660        if (!$this->is_shared_template()) {
661            $fieldname = \core_grades\component_gradeitems::get_field_name_for_itemname($this->component, $this->area, 'grade');
662            if (!empty($this->moduleinstance->{$fieldname})) {
663                $graderange = make_grades_menu($this->moduleinstance->{$fieldname});
664                $returnvalue['modulegrade'] = count($graderange) - 1;
665            }
666        }
667        return $returnvalue;
668    }
669
670    /**
671     * @return array An array containing 2 key/value pairs which hold the external_multiple_structure
672     * for the 'guide_criteria' and the 'guide_comments'.
673     * @see gradingform_controller::get_external_definition_details()
674     * @since Moodle 2.5
675     */
676    public static function get_external_definition_details() {
677        $guide_criteria = new external_multiple_structure(
678                              new external_single_structure(
679                                  array(
680                                      'id'   => new external_value(PARAM_INT, 'criterion id', VALUE_OPTIONAL),
681                                      'sortorder' => new external_value(PARAM_INT, 'sortorder', VALUE_OPTIONAL),
682                                      'description' => new external_value(PARAM_RAW, 'description', VALUE_OPTIONAL),
683                                      'descriptionformat' => new external_format_value('description', VALUE_OPTIONAL),
684                                      'shortname' => new external_value(PARAM_TEXT, 'description'),
685                                      'descriptionmarkers' => new external_value(PARAM_RAW, 'markers description', VALUE_OPTIONAL),
686                                      'descriptionmarkersformat' => new external_format_value('descriptionmarkers', VALUE_OPTIONAL),
687                                      'maxscore' => new external_value(PARAM_FLOAT, 'maximum score')
688                                      )
689                                  )
690        );
691        $guide_comments = new external_multiple_structure(
692                              new external_single_structure(
693                                  array(
694                                      'id'   => new external_value(PARAM_INT, 'criterion id', VALUE_OPTIONAL),
695                                      'sortorder' => new external_value(PARAM_INT, 'sortorder', VALUE_OPTIONAL),
696                                      'description' => new external_value(PARAM_RAW, 'description', VALUE_OPTIONAL),
697                                      'descriptionformat' => new external_format_value('description', VALUE_OPTIONAL)
698                                   )
699                              ), 'comments', VALUE_OPTIONAL
700        );
701        return array('guide_criteria' => $guide_criteria, 'guide_comments' => $guide_comments);
702    }
703
704    /**
705     * Returns an array that defines the structure of the guide's filling. This function is used by
706     * the web service function core_grading_external::get_gradingform_instances().
707     *
708     * @return An array containing a single key/value pair with the 'criteria' external_multiple_structure
709     * @see gradingform_controller::get_external_instance_filling_details()
710     * @since Moodle 2.6
711     */
712    public static function get_external_instance_filling_details() {
713        $criteria = new external_multiple_structure(
714            new external_single_structure(
715                array(
716                    'id' => new external_value(PARAM_INT, 'filling id'),
717                    'criterionid' => new external_value(PARAM_INT, 'criterion id'),
718                    'levelid' => new external_value(PARAM_INT, 'level id', VALUE_OPTIONAL),
719                    'remark' => new external_value(PARAM_RAW, 'remark', VALUE_OPTIONAL),
720                    'remarkformat' => new external_format_value('remark', VALUE_OPTIONAL),
721                    'score' => new external_value(PARAM_FLOAT, 'maximum score')
722                )
723            ), 'filling', VALUE_OPTIONAL
724        );
725        return array ('criteria' => $criteria);
726    }
727
728}
729
730/**
731 * Class to manage one guide grading instance. Stores information and performs actions like
732 * update, copy, validate, submit, etc.
733 *
734 * @package    gradingform_guide
735 * @copyright  2012 Dan Marsden <dan@danmarsden.com>
736 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
737 */
738class gradingform_guide_instance extends gradingform_instance {
739
740    /** @var array */
741    protected $guide;
742
743    /** @var array An array of validation errors */
744    protected $validationerrors = array();
745
746    /**
747     * Deletes this (INCOMPLETE) instance from database.
748     */
749    public function cancel() {
750        global $DB;
751        parent::cancel();
752        $DB->delete_records('gradingform_guide_fillings', array('instanceid' => $this->get_id()));
753    }
754
755    /**
756     * Duplicates the instance before editing (optionally substitutes raterid and/or itemid with
757     * the specified values)
758     *
759     * @param int $raterid value for raterid in the duplicate
760     * @param int $itemid value for itemid in the duplicate
761     * @return int id of the new instance
762     */
763    public function copy($raterid, $itemid) {
764        global $DB;
765        $instanceid = parent::copy($raterid, $itemid);
766        $currentgrade = $this->get_guide_filling();
767        foreach ($currentgrade['criteria'] as $criterionid => $record) {
768            $params = array('instanceid' => $instanceid, 'criterionid' => $criterionid,
769                'score' => $record['score'], 'remark' => $record['remark'], 'remarkformat' => $record['remarkformat']);
770            $DB->insert_record('gradingform_guide_fillings', $params);
771        }
772        return $instanceid;
773    }
774
775    /**
776     * Determines whether the submitted form was empty.
777     *
778     * @param array $elementvalue value of element submitted from the form
779     * @return boolean true if the form is empty
780     */
781    public function is_empty_form($elementvalue) {
782        $criteria = $this->get_controller()->get_definition()->guide_criteria;
783        foreach ($criteria as $id => $criterion) {
784            $score = $elementvalue['criteria'][$id]['score'];
785            $remark = $elementvalue['criteria'][$id]['remark'];
786
787            if ((isset($score) && $score !== '')
788                    || ((isset($remark) && $remark !== ''))) {
789                return false;
790            }
791        }
792        return true;
793    }
794
795    /**
796     * Validates that guide is fully completed and contains valid grade on each criterion
797     *
798     * @param array $elementvalue value of element as came in form submit
799     * @return boolean true if the form data is validated and contains no errors
800     */
801    public function validate_grading_element($elementvalue) {
802        $criteria = $this->get_controller()->get_definition()->guide_criteria;
803        if (!isset($elementvalue['criteria']) || !is_array($elementvalue['criteria']) ||
804            count($elementvalue['criteria']) < count($criteria)) {
805            return false;
806        }
807        // Reset validation errors.
808        $this->validationerrors = null;
809        foreach ($criteria as $id => $criterion) {
810            if (!isset($elementvalue['criteria'][$id]['score'])
811                    || $criterion['maxscore'] < $elementvalue['criteria'][$id]['score']
812                    || !is_numeric($elementvalue['criteria'][$id]['score'])
813                    || $elementvalue['criteria'][$id]['score'] < 0) {
814                $this->validationerrors[$id]['score'] = $elementvalue['criteria'][$id]['score'];
815            }
816        }
817        if (!empty($this->validationerrors)) {
818            return false;
819        }
820        return true;
821    }
822
823    /**
824     * Retrieves from DB and returns the data how this guide was filled
825     *
826     * @param bool $force whether to force DB query even if the data is cached
827     * @return array
828     */
829    public function get_guide_filling($force = false) {
830        global $DB;
831        if ($this->guide === null || $force) {
832            $records = $DB->get_records('gradingform_guide_fillings', array('instanceid' => $this->get_id()));
833            $this->guide = array('criteria' => array());
834            foreach ($records as $record) {
835                $record->score = (float)$record->score; // Strip trailing 0.
836                $this->guide['criteria'][$record->criterionid] = (array)$record;
837            }
838        }
839        return $this->guide;
840    }
841
842    /**
843     * Updates the instance with the data received from grading form. This function may be
844     * called via AJAX when grading is not yet completed, so it does not change the
845     * status of the instance.
846     *
847     * @param array $data
848     */
849    public function update($data) {
850        global $DB;
851        $currentgrade = $this->get_guide_filling();
852        parent::update($data);
853
854        foreach ($data['criteria'] as $criterionid => $record) {
855            if (!array_key_exists($criterionid, $currentgrade['criteria'])) {
856                $newrecord = array('instanceid' => $this->get_id(), 'criterionid' => $criterionid,
857                    'score' => $record['score'], 'remarkformat' => FORMAT_MOODLE);
858                if (isset($record['remark'])) {
859                    $newrecord['remark'] = $record['remark'];
860                }
861                $DB->insert_record('gradingform_guide_fillings', $newrecord);
862            } else {
863                $newrecord = array('id' => $currentgrade['criteria'][$criterionid]['id']);
864                foreach (array('score', 'remark'/*, 'remarkformat' TODO */) as $key) {
865                    if (isset($record[$key]) && $currentgrade['criteria'][$criterionid][$key] != $record[$key]) {
866                        $newrecord[$key] = $record[$key];
867                    }
868                }
869                if (count($newrecord) > 1) {
870                    $DB->update_record('gradingform_guide_fillings', $newrecord);
871                }
872            }
873        }
874        foreach ($currentgrade['criteria'] as $criterionid => $record) {
875            if (!array_key_exists($criterionid, $data['criteria'])) {
876                $DB->delete_records('gradingform_guide_fillings', array('id' => $record['id']));
877            }
878        }
879        $this->get_guide_filling(true);
880    }
881
882    /**
883     * Removes the attempt from the gradingform_guide_fillings table
884     * @param array $data the attempt data
885     */
886    public function clear_attempt($data) {
887        global $DB;
888
889        foreach ($data['criteria'] as $criterionid => $record) {
890            $DB->delete_records('gradingform_guide_fillings',
891                array('criterionid' => $criterionid, 'instanceid' => $this->get_id()));
892        }
893    }
894
895    /**
896     * Calculates the grade to be pushed to the gradebook
897     *
898     * @return float|int the valid grade from $this->get_controller()->get_grade_range()
899     */
900    public function get_grade() {
901        $grade = $this->get_guide_filling();
902
903        if (!($scores = $this->get_controller()->get_min_max_score()) || $scores['maxscore'] <= $scores['minscore']) {
904            return -1;
905        }
906
907        $graderange = array_keys($this->get_controller()->get_grade_range());
908        if (empty($graderange)) {
909            return -1;
910        }
911        sort($graderange);
912        $mingrade = $graderange[0];
913        $maxgrade = $graderange[count($graderange) - 1];
914
915        $curscore = 0;
916        foreach ($grade['criteria'] as $record) {
917            $curscore += $record['score'];
918        }
919        $gradeoffset = ($curscore-$scores['minscore'])/($scores['maxscore']-$scores['minscore'])*
920            ($maxgrade-$mingrade);
921        if ($this->get_controller()->get_allow_grade_decimals()) {
922            return $gradeoffset + $mingrade;
923        }
924        return round($gradeoffset, 0) + $mingrade;
925    }
926
927    /**
928     * Returns html for form element of type 'grading'.
929     *
930     * @param moodle_page $page
931     * @param MoodleQuickForm_grading $gradingformelement
932     * @return string
933     */
934    public function render_grading_element($page, $gradingformelement) {
935        if (!$gradingformelement->_flagFrozen) {
936            $module = array('name'=>'gradingform_guide', 'fullpath'=>'/grade/grading/form/guide/js/guide.js');
937            $page->requires->js_init_call('M.gradingform_guide.init', array(
938                array('name' => $gradingformelement->getName())), true, $module);
939            $mode = gradingform_guide_controller::DISPLAY_EVAL;
940        } else {
941            if ($gradingformelement->_persistantFreeze) {
942                $mode = gradingform_guide_controller::DISPLAY_EVAL_FROZEN;
943            } else {
944                $mode = gradingform_guide_controller::DISPLAY_REVIEW;
945            }
946        }
947        $criteria = $this->get_controller()->get_definition()->guide_criteria;
948        $comments = $this->get_controller()->get_definition()->guide_comments;
949        $options = $this->get_controller()->get_options();
950        $value = $gradingformelement->getValue();
951        $html = '';
952        if ($value === null) {
953            $value = $this->get_guide_filling();
954        } else if (!$this->validate_grading_element($value)) {
955            $html .= html_writer::tag('div', get_string('guidenotcompleted', 'gradingform_guide'),
956                array('class' => 'gradingform_guide-error'));
957            if (!empty($this->validationerrors)) {
958                foreach ($this->validationerrors as $id => $err) {
959                    $a = new stdClass();
960                    $a->criterianame = s($criteria[$id]['shortname']);
961                    $a->maxscore = $criteria[$id]['maxscore'];
962                    if ($this->validationerrors[$id]['score'] < 0) {
963                        $html .= html_writer::tag('div', get_string('err_scoreisnegative', 'gradingform_guide', $a),
964                        array('class' => 'gradingform_guide-error'));
965                    } else {
966                        $html .= html_writer::tag('div', get_string('err_scoreinvalid', 'gradingform_guide', $a),
967                        array('class' => 'gradingform_guide-error'));
968                    }
969                }
970            }
971        }
972        $currentinstance = $this->get_current_instance();
973        if ($currentinstance && $currentinstance->get_status() == gradingform_instance::INSTANCE_STATUS_NEEDUPDATE) {
974            $html .= html_writer::tag('div', get_string('needregrademessage', 'gradingform_guide'),
975                array('class' => 'gradingform_guide-regrade', 'role' => 'alert'));
976        }
977        $haschanges = false;
978        if ($currentinstance) {
979            $curfilling = $currentinstance->get_guide_filling();
980            foreach ($curfilling['criteria'] as $criterionid => $curvalues) {
981                $value['criteria'][$criterionid]['score'] = $curvalues['score'];
982                $newremark = null;
983                $newscore = null;
984                if (isset($value['criteria'][$criterionid]['remark'])) {
985                    $newremark = $value['criteria'][$criterionid]['remark'];
986                }
987                if (isset($value['criteria'][$criterionid]['score'])) {
988                    $newscore = $value['criteria'][$criterionid]['score'];
989                }
990                if ($newscore != $curvalues['score'] || $newremark != $curvalues['remark']) {
991                    $haschanges = true;
992                }
993            }
994        }
995        if ($this->get_data('isrestored') && $haschanges) {
996            $html .= html_writer::tag('div', get_string('restoredfromdraft', 'gradingform_guide'),
997                array('class' => 'gradingform_guide-restored'));
998        }
999        $html .= html_writer::tag('div', $this->get_controller()->get_formatted_description(),
1000            array('class' => 'gradingform_guide-description'));
1001        $html .= $this->get_controller()->get_renderer($page)->display_guide($criteria, $comments, $options, $mode,
1002            $gradingformelement->getName(), $value, $this->validationerrors);
1003        return $html;
1004    }
1005}
1006
1007/**
1008 * Get the icon mapping for font-awesome.
1009 *
1010 * @return array
1011 */
1012function gradingform_guide_get_fontawesome_icon_map(): array {
1013    return [
1014        'gradingform_guide:info' => 'fa-info-circle',
1015        'gradingform_guide:plus' => 'fa-plus',
1016    ];
1017}
1018