first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
@@ -0,0 +1,78 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy Subsystem implementation for quiz_overview..
*
* @package quiz_overview
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace quiz_overview\privacy;
use \core_privacy\local\request\writer;
use \core_privacy\local\request\transform;
use \core_privacy\local\metadata\collection;
use \core_privacy\manager;
defined('MOODLE_INTERNAL') || die();
/**
* Privacy Subsystem implementation for quiz_overview..
*
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\user_preference_provider {
/**
* Returns meta data about this system.
*
* @param collection $collection The initialised collection to add items to.
* @return collection A listing of user data stored through this system.
*/
public static function get_metadata(collection $collection): collection {
$collection->add_user_preference('quiz_overview_slotmarks', 'privacy:metadata:preference:quiz_overview_slotmarks');
return $collection;
}
/**
* Export all user preferences for the plugin.
*
* @param int $userid The userid of the user whose data is to be exported.
*/
public static function export_user_preferences(int $userid) {
$preference = get_user_preferences('quiz_overview_slotmarks', null, $userid);
if (null !== $preference) {
if (empty($preference)) {
$description = get_string('privacy:preference:slotmarks:no', 'quiz_overview');
} else {
$description = get_string('privacy:preference:slotmarks:yes', 'quiz_overview');
}
writer::export_user_preference(
'quiz_overview',
'slotmarks',
transform::yesno($preference),
$description
);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Post-install script for the quiz grades report.
* @package quiz_overview
* @copyright 2013 Tim Hunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Post-install script
*/
function xmldb_quiz_overview_install() {
global $DB;
$record = new stdClass();
$record->name = 'overview';
$record->displayorder = '10000';
$DB->insert_record('quiz_reports', $record);
}
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="mod/quiz/report/overview/db" VERSION="20180319" COMMENT="XMLDB file for Moodle mod/quiz/report/overview"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../../../lib/xmldb/xmldb.xsd"
>
<TABLES>
<TABLE NAME="quiz_overview_regrades" COMMENT="This table records which question attempts need regrading and the grade they will be regraded to.">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="questionusageid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Foreign key references question_usages.id, or equivalently quiz_attempt.uniqueid."/>
<FIELD NAME="slot" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Foreign key, references question_attempts.slot"/>
<FIELD NAME="newfraction" TYPE="number" LENGTH="12" NOTNULL="false" SEQUENCE="false" DECIMALS="7" COMMENT="The new fraction for this question_attempt after regrading."/>
<FIELD NAME="oldfraction" TYPE="number" LENGTH="12" NOTNULL="false" SEQUENCE="false" DECIMALS="7" COMMENT="The previous fraction for this question_attempt."/>
<FIELD NAME="regraded" TYPE="int" LENGTH="4" NOTNULL="true" SEQUENCE="false" COMMENT="set to 0 if element has just been regraded. Set to 1 if element has been marked as needing regrading."/>
<FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Timestamp of when this row was last modified."/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="questionusageid-slot" TYPE="foreign" FIELDS="questionusageid, slot" REFTABLE="question_attempts" REFFIELDS="questionusageid, slot"/>
</KEYS>
</TABLE>
</TABLES>
</XMLDB>
+43
View File
@@ -0,0 +1,43 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Quiz overview report upgrade script.
*
* @package quiz_overview
* @copyright 2008 Jamie Pratt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Quiz overview report upgrade function.
* @param number $oldversion
*/
function xmldb_quiz_overview_upgrade($oldversion) {
// Automatically generated Moodle v4.1.0 release upgrade line.
// Put any upgrade step following this.
// Automatically generated Moodle v4.2.0 release upgrade line.
// Put any upgrade step following this.
// Automatically generated Moodle v4.3.0 release upgrade line.
// Put any upgrade step following this.
// Automatically generated Moodle v4.4.0 release upgrade line.
// Put any upgrade step following this.
return true;
}
@@ -0,0 +1,80 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Strings for component 'quiz_overview', language 'en', branch 'MOODLE_20_STABLE'
*
* @package quiz_overview
* @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
$string['allattempts'] = 'Show all attempts';
$string['allattemptscontributetograde'] = 'All attempts contribute to final grade for user.';
$string['allstudents'] = 'Show all {$a}';
$string['attemptsonly'] = 'Show {$a} with attempts only';
$string['attemptsprepage'] = 'Attempts shown per page';
$string['deleteselected'] = 'Delete selected attempts';
$string['done'] = 'Done';
$string['err_failedtodeleteregrades'] = 'Failed to delete calculated attempt grades';
$string['err_failedtorecalculateattemptgrades'] = 'Failed to recalculate attempt grades';
$string['highlightinggraded'] = 'The user attempt that contributes to final grade is highlighted.';
$string['needed'] = 'Needed';
$string['noattemptsonly'] = 'Show / download {$a} with no attempts only';
$string['noattemptstoregrade'] = 'No attempts need regrading';
$string['nogradepermission'] = 'You don\'t have permission to grade this quiz.';
$string['onlyoneattemptallowed'] = 'Only one attempt per user allowed on this quiz.';
$string['optallattempts'] = 'all attempts';
$string['optallstudents'] = 'all {$a} who have or have not attempted the quiz';
$string['optattemptsonly'] = '{$a} who have attempted the quiz';
$string['optnoattemptsonly'] = '{$a} who have not attempted the quiz';
$string['optonlyregradedattempts'] = 'that have been regraded / are marked as needing regrading';
$string['overview'] = 'Grades';
$string['overviewdownload'] = 'Overview download';
$string['overviewfilename'] = 'grades';
$string['overviewreport'] = 'Grades report';
$string['overviewreportgraph'] = 'Overall number of students achieving grade ranges';
$string['overviewreportgraphgroup'] = 'Number of students in group \'{$a}\' achieving grade ranges';
$string['pagesize'] = 'Page size';
$string['pluginname'] = 'Grades';
$string['preferencespage'] = 'Preferences just for this page';
$string['preferencessave'] = 'Show report';
$string['preferencesuser'] = 'Your preferences for this report';
$string['privacy:metadata:preference:quiz_overview_slotmarks'] = 'Whether to show marks for each question slot.';
$string['privacy:preference:slotmarks:yes'] = 'Marks are shown alongside the question slot.';
$string['privacy:preference:slotmarks:no'] = 'Marks are not shown alongside the question slot.';
$string['regrade'] = 'Regrade';
$string['regradeall'] = 'Regrade all';
$string['regradealldry'] = 'Dry run a full regrade';
$string['regradealldrydo'] = 'Regrade attempts marked as needing regrading ({$a})';
$string['regradealldrydogroup'] = 'Regrade attempts ({$a->countregradeneeded}) marked as needing regrading in group \'{$a->groupname}\'';
$string['regradealldrygroup'] = 'Dry run a full regrade for group \'{$a->groupname}\'';
$string['regradeallgroup'] = 'Full regrade for group \'{$a->groupname}\'';
$string['regradecomplete'] = 'Regrade completed';
$string['regradedsuccessfullyxofy'] = 'Finished regrading ({$a->done}/{$a->count})';
$string['regradeheader'] = 'Regrading';
$string['regradeselected'] = 'Regrade selected attempts';
$string['regradingattemptissue'] = 'Slot {$a->slot}: {$a->reason}';
$string['regradingattemptxofy'] = 'Regrading attempt ({$a->done}/{$a->count})';
$string['regradingattemptxofyproblem'] = 'The following questions could not be regraded in attempt {$a->attemptnum} by {$a->name} (id {$a->attemptid})';
$string['regradingattemptxofywithdetails'] = 'Regrading attempt ({$a->done}/{$a->count}) - Attempt {$a->attemptnum} by {$a->name} (id {$a->attemptid})';
$string['show'] = 'Show / download';
$string['showattempts'] = 'Only show / download attempts';
$string['showdetailedmarks'] = 'Marks for each question';
$string['showinggraded'] = 'Showing only the attempt graded for each user.';
$string['showinggradedandungraded'] = 'Showing graded and ungraded attempts for each user. The one attempt for each user that is graded is highlighted. The grading method for this quiz is {$a}.';
$string['studentingroup'] = '\'{$a->coursestudent}\' in group \'{$a->groupname}\'';
$string['studentingrouplong'] = '\'{$a->coursestudent}\' in this group';
@@ -0,0 +1,45 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
use mod_quiz\local\reports\attempts_report;
use mod_quiz\local\reports\attempts_report_options_form;
/**
* Quiz overview report settings form.
*
* @copyright 2008 Jamie Pratt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class quiz_overview_settings_form extends attempts_report_options_form {
protected function other_attempt_fields(MoodleQuickForm $mform) {
if (has_capability('mod/quiz:regrade', $this->_customdata['context'])) {
$mform->addElement('advcheckbox', 'onlyregraded', get_string('reportshowonly', 'quiz'),
get_string('optonlyregradedattempts', 'quiz_overview'));
$mform->disabledIf('onlyregraded', 'attempts', 'eq', attempts_report::ENROLLED_WITHOUT);
}
}
protected function other_preference_fields(MoodleQuickForm $mform) {
if (quiz_has_grades($this->_customdata['quiz'])) {
$mform->addElement('selectyesno', 'slotmarks',
get_string('showdetailedmarks', 'quiz_overview'));
} else {
$mform->addElement('hidden', 'slotmarks', 0);
$mform->setType('slotmarks', PARAM_INT);
}
}
}
@@ -0,0 +1,94 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
use mod_quiz\local\reports\attempts_report;
use mod_quiz\local\reports\attempts_report_options;
/**
* Class to store the options for a {@link quiz_overview_report}.
*
* @copyright 2012 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class quiz_overview_options extends attempts_report_options {
/** @var bool whether to show only attempt that need regrading. */
public $onlyregraded = false;
/** @var bool whether to show marks for each question (slot). */
public $slotmarks = true;
protected function get_url_params() {
$params = parent::get_url_params();
$params['onlyregraded'] = $this->onlyregraded;
$params['slotmarks'] = $this->slotmarks;
return $params;
}
public function get_initial_form_data() {
$toform = parent::get_initial_form_data();
$toform->onlyregraded = $this->onlyregraded;
$toform->slotmarks = $this->slotmarks;
return $toform;
}
public function setup_from_form_data($fromform) {
parent::setup_from_form_data($fromform);
$this->onlyregraded = !empty($fromform->onlyregraded);
$this->slotmarks = $fromform->slotmarks;
}
public function setup_from_params() {
parent::setup_from_params();
$this->onlyregraded = optional_param('onlyregraded', $this->onlyregraded, PARAM_BOOL);
$this->slotmarks = optional_param('slotmarks', $this->slotmarks, PARAM_BOOL);
}
public function setup_from_user_preferences() {
parent::setup_from_user_preferences();
$this->slotmarks = get_user_preferences('quiz_overview_slotmarks', $this->slotmarks);
}
public function update_user_preferences() {
parent::update_user_preferences();
if (quiz_has_grades($this->quiz)) {
set_user_preference('quiz_overview_slotmarks', $this->slotmarks);
}
}
public function resolve_dependencies() {
parent::resolve_dependencies();
if ($this->attempts == attempts_report::ENROLLED_WITHOUT) {
$this->onlyregraded = false;
}
if (!$this->usercanseegrades) {
$this->slotmarks = false;
}
// We only want to show the checkbox to delete attempts
// if the user has permissions and if the report mode is showing attempts.
$this->checkboxcolumn = has_any_capability(
['mod/quiz:regrade', 'mod/quiz:deleteattempts'], context_module::instance($this->cm->id))
&& ($this->attempts != attempts_report::ENROLLED_WITHOUT);
}
}
+374
View File
@@ -0,0 +1,374 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
use mod_quiz\local\reports\attempts_report_table;
use mod_quiz\quiz_attempt;
/**
* This is a table subclass for displaying the quiz grades report.
*
* @copyright 2008 Jamie Pratt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class quiz_overview_table extends attempts_report_table {
/** @var array used to store information about which questoins have been regraded. */
protected $regradedqs = [];
/**
* Constructor
* @param stdClass $quiz
* @param context $context
* @param string $qmsubselect
* @param quiz_overview_options $options
* @param \core\dml\sql_join $groupstudentsjoins
* @param \core\dml\sql_join $studentsjoins
* @param array $questions
* @param moodle_url $reporturl
*/
public function __construct($quiz, $context, $qmsubselect,
quiz_overview_options $options, \core\dml\sql_join $groupstudentsjoins,
\core\dml\sql_join $studentsjoins, $questions, $reporturl) {
parent::__construct('mod-quiz-report-overview-report', $quiz , $context,
$qmsubselect, $options, $groupstudentsjoins, $studentsjoins, $questions, $reporturl);
}
public function build_table() {
global $DB;
if (!$this->rawdata) {
return;
}
$this->strtimeformat = str_replace(',', ' ', get_string('strftimedatetime'));
parent::build_table();
// End of adding the data from attempts. Now add averages at bottom.
$this->add_separator();
if (!empty($this->groupstudentsjoins->joins)) {
$hasgroupstudents = $DB->record_exists_sql("
SELECT 1
FROM {user} u
{$this->groupstudentsjoins->joins}
WHERE {$this->groupstudentsjoins->wheres}
", $this->groupstudentsjoins->params);
if ($hasgroupstudents) {
$this->add_average_row(get_string('groupavg', 'grades'), $this->groupstudentsjoins);
}
}
if (!empty($this->studentsjoins->joins)) {
$hasstudents = $DB->record_exists_sql("
SELECT 1
FROM {user} u
{$this->studentsjoins->joins}
WHERE {$this->studentsjoins->wheres}
" , $this->studentsjoins->params);
if ($hasstudents) {
$this->add_average_row(get_string('overallaverage', 'grades'), $this->studentsjoins);
}
}
}
/**
* Calculate the average overall and question scores for a set of attempts at the quiz.
*
* @param string $label the title ot use for this row.
* @param \core\dml\sql_join $usersjoins to indicate a set of users.
* @return array of table cells that make up the average row.
*/
public function compute_average_row($label, \core\dml\sql_join $usersjoins) {
global $DB;
list($fields, $from, $where, $params) = $this->base_sql($usersjoins);
$record = $DB->get_record_sql("
SELECT AVG(quizaouter.sumgrades) AS grade, COUNT(quizaouter.sumgrades) AS numaveraged
FROM {quiz_attempts} quizaouter
JOIN (
SELECT DISTINCT quiza.id
FROM $from
WHERE $where
) relevant_attempt_ids ON quizaouter.id = relevant_attempt_ids.id
", $params);
$record->grade = quiz_rescale_grade($record->grade, $this->quiz, false);
if ($this->is_downloading()) {
$namekey = 'lastname';
} else {
$namekey = 'fullname';
}
$averagerow = [
$namekey => $label,
'sumgrades' => $this->format_average($record),
'feedbacktext' => strip_tags(quiz_report_feedback_for_grade(
$record->grade, $this->quiz->id, $this->context))
];
if ($this->options->slotmarks) {
$dm = new question_engine_data_mapper();
$qubaids = new qubaid_join("{quiz_attempts} quizaouter
JOIN (
SELECT DISTINCT quiza.id
FROM $from
WHERE $where
) relevant_attempt_ids ON quizaouter.id = relevant_attempt_ids.id",
'quizaouter.uniqueid', '1 = 1', $params);
$avggradebyq = $dm->load_average_marks($qubaids, array_keys($this->questions));
$averagerow += $this->format_average_grade_for_questions($avggradebyq);
}
return $averagerow;
}
/**
* Add an average grade row for a set of users.
*
* @param string $label the title ot use for this row.
* @param \core\dml\sql_join $usersjoins (joins, wheres, params) for the users to average over.
*/
protected function add_average_row($label, \core\dml\sql_join $usersjoins) {
$averagerow = $this->compute_average_row($label, $usersjoins);
$this->add_data_keyed($averagerow);
}
/**
* Helper userd by {@link add_average_row()}.
* @param array $gradeaverages the raw grades.
* @return array the (partial) row of data.
*/
protected function format_average_grade_for_questions($gradeaverages) {
$row = [];
if (!$gradeaverages) {
$gradeaverages = [];
}
foreach ($this->questions as $question) {
if (isset($gradeaverages[$question->slot]) && $question->maxmark > 0) {
$record = $gradeaverages[$question->slot];
$record->grade = quiz_rescale_grade(
$record->averagefraction * $question->maxmark, $this->quiz, false);
} else {
$record = new stdClass();
$record->grade = null;
$record->numaveraged = 0;
}
$row['qsgrade' . $question->slot] = $this->format_average($record, true);
}
return $row;
}
/**
* Format an entry in an average row.
* @param stdClass $record with fields grade and numaveraged.
* @param bool $question true if this is a question score, false if it is an overall score.
* @return string HTML fragment for an average score (with number of things included in the average).
*/
protected function format_average($record, $question = false) {
if (is_null($record->grade)) {
$average = '-';
} else if ($question) {
$average = quiz_format_question_grade($this->quiz, $record->grade);
} else {
$average = quiz_format_grade($this->quiz, $record->grade);
}
if ($this->download) {
return $average;
} else if (is_null($record->numaveraged) || $record->numaveraged == 0) {
return html_writer::tag('span', html_writer::tag('span',
$average, ['class' => 'average']), ['class' => 'avgcell']);
} else {
return html_writer::tag('span', html_writer::tag('span',
$average, ['class' => 'average']) . ' ' . html_writer::tag('span',
'(' . $record->numaveraged . ')', ['class' => 'count']),
['class' => 'avgcell']);
}
}
protected function submit_buttons() {
if (has_capability('mod/quiz:regrade', $this->context)) {
$regradebuttonparams = [
'type' => 'submit',
'class' => 'btn btn-secondary mr-1',
'name' => 'regrade',
'value' => get_string('regradeselected', 'quiz_overview'),
'data-action' => 'toggle',
'data-togglegroup' => $this->togglegroup,
'data-toggle' => 'action',
'disabled' => true
];
echo html_writer::empty_tag('input', $regradebuttonparams);
}
parent::submit_buttons();
}
public function col_sumgrades($attempt) {
if ($attempt->state != quiz_attempt::FINISHED) {
return '-';
}
$grade = quiz_rescale_grade($attempt->sumgrades, $this->quiz);
if ($this->is_downloading()) {
return $grade;
}
if (isset($this->regradedqs[$attempt->usageid])) {
$newsumgrade = 0;
$oldsumgrade = 0;
foreach ($this->questions as $question) {
if (isset($this->regradedqs[$attempt->usageid][$question->slot])) {
$newsumgrade += $this->regradedqs[$attempt->usageid]
[$question->slot]->newfraction * $question->maxmark;
$oldsumgrade += $this->regradedqs[$attempt->usageid]
[$question->slot]->oldfraction * $question->maxmark;
} else {
$newsumgrade += $this->lateststeps[$attempt->usageid]
[$question->slot]->fraction * $question->maxmark;
$oldsumgrade += $this->lateststeps[$attempt->usageid]
[$question->slot]->fraction * $question->maxmark;
}
}
$newsumgrade = quiz_rescale_grade($newsumgrade, $this->quiz);
$oldsumgrade = quiz_rescale_grade($oldsumgrade, $this->quiz);
$grade = html_writer::tag('del', $oldsumgrade) . '/' .
html_writer::empty_tag('br') . $newsumgrade;
}
return html_writer::link(new moodle_url('/mod/quiz/review.php',
['attempt' => $attempt->attempt]), $grade,
['title' => get_string('reviewattempt', 'quiz')]);
}
/**
* @param string $colname the name of the column.
* @param stdClass $attempt the row of data - see the SQL in display() in
* mod/quiz/report/overview/report.php to see what fields are present,
* and what they are called.
* @return string the contents of the cell.
*/
public function other_cols($colname, $attempt) {
if (!preg_match('/^qsgrade(\d+)$/', $colname, $matches)) {
return parent::other_cols($colname, $attempt);
}
$slot = $matches[1];
$question = $this->questions[$slot];
if (!isset($this->lateststeps[$attempt->usageid][$slot])) {
return '-';
}
$stepdata = $this->lateststeps[$attempt->usageid][$slot];
$state = question_state::get($stepdata->state);
if ($question->maxmark == 0) {
$grade = '-';
} else if (is_null($stepdata->fraction)) {
if ($state == question_state::$needsgrading) {
$grade = get_string('requiresgrading', 'question');
} else {
$grade = '-';
}
} else {
$grade = quiz_rescale_grade(
$stepdata->fraction * $question->maxmark, $this->quiz, 'question');
}
if ($this->is_downloading()) {
return $grade;
}
if (isset($this->regradedqs[$attempt->usageid][$slot])) {
$gradefromdb = $grade;
$newgrade = quiz_rescale_grade(
$this->regradedqs[$attempt->usageid][$slot]->newfraction * $question->maxmark,
$this->quiz, 'question');
$oldgrade = quiz_rescale_grade(
$this->regradedqs[$attempt->usageid][$slot]->oldfraction * $question->maxmark,
$this->quiz, 'question');
$grade = html_writer::tag('del', $oldgrade) . '/' .
html_writer::empty_tag('br') . $newgrade;
}
return $this->make_review_link($grade, $attempt, $slot);
}
public function col_regraded($attempt) {
if ($attempt->regraded == '') {
return '';
} else if ($attempt->regraded == 0) {
return get_string('needed', 'quiz_overview');
} else if ($attempt->regraded == 1) {
return get_string('done', 'quiz_overview');
}
}
protected function update_sql_after_count($fields, $from, $where, $params) {
$fields .= ", COALESCE((
SELECT MAX(qqr.regraded)
FROM {quiz_overview_regrades} qqr
WHERE qqr.questionusageid = quiza.uniqueid
), -1) AS regraded";
if ($this->options->onlyregraded) {
$where .= " AND COALESCE((
SELECT MAX(qqr.regraded)
FROM {quiz_overview_regrades} qqr
WHERE qqr.questionusageid = quiza.uniqueid
), -1) <> -1";
}
return [$fields, $from, $where, $params];
}
protected function requires_latest_steps_loaded() {
return $this->options->slotmarks;
}
protected function is_latest_step_column($column) {
if (preg_match('/^qsgrade([0-9]+)/', $column, $matches)) {
return $matches[1];
}
return false;
}
protected function get_required_latest_state_fields($slot, $alias) {
return "$alias.fraction * $alias.maxmark AS qsgrade$slot";
}
public function query_db($pagesize, $useinitialsbar = true) {
parent::query_db($pagesize, $useinitialsbar);
if ($this->options->slotmarks && has_capability('mod/quiz:regrade', $this->context)) {
$this->regradedqs = $this->get_regraded_questions();
}
}
/**
* Get all the questions in all the attempts being displayed that need regrading.
* @return array A two dimensional array $questionusageid => $slot => $regradeinfo.
*/
protected function get_regraded_questions() {
global $DB;
$qubaids = $this->get_qubaids_condition();
$regradedqs = $DB->get_records_select('quiz_overview_regrades',
'questionusageid ' . $qubaids->usage_id_in(), $qubaids->usage_id_in_params());
return quiz_report_index_by_keys($regradedqs, ['questionusageid', 'slot']);
}
}
+708
View File
@@ -0,0 +1,708 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This file defines the quiz overview report class.
*
* @package quiz_overview
* @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use mod_quiz\local\reports\attempts_report;
use mod_quiz\question\bank\qbank_helper;
use mod_quiz\quiz_attempt;
use mod_quiz\quiz_settings;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_options.php');
require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_form.php');
require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_table.php');
/**
* Quiz report subclass for the overview (grades) report.
*
* @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class quiz_overview_report extends attempts_report {
public function display($quiz, $cm, $course) {
global $DB, $PAGE;
list($currentgroup, $studentsjoins, $groupstudentsjoins, $allowedjoins) = $this->init(
'overview', 'quiz_overview_settings_form', $quiz, $cm, $course);
$options = new quiz_overview_options('overview', $quiz, $cm, $course);
if ($fromform = $this->form->get_data()) {
$options->process_settings_from_form($fromform);
} else {
$options->process_settings_from_params();
}
$this->form->set_data($options->get_initial_form_data());
// Load the required questions.
$questions = quiz_report_get_significant_questions($quiz);
// Prepare for downloading, if applicable.
$courseshortname = format_string($course->shortname, true,
['context' => context_course::instance($course->id)]);
$table = new quiz_overview_table($quiz, $this->context, $this->qmsubselect,
$options, $groupstudentsjoins, $studentsjoins, $questions, $options->get_url());
$filename = quiz_report_download_filename(get_string('overviewfilename', 'quiz_overview'),
$courseshortname, $quiz->name);
$table->is_downloading($options->download, $filename,
$courseshortname . ' ' . format_string($quiz->name, true));
if ($table->is_downloading()) {
raise_memory_limit(MEMORY_EXTRA);
}
$this->hasgroupstudents = false;
if (!empty($groupstudentsjoins->joins)) {
$sql = "SELECT DISTINCT u.id
FROM {user} u
$groupstudentsjoins->joins
WHERE $groupstudentsjoins->wheres";
$this->hasgroupstudents = $DB->record_exists_sql($sql, $groupstudentsjoins->params);
}
$hasstudents = false;
if (!empty($studentsjoins->joins)) {
$sql = "SELECT DISTINCT u.id
FROM {user} u
$studentsjoins->joins
WHERE $studentsjoins->wheres";
$hasstudents = $DB->record_exists_sql($sql, $studentsjoins->params);
}
if ($options->attempts == self::ALL_WITH) {
// This option is only available to users who can access all groups in
// groups mode, so setting allowed to empty (which means all quiz attempts
// are accessible, is not a security porblem.
$allowedjoins = new \core\dml\sql_join();
}
$this->process_actions($quiz, $cm, $currentgroup, $groupstudentsjoins, $allowedjoins, $options->get_url());
$hasquestions = quiz_has_questions($quiz->id);
// Start output.
if (!$table->is_downloading()) {
// Only print headers if not asked to download data.
$this->print_standard_header_and_messages($cm, $course, $quiz,
$options, $currentgroup, $hasquestions, $hasstudents);
// Print the display options.
$this->form->display();
}
$hasstudents = $hasstudents && (!$currentgroup || $this->hasgroupstudents);
if ($hasquestions && ($hasstudents || $options->attempts == self::ALL_WITH)) {
// Construct the SQL.
$table->setup_sql_queries($allowedjoins);
if (!$table->is_downloading()) {
// Output the regrade buttons.
if (has_capability('mod/quiz:regrade', $this->context)) {
$regradesneeded = $this->count_question_attempts_needing_regrade(
$quiz, $groupstudentsjoins);
if ($currentgroup) {
$a= new stdClass();
$a->groupname = format_string(groups_get_group_name($currentgroup), true, [
'context' => $this->context,
]);
$a->coursestudents = get_string('participants');
$a->countregradeneeded = $regradesneeded;
$regradealldrydolabel =
get_string('regradealldrydogroup', 'quiz_overview', $a);
$regradealldrylabel =
get_string('regradealldrygroup', 'quiz_overview', $a);
$regradealllabel =
get_string('regradeallgroup', 'quiz_overview', $a);
} else {
$regradealldrydolabel =
get_string('regradealldrydo', 'quiz_overview', $regradesneeded);
$regradealldrylabel =
get_string('regradealldry', 'quiz_overview');
$regradealllabel =
get_string('regradeall', 'quiz_overview');
}
$displayurl = new moodle_url($options->get_url(), ['sesskey' => sesskey()]);
echo '<div class="regradebuttons">';
echo '<form action="'.$displayurl->out_omit_querystring().'">';
echo '<div>';
echo html_writer::input_hidden_params($displayurl);
echo '<input type="submit" class="btn btn-secondary" name="regradeall" value="'.$regradealllabel.'"/>';
echo '<input type="submit" class="btn btn-secondary ml-1" name="regradealldry" value="' .
$regradealldrylabel . '"/>';
if ($regradesneeded) {
echo '<input type="submit" class="btn btn-secondary ml-1" name="regradealldrydo" value="' .
$regradealldrydolabel . '"/>';
}
echo '</div>';
echo '</form>';
echo '</div>';
}
// Print information on the grading method.
if ($strattempthighlight = quiz_report_highlighting_grading_method(
$quiz, $this->qmsubselect, $options->onlygraded)) {
echo '<div class="quizattemptcounts mt-3">' . $strattempthighlight . '</div>';
}
}
// Define table columns.
$columns = [];
$headers = [];
if (!$table->is_downloading() && $options->checkboxcolumn) {
$columnname = 'checkbox';
$columns[] = $columnname;
$headers[] = $table->checkbox_col_header($columnname);
}
$this->add_user_columns($table, $columns, $headers);
$this->add_state_column($columns, $headers);
$this->add_time_columns($columns, $headers);
$this->add_grade_columns($quiz, $options->usercanseegrades, $columns, $headers, false);
$this->add_grade_item_columns($options->usercanseegrades, $columns, $headers);
if (!$table->is_downloading() && has_capability('mod/quiz:regrade', $this->context) &&
$this->has_regraded_questions($table->sql->from, $table->sql->where, $table->sql->params)) {
$columns[] = 'regraded';
$headers[] = get_string('regrade', 'quiz_overview');
}
if ($options->slotmarks) {
foreach ($questions as $slot => $question) {
$columns[] = 'qsgrade' . $slot;
$header = get_string('qbrief', 'quiz', $question->displaynumber);
if (!$table->is_downloading()) {
$header .= '<br />';
} else {
$header .= ' ';
}
$header .= '/' . quiz_rescale_grade($question->maxmark, $quiz, 'question');
$headers[] = $header;
}
}
$this->set_up_table_columns($table, $columns, $headers, $this->get_base_url(), $options, false);
$table->set_attribute('class', 'generaltable generalbox grades');
$table->out($options->pagesize, true);
}
if (!$table->is_downloading() && $options->usercanseegrades) {
$output = $PAGE->get_renderer('mod_quiz');
list($bands, $bandwidth) = self::get_bands_count_and_width($quiz);
$labels = self::get_bands_labels($bands, $bandwidth, $quiz);
if ($currentgroup && $this->hasgroupstudents) {
$sql = "SELECT qg.id
FROM {quiz_grades} qg
JOIN {user} u on u.id = qg.userid
{$groupstudentsjoins->joins}
WHERE qg.quiz = $quiz->id AND {$groupstudentsjoins->wheres}";
if ($DB->record_exists_sql($sql, $groupstudentsjoins->params)) {
$data = quiz_report_grade_bands($bandwidth, $bands, $quiz->id, $groupstudentsjoins);
$chart = self::get_chart($labels, $data);
$groupname = format_string(groups_get_group_name($currentgroup), true, [
'context' => $this->context,
]);
$graphname = get_string('overviewreportgraphgroup', 'quiz_overview', $groupname);
// Numerical range data should display in LTR even for RTL languages.
echo $output->chart($chart, $graphname, ['dir' => 'ltr']);
}
}
if ($DB->record_exists('quiz_grades', ['quiz' => $quiz->id])) {
$data = quiz_report_grade_bands($bandwidth, $bands, $quiz->id, new \core\dml\sql_join());
$chart = self::get_chart($labels, $data);
$graphname = get_string('overviewreportgraph', 'quiz_overview');
// Numerical range data should display in LTR even for RTL languages.
echo $output->chart($chart, $graphname, ['dir' => 'ltr']);
}
}
return true;
}
/**
* Extends parent function processing any submitted actions.
*
* @param stdClass $quiz
* @param stdClass $cm
* @param int $currentgroup
* @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params)
* @param \core\dml\sql_join $allowedjoins (joins, wheres, params)
* @param moodle_url $redirecturl
*/
protected function process_actions($quiz, $cm, $currentgroup, \core\dml\sql_join $groupstudentsjoins,
\core\dml\sql_join $allowedjoins, $redirecturl) {
parent::process_actions($quiz, $cm, $currentgroup, $groupstudentsjoins, $allowedjoins, $redirecturl);
if (empty($currentgroup) || $this->hasgroupstudents) {
if (optional_param('regrade', 0, PARAM_BOOL) && confirm_sesskey()) {
if ($attemptids = optional_param_array('attemptid', [], PARAM_INT)) {
$this->start_regrade($quiz, $cm);
$this->regrade_attempts($quiz, false, $groupstudentsjoins, $attemptids);
$this->finish_regrade($redirecturl);
}
}
}
if (optional_param('regradeall', 0, PARAM_BOOL) && confirm_sesskey()) {
$this->start_regrade($quiz, $cm);
$this->regrade_attempts($quiz, false, $groupstudentsjoins);
$this->finish_regrade($redirecturl);
} else if (optional_param('regradealldry', 0, PARAM_BOOL) && confirm_sesskey()) {
$this->start_regrade($quiz, $cm);
$this->regrade_attempts($quiz, true, $groupstudentsjoins);
$this->finish_regrade($redirecturl);
} else if (optional_param('regradealldrydo', 0, PARAM_BOOL) && confirm_sesskey()) {
$this->start_regrade($quiz, $cm);
$this->regrade_attempts_needing_it($quiz, $groupstudentsjoins);
$this->finish_regrade($redirecturl);
}
}
/**
* Check necessary capabilities, and start the display of the regrade progress page.
* @param stdClass $quiz the quiz settings.
* @param stdClass $cm the cm object for the quiz.
*/
protected function start_regrade($quiz, $cm) {
require_capability('mod/quiz:regrade', $this->context);
$this->print_header_and_tabs(
$cm,
get_course($cm->course),
$quiz,
$this->mode
);
}
/**
* Finish displaying the regrade progress page.
* @param moodle_url $nexturl where to send the user after the regrade.
* @uses exit. This method never returns.
*/
protected function finish_regrade($nexturl) {
global $OUTPUT;
\core\notification::success(get_string('regradecomplete', 'quiz_overview'));
echo $OUTPUT->continue_button($nexturl);
echo $OUTPUT->footer();
die();
}
/**
* Unlock the session and allow the regrading process to run in the background.
*/
protected function unlock_session() {
\core\session\manager::write_close();
ignore_user_abort(true);
}
/**
* Regrade a particular quiz attempt. Either for real ($dryrun = false), or
* as a pretend regrade to see which fractions would change. The outcome is
* stored in the quiz_overview_regrades table.
*
* Note, $attempt is not upgraded in the database. The caller needs to do that.
* However, $attempt->sumgrades is updated, if this is not a dry run.
*
* @param stdClass $attempt the quiz attempt to regrade.
* @param bool $dryrun if true, do a pretend regrade, otherwise do it for real.
* @param array $slots if null, regrade all questions, otherwise, just regrade
* the questions with those slots.
* @return array messages array with keys slot number, and values reasons why that slot cannot be regraded.
*/
public function regrade_attempt($attempt, $dryrun = false, $slots = null): array {
global $DB;
// Need more time for a quiz with many questions.
core_php_time_limit::raise(300);
$transaction = $DB->start_delegated_transaction();
$quba = question_engine::load_questions_usage_by_activity($attempt->uniqueid);
$versioninformation = qbank_helper::get_version_information_for_questions_in_attempt(
$attempt, $this->context);
if (is_null($slots)) {
$slots = $quba->get_slots();
}
$messages = [];
$finished = $attempt->state == quiz_attempt::FINISHED;
foreach ($slots as $slot) {
$qqr = new stdClass();
$qqr->oldfraction = $quba->get_question_fraction($slot);
$otherquestionversion = question_bank::load_question($versioninformation[$slot]->newquestionid);
$message = $quba->validate_can_regrade_with_other_version($slot, $otherquestionversion);
if ($message) {
$messages[$slot] = $message;
continue;
}
$quba->regrade_question($slot, $finished, null, $otherquestionversion);
$qqr->newfraction = $quba->get_question_fraction($slot);
if (abs($qqr->oldfraction - $qqr->newfraction) > 1e-7) {
$qqr->questionusageid = $quba->get_id();
$qqr->slot = $slot;
$qqr->regraded = empty($dryrun);
$qqr->timemodified = time();
$DB->insert_record('quiz_overview_regrades', $qqr, false);
}
}
if (!$dryrun) {
question_engine::save_questions_usage_by_activity($quba);
$params = [
'objectid' => $attempt->id,
'relateduserid' => $attempt->userid,
'context' => $this->context,
'other' => [
'quizid' => $attempt->quiz
]
];
$event = \mod_quiz\event\attempt_regraded::create($params);
$event->trigger();
}
$transaction->allow_commit();
// Really, PHP should not need this hint, but without this, we just run out of memory.
$quba = null;
$transaction = null;
gc_collect_cycles();
return $messages;
}
/**
* Regrade attempts for this quiz, exactly which attempts are regraded is
* controlled by the parameters.
*
* @param stdClass $quiz the quiz settings.
* @param bool $dryrun if true, do a pretend regrade, otherwise do it for real.
* @param \core\dml\sql_join|null $groupstudentsjoins empty for all attempts, otherwise regrade attempts
* for these users.
* @param array $attemptids blank for all attempts, otherwise only regrade
* attempts whose id is in this list.
*/
protected function regrade_attempts($quiz, $dryrun = false,
core\dml\sql_join $groupstudentsjoins = null, $attemptids = []) {
global $DB;
$this->unlock_session();
$userfieldsapi = \core_user\fields::for_name();
$sql = "SELECT quiza.*, " . $userfieldsapi->get_sql('u', false, '', '', false)->selects . "
FROM {quiz_attempts} quiza
JOIN {user} u ON u.id = quiza.userid";
$where = "quiz = :qid AND preview = 0";
$params = ['qid' => $quiz->id];
if ($this->hasgroupstudents && !empty($groupstudentsjoins->joins)) {
$sql .= "\n{$groupstudentsjoins->joins}";
$where .= " AND {$groupstudentsjoins->wheres}";
$params += $groupstudentsjoins->params;
}
if ($attemptids) {
list($attemptidcondition, $attemptidparams) = $DB->get_in_or_equal($attemptids, SQL_PARAMS_NAMED);
$where .= " AND quiza.id $attemptidcondition";
$params += $attemptidparams;
}
$sql .= "\nWHERE {$where}";
$attempts = $DB->get_records_sql($sql, $params);
if (!$attempts) {
return;
}
$this->regrade_batch_of_attempts($quiz, $attempts, $dryrun, $groupstudentsjoins);
}
/**
* Regrade those questions in those attempts that are marked as needing regrading
* in the quiz_overview_regrades table.
* @param stdClass $quiz the quiz settings.
* @param \core\dml\sql_join $groupstudentsjoins empty for all attempts, otherwise regrade attempts
* for these users.
*/
protected function regrade_attempts_needing_it($quiz, \core\dml\sql_join $groupstudentsjoins) {
global $DB;
$this->unlock_session();
$join = '{quiz_overview_regrades} qqr ON qqr.questionusageid = quiza.uniqueid';
$where = "quiza.quiz = :qid AND quiza.preview = 0 AND qqr.regraded = 0";
$params = ['qid' => $quiz->id];
// Fetch all attempts that need regrading.
if ($this->hasgroupstudents && !empty($groupstudentsjoins->joins)) {
$join .= "\nJOIN {user} u ON u.id = quiza.userid
{$groupstudentsjoins->joins}";
$where .= " AND {$groupstudentsjoins->wheres}";
$params += $groupstudentsjoins->params;
}
$toregrade = $DB->get_recordset_sql("
SELECT quiza.uniqueid, qqr.slot
FROM {quiz_attempts} quiza
JOIN $join
WHERE $where", $params);
$attemptquestions = [];
foreach ($toregrade as $row) {
$attemptquestions[$row->uniqueid][] = $row->slot;
}
$toregrade->close();
if (!$attemptquestions) {
return;
}
list($uniqueidcondition, $params) = $DB->get_in_or_equal(array_keys($attemptquestions));
$userfieldsapi = \core_user\fields::for_name();
$attempts = $DB->get_records_sql("
SELECT quiza.*, " . $userfieldsapi->get_sql('u', false, '', '', false)->selects . "
FROM {quiz_attempts} quiza
JOIN {user} u ON u.id = quiza.userid
WHERE quiza.uniqueid $uniqueidcondition
", $params);
foreach ($attempts as $attempt) {
$attempt->regradeonlyslots = $attemptquestions[$attempt->uniqueid];
}
$this->regrade_batch_of_attempts($quiz, $attempts, false, $groupstudentsjoins);
}
/**
* This is a helper used by {@link regrade_attempts()} and
* {@link regrade_attempts_needing_it()}.
*
* Given an array of attempts, it regrades them all, or does a dry run.
* Each object in the attempts array must be a row from the quiz_attempts
* table, with the \core_user\fields::for_name() fields from the user table joined in.
* In addition, if $attempt->regradeonlyslots is set, then only those slots
* are regraded, otherwise all slots are regraded.
*
* @param stdClass $quiz the quiz settings.
* @param array $attempts of data from the quiz_attempts table, with extra data as above.
* @param bool $dryrun if true, do a pretend regrade, otherwise do it for real.
* @param \core\dml\sql_join $groupstudentsjoins empty for all attempts, otherwise regrade attempts
*/
protected function regrade_batch_of_attempts($quiz, array $attempts,
bool $dryrun, \core\dml\sql_join $groupstudentsjoins) {
global $OUTPUT;
$this->clear_regrade_table($quiz, $groupstudentsjoins);
$progressbar = new progress_bar('quiz_overview_regrade', 500, true);
$a = [
'count' => count($attempts),
'done' => 0,
];
foreach ($attempts as $attempt) {
$a['done']++;
$a['attemptnum'] = $attempt->attempt;
$a['name'] = fullname($attempt);
$a['attemptid'] = $attempt->id;
if (!isset($attempt->regradeonlyslots)) {
$attempt->regradeonlyslots = null;
}
$progressbar->update($a['done'], $a['count'],
get_string('regradingattemptxofywithdetails', 'quiz_overview', $a));
$messages = $this->regrade_attempt($attempt, $dryrun, $attempt->regradeonlyslots);
if ($messages) {
$items = [];
foreach ($messages as $slot => $message) {
$items[] = get_string('regradingattemptissue', 'quiz_overview',
['slot' => $slot, 'reason' => $message]);
}
echo $OUTPUT->notification(
html_writer::tag('p', get_string('regradingattemptxofyproblem', 'quiz_overview', $a)) .
html_writer::alist($items), \core\output\notification::NOTIFY_WARNING);
}
}
$progressbar->update($a['done'], $a['count'],
get_string('regradedsuccessfullyxofy', 'quiz_overview', $a));
if (!$dryrun) {
$this->update_overall_grades($quiz);
}
}
/**
* Count the number of attempts in need of a regrade.
*
* @param stdClass $quiz the quiz settings.
* @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params) If this is given, only data relating
* to these users is cleared.
* @return int the number of attempts.
*/
protected function count_question_attempts_needing_regrade($quiz, \core\dml\sql_join $groupstudentsjoins) {
global $DB;
$userjoin = '';
$usertest = '';
$params = [];
if ($this->hasgroupstudents) {
$userjoin = "JOIN {user} u ON u.id = quiza.userid
{$groupstudentsjoins->joins}";
$usertest = "{$groupstudentsjoins->wheres} AND u.id = quiza.userid AND ";
$params = $groupstudentsjoins->params;
}
$params['cquiz'] = $quiz->id;
$sql = "SELECT COUNT(DISTINCT quiza.id)
FROM {quiz_attempts} quiza
JOIN {quiz_overview_regrades} qqr ON quiza.uniqueid = qqr.questionusageid
$userjoin
WHERE
$usertest
quiza.quiz = :cquiz AND
quiza.preview = 0 AND
qqr.regraded = 0";
return $DB->count_records_sql($sql, $params);
}
/**
* Are there any pending regrades in the table we are going to show?
* @param string $from tables used by the main query.
* @param string $where where clause used by the main query.
* @param array $params required by the SQL.
* @return bool whether there are pending regrades.
*/
protected function has_regraded_questions($from, $where, $params) {
global $DB;
return $DB->record_exists_sql("
SELECT 1
FROM {$from}
JOIN {quiz_overview_regrades} qor ON qor.questionusageid = quiza.uniqueid
WHERE {$where}", $params);
}
/**
* Remove all information about pending/complete regrades from the database.
* @param stdClass $quiz the quiz settings.
* @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params). If this is given, only data relating
* to these users is cleared.
*/
protected function clear_regrade_table($quiz, \core\dml\sql_join $groupstudentsjoins) {
global $DB;
// Fetch all attempts that need regrading.
$select = "questionusageid IN (
SELECT uniqueid
FROM {quiz_attempts} quiza";
$where = "WHERE quiza.quiz = :qid";
$params = ['qid' => $quiz->id];
if ($this->hasgroupstudents && !empty($groupstudentsjoins->joins)) {
$select .= "\nJOIN {user} u ON u.id = quiza.userid
{$groupstudentsjoins->joins}";
$where .= " AND {$groupstudentsjoins->wheres}";
$params += $groupstudentsjoins->params;
}
$select .= "\n$where)";
$DB->delete_records_select('quiz_overview_regrades', $select, $params);
}
/**
* Update the final grades for all attempts. This method is used following a regrade.
*
* @param stdClass $quiz the quiz settings.
*/
protected function update_overall_grades($quiz) {
$gradecalculator = $this->quizobj->get_grade_calculator();
$gradecalculator->recompute_all_attempt_sumgrades();
$gradecalculator->recompute_all_final_grades();
quiz_update_grades($quiz);
}
/**
* Get the bands configuration for the quiz.
*
* This returns the configuration for having between 11 and 20 bars in
* a chart based on the maximum grade to be given on a quiz. The width of
* a band is the number of grade points it encapsulates.
*
* @param stdClass $quiz The quiz object.
* @return array Contains the number of bands, and their width.
*/
public static function get_bands_count_and_width($quiz) {
$bands = $quiz->grade;
while ($bands > 20 || $bands <= 10) {
if ($bands > 50) {
$bands /= 5;
} else if ($bands > 20) {
$bands /= 2;
}
if ($bands < 4) {
$bands *= 5;
} else if ($bands <= 10) {
$bands *= 2;
}
}
// See MDL-34589. Using doubles as array keys causes problems in PHP 5.4, hence the explicit cast to int.
$bands = (int) ceil($bands);
return [$bands, $quiz->grade / $bands];
}
/**
* Get the bands labels.
*
* @param int $bands The number of bands.
* @param int $bandwidth The band width.
* @param stdClass $quiz The quiz object.
* @return string[] The labels.
*/
public static function get_bands_labels($bands, $bandwidth, $quiz) {
$bandlabels = [];
for ($i = 1; $i <= $bands; $i++) {
$bandlabels[] = quiz_format_grade($quiz, ($i - 1) * $bandwidth) . ' - ' . quiz_format_grade($quiz, $i * $bandwidth);
}
return $bandlabels;
}
/**
* Get a chart.
*
* @param string[] $labels Chart labels.
* @param int[] $data The data.
* @return \core\chart_base
*/
protected static function get_chart($labels, $data) {
$chart = new \core\chart_bar();
$chart->set_labels($labels);
$chart->get_xaxis(0, true)->set_label(get_string('gradenoun'));
$yaxis = $chart->get_yaxis(0, true);
$yaxis->set_label(get_string('participants'));
$yaxis->set_stepsize(max(1, round(max($data) / 10)));
$series = new \core\chart_series(get_string('participants'), $data);
$chart->add_series($series);
return $chart;
}
}
@@ -0,0 +1,115 @@
@mod @mod_quiz @quiz @quiz_overview
Feature: Basic use of the Grades report
In order to easily get an overview of quiz attempts
As a teacher
I need to use the Grades report
Background:
Given the "multilang" filter is "on"
And the "multilang" filter applies to "content and headings"
And the following "custom profile fields" exist:
| datatype | shortname | name |
| text | fruit | Fruit |
And the following "users" exist:
| username | firstname | lastname | email | idnumber | profile_field_fruit |
| teacher1 | T1 | Teacher1 | teacher1@example.com | T1000 | |
| student1 | S1 | Student1 | student1@example.com | S1000 | Apple |
| student2 | S2 | Student2 | student2@example.com | S2000 | Banana |
| student3 | S3 | Student3 | student3@example.com | S3000 | Pear |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
| student2 | C1 | student |
| student3 | C1 | student |
And the following "groups" exist:
| course | idnumber | name |
| C1 | G1 | <span class="multilang" lang="en">English</span><span class="multilang" lang="es">Spanish</span> |
| C1 | G2 | Group 2 |
And the following "group members" exist:
| group | user |
| G1 | student1 |
| G1 | student2 |
| G2 | student3 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode |
| quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | 2 |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | description | Intro | Welcome to this quiz |
| Test questions | truefalse | TF1 | First question |
| Test questions | truefalse | TF2 | Second question |
And quiz "Quiz 1" contains the following questions:
| question | page | maxmark | displaynumber |
| Intro | 1 | | |
| TF1 | 1 | | |
| TF2 | 1 | 3.0 | 2a |
And user "student1" has attempted "Quiz 1" with responses:
| slot | response |
| 2 | True |
| 3 | False |
And user "student2" has attempted "Quiz 1" with responses:
| slot | response |
| 2 | True |
| 3 | True |
@javascript
Scenario: Using the Grades report
# Basic check of the Grades report
When I am on the "Quiz 1" "quiz activity" page logged in as teacher1
And I navigate to "Results" in current page administration
Then I should see "Attempts: 2"
# Verify that the right columns are visible
And I should see "Q. 1"
And I should see "Q. 2a"
And I should not see "Q. 3"
# Check student1's grade
And I should see "25.00" in the "S1 Student1" "table_row"
# And student2's grade
And I should see "100.00" in the "S2 Student2" "table_row"
# Check changing the form parameters
And I set the field "Attempts from" to "enrolled users who have not attempted the quiz"
And I press "Show report"
# Note: teachers should not appear in the report.
# Check student3's grade
And I should see "-" in the "S3 Student3" "table_row"
And I set the field "Attempts from" to "enrolled users who have, or have not, attempted the quiz"
And I press "Show report"
# Check student1's grade
And I should see "25.00" in the "S1 Student1" "table_row"
# Check student2's grade
And I should see "100.00" in the "S2 Student2" "table_row"
# Check student3's grade
And I should see "-" in the "S3 Student3" "table_row"
And I set the field "Attempts from" to "all users who have attempted the quiz"
And I press "Show report"
# Check student1's grade
And I should see "25.00" in the "S1 Student1" "table_row"
# Check student2's grade
And I should see "100.00" in the "S2 Student2" "table_row"
# Verify groups are displayed correctly.
And I set the field "Visible groups" to "English"
And "Full regrade for group 'English'" "button" should exist
And "Dry run a full regrade for group 'English'" "button" should exist
And I should see "Number of students in group 'English' achieving grade ranges"
@javascript
Scenario: View custom user profile fields in the grades report
Given the following config values are set as admin:
| showuseridentity | email,profile_field_fruit |
And I am on the "Quiz 1" "quiz activity" page logged in as teacher1
And I navigate to "Results" in current page administration
Then I should see "Apple" in the "S1 Student1" "table_row"
And I should see "Banana" in the "S2 Student2" "table_row"
@@ -0,0 +1,54 @@
@mod @mod_quiz @quiz @quiz_overview
Feature: Grades report for a quiz with multiple grade items
In to get an overview of quiz attempt grade
As a teacher
I need the Grades report to show all grade items
Background:
Given the following "users" exist:
| username | firstname | lastname |
| student | Lorna | Lott |
| teacher | Mark | Allwright |
And the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| student | C1 | student |
| teacher | C1 | teacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "activities" exist:
| activity | name | course |
| quiz | Test quiz | C1 |
@javascript
Scenario: Quiz grades report with multiple grade items
Given the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | truefalse | Reading | Can you read this? |
| Test questions | truefalse | Listening | Can you hear this? |
And the following "mod_quiz > grade items" exist:
| quiz | name |
| Test quiz | Reading |
| Test quiz | Listening |
And quiz "Test quiz" contains the following questions:
| question | page | grade item |
| Reading | 1 | Reading |
| Listening | 1 | Listening |
And user "student" has attempted "Test quiz" with responses:
| slot | response |
| 1 | True |
| 2 | False |
When I am on the "Test quiz" "mod_quiz > Grades report" page logged in as teacher
Then "Lorna LottReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "50.00"
And "Lorna LottReview attempt" row "Q. 1/50.00Sort by Q. 1/50.00 Ascending" column of "attempts" table should contain "0.00"
And "Lorna LottReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "0.00"
And "Lorna LottReview attempt" row "Reading/1.00Sort by Reading/1.00 Ascending" column of "attempts" table should contain "1.00"
And "Lorna LottReview attempt" row "Listening/1.00Sort by Listening/1.00 Ascending" column of "attempts" table should contain "0.00"
And I follow "Reading/1.00Sort by Reading/1.00 Ascending"
# Main thing to check here is that sorting does not give a fatal error
And "Lorna LottReview attempt" row "Listening/1.00Sort by Listening/1.00 Ascending" column of "attempts" table should contain "0.00"
@@ -0,0 +1,220 @@
@mod @mod_quiz @quiz @quiz_overview @javascript
Feature: Regrading quiz attempts using the Grades report
In order to be able to correct mistakes I made setting up my quiz
As a teacher
I need to be able to re-grade attempts after editing questions
Background:
Given the following "users" exist:
| username | firstname | lastname |
| teacher | Mark | Allwright |
| student1 | Student | One |
| student2 | Student | Two |
| student3 | Student | Three |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher | C1 | editingteacher |
| student1 | C1 | student |
| student2 | C1 | student |
| student3 | C1 | student |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Quiz for testing regrading | C1 | quiz1 |
And the following "questions" exist:
| questioncategory | qtype | name |
| Test questions | truefalse | TF |
| Test questions | shortanswer | SA |
And quiz "Quiz for testing regrading" contains the following questions:
| question | page | maxmark |
| TF | 1 | 5.0 |
| SA | 1 | 5.0 |
And user "student1" has attempted "Quiz for testing regrading" with responses:
| slot | response |
| 1 | True |
| 2 | frog |
And user "student2" has attempted "Quiz for testing regrading" with responses:
| slot | response |
| 1 | True |
| 2 | toad |
Scenario: Regrade all attempts
Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher
And I navigate to "Results" in current page administration
When I press "Regrade all"
# Note, the order is not defined, so we can only check part of the message.
# Also, nothing has changed in the quiz, so the regrade won't alter any scores,
# but this is still a useful test that the regrade process completes without errors.
Then I should see "Quiz for testing regrading"
And I should see "Finished regrading (2/2)"
And I should see "Regrade completed"
And I press "Continue"
# These next tests just serve to check we got back to the report.
And I should see "Quiz for testing regrading"
And I should see "Overall number of students achieving grade ranges"
Scenario: Regrade selected attempts
Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher
And I navigate to "Results" in current page administration
When I click on "Select attempt" "checkbox" in the "Student Two" "table_row"
And I press "Regrade selected attempts"
Then I should see "Quiz for testing regrading"
And I should see "Finished regrading (1/1)"
And I should see "Regrade completed"
And I press "Continue"
# These next tests just serve to check we got back to the report.
And I should see "Quiz for testing regrading"
And I should see "Overall number of students achieving grade ranges"
Scenario: Dry-run a full regrade, then regrade the attempts that will need it.
Given I am on the "Quiz for testing regrading" "mod_quiz > edit" page logged in as teacher
And I follow "Edit question SA"
And I set the field "id_fraction_1" to "50%"
And I press "id_submitbutton"
And I set the field "version" in the "TF" "list_item" to "v1"
And I set the field "version" in the "SA" "list_item" to "v2 (latest)"
And I follow "Attempts: 2"
And I press "Dry run a full regrade"
# Note, the order is not defined, so we can only check part of the message.
Then I should see "Quiz for testing regrading"
And I should see "Finished regrading (2/2)"
And I should see "Regrade completed"
And I press "Continue"
And "Student One" row "Regrade" column of "attempts" table should not contain "Needed"
And "Student TwoReview attempt" row "Regrade" column of "attempts" table should contain "Needed"
# In the following, the first number is strike-through, and the second is not, but Behat can't see that.
# At this point, it is showing what would change.
And "Student TwoReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "40.00/25.00"
And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00"
And I press "Regrade attempts marked as needing regrading (1)"
And I should see "Quiz for testing regrading"
And I should see "Finished regrading (1/1)"
And I should see "Regrade completed"
And I press "Continue"
# These next tests just serve to check we got back to the report.
And I should see "Quiz for testing regrading"
And I should see "Overall number of students achieving grade ranges"
# Now, both old-score strike-through and new score plain, are still shown, but now it indicates what did change.
And "Student TwoReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "40.00/25.00"
And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00"
And "Regrade attempts marked as needing regrading" "button" should not exist
Scenario: Regrade all attempts works against quiz selected question version
Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher
And I navigate to "Results" in current page administration
When I press "Dry run a full regrade"
Then I should see "Quiz for testing regrading"
And I should see "Finished regrading (2/2)"
And I should see "Regrade completed"
And I press "Continue"
And I should see "Quiz for testing regrading"
And I should see "Overall number of students achieving grade ranges"
And "Student One" row "Regrade" column of "attempts" table should not contain "Needed"
And I am on the "Quiz for testing regrading" "mod_quiz > question bank" page
And I choose "Edit question" action for "TF" in the question bank
And I set the field "Correct answer" to "False"
And I press "id_submitbutton"
And I am on the "Quiz for testing regrading" "mod_quiz > edit" page
And I set the field "version" in the "TF" "list_item" to "v2 (latest)"
And I navigate to "Results" in current page administration
And I press "Dry run a full regrade"
And I should see "Regrade completed"
And I press "Continue"
And "student1@example.com" row "Regrade" column of "attempts" table should contain "Needed"
And "Correct" "icon" should appear before "50.00/0.00" "text"
And I press "Regrade all"
And I should see "Regrade completed"
And I press "Continue"
Then "student1@example.com" row "Regrade" column of "attempts" table should contain "Done"
And "Student OneReview attempt" row "Q. 1/50.00Sort by Q. 1/50.00 Ascending" column of "attempts" table should contain "50.00/0.00"
And "Incorrect" "icon" should appear before "50.00/0.00" "text"
Scenario: Regrade all attempts works against quiz selected latest question version
Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher
And I navigate to "Results" in current page administration
And I click on "mod-quiz-report-overview-report-selectall-attempts" "checkbox"
And I click on "Delete selected attempts" "button"
And I click on "Yes" "button"
And I am on the "Quiz for testing regrading" "mod_quiz > edit" page
And I should see "(latest)" in the "TF" "list_item"
# Create multiple question versions.
And I am on the "Quiz for testing regrading" "mod_quiz > question bank" page
And I choose "Edit question" action for "TF" in the question bank
And I set the field "Correct answer" to "True"
And I press "id_submitbutton"
And I choose "Edit question" action for "TF" in the question bank
And I set the field "Question name" to "New version of TF"
And I set the field "Correct answer" to "False"
And I press "id_submitbutton"
And I am on the "Quiz for testing regrading" "mod_quiz > edit" page
And I should see "(latest)" in the "TF" "list_item"
And I click on "version" "select" in the "TF" "list_item"
And I should see "v1"
And I should see "v2"
And I should see "v3 (latest)"
# Set version that is going to be attempted to an older one.
And I set the field "version" in the "TF" "list_item" to "v1"
And user "student3" has attempted "Quiz for testing regrading" with responses:
| slot | response |
| 1 | True |
| 2 | toad |
And I am on the "Quiz for testing regrading" "mod_quiz > edit" page
And I set the field "version" in the "TF" "list_item" to "Always latest"
And I navigate to "Results" in current page administration
And I press "Regrade all"
And I should see "Finished regrading (1/1)"
And I should see "Regrade completed"
And I press "Continue"
Then "student3@example.com" row "Q. 1/50.00Sort by Q. 1/50.00 Ascending" column of "attempts" table should contain "50.00/0.00"
And "Incorrect" "icon" should appear before "50.00/0.00" "text"
Scenario: Regrade attempts should always regrade against latest random question version
Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher
And I navigate to "Results" in current page administration
And I click on "mod-quiz-report-overview-report-selectall-attempts" "checkbox"
And I click on "Delete selected attempts" "button"
And I click on "Yes" "button"
# Create multiple question versions.
And I am on the "Quiz for testing regrading" "mod_quiz > question bank" page
And I choose "Delete" action for "SA" in the question bank
And I press "Delete"
And I am on the "Quiz for testing regrading" "mod_quiz > edit" page
And I click on "Delete" "link" in the "TF" "list_item"
And I click on "Yes" "button" in the "Confirm" "dialogue"
And I click on "Delete" "link" in the "SA" "list_item"
And I click on "Yes" "button" in the "Confirm" "dialogue"
And I click on "Add" "link"
And I follow "a random question"
And I press "Add random question"
And I am on the "Quiz for testing regrading" "quiz activity" page logged in as student3
And I click on "Attempt quiz" "button"
And I should see "The answer is true."
And I set the field "True" to "1"
And I click on "Finish attempt ..." "button"
And I press "Submit all and finish"
And I click on "Submit" "button" in the "Submit all your answers and finish?" "dialogue"
And I am on the "Quiz for testing regrading" "mod_quiz > question bank" page logged in as teacher
And I choose "Edit question" action for "TF" in the question bank
And I set the field "Correct answer" to "False"
And I press "id_submitbutton"
And I navigate to "Results" in current page administration
And "student3@example.com" row "Q. 1/100.00Sort by Q. 1/100.00 Ascending" column of "attempts" table should contain "100.00"
And "Correct" "icon" should be visible
And I press "Regrade all"
And I should see "Finished regrading (1/1)"
And I should see "Regrade completed"
And I press "Continue"
Then "student3@example.com" row "Q. 1/100.00Sort by Q. 1/100.00 Ascending" column of "attempts" table should contain "100.00/0.00"
And "Incorrect" "icon" should be visible
@@ -0,0 +1,56 @@
@mod @mod_quiz @quiz @quiz_overview @javascript
Feature: Quiz regrade when not possible
In order avoid errors
As a teacher
I need the system to prevent impossible regrade scenarios
Background:
Given the following "users" exist:
| username | firstname | lastname |
| teacher | Mark | Allwright |
| student | Student | One |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher | C1 | editingteacher |
| student | C1 | student |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Quiz for testing regrading | C1 | quiz1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Activity module | quiz1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | template | name |
| Test questions | multichoice | one_of_four | MC |
And quiz "Quiz for testing regrading" contains the following questions:
| question | page | maxmark |
| MC | 1 | 10.0 |
And user "student" has attempted "Quiz for testing regrading" with responses:
| slot | response |
| 1 | B |
Scenario: Try a regrade after the question has been edited to have a different number of choices
# Edit the question so that V2 has the fourth choice removed.
Given I am on the "MC" "core_question > edit" page logged in as teacher
And I set the following fields to these values:
| Choice 4 | |
| id_feedback_3 | |
And I press "id_submitbutton"
# Try a regrade, and verify what happened is reported.
When I am on the "Quiz for testing regrading" "mod_quiz > grades report" page
And I press "Regrade all"
Then I should see "Quiz for testing regrading"
And I should see "The following questions could not be regraded in attempt 1 by Student One"
And I should see "Slot 1: The number of choices in the question has changed."
And I should see "Finished regrading (1/1)"
And I should see "Regrade completed"
And I press "Continue"
# These next tests just serve to check we got back to the report.
And I should see "Quiz for testing regrading"
And I should see "Overall number of students achieving grade ranges"
@@ -0,0 +1,49 @@
@mod @mod_quiz @quiz @quiz_overview @javascript
Feature: Reopening Never submitted quiz attempts
In order to cut some slack to students who forgot to submit their quiz attempt
As a teacher
I need to be able to reopen selected attempts.
Background:
Given the following "users" exist:
| username | firstname | lastname |
| teacher | Mark | Allwright |
| student | Freddy | Forgetful |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher | C1 | editingteacher |
| student | C1 | student |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name |
| Test questions | truefalse | TF |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Test quiz | C1 | quiz1 |
And quiz "Test quiz" contains the following questions:
| question | page |
| TF | 1 |
And user "student" has started an attempt at quiz "Test quiz"
And the attempt at "Test quiz" by "student" was never submitted
Scenario: Attempt can be reopened
Given I am on the "Test quiz" "mod_quiz > Grades report" page logged in as teacher
When I press "Reopen"
And I should see "This will reopen attempt 1 by Freddy Forgetful." in the "Reopen attempt?" "dialogue"
And I should see "The attempt will remain open and can be continued." in the "Reopen attempt?" "dialogue"
And I click on "Reopen" "button" in the "Reopen attempt?" "dialogue"
Then I should see "In progress" in the "Freddy Forgetful" "table_row"
And "Reopen" "button" should not exist
Scenario: Reopening an attempt can be cancelled and then nothing happens
Given I am on the "Test quiz" "mod_quiz > Grades report" page logged in as teacher
And I start watching to see if a new page loads
When I press "Reopen"
And I click on "Cancel" "button" in the "Reopen attempt?" "dialogue"
Then a new page should not have loaded since I started watching
And I should see "Never submitted" in the "Freddy Forgetful" "table_row"
@@ -0,0 +1,56 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
use mod_quiz\local\reports\attempts_report;
/**
* Makes some protected methods of attempts_report public to facilitate testing.
*
* @package quiz_overview
* @copyright 2020 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Makes some protected methods of attempts_report public to facilitate testing.
*
* @copyright 2020 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class testable_quiz_attempts_report extends attempts_report {
/**
* Override this function to displays the report.
* @param stdClass $cm the course-module for this quiz.
* @param stdClass $course the course we are in.
* @param stdClass $quiz this quiz.
*/
public function display($cm, $course, $quiz) {
}
/**
* Testable delete_selected_attempts function.
*
* @param stdClass $quiz
* @param stdClass $cm
* @param array $attemptids
* @param \core\dml\sql_join $allowedjoins
*/
public function delete_selected_attempts($quiz, $cm, $attemptids, \core\dml\sql_join $allowedjoins) {
parent::delete_selected_attempts($quiz, $cm, $attemptids, $allowedjoins);
}
}
@@ -0,0 +1,110 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy provider tests.
*
* @package quiz_overview
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace quiz_overview\privacy;
use core_privacy\local\metadata\collection;
use quiz_overview\privacy\provider;
use core_privacy\local\request\writer;
use core_privacy\local\request\transform;
defined('MOODLE_INTERNAL') || die();
/**
* Privacy provider tests class.
*
* @package quiz_overview
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider_test extends \core_privacy\tests\provider_testcase {
/**
* When no preference exists, there should be no export.
*/
public function test_preference_unset(): void {
global $USER;
$this->resetAfterTest();
$this->setAdminUser();
provider::export_user_preferences($USER->id);
$this->assertFalse(writer::with_context(\context_system::instance())->has_any_data());
}
/**
* Preference does exist.
*/
public function test_preference_yes(): void {
$this->resetAfterTest();
// Create test user, add some preferences.
$user = $this->getDataGenerator()->create_user();
$this->setUser($user);
set_user_preference('quiz_overview_slotmarks', 1, $user);
// Switch to admin user (so we can validate preferences of the correct user are being exported).
$this->setAdminUser();
// Export test users preferences.
provider::export_user_preferences($user->id);
$writer = writer::with_context(\context_system::instance());
$this->assertTrue($writer->has_any_data());
$preferences = $writer->get_user_preferences('quiz_overview');
$this->assertNotEmpty($preferences->slotmarks);
$this->assertEquals(transform::yesno(1), $preferences->slotmarks->value);
$description = get_string('privacy:preference:slotmarks:yes', 'quiz_overview');
$this->assertEquals($description, $preferences->slotmarks->description);
}
/**
* Preference does exist and is no.
*/
public function test_preference_no(): void {
$this->resetAfterTest();
// Create test user, add some preferences.
$user = $this->getDataGenerator()->create_user();
$this->setUser($user);
set_user_preference('quiz_overview_slotmarks', 0);
// Switch to admin user (so we can validate preferences of the correct user are being exported).
$this->setAdminUser();
// Export test users preferences.
provider::export_user_preferences($user->id);
$writer = writer::with_context(\context_system::instance());
$this->assertTrue($writer->has_any_data());
$preferences = $writer->get_user_preferences('quiz_overview');
$this->assertNotEmpty($preferences->slotmarks);
$this->assertEquals(transform::yesno(0), $preferences->slotmarks->value);
$description = get_string('privacy:preference:slotmarks:no', 'quiz_overview');
$this->assertEquals($description, $preferences->slotmarks->description);
}
}
@@ -0,0 +1,406 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace quiz_overview;
use core_question\local\bank\question_version_status;
use mod_quiz\external\submit_question_version;
use mod_quiz\quiz_attempt;
use question_engine;
use mod_quiz\quiz_settings;
use mod_quiz\local\reports\attempts_report;
use quiz_overview_options;
use quiz_overview_report;
use quiz_overview_table;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
require_once($CFG->dirroot . '/mod/quiz/report/overview/report.php');
require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_form.php');
require_once($CFG->dirroot . '/mod/quiz/report/overview/tests/helpers.php');
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
/**
* Tests for the quiz overview report.
*
* @package quiz_overview
* @copyright 2014 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class report_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
/**
* Data provider for test_report_sql.
*
* @return array the data for the test sub-cases.
*/
public function report_sql_cases(): array {
return [[null], ['csv']]; // Only need to test on or off, not all download types.
}
/**
* Test how the report queries the database.
*
* @param string|null $isdownloading a download type, or null.
* @dataProvider report_sql_cases
*/
public function test_report_sql(?string $isdownloading): void {
global $DB;
$this->resetAfterTest();
// Create a course and a quiz.
$generator = $this->getDataGenerator();
$course = $generator->create_course();
$quizgenerator = $generator->get_plugin_generator('mod_quiz');
$quiz = $quizgenerator->create_instance(['course' => $course->id,
'grademethod' => QUIZ_GRADEHIGHEST, 'grade' => 100.0, 'sumgrades' => 10.0,
'attempts' => 10]);
// Add one question.
/** @var core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$q = $questiongenerator->create_question('essay', 'plain', ['category' => $cat->id]);
quiz_add_quiz_question($q->id, $quiz, 0 , 10);
// Create some students and enrol them in the course.
$student1 = $generator->create_user();
$student2 = $generator->create_user();
$student3 = $generator->create_user();
$generator->enrol_user($student1->id, $course->id);
$generator->enrol_user($student2->id, $course->id);
$generator->enrol_user($student3->id, $course->id);
// This line is not really necessary for the test asserts below,
// but what it does is add an extra user row returned by
// get_enrolled_with_capabilities_join because of a second enrolment.
// The extra row returned used to make $table->query_db complain
// about duplicate records. So this is really a test that an extra
// student enrolment does not cause duplicate records in this query.
$generator->enrol_user($student2->id, $course->id, null, 'self');
// Also create a user who should not appear in the reports,
// because they have a role with neither 'mod/quiz:attempt'
// nor 'mod/quiz:reviewmyattempts'.
$tutor = $generator->create_user();
$generator->enrol_user($tutor->id, $course->id, 'teacher');
// The test data.
$timestamp = 1234567890;
$attempts = [
[$quiz, $student1, 1, 0.0, quiz_attempt::FINISHED],
[$quiz, $student1, 2, 5.0, quiz_attempt::FINISHED],
[$quiz, $student1, 3, 8.0, quiz_attempt::FINISHED],
[$quiz, $student1, 4, null, quiz_attempt::ABANDONED],
[$quiz, $student1, 5, null, quiz_attempt::IN_PROGRESS],
[$quiz, $student2, 1, null, quiz_attempt::ABANDONED],
[$quiz, $student2, 2, null, quiz_attempt::ABANDONED],
[$quiz, $student2, 3, 7.0, quiz_attempt::FINISHED],
[$quiz, $student2, 4, null, quiz_attempt::ABANDONED],
[$quiz, $student2, 5, null, quiz_attempt::ABANDONED],
];
// Load it in to quiz attempts table.
foreach ($attempts as $attemptdata) {
list($quiz, $student, $attemptnumber, $sumgrades, $state) = $attemptdata;
$timestart = $timestamp + $attemptnumber * 3600;
$quizobj = quiz_settings::create($quiz->id, $student->id);
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
// Create the new attempt and initialize the question sessions.
$attempt = quiz_create_attempt($quizobj, $attemptnumber, null, $timestart, false, $student->id);
$attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timestamp);
$attempt = quiz_attempt_save_started($quizobj, $quba, $attempt);
// Process some responses from the student.
$attemptobj = quiz_attempt::create($attempt->id);
switch ($state) {
case quiz_attempt::ABANDONED:
$attemptobj->process_abandon($timestart + 300, false);
break;
case quiz_attempt::IN_PROGRESS:
// Do nothing.
break;
case quiz_attempt::FINISHED:
// Save answer and finish attempt.
$attemptobj->process_submitted_actions($timestart + 300, false, [
1 => ['answer' => 'My essay by ' . $student->firstname, 'answerformat' => FORMAT_PLAIN]]);
$attemptobj->process_finish($timestart + 600, false);
// Manually grade it.
$quba = $attemptobj->get_question_usage();
$quba->get_question_attempt(1)->manual_grade(
'Comment', $sumgrades, FORMAT_HTML, $timestart + 1200);
question_engine::save_questions_usage_by_activity($quba);
$update = new \stdClass();
$update->id = $attemptobj->get_attemptid();
$update->timemodified = $timestart + 1200;
$update->sumgrades = $quba->get_total_mark();
$DB->update_record('quiz_attempts', $update);
$attemptobj->get_quizobj()->get_grade_calculator()->recompute_final_grade($student->id);
break;
}
}
// Actually getting the SQL to run is quite hard. Do a minimal set up of
// some objects.
$context = \context_module::instance($quiz->cmid);
$cm = get_coursemodule_from_id('quiz', $quiz->cmid);
$qmsubselect = quiz_report_qm_filter_select($quiz);
$studentsjoins = get_enrolled_with_capabilities_join($context, '',
['mod/quiz:attempt', 'mod/quiz:reviewmyattempts']);
$empty = new \core\dml\sql_join();
// Set the options.
$reportoptions = new quiz_overview_options('overview', $quiz, $cm, null);
$reportoptions->attempts = attempts_report::ENROLLED_ALL;
$reportoptions->onlygraded = true;
$reportoptions->states = [quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE, quiz_attempt::FINISHED];
// Now do a minimal set-up of the table class.
$q->slot = 1;
$q->maxmark = 10;
$table = new quiz_overview_table($quiz, $context, $qmsubselect, $reportoptions,
$empty, $studentsjoins, [1 => $q], null);
$table->download = $isdownloading; // Cannot call the is_downloading API, because it gives errors.
$table->define_columns(['fullname']);
$table->sortable(true, 'uniqueid');
$table->define_baseurl(new \moodle_url('/mod/quiz/report.php'));
$table->setup();
// Run the query.
$table->setup_sql_queries($studentsjoins);
$table->query_db(30, false);
// Should be 4 rows, matching count($table->rawdata) tested below.
// The count is only done if not downloading.
if (!$isdownloading) {
$this->assertEquals(4, $table->totalrows);
}
// Verify what was returned: Student 1's best and in progress attempts.
// Student 2's finshed attempt, and Student 3 with no attempt.
// The array key is {student id}#{attempt number}.
$this->assertEquals(4, count($table->rawdata));
$this->assertArrayHasKey($student1->id . '#3', $table->rawdata);
$this->assertEquals(1, $table->rawdata[$student1->id . '#3']->gradedattempt);
$this->assertArrayHasKey($student1->id . '#3', $table->rawdata);
$this->assertEquals(0, $table->rawdata[$student1->id . '#5']->gradedattempt);
$this->assertArrayHasKey($student2->id . '#3', $table->rawdata);
$this->assertEquals(1, $table->rawdata[$student2->id . '#3']->gradedattempt);
$this->assertArrayHasKey($student3->id . '#0', $table->rawdata);
$this->assertEquals(0, $table->rawdata[$student3->id . '#0']->gradedattempt);
// Check the calculation of averages.
$averagerow = $table->compute_average_row('overallaverage', $studentsjoins);
$this->assertStringContainsString('75.00', $averagerow['sumgrades']);
$this->assertStringContainsString('75.00', $averagerow['qsgrade1']);
if (!$isdownloading) {
$this->assertStringContainsString('(2)', $averagerow['sumgrades']);
$this->assertStringContainsString('(2)', $averagerow['qsgrade1']);
}
// Ensure that filtering by initial does not break it.
// This involves setting a private properly of the base class, which is
// only really possible using reflection :-(.
$reflectionobject = new \ReflectionObject($table);
while ($parent = $reflectionobject->getParentClass()) {
$reflectionobject = $parent;
}
$prefsproperty = $reflectionobject->getProperty('prefs');
$prefs = $prefsproperty->getValue($table);
$prefs['i_first'] = 'A';
$prefsproperty->setValue($table, $prefs);
list($fields, $from, $where, $params) = $table->base_sql($studentsjoins);
$table->set_count_sql("SELECT COUNT(1) FROM (SELECT $fields FROM $from WHERE $where) temp WHERE 1 = 1", $params);
$table->set_sql($fields, $from, $where, $params);
$table->query_db(30, false);
// Just verify that this does not cause a fatal error.
}
/**
* Bands provider.
* @return array
*/
public function get_bands_count_and_width_provider(): array {
return [
[10, [20, .5]],
[20, [20, 1]],
[30, [15, 2]],
// TODO MDL-55068 Handle bands better when grade is 50.
// [50, [10, 5]],
[100, [20, 5]],
[200, [20, 10]],
];
}
/**
* Test bands.
*
* @dataProvider get_bands_count_and_width_provider
* @param int $grade grade
* @param array $expected
*/
public function test_get_bands_count_and_width(int $grade, array $expected): void {
$this->resetAfterTest();
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$quiz = $quizgenerator->create_instance(['course' => SITEID, 'grade' => $grade]);
$this->assertEquals($expected, quiz_overview_report::get_bands_count_and_width($quiz));
}
/**
* Test delete_selected_attempts function.
*/
public function test_delete_selected_attempts(): void {
$this->resetAfterTest();
$timestamp = 1234567890;
$timestart = $timestamp + 3600;
// Create a course and a quiz.
$generator = $this->getDataGenerator();
$course = $generator->create_course();
$quizgenerator = $generator->get_plugin_generator('mod_quiz');
$quiz = $quizgenerator->create_instance([
'course' => $course->id,
'grademethod' => QUIZ_GRADEHIGHEST,
'grade' => 100.0,
'sumgrades' => 10.0,
'attempts' => 10
]);
// Add one question.
/** @var core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$q = $questiongenerator->create_question('essay', 'plain', ['category' => $cat->id]);
quiz_add_quiz_question($q->id, $quiz, 0 , 10);
// Create student and enrol them in the course.
// Note: we create two enrolments, to test the problem reported in MDL-67942.
$student = $generator->create_user();
$generator->enrol_user($student->id, $course->id);
$generator->enrol_user($student->id, $course->id, null, 'self');
$context = \context_module::instance($quiz->cmid);
$cm = get_coursemodule_from_id('quiz', $quiz->cmid);
$allowedjoins = get_enrolled_with_capabilities_join($context, '', ['mod/quiz:attempt', 'mod/quiz:reviewmyattempts']);
$quizattemptsreport = new \testable_quiz_attempts_report();
// Create the new attempt and initialize the question sessions.
$quizobj = quiz_settings::create($quiz->id, $student->id);
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
$attempt = quiz_create_attempt($quizobj, 1, null, $timestart, false, $student->id);
$attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timestamp);
$attempt = quiz_attempt_save_started($quizobj, $quba, $attempt);
// Delete the student's attempt.
$quizattemptsreport->delete_selected_attempts($quiz, $cm, [$attempt->id], $allowedjoins);
}
/**
* Test question regrade for selected versions.
*
* @covers ::regrade_question
*/
public function test_regrade_question(): void {
global $DB;
$this->resetAfterTest();
$this->setAdminUser();
$course = $this->getDataGenerator()->create_course();
$quiz = $this->create_test_quiz($course);
$cm = get_fast_modinfo($course->id)->get_cm($quiz->cmid);
$context = \context_module::instance($quiz->cmid);
/** @var core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create a couple of questions.
$cat = $questiongenerator->create_question_category(['contextid' => $context->id]);
$q = $questiongenerator->create_question('shortanswer', null,
['category' => $cat->id, 'name' => 'Toad scores 0.8']);
// Create a version, the last one draft.
// Sadly, update_question is a bit dodgy, so it can't handle updating the answer score.
$q2 = $questiongenerator->update_question($q, null,
['name' => 'Toad now scores 1.0']);
$toadanswer = $DB->get_record_select('question_answers',
'question = ? AND ' . $DB->sql_compare_text('answer') . ' = ?',
[$q2->id, 'toad'], '*', MUST_EXIST);
$DB->set_field('question_answers', 'fraction', 1, ['id' => $toadanswer->id]);
// Add the question to the quiz.
quiz_add_quiz_question($q2->id, $quiz, 0, 10);
// Attempt the quiz, submitting response 'toad'.
$quizobj = quiz_settings::create($quiz->id);
$attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null);
$attemptobj = quiz_attempt::create($attempt->id);
$attemptobj->process_submitted_actions(time(), false, [1 => ['answer' => 'toad']]);
$attemptobj->process_finish(time(), false);
// We should be using 'always latest' version, which is currently v2, so should be right.
$this->assertEquals(10, $attemptobj->get_question_usage()->get_total_mark());
// Now change the quiz to use fixed version 1.
$slot = $quizobj->get_question($q2->id);
submit_question_version::execute($slot->slotid, 1);
// Regrade.
$report = new quiz_overview_report();
$report->init('overview', 'quiz_overview_settings_form', $quiz, $cm, $course);
$report->regrade_attempt($attempt);
// The mark should now be 8.
$attemptobj = quiz_attempt::create($attempt->id);
$this->assertEquals(8, $attemptobj->get_question_usage()->get_total_mark());
// Now add two more versions, the second of which is draft.
$q3 = $questiongenerator->update_question($q, null,
['name' => 'Toad now scores 0.5']);
$toadanswer = $DB->get_record_select('question_answers',
'question = ? AND ' . $DB->sql_compare_text('answer') . ' = ?',
[$q3->id, 'toad'], '*', MUST_EXIST);
$DB->set_field('question_answers', 'fraction', 0.5, ['id' => $toadanswer->id]);
$q4 = $questiongenerator->update_question($q, null,
['name' => 'Toad now scores 0.3',
'status' => question_version_status::QUESTION_STATUS_DRAFT]);
$toadanswer = $DB->get_record_select('question_answers',
'question = ? AND ' . $DB->sql_compare_text('answer') . ' = ?',
[$q4->id, 'toad'], '*', MUST_EXIST);
$DB->set_field('question_answers', 'fraction', 0.3, ['id' => $toadanswer->id]);
// Now change the quiz back to always latest and regrade again.
submit_question_version::execute($slot->slotid, 0);
$report->regrade_attempt($attempt);
// Score should now be 5, because v3 is the latest non-draft version.
$attemptobj = quiz_attempt::create($attempt->id);
$this->assertEquals(5, $attemptobj->get_question_usage()->get_total_mark());
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Quiz overview report version information.
*
* @package quiz_overview
* @copyright 2008 Jamie Pratt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2024042200;
$plugin->requires = 2024041600;
$plugin->component = 'quiz_overview';